cmd_log: use --follow by default
[yap.git] / yap / yap.py
blobb91b8f6e95ac24d5c60ffffd670489785deacca7
1 import sys
2 import os
3 import glob
4 import getopt
5 import pickle
6 import tempfile
8 from plugin import YapPlugin
9 from util import *
11 class ShellError(Exception):
12 def __init__(self, cmd, rc):
13 self.cmd = cmd
14 self.rc = rc
16 def __str__(self):
17 return "%s returned %d" % (self.cmd, self.rc)
19 class YapError(Exception):
20 def __init__(self, msg):
21 self.msg = msg
23 def __str__(self):
24 return self.msg
26 class Yap(object):
27 def __init__(self):
28 self.plugins = set()
29 self.overrides = []
30 plugindir = os.path.expanduser("~/.yap/plugins")
31 for p in glob.glob(os.path.join(plugindir, "*.py")):
32 glbls = {}
33 execfile(p, glbls)
34 for cls in glbls.values():
35 if not type(cls) == type:
36 continue
37 if not issubclass(cls, YapPlugin):
38 continue
39 if cls is YapPlugin:
40 continue
41 x = cls(self)
42 self.plugins.add(x)
44 for func in dir(x):
45 if not func.startswith('cmd_'):
46 continue
47 if func in self.overrides:
48 print >>sys.stderr, "Plugin %s overrides already overridden function %s. Disabling" % (p, func)
49 self.plugins.remove(x)
50 break
52 def _add_new_file(self, file):
53 repo = get_output('git rev-parse --git-dir')[0]
54 dir = os.path.join(repo, 'yap')
55 try:
56 os.mkdir(dir)
57 except OSError:
58 pass
59 files = self._get_new_files()
60 files.append(file)
61 path = os.path.join(dir, 'new-files')
62 pickle.dump(files, open(path, 'w'))
64 def _get_new_files(self):
65 repo = get_output('git rev-parse --git-dir')[0]
66 path = os.path.join(repo, 'yap', 'new-files')
67 try:
68 files = pickle.load(file(path))
69 except IOError:
70 files = []
72 x = []
73 for f in files:
74 # if f in the index
75 if get_output("git ls-files --cached '%s'" % f) != []:
76 continue
77 x.append(f)
78 return x
80 def _remove_new_file(self, file):
81 files = self._get_new_files()
82 files = filter(lambda x: x != file, files)
84 repo = get_output('git rev-parse --git-dir')[0]
85 path = os.path.join(repo, 'yap', 'new-files')
86 pickle.dump(files, open(path, 'w'))
88 def _clear_new_files(self):
89 repo = get_output('git rev-parse --git-dir')[0]
90 path = os.path.join(repo, 'yap', 'new-files')
91 os.unlink(path)
93 def _assert_file_exists(self, file):
94 if not os.access(file, os.R_OK):
95 raise YapError("No such file: %s" % file)
97 def _get_staged_files(self):
98 if run_command("git rev-parse HEAD"):
99 files = get_output("git ls-files --cached")
100 else:
101 files = get_output("git diff-index --cached --name-only HEAD")
102 unmerged = self._get_unmerged_files()
103 if unmerged:
104 unmerged = set(unmerged)
105 files = set(files).difference(unmerged)
106 files = list(files)
107 return files
109 def _get_unstaged_files(self):
110 files = get_output("git ls-files -m")
111 prefix = get_output("git rev-parse --show-prefix")
112 if prefix:
113 files = [ os.path.join(prefix[0], x) for x in files ]
114 files += self._get_new_files()
115 unmerged = self._get_unmerged_files()
116 if unmerged:
117 unmerged = set(unmerged)
118 files = set(files).difference(unmerged)
119 files = list(files)
120 return files
122 def _get_unmerged_files(self):
123 files = get_output("git ls-files -u")
124 files = [ x.replace('\t', ' ').split(' ')[3] for x in files ]
125 prefix = get_output("git rev-parse --show-prefix")
126 if prefix:
127 files = [ os.path.join(prefix[0], x) for x in files ]
128 return list(set(files))
130 def _delete_branch(self, branch, force):
131 current = get_output("git symbolic-ref HEAD")
132 if current:
133 current = current[0].replace('refs/heads/', '')
134 if branch == current:
135 raise YapError("Can't delete current branch")
137 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
138 if not ref:
139 raise YapError("No such branch: %s" % branch)
140 run_safely("git update-ref -d 'refs/heads/%s' '%s'" % (branch, ref[0]))
142 if not force:
143 name = get_output("git name-rev --name-only '%s'" % ref[0])[0]
144 if name == 'undefined':
145 run_command("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
146 raise YapError("Refusing to delete leaf branch (use -f to force)")
147 def _get_pager_cmd(self):
148 if 'YAP_PAGER' in os.environ:
149 return os.environ['YAP_PAGER']
150 elif 'GIT_PAGER' in os.environ:
151 return os.environ['GIT_PAGER']
152 elif 'PAGER' in os.environ:
153 return os.environ['PAGER']
154 else:
155 return "more"
157 def _add_one(self, file):
158 self._assert_file_exists(file)
159 x = get_output("git ls-files '%s'" % file)
160 if x != []:
161 raise YapError("File '%s' already in repository" % file)
162 self._add_new_file(file)
164 def _rm_one(self, file):
165 self._assert_file_exists(file)
166 if get_output("git ls-files '%s'" % file) != []:
167 run_safely("git rm --cached '%s'" % file)
168 self._remove_new_file(file)
170 def _stage_one(self, file, allow_unmerged=False):
171 self._assert_file_exists(file)
172 prefix = get_output("git rev-parse --show-prefix")
173 if prefix:
174 tmp = os.path.normpath(os.path.join(prefix[0], file))
175 else:
176 tmp = file
177 if not allow_unmerged and tmp in self._get_unmerged_files():
178 raise YapError("Refusing to stage conflicted file: %s" % file)
179 run_safely("git update-index --add '%s'" % file)
181 def _unstage_one(self, file):
182 self._assert_file_exists(file)
183 if run_command("git rev-parse HEAD"):
184 run_safely("git update-index --force-remove '%s'" % file)
185 else:
186 run_safely("git diff-index -p HEAD '%s' | git apply -R --cached" % file)
188 def _revert_one(self, file):
189 self._assert_file_exists(file)
190 self._unstage_one(file)
191 run_safely("git checkout-index -u -f '%s'" % file)
193 def _parse_commit(self, commit):
194 lines = get_output("git cat-file commit '%s'" % commit)
195 commit = {}
197 mode = None
198 for l in lines:
199 if mode != 'commit' and l.strip() == "":
200 mode = 'commit'
201 commit['log'] = []
202 continue
203 if mode == 'commit':
204 commit['log'].append(l)
205 continue
207 x = l.split(' ')
208 k = x[0]
209 v = ' '.join(x[1:])
210 commit[k] = v
211 commit['log'] = '\n'.join(commit['log'])
212 return commit
214 def _check_commit(self, **flags):
215 if '-a' in flags and '-d' in flags:
216 raise YapError("Conflicting flags: -a and -d")
218 if '-d' not in flags and self._get_unstaged_files():
219 if '-a' not in flags and self._get_staged_files():
220 raise YapError("Staged and unstaged changes present. Specify what to commit")
221 os.system("git diff-files -p | git apply --cached")
222 for f in self._get_new_files():
223 self._stage_one(f)
225 def _do_uncommit(self):
226 commit = self._parse_commit("HEAD")
227 repo = get_output('git rev-parse --git-dir')[0]
228 dir = os.path.join(repo, 'yap')
229 try:
230 os.mkdir(dir)
231 except OSError:
232 pass
233 msg_file = os.path.join(dir, 'msg')
234 fd = file(msg_file, 'w')
235 print >>fd, commit['log']
236 fd.close()
238 tree = get_output("git rev-parse --verify HEAD^")
239 run_safely("git update-ref -m uncommit HEAD '%s'" % tree[0])
241 def _do_commit(self, msg=None):
242 tree = get_output("git write-tree")[0]
243 parent = get_output("git rev-parse --verify HEAD 2> /dev/null")[0]
245 if os.environ.has_key('YAP_EDITOR'):
246 editor = os.environ['YAP_EDITOR']
247 elif os.environ.has_key('GIT_EDITOR'):
248 editor = os.environ['GIT_EDITOR']
249 elif os.environ.has_key('EDITOR'):
250 editor = os.environ['EDITOR']
251 else:
252 editor = "vi"
254 fd, tmpfile = tempfile.mkstemp("yap")
255 os.close(fd)
258 if msg is None:
259 repo = get_output('git rev-parse --git-dir')[0]
260 msg_file = os.path.join(repo, 'yap', 'msg')
261 if os.access(msg_file, os.R_OK):
262 fd1 = file(msg_file)
263 fd2 = file(tmpfile, 'w')
264 for l in fd1.xreadlines():
265 print >>fd2, l.strip()
266 fd2.close()
267 os.unlink(msg_file)
268 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
269 raise YapError("Editing commit message failed")
270 fd = file(tmpfile)
271 msg = fd.readlines()
272 msg = ''.join(msg)
274 msg = msg.strip()
275 if not msg:
276 raise YapError("Refusing to use empty commit message")
278 (fd_w, fd_r) = os.popen2("git stripspace > %s" % tmpfile)
279 print >>fd_w, msg,
280 fd_w.close()
281 fd_r.close()
283 if parent != 'HEAD':
284 commit = get_output("git commit-tree '%s' -p '%s' < '%s'" % (tree, parent, tmpfile))
285 else:
286 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
288 os.unlink(tmpfile)
289 run_safely("git update-ref HEAD '%s'" % commit[0])
291 def _check_rebasing(self):
292 repo = get_output('git rev-parse --git-dir')[0]
293 dotest = os.path.join(repo, '.dotest')
294 if os.access(dotest, os.R_OK):
295 raise YapError("A git operation is in progress. Complete it first")
296 dotest = os.path.join(repo, '..', '.dotest')
297 if os.access(dotest, os.R_OK):
298 raise YapError("A git operation is in progress. Complete it first")
300 def _check_git(self):
301 if run_command("git rev-parse --git-dir"):
302 raise YapError("That command must be run from inside a git repository")
304 def _list_remotes(self):
305 remotes = get_output("git config --get-regexp '^remote.*.url'")
306 for x in remotes:
307 remote, url = x.split(' ')
308 remote = remote.replace('remote.', '')
309 remote = remote.replace('.url', '')
310 yield remote, url
312 def _unstage_all(self):
313 try:
314 run_safely("git read-tree -m HEAD")
315 except ShellError:
316 run_safely("git read-tree HEAD")
317 run_safely("git update-index -q --refresh")
319 def _get_tracking(self, current):
320 remote = get_output("git config branch.%s.remote" % current)
321 if not remote:
322 raise YapError("No tracking branch configured for '%s'" % current)
324 merge = get_output("git config branch.%s.merge" % current)
325 if not merge:
326 raise YapError("No tracking branch configured for '%s'" % current)
327 return remote[0], merge
329 @short_help("make a local copy of an existing repository")
330 @long_help("""
331 The first argument is a URL to the existing repository. This can be an
332 absolute path if the repository is local, or a URL with the git://,
333 ssh://, or http:// schemes. By default, the directory used is the last
334 component of the URL, sans '.git'. This can be overridden by providing
335 a second argument.
336 """)
337 def cmd_clone(self, url, directory=None):
338 "<url> [directory]"
340 if '://' not in url and url[0] != '/':
341 url = os.path.join(os.getcwd(), url)
343 url = url.rstrip('/')
344 if directory is None:
345 directory = url.rsplit('/')[-1]
346 directory = directory.replace('.git', '')
348 try:
349 os.mkdir(directory)
350 except OSError:
351 raise YapError("Directory exists: %s" % directory)
352 os.chdir(directory)
353 self.cmd_init()
354 self.cmd_repo("origin", url)
355 self.cmd_fetch("origin")
357 branch = None
358 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
359 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
360 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
361 if get_output("git rev-parse %s" % b)[0] == hash:
362 branch = b
363 break
364 if branch is None:
365 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
366 branch = "refs/remotes/origin/master"
367 if branch is None:
368 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'")
369 branch = branch[0]
371 hash = get_output("git rev-parse %s" % branch)
372 assert hash
373 branch = branch.replace('refs/remotes/origin/', '')
374 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
375 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
376 self.cmd_revert(**{'-a': 1})
378 @short_help("turn a directory into a repository")
379 @long_help("""
380 Converts the current working directory into a repository. The primary
381 side-effect of this command is the creation of a '.git' subdirectory.
382 No files are added nor commits made.
383 """)
384 def cmd_init(self):
385 os.system("git init")
387 @short_help("add a new file to the repository")
388 @long_help("""
389 The arguments are the files to be added to the repository. Once added,
390 the files will show as "unstaged changes" in the output of 'status'. To
391 reverse the effects of this command, see 'rm'.
392 """)
393 def cmd_add(self, *files):
394 "<file>..."
395 self._check_git()
397 if not files:
398 raise TypeError
400 for f in files:
401 self._add_one(f)
402 self.cmd_status()
404 @short_help("delete a file from the repository")
405 @long_help("""
406 The arguments are the files to be removed from the current revision of
407 the repository. The files will still exist in any past commits that the
408 files may have been a part of. The file is not actually deleted, it is
409 just no longer tracked as part of the repository.
410 """)
411 def cmd_rm(self, *files):
412 "<file>..."
413 self._check_git()
414 if not files:
415 raise TypeError
417 for f in files:
418 self._rm_one(f)
419 self.cmd_status()
421 @short_help("stage changes in a file for commit")
422 @long_help("""
423 The arguments are the files to be staged. Staging changes is a way to
424 build up a commit when you do not want to commit all changes at once.
425 To commit only staged changes, use the '-d' flag to 'commit.' To
426 reverse the effects of this command, see 'unstage'. Once staged, the
427 files will show as "staged changes" in the output of 'status'.
428 """)
429 def cmd_stage(self, *files):
430 "<file>..."
431 self._check_git()
432 if not files:
433 raise TypeError
435 for f in files:
436 self._stage_one(f)
437 self.cmd_status()
439 @short_help("unstage changes in a file")
440 @long_help("""
441 The arguments are the files to be unstaged. Once unstaged, the files
442 will show as "unstaged changes" in the output of 'status'. The '-a'
443 flag can be used to unstage all staged changes at once.
444 """)
445 @takes_options("a")
446 def cmd_unstage(self, *files, **flags):
447 "[-a] | <file>..."
448 self._check_git()
449 if '-a' in flags:
450 self._unstage_all()
451 self.cmd_status()
452 return
454 if not files:
455 raise TypeError
457 for f in files:
458 self._unstage_one(f)
459 self.cmd_status()
461 @short_help("show files with staged and unstaged changes")
462 @long_help("""
463 Show the files in the repository with changes since the last commit,
464 categorized based on whether the changes are staged or not. A file may
465 appear under each heading if the same file has both staged and unstaged
466 changes.
467 """)
468 def cmd_status(self):
470 self._check_git()
471 branch = get_output("git symbolic-ref HEAD")
472 if branch:
473 branch = branch[0].replace('refs/heads/', '')
474 else:
475 branch = "DETACHED"
476 print "Current branch: %s" % branch
478 print "Files with staged changes:"
479 files = self._get_staged_files()
480 for f in files:
481 print "\t%s" % f
482 if not files:
483 print "\t(none)"
485 print "Files with unstaged changes:"
486 files = self._get_unstaged_files()
487 for f in files:
488 print "\t%s" % f
489 if not files:
490 print "\t(none)"
492 files = self._get_unmerged_files()
493 if files:
494 print "Files with conflicts:"
495 for f in files:
496 print "\t%s" % f
498 @short_help("remove uncommitted changes from a file (*)")
499 @long_help("""
500 The arguments are the files whose changes will be reverted. If the '-a'
501 flag is given, then all files will have uncommitted changes removed.
502 Note that there is no way to reverse this command short of manually
503 editing each file again.
504 """)
505 @takes_options("a")
506 def cmd_revert(self, *files, **flags):
507 "(-a | <file>)"
508 self._check_git()
509 if '-a' in flags:
510 self._unstage_all()
511 run_safely("git checkout-index -u -f -a")
512 self.cmd_status()
513 return
515 if not files:
516 raise TypeError
518 for f in files:
519 self._revert_one(f)
520 self.cmd_status()
522 @short_help("record changes to files as a new commit")
523 @long_help("""
524 Create a new commit recording changes since the last commit. If there
525 are only unstaged changes, those will be recorded. If there are only
526 staged changes, those will be recorded. Otherwise, you will have to
527 specify either the '-a' flag or the '-d' flag to commit all changes or
528 only staged changes, respectively. To reverse the effects of this
529 command, see 'uncommit'.
530 """)
531 @takes_options("adm:")
532 def cmd_commit(self, **flags):
533 "[-a | -d]"
534 self._check_git()
535 self._check_rebasing()
536 self._check_commit(**flags)
537 if not self._get_staged_files():
538 raise YapError("No changes to commit")
539 msg = flags.get('-m', None)
540 self._do_commit(msg)
541 self.cmd_status()
543 @short_help("reverse the actions of the last commit")
544 @long_help("""
545 Reverse the effects of the last 'commit' operation. The changes that
546 were part of the previous commit will show as "staged changes" in the
547 output of 'status'. This means that if no files were changed since the
548 last commit was created, 'uncommit' followed by 'commit' is a lossless
549 operation.
550 """)
551 def cmd_uncommit(self):
553 self._check_git()
554 self._do_uncommit()
555 self.cmd_status()
557 @short_help("report the current version of yap")
558 def cmd_version(self):
559 print "Yap version 0.1"
561 @short_help("show the changelog for particular versions or files")
562 @long_help("""
563 The arguments are the files with which to filter history. If none are
564 given, all changes are listed. Otherwise only commits that affected one
565 or more of the given files are listed. The -r option changes the
566 starting revision for traversing history. By default, history is listed
567 starting at HEAD.
568 """)
569 @takes_options("pr:")
570 def cmd_log(self, *paths, **flags):
571 "[-p] [-r <rev>] <path>..."
572 self._check_git()
573 rev = flags.get('-r', 'HEAD')
575 if '-p' in flags:
576 flags['-p'] = '-p'
578 if len(paths) == 1:
579 follow = "--follow"
580 else:
581 follow = ""
582 paths = ' '.join(paths)
583 os.system("git log -M -C %s %s '%s' -- %s"
584 % (follow, flags.get('-p', '--name-status'), rev, paths))
586 @short_help("show staged, unstaged, or all uncommitted changes")
587 @long_help("""
588 Show staged, unstaged, or all uncommitted changes. By default, all
589 changes are shown. The '-u' flag causes only unstaged changes to be
590 shown. The '-d' flag causes only staged changes to be shown.
591 """)
592 @takes_options("ud")
593 def cmd_diff(self, **flags):
594 "[ -u | -d ]"
595 self._check_git()
596 if '-u' in flags and '-d' in flags:
597 raise YapError("Conflicting flags: -u and -d")
599 pager = self._get_pager_cmd()
601 if '-u' in flags:
602 os.system("git diff-files -p | %s" % pager)
603 elif '-d' in flags:
604 os.system("git diff-index --cached -p HEAD | %s" % pager)
605 else:
606 os.system("git diff-index -p HEAD | %s" % pager)
608 @short_help("list, create, or delete branches")
609 @long_help("""
610 If no arguments are specified, a list of local branches is given. The
611 current branch is indicated by a "*" next to the name. If an argument
612 is given, it is taken as the name of a new branch to create. The branch
613 will start pointing at the current HEAD. See 'point' for details on
614 changing the revision of the new branch. Note that this command does
615 not switch the current working branch. See 'switch' for details on
616 changing the current working branch.
618 The '-d' flag can be used to delete local branches. If the delete
619 operation would remove the last branch reference to a given line of
620 history (colloquially referred to as "dangling commits"), yap will
621 report an error and abort. The '-f' flag can be used to force the delete
622 in spite of this.
623 """)
624 @takes_options("fd:")
625 def cmd_branch(self, branch=None, **flags):
626 "[ [-f] -d <branch> | <branch> ]"
627 self._check_git()
628 force = '-f' in flags
629 if '-d' in flags:
630 self._delete_branch(flags['-d'], force)
631 self.cmd_branch()
632 return
634 if branch is not None:
635 ref = get_output("git rev-parse --verify HEAD")
636 if not ref:
637 raise YapError("No branch point yet. Make a commit")
638 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
640 current = get_output("git symbolic-ref HEAD")
641 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
642 for b in branches:
643 if current and b == current[0]:
644 print "* ",
645 else:
646 print " ",
647 b = b.replace('refs/heads/', '')
648 print b
650 @short_help("change the current working branch")
651 @long_help("""
652 The argument is the name of the branch to make the current working
653 branch. This command will fail if there are uncommitted changes to any
654 files. Otherwise, the contents of the files in the working directory
655 are updated to reflect their state in the new branch. Additionally, any
656 future commits are added to the new branch instead of the previous line
657 of history.
658 """)
659 @takes_options("f")
660 def cmd_switch(self, branch, **flags):
661 "[-f] <branch>"
662 self._check_git()
663 self._check_rebasing()
664 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
665 if not ref:
666 raise YapError("No such branch: %s" % branch)
668 if '-f' not in flags and (self._get_unstaged_files() or self._get_staged_files()):
669 raise YapError("You have uncommitted changes. Use -f to continue anyway")
671 if self._get_unstaged_files() and self._get_staged_files():
672 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
674 staged = bool(self._get_staged_files())
676 run_command("git diff-files -p | git apply --cached")
677 for f in self._get_new_files():
678 self._stage_one(f)
680 idx = get_output("git write-tree")
681 new = get_output("git rev-parse refs/heads/%s" % branch)
682 if os.system("git read-tree --aggressive -u -m HEAD %s %s" % (idx[0], new[0])):
683 raise YapError("Failed to switch")
684 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
686 if not staged:
687 self._unstage_all()
688 self.cmd_status()
690 @short_help("move the current branch to a different revision")
691 @long_help("""
692 The argument is the hash of the commit to which the current branch
693 should point, or alternately a branch or tag (a.k.a, "committish"). If
694 moving the branch would create "dangling commits" (see 'branch'), yap
695 will report an error and abort. The '-f' flag can be used to force the
696 operation in spite of this.
697 """)
698 @takes_options("f")
699 def cmd_point(self, where, **flags):
700 "[-f] <where>"
701 self._check_git()
702 self._check_rebasing()
704 head = get_output("git rev-parse --verify HEAD")
705 if not head:
706 raise YapError("No commit yet; nowhere to point")
708 ref = get_output("git rev-parse --verify '%s'" % where)
709 if not ref:
710 raise YapError("Not a valid ref: %s" % where)
712 if self._get_unstaged_files() or self._get_staged_files():
713 raise YapError("You have uncommitted changes. Commit them first")
715 type = get_output("git cat-file -t '%s'" % ref[0])
716 if type and type[0] == "tag":
717 tag = get_output("git cat-file tag '%s'" % ref[0])
718 ref[0] = tag[0].split(' ')[1]
720 run_safely("git update-ref HEAD '%s'" % ref[0])
722 if '-f' not in flags:
723 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
724 if name == "undefined":
725 os.system("git update-ref HEAD '%s'" % head[0])
726 raise YapError("Pointing there will lose commits. Use -f to force")
728 try:
729 run_safely("git read-tree -u -m HEAD")
730 except ShellError:
731 run_safely("git read-tree HEAD")
732 run_safely("git checkout-index -u -f -a")
734 @short_help("alter history by dropping or amending commits")
735 @long_help("""
736 This command operates in two distinct modes, "amend" and "drop" mode.
737 In drop mode, the given commit is removed from the history of the
738 current branch, as though that commit never happened. By default the
739 commit used is HEAD.
741 In amend mode, the uncommitted changes present are merged into a
742 previous commit. This is useful for correcting typos or adding missed
743 files into past commits. By default the commit used is HEAD.
745 While rewriting history it is possible that conflicts will arise. If
746 this happens, the rewrite will pause and you will be prompted to resolve
747 the conflicts and stage them. Once that is done, you will run "yap
748 history continue." If instead you want the conflicting commit removed
749 from history (perhaps your changes supercede that commit) you can run
750 "yap history skip". Once the rewrite completes, your branch will be on
751 the same commit as when the rewrite started.
752 """)
753 def cmd_history(self, subcmd, *args):
754 "amend | drop <commit>"
755 self._check_git()
757 if subcmd not in ("amend", "drop", "continue", "skip"):
758 raise TypeError
760 resolvemsg = """
761 When you have resolved the conflicts run \"yap history continue\".
762 To skip the problematic patch, run \"yap history skip\"."""
764 if subcmd == "continue":
765 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
766 return
767 if subcmd == "skip":
768 os.system("git reset --hard")
769 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
770 return
772 if subcmd == "amend":
773 flags, args = getopt.getopt(args, "ad")
774 flags = dict(flags)
776 if len(args) > 1:
777 raise TypeError
778 if args:
779 commit = args[0]
780 else:
781 commit = "HEAD"
783 if run_command("git rev-parse --verify '%s'" % commit):
784 raise YapError("Not a valid commit: %s" % commit)
786 self._check_rebasing()
788 if subcmd == "amend":
789 self._check_commit(**flags)
790 if self._get_unstaged_files():
791 # XXX: handle unstaged changes better
792 raise YapError("Commit away changes that you aren't amending")
794 self._unstage_all()
796 start = get_output("git rev-parse HEAD")
797 stash = get_output("git stash create")
798 run_command("git reset --hard")
799 try:
800 fd, tmpfile = tempfile.mkstemp("yap")
801 try:
802 try:
803 os.close(fd)
804 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
805 if subcmd == "amend":
806 self.cmd_point(commit, **{'-f': True})
807 finally:
808 if subcmd == "amend":
809 if stash:
810 rc = os.system("git stash apply %s" % stash[0])
811 if rc:
812 self.cmd_point(start[0], **{'-f': True})
813 os.system("git stash apply %s" % stash[0])
814 raise YapError("Failed to apply stash")
815 stash = None
817 if subcmd == "amend":
818 self._do_uncommit()
819 for f in self._get_unstaged_files():
820 self._stage_one(f)
821 self._do_commit()
822 else:
823 self.cmd_point("%s^" % commit, **{'-f': True})
825 stat = os.stat(tmpfile)
826 size = stat[6]
827 if size > 0:
828 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
829 if (rc):
830 raise YapError("Failed to apply changes")
831 finally:
832 os.unlink(tmpfile)
833 finally:
834 if stash:
835 run_command("git stash apply %s" % stash[0])
836 self.cmd_status()
838 @short_help("show the changes introduced by a given commit")
839 @long_help("""
840 By default, the changes in the last commit are shown. To override this,
841 specify a hash, branch, or tag (committish). The hash of the commit,
842 the commit's author, log message, and a diff of the changes are shown.
843 """)
844 def cmd_show(self, commit="HEAD"):
845 "[commit]"
846 self._check_git()
847 os.system("git show '%s'" % commit)
849 @short_help("apply the changes in a given commit to the current branch")
850 @long_help("""
851 The argument is the hash, branch, or tag (committish) of the commit to
852 be applied. In general, it only makes sense to apply commits that
853 happened on another branch. The '-r' flag can be used to have the
854 changes in the given commit reversed from the current branch. In
855 general, this only makes sense for commits that happened on the current
856 branch.
857 """)
858 @takes_options("r")
859 def cmd_cherry_pick(self, commit, **flags):
860 "[-r] <commit>"
861 self._check_git()
862 if '-r' in flags:
863 os.system("git revert '%s'" % commit)
864 else:
865 os.system("git cherry-pick '%s'" % commit)
867 @short_help("list, add, or delete configured remote repositories")
868 @long_help("""
869 When invoked with no arguments, this command will show the list of
870 currently configured remote repositories, giving both the name and URL
871 of each. To add a new repository, give the desired name as the first
872 argument and the URL as the second. The '-d' flag can be used to remove
873 a previously added repository.
874 """)
875 @takes_options("d:")
876 def cmd_repo(self, name=None, url=None, **flags):
877 "[<name> <url> | -d <name>]"
878 self._check_git()
879 if name is not None and url is None:
880 raise TypeError
882 if '-d' in flags:
883 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
884 raise YapError("No such repository: %s" % flags['-d'])
885 os.system("git config --unset remote.%s.url" % flags['-d'])
886 os.system("git config --unset remote.%s.fetch" % flags['-d'])
888 if name:
889 if name in [ x[0] for x in self._list_remotes() ]:
890 raise YapError("Repository '%s' already exists" % flags['-d'])
891 os.system("git config remote.%s.url %s" % (name, url))
892 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
894 for remote, url in self._list_remotes():
895 print "%-20s %s" % (remote, url)
897 @short_help("send local commits to a remote repository")
898 @long_help("""
899 When invoked with no arguments, the current branch is synchronized to
900 the tracking branch of the tracking remote. If no tracking remote is
901 specified, the repository will have to be specified on the command line.
902 In that case, the default is to push to a branch with the same name as
903 the current branch. This behavior can be overridden by giving a second
904 argument to specify the remote branch.
906 If the remote branch does not currently exist, the command will abort
907 unless the -c flag is provided. If the remote branch is not a direct
908 descendent of the local branch, the command will abort unless the -f
909 flag is provided. Forcing a push in this way can be problematic to
910 other users of the repository if they are not expecting it.
912 To delete a branch on the remote repository, use the -d flag.
913 """)
914 @takes_options("cdf")
915 def cmd_push(self, repo=None, rhs=None, **flags):
916 "[-c | -d] <repo>"
917 self._check_git()
918 if '-c' in flags and '-d' in flags:
919 raise TypeError
921 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
922 raise YapError("No such repository: %s" % repo)
924 current = get_output("git symbolic-ref HEAD")
925 if not current:
926 raise YapError("Not on a branch!")
928 self._check_rebasing()
930 current = current[0].replace('refs/heads/', '')
931 remote = get_output("git config branch.%s.remote" % current)
932 if repo is None and remote:
933 repo = remote[0]
935 if repo is None:
936 raise YapError("No tracking branch configured; specify destination repository")
938 if rhs is None and remote and remote[0] == repo:
939 merge = get_output("git config branch.%s.merge" % current)
940 if merge:
941 rhs = merge[0]
943 if rhs is None:
944 rhs = "refs/heads/%s" % current
946 if '-c' not in flags and '-d' not in flags:
947 if run_command("git rev-parse --verify refs/remotes/%s/%s"
948 % (repo, rhs.replace('refs/heads/', ''))):
949 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
950 if '-f' not in flags:
951 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo, rhs.replace('refs/heads/', '')))
952 base = get_output("git merge-base HEAD %s" % hash[0])
953 assert base
954 if base[0] != hash[0]:
955 raise YapError("Branch not up-to-date with remote. Update or use -f")
957 print "About to push local branch '%s' to '%s' on '%s'" % (current, rhs, repo)
958 print "Continue (y/n)? ",
959 sys.stdout.flush()
960 ans = sys.stdin.readline().strip()
962 if ans.lower() != 'y' and ans.lower() != 'yes':
963 raise YapError("Aborted.")
965 if '-f' in flags:
966 flags['-f'] = '-f'
968 if '-d' in flags:
969 lhs = ""
970 else:
971 lhs = "refs/heads/%s" % current
972 rc = os.system("git push %s %s %s:%s" % (flags.get('-f', ''), repo, lhs, rhs))
973 if rc:
974 raise YapError("Push failed.")
976 @short_help("retrieve commits from a remote repository")
977 @long_help("""
978 When run with no arguments, the command will retrieve new commits from
979 the remote tracking repository. Note that this does not in any way
980 alter the current branch. For that, see "update". If a remote other
981 than the tracking remote is desired, it can be specified as the first
982 argument.
983 """)
984 def cmd_fetch(self, repo=None):
985 "<repo>"
986 self._check_git()
987 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
988 raise YapError("No such repository: %s" % repo)
989 if repo is None:
990 remote = get_output("git config branch.%s.remote" % current)
991 repo = remote[0]
992 if repo is None:
993 raise YapError("No tracking branch configured; specify a repository")
994 os.system("git fetch %s" % repo)
996 @short_help("update the current branch relative to its tracking branch")
997 @long_help("""
998 Updates the current branch relative to its remote tracking branch. This
999 command requires that the current branch have a remote tracking branch
1000 configured. If any conflicts occur while applying your changes to the
1001 updated remote, the command will pause to allow you to fix them. Once
1002 that is done, run "update" with the "continue" subcommand. Alternately,
1003 the "skip" subcommand can be used to discard the conflicting changes.
1004 """)
1005 def cmd_update(self, subcmd=None):
1006 "[continue | skip]"
1007 self._check_git()
1008 if subcmd and subcmd not in ["continue", "skip"]:
1009 raise TypeError
1011 resolvemsg = """
1012 When you have resolved the conflicts run \"yap update continue\".
1013 To skip the problematic patch, run \"yap update skip\"."""
1015 if subcmd == "continue":
1016 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
1017 return
1018 if subcmd == "skip":
1019 os.system("git reset --hard")
1020 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
1021 return
1023 self._check_rebasing()
1024 if self._get_unstaged_files() or self._get_staged_files():
1025 raise YapError("You have uncommitted changes. Commit them first")
1027 current = get_output("git symbolic-ref HEAD")
1028 if not current:
1029 raise YapError("Not on a branch!")
1031 current = current[0].replace('refs/heads/', '')
1032 remote, merge = self._get_tracking(current)
1033 merge = merge[0].replace('refs/heads/', '')
1035 self.cmd_fetch(remote)
1036 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
1038 try:
1039 fd, tmpfile = tempfile.mkstemp("yap")
1040 os.close(fd)
1041 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
1042 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
1044 stat = os.stat(tmpfile)
1045 size = stat[6]
1046 if size > 0:
1047 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
1048 if (rc):
1049 raise YapError("Failed to apply changes")
1050 finally:
1051 os.unlink(tmpfile)
1053 @short_help("query and configure remote branch tracking")
1054 @long_help("""
1055 When invoked with no arguments, the command displays the tracking
1056 information for the current branch. To configure the tracking
1057 information, two arguments for the remote repository and remote branch
1058 are given. The tracking information is used to provide defaults for
1059 where to push local changes and from where to get updates to the branch.
1060 """)
1061 def cmd_track(self, repo=None, branch=None):
1062 "[<repo> <branch>]"
1063 self._check_git()
1065 current = get_output("git symbolic-ref HEAD")
1066 if not current:
1067 raise YapError("Not on a branch!")
1068 current = current[0].replace('refs/heads/', '')
1070 if repo is None and branch is None:
1071 repo, merge = self._get_tracking(current)
1072 merge = merge[0].replace('refs/heads/', '')
1073 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
1074 return
1076 if repo is None or branch is None:
1077 raise TypeError
1079 if repo not in [ x[0] for x in self._list_remotes() ]:
1080 raise YapError("No such repository: %s" % repo)
1082 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
1083 raise YapError("No such branch '%s' on repository '%s'" % (repo, branch))
1085 os.system("git config branch.%s.remote '%s'" % (current, repo))
1086 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
1087 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
1089 @short_help("mark files with conflicts as resolved")
1090 @long_help("""
1091 The arguments are the files to be marked resolved. When a conflict
1092 occurs while merging changes to a file, that file is marked as
1093 "unmerged." Until the file(s) with conflicts are marked resolved,
1094 commits cannot be made.
1095 """)
1096 def cmd_resolved(self, *args):
1097 "<file>..."
1098 self._check_git()
1099 if not files:
1100 raise TypeError
1102 for f in files:
1103 self._stage_one(f, True)
1104 self.cmd_status()
1106 def cmd_help(self, cmd=None):
1107 if cmd is not None:
1108 try:
1109 attr = self.__getattribute__("cmd_"+cmd.replace('-', '_'))
1110 except AttributeError:
1111 raise YapError("No such command: %s" % cmd)
1112 try:
1113 help = attr.long_help
1114 except AttributeError:
1115 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
1117 print >>sys.stderr, "The '%s' command" % cmd
1118 print >>sys.stderr, "\tyap %s %s" % (cmd, attr.__doc__)
1119 print >>sys.stderr, "%s" % help
1120 return
1122 print >> sys.stderr, "Yet Another (Git) Porcelein"
1123 print >> sys.stderr
1125 for name in dir(self):
1126 if not name.startswith('cmd_'):
1127 continue
1128 attr = self.__getattribute__(name)
1129 if not callable(attr):
1130 continue
1131 try:
1132 short_msg = attr.short_help
1133 except AttributeError:
1134 continue
1136 name = name.replace('cmd_', '')
1137 name = name.replace('_', '-')
1138 print >> sys.stderr, "%-16s%s" % (name, short_msg)
1139 print >> sys.stderr
1140 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
1142 def cmd_usage(self):
1143 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
1144 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"
1146 def main(self, args):
1147 if len(args) < 1:
1148 self.cmd_usage()
1149 sys.exit(2)
1151 command = args[0]
1152 args = args[1:]
1154 if run_command("git --version"):
1155 print >>sys.stderr, "Failed to run git; is it installed?"
1156 sys.exit(1)
1158 debug = os.getenv('YAP_DEBUG')
1160 try:
1161 command = command.replace('-', '_')
1163 meth = None
1164 for p in self.plugins:
1165 try:
1166 meth = p.__getattribute__("cmd_"+command)
1167 except AttributeError:
1168 continue
1170 try:
1171 default_meth = self.__getattribute__("cmd_"+command)
1172 except AttributeError:
1173 default_meth = None
1175 if meth is None:
1176 meth = default_meth
1177 if meth is None:
1178 raise AttributeError
1180 try:
1181 if "options" in meth.__dict__:
1182 options = meth.options
1183 if default_meth and "options" in default_meth.__dict__:
1184 options += default_meth.options
1185 flags, args = getopt.getopt(args, options)
1186 flags = dict(flags)
1187 else:
1188 flags = dict()
1190 # invoke pre-hooks
1191 for p in self.plugins:
1192 try:
1193 meth = p.__getattribute__("pre_"+command)
1194 except AttributeError:
1195 continue
1196 meth(*args, **flags)
1198 meth(*args, **flags)
1200 # invoke post-hooks
1201 for p in self.plugins:
1202 try:
1203 meth = p.__getattribute__("post_"+command)
1204 except AttributeError:
1205 continue
1206 meth()
1208 except (TypeError, getopt.GetoptError):
1209 if debug:
1210 raise
1211 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, meth.__doc__)
1212 except YapError, e:
1213 print >> sys.stderr, e
1214 sys.exit(1)
1215 except AttributeError:
1216 if debug:
1217 raise
1218 self.cmd_usage()
1219 sys.exit(2)