cmd_log and cmd_show: go through the resolve_rev interface
[yap.git] / yap / yap.py
blob7a340a4d04eecfef3698e1b59b4916d578b4e60b
1 import sys
2 import os
3 import glob
4 import getopt
5 import pickle
6 import tempfile
8 from util import *
10 class ShellError(Exception):
11 def __init__(self, cmd, rc):
12 self.cmd = cmd
13 self.rc = rc
15 def __str__(self):
16 return "%s returned %d" % (self.cmd, self.rc)
18 class YapError(Exception):
19 def __init__(self, msg):
20 self.msg = msg
22 def __str__(self):
23 return self.msg
25 class YapCore(object):
26 def _add_new_file(self, file):
27 repo = get_output('git rev-parse --git-dir')[0]
28 dir = os.path.join(repo, 'yap')
29 try:
30 os.mkdir(dir)
31 except OSError:
32 pass
33 files = self._get_new_files()
34 files.append(file)
35 path = os.path.join(dir, 'new-files')
36 pickle.dump(files, open(path, 'w'))
38 def _get_new_files(self):
39 repo = get_output('git rev-parse --git-dir')[0]
40 path = os.path.join(repo, 'yap', 'new-files')
41 try:
42 files = pickle.load(file(path))
43 except IOError:
44 files = []
46 x = []
47 for f in files:
48 # if f in the index
49 if get_output("git ls-files --cached '%s'" % f) != []:
50 continue
51 x.append(f)
52 return x
54 def _remove_new_file(self, file):
55 files = self._get_new_files()
56 files = filter(lambda x: x != file, files)
58 repo = get_output('git rev-parse --git-dir')[0]
59 path = os.path.join(repo, 'yap', 'new-files')
60 try:
61 pickle.dump(files, open(path, 'w'))
62 except IOError:
63 pass
65 def _clear_new_files(self):
66 repo = get_output('git rev-parse --git-dir')[0]
67 path = os.path.join(repo, 'yap', 'new-files')
68 os.unlink(path)
70 def _assert_file_exists(self, file):
71 if not os.access(file, os.R_OK):
72 raise YapError("No such file: %s" % file)
74 def _get_staged_files(self):
75 if run_command("git rev-parse HEAD"):
76 files = get_output("git ls-files --cached")
77 else:
78 files = get_output("git diff-index --cached --name-only HEAD")
79 unmerged = self._get_unmerged_files()
80 if unmerged:
81 unmerged = set(unmerged)
82 files = set(files).difference(unmerged)
83 files = list(files)
84 return files
86 def _get_unstaged_files(self):
87 files = get_output("git ls-files -m")
88 prefix = get_output("git rev-parse --show-prefix")
89 if prefix:
90 files = [ os.path.join(prefix[0], x) for x in files ]
91 files += self._get_new_files()
92 unmerged = self._get_unmerged_files()
93 if unmerged:
94 unmerged = set(unmerged)
95 files = set(files).difference(unmerged)
96 files = list(files)
97 return files
99 def _get_unmerged_files(self):
100 files = get_output("git ls-files -u")
101 files = [ x.replace('\t', ' ').split(' ')[3] for x in files ]
102 prefix = get_output("git rev-parse --show-prefix")
103 if prefix:
104 files = [ os.path.join(prefix[0], x) for x in files ]
105 return list(set(files))
107 def _resolve_rev(self, rev):
108 ref = get_output("git rev-parse --verify %s 2>/dev/null" % rev)
109 if not ref:
110 raise YapError("No such revision: %s" % rev)
111 return ref[0]
113 def _delete_branch(self, branch, force):
114 current = get_output("git symbolic-ref HEAD")
115 if current:
116 current = current[0].replace('refs/heads/', '')
117 if branch == current:
118 raise YapError("Can't delete current branch")
120 ref = self._resolve_rev('refs/heads/'+branch)
121 run_safely("git update-ref -d 'refs/heads/%s' '%s'" % (branch, ref))
123 if not force:
124 name = get_output("git name-rev --name-only '%s'" % ref)[0]
125 if name == 'undefined':
126 run_command("git update-ref 'refs/heads/%s' '%s'" % (branch, ref))
127 raise YapError("Refusing to delete leaf branch (use -f to force)")
128 def _get_pager_cmd(self):
129 if 'YAP_PAGER' in os.environ:
130 return os.environ['YAP_PAGER']
131 elif 'GIT_PAGER' in os.environ:
132 return os.environ['GIT_PAGER']
133 elif 'PAGER' in os.environ:
134 return os.environ['PAGER']
135 else:
136 return "more"
138 def _add_one(self, file):
139 self._assert_file_exists(file)
140 x = get_output("git ls-files '%s'" % file)
141 if x != []:
142 raise YapError("File '%s' already in repository" % file)
143 self._add_new_file(file)
145 def _rm_one(self, file):
146 self._assert_file_exists(file)
147 if get_output("git ls-files '%s'" % file) != []:
148 run_safely("git rm --cached '%s'" % file)
149 self._remove_new_file(file)
151 def _stage_one(self, file, allow_unmerged=False):
152 self._assert_file_exists(file)
153 prefix = get_output("git rev-parse --show-prefix")
154 if prefix:
155 tmp = os.path.normpath(os.path.join(prefix[0], file))
156 else:
157 tmp = file
158 if not allow_unmerged and tmp in self._get_unmerged_files():
159 raise YapError("Refusing to stage conflicted file: %s" % file)
160 run_safely("git update-index --add '%s'" % file)
162 def _unstage_one(self, file):
163 self._assert_file_exists(file)
164 if run_command("git rev-parse HEAD"):
165 rc = run_command("git update-index --force-remove '%s'" % file)
166 else:
167 rc = run_command("git diff-index --cached -p HEAD '%s' | git apply -R --cached" % file)
168 if rc:
169 raise YapError("Failed to unstage")
171 def _revert_one(self, file):
172 self._assert_file_exists(file)
173 try:
174 self._unstage_one(file)
175 except YapError:
176 pass
177 run_safely("git checkout-index -u -f '%s'" % file)
179 def _parse_commit(self, commit):
180 lines = get_output("git cat-file commit '%s'" % commit)
181 commit = {}
183 mode = None
184 for l in lines:
185 if mode != 'commit' and l.strip() == "":
186 mode = 'commit'
187 commit['log'] = []
188 continue
189 if mode == 'commit':
190 commit['log'].append(l)
191 continue
193 x = l.split(' ')
194 k = x[0]
195 v = ' '.join(x[1:])
196 commit[k] = v
197 commit['log'] = '\n'.join(commit['log'])
198 return commit
200 def _check_commit(self, **flags):
201 if '-a' in flags and '-d' in flags:
202 raise YapError("Conflicting flags: -a and -d")
204 if '-d' not in flags and self._get_unstaged_files():
205 if '-a' not in flags and self._get_staged_files():
206 raise YapError("Staged and unstaged changes present. Specify what to commit")
207 os.system("git diff-files -p | git apply --cached")
208 for f in self._get_new_files():
209 self._stage_one(f)
211 def _do_uncommit(self):
212 commit = self._parse_commit("HEAD")
213 repo = get_output('git rev-parse --git-dir')[0]
214 dir = os.path.join(repo, 'yap')
215 try:
216 os.mkdir(dir)
217 except OSError:
218 pass
219 msg_file = os.path.join(dir, 'msg')
220 fd = file(msg_file, 'w')
221 print >>fd, commit['log']
222 fd.close()
224 tree = get_output("git rev-parse --verify HEAD^")
225 run_safely("git update-ref -m uncommit HEAD '%s'" % tree[0])
227 def _do_commit(self, msg=None):
228 tree = get_output("git write-tree")[0]
230 repo = get_output('git rev-parse --git-dir')[0]
231 head_file = os.path.join(repo, 'yap', 'merge')
232 try:
233 parent = pickle.load(file(head_file))
234 except IOError:
235 parent = get_output("git rev-parse --verify HEAD 2> /dev/null")
237 if os.environ.has_key('YAP_EDITOR'):
238 editor = os.environ['YAP_EDITOR']
239 elif os.environ.has_key('GIT_EDITOR'):
240 editor = os.environ['GIT_EDITOR']
241 elif os.environ.has_key('EDITOR'):
242 editor = os.environ['EDITOR']
243 else:
244 editor = "vi"
246 fd, tmpfile = tempfile.mkstemp("yap")
247 os.close(fd)
250 if msg is None:
251 msg_file = os.path.join(repo, 'yap', 'msg')
252 if os.access(msg_file, os.R_OK):
253 fd1 = file(msg_file)
254 fd2 = file(tmpfile, 'w')
255 for l in fd1.xreadlines():
256 print >>fd2, l.strip()
257 fd2.close()
258 os.unlink(msg_file)
259 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
260 raise YapError("Editing commit message failed")
261 fd = file(tmpfile)
262 msg = fd.readlines()
263 msg = ''.join(msg)
265 msg = msg.strip()
266 if not msg:
267 raise YapError("Refusing to use empty commit message")
269 (fd_w, fd_r) = os.popen2("git stripspace > %s" % tmpfile)
270 print >>fd_w, msg,
271 fd_w.close()
272 fd_r.close()
274 if parent:
275 parent = ' -p '.join(parent)
276 commit = get_output("git commit-tree '%s' -p %s < '%s'" % (tree, parent, tmpfile))
277 else:
278 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
280 os.unlink(tmpfile)
281 run_safely("git update-ref HEAD '%s'" % commit[0])
282 self._clear_state()
284 def _check_rebasing(self):
285 repo = get_output('git rev-parse --git-dir')[0]
286 dotest = os.path.join(repo, '.dotest')
287 if os.access(dotest, os.R_OK):
288 raise YapError("A git operation is in progress. Complete it first")
289 dotest = os.path.join(repo, '..', '.dotest')
290 if os.access(dotest, os.R_OK):
291 raise YapError("A git operation is in progress. Complete it first")
293 def _check_git(self):
294 if run_command("git rev-parse --git-dir"):
295 raise YapError("That command must be run from inside a git repository")
297 def _list_remotes(self):
298 remotes = get_output("git config --get-regexp '^remote.*.url'")
299 for x in remotes:
300 remote, url = x.split(' ')
301 remote = remote.replace('remote.', '')
302 remote = remote.replace('.url', '')
303 yield remote, url
305 def _unstage_all(self):
306 try:
307 run_safely("git read-tree -m HEAD")
308 except ShellError:
309 run_safely("git read-tree HEAD")
310 run_safely("git update-index -q --refresh")
312 def _get_tracking(self, current):
313 remote = get_output("git config branch.%s.remote" % current)
314 if not remote:
315 raise YapError("No tracking branch configured for '%s'" % current)
317 merge = get_output("git config branch.%s.merge" % current)
318 if not merge:
319 raise YapError("No tracking branch configured for '%s'" % current)
320 return remote[0], merge[0]
322 def _confirm_push(self, current, rhs, repo):
323 print "About to push local branch '%s' to '%s' on '%s'" % (current, rhs, repo)
324 print "Continue (y/n)? ",
325 sys.stdout.flush()
326 ans = sys.stdin.readline().strip()
328 if ans.lower() != 'y' and ans.lower() != 'yes':
329 raise YapError("Aborted.")
331 def _clear_state(self):
332 repo = get_output('git rev-parse --git-dir')[0]
333 dir = os.path.join(repo, 'yap')
334 for f in "new-files", "merge", "msg":
335 try:
336 os.unlink(os.path.join(dir, f))
337 except OSError:
338 pass
340 def _get_attr(self, name, attr):
341 val = None
342 for c in self.__class__.__bases__:
343 try:
344 m2 = c.__dict__[name]
345 except KeyError:
346 continue
347 try:
348 val = m2.__getattribute__(attr)
349 except AttributeError:
350 continue
351 return val
353 @short_help("make a local copy of an existing repository")
354 @long_help("""
355 The first argument is a URL to the existing repository. This can be an
356 absolute path if the repository is local, or a URL with the git://,
357 ssh://, or http:// schemes. By default, the directory used is the last
358 component of the URL, sans '.git'. This can be overridden by providing
359 a second argument.
360 """)
361 def cmd_clone(self, url, directory=None):
362 "<url> [directory]"
364 if '://' not in url and url[0] != '/':
365 url = os.path.join(os.getcwd(), url)
367 url = url.rstrip('/')
368 if directory is None:
369 directory = url.rsplit('/')[-1]
370 directory = directory.replace('.git', '')
372 try:
373 os.mkdir(directory)
374 except OSError:
375 raise YapError("Directory exists: %s" % directory)
376 os.chdir(directory)
377 self.cmd_init()
378 self.cmd_repo("origin", url)
379 self.cmd_fetch("origin")
381 branch = None
382 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
383 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
384 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
385 if get_output("git rev-parse %s" % b)[0] == hash:
386 branch = b
387 break
388 if branch is None:
389 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
390 branch = "refs/remotes/origin/master"
391 if branch is None:
392 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'")
393 branch = branch[0]
395 hash = get_output("git rev-parse %s" % branch)
396 assert hash
397 branch = branch.replace('refs/remotes/origin/', '')
398 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
399 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
400 self.cmd_revert(**{'-a': 1})
402 @short_help("turn a directory into a repository")
403 @long_help("""
404 Converts the current working directory into a repository. The primary
405 side-effect of this command is the creation of a '.git' subdirectory.
406 No files are added nor commits made.
407 """)
408 def cmd_init(self):
409 os.system("git init")
411 @short_help("add a new file to the repository")
412 @long_help("""
413 The arguments are the files to be added to the repository. Once added,
414 the files will show as "unstaged changes" in the output of 'status'. To
415 reverse the effects of this command, see 'rm'.
416 """)
417 def cmd_add(self, *files):
418 "<file>..."
419 self._check_git()
421 if not files:
422 raise TypeError
424 for f in files:
425 self._add_one(f)
426 self.cmd_status()
428 @short_help("delete a file from the repository")
429 @long_help("""
430 The arguments are the files to be removed from the current revision of
431 the repository. The files will still exist in any past commits that the
432 files may have been a part of. The file is not actually deleted, it is
433 just no longer tracked as part of the repository.
434 """)
435 def cmd_rm(self, *files):
436 "<file>..."
437 self._check_git()
438 if not files:
439 raise TypeError
441 for f in files:
442 self._rm_one(f)
443 self.cmd_status()
445 @short_help("stage changes in a file for commit")
446 @long_help("""
447 The arguments are the files to be staged. Staging changes is a way to
448 build up a commit when you do not want to commit all changes at once.
449 To commit only staged changes, use the '-d' flag to 'commit.' To
450 reverse the effects of this command, see 'unstage'. Once staged, the
451 files will show as "staged changes" in the output of 'status'.
452 """)
453 def cmd_stage(self, *files):
454 "<file>..."
455 self._check_git()
456 if not files:
457 raise TypeError
459 for f in files:
460 self._stage_one(f)
461 self.cmd_status()
463 @short_help("unstage changes in a file")
464 @long_help("""
465 The arguments are the files to be unstaged. Once unstaged, the files
466 will show as "unstaged changes" in the output of 'status'. The '-a'
467 flag can be used to unstage all staged changes at once.
468 """)
469 @takes_options("a")
470 def cmd_unstage(self, *files, **flags):
471 "[-a] | <file>..."
472 self._check_git()
473 if '-a' in flags:
474 files = self._get_staged_files()
476 if not files:
477 raise TypeError
479 for f in files:
480 self._unstage_one(f)
481 self.cmd_status()
483 @short_help("show files with staged and unstaged changes")
484 @long_help("""
485 Show the files in the repository with changes since the last commit,
486 categorized based on whether the changes are staged or not. A file may
487 appear under each heading if the same file has both staged and unstaged
488 changes.
489 """)
490 def cmd_status(self):
492 self._check_git()
493 branch = get_output("git symbolic-ref HEAD")
494 if branch:
495 branch = branch[0].replace('refs/heads/', '')
496 else:
497 branch = "DETACHED"
498 print "Current branch: %s" % branch
500 print "Files with staged changes:"
501 files = self._get_staged_files()
502 for f in files:
503 print "\t%s" % f
504 if not files:
505 print "\t(none)"
507 print "Files with unstaged changes:"
508 files = self._get_unstaged_files()
509 for f in files:
510 print "\t%s" % f
511 if not files:
512 print "\t(none)"
514 files = self._get_unmerged_files()
515 if files:
516 print "Files with conflicts:"
517 for f in files:
518 print "\t%s" % f
520 @short_help("remove uncommitted changes from a file (*)")
521 @long_help("""
522 The arguments are the files whose changes will be reverted. If the '-a'
523 flag is given, then all files will have uncommitted changes removed.
524 Note that there is no way to reverse this command short of manually
525 editing each file again.
526 """)
527 @takes_options("a")
528 def cmd_revert(self, *files, **flags):
529 "(-a | <file>)"
530 self._check_git()
531 if '-a' in flags:
532 self._unstage_all()
533 run_safely("git checkout-index -u -f -a")
534 self._clear_state()
535 self.cmd_status()
536 return
538 if not files:
539 raise TypeError
541 for f in files:
542 self._revert_one(f)
543 self.cmd_status()
545 @short_help("record changes to files as a new commit")
546 @long_help("""
547 Create a new commit recording changes since the last commit. If there
548 are only unstaged changes, those will be recorded. If there are only
549 staged changes, those will be recorded. Otherwise, you will have to
550 specify either the '-a' flag or the '-d' flag to commit all changes or
551 only staged changes, respectively. To reverse the effects of this
552 command, see 'uncommit'.
553 """)
554 @takes_options("adm:")
555 def cmd_commit(self, **flags):
556 "[-a | -d] [-m <msg>]"
557 self._check_git()
558 self._check_rebasing()
559 self._check_commit(**flags)
560 if not self._get_staged_files():
561 raise YapError("No changes to commit")
562 msg = flags.get('-m', None)
563 self._do_commit(msg)
564 self.cmd_status()
566 @short_help("reverse the actions of the last commit")
567 @long_help("""
568 Reverse the effects of the last 'commit' operation. The changes that
569 were part of the previous commit will show as "staged changes" in the
570 output of 'status'. This means that if no files were changed since the
571 last commit was created, 'uncommit' followed by 'commit' is a lossless
572 operation.
573 """)
574 def cmd_uncommit(self):
576 self._check_git()
577 self._do_uncommit()
578 self.cmd_status()
580 @short_help("report the current version of yap")
581 def cmd_version(self):
582 print "Yap version 0.1"
584 @short_help("show the changelog for particular versions or files")
585 @long_help("""
586 The arguments are the files with which to filter history. If none are
587 given, all changes are listed. Otherwise only commits that affected one
588 or more of the given files are listed. The -r option changes the
589 starting revision for traversing history. By default, history is listed
590 starting at HEAD.
591 """)
592 @takes_options("pr:")
593 def cmd_log(self, *paths, **flags):
594 "[-p] [-r <rev>] <path>..."
595 self._check_git()
596 rev = flags.get('-r', 'HEAD')
597 rev = self._resolve_rev(rev)
599 if '-p' in flags:
600 flags['-p'] = '-p'
602 if len(paths) == 1:
603 follow = "--follow"
604 else:
605 follow = ""
606 paths = ' '.join(paths)
607 os.system("git log -M -C %s %s '%s' -- %s"
608 % (follow, flags.get('-p', '--name-status'), rev, paths))
610 @short_help("show staged, unstaged, or all uncommitted changes")
611 @long_help("""
612 Show staged, unstaged, or all uncommitted changes. By default, all
613 changes are shown. The '-u' flag causes only unstaged changes to be
614 shown. The '-d' flag causes only staged changes to be shown.
615 """)
616 @takes_options("ud")
617 def cmd_diff(self, **flags):
618 "[ -u | -d ]"
619 self._check_git()
620 if '-u' in flags and '-d' in flags:
621 raise YapError("Conflicting flags: -u and -d")
623 pager = self._get_pager_cmd()
625 if '-u' in flags:
626 os.system("git diff-files -p | %s" % pager)
627 elif '-d' in flags:
628 os.system("git diff-index --cached -p HEAD | %s" % pager)
629 else:
630 os.system("git diff-index -p HEAD | %s" % pager)
632 @short_help("list, create, or delete branches")
633 @long_help("""
634 If no arguments are specified, a list of local branches is given. The
635 current branch is indicated by a "*" next to the name. If an argument
636 is given, it is taken as the name of a new branch to create. The branch
637 will start pointing at the current HEAD. See 'point' for details on
638 changing the revision of the new branch. Note that this command does
639 not switch the current working branch. See 'switch' for details on
640 changing the current working branch.
642 The '-d' flag can be used to delete local branches. If the delete
643 operation would remove the last branch reference to a given line of
644 history (colloquially referred to as "dangling commits"), yap will
645 report an error and abort. The '-f' flag can be used to force the delete
646 in spite of this.
647 """)
648 @takes_options("fd:")
649 def cmd_branch(self, branch=None, **flags):
650 "[ [-f] -d <branch> | <branch> ]"
651 self._check_git()
652 force = '-f' in flags
653 if '-d' in flags:
654 self._delete_branch(flags['-d'], force)
655 self.cmd_branch()
656 return
658 if branch is not None:
659 ref = get_output("git rev-parse --verify HEAD")
660 if not ref:
661 raise YapError("No branch point yet. Make a commit")
662 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
664 current = get_output("git symbolic-ref HEAD")
665 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
666 for b in branches:
667 if current and b == current[0]:
668 print "* ",
669 else:
670 print " ",
671 b = b.replace('refs/heads/', '')
672 print b
674 @short_help("change the current working branch")
675 @long_help("""
676 The argument is the name of the branch to make the current working
677 branch. This command will fail if there are uncommitted changes to any
678 files. Otherwise, the contents of the files in the working directory
679 are updated to reflect their state in the new branch. Additionally, any
680 future commits are added to the new branch instead of the previous line
681 of history.
682 """)
683 @takes_options("f")
684 def cmd_switch(self, branch, **flags):
685 "[-f] <branch>"
686 self._check_git()
687 self._check_rebasing()
688 ref = self._resolve_rev('refs/heads/'+branch)
690 if '-f' not in flags:
691 if (self._get_staged_files()
692 or (self._get_unstaged_files()
693 and run_command("git update-index --refresh"))):
694 raise YapError("You have uncommitted changes. Use -f to continue anyway")
696 if self._get_unstaged_files() and self._get_staged_files():
697 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
699 staged = bool(self._get_staged_files())
701 run_command("git diff-files -p | git apply --cached")
702 for f in self._get_new_files():
703 self._stage_one(f)
705 idx = get_output("git write-tree")
706 new = self._resolve_rev('refs/heads/'+branch)
707 readtree = "git read-tree --aggressive -u -m HEAD %s %s" % (idx[0], new)
708 if run_command(readtree):
709 run_command("git update-index --refresh")
710 if os.system(readtree):
711 raise YapError("Failed to switch")
712 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
714 if '-f' not in flags:
715 self._clear_state()
717 if not staged:
718 self._unstage_all()
719 self.cmd_status()
721 @short_help("move the current branch to a different revision")
722 @long_help("""
723 The argument is the hash of the commit to which the current branch
724 should point, or alternately a branch or tag (a.k.a, "committish"). If
725 moving the branch would create "dangling commits" (see 'branch'), yap
726 will report an error and abort. The '-f' flag can be used to force the
727 operation in spite of this.
728 """)
729 @takes_options("f")
730 def cmd_point(self, where, **flags):
731 "[-f] <where>"
732 self._check_git()
733 self._check_rebasing()
735 head = get_output("git rev-parse --verify HEAD")
736 if not head:
737 raise YapError("No commit yet; nowhere to point")
739 ref = self._resolve_rev(where)
740 ref = get_output("git rev-parse --verify '%s^{commit}'" % ref)
741 if not ref:
742 raise YapError("Not a commit: %s" % where)
744 if self._get_unstaged_files() or self._get_staged_files():
745 raise YapError("You have uncommitted changes. Commit them first")
747 run_safely("git update-ref HEAD '%s'" % ref[0])
749 if '-f' not in flags:
750 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
751 if name == "undefined":
752 os.system("git update-ref HEAD '%s'" % head[0])
753 raise YapError("Pointing there will lose commits. Use -f to force")
755 try:
756 run_safely("git read-tree -u -m HEAD")
757 except ShellError:
758 run_safely("git read-tree HEAD")
759 run_safely("git checkout-index -u -f -a")
760 self._clear_state()
762 @short_help("alter history by dropping or amending commits")
763 @long_help("""
764 This command operates in two distinct modes, "amend" and "drop" mode.
765 In drop mode, the given commit is removed from the history of the
766 current branch, as though that commit never happened. By default the
767 commit used is HEAD.
769 In amend mode, the uncommitted changes present are merged into a
770 previous commit. This is useful for correcting typos or adding missed
771 files into past commits. By default the commit used is HEAD.
773 While rewriting history it is possible that conflicts will arise. If
774 this happens, the rewrite will pause and you will be prompted to resolve
775 the conflicts and stage them. Once that is done, you will run "yap
776 history continue." If instead you want the conflicting commit removed
777 from history (perhaps your changes supercede that commit) you can run
778 "yap history skip". Once the rewrite completes, your branch will be on
779 the same commit as when the rewrite started.
780 """)
781 def cmd_history(self, subcmd, *args):
782 "amend | drop <commit>"
783 self._check_git()
785 if subcmd not in ("amend", "drop", "continue", "skip"):
786 raise TypeError
788 resolvemsg = """
789 When you have resolved the conflicts run \"yap history continue\".
790 To skip the problematic patch, run \"yap history skip\"."""
792 if subcmd == "continue":
793 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
794 return
795 if subcmd == "skip":
796 os.system("git reset --hard")
797 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
798 return
800 if subcmd == "amend":
801 flags, args = getopt.getopt(args, "ad")
802 flags = dict(flags)
804 if len(args) > 1:
805 raise TypeError
806 if args:
807 commit = args[0]
808 else:
809 commit = "HEAD"
811 self._resolve_rev(commit)
812 self._check_rebasing()
814 if subcmd == "amend":
815 self._check_commit(**flags)
816 if self._get_unstaged_files():
817 # XXX: handle unstaged changes better
818 raise YapError("Commit away changes that you aren't amending")
820 self._unstage_all()
822 start = get_output("git rev-parse HEAD")
823 stash = get_output("git stash create")
824 run_command("git reset --hard")
825 try:
826 fd, tmpfile = tempfile.mkstemp("yap")
827 try:
828 try:
829 os.close(fd)
830 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
831 if subcmd == "amend":
832 self.cmd_point(commit, **{'-f': True})
833 finally:
834 if subcmd == "amend":
835 if stash:
836 rc = os.system("git stash apply %s" % stash[0])
837 if rc:
838 self.cmd_point(start[0], **{'-f': True})
839 os.system("git stash apply %s" % stash[0])
840 raise YapError("Failed to apply stash")
841 stash = None
843 if subcmd == "amend":
844 self._do_uncommit()
845 self._check_commit(**{'-a': True})
846 self._do_commit()
847 else:
848 self.cmd_point("%s^" % commit, **{'-f': True})
850 stat = os.stat(tmpfile)
851 size = stat[6]
852 if size > 0:
853 run_safely("git update-index --refresh")
854 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
855 if (rc):
856 raise YapError("Failed to apply changes")
857 finally:
858 os.unlink(tmpfile)
859 finally:
860 if stash:
861 run_command("git stash apply %s" % stash[0])
862 self.cmd_status()
864 @short_help("show the changes introduced by a given commit")
865 @long_help("""
866 By default, the changes in the last commit are shown. To override this,
867 specify a hash, branch, or tag (committish). The hash of the commit,
868 the commit's author, log message, and a diff of the changes are shown.
869 """)
870 def cmd_show(self, commit="HEAD"):
871 "[commit]"
872 self._check_git()
873 commit = self._resolve_rev(commit)
874 os.system("git show '%s'" % commit)
876 @short_help("apply the changes in a given commit to the current branch")
877 @long_help("""
878 The argument is the hash, branch, or tag (committish) of the commit to
879 be applied. In general, it only makes sense to apply commits that
880 happened on another branch. The '-r' flag can be used to have the
881 changes in the given commit reversed from the current branch. In
882 general, this only makes sense for commits that happened on the current
883 branch.
884 """)
885 @takes_options("r")
886 def cmd_cherry_pick(self, commit, **flags):
887 "[-r] <commit>"
888 self._check_git()
889 if '-r' in flags:
890 os.system("git revert '%s'" % commit)
891 else:
892 os.system("git cherry-pick '%s'" % commit)
894 @short_help("list, add, or delete configured remote repositories")
895 @long_help("""
896 When invoked with no arguments, this command will show the list of
897 currently configured remote repositories, giving both the name and URL
898 of each. To add a new repository, give the desired name as the first
899 argument and the URL as the second. The '-d' flag can be used to remove
900 a previously added repository.
901 """)
902 @takes_options("d:")
903 def cmd_repo(self, name=None, url=None, **flags):
904 "[<name> <url> | -d <name>]"
905 self._check_git()
906 if name is not None and url is None:
907 raise TypeError
909 if '-d' in flags:
910 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
911 raise YapError("No such repository: %s" % flags['-d'])
912 os.system("git config --unset remote.%s.url" % flags['-d'])
913 os.system("git config --unset remote.%s.fetch" % flags['-d'])
914 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
915 hash = get_output("git rev-parse %s" % b)
916 assert hash
917 run_safely("git update-ref -d %s %s" % (b, hash[0]))
919 if name:
920 if name in [ x[0] for x in self._list_remotes() ]:
921 raise YapError("Repository '%s' already exists" % name)
922 os.system("git config remote.%s.url %s" % (name, url))
923 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
925 for remote, url in self._list_remotes():
926 print "%-20s %s" % (remote, url)
928 @short_help("send local commits to a remote repository (*)")
929 @long_help("""
930 When invoked with no arguments, the current branch is synchronized to
931 the tracking branch of the tracking remote. If no tracking remote is
932 specified, the repository will have to be specified on the command line.
933 In that case, the default is to push to a branch with the same name as
934 the current branch. This behavior can be overridden by giving a second
935 argument to specify the remote branch.
937 If the remote branch does not currently exist, the command will abort
938 unless the -c flag is provided. If the remote branch is not a direct
939 descendent of the local branch, the command will abort unless the -f
940 flag is provided. Forcing a push in this way can be problematic to
941 other users of the repository if they are not expecting it.
943 To delete a branch on the remote repository, use the -d flag.
944 """)
945 @takes_options("cdf")
946 def cmd_push(self, repo=None, rhs=None, **flags):
947 "[-c | -d] <repo>"
948 self._check_git()
949 if '-c' in flags and '-d' in flags:
950 raise TypeError
952 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
953 raise YapError("No such repository: %s" % repo)
955 current = get_output("git symbolic-ref HEAD")
956 if not current:
957 raise YapError("Not on a branch!")
959 self._check_rebasing()
961 current = current[0].replace('refs/heads/', '')
962 remote = get_output("git config branch.%s.remote" % current)
963 if repo is None and remote:
964 repo = remote[0]
966 if repo is None:
967 raise YapError("No tracking branch configured; specify destination repository")
969 if rhs is None and remote and remote[0] == repo:
970 merge = get_output("git config branch.%s.merge" % current)
971 if merge:
972 rhs = merge[0]
974 if rhs is None:
975 rhs = "refs/heads/%s" % current
977 if '-c' not in flags and '-d' not in flags:
978 if run_command("git rev-parse --verify refs/remotes/%s/%s"
979 % (repo, rhs.replace('refs/heads/', ''))):
980 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
981 if '-f' not in flags:
982 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo, rhs.replace('refs/heads/', '')))
983 base = get_output("git merge-base HEAD %s" % hash[0])
984 assert base
985 if base[0] != hash[0]:
986 raise YapError("Branch not up-to-date with remote. Update or use -f")
988 self._confirm_push(current, rhs, repo)
989 if '-f' in flags:
990 flags['-f'] = '-f'
992 if '-d' in flags:
993 lhs = ""
994 else:
995 lhs = "refs/heads/%s" % current
996 rc = os.system("git push %s %s %s:%s" % (flags.get('-f', ''), repo, lhs, rhs))
997 if rc:
998 raise YapError("Push failed.")
1000 @short_help("retrieve commits from a remote repository")
1001 @long_help("""
1002 When run with no arguments, the command will retrieve new commits from
1003 the remote tracking repository. Note that this does not in any way
1004 alter the current branch. For that, see "update". If a remote other
1005 than the tracking remote is desired, it can be specified as the first
1006 argument.
1007 """)
1008 def cmd_fetch(self, repo=None):
1009 "<repo>"
1010 self._check_git()
1011 current = get_output("git symbolic-ref HEAD")
1012 if not current:
1013 raise YapError("Not on a branch!")
1015 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
1016 raise YapError("No such repository: %s" % repo)
1017 if repo is None:
1018 current = current[0].replace('refs/heads/', '')
1019 remote = get_output("git config branch.%s.remote" % current)
1020 if remote:
1021 repo = remote[0]
1022 if repo is None:
1023 raise YapError("No tracking branch configured; specify a repository")
1024 os.system("git fetch %s" % repo)
1026 @short_help("update the current branch relative to its tracking branch")
1027 @long_help("""
1028 Updates the current branch relative to its remote tracking branch. This
1029 command requires that the current branch have a remote tracking branch
1030 configured. If any conflicts occur while applying your changes to the
1031 updated remote, the command will pause to allow you to fix them. Once
1032 that is done, run "update" with the "continue" subcommand. Alternately,
1033 the "skip" subcommand can be used to discard the conflicting changes.
1034 """)
1035 def cmd_update(self, subcmd=None):
1036 "[continue | skip]"
1037 self._check_git()
1038 if subcmd and subcmd not in ["continue", "skip"]:
1039 raise TypeError
1041 resolvemsg = """
1042 When you have resolved the conflicts run \"yap update continue\".
1043 To skip the problematic patch, run \"yap update skip\"."""
1045 if subcmd == "continue":
1046 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
1047 return
1048 if subcmd == "skip":
1049 os.system("git reset --hard")
1050 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
1051 return
1053 self._check_rebasing()
1054 if self._get_unstaged_files() or self._get_staged_files():
1055 raise YapError("You have uncommitted changes. Commit them first")
1057 current = get_output("git symbolic-ref HEAD")
1058 if not current:
1059 raise YapError("Not on a branch!")
1061 current = current[0].replace('refs/heads/', '')
1062 remote, merge = self._get_tracking(current)
1063 merge = merge.replace('refs/heads/', '')
1065 self.cmd_fetch(remote)
1066 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
1068 try:
1069 fd, tmpfile = tempfile.mkstemp("yap")
1070 os.close(fd)
1071 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
1072 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
1074 stat = os.stat(tmpfile)
1075 size = stat[6]
1076 if size > 0:
1077 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
1078 if (rc):
1079 raise YapError("Failed to apply changes")
1080 finally:
1081 os.unlink(tmpfile)
1083 @short_help("query and configure remote branch tracking")
1084 @long_help("""
1085 When invoked with no arguments, the command displays the tracking
1086 information for the current branch. To configure the tracking
1087 information, two arguments for the remote repository and remote branch
1088 are given. The tracking information is used to provide defaults for
1089 where to push local changes and from where to get updates to the branch.
1090 """)
1091 def cmd_track(self, repo=None, branch=None):
1092 "[<repo> <branch>]"
1093 self._check_git()
1095 current = get_output("git symbolic-ref HEAD")
1096 if not current:
1097 raise YapError("Not on a branch!")
1098 current = current[0].replace('refs/heads/', '')
1100 if repo is None and branch is None:
1101 repo, merge = self._get_tracking(current)
1102 merge = merge.replace('refs/heads/', '')
1103 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
1104 return
1106 if repo is None or branch is None:
1107 raise TypeError
1109 if repo not in [ x[0] for x in self._list_remotes() ]:
1110 raise YapError("No such repository: %s" % repo)
1112 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
1113 raise YapError("No such branch '%s' on repository '%s'" % (branch, repo))
1115 os.system("git config branch.%s.remote '%s'" % (current, repo))
1116 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
1117 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
1119 @short_help("mark files with conflicts as resolved")
1120 @long_help("""
1121 The arguments are the files to be marked resolved. When a conflict
1122 occurs while merging changes to a file, that file is marked as
1123 "unmerged." Until the file(s) with conflicts are marked resolved,
1124 commits cannot be made.
1125 """)
1126 def cmd_resolved(self, *files):
1127 "<file>..."
1128 self._check_git()
1129 if not files:
1130 raise TypeError
1132 for f in files:
1133 self._stage_one(f, True)
1134 self.cmd_status()
1136 @short_help("merge a branch into the current branch")
1137 def cmd_merge(self, branch):
1138 "<branch>"
1139 self._check_git()
1141 branch_name = branch
1142 branch = self._resolve_rev(branch)
1143 base = get_output("git merge-base HEAD %s" % branch)
1144 if not base:
1145 raise YapError("Branch '%s' is not a fork of the current branch"
1146 % branch)
1148 readtree = ("git read-tree --aggressive -u -m %s HEAD %s"
1149 % (base[0], branch))
1150 if run_command(readtree):
1151 run_command("git update-index --refresh")
1152 if os.system(readtree):
1153 raise YapError("Failed to merge")
1155 repo = get_output('git rev-parse --git-dir')[0]
1156 dir = os.path.join(repo, 'yap')
1157 try:
1158 os.mkdir(dir)
1159 except OSError:
1160 pass
1161 msg_file = os.path.join(dir, 'msg')
1162 msg = file(msg_file, 'w')
1163 print >>msg, "Merge branch '%s'" % branch_name
1164 msg.close()
1166 head = get_output("git rev-parse --verify HEAD")
1167 assert head
1168 heads = [head[0], branch]
1169 head_file = os.path.join(dir, 'merge')
1170 pickle.dump(heads, file(head_file, 'w'))
1172 self._merge_index(branch, base[0])
1173 if self._get_unmerged_files():
1174 self.cmd_status()
1175 raise YapError("Fix conflicts then commit")
1177 self._do_commit()
1179 def _merge_index(self, branch, base):
1180 for f in self._get_unmerged_files():
1181 fd, bfile = tempfile.mkstemp("yap")
1182 os.close(fd)
1183 rc = os.system("git show %s:%s > %s" % (base, f, bfile))
1184 assert rc == 0
1186 fd, ofile = tempfile.mkstemp("yap")
1187 os.close(fd)
1188 rc = os.system("git show %s:%s > %s" % (branch, f, ofile))
1189 assert rc == 0
1191 command = "git merge-file -L %(file)s -L %(file)s.base -L %(file)s.%(branch)s %(file)s %(base)s %(other)s " % dict(file=f, branch=branch, base=bfile, other=ofile)
1192 rc = os.system(command)
1193 os.unlink(ofile)
1194 os.unlink(bfile)
1196 assert rc >= 0
1197 if rc == 0:
1198 self._stage_one(f, True)
1200 def cmd_help(self, cmd=None):
1201 if cmd is not None:
1202 oldcmd = cmd
1203 cmd = "cmd_" + cmd.replace('-', '_')
1204 try:
1205 attr = self.__getattribute__(cmd)
1206 except AttributeError:
1207 raise YapError("No such command: %s" % cmd)
1209 try:
1210 help = self._get_attr(cmd, "long_help")
1211 except AttributeError:
1212 raise
1213 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
1215 print >>sys.stderr, "The '%s' command" % oldcmd
1216 print >>sys.stderr, "\tyap %s %s" % (oldcmd, attr.__doc__)
1217 print >>sys.stderr, "%s" % help
1218 return
1220 print >> sys.stderr, "Yet Another (Git) Porcelein"
1221 print >> sys.stderr
1223 for name in dir(self):
1224 if not name.startswith('cmd_'):
1225 continue
1226 attr = self.__getattribute__(name)
1227 if not callable(attr):
1228 continue
1230 try:
1231 short_msg = self._get_attr(name, "short_help")
1232 except AttributeError:
1233 continue
1235 name = name.replace('cmd_', '')
1236 name = name.replace('_', '-')
1237 print >> sys.stderr, "%-16s%s" % (name, short_msg)
1239 print >> sys.stderr
1240 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
1242 @short_help("show information about loaded plugins")
1243 def cmd_plugins(self):
1245 print >> sys.stderr, "Loaded plugins:"
1246 plugins = load_plugins()
1247 for name, cls in plugins.items():
1248 print "\t%-16s: %s" % (name, cls.__doc__)
1249 if not plugins:
1250 print "\t%-16s" % "None"
1252 def cmd_usage(self):
1253 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
1254 print >> sys.stderr, " valid commands: help init clone add rm stage unstage status revert commit uncommit log show diff branch switch point cherry-pick repo track push fetch update history resolved version"
1256 def load_plugins():
1257 plugindir = os.path.join("~", ".yap", "plugins")
1258 plugindir = os.path.expanduser(plugindir)
1259 plugindir = os.path.join(plugindir, "*.py")
1261 plugins = dict()
1262 for p in glob.glob(os.path.expanduser(plugindir)):
1263 plugin = os.path.basename(p).replace('.py', '')
1264 m = __import__(plugin)
1265 for k in dir(m):
1266 cls = m.__dict__[k]
1267 if not type(cls) == type:
1268 continue
1269 if not issubclass(cls, YapCore):
1270 continue
1271 if cls is YapCore:
1272 continue
1273 plugins[k] = cls
1274 return plugins
1276 def yap_metaclass(name, bases, dct):
1277 plugindir = os.path.join("~", ".yap", "plugins")
1278 plugindir = os.path.expanduser(plugindir)
1279 sys.path.insert(0, plugindir)
1281 plugins = set(load_plugins().values())
1282 p2 = plugins.copy()
1283 for cls in plugins:
1284 p2 -= set(cls.__bases__)
1285 plugins = p2
1286 bases = list(plugins) + list(bases)
1287 return type(name, tuple(bases), dct)
1289 class Yap(YapCore):
1290 __metaclass__ = yap_metaclass
1292 def main(self, args):
1293 if len(args) < 1:
1294 self.cmd_usage()
1295 sys.exit(2)
1297 command = args[0]
1298 args = args[1:]
1300 if run_command("git --version"):
1301 print >>sys.stderr, "Failed to run git; is it installed?"
1302 sys.exit(1)
1304 debug = os.getenv('YAP_DEBUG')
1306 try:
1307 command = command.replace('-', '_')
1308 meth = self.__getattribute__("cmd_"+command)
1309 doc = self._get_attr("cmd_"+command, "__doc__")
1311 try:
1312 options = ""
1313 for c in self.__class__.__bases__:
1314 try:
1315 t = c.__dict__["cmd_"+command]
1316 except KeyError:
1317 continue
1318 if "options" in t.__dict__:
1319 options += t.options
1321 if options:
1322 try:
1323 flags, args = getopt.getopt(args, options)
1324 flags = dict(flags)
1325 except getopt.GetoptError, e:
1326 if debug:
1327 raise
1328 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1329 print e
1330 sys.exit(2)
1331 else:
1332 flags = dict()
1334 meth(*args, **flags)
1335 except (TypeError, getopt.GetoptError):
1336 if debug:
1337 raise
1338 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1339 except YapError, e:
1340 if debug:
1341 raise
1342 print >> sys.stderr, e
1343 sys.exit(1)
1344 except AttributeError:
1345 if debug:
1346 raise
1347 self.cmd_usage()
1348 sys.exit(2)