8 from plugin
import YapPlugin
11 class ShellError(Exception):
12 def __init__(self
, cmd
, rc
):
17 return "%s returned %d" % (self
.cmd
, self
.rc
)
22 raise ShellError(cmd
, rc
)
24 class YapError(Exception):
25 def __init__(self
, msg
):
34 plugindir
= os
.path
.expanduser("~/.yap/plugins")
35 for p
in glob
.glob(os
.path
.join(plugindir
, "*.py")):
38 for cls
in glbls
.values():
39 if not type(cls
) == type:
41 if not issubclass(cls
, YapPlugin
):
46 # XXX: check for override overlap
49 def _add_new_file(self
, file):
50 repo
= get_output('git rev-parse --git-dir')[0]
51 dir = os
.path
.join(repo
, 'yap')
56 files
= self
._get
_new
_files
()
58 path
= os
.path
.join(dir, 'new-files')
59 pickle
.dump(files
, open(path
, 'w'))
61 def _get_new_files(self
):
62 repo
= get_output('git rev-parse --git-dir')[0]
63 path
= os
.path
.join(repo
, 'yap', 'new-files')
65 files
= pickle
.load(file(path
))
72 if get_output("git ls-files --cached '%s'" % f
) != []:
77 def _remove_new_file(self
, file):
78 files
= self
._get
_new
_files
()
79 files
= filter(lambda x
: x
!= file, files
)
81 repo
= get_output('git rev-parse --git-dir')[0]
82 path
= os
.path
.join(repo
, 'yap', 'new-files')
83 pickle
.dump(files
, open(path
, 'w'))
85 def _clear_new_files(self
):
86 repo
= get_output('git rev-parse --git-dir')[0]
87 path
= os
.path
.join(repo
, 'yap', 'new-files')
90 def _assert_file_exists(self
, file):
91 if not os
.access(file, os
.R_OK
):
92 raise YapError("No such file: %s" % file)
94 def _get_staged_files(self
):
95 if run_command("git rev-parse HEAD"):
96 files
= get_output("git ls-files --cached")
98 files
= get_output("git diff-index --cached --name-only HEAD")
101 def _get_unstaged_files(self
):
102 files
= self
._get
_new
_files
()
103 files
+= get_output("git ls-files -m")
106 def _delete_branch(self
, branch
, force
):
107 current
= get_output("git symbolic-ref HEAD")[0]
108 current
= current
.replace('refs/heads/', '')
109 if branch
== current
:
110 raise YapError("Can't delete current branch")
112 ref
= get_output("git rev-parse --verify 'refs/heads/%s'" % branch
)
114 raise YapError("No such branch: %s" % branch
)
115 run_safely("git update-ref -d 'refs/heads/%s' '%s'" % (branch
, ref
[0]))
118 name
= get_output("git name-rev --name-only '%s'" % ref
[0])[0]
119 if name
== 'undefined':
120 run_command("git update-ref 'refs/heads/%s' '%s'" % (branch
, ref
[0]))
121 raise YapError("Refusing to delete leaf branch (use -f to force)")
122 def _get_pager_cmd(self
):
123 if 'YAP_PAGER' in os
.environ
:
124 return os
.environ
['YAP_PAGER']
125 elif 'GIT_PAGER' in os
.environ
:
126 return os
.environ
['GIT_PAGER']
127 elif 'PAGER' in os
.environ
:
128 return os
.environ
['PAGER']
132 def _add_one(self
, file):
133 self
._assert
_file
_exists
(file)
134 x
= get_output("git ls-files '%s'" % file)
136 raise YapError("File '%s' already in repository" % file)
137 self
._add
_new
_file
(file)
139 def _rm_one(self
, file):
140 self
._assert
_file
_exists
(file)
141 if get_output("git ls-files '%s'" % file) != []:
142 run_safely("git rm --cached '%s'" % file)
143 self
._remove
_new
_file
(file)
145 def _stage_one(self
, file):
146 self
._assert
_file
_exists
(file)
147 run_safely("git update-index --add '%s'" % file)
149 def _unstage_one(self
, file):
150 self
._assert
_file
_exists
(file)
151 if run_command("git rev-parse HEAD"):
152 run_safely("git update-index --force-remove '%s'" % file)
154 run_safely("git diff-index -p HEAD '%s' | git apply -R --cached" % file)
156 def _revert_one(self
, file):
157 self
._assert
_file
_exists
(file)
158 self
._unstage
_one
(file)
159 run_safely("git checkout-index -u -f '%s'" % file)
161 def _parse_commit(self
, commit
):
162 lines
= get_output("git cat-file commit '%s'" % commit
)
167 if mode
!= 'commit' and l
.strip() == "":
172 commit
['log'].append(l
)
179 commit
['log'] = '\n'.join(commit
['log'])
182 def _check_commit(self
, **flags
):
183 if '-a' in flags
and '-d' in flags
:
184 raise YapError("Conflicting flags: -a and -d")
186 if '-d' not in flags
and self
._get
_unstaged
_files
():
187 if '-a' not in flags
and self
._get
_staged
_files
():
188 raise YapError("Staged and unstaged changes present. Specify what to commit")
189 run_safely("git diff-files -p | git apply --cached")
190 for f
in self
._get
_new
_files
():
193 def _do_uncommit(self
):
194 commit
= self
._parse
_commit
("HEAD")
195 repo
= get_output('git rev-parse --git-dir')[0]
196 dir = os
.path
.join(repo
, 'yap')
201 msg_file
= os
.path
.join(dir, 'msg')
202 fd
= file(msg_file
, 'w')
203 print >>fd
, commit
['log']
206 tree
= get_output("git rev-parse --verify HEAD^")
207 run_safely("git update-ref -m uncommit HEAD '%s'" % tree
[0])
209 def _do_commit(self
, msg
=None):
210 tree
= get_output("git write-tree")[0]
211 parent
= get_output("git rev-parse --verify HEAD 2> /dev/null")[0]
213 if os
.environ
.has_key('YAP_EDITOR'):
214 editor
= os
.environ
['YAP_EDITOR']
215 elif os
.environ
.has_key('GIT_EDITOR'):
216 editor
= os
.environ
['GIT_EDITOR']
217 elif os
.environ
.has_key('EDITOR'):
218 editor
= os
.environ
['EDITOR']
222 fd
, tmpfile
= tempfile
.mkstemp("yap")
225 repo
= get_output('git rev-parse --git-dir')[0]
226 msg_file
= os
.path
.join(repo
, 'yap', 'msg')
227 if os
.access(msg_file
, os
.R_OK
):
229 fd2
= file(tmpfile
, 'w')
230 for l
in fd1
.xreadlines():
231 print >>fd2
, l
.strip()
236 fd
= file(tmpfile
, 'w')
239 elif os
.system("%s '%s'" % (editor
, tmpfile
)) != 0:
240 raise YapError("Editing commit message failed")
242 commit
= get_output("git commit-tree '%s' -p '%s' < '%s'" % (tree
, parent
, tmpfile
))
244 commit
= get_output("git commit-tree '%s' < '%s'" % (tree
, tmpfile
))
246 raise YapError("Commit failed; no log message?")
248 run_safely("git update-ref HEAD '%s'" % commit
[0])
250 def _check_rebasing(self
):
251 repo
= get_output('git rev-parse --git-dir')[0]
252 dotest
= os
.path
.join(repo
, '.dotest')
253 if os
.access(dotest
, os
.R_OK
):
254 raise YapError("A git operation is in progress. Complete it first")
255 dotest
= os
.path
.join(repo
, '..', '.dotest')
256 if os
.access(dotest
, os
.R_OK
):
257 raise YapError("A git operation is in progress. Complete it first")
259 def _list_remotes(self
):
260 remotes
= get_output("git config --get-regexp 'remote.*.url'")
262 remote
, url
= x
.split(' ')
263 remote
= remote
.replace('remote.', '')
264 remote
= remote
.replace('.url', '')
267 @short_help("make a local copy of an existing repository")
269 The first argument is a URL to the existing repository. This can be an
270 absolute path if the repository is local, or a URL with the git://,
271 ssh://, or http:// schemes. By default, the directory used is the last
272 component of the URL, sans '.git'. This can be overridden by providing
275 def cmd_clone(self
, url
, directory
=""):
277 # XXX: implement in terms of init + remote add + fetch
278 os
.system("git clone '%s' %s" % (url
, directory
))
280 @short_help("turn a directory into a repository")
282 Converts the current working directory into a repository. The primary
283 side-effect of this command is the creation of a '.git' subdirectory.
284 No files are added nor commits made.
287 os
.system("git init")
289 @short_help("add a new file to the repository")
291 The arguments are the files to be added to the repository. Once added,
292 the files will show as "unstaged changes" in the output of 'status'. To
293 reverse the effects of this command, see 'rm'.
295 def cmd_add(self
, *files
):
304 @short_help("delete a file from the repository")
306 The arguments are the files to be removed from the current revision of
307 the repository. The files will still exist in any past commits that the
308 file may have been a part of. The file is not actually deleted, it is
309 just no longer tracked as part of the repository.
311 def cmd_rm(self
, *files
):
320 @short_help("stage changes in a file for commit")
322 The arguments are the files to be staged. Staging changes is a way to
323 build up a commit when you do not want to commit all changes at once.
324 To commit only staged changes, use the '-d' flag to 'commit.' To
325 reverse the effects of this command, see 'unstage'. Once staged, the
326 files will show as "staged changes" in the output of 'status'.
328 def cmd_stage(self
, *files
):
337 @short_help("unstage changes in a file")
339 The arguments are the files to be unstaged. Once unstaged, the files
340 will show as "unstaged changes" in the output of 'status'. The '-a'
341 flag can be used to unstage all staged changes at once.
344 def cmd_unstage(self
, *files
, **flags
):
347 run_safely("git read-tree -m HEAD")
358 @short_help("show files with staged and unstaged changes")
360 Show the files in the repository with changes since the last commit,
361 categorized based on whether the changes are staged or not. A file may
362 appear under each heading if the same file has both staged and unstaged
365 def cmd_status(self
):
366 branch
= get_output("git symbolic-ref HEAD")[0]
367 branch
= branch
.replace('refs/heads/', '')
368 print "Current branch: %s" % branch
370 print "Files with staged changes:"
371 files
= self
._get
_staged
_files
()
377 print "Files with unstaged changes:"
378 prefix
= get_output("git rev-parse --show-prefix")
379 files
= self
._get
_unstaged
_files
()
382 f
= os
.path
.join(prefix
[0], f
)
387 @short_help("remove uncommitted changes from a file (*)")
389 The arguments are the files whose changes will be reverted. If the '-a'
390 flag is given, then all files will have uncommitted changes removed.
391 Note that there is no way to reverse this command short of manually
392 editing each file again.
395 def cmd_revert(self
, *files
, **flags
):
398 run_safely("git read-tree -m HEAD")
399 run_safely("git checkout-index -u -f -a")
410 @short_help("record changes to files as a new commit")
412 Create a new commit recording changes since the last commit. If there
413 are only unstaged changes, those will be recorded. If there are only
414 staged changes, those will be recorder. Otherwise, you will have to
415 specify either the '-a' flag or the '-d' flag to commit all changes or
416 only staged changes, respectively. To reverse the effects of this
417 command, see 'uncommit'.
419 @takes_options("adm:")
420 def cmd_commit(self
, **flags
):
421 self
._check
_rebasing
()
422 self
._check
_commit
(**flags
)
423 if not self
._get
_staged
_files
():
424 raise YapError("No changes to commit")
425 msg
= flags
.get('-m', None)
429 @short_help("reverse the actions of the last commit")
431 Reverse the effects of the last 'commit' operation. The changes that
432 were part of the previous commit will show as "staged changes" in the
433 output of 'status'. This means that if no files were changed since the
434 last commit was created, 'uncommit' followed by 'commit' is a lossless
437 def cmd_uncommit(self
):
441 @short_help("report the current version of yap")
442 def cmd_version(self
):
443 print "Yap version 0.1"
445 @short_help("show the changelog for particular versions or files")
447 The arguments are the files with which to filter history. If none are
448 given, all changes are listed. Otherwise only commits that affected one
449 or more of the given files are listed. The -r option changes the
450 starting revision for traversing history. By default, history is listed
454 def cmd_log(self
, *paths
, **flags
):
455 "[-r <rev>] <path>..."
456 rev
= flags
.get('-r', 'HEAD')
457 paths
= ' '.join(paths
)
458 os
.system("git log --name-status '%s' -- %s" % (rev
, paths
))
460 @short_help("show staged, unstaged, or all uncommitted changes")
462 Show staged, unstaged, or all uncommitted changes. By default, all
463 changes are shown. The '-u' flag causes only unstaged changes to be
464 shown. The '-d' flag causes only staged changes to be shown.
467 def cmd_diff(self
, **flags
):
469 if '-u' in flags
and '-d' in flags
:
470 raise YapError("Conflicting flags: -u and -d")
472 pager
= self
._get
_pager
_cmd
()
475 os
.system("git diff-files -p | %s" % pager
)
477 os
.system("git diff-index --cached -p HEAD | %s" % pager
)
479 os
.system("git diff-index -p HEAD | %s" % pager
)
481 @short_help("list, create, or delete branches")
483 If no arguments are given, a list of local branches is given. The
484 current branch is indicated by a "*" next to the name. If an argument
485 is given, it is taken as the name of a new branch to create. The branch
486 will start pointing at the current HEAD. See 'point' for details on
487 changing the revision of the new branch. Note that this command does
488 not switch the current working branch. See 'switch' for details on
489 changing the current working branch.
491 The '-d' flag can be used to delete local branches. If the delete
492 operation would remove the last branch reference to a given line of
493 history (colloquially referred to as "dangling commits"), yap will
494 report an error and abort. The '-f' flag can be used to force the delete
497 @takes_options("fd:")
498 def cmd_branch(self
, branch
=None, **flags
):
499 "[ [-f] -d <branch> | <branch> ]"
500 force
= '-f' in flags
502 self
._delete
_branch
(flags
['-d'], force
)
506 if branch
is not None:
507 ref
= get_output("git rev-parse --verify HEAD")
509 raise YapError("No branch point yet. Make a commit")
510 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch
, ref
[0]))
512 current
= get_output("git symbolic-ref HEAD")[0]
513 branches
= get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
519 b
= b
.replace('refs/heads/', '')
522 @short_help("change the current working branch")
524 The argument is the name of the branch to make the current working
525 branch. This command will fail if there are uncommitted changes to any
526 files. Otherwise, the contents of the files in the working directory
527 are updated to reflect their state in the new branch. Additionally, any
528 future commits are added to the new branch instead of the previous line
531 def cmd_switch(self
, branch
):
533 ref
= get_output("git rev-parse --verify 'refs/heads/%s'" % branch
)
535 raise YapError("No such branch: %s" % branch
)
537 # XXX: support merging like git-checkout
538 if self
._get
_unstaged
_files
() or self
._get
_staged
_files
():
539 raise YapError("You have uncommitted changes. Commit them first")
541 run_safely("git symbolic-ref HEAD refs/heads/'%s'" % branch
)
542 run_safely("git read-tree -m HEAD")
543 run_safely("git checkout-index -u -f -a")
546 @short_help("move the current branch to a different revision")
548 The argument is the hash of the commit to which the current branch
549 should point, or alternately a branch or tag (a.k.a, "committish"). If
550 moving the branch would create "dangling commits" (see 'branch'), yap
551 will report an error and abort. The '-f' flag can be used to force the
552 operation in spite of this.
555 def cmd_point(self
, where
, **flags
):
557 head
= get_output("git rev-parse --verify HEAD")
559 raise YapError("No commit yet; nowhere to point")
561 ref
= get_output("git rev-parse --verify '%s'" % where
)
563 raise YapError("Not a valid ref: %s" % where
)
565 if self
._get
_unstaged
_files
() or self
._get
_staged
_files
():
566 raise YapError("You have uncommitted changes. Commit them first")
568 type = get_output("git cat-file -t '%s'" % ref
[0])
569 if type and type[0] == "tag":
570 tag
= get_output("git cat-file tag '%s'" % ref
[0])
571 ref
[0] = tag
[0].split(' ')[1]
573 run_safely("git update-ref HEAD '%s'" % ref
[0])
575 if '-f' not in flags
:
576 name
= get_output("git name-rev --name-only '%s'" % head
[0])[0]
577 if name
== "undefined":
578 os
.system("git update-ref HEAD '%s'" % head
[0])
579 raise YapError("Pointing there will lose commits. Use -f to force")
581 run_safely("git read-tree -m HEAD")
582 run_safely("git checkout-index -u -f -a")
584 @short_help("alter history by dropping or amending commits")
586 This command operates in two distinct modes, "amend" and "drop" mode.
587 In drop mode, the given commit is removed from the history of the
588 current branch, as though that commit never happened. By default the
591 In amend mode, the uncommitted changes present are merged into a
592 previous commit. This is useful for correcting typos or adding missed
593 files into past commits. By default the commit used is HEAD.
595 While rewriting history it is possible that conflicts will arise. If
596 this happens, the rewrite will pause and you will be prompted to resolve
597 the conflicts and staged them. Once that is done, you will run "yap
598 history continue." If instead you want the conflicting commit removed
599 from history (perhaps your changes supercede that commit) you can run
600 "yap history skip". Once the rewrite completes, your branch will be on
601 the same commit as when the rewrite started.
603 def cmd_history(self
, subcmd
, *args
):
604 "amend | drop <commit>"
606 if subcmd
not in ("amend", "drop", "continue", "skip"):
610 When you have resolved the conflicts run \"yap history continue\".
611 To skip the problematic patch, run \"yap history skip\"."""
613 if subcmd
== "continue":
614 os
.system("git am -3 -r --resolvemsg='%s'" % resolvemsg
)
617 os
.system("git reset --hard")
618 os
.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg
)
621 if subcmd
== "amend":
622 flags
, args
= getopt
.getopt(args
, "ad")
632 if run_command("git rev-parse --verify '%s'" % commit
):
633 raise YapError("Not a valid commit: %s" % commit
)
635 self
._check
_rebasing
()
637 if subcmd
== "amend":
638 self
._check
_commit
(**flags
)
639 if self
._get
_unstaged
_files
():
640 # XXX: handle unstaged changes better
641 raise YapError("Commit away changes that you aren't amending")
643 stash
= get_output("git stash create")
644 run_command("git reset --hard")
646 fd
, tmpfile
= tempfile
.mkstemp("yap")
649 run_safely("git format-patch -k --stdout '%s' > %s" % (commit
, tmpfile
))
650 if subcmd
== "amend":
651 self
.cmd_point(commit
, **{'-f': True})
653 run_command("git stash apply --index %s" % stash
[0])
656 stash
= get_output("git stash create")
657 run_command("git reset --hard")
659 self
.cmd_point("%s^" % commit
, **{'-f': True})
662 fd
, tmpfile
= tempfile
.mkstemp("yap")
664 os
.system("git format-patch -k --stdout '%s' > %s" % (commit
, tmpfile
))
665 if subcmd
== "amend":
666 self
.cmd_point(commit
, **{'-f': True})
668 if subcmd
== "amend":
669 run_command("git stash apply --index %s" % stash
[0])
672 if subcmd
== "amend":
676 self
.cmd_point("%s^" % commit
, **{'-f': True})
678 stat
= os
.stat(tmpfile
)
681 rc
= os
.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg
, tmpfile
))
683 raise YapError("Failed to apply changes")
686 run_command("git stash apply --index %s" % stash
[0])
691 @short_help("show the changes introduced by a given commit")
693 By default, the changes in the last commit are shown. To override this,
694 specify a hash, branch, or tag (committish). The hash of the commit,
695 the commit's author, log message, and a diff of the changes are shown.
697 def cmd_show(self
, commit
="HEAD"):
699 os
.system("git show '%s'" % commit
)
701 @short_help("apply the changes in a given commit to the current branch")
703 The argument is the hash, branch, or tag (committish) of the commit to
704 be applied. In general, it only makes sense to apply commits that
705 happened on another branch. The '-r' flag can be used to have the
706 changes in the given commit reversed from the current branch. In
707 general, this only makes sense for commits that happened on the current
711 def cmd_cherry_pick(self
, commit
, **flags
):
714 os
.system("git revert '%s'" % commit
)
716 os
.system("git cherry-pick '%s'" % commit
)
718 @short_help("list, add, or delete configured remote repositories")
720 When invoked with no arguments, this command will show the list of
721 currently configured remote repositories, giving both the name and URL
722 of each. To add a new repository, give the desired name as the first
723 argument and the URL as the second. The '-d' flag can be used to remove
724 a previously added repository.
727 def cmd_repo(self
, name
=None, url
=None, **flags
):
728 "[<name> <url> | -d <name>]"
729 if name
is not None and url
is None:
733 if flags
['-d'] not in self
._list
_remotes
():
734 raise YapError("No such repository: %s" % flags
['-d'])
735 os
.system("git config --unset remote.%s.url" % flags
['-d'])
736 os
.system("git config --unset remote.%s.fetch" % flags
['-d'])
739 if flags
['-d'] in self
._list
_remotes
():
740 raise YapError("Repository '%s' already exists" % flags
['-d'])
741 os
.system("git config remote.%s.url %s" % (name
, url
))
742 os
.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name
, url
))
744 for remote
, url
in self
._list
_remotes
():
745 print "%s:\t\t%s" % (remote
, url
)
747 def cmd_help(self
, cmd
=None):
750 attr
= self
.__getattribute
__("cmd_"+cmd
)
751 except AttributeError:
752 raise YapError("No such command: %s" % cmd
)
754 help = attr
.long_help
755 except AttributeError:
756 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd
)
758 print >>sys
.stderr
, "The '%s' command" % cmd
759 print >>sys
.stderr
, "\tyap %s %s" % (cmd
, attr
.__doc
__)
760 print >>sys
.stderr
, "%s" % help
763 print >> sys
.stderr
, "Yet Another (Git) Porcelein"
766 for name
in dir(self
):
767 if not name
.startswith('cmd_'):
769 attr
= self
.__getattribute
__(name
)
770 if not callable(attr
):
773 short_msg
= attr
.short_help
774 except AttributeError:
777 name
= name
.replace('cmd_', '')
778 name
= name
.replace('_', '-')
779 print >> sys
.stderr
, "%-16s%s" % (name
, short_msg
)
781 print >> sys
.stderr
, "(*) Indicates that the command is not readily reversible"
784 print >> sys
.stderr
, "usage: %s <command>" % sys
.argv
[0]
785 print >> sys
.stderr
, " valid commands: help init add rm stage unstage status revert commit uncommit log show diff branch switch point cherry-pick history version"
787 def main(self
, args
):
795 debug
= os
.getenv('YAP_DEBUG')
798 command
= command
.replace('-', '_')
801 for p
in self
.plugins
:
803 meth
= p
.__getattribute
__("cmd_"+command
)
804 except AttributeError:
808 default_meth
= self
.__getattribute
__("cmd_"+command
)
809 except AttributeError:
818 if "options" in meth
.__dict
__:
819 options
= meth
.options
820 if default_meth
and "options" in default_meth
.__dict
__:
821 options
+= default_meth
.options
822 flags
, args
= getopt
.getopt(args
, options
)
828 for p
in self
.plugins
:
830 meth
= p
.__getattribute
__("pre_"+command
)
831 except AttributeError:
838 for p
in self
.plugins
:
840 meth
= p
.__getattribute
__("post_"+command
)
841 except AttributeError:
845 except (TypeError, getopt
.GetoptError
):
848 print "%s %s %s" % (sys
.argv
[0], command
, meth
.__doc
__)
850 print >> sys
.stderr
, e
852 except AttributeError: