8 from plugin
import YapPlugin
12 output
= fd
.readlines()
14 return [x
.strip() for x
in output
]
17 rc
= os
.system("%s > /dev/null 2>&1" % cmd
)
21 class YapError(Exception):
22 def __init__(self
, msg
):
28 def takes_options(options
):
30 func
.options
= options
34 def short_help(help_msg
):
36 func
.short_help
= help_msg
40 def long_help(help_msg
):
42 func
.long_help
= help_msg
49 plugindir
= os
.path
.expanduser("~/.yap/plugins")
50 for p
in glob
.glob(os
.path
.join(plugindir
, "*.py")):
53 for cls
in glbls
.values():
54 if not type(cls
) == type:
56 if not issubclass(cls
, YapPlugin
):
61 # XXX: check for override overlap
64 def _add_new_file(self
, file):
65 repo
= get_output('git rev-parse --git-dir')[0]
66 dir = os
.path
.join(repo
, 'yap')
71 files
= self
._get
_new
_files
()
73 path
= os
.path
.join(dir, 'new-files')
74 pickle
.dump(files
, open(path
, 'w'))
76 def _get_new_files(self
):
77 repo
= get_output('git rev-parse --git-dir')[0]
78 path
= os
.path
.join(repo
, 'yap', 'new-files')
80 files
= pickle
.load(file(path
))
87 if get_output("git ls-files --cached '%s'" % f
) != []:
92 def _remove_new_file(self
, file):
93 files
= self
._get
_new
_files
()
94 files
= filter(lambda x
: x
!= file, files
)
96 repo
= get_output('git rev-parse --git-dir')[0]
97 path
= os
.path
.join(repo
, 'yap', 'new-files')
98 pickle
.dump(files
, open(path
, 'w'))
100 def _clear_new_files(self
):
101 repo
= get_output('git rev-parse --git-dir')[0]
102 path
= os
.path
.join(repo
, 'yap', 'new-files')
105 def _assert_file_exists(self
, file):
106 if not os
.access(file, os
.R_OK
):
107 raise YapError("No such file: %s" % file)
109 def _get_staged_files(self
):
110 if run_command("git rev-parse HEAD"):
111 files
= get_output("git ls-files --cached")
113 files
= get_output("git diff-index --cached --name-only HEAD")
116 def _get_unstaged_files(self
):
117 files
= self
._get
_new
_files
()
118 files
+= get_output("git ls-files -m")
121 def _delete_branch(self
, branch
, force
):
122 current
= get_output("git symbolic-ref HEAD")[0]
123 current
= current
.replace('refs/heads/', '')
124 if branch
== current
:
125 raise YapError("Can't delete current branch")
127 ref
= get_output("git rev-parse 'refs/heads/%s'" % branch
)
129 raise YapError("No such branch: %s" % branch
)
130 os
.system("git update-ref -d 'refs/heads/%s' '%s'" % (branch
, ref
[0]))
133 name
= get_output("git name-rev --name-only '%s'" % ref
[0])[0]
134 if name
== 'undefined':
135 os
.system("git update-ref 'refs/heads/%s' '%s'" % (branch
, ref
[0]))
136 raise YapError("Refusing to delete leaf branch (use -f to force)")
137 def _get_pager_cmd(self
):
138 if 'YAP_PAGER' in os
.environ
:
139 return os
.environ
['YAP_PAGER']
140 elif 'GIT_PAGER' in os
.environ
:
141 return os
.environ
['GIT_PAGER']
142 elif 'PAGER' in os
.environ
:
143 return os
.environ
['PAGER']
147 def _add_one(self
, file):
148 self
._assert
_file
_exists
(file)
149 x
= get_output("git ls-files '%s'" % file)
151 raise YapError("File '%s' already in repository" % file)
152 self
._add
_new
_file
(file)
154 def _rm_one(self
, file):
155 self
._assert
_file
_exists
(file)
156 if get_output("git ls-files '%s'" % file) != []:
157 os
.system("git rm --cached '%s'" % file)
158 self
._remove
_new
_file
(file)
160 def _stage_one(self
, file):
161 self
._assert
_file
_exists
(file)
162 os
.system("git update-index --add '%s'" % file)
164 def _unstage_one(self
, file):
165 self
._assert
_file
_exists
(file)
166 if run_command("git rev-parse HEAD"):
167 os
.system("git update-index --force-remove '%s'" % file)
169 os
.system("git diff-index -p HEAD '%s' | git apply -R --cached" % file)
171 def _revert_one(self
, file):
172 self
._assert
_file
_exists
(file)
173 self
._unstage
_one
(file)
174 os
.system("git checkout-index -f '%s'" % file)
176 def _parse_commit(self
, commit
):
177 lines
= get_output("git cat-file commit '%s'" % commit
)
182 if mode
!= 'commit' and l
.strip() == "":
187 commit
['log'].append(l
)
194 commit
['log'] = '\n'.join(commit
['log'])
197 def _check_commit(self
, **flags
):
198 if '-a' in flags
and '-d' in flags
:
199 raise YapError("Conflicting flags: -a and -d")
201 if '-d' not in flags
and self
._get
_unstaged
_files
():
202 if '-a' not in flags
and self
._get
_staged
_files
():
203 raise YapError("Staged and unstaged changes present. Specify what to commit")
204 os
.system("git diff-files -p | git apply --cached 2>/dev/null")
205 for f
in self
._get
_new
_files
():
208 if not self
._get
_staged
_files
():
209 raise YapError("No changes to commit")
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')
219 msg_file
= os
.path
.join(dir, 'msg')
220 fd
= file(msg_file
, 'w')
221 print >>fd
, commit
['log']
224 tree
= get_output("git rev-parse HEAD^")
225 os
.system("git update-ref -m uncommit HEAD '%s'" % tree
[0])
227 def _do_commit(self
):
228 tree
= get_output("git write-tree")[0]
229 parent
= get_output("git rev-parse HEAD 2> /dev/null")[0]
231 if os
.environ
.has_key('YAP_EDITOR'):
232 editor
= os
.environ
['YAP_EDITOR']
233 elif os
.environ
.has_key('GIT_EDITOR'):
234 editor
= os
.environ
['GIT_EDITOR']
235 elif os
.environ
.has_key('EDITOR'):
236 editor
= os
.environ
['EDITOR']
240 fd
, tmpfile
= tempfile
.mkstemp("yap")
243 repo
= get_output('git rev-parse --git-dir')[0]
244 msg_file
= os
.path
.join(repo
, 'yap', 'msg')
245 if os
.access(msg_file
, os
.R_OK
):
247 fd2
= file(tmpfile
, 'w')
248 for l
in fd1
.xreadlines():
249 print >>fd2
, l
.strip()
253 if os
.system("%s '%s'" % (editor
, tmpfile
)) != 0:
254 raise YapError("Editing commit message failed")
256 commit
= get_output("git commit-tree '%s' -p '%s' < '%s'" % (tree
, parent
, tmpfile
))
258 commit
= get_output("git commit-tree '%s' < '%s'" % (tree
, tmpfile
))
260 raise YapError("Commit failed; no log message?")
262 os
.system("git update-ref HEAD '%s'" % commit
[0])
264 def _check_rebasing(self
):
265 repo
= get_output('git rev-parse --git-dir')[0]
266 dotest
= os
.path
.join(repo
, '.dotest')
267 if os
.access(dotest
, os
.R_OK
):
268 raise YapError("A git operation is in progress. Complete it first")
269 dotest
= os
.path
.join(repo
, '..', '.dotest')
270 if os
.access(dotest
, os
.R_OK
):
271 raise YapError("A git operation is in progress. Complete it first")
273 def _list_remotes(self
):
274 remotes
= get_output("git config --get-regexp 'remote.*.url'")
276 remote
, url
= x
.split(' ')
277 remote
= remote
.replace('remote.', '')
278 remote
= remote
.replace('.url', '')
281 @short_help("make a local copy of an existing repository")
283 The first argument is a URL to the existing repository. This can be an
284 absolute path if the repository is local, or a URL with the git://,
285 ssh://, or http:// schemes. By default, the directory used is the last
286 component of the URL, sans '.git'. This can be overridden by providing
289 def cmd_clone(self
, url
, directory
=""):
291 # XXX: implement in terms of init + remote add + fetch
292 os
.system("git clone '%s' %s" % (url
, directory
))
294 @short_help("turn a directory into a repository")
296 Converts the current working directory into a repository. The primary
297 side-effect of this command is the creation of a '.git' subdirectory.
298 No files are added nor commits made.
301 os
.system("git init")
303 @short_help("add a new file to the repository")
305 The arguments are the files to be added to the repository. Once added,
306 the files will show as "unstaged changes" in the output of 'status'. To
307 reverse the effects of this command, see 'rm'.
309 def cmd_add(self
, *files
):
318 @short_help("delete a file from the repository")
320 The arguments are the files to be removed from the current revision of
321 the repository. The files will still exist in any past commits that the
322 file may have been a part of. The file is not actually deleted, it is
323 just no longer tracked as part of the repository.
325 def cmd_rm(self
, *files
):
334 @short_help("stage changes in a file for commit")
336 The arguments are the files to be staged. Staging changes is a way to
337 build up a commit when you do not want to commit all changes at once.
338 To commit only staged changes, use the '-d' flag to 'commit.' To
339 reverse the effects of this command, see 'unstage'. Once staged, the
340 files will show as "staged changes" in the output of 'status'.
342 def cmd_stage(self
, *files
):
351 @short_help("unstage changes in a file")
353 The arguments are the files to be unstaged. Once unstaged, the files
354 will show as "unstaged changes" in the output of 'status'. The '-a'
355 flag can be used to unstage all staged changes at once.
358 def cmd_unstage(self
, *files
, **flags
):
361 os
.system("git read-tree HEAD")
372 @short_help("show files with staged and unstaged changes")
374 Show the files in the repository with changes since the last commit,
375 categorized based on whether the changes are staged or not. A file may
376 appear under each heading if the same file has both staged and unstaged
379 def cmd_status(self
):
380 branch
= get_output("git symbolic-ref HEAD")[0]
381 branch
= branch
.replace('refs/heads/', '')
382 print "Current branch: %s" % branch
384 print "Files with staged changes:"
385 files
= self
._get
_staged
_files
()
391 print "Files with unstaged changes:"
392 prefix
= get_output("git rev-parse --show-prefix")
393 files
= self
._get
_unstaged
_files
()
396 f
= os
.path
.join(prefix
[0], f
)
401 @short_help("remove uncommitted changes from a file (*)")
403 The arguments are the files whose changes will be reverted. If the '-a'
404 flag is given, then all files will have uncommitted changes removed.
405 Note that there is no way to reverse this command short of manually
406 editing each file again.
409 def cmd_revert(self
, *files
, **flags
):
412 os
.system("git read-tree HEAD")
413 os
.system("git checkout-index -f -a")
423 @short_help("record changes to files as a new commit")
425 Create a new commit recording changes since the last commit. If there
426 are only unstaged changes, those will be recorded. If there are only
427 staged changes, those will be recorder. Otherwise, you will have to
428 specify either the '-a' flag or the '-d' flag to commit all changes or
429 only staged changes, respectively. To reverse the effects of this
430 command, see 'uncommit'.
433 def cmd_commit(self
, **flags
):
434 self
._check
_rebasing
()
435 self
._check
_commit
(**flags
)
439 @short_help("reverse the actions of the last commit")
441 Reverse the effects of the last 'commit' operation. The changes that
442 were part of the previous commit will show as "staged changes" in the
443 output of 'status'. This means that if no files were changed since the
444 last commit was created, 'uncommit' followed by 'commit' is a lossless
447 def cmd_uncommit(self
):
451 @short_help("report the current version of yap")
452 def cmd_version(self
):
453 print "Yap version 0.1"
455 @short_help("show the changelog for particular versions or files")
457 The arguments are the files with which to filter history. If none are
458 given, all changes are listed. Otherwise only commits that affected one
459 or more of the given files are listed. The -r option changes the
460 starting revision for traversing history. By default, history is listed
464 def cmd_log(self
, *paths
, **flags
):
465 "[-r <rev>] <path>..."
466 rev
= flags
.get('-r', 'HEAD')
467 paths
= ' '.join(paths
)
468 os
.system("git log --name-status '%s' -- %s" % (rev
, paths
))
470 @short_help("show staged, unstaged, or all uncommitted changes")
472 Show staged, unstaged, or all uncommitted changes. By default, all
473 changes are shown. The '-u' flag causes only unstaged changes to be
474 shown. The '-d' flag causes only staged changes to be shown.
477 def cmd_diff(self
, **flags
):
479 if '-u' in flags
and '-d' in flags
:
480 raise YapError("Conflicting flags: -u and -d")
482 pager
= self
._get
_pager
_cmd
()
484 os
.system("git update-index -q --refresh")
486 os
.system("git diff-files -p | %s" % pager
)
488 os
.system("git diff-index --cached -p HEAD | %s" % pager
)
490 os
.system("git diff-index -p HEAD | %s" % pager
)
492 @short_help("list, create, or delete branches")
494 If no arguments are given, a list of local branches is given. The
495 current branch is indicated by a "*" next to the name. If an argument
496 is given, it is taken as the name of a new branch to create. The branch
497 will start pointing at the current HEAD. See 'point' for details on
498 changing the revision of the new branch. Note that this command does
499 not switch the current working branch. See 'switch' for details on
500 changing the current working branch.
502 The '-d' flag can be used to delete local branches. If the delete
503 operation would remove the last branch reference to a given line of
504 history (colloquially referred to as "dangling commits"), yap will
505 report an error and abort. The '-f' flag can be used to force the delete
508 @takes_options("fd:")
509 def cmd_branch(self
, branch
=None, **flags
):
510 "[ [-f] -d <branch> | <branch> ]"
511 force
= '-f' in flags
513 self
._delete
_branch
(flags
['-d'], force
)
517 if branch
is not None:
518 ref
= get_output("git rev-parse HEAD")
520 raise YapError("No branch point yet. Make a commit")
521 os
.system("git update-ref 'refs/heads/%s' '%s'" % (branch
, ref
[0]))
523 current
= get_output("git symbolic-ref HEAD")[0]
524 branches
= get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
530 b
= b
.replace('refs/heads/', '')
533 @short_help("change the current working branch")
535 The argument is the name of the branch to make the current working
536 branch. This command will fail if there are uncommitted changes to any
537 files. Otherwise, the contents of the files in the working directory
538 are updated to reflect their state in the new branch. Additionally, any
539 future commits are added to the new branch instead of the previous line
542 def cmd_switch(self
, branch
):
544 ref
= get_output("git rev-parse 'refs/heads/%s'" % branch
)
546 raise YapError("No such branch: %s" % branch
)
548 # XXX: support merging like git-checkout
549 if self
._get
_unstaged
_files
() or self
._get
_staged
_files
():
550 raise YapError("You have uncommitted changes. Commit them first")
552 os
.system("git symbolic-ref HEAD refs/heads/'%s'" % branch
)
553 os
.system("git read-tree HEAD")
554 os
.system("git checkout-index -f -a")
557 @short_help("move the current branch to a different revision")
559 The argument is the hash of the commit to which the current branch
560 should point, or alternately a branch or tag (a.k.a, "committish"). If
561 moving the branch would create "dangling commits" (see 'branch'), yap
562 will report an error and abort. The '-f' flag can be used to force the
563 operation in spite of this.
566 def cmd_point(self
, where
, **flags
):
568 head
= get_output("git rev-parse HEAD")
570 raise YapError("No commit yet; nowhere to point")
572 ref
= get_output("git rev-parse '%s'" % where
)
574 raise YapError("Not a valid ref: %s" % where
)
576 if self
._get
_unstaged
_files
() or self
._get
_staged
_files
():
577 raise YapError("You have uncommitted changes. Commit them first")
579 type = get_output("git cat-file -t '%s'" % ref
[0])
580 if type and type[0] == "tag":
581 tag
= get_output("git cat-file tag '%s'" % ref
[0])
582 ref
[0] = tag
[0].split(' ')[1]
584 os
.system("git update-ref HEAD '%s'" % ref
[0])
586 if '-f' not in flags
:
587 name
= get_output("git name-rev --name-only '%s'" % head
[0])[0]
588 if name
== "undefined":
589 os
.system("git update-ref HEAD '%s'" % head
[0])
590 raise YapError("Pointing there will lose commits. Use -f to force")
592 os
.system("git read-tree HEAD")
593 os
.system("git checkout-index -f -a")
594 os
.system("git update-index --refresh")
596 @short_help("alter history by dropping or amending commits")
598 This command operates in two distinct modes, "amend" and "drop" mode.
599 In drop mode, the given commit is removed from the history of the
600 current branch, as though that commit never happened. By default the
603 In amend mode, the uncommitted changes present are merged into a
604 previous commit. This is useful for correcting typos or adding missed
605 files into past commits. By default the commit used is HEAD.
607 While rewriting history it is possible that conflicts will arise. If
608 this happens, the rewrite will pause and you will be prompted to resolve
609 the conflicts and staged them. Once that is done, you will run "yap
610 history continue." If instead you want the conflicting commit removed
611 from history (perhaps your changes supercede that commit) you can run
612 "yap history skip". Once the rewrite completes, your branch will be on
613 the same commit as when the rewrite started.
615 def cmd_history(self
, subcmd
, *args
):
616 "amend | drop <commit>"
618 if subcmd
not in ("amend", "drop", "continue", "skip"):
622 When you have resolved the conflicts run \"yap history continue\".
623 To skip the problematic patch, run \"yap history skip\"."""
625 if subcmd
== "continue":
626 os
.system("git am -3 -r --resolvemsg='%s'" % resolvemsg
)
629 os
.system("git reset --hard")
630 os
.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg
)
633 if subcmd
== "amend":
634 flags
, args
= getopt
.getopt(args
, "ad")
644 if run_command("git rev-parse --verify '%s'" % commit
):
645 raise YapError("Not a valid commit: %s" % commit
)
647 self
._check
_rebasing
()
649 if subcmd
== "amend":
650 self
._check
_commit
(**flags
)
652 stash
= get_output("git stash create")
653 run_command("git reset --hard")
655 if subcmd
== "amend" and not stash
:
656 raise YapError("Failed to stash; no changes?")
658 fd
, tmpfile
= tempfile
.mkstemp("yap")
661 os
.system("git format-patch -k --stdout '%s' > %s" % (commit
, tmpfile
))
662 if subcmd
== "amend":
663 self
.cmd_point(commit
, **{'-f': True})
664 run_command("git stash apply --index %s" % stash
[0])
667 stash
= get_output("git stash create")
668 run_command("git reset --hard")
670 self
.cmd_point("%s^" % commit
, **{'-f': True})
672 stat
= os
.stat(tmpfile
)
675 rc
= os
.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg
, tmpfile
))
677 raise YapError("Failed to apply changes")
680 run_command("git stash apply %s" % stash
[0])
685 @short_help("show the changes introduced by a given commit")
687 By default, the changes in the last commit are shown. To override this,
688 specify a hash, branch, or tag (committish). The hash of the commit,
689 the commit's author, log message, and a diff of the changes are shown.
691 def cmd_show(self
, commit
="HEAD"):
693 os
.system("git show '%s'" % commit
)
695 @short_help("apply the changes in a given commit to the current branch")
697 The argument is the hash, branch, or tag (committish) of the commit to
698 be applied. In general, it only makes sense to apply commits that
699 happened on another branch. The '-r' flag can be used to have the
700 changes in the given commit reversed from the current branch. In
701 general, this only makes sense for commits that happened on the current
705 def cmd_cherry_pick(self
, commit
, **flags
):
708 os
.system("git revert '%s'" % commit
)
710 os
.system("git cherry-pick '%s'" % commit
)
712 @short_help("list, add, or delete configured remote repositories")
714 When invoked with no arguments, this command will show the list of
715 currently configured remote repositories, giving both the name and URL
716 of each. To add a new repository, give the desired name as the first
717 argument and the URL as the second. The '-d' flag can be used to remove
718 a previously added repository.
721 def cmd_repo(self
, name
=None, url
=None, **flags
):
722 "[<name> <url> | -d <name>]"
723 if name
is not None and url
is None:
727 if flags
['-d'] not in self
._list
_remotes
():
728 raise YapError("No such repository: %s" % flags
['-d'])
729 os
.system("git config --unset remote.%s.url" % flags
['-d'])
730 os
.system("git config --unset remote.%s.fetch" % flags
['-d'])
733 if flags
['-d'] in self
._list
_remotes
():
734 raise YapError("Repository '%s' already exists" % flags
['-d'])
735 os
.system("git config remote.%s.url %s" % (name
, url
))
736 os
.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name
, url
))
738 for remote
, url
in self
._list
_remotes
():
739 print "%s:\t\t%s" % (remote
, url
)
741 def cmd_help(self
, cmd
=None):
744 attr
= self
.__getattribute
__("cmd_"+cmd
)
745 except AttributeError:
746 raise YapError("No such command: %s" % cmd
)
748 help = attr
.long_help
749 except AttributeError:
750 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd
)
752 print >>sys
.stderr
, "The '%s' command" % cmd
753 print >>sys
.stderr
, "\tyap %s %s" % (cmd
, attr
.__doc
__)
754 print >>sys
.stderr
, "%s" % help
757 print >> sys
.stderr
, "Yet Another (Git) Porcelein"
760 for name
in dir(self
):
761 if not name
.startswith('cmd_'):
763 attr
= self
.__getattribute
__(name
)
764 if not callable(attr
):
767 short_msg
= attr
.short_help
768 except AttributeError:
771 name
= name
.replace('cmd_', '')
772 name
= name
.replace('_', '-')
773 print >> sys
.stderr
, "%-16s%s" % (name
, short_msg
)
775 print >> sys
.stderr
, "(*) Indicates that the command is not readily reversible"
778 print >> sys
.stderr
, "usage: %s <command>" % sys
.argv
[0]
779 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"
781 def main(self
, args
):
789 debug
= os
.getenv('YAP_DEBUG')
792 command
= command
.replace('-', '_')
793 meth
= self
.__getattribute
__("cmd_"+command
)
795 if "options" in meth
.__dict
__:
796 flags
, args
= getopt
.getopt(args
, meth
.options
)
802 except (TypeError, getopt
.GetoptError
):
805 print "%s %s %s" % (sys
.argv
[0], command
, meth
.__doc
__)
807 print >> sys
.stderr
, e
809 except AttributeError: