9 output
= fd
.readlines()
11 return [x
.strip() for x
in output
]
14 rc
= os
.system("%s > /dev/null 2>&1" % cmd
)
18 class YapError(Exception):
19 def __init__(self
, msg
):
25 def takes_options(options
):
27 func
.options
= options
31 def short_help(help_msg
):
33 func
.short_help
= help_msg
37 def long_help(help_msg
):
39 func
.long_help
= help_msg
44 def _add_new_file(self
, file):
45 repo
= get_output('git rev-parse --git-dir')[0]
46 dir = os
.path
.join(repo
, 'yap')
51 files
= self
._get
_new
_files
()
53 path
= os
.path
.join(dir, 'new-files')
54 pickle
.dump(files
, open(path
, 'w'))
56 def _get_new_files(self
):
57 repo
= get_output('git rev-parse --git-dir')[0]
58 path
= os
.path
.join(repo
, 'yap', 'new-files')
60 files
= pickle
.load(file(path
))
67 if get_output("git ls-files --cached '%s'" % f
) != []:
72 def _remove_new_file(self
, file):
73 files
= self
._get
_new
_files
()
74 files
= filter(lambda x
: x
!= file, files
)
76 repo
= get_output('git rev-parse --git-dir')[0]
77 path
= os
.path
.join(repo
, 'yap', 'new-files')
78 pickle
.dump(files
, open(path
, 'w'))
80 def _clear_new_files(self
):
81 repo
= get_output('git rev-parse --git-dir')[0]
82 path
= os
.path
.join(repo
, 'yap', 'new-files')
85 def _assert_file_exists(self
, file):
86 if not os
.access(file, os
.R_OK
):
87 raise YapError("No such file: %s" % file)
89 def _get_staged_files(self
):
90 if run_command("git rev-parse HEAD"):
91 files
= get_output("git ls-files --cached")
93 files
= get_output("git diff-index --cached --name-only HEAD")
96 def _get_unstaged_files(self
):
97 files
= self
._get
_new
_files
()
98 files
+= get_output("git ls-files -m")
101 def _delete_branch(self
, branch
, force
):
102 current
= get_output("git symbolic-ref HEAD")[0]
103 current
= current
.replace('refs/heads/', '')
104 if branch
== current
:
105 raise YapError("Can't delete current branch")
107 ref
= get_output("git rev-parse 'refs/heads/%s'" % branch
)
109 raise YapError("No such branch: %s" % branch
)
110 os
.system("git update-ref -d 'refs/heads/%s' '%s'" % (branch
, ref
[0]))
113 name
= get_output("git name-rev --name-only '%s'" % ref
[0])[0]
114 if name
== 'undefined':
115 os
.system("git update-ref 'refs/heads/%s' '%s'" % (branch
, ref
[0]))
116 raise YapError("Refusing to delete leaf branch (use -f to force)")
117 def _get_pager_cmd(self
):
118 if 'YAP_PAGER' in os
.environ
:
119 return os
.environ
['YAP_PAGER']
120 elif 'GIT_PAGER' in os
.environ
:
121 return os
.environ
['GIT_PAGER']
122 elif 'PAGER' in os
.environ
:
123 return os
.environ
['PAGER']
127 def _add_one(self
, file):
128 self
._assert
_file
_exists
(file)
129 x
= get_output("git ls-files '%s'" % file)
131 raise YapError("File '%s' already in repository" % file)
132 self
._add
_new
_file
(file)
134 def _rm_one(self
, file):
135 self
._assert
_file
_exists
(file)
136 if get_output("git ls-files '%s'" % file) != []:
137 os
.system("git rm --cached '%s'" % file)
138 self
._remove
_new
_file
(file)
140 def _stage_one(self
, file):
141 self
._assert
_file
_exists
(file)
142 os
.system("git update-index --add '%s'" % file)
144 def _unstage_one(self
, file):
145 self
._assert
_file
_exists
(file)
146 if run_command("git rev-parse HEAD"):
147 os
.system("git update-index --force-remove '%s'" % file)
149 os
.system("git diff-index -p HEAD '%s' | git apply -R --cached" % file)
151 def _revert_one(self
, file):
152 self
._assert
_file
_exists
(file)
153 self
._unstage
_one
(file)
154 os
.system("git checkout-index -f '%s'" % file)
156 def _parse_commit(self
, commit
):
157 lines
= get_output("git cat-file commit '%s'" % commit
)
162 if mode
!= 'commit' and l
.strip() == "":
167 commit
['log'].append(l
)
174 commit
['log'] = '\n'.join(commit
['log'])
177 def _check_commit(self
, **flags
):
178 if '-a' in flags
and '-d' in flags
:
179 raise YapError("Conflicting flags: -a and -d")
181 if '-d' not in flags
and self
._get
_unstaged
_files
():
182 if '-a' not in flags
and self
._get
_staged
_files
():
183 raise YapError("Staged and unstaged changes present. Specify what to commit")
184 os
.system("git diff-files -p | git apply --cached 2>/dev/null")
185 for f
in self
._get
_new
_files
():
188 def _do_uncommit(self
):
189 commit
= self
._parse
_commit
("HEAD")
190 repo
= get_output('git rev-parse --git-dir')[0]
191 dir = os
.path
.join(repo
, 'yap')
196 msg_file
= os
.path
.join(dir, 'msg')
197 fd
= file(msg_file
, 'w')
198 print >>fd
, commit
['log']
201 tree
= get_output("git rev-parse HEAD^")
202 os
.system("git update-ref -m uncommit HEAD '%s'" % tree
[0])
204 def _do_commit(self
):
205 tree
= get_output("git write-tree")[0]
206 parent
= get_output("git rev-parse HEAD 2> /dev/null")[0]
208 if os
.environ
.has_key('YAP_EDITOR'):
209 editor
= os
.environ
['YAP_EDITOR']
210 elif os
.environ
.has_key('GIT_EDITOR'):
211 editor
= os
.environ
['GIT_EDITOR']
212 elif os
.environ
.has_key('EDITOR'):
213 editor
= os
.environ
['EDITOR']
217 fd
, tmpfile
= tempfile
.mkstemp("yap")
220 repo
= get_output('git rev-parse --git-dir')[0]
221 msg_file
= os
.path
.join(repo
, 'yap', 'msg')
222 if os
.access(msg_file
, os
.R_OK
):
224 fd2
= file(tmpfile
, 'w')
225 for l
in fd1
.xreadlines():
226 print >>fd2
, l
.strip()
230 if os
.system("%s '%s'" % (editor
, tmpfile
)) != 0:
231 raise YapError("Editing commit message failed")
233 commit
= get_output("git commit-tree '%s' -p '%s' < '%s'" % (tree
, parent
, tmpfile
))
235 commit
= get_output("git commit-tree '%s' < '%s'" % (tree
, tmpfile
))
237 raise YapError("Commit failed; no log message?")
239 os
.system("git update-ref HEAD '%s'" % commit
[0])
241 def _check_rebasing(self
):
242 repo
= get_output('git rev-parse --git-dir')[0]
243 dotest
= os
.path
.join(repo
, '.dotest')
244 if os
.access(dotest
, os
.R_OK
):
245 raise YapError("A git operation is in progress. Complete it first")
246 dotest
= os
.path
.join(repo
, '..', '.dotest')
247 if os
.access(dotest
, os
.R_OK
):
248 raise YapError("A git operation is in progress. Complete it first")
250 def _list_remotes(self
):
251 remotes
= get_output("git config --get-regexp 'remote.*.url'")
253 remote
, url
= x
.split(' ')
254 remote
= remote
.replace('remote.', '')
255 remote
= remote
.replace('.url', '')
258 @short_help("make a local copy of an existing repository")
260 The first argument is a URL to the existing repository. This can be an
261 absolute path if the repository is local, or a URL with the git://,
262 ssh://, or http:// schemes. By default, the directory used is the last
263 component of the URL, sans '.git'. This can be overridden by providing
266 def cmd_clone(self
, url
, directory
=""):
268 # XXX: implement in terms of init + remote add + fetch
269 os
.system("git clone '%s' %s" % (url
, directory
))
271 @short_help("turn a directory into a repository")
273 Converts the current working directory into a repository. The primary
274 side-effect of this command is the creation of a '.git' subdirectory.
275 No files are added nor commits made.
278 os
.system("git init")
280 @short_help("add a new file to the repository")
282 The arguments are the files to be added to the repository. Once added,
283 the files will show as "unstaged changes" in the output of 'status'. To
284 reverse the effects of this command, see 'rm'.
286 def cmd_add(self
, *files
):
295 @short_help("delete a file from the repository")
297 The arguments are the files to be removed from the current revision of
298 the repository. The files will still exist in any past commits that the
299 file may have been a part of. The file is not actually deleted, it is
300 just no longer tracked as part of the repository.
302 def cmd_rm(self
, *files
):
311 @short_help("stage changes in a file for commit")
313 The arguments are the files to be staged. Staging changes is a way to
314 build up a commit when you do not want to commit all changes at once.
315 To commit only staged changes, use the '-d' flag to 'commit.' To
316 reverse the effects of this command, see 'unstage'. Once staged, the
317 files will show as "staged changes" in the output of 'status'.
319 def cmd_stage(self
, *files
):
328 @short_help("unstage changes in a file")
330 The arguments are the files to be unstaged. Once unstaged, the files
331 will show as "unstaged changes" in the output of 'status'. The '-a'
332 flag can be used to unstage all staged changes at once.
335 def cmd_unstage(self
, *files
, **flags
):
338 os
.system("git read-tree HEAD")
349 @short_help("show files with staged and unstaged changes")
351 Show the files in the repository with changes since the last commit,
352 categorized based on whether the changes are staged or not. A file may
353 appear under each heading if the same file has both staged and unstaged
356 def cmd_status(self
):
357 branch
= get_output("git symbolic-ref HEAD")[0]
358 branch
= branch
.replace('refs/heads/', '')
359 print "Current branch: %s" % branch
361 print "Files with staged changes:"
362 files
= self
._get
_staged
_files
()
368 print "Files with unstaged changes:"
369 prefix
= get_output("git rev-parse --show-prefix")
370 files
= self
._get
_unstaged
_files
()
373 f
= os
.path
.join(prefix
[0], f
)
378 @short_help("remove uncommitted changes from a file (*)")
380 The arguments are the files whose changes will be reverted. If the '-a'
381 flag is given, then all files will have uncommitted changes removed.
382 Note that there is no way to reverse this command short of manually
383 editing each file again.
386 def cmd_revert(self
, *files
, **flags
):
389 os
.system("git read-tree HEAD")
390 os
.system("git checkout-index -f -a")
400 @short_help("record changes to files as a new commit")
402 Create a new commit recording changes since the last commit. If there
403 are only unstaged changes, those will be recorded. If there are only
404 staged changes, those will be recorder. Otherwise, you will have to
405 specify either the '-a' flag or the '-d' flag to commit all changes or
406 only staged changes, respectively. To reverse the effects of this
407 command, see 'uncommit'.
410 def cmd_commit(self
, **flags
):
411 self
._check
_rebasing
()
412 self
._check
_commit
(**flags
)
413 if not self
._get
_staged
_files
():
414 raise YapError("No changes to commit")
418 @short_help("reverse the actions of the last commit")
420 Reverse the effects of the last 'commit' operation. The changes that
421 were part of the previous commit will show as "staged changes" in the
422 output of 'status'. This means that if no files were changed since the
423 last commit was created, 'uncommit' followed by 'commit' is a lossless
426 def cmd_uncommit(self
):
430 @short_help("report the current version of yap")
431 def cmd_version(self
):
432 print "Yap version 0.1"
434 @short_help("show the changelog for particular versions or files")
436 The arguments are the files with which to filter history. If none are
437 given, all changes are listed. Otherwise only commits that affected one
438 or more of the given files are listed. The -r option changes the
439 starting revision for traversing history. By default, history is listed
443 def cmd_log(self
, *paths
, **flags
):
444 "[-r <rev>] <path>..."
445 rev
= flags
.get('-r', 'HEAD')
446 paths
= ' '.join(paths
)
447 os
.system("git log --name-status '%s' -- %s" % (rev
, paths
))
449 @short_help("show staged, unstaged, or all uncommitted changes")
451 Show staged, unstaged, or all uncommitted changes. By default, all
452 changes are shown. The '-u' flag causes only unstaged changes to be
453 shown. The '-d' flag causes only staged changes to be shown.
456 def cmd_diff(self
, **flags
):
458 if '-u' in flags
and '-d' in flags
:
459 raise YapError("Conflicting flags: -u and -d")
461 pager
= self
._get
_pager
_cmd
()
464 os
.system("git diff-files -p | %s" % pager
)
466 os
.system("git diff-index --cached -p HEAD | %s" % pager
)
468 os
.system("git diff-index -p HEAD | %s" % pager
)
470 @short_help("list, create, or delete branches")
472 If no arguments are given, a list of local branches is given. The
473 current branch is indicated by a "*" next to the name. If an argument
474 is given, it is taken as the name of a new branch to create. The branch
475 will start pointing at the current HEAD. See 'point' for details on
476 changing the revision of the new branch. Note that this command does
477 not switch the current working branch. See 'switch' for details on
478 changing the current working branch.
480 The '-d' flag can be used to delete local branches. If the delete
481 operation would remove the last branch reference to a given line of
482 history (colloquially referred to as "dangling commits"), yap will
483 report an error and abort. The '-f' flag can be used to force the delete
486 @takes_options("fd:")
487 def cmd_branch(self
, branch
=None, **flags
):
488 "[ [-f] -d <branch> | <branch> ]"
489 force
= '-f' in flags
491 self
._delete
_branch
(flags
['-d'], force
)
495 if branch
is not None:
496 ref
= get_output("git rev-parse HEAD")
498 raise YapError("No branch point yet. Make a commit")
499 os
.system("git update-ref 'refs/heads/%s' '%s'" % (branch
, ref
[0]))
501 current
= get_output("git symbolic-ref HEAD")[0]
502 branches
= get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
508 b
= b
.replace('refs/heads/', '')
511 @short_help("change the current working branch")
513 The argument is the name of the branch to make the current working
514 branch. This command will fail if there are uncommitted changes to any
515 files. Otherwise, the contents of the files in the working directory
516 are updated to reflect their state in the new branch. Additionally, any
517 future commits are added to the new branch instead of the previous line
520 def cmd_switch(self
, branch
):
522 ref
= get_output("git rev-parse 'refs/heads/%s'" % branch
)
524 raise YapError("No such branch: %s" % branch
)
526 # XXX: support merging like git-checkout
527 if self
._get
_unstaged
_files
() or self
._get
_staged
_files
():
528 raise YapError("You have uncommitted changes. Commit them first")
530 os
.system("git symbolic-ref HEAD refs/heads/'%s'" % branch
)
531 os
.system("git read-tree HEAD")
532 os
.system("git checkout-index -f -a")
535 @short_help("move the current branch to a different revision")
537 The argument is the hash of the commit to which the current branch
538 should point, or alternately a branch or tag (a.k.a, "committish"). If
539 moving the branch would create "dangling commits" (see 'branch'), yap
540 will report an error and abort. The '-f' flag can be used to force the
541 operation in spite of this.
544 def cmd_point(self
, where
, **flags
):
546 head
= get_output("git rev-parse HEAD")
548 raise YapError("No commit yet; nowhere to point")
550 ref
= get_output("git rev-parse '%s'" % where
)
552 raise YapError("Not a valid ref: %s" % where
)
554 if self
._get
_unstaged
_files
() or self
._get
_staged
_files
():
555 raise YapError("You have uncommitted changes. Commit them first")
557 type = get_output("git cat-file -t '%s'" % ref
[0])
558 if type and type[0] == "tag":
559 tag
= get_output("git cat-file tag '%s'" % ref
[0])
560 ref
[0] = tag
[0].split(' ')[1]
562 os
.system("git update-ref HEAD '%s'" % ref
[0])
564 if '-f' not in flags
:
565 name
= get_output("git name-rev --name-only '%s'" % head
[0])[0]
566 if name
== "undefined":
567 os
.system("git update-ref HEAD '%s'" % head
[0])
568 raise YapError("Pointing there will lose commits. Use -f to force")
570 os
.system("git read-tree HEAD")
571 os
.system("git checkout-index -u -f -a")
573 @short_help("alter history by dropping or amending commits")
575 This command operates in two distinct modes, "amend" and "drop" mode.
576 In drop mode, the given commit is removed from the history of the
577 current branch, as though that commit never happened. By default the
580 In amend mode, the uncommitted changes present are merged into a
581 previous commit. This is useful for correcting typos or adding missed
582 files into past commits. By default the commit used is HEAD.
584 While rewriting history it is possible that conflicts will arise. If
585 this happens, the rewrite will pause and you will be prompted to resolve
586 the conflicts and staged them. Once that is done, you will run "yap
587 history continue." If instead you want the conflicting commit removed
588 from history (perhaps your changes supercede that commit) you can run
589 "yap history skip". Once the rewrite completes, your branch will be on
590 the same commit as when the rewrite started.
592 def cmd_history(self
, subcmd
, *args
):
593 "amend | drop <commit>"
595 if subcmd
not in ("amend", "drop", "continue", "skip"):
599 When you have resolved the conflicts run \"yap history continue\".
600 To skip the problematic patch, run \"yap history skip\"."""
602 if subcmd
== "continue":
603 os
.system("git am -3 -r --resolvemsg='%s'" % resolvemsg
)
606 os
.system("git reset --hard")
607 os
.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg
)
610 if subcmd
== "amend":
611 flags
, args
= getopt
.getopt(args
, "ad")
621 if run_command("git rev-parse --verify '%s'" % commit
):
622 raise YapError("Not a valid commit: %s" % commit
)
624 self
._check
_rebasing
()
626 if subcmd
== "amend":
627 self
._check
_commit
(**flags
)
629 stash
= get_output("git stash create")
630 run_command("git reset --hard")
632 fd
, tmpfile
= tempfile
.mkstemp("yap")
635 os
.system("git format-patch -k --stdout '%s' > %s" % (commit
, tmpfile
))
636 if subcmd
== "amend":
637 self
.cmd_point(commit
, **{'-f': True})
639 run_command("git stash apply --index %s" % stash
[0])
642 stash
= get_output("git stash create")
643 run_command("git reset --hard")
645 self
.cmd_point("%s^" % commit
, **{'-f': True})
647 stat
= os
.stat(tmpfile
)
650 rc
= os
.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg
, tmpfile
))
652 raise YapError("Failed to apply changes")
655 run_command("git stash apply %s" % stash
[0])
660 @short_help("show the changes introduced by a given commit")
662 By default, the changes in the last commit are shown. To override this,
663 specify a hash, branch, or tag (committish). The hash of the commit,
664 the commit's author, log message, and a diff of the changes are shown.
666 def cmd_show(self
, commit
="HEAD"):
668 os
.system("git show '%s'" % commit
)
670 @short_help("apply the changes in a given commit to the current branch")
672 The argument is the hash, branch, or tag (committish) of the commit to
673 be applied. In general, it only makes sense to apply commits that
674 happened on another branch. The '-r' flag can be used to have the
675 changes in the given commit reversed from the current branch. In
676 general, this only makes sense for commits that happened on the current
680 def cmd_cherry_pick(self
, commit
, **flags
):
683 os
.system("git revert '%s'" % commit
)
685 os
.system("git cherry-pick '%s'" % commit
)
687 @short_help("list, add, or delete configured remote repositories")
689 When invoked with no arguments, this command will show the list of
690 currently configured remote repositories, giving both the name and URL
691 of each. To add a new repository, give the desired name as the first
692 argument and the URL as the second. The '-d' flag can be used to remove
693 a previously added repository.
696 def cmd_repo(self
, name
=None, url
=None, **flags
):
697 "[<name> <url> | -d <name>]"
698 if name
is not None and url
is None:
702 if flags
['-d'] not in self
._list
_remotes
():
703 raise YapError("No such repository: %s" % flags
['-d'])
704 os
.system("git config --unset remote.%s.url" % flags
['-d'])
705 os
.system("git config --unset remote.%s.fetch" % flags
['-d'])
708 if flags
['-d'] in self
._list
_remotes
():
709 raise YapError("Repository '%s' already exists" % flags
['-d'])
710 os
.system("git config remote.%s.url %s" % (name
, url
))
711 os
.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name
, url
))
713 for remote
, url
in self
._list
_remotes
():
714 print "%s:\t\t%s" % (remote
, url
)
716 def cmd_help(self
, cmd
=None):
719 attr
= self
.__getattribute
__("cmd_"+cmd
)
720 except AttributeError:
721 raise YapError("No such command: %s" % cmd
)
723 help = attr
.long_help
724 except AttributeError:
725 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd
)
727 print >>sys
.stderr
, "The '%s' command" % cmd
728 print >>sys
.stderr
, "\tyap %s %s" % (cmd
, attr
.__doc
__)
729 print >>sys
.stderr
, "%s" % help
732 print >> sys
.stderr
, "Yet Another (Git) Porcelein"
735 for name
in dir(self
):
736 if not name
.startswith('cmd_'):
738 attr
= self
.__getattribute
__(name
)
739 if not callable(attr
):
742 short_msg
= attr
.short_help
743 except AttributeError:
746 name
= name
.replace('cmd_', '')
747 name
= name
.replace('_', '-')
748 print >> sys
.stderr
, "%-16s%s" % (name
, short_msg
)
750 print >> sys
.stderr
, "(*) Indicates that the command is not readily reversible"
753 print >> sys
.stderr
, "usage: %s <command>" % sys
.argv
[0]
754 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"
756 def main(self
, args
):
764 debug
= os
.getenv('YAP_DEBUG')
767 command
= command
.replace('-', '_')
768 meth
= self
.__getattribute
__("cmd_"+command
)
770 if "options" in meth
.__dict
__:
771 flags
, args
= getopt
.getopt(args
, meth
.options
)
777 except (TypeError, getopt
.GetoptError
):
780 print "%s %s %s" % (sys
.argv
[0], command
, meth
.__doc
__)
782 print >> sys
.stderr
, e
784 except AttributeError: