10 class ShellError(Exception):
11 def __init__(self
, cmd
, rc
):
16 return "%s returned %d" % (self
.cmd
, self
.rc
)
18 class YapError(Exception):
19 def __init__(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')
33 files
= self
._get
_new
_files
()
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')
42 files
= pickle
.load(file(path
))
49 if get_output("git ls-files --cached '%s'" % f
) != []:
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')
61 pickle
.dump(files
, open(path
, 'w'))
65 def _assert_file_exists(self
, file):
66 if not os
.access(file, os
.R_OK
):
67 raise YapError("No such file: %s" % file)
69 def _repo_path_to_rel(self
, path
):
70 prefix
= get_output("git rev-parse --show-prefix")
74 prefix
= [ prefix
[0] ]
76 head
, tail
= os
.path
.split(prefix
[0])
81 prefix
.insert(1, tail
)
85 head
, tail
= os
.path
.split(path
[0])
93 for a
, b
in zip(prefix
, path
):
99 cdup
= [".."] * (len(prefix
) - common
)
100 path
= cdup
+ list(path
)
101 path
= os
.path
.join(*path
)
104 def _get_staged_files(self
):
105 if run_command("git rev-parse HEAD"):
106 files
= get_output("git ls-files --cached")
108 files
= get_output("git diff-index --cached --name-only HEAD")
109 unmerged
= self
._get
_unmerged
_files
()
111 unmerged
= set(unmerged
)
112 files
= set(files
).difference(unmerged
)
116 def _get_unstaged_files(self
):
118 cdup
= self
._get
_cdup
()
120 files
= get_output("git ls-files -m")
123 new_files
= self
._get
_new
_files
()
125 staged
= self
._get
_staged
_files
()
128 new_files
= set(new_files
).difference(staged
)
129 new_files
= list(new_files
)
131 unmerged
= self
._get
_unmerged
_files
()
133 unmerged
= set(unmerged
)
134 files
= set(files
).difference(unmerged
)
138 def _get_unmerged_files(self
):
140 cdup
= self
._get
_cdup
()
142 files
= get_output("git ls-files -u")
144 files
= [ x
.replace('\t', ' ').split(' ')[3] for x
in files
]
145 return list(set(files
))
147 def _resolve_rev(self
, rev
):
148 ref
= get_output("git rev-parse --verify %s 2>/dev/null" % rev
)
150 raise YapError("No such revision: %s" % rev
)
153 def _delete_branch(self
, branch
, force
):
154 current
= get_output("git symbolic-ref HEAD")
156 current
= current
[0].replace('refs/heads/', '')
157 if branch
== current
:
158 raise YapError("Can't delete current branch")
160 ref
= self
._resolve
_rev
('refs/heads/'+branch
)
161 run_safely("git update-ref -d 'refs/heads/%s' '%s'" % (branch
, ref
))
164 name
= get_output("git name-rev --name-only '%s'" % ref
)[0]
165 if name
== 'undefined':
166 run_command("git update-ref 'refs/heads/%s' '%s'" % (branch
, ref
))
167 raise YapError("Refusing to delete leaf branch (use -f to force)")
168 def _get_pager_cmd(self
):
169 if 'YAP_PAGER' in os
.environ
:
170 return os
.environ
['YAP_PAGER']
171 elif 'GIT_PAGER' in os
.environ
:
172 return os
.environ
['GIT_PAGER']
173 elif 'PAGER' in os
.environ
:
174 return os
.environ
['PAGER']
178 def _add_one(self
, file):
179 self
._assert
_file
_exists
(file)
180 x
= get_output("git ls-files '%s'" % file)
181 if x
!= [] or file in self
._get
_new
_files
():
182 raise YapError("File '%s' already in repository" % file)
183 self
._add
_new
_file
(file)
185 def _rm_one(self
, file):
186 self
._assert
_file
_exists
(file)
187 if get_output("git ls-files '%s'" % file) != []:
188 run_safely("git rm --cached '%s'" % file)
189 self
._remove
_new
_file
(file)
191 def _stage_one(self
, file, allow_unmerged
=False):
192 self
._assert
_file
_exists
(file)
193 prefix
= get_output("git rev-parse --show-prefix")
195 tmp
= os
.path
.normpath(os
.path
.join(prefix
[0], file))
198 if not allow_unmerged
and tmp
in self
._get
_unmerged
_files
():
199 raise YapError("Refusing to stage conflicted file: %s" % file)
200 run_safely("git update-index --add '%s'" % file)
203 cdup
= get_output("git rev-parse --show-cdup")
211 def _unstage_one(self
, file):
212 self
._assert
_file
_exists
(file)
213 if run_command("git rev-parse HEAD"):
214 rc
= run_command("git update-index --force-remove '%s'" % file)
216 cdup
= self
._get
_cdup
()
217 rc
= run_command("git diff-index --cached -p HEAD '%s' | (cd %s; git apply -R --cached)" % (file, cdup
))
219 raise YapError("Failed to unstage")
221 def _revert_one(self
, file):
222 self
._assert
_file
_exists
(file)
224 self
._unstage
_one
(file)
227 run_safely("git checkout-index -u -f '%s'" % file)
229 def _parse_commit(self
, commit
):
230 lines
= get_output("git cat-file commit '%s'" % commit
)
235 if mode
!= 'commit' and l
.strip() == "":
240 commit
['log'].append(l
)
247 commit
['log'] = '\n'.join(commit
['log'])
250 def _check_commit(self
, **flags
):
251 if '-a' in flags
and '-d' in flags
:
252 raise YapError("Conflicting flags: -a and -d")
254 if '-d' not in flags
and self
._get
_unstaged
_files
():
255 if '-a' not in flags
and self
._get
_staged
_files
():
256 raise YapError("Staged and unstaged changes present. Specify what to commit")
257 cdup
= self
._get
_cdup
()
258 os
.system("git diff-files -p | (cd %s; git apply --cached)" % cdup
)
259 for f
in self
._get
_new
_files
():
262 def _do_uncommit(self
):
263 commit
= self
._parse
_commit
("HEAD")
264 repo
= get_output('git rev-parse --git-dir')[0]
265 dir = os
.path
.join(repo
, 'yap')
270 msg_file
= os
.path
.join(dir, 'msg')
271 fd
= file(msg_file
, 'w')
272 print >>fd
, commit
['log']
275 tree
= get_output("git rev-parse --verify HEAD^")
276 run_safely("git update-ref -m uncommit HEAD '%s'" % tree
[0])
278 def _do_commit(self
, msg
=None):
279 tree
= get_output("git write-tree")[0]
281 repo
= get_output('git rev-parse --git-dir')[0]
282 head_file
= os
.path
.join(repo
, 'yap', 'merge')
284 parent
= pickle
.load(file(head_file
))
286 parent
= get_output("git rev-parse --verify HEAD 2> /dev/null")
288 if os
.environ
.has_key('YAP_EDITOR'):
289 editor
= os
.environ
['YAP_EDITOR']
290 elif os
.environ
.has_key('GIT_EDITOR'):
291 editor
= os
.environ
['GIT_EDITOR']
292 elif os
.environ
.has_key('EDITOR'):
293 editor
= os
.environ
['EDITOR']
297 fd
, tmpfile
= tempfile
.mkstemp("yap")
301 msg_file
= os
.path
.join(repo
, 'yap', 'msg')
302 if os
.access(msg_file
, os
.R_OK
):
304 fd2
= file(tmpfile
, 'w')
305 for l
in fd1
.xreadlines():
306 print >>fd2
, l
.strip()
309 if os
.system("%s '%s'" % (editor
, tmpfile
)) != 0:
310 raise YapError("Editing commit message failed")
317 raise YapError("Refusing to use empty commit message")
319 fd
= os
.popen("git stripspace > %s" % tmpfile
, 'w')
324 parent
= ' -p '.join(parent
)
325 commit
= get_output("git commit-tree '%s' -p %s < '%s'" % (tree
, parent
, tmpfile
))
327 commit
= get_output("git commit-tree '%s' < '%s'" % (tree
, tmpfile
))
330 run_safely("git update-ref HEAD '%s'" % commit
[0])
333 def _check_rebasing(self
):
334 repo
= get_output('git rev-parse --git-dir')[0]
335 dotest
= os
.path
.join(repo
, '.dotest')
336 if os
.access(dotest
, os
.R_OK
):
337 raise YapError("A git operation is in progress. Complete it first")
338 dotest
= os
.path
.join(repo
, '..', '.dotest')
339 if os
.access(dotest
, os
.R_OK
):
340 raise YapError("A git operation is in progress. Complete it first")
342 def _check_git(self
):
343 if run_command("git rev-parse --git-dir"):
344 raise YapError("That command must be run from inside a git repository")
346 def _list_remotes(self
):
347 remotes
= get_output("git config --get-regexp '^remote.*.url'")
349 remote
, url
= x
.split(' ')
350 remote
= remote
.replace('remote.', '')
351 remote
= remote
.replace('.url', '')
354 def _unstage_all(self
):
356 run_safely("git read-tree -m HEAD")
358 run_safely("git read-tree HEAD")
359 run_safely("git update-index -q --refresh")
361 def _get_tracking(self
, current
):
362 remote
= get_output("git config branch.%s.remote" % current
)
364 raise YapError("No tracking branch configured for '%s'" % current
)
366 merge
= get_output("git config branch.%s.merge" % current
)
368 raise YapError("No tracking branch configured for '%s'" % current
)
369 return remote
[0], merge
[0]
371 def _confirm_push(self
, current
, rhs
, repo
):
372 print "About to push local branch '%s' to '%s' on '%s'" % (current
, rhs
, repo
)
373 print "Continue (y/n)? ",
375 ans
= sys
.stdin
.readline().strip()
377 if ans
.lower() != 'y' and ans
.lower() != 'yes':
378 raise YapError("Aborted.")
380 def _clear_state(self
):
381 repo
= get_output('git rev-parse --git-dir')[0]
382 dir = os
.path
.join(repo
, 'yap')
383 for f
in "new-files", "merge", "msg":
385 os
.unlink(os
.path
.join(dir, f
))
389 def _get_attr(self
, name
, attr
):
391 for c
in self
.__class
__.__bases
__:
393 m2
= c
.__dict
__[name
]
397 val
= m2
.__getattribute__(attr
)
398 except AttributeError:
402 def _filter_log(self
, commit
):
405 def _check_rename(self
, rev
, path
):
406 renames
= get_output("git diff-tree -C -M --diff-filter=R %s %s^"
409 r
= r
.replace('\t', ' ')
410 fields
= r
.split(' ')
411 mode1
, mode2
, hash1
, hash2
, rename
, dst
, src
= fields
416 @short_help("make a local copy of an existing repository")
418 The first argument is a URL to the existing repository. This can be an
419 absolute path if the repository is local, or a URL with the git://,
420 ssh://, or http:// schemes. By default, the directory used is the last
421 component of the URL, sans '.git'. This can be overridden by providing
424 def cmd_clone(self
, url
, directory
=None):
427 if '://' not in url
and url
[0] != '/':
428 url
= os
.path
.join(os
.getcwd(), url
)
430 url
= url
.rstrip('/')
431 if directory
is None:
432 directory
= url
.rsplit('/')[-1]
433 directory
= directory
.replace('.git', '')
438 raise YapError("Directory exists: %s" % directory
)
441 self
.cmd_repo("origin", url
)
442 self
.cmd_fetch("origin")
445 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
446 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
447 for b
in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin'"):
448 if get_output("git rev-parse %s" % b
)[0] == hash:
452 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
453 branch
= "refs/remotes/origin/master"
455 branch
= get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin'")
458 hash = get_output("git rev-parse %s" % branch
)
460 branch
= branch
.replace('refs/remotes/origin/', '')
461 run_safely("git update-ref refs/heads/%s %s" % (branch
, hash[0]))
462 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch
)
463 self
.cmd_revert(**{'-a': 1})
465 @short_help("turn a directory into a repository")
467 Converts the current working directory into a repository. The primary
468 side-effect of this command is the creation of a '.git' subdirectory.
469 No files are added nor commits made.
472 os
.system("git init")
474 @short_help("add a new file to the repository")
476 The arguments are the files to be added to the repository. Once added,
477 the files will show as "unstaged changes" in the output of 'status'. To
478 reverse the effects of this command, see 'rm'.
480 def cmd_add(self
, *files
):
491 @short_help("delete a file from the repository")
493 The arguments are the files to be removed from the current revision of
494 the repository. The files will still exist in any past commits that the
495 files may have been a part of. The file is not actually deleted, it is
496 just no longer tracked as part of the repository.
498 def cmd_rm(self
, *files
):
508 @short_help("stage changes in a file for commit")
510 The arguments are the files to be staged. Staging changes is a way to
511 build up a commit when you do not want to commit all changes at once.
512 To commit only staged changes, use the '-d' flag to 'commit.' To
513 reverse the effects of this command, see 'unstage'. Once staged, the
514 files will show as "staged changes" in the output of 'status'.
516 def cmd_stage(self
, *files
):
526 @short_help("unstage changes in a file")
528 The arguments are the files to be unstaged. Once unstaged, the files
529 will show as "unstaged changes" in the output of 'status'. The '-a'
530 flag can be used to unstage all staged changes at once.
533 def cmd_unstage(self
, *files
, **flags
):
537 files
= self
._get
_staged
_files
()
540 raise YapError("Nothing to do")
542 files
= [ self
._repo
_path
_to
_rel
(x
) for x
in files
]
547 @short_help("show files with staged and unstaged changes")
549 Show the files in the repository with changes since the last commit,
550 categorized based on whether the changes are staged or not. A file may
551 appear under each heading if the same file has both staged and unstaged
554 def cmd_status(self
):
557 branch
= get_output("git symbolic-ref HEAD")
559 branch
= branch
[0].replace('refs/heads/', '')
562 print "Current branch: %s" % branch
564 print "Files with staged changes:"
565 files
= self
._get
_staged
_files
()
567 print "\t%s" % self
._repo
_path
_to
_rel
(f
)
571 print "Files with unstaged changes:"
572 files
= self
._get
_unstaged
_files
()
574 print "\t%s" % self
._repo
_path
_to
_rel
(f
)
578 files
= self
._get
_unmerged
_files
()
580 print "Files with conflicts:"
582 print "\t%s" % self
._repo
_path
_to
_rel
(f
)
584 @short_help("remove uncommitted changes from a file (*)")
586 The arguments are the files whose changes will be reverted. If the '-a'
587 flag is given, then all files will have uncommitted changes removed.
588 Note that there is no way to reverse this command short of manually
589 editing each file again.
592 def cmd_revert(self
, *files
, **flags
):
597 run_safely("git checkout-index -u -f -a")
609 @short_help("record changes to files as a new commit")
611 Create a new commit recording changes since the last commit. If there
612 are only unstaged changes, those will be recorded. If there are only
613 staged changes, those will be recorded. Otherwise, you will have to
614 specify either the '-a' flag or the '-d' flag to commit all changes or
615 only staged changes, respectively. To reverse the effects of this
616 command, see 'uncommit'.
618 @takes_options("adm:")
619 def cmd_commit(self
, **flags
):
620 "[-a | -d] [-m <msg>]"
622 self
._check
_rebasing
()
623 self
._check
_commit
(**flags
)
624 if not self
._get
_staged
_files
():
625 raise YapError("No changes to commit")
626 msg
= flags
.get('-m', None)
630 @short_help("reverse the actions of the last commit")
632 Reverse the effects of the last 'commit' operation. The changes that
633 were part of the previous commit will show as "staged changes" in the
634 output of 'status'. This means that if no files were changed since the
635 last commit was created, 'uncommit' followed by 'commit' is a lossless
638 def cmd_uncommit(self
):
644 @short_help("report the current version of yap")
645 def cmd_version(self
):
646 print "Yap version %s" % self
.version
648 @short_help("show the changelog for particular versions or files")
650 The arguments are the files with which to filter history. If none are
651 given, all changes are listed. Otherwise only commits that affected one
652 or more of the given files are listed. The -r option changes the
653 starting revision for traversing history. By default, history is listed
656 @takes_options("pr:")
657 def cmd_log(self
, *paths
, **flags
):
658 "[-p] [-r <rev>] <path>..."
660 rev
= flags
.get('-r', 'HEAD')
661 rev
= self
._resolve
_rev
(rev
)
668 pager
= os
.popen(self
._get
_pager
_cmd
(), 'w')
671 for hash in yield_output("git rev-list '%s' -- %s"
672 % (rev
, ' '.join(paths
))):
673 commit
= get_output("git show -M -C %s %s"
674 % (flags
.get('-p', '--name-status'), hash),
676 commit
= self
._filter
_log
(commit
)
677 print >>pager
, ''.join(commit
)
681 src
= self
._check
_rename
(hash, paths
[0])
690 except (IOError, KeyboardInterrupt):
693 @short_help("show staged, unstaged, or all uncommitted changes")
695 Show staged, unstaged, or all uncommitted changes. By default, all
696 changes are shown. The '-u' flag causes only unstaged changes to be
697 shown. The '-d' flag causes only staged changes to be shown.
700 def cmd_diff(self
, **flags
):
703 if '-u' in flags
and '-d' in flags
:
704 raise YapError("Conflicting flags: -u and -d")
706 pager
= self
._get
_pager
_cmd
()
709 os
.system("git diff-files -p | %s" % pager
)
711 os
.system("git diff-index --cached -p HEAD | %s" % pager
)
713 os
.system("git diff-index -p HEAD | %s" % pager
)
715 @short_help("list, create, or delete branches")
717 If no arguments are specified, a list of local branches is given. The
718 current branch is indicated by a "*" next to the name. If an argument
719 is given, it is taken as the name of a new branch to create. The branch
720 will start pointing at the current HEAD. See 'point' for details on
721 changing the revision of the new branch. Note that this command does
722 not switch the current working branch. See 'switch' for details on
723 changing the current working branch.
725 The '-d' flag can be used to delete local branches. If the delete
726 operation would remove the last branch reference to a given line of
727 history (colloquially referred to as "dangling commits"), yap will
728 report an error and abort. The '-f' flag can be used to force the delete
731 @takes_options("fd:")
732 def cmd_branch(self
, branch
=None, **flags
):
733 "[ [-f] -d <branch> | <branch> ]"
735 force
= '-f' in flags
737 self
._delete
_branch
(flags
['-d'], force
)
741 if branch
is not None:
742 ref
= get_output("git rev-parse --verify HEAD")
744 raise YapError("No branch point yet. Make a commit")
745 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch
, ref
[0]))
747 current
= get_output("git symbolic-ref HEAD")
748 branches
= get_output("git for-each-ref --format='%(refname)' 'refs/heads'")
750 if current
and b
== current
[0]:
754 b
= b
.replace('refs/heads/', '')
757 @short_help("change the current working branch")
759 The argument is the name of the branch to make the current working
760 branch. This command will fail if there are uncommitted changes to any
761 files. Otherwise, the contents of the files in the working directory
762 are updated to reflect their state in the new branch. Additionally, any
763 future commits are added to the new branch instead of the previous line
767 def cmd_switch(self
, branch
, **flags
):
770 self
._check
_rebasing
()
771 ref
= self
._resolve
_rev
('refs/heads/'+branch
)
773 if '-f' not in flags
:
774 if (self
._get
_staged
_files
()
775 or (self
._get
_unstaged
_files
()
776 and run_command("git update-index --refresh"))):
777 raise YapError("You have uncommitted changes. Use -f to continue anyway")
779 if self
._get
_unstaged
_files
() and self
._get
_staged
_files
():
780 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
782 staged
= bool(self
._get
_staged
_files
())
784 cdup
= self
._get
_cdup
()
785 run_command("git diff-files -p | (cd %s; git apply --cached)" % cdup
)
786 for f
in self
._get
_new
_files
():
789 idx
= get_output("git write-tree")
790 new
= self
._resolve
_rev
('refs/heads/'+branch
)
792 run_command("git update-index --refresh")
793 readtree
= "git read-tree -v --aggressive -u -m HEAD %s %s" % (idx
[0], new
)
794 if os
.system(readtree
):
795 raise YapError("Failed to switch")
796 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch
)
798 if '-f' not in flags
:
805 @short_help("move the current branch to a different revision")
807 The argument is the hash of the commit to which the current branch
808 should point, or alternately a branch or tag (a.k.a, "committish"). If
809 moving the branch would create "dangling commits" (see 'branch'), yap
810 will report an error and abort. The '-f' flag can be used to force the
811 operation in spite of this.
814 def cmd_point(self
, where
, **flags
):
817 self
._check
_rebasing
()
819 head
= get_output("git rev-parse --verify HEAD")
821 raise YapError("No commit yet; nowhere to point")
823 ref
= self
._resolve
_rev
(where
)
824 ref
= get_output("git rev-parse --verify '%s^{commit}'" % ref
)
826 raise YapError("Not a commit: %s" % where
)
828 if self
._get
_unstaged
_files
() or self
._get
_staged
_files
():
829 raise YapError("You have uncommitted changes. Commit them first")
831 run_safely("git update-ref HEAD '%s'" % ref
[0])
833 if '-f' not in flags
:
834 name
= get_output("git name-rev --name-only '%s'" % head
[0])[0]
835 if name
== "undefined":
836 os
.system("git update-ref HEAD '%s'" % head
[0])
837 raise YapError("Pointing there will lose commits. Use -f to force")
839 run_command("git update-index --refresh")
840 rc
= os
.system("git read-tree -v --reset -u HEAD")
842 raise YapError("checkout-index failed")
845 @short_help("alter history by dropping or amending commits")
847 This command operates in two distinct modes, "amend" and "drop" mode.
848 In drop mode, the given commit is removed from the history of the
849 current branch, as though that commit never happened. By default the
852 In amend mode, the uncommitted changes present are merged into a
853 previous commit. This is useful for correcting typos or adding missed
854 files into past commits. By default the commit used is HEAD.
856 While rewriting history it is possible that conflicts will arise. If
857 this happens, the rewrite will pause and you will be prompted to resolve
858 the conflicts and stage them. Once that is done, you will run "yap
859 history continue." If instead you want the conflicting commit removed
860 from history (perhaps your changes supercede that commit) you can run
861 "yap history skip". Once the rewrite completes, your branch will be on
862 the same commit as when the rewrite started.
864 def cmd_history(self
, subcmd
, *args
):
865 "amend | drop <commit>"
868 if subcmd
not in ("amend", "drop", "continue", "skip"):
872 When you have resolved the conflicts run \"yap history continue\".
873 To skip the problematic patch, run \"yap history skip\"."""
875 if subcmd
== "continue":
876 os
.system("git am -3 -r --resolvemsg='%s'" % resolvemsg
)
879 os
.system("git reset --hard")
880 os
.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg
)
883 if subcmd
== "amend":
884 flags
, args
= getopt
.getopt(args
, "ad")
894 self
._resolve
_rev
(commit
)
895 self
._check
_rebasing
()
897 if subcmd
== "amend":
898 self
._check
_commit
(**flags
)
899 if self
._get
_unstaged
_files
():
900 # XXX: handle unstaged changes better
901 raise YapError("Commit away changes that you aren't amending")
905 start
= get_output("git rev-parse HEAD")
906 stash
= get_output("git stash create")
907 run_command("git reset --hard")
909 fd
, tmpfile
= tempfile
.mkstemp("yap")
913 os
.system("git format-patch -k --stdout '%s' > %s" % (commit
, tmpfile
))
914 if subcmd
== "amend":
915 self
.cmd_point(commit
, **{'-f': True})
917 if subcmd
== "amend":
919 rc
= os
.system("git stash apply %s" % stash
[0])
921 self
.cmd_point(start
[0], **{'-f': True})
922 os
.system("git stash apply %s" % stash
[0])
923 raise YapError("Failed to apply stash")
926 if subcmd
== "amend":
928 self
._check
_commit
(**{'-a': True})
931 self
.cmd_point("%s^" % commit
, **{'-f': True})
933 stat
= os
.stat(tmpfile
)
936 run_safely("git update-index --refresh")
937 rc
= os
.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg
, tmpfile
))
939 raise YapError("Failed to apply changes")
944 run_command("git stash apply %s" % stash
[0])
947 @short_help("show the changes introduced by a given commit")
949 By default, the changes in the last commit are shown. To override this,
950 specify a hash, branch, or tag (committish). The hash of the commit,
951 the commit's author, log message, and a diff of the changes are shown.
953 def cmd_show(self
, commit
="HEAD"):
956 commit
= self
._resolve
_rev
(commit
)
957 os
.system("git show '%s'" % commit
)
959 @short_help("apply the changes in a given commit to the current branch")
961 The argument is the hash, branch, or tag (committish) of the commit to
962 be applied. In general, it only makes sense to apply commits that
963 happened on another branch. The '-r' flag can be used to have the
964 changes in the given commit reversed from the current branch. In
965 general, this only makes sense for commits that happened on the current
969 def cmd_cherry_pick(self
, commit
, **flags
):
972 commit
= self
._resolve
_rev
(commit
)
974 os
.system("git revert '%s'" % commit
)
976 os
.system("git cherry-pick '%s'" % commit
)
978 @short_help("list, add, or delete configured remote repositories")
980 When invoked with no arguments, this command will show the list of
981 currently configured remote repositories, giving both the name and URL
982 of each. To add a new repository, give the desired name as the first
983 argument and the URL as the second. The '-d' flag can be used to remove
984 a previously added repository.
987 def cmd_repo(self
, name
=None, url
=None, **flags
):
988 "[<name> <url> | -d <name>]"
990 if name
is not None and url
is None:
994 if flags
['-d'] not in [ x
[0] for x
in self
._list
_remotes
() ]:
995 raise YapError("No such repository: %s" % flags
['-d'])
996 os
.system("git config --unset remote.%s.url" % flags
['-d'])
997 os
.system("git config --unset remote.%s.fetch" % flags
['-d'])
998 for b
in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin'"):
999 hash = get_output("git rev-parse %s" % b
)
1001 run_safely("git update-ref -d %s %s" % (b
, hash[0]))
1004 if name
in [ x
[0] for x
in self
._list
_remotes
() ]:
1005 raise YapError("Repository '%s' already exists" % name
)
1006 os
.system("git config remote.%s.url %s" % (name
, url
))
1007 os
.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name
, name
))
1009 for remote
, url
in self
._list
_remotes
():
1011 print " URL: %s" % url
1013 for b
in get_output("git for-each-ref --format='%%(refname)' 'refs/remotes/%s'" % remote
):
1014 b
= b
.replace('refs/remotes/', '')
1016 branches
= "Branches: "
1019 print " %s%s" % (branches
, b
)
1022 @short_help("send local commits to a remote repository (*)")
1024 When invoked with no arguments, the current branch is synchronized to
1025 the tracking branch of the tracking remote. If no tracking remote is
1026 specified, the repository will have to be specified on the command line.
1027 In that case, the default is to push to a branch with the same name as
1028 the current branch. This behavior can be overridden by giving a second
1029 argument to specify the remote branch.
1031 If the remote branch does not currently exist, the command will abort
1032 unless the -c flag is provided. If the remote branch is not a direct
1033 descendent of the local branch, the command will abort unless the -f
1034 flag is provided. Forcing a push in this way can be problematic to
1035 other users of the repository if they are not expecting it.
1037 To delete a branch on the remote repository, use the -d flag.
1039 @takes_options("cdf")
1040 def cmd_push(self
, repo
=None, rhs
=None, **flags
):
1043 if '-c' in flags
and '-d' in flags
:
1046 if repo
and repo
not in [ x
[0] for x
in self
._list
_remotes
() ]:
1047 raise YapError("No such repository: %s" % repo
)
1049 current
= get_output("git symbolic-ref HEAD")
1051 raise YapError("Not on a branch!")
1053 self
._check
_rebasing
()
1055 current
= current
[0].replace('refs/heads/', '')
1056 remote
= get_output("git config branch.%s.remote" % current
)
1057 if repo
is None and remote
:
1061 raise YapError("No tracking branch configured; specify destination repository")
1063 if rhs
is None and remote
and remote
[0] == repo
:
1064 merge
= get_output("git config branch.%s.merge" % current
)
1069 rhs
= "refs/heads/%s" % current
1071 if '-c' not in flags
and '-d' not in flags
:
1072 if run_command("git rev-parse --verify refs/remotes/%s/%s"
1073 % (repo
, rhs
.replace('refs/heads/', ''))):
1074 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
1075 if '-f' not in flags
:
1076 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo
, rhs
.replace('refs/heads/', '')))
1077 base
= get_output("git merge-base HEAD %s" % hash[0])
1079 if base
[0] != hash[0]:
1080 raise YapError("Branch not up-to-date with remote. Update or use -f")
1082 self
._confirm
_push
(current
, rhs
, repo
)
1089 lhs
= "refs/heads/%s" % current
1090 rc
= os
.system("git push %s %s %s:%s" % (flags
.get('-f', ''), repo
, lhs
, rhs
))
1092 raise YapError("Push failed.")
1094 @short_help("retrieve commits from a remote repository")
1096 When run with no arguments, the command will retrieve new commits from
1097 the remote tracking repository. Note that this does not in any way
1098 alter the current branch. For that, see "update". If a remote other
1099 than the tracking remote is desired, it can be specified as the first
1102 def cmd_fetch(self
, repo
=None):
1105 current
= get_output("git symbolic-ref HEAD")
1107 raise YapError("Not on a branch!")
1109 if repo
and repo
not in [ x
[0] for x
in self
._list
_remotes
() ]:
1110 raise YapError("No such repository: %s" % repo
)
1112 current
= current
[0].replace('refs/heads/', '')
1113 remote
= get_output("git config branch.%s.remote" % current
)
1117 raise YapError("No tracking branch configured; specify a repository")
1118 os
.system("git fetch %s" % repo
)
1120 @short_help("update the current branch relative to its tracking branch")
1122 Updates the current branch relative to its remote tracking branch. This
1123 command requires that the current branch have a remote tracking branch
1124 configured. If any conflicts occur while applying your changes to the
1125 updated remote, the command will pause to allow you to fix them. Once
1126 that is done, run "update" with the "continue" subcommand. Alternately,
1127 the "skip" subcommand can be used to discard the conflicting changes.
1129 def cmd_update(self
, subcmd
=None):
1132 if subcmd
and subcmd
not in ["continue", "skip"]:
1136 When you have resolved the conflicts run \"yap update continue\".
1137 To skip the problematic patch, run \"yap update skip\"."""
1139 if subcmd
== "continue":
1140 os
.system("git am -3 -r --resolvemsg='%s'" % resolvemsg
)
1142 if subcmd
== "skip":
1143 os
.system("git reset --hard")
1144 os
.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg
)
1147 self
._check
_rebasing
()
1148 if self
._get
_unstaged
_files
() or self
._get
_staged
_files
():
1149 raise YapError("You have uncommitted changes. Commit them first")
1151 current
= get_output("git symbolic-ref HEAD")
1153 raise YapError("Not on a branch!")
1155 current
= current
[0].replace('refs/heads/', '')
1156 remote
, merge
= self
._get
_tracking
(current
)
1157 merge
= merge
.replace('refs/heads/', '')
1159 self
.cmd_fetch(remote
)
1160 base
= get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote
, merge
))
1163 fd
, tmpfile
= tempfile
.mkstemp("yap")
1165 os
.system("git format-patch -k --stdout '%s' > %s" % (base
[0], tmpfile
))
1166 self
.cmd_point("refs/remotes/%s/%s" % (remote
, merge
), **{'-f': True})
1168 stat
= os
.stat(tmpfile
)
1171 rc
= os
.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg
, tmpfile
))
1173 raise YapError("Failed to apply changes")
1177 @short_help("query and configure remote branch tracking")
1179 When invoked with no arguments, the command displays the tracking
1180 information for the current branch. To configure the tracking
1181 information, two arguments for the remote repository and remote branch
1182 are given. The tracking information is used to provide defaults for
1183 where to push local changes and from where to get updates to the branch.
1185 def cmd_track(self
, repo
=None, branch
=None):
1189 current
= get_output("git symbolic-ref HEAD")
1191 raise YapError("Not on a branch!")
1192 current
= current
[0].replace('refs/heads/', '')
1194 if repo
is None and branch
is None:
1195 repo
, merge
= self
._get
_tracking
(current
)
1196 merge
= merge
.replace('refs/heads/', '')
1197 print "Branch '%s' tracking refs/remotes/%s/%s" % (current
, repo
, merge
)
1200 if repo
is None or branch
is None:
1203 if repo
not in [ x
[0] for x
in self
._list
_remotes
() ]:
1204 raise YapError("No such repository: %s" % repo
)
1206 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo
, branch
)):
1207 raise YapError("No such branch '%s' on repository '%s'" % (branch
, repo
))
1209 os
.system("git config branch.%s.remote '%s'" % (current
, repo
))
1210 os
.system("git config branch.%s.merge 'refs/heads/%s'" % (current
, branch
))
1211 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current
, repo
, branch
)
1213 @short_help("mark files with conflicts as resolved")
1215 The arguments are the files to be marked resolved. When a conflict
1216 occurs while merging changes to a file, that file is marked as
1217 "unmerged." Until the file(s) with conflicts are marked resolved,
1218 commits cannot be made.
1220 def cmd_resolved(self
, *files
):
1227 self
._stage
_one
(f
, True)
1230 @short_help("merge a branch into the current branch")
1231 def cmd_merge(self
, branch
):
1235 branch_name
= branch
1236 branch
= self
._resolve
_rev
(branch
)
1237 base
= get_output("git merge-base HEAD %s" % branch
)
1239 raise YapError("Branch '%s' is not a fork of the current branch"
1242 readtree
= ("git read-tree --aggressive -u -m %s HEAD %s"
1243 % (base
[0], branch
))
1244 if run_command(readtree
):
1245 run_command("git update-index --refresh")
1246 if os
.system(readtree
):
1247 raise YapError("Failed to merge")
1249 repo
= get_output('git rev-parse --git-dir')[0]
1250 dir = os
.path
.join(repo
, 'yap')
1255 msg_file
= os
.path
.join(dir, 'msg')
1256 msg
= file(msg_file
, 'w')
1257 print >>msg
, "Merge branch '%s'" % branch_name
1260 head
= get_output("git rev-parse --verify HEAD")
1262 heads
= [head
[0], branch
]
1263 head_file
= os
.path
.join(dir, 'merge')
1264 pickle
.dump(heads
, file(head_file
, 'w'))
1266 self
._merge
_index
(branch
, base
[0])
1267 if self
._get
_unmerged
_files
():
1269 raise YapError("Fix conflicts then commit")
1273 def _merge_index(self
, branch
, base
):
1274 for f
in self
._get
_unmerged
_files
():
1275 fd
, bfile
= tempfile
.mkstemp("yap")
1277 rc
= os
.system("git show %s:%s > %s" % (base
, f
, bfile
))
1280 fd
, ofile
= tempfile
.mkstemp("yap")
1282 rc
= os
.system("git show %s:%s > %s" % (branch
, f
, ofile
))
1285 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
)
1286 rc
= os
.system(command
)
1292 self
._stage
_one
(f
, True)
1294 def cmd_help(self
, cmd
=None):
1297 cmd
= "cmd_" + cmd
.replace('-', '_')
1299 attr
= self
.__getattribute
__(cmd
)
1300 except AttributeError:
1301 raise YapError("No such command: %s" % cmd
)
1303 help = self
._get
_attr
(cmd
, "long_help")
1305 raise YapError("Sorry, no help for '%s'. Ask Steven." % oldcmd
)
1307 print >>sys
.stderr
, "The '%s' command" % oldcmd
1308 doc
= self
._get
_attr
(cmd
, "__doc__")
1311 print >>sys
.stderr
, "\tyap %s %s" % (oldcmd
, doc
)
1312 print >>sys
.stderr
, "%s" % help
1315 print >> sys
.stderr
, "Yet Another (Git) Porcelein"
1318 for name
in dir(self
):
1319 if not name
.startswith('cmd_'):
1321 attr
= self
.__getattribute
__(name
)
1322 if not callable(attr
):
1325 short_msg
= self
._get
_attr
(name
, "short_help")
1326 if short_msg
is None:
1329 name
= name
.replace('cmd_', '')
1330 name
= name
.replace('_', '-')
1331 print >> sys
.stderr
, "%-16s%s" % (name
, short_msg
)
1334 print >> sys
.stderr
, "(*) Indicates that the command is not readily reversible"
1336 @short_help("show information about loaded plugins")
1337 def cmd_plugins(self
):
1339 print >> sys
.stderr
, "Loaded plugins:"
1340 plugins
= load_plugins()
1341 for name
, cls
in plugins
.items():
1342 print "\t%-16s: %s" % (name
, cls
.__doc
__)
1344 print "\t%-16s" % "None"
1346 def cmd_usage(self
):
1347 print >> sys
.stderr
, "usage: %s <command>" % os
.path
.basename(sys
.argv
[0])
1348 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"
1351 plugindir
= os
.path
.join("~", ".yap", "plugins")
1352 plugindir
= os
.path
.expanduser(plugindir
)
1353 plugindir
= os
.path
.join(plugindir
, "*.py")
1356 for p
in glob
.glob(os
.path
.expanduser(plugindir
)):
1357 plugin
= os
.path
.basename(p
).replace('.py', '')
1358 m
= __import__(plugin
)
1361 if not type(cls
) == type:
1363 if not issubclass(cls
, YapCore
):
1370 def yap_metaclass(name
, bases
, dct
):
1371 plugindir
= os
.path
.join("~", ".yap", "plugins")
1372 plugindir
= os
.path
.expanduser(plugindir
)
1373 sys
.path
.insert(0, plugindir
)
1375 plugins
= set(load_plugins().values())
1378 p2
-= set(cls
.__bases
__)
1380 bases
= list(plugins
) + list(bases
)
1381 return type(name
, tuple(bases
), dct
)
1384 __metaclass__
= yap_metaclass
1386 def main(self
, args
):
1394 if run_command("git --version"):
1395 print >>sys
.stderr
, "Failed to run git; is it installed?"
1398 debug
= os
.getenv('YAP_DEBUG')
1401 command
= command
.replace('-', '_')
1402 meth
= self
.__getattribute
__("cmd_"+command
)
1403 doc
= self
._get
_attr
("cmd_"+command
, "__doc__")
1407 for c
in self
.__class
__.__bases
__:
1409 t
= c
.__dict
__["cmd_"+command
]
1412 if "options" in t
.__dict
__:
1413 options
+= t
.options
1417 flags
, args
= getopt
.getopt(args
, options
)
1419 except getopt
.GetoptError
, e
:
1422 print "Usage: %s %s %s" % (os
.path
.basename(sys
.argv
[0]), command
, doc
)
1428 meth(*args
, **flags
)
1429 except (TypeError, getopt
.GetoptError
):
1432 print "Usage: %s %s %s" % (os
.path
.basename(sys
.argv
[0]), command
, doc
)
1436 print >> sys
.stderr
, e
1438 except AttributeError: