README: Add a "features" section
[yap.git] / yap / yap.py
blobe7e709beef54963884af4a7f31eb0e4b58be44b2
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 = dict()
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 k, cls in glbls.items():
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)
43 for func in dir(x):
44 if not func.startswith('cmd_'):
45 continue
46 if func in self.overrides:
47 print >>sys.stderr, "Plugin %s overrides already overridden function %s. Disabling" % (p, func)
48 break
49 self.plugins[k] = x
51 def _add_new_file(self, file):
52 repo = get_output('git rev-parse --git-dir')[0]
53 dir = os.path.join(repo, 'yap')
54 try:
55 os.mkdir(dir)
56 except OSError:
57 pass
58 files = self._get_new_files()
59 files.append(file)
60 path = os.path.join(dir, 'new-files')
61 pickle.dump(files, open(path, 'w'))
63 def _get_new_files(self):
64 repo = get_output('git rev-parse --git-dir')[0]
65 path = os.path.join(repo, 'yap', 'new-files')
66 try:
67 files = pickle.load(file(path))
68 except IOError:
69 files = []
71 x = []
72 for f in files:
73 # if f in the index
74 if get_output("git ls-files --cached '%s'" % f) != []:
75 continue
76 x.append(f)
77 return x
79 def _remove_new_file(self, file):
80 files = self._get_new_files()
81 files = filter(lambda x: x != file, files)
83 repo = get_output('git rev-parse --git-dir')[0]
84 path = os.path.join(repo, 'yap', 'new-files')
85 try:
86 pickle.dump(files, open(path, 'w'))
87 except IOError:
88 pass
90 def _clear_new_files(self):
91 repo = get_output('git rev-parse --git-dir')[0]
92 path = os.path.join(repo, 'yap', 'new-files')
93 os.unlink(path)
95 def _assert_file_exists(self, file):
96 if not os.access(file, os.R_OK):
97 raise YapError("No such file: %s" % file)
99 def _get_staged_files(self):
100 if run_command("git rev-parse HEAD"):
101 files = get_output("git ls-files --cached")
102 else:
103 files = get_output("git diff-index --cached --name-only HEAD")
104 unmerged = self._get_unmerged_files()
105 if unmerged:
106 unmerged = set(unmerged)
107 files = set(files).difference(unmerged)
108 files = list(files)
109 return files
111 def _get_unstaged_files(self):
112 files = get_output("git ls-files -m")
113 prefix = get_output("git rev-parse --show-prefix")
114 if prefix:
115 files = [ os.path.join(prefix[0], x) for x in files ]
116 files += self._get_new_files()
117 unmerged = self._get_unmerged_files()
118 if unmerged:
119 unmerged = set(unmerged)
120 files = set(files).difference(unmerged)
121 files = list(files)
122 return files
124 def _get_unmerged_files(self):
125 files = get_output("git ls-files -u")
126 files = [ x.replace('\t', ' ').split(' ')[3] for x in files ]
127 prefix = get_output("git rev-parse --show-prefix")
128 if prefix:
129 files = [ os.path.join(prefix[0], x) for x in files ]
130 return list(set(files))
132 def _delete_branch(self, branch, force):
133 current = get_output("git symbolic-ref HEAD")
134 if current:
135 current = current[0].replace('refs/heads/', '')
136 if branch == current:
137 raise YapError("Can't delete current branch")
139 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
140 if not ref:
141 raise YapError("No such branch: %s" % branch)
142 run_safely("git update-ref -d 'refs/heads/%s' '%s'" % (branch, ref[0]))
144 if not force:
145 name = get_output("git name-rev --name-only '%s'" % ref[0])[0]
146 if name == 'undefined':
147 run_command("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
148 raise YapError("Refusing to delete leaf branch (use -f to force)")
149 def _get_pager_cmd(self):
150 if 'YAP_PAGER' in os.environ:
151 return os.environ['YAP_PAGER']
152 elif 'GIT_PAGER' in os.environ:
153 return os.environ['GIT_PAGER']
154 elif 'PAGER' in os.environ:
155 return os.environ['PAGER']
156 else:
157 return "more"
159 def _add_one(self, file):
160 self._assert_file_exists(file)
161 x = get_output("git ls-files '%s'" % file)
162 if x != []:
163 raise YapError("File '%s' already in repository" % file)
164 self._add_new_file(file)
166 def _rm_one(self, file):
167 self._assert_file_exists(file)
168 if get_output("git ls-files '%s'" % file) != []:
169 run_safely("git rm --cached '%s'" % file)
170 self._remove_new_file(file)
172 def _stage_one(self, file, allow_unmerged=False):
173 self._assert_file_exists(file)
174 prefix = get_output("git rev-parse --show-prefix")
175 if prefix:
176 tmp = os.path.normpath(os.path.join(prefix[0], file))
177 else:
178 tmp = file
179 if not allow_unmerged and tmp in self._get_unmerged_files():
180 raise YapError("Refusing to stage conflicted file: %s" % file)
181 run_safely("git update-index --add '%s'" % file)
183 def _unstage_one(self, file):
184 self._assert_file_exists(file)
185 if run_command("git rev-parse HEAD"):
186 rc = run_command("git update-index --force-remove '%s'" % file)
187 else:
188 rc = run_command("git diff-index -p HEAD '%s' | git apply -R --cached" % file)
189 if rc:
190 raise YapError("Failed to unstage")
192 def _revert_one(self, file):
193 self._assert_file_exists(file)
194 try:
195 self._unstage_one(file)
196 except YapError:
197 pass
198 run_safely("git checkout-index -u -f '%s'" % file)
200 def _parse_commit(self, commit):
201 lines = get_output("git cat-file commit '%s'" % commit)
202 commit = {}
204 mode = None
205 for l in lines:
206 if mode != 'commit' and l.strip() == "":
207 mode = 'commit'
208 commit['log'] = []
209 continue
210 if mode == 'commit':
211 commit['log'].append(l)
212 continue
214 x = l.split(' ')
215 k = x[0]
216 v = ' '.join(x[1:])
217 commit[k] = v
218 commit['log'] = '\n'.join(commit['log'])
219 return commit
221 def _check_commit(self, **flags):
222 if '-a' in flags and '-d' in flags:
223 raise YapError("Conflicting flags: -a and -d")
225 if '-d' not in flags and self._get_unstaged_files():
226 if '-a' not in flags and self._get_staged_files():
227 raise YapError("Staged and unstaged changes present. Specify what to commit")
228 os.system("git diff-files -p | git apply --cached")
229 for f in self._get_new_files():
230 self._stage_one(f)
232 def _do_uncommit(self):
233 commit = self._parse_commit("HEAD")
234 repo = get_output('git rev-parse --git-dir')[0]
235 dir = os.path.join(repo, 'yap')
236 try:
237 os.mkdir(dir)
238 except OSError:
239 pass
240 msg_file = os.path.join(dir, 'msg')
241 fd = file(msg_file, 'w')
242 print >>fd, commit['log']
243 fd.close()
245 tree = get_output("git rev-parse --verify HEAD^")
246 run_safely("git update-ref -m uncommit HEAD '%s'" % tree[0])
248 def _do_commit(self, msg=None):
249 tree = get_output("git write-tree")[0]
250 parent = get_output("git rev-parse --verify HEAD 2> /dev/null")
252 if os.environ.has_key('YAP_EDITOR'):
253 editor = os.environ['YAP_EDITOR']
254 elif os.environ.has_key('GIT_EDITOR'):
255 editor = os.environ['GIT_EDITOR']
256 elif os.environ.has_key('EDITOR'):
257 editor = os.environ['EDITOR']
258 else:
259 editor = "vi"
261 fd, tmpfile = tempfile.mkstemp("yap")
262 os.close(fd)
265 if msg is None:
266 repo = get_output('git rev-parse --git-dir')[0]
267 msg_file = os.path.join(repo, 'yap', 'msg')
268 if os.access(msg_file, os.R_OK):
269 fd1 = file(msg_file)
270 fd2 = file(tmpfile, 'w')
271 for l in fd1.xreadlines():
272 print >>fd2, l.strip()
273 fd2.close()
274 os.unlink(msg_file)
275 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
276 raise YapError("Editing commit message failed")
277 fd = file(tmpfile)
278 msg = fd.readlines()
279 msg = ''.join(msg)
281 msg = msg.strip()
282 if not msg:
283 raise YapError("Refusing to use empty commit message")
285 (fd_w, fd_r) = os.popen2("git stripspace > %s" % tmpfile)
286 print >>fd_w, msg,
287 fd_w.close()
288 fd_r.close()
290 if parent:
291 commit = get_output("git commit-tree '%s' -p '%s' < '%s'" % (tree, parent[0], tmpfile))
292 else:
293 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
295 os.unlink(tmpfile)
296 run_safely("git update-ref HEAD '%s'" % commit[0])
298 def _check_rebasing(self):
299 repo = get_output('git rev-parse --git-dir')[0]
300 dotest = os.path.join(repo, '.dotest')
301 if os.access(dotest, os.R_OK):
302 raise YapError("A git operation is in progress. Complete it first")
303 dotest = os.path.join(repo, '..', '.dotest')
304 if os.access(dotest, os.R_OK):
305 raise YapError("A git operation is in progress. Complete it first")
307 def _check_git(self):
308 if run_command("git rev-parse --git-dir"):
309 raise YapError("That command must be run from inside a git repository")
311 def _list_remotes(self):
312 remotes = get_output("git config --get-regexp '^remote.*.url'")
313 for x in remotes:
314 remote, url = x.split(' ')
315 remote = remote.replace('remote.', '')
316 remote = remote.replace('.url', '')
317 yield remote, url
319 def _unstage_all(self):
320 try:
321 run_safely("git read-tree -m HEAD")
322 except ShellError:
323 run_safely("git read-tree HEAD")
324 run_safely("git update-index -q --refresh")
326 def _get_tracking(self, current):
327 remote = get_output("git config branch.%s.remote" % current)
328 if not remote:
329 raise YapError("No tracking branch configured for '%s'" % current)
331 merge = get_output("git config branch.%s.merge" % current)
332 if not merge:
333 raise YapError("No tracking branch configured for '%s'" % current)
334 return remote[0], merge[0]
336 def __getattribute__(self, attr):
337 if attr.startswith("cmd_"):
338 meth = None
339 for p in self.plugins.values():
340 try:
341 meth = p.__getattribute__(attr)
342 break
343 except AttributeError:
344 continue
346 if meth:
347 return meth
348 return super(Yap, self).__getattribute__(attr)
350 def _call_base(self, method, *args, **flags):
351 base_method = super(Yap, self).__getattribute__(method)
352 return base_method(*args, **flags)
353 def _confirm_push(self, current, rhs, repo):
354 print "About to push local branch '%s' to '%s' on '%s'" % (current, rhs, repo)
355 print "Continue (y/n)? ",
356 sys.stdout.flush()
357 ans = sys.stdin.readline().strip()
359 if ans.lower() != 'y' and ans.lower() != 'yes':
360 raise YapError("Aborted.")
362 @short_help("make a local copy of an existing repository")
363 @long_help("""
364 The first argument is a URL to the existing repository. This can be an
365 absolute path if the repository is local, or a URL with the git://,
366 ssh://, or http:// schemes. By default, the directory used is the last
367 component of the URL, sans '.git'. This can be overridden by providing
368 a second argument.
369 """)
370 def cmd_clone(self, url, directory=None):
371 "<url> [directory]"
373 if '://' not in url and url[0] != '/':
374 url = os.path.join(os.getcwd(), url)
376 url = url.rstrip('/')
377 if directory is None:
378 directory = url.rsplit('/')[-1]
379 directory = directory.replace('.git', '')
381 try:
382 os.mkdir(directory)
383 except OSError:
384 raise YapError("Directory exists: %s" % directory)
385 os.chdir(directory)
386 self.cmd_init()
387 self.cmd_repo("origin", url)
388 self.cmd_fetch("origin")
390 branch = None
391 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
392 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
393 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
394 if get_output("git rev-parse %s" % b)[0] == hash:
395 branch = b
396 break
397 if branch is None:
398 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
399 branch = "refs/remotes/origin/master"
400 if branch is None:
401 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'")
402 branch = branch[0]
404 hash = get_output("git rev-parse %s" % branch)
405 assert hash
406 branch = branch.replace('refs/remotes/origin/', '')
407 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
408 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
409 self.cmd_revert(**{'-a': 1})
411 @short_help("turn a directory into a repository")
412 @long_help("""
413 Converts the current working directory into a repository. The primary
414 side-effect of this command is the creation of a '.git' subdirectory.
415 No files are added nor commits made.
416 """)
417 def cmd_init(self):
418 os.system("git init")
420 @short_help("add a new file to the repository")
421 @long_help("""
422 The arguments are the files to be added to the repository. Once added,
423 the files will show as "unstaged changes" in the output of 'status'. To
424 reverse the effects of this command, see 'rm'.
425 """)
426 def cmd_add(self, *files):
427 "<file>..."
428 self._check_git()
430 if not files:
431 raise TypeError
433 for f in files:
434 self._add_one(f)
435 self.cmd_status()
437 @short_help("delete a file from the repository")
438 @long_help("""
439 The arguments are the files to be removed from the current revision of
440 the repository. The files will still exist in any past commits that the
441 files may have been a part of. The file is not actually deleted, it is
442 just no longer tracked as part of the repository.
443 """)
444 def cmd_rm(self, *files):
445 "<file>..."
446 self._check_git()
447 if not files:
448 raise TypeError
450 for f in files:
451 self._rm_one(f)
452 self.cmd_status()
454 @short_help("stage changes in a file for commit")
455 @long_help("""
456 The arguments are the files to be staged. Staging changes is a way to
457 build up a commit when you do not want to commit all changes at once.
458 To commit only staged changes, use the '-d' flag to 'commit.' To
459 reverse the effects of this command, see 'unstage'. Once staged, the
460 files will show as "staged changes" in the output of 'status'.
461 """)
462 def cmd_stage(self, *files):
463 "<file>..."
464 self._check_git()
465 if not files:
466 raise TypeError
468 for f in files:
469 self._stage_one(f)
470 self.cmd_status()
472 @short_help("unstage changes in a file")
473 @long_help("""
474 The arguments are the files to be unstaged. Once unstaged, the files
475 will show as "unstaged changes" in the output of 'status'. The '-a'
476 flag can be used to unstage all staged changes at once.
477 """)
478 @takes_options("a")
479 def cmd_unstage(self, *files, **flags):
480 "[-a] | <file>..."
481 self._check_git()
482 if '-a' in flags:
483 self._unstage_all()
484 self.cmd_status()
485 return
487 if not files:
488 raise TypeError
490 for f in files:
491 self._unstage_one(f)
492 self.cmd_status()
494 @short_help("show files with staged and unstaged changes")
495 @long_help("""
496 Show the files in the repository with changes since the last commit,
497 categorized based on whether the changes are staged or not. A file may
498 appear under each heading if the same file has both staged and unstaged
499 changes.
500 """)
501 def cmd_status(self):
503 self._check_git()
504 branch = get_output("git symbolic-ref HEAD")
505 if branch:
506 branch = branch[0].replace('refs/heads/', '')
507 else:
508 branch = "DETACHED"
509 print "Current branch: %s" % branch
511 print "Files with staged changes:"
512 files = self._get_staged_files()
513 for f in files:
514 print "\t%s" % f
515 if not files:
516 print "\t(none)"
518 print "Files with unstaged changes:"
519 files = self._get_unstaged_files()
520 for f in files:
521 print "\t%s" % f
522 if not files:
523 print "\t(none)"
525 files = self._get_unmerged_files()
526 if files:
527 print "Files with conflicts:"
528 for f in files:
529 print "\t%s" % f
531 @short_help("remove uncommitted changes from a file (*)")
532 @long_help("""
533 The arguments are the files whose changes will be reverted. If the '-a'
534 flag is given, then all files will have uncommitted changes removed.
535 Note that there is no way to reverse this command short of manually
536 editing each file again.
537 """)
538 @takes_options("a")
539 def cmd_revert(self, *files, **flags):
540 "(-a | <file>)"
541 self._check_git()
542 if '-a' in flags:
543 self._unstage_all()
544 run_safely("git checkout-index -u -f -a")
545 self.cmd_status()
546 return
548 if not files:
549 raise TypeError
551 for f in files:
552 self._revert_one(f)
553 self.cmd_status()
555 @short_help("record changes to files as a new commit")
556 @long_help("""
557 Create a new commit recording changes since the last commit. If there
558 are only unstaged changes, those will be recorded. If there are only
559 staged changes, those will be recorded. Otherwise, you will have to
560 specify either the '-a' flag or the '-d' flag to commit all changes or
561 only staged changes, respectively. To reverse the effects of this
562 command, see 'uncommit'.
563 """)
564 @takes_options("adm:")
565 def cmd_commit(self, **flags):
566 "[-a | -d] [-m <msg>]"
567 self._check_git()
568 self._check_rebasing()
569 self._check_commit(**flags)
570 if not self._get_staged_files():
571 raise YapError("No changes to commit")
572 msg = flags.get('-m', None)
573 self._do_commit(msg)
574 self.cmd_status()
576 @short_help("reverse the actions of the last commit")
577 @long_help("""
578 Reverse the effects of the last 'commit' operation. The changes that
579 were part of the previous commit will show as "staged changes" in the
580 output of 'status'. This means that if no files were changed since the
581 last commit was created, 'uncommit' followed by 'commit' is a lossless
582 operation.
583 """)
584 def cmd_uncommit(self):
586 self._check_git()
587 self._do_uncommit()
588 self.cmd_status()
590 @short_help("report the current version of yap")
591 def cmd_version(self):
592 print "Yap version 0.1"
594 @short_help("show the changelog for particular versions or files")
595 @long_help("""
596 The arguments are the files with which to filter history. If none are
597 given, all changes are listed. Otherwise only commits that affected one
598 or more of the given files are listed. The -r option changes the
599 starting revision for traversing history. By default, history is listed
600 starting at HEAD.
601 """)
602 @takes_options("pr:")
603 def cmd_log(self, *paths, **flags):
604 "[-p] [-r <rev>] <path>..."
605 self._check_git()
606 rev = flags.get('-r', 'HEAD')
608 if '-p' in flags:
609 flags['-p'] = '-p'
611 if len(paths) == 1:
612 follow = "--follow"
613 else:
614 follow = ""
615 paths = ' '.join(paths)
616 os.system("git log -M -C %s %s '%s' -- %s"
617 % (follow, flags.get('-p', '--name-status'), rev, paths))
619 @short_help("show staged, unstaged, or all uncommitted changes")
620 @long_help("""
621 Show staged, unstaged, or all uncommitted changes. By default, all
622 changes are shown. The '-u' flag causes only unstaged changes to be
623 shown. The '-d' flag causes only staged changes to be shown.
624 """)
625 @takes_options("ud")
626 def cmd_diff(self, **flags):
627 "[ -u | -d ]"
628 self._check_git()
629 if '-u' in flags and '-d' in flags:
630 raise YapError("Conflicting flags: -u and -d")
632 pager = self._get_pager_cmd()
634 if '-u' in flags:
635 os.system("git diff-files -p | %s" % pager)
636 elif '-d' in flags:
637 os.system("git diff-index --cached -p HEAD | %s" % pager)
638 else:
639 os.system("git diff-index -p HEAD | %s" % pager)
641 @short_help("list, create, or delete branches")
642 @long_help("""
643 If no arguments are specified, a list of local branches is given. The
644 current branch is indicated by a "*" next to the name. If an argument
645 is given, it is taken as the name of a new branch to create. The branch
646 will start pointing at the current HEAD. See 'point' for details on
647 changing the revision of the new branch. Note that this command does
648 not switch the current working branch. See 'switch' for details on
649 changing the current working branch.
651 The '-d' flag can be used to delete local branches. If the delete
652 operation would remove the last branch reference to a given line of
653 history (colloquially referred to as "dangling commits"), yap will
654 report an error and abort. The '-f' flag can be used to force the delete
655 in spite of this.
656 """)
657 @takes_options("fd:")
658 def cmd_branch(self, branch=None, **flags):
659 "[ [-f] -d <branch> | <branch> ]"
660 self._check_git()
661 force = '-f' in flags
662 if '-d' in flags:
663 self._delete_branch(flags['-d'], force)
664 self.cmd_branch()
665 return
667 if branch is not None:
668 ref = get_output("git rev-parse --verify HEAD")
669 if not ref:
670 raise YapError("No branch point yet. Make a commit")
671 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
673 current = get_output("git symbolic-ref HEAD")
674 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
675 for b in branches:
676 if current and b == current[0]:
677 print "* ",
678 else:
679 print " ",
680 b = b.replace('refs/heads/', '')
681 print b
683 @short_help("change the current working branch")
684 @long_help("""
685 The argument is the name of the branch to make the current working
686 branch. This command will fail if there are uncommitted changes to any
687 files. Otherwise, the contents of the files in the working directory
688 are updated to reflect their state in the new branch. Additionally, any
689 future commits are added to the new branch instead of the previous line
690 of history.
691 """)
692 @takes_options("f")
693 def cmd_switch(self, branch, **flags):
694 "[-f] <branch>"
695 self._check_git()
696 self._check_rebasing()
697 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
698 if not ref:
699 raise YapError("No such branch: %s" % branch)
701 if '-f' not in flags:
702 if (self._get_staged_files()
703 or (self._get_unstaged_files()
704 and run_command("git update-index --refresh"))):
705 raise YapError("You have uncommitted changes. Use -f to continue anyway")
707 if self._get_unstaged_files() and self._get_staged_files():
708 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
710 staged = bool(self._get_staged_files())
712 run_command("git diff-files -p | git apply --cached")
713 for f in self._get_new_files():
714 self._stage_one(f)
716 idx = get_output("git write-tree")
717 new = get_output("git rev-parse refs/heads/%s" % branch)
718 readtree = "git read-tree --aggressive -u -m HEAD %s %s" % (idx[0], new[0])
719 if run_command(readtree):
720 run_command("git update-index --refresh")
721 if os.system(readtree):
722 raise YapError("Failed to switch")
723 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
725 if not staged:
726 self._unstage_all()
727 self.cmd_status()
729 @short_help("move the current branch to a different revision")
730 @long_help("""
731 The argument is the hash of the commit to which the current branch
732 should point, or alternately a branch or tag (a.k.a, "committish"). If
733 moving the branch would create "dangling commits" (see 'branch'), yap
734 will report an error and abort. The '-f' flag can be used to force the
735 operation in spite of this.
736 """)
737 @takes_options("f")
738 def cmd_point(self, where, **flags):
739 "[-f] <where>"
740 self._check_git()
741 self._check_rebasing()
743 head = get_output("git rev-parse --verify HEAD")
744 if not head:
745 raise YapError("No commit yet; nowhere to point")
747 ref = get_output("git rev-parse --verify '%s^{commit}'" % where)
748 if not ref:
749 raise YapError("Not a valid ref: %s" % where)
751 if self._get_unstaged_files() or self._get_staged_files():
752 raise YapError("You have uncommitted changes. Commit them first")
754 run_safely("git update-ref HEAD '%s'" % ref[0])
756 if '-f' not in flags:
757 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
758 if name == "undefined":
759 os.system("git update-ref HEAD '%s'" % head[0])
760 raise YapError("Pointing there will lose commits. Use -f to force")
762 try:
763 run_safely("git read-tree -u -m HEAD")
764 except ShellError:
765 run_safely("git read-tree HEAD")
766 run_safely("git checkout-index -u -f -a")
768 @short_help("alter history by dropping or amending commits")
769 @long_help("""
770 This command operates in two distinct modes, "amend" and "drop" mode.
771 In drop mode, the given commit is removed from the history of the
772 current branch, as though that commit never happened. By default the
773 commit used is HEAD.
775 In amend mode, the uncommitted changes present are merged into a
776 previous commit. This is useful for correcting typos or adding missed
777 files into past commits. By default the commit used is HEAD.
779 While rewriting history it is possible that conflicts will arise. If
780 this happens, the rewrite will pause and you will be prompted to resolve
781 the conflicts and stage them. Once that is done, you will run "yap
782 history continue." If instead you want the conflicting commit removed
783 from history (perhaps your changes supercede that commit) you can run
784 "yap history skip". Once the rewrite completes, your branch will be on
785 the same commit as when the rewrite started.
786 """)
787 def cmd_history(self, subcmd, *args):
788 "amend | drop <commit>"
789 self._check_git()
791 if subcmd not in ("amend", "drop", "continue", "skip"):
792 raise TypeError
794 resolvemsg = """
795 When you have resolved the conflicts run \"yap history continue\".
796 To skip the problematic patch, run \"yap history skip\"."""
798 if subcmd == "continue":
799 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
800 return
801 if subcmd == "skip":
802 os.system("git reset --hard")
803 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
804 return
806 if subcmd == "amend":
807 flags, args = getopt.getopt(args, "ad")
808 flags = dict(flags)
810 if len(args) > 1:
811 raise TypeError
812 if args:
813 commit = args[0]
814 else:
815 commit = "HEAD"
817 if run_command("git rev-parse --verify '%s'" % commit):
818 raise YapError("Not a valid commit: %s" % commit)
820 self._check_rebasing()
822 if subcmd == "amend":
823 self._check_commit(**flags)
824 if self._get_unstaged_files():
825 # XXX: handle unstaged changes better
826 raise YapError("Commit away changes that you aren't amending")
828 self._unstage_all()
830 start = get_output("git rev-parse HEAD")
831 stash = get_output("git stash create")
832 run_command("git reset --hard")
833 try:
834 fd, tmpfile = tempfile.mkstemp("yap")
835 try:
836 try:
837 os.close(fd)
838 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
839 if subcmd == "amend":
840 self.cmd_point(commit, **{'-f': True})
841 finally:
842 if subcmd == "amend":
843 if stash:
844 rc = os.system("git stash apply %s" % stash[0])
845 if rc:
846 self.cmd_point(start[0], **{'-f': True})
847 os.system("git stash apply %s" % stash[0])
848 raise YapError("Failed to apply stash")
849 stash = None
851 if subcmd == "amend":
852 self._do_uncommit()
853 self._check_commit(**{'-a': True})
854 self._do_commit()
855 else:
856 self.cmd_point("%s^" % commit, **{'-f': True})
858 stat = os.stat(tmpfile)
859 size = stat[6]
860 if size > 0:
861 run_safely("git update-index --refresh")
862 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
863 if (rc):
864 raise YapError("Failed to apply changes")
865 finally:
866 os.unlink(tmpfile)
867 finally:
868 if stash:
869 run_command("git stash apply %s" % stash[0])
870 self.cmd_status()
872 @short_help("show the changes introduced by a given commit")
873 @long_help("""
874 By default, the changes in the last commit are shown. To override this,
875 specify a hash, branch, or tag (committish). The hash of the commit,
876 the commit's author, log message, and a diff of the changes are shown.
877 """)
878 def cmd_show(self, commit="HEAD"):
879 "[commit]"
880 self._check_git()
881 os.system("git show '%s'" % commit)
883 @short_help("apply the changes in a given commit to the current branch")
884 @long_help("""
885 The argument is the hash, branch, or tag (committish) of the commit to
886 be applied. In general, it only makes sense to apply commits that
887 happened on another branch. The '-r' flag can be used to have the
888 changes in the given commit reversed from the current branch. In
889 general, this only makes sense for commits that happened on the current
890 branch.
891 """)
892 @takes_options("r")
893 def cmd_cherry_pick(self, commit, **flags):
894 "[-r] <commit>"
895 self._check_git()
896 if '-r' in flags:
897 os.system("git revert '%s'" % commit)
898 else:
899 os.system("git cherry-pick '%s'" % commit)
901 @short_help("list, add, or delete configured remote repositories")
902 @long_help("""
903 When invoked with no arguments, this command will show the list of
904 currently configured remote repositories, giving both the name and URL
905 of each. To add a new repository, give the desired name as the first
906 argument and the URL as the second. The '-d' flag can be used to remove
907 a previously added repository.
908 """)
909 @takes_options("d:")
910 def cmd_repo(self, name=None, url=None, **flags):
911 "[<name> <url> | -d <name>]"
912 self._check_git()
913 if name is not None and url is None:
914 raise TypeError
916 if '-d' in flags:
917 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
918 raise YapError("No such repository: %s" % flags['-d'])
919 os.system("git config --unset remote.%s.url" % flags['-d'])
920 os.system("git config --unset remote.%s.fetch" % flags['-d'])
922 if name:
923 if name in [ x[0] for x in self._list_remotes() ]:
924 raise YapError("Repository '%s' already exists" % flags['-d'])
925 os.system("git config remote.%s.url %s" % (name, url))
926 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
928 for remote, url in self._list_remotes():
929 print "%-20s %s" % (remote, url)
931 @short_help("send local commits to a remote repository (*)")
932 @long_help("""
933 When invoked with no arguments, the current branch is synchronized to
934 the tracking branch of the tracking remote. If no tracking remote is
935 specified, the repository will have to be specified on the command line.
936 In that case, the default is to push to a branch with the same name as
937 the current branch. This behavior can be overridden by giving a second
938 argument to specify the remote branch.
940 If the remote branch does not currently exist, the command will abort
941 unless the -c flag is provided. If the remote branch is not a direct
942 descendent of the local branch, the command will abort unless the -f
943 flag is provided. Forcing a push in this way can be problematic to
944 other users of the repository if they are not expecting it.
946 To delete a branch on the remote repository, use the -d flag.
947 """)
948 @takes_options("cdf")
949 def cmd_push(self, repo=None, rhs=None, **flags):
950 "[-c | -d] <repo>"
951 self._check_git()
952 if '-c' in flags and '-d' in flags:
953 raise TypeError
955 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
956 raise YapError("No such repository: %s" % repo)
958 current = get_output("git symbolic-ref HEAD")
959 if not current:
960 raise YapError("Not on a branch!")
962 self._check_rebasing()
964 current = current[0].replace('refs/heads/', '')
965 remote = get_output("git config branch.%s.remote" % current)
966 if repo is None and remote:
967 repo = remote[0]
969 if repo is None:
970 raise YapError("No tracking branch configured; specify destination repository")
972 if rhs is None and remote and remote[0] == repo:
973 merge = get_output("git config branch.%s.merge" % current)
974 if merge:
975 rhs = merge[0]
977 if rhs is None:
978 rhs = "refs/heads/%s" % current
980 if '-c' not in flags and '-d' not in flags:
981 if run_command("git rev-parse --verify refs/remotes/%s/%s"
982 % (repo, rhs.replace('refs/heads/', ''))):
983 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
984 if '-f' not in flags:
985 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo, rhs.replace('refs/heads/', '')))
986 base = get_output("git merge-base HEAD %s" % hash[0])
987 assert base
988 if base[0] != hash[0]:
989 raise YapError("Branch not up-to-date with remote. Update or use -f")
991 self._confirm_push(current, rhs, repo)
992 if '-f' in flags:
993 flags['-f'] = '-f'
995 if '-d' in flags:
996 lhs = ""
997 else:
998 lhs = "refs/heads/%s" % current
999 rc = os.system("git push %s %s %s:%s" % (flags.get('-f', ''), repo, lhs, rhs))
1000 if rc:
1001 raise YapError("Push failed.")
1003 @short_help("retrieve commits from a remote repository")
1004 @long_help("""
1005 When run with no arguments, the command will retrieve new commits from
1006 the remote tracking repository. Note that this does not in any way
1007 alter the current branch. For that, see "update". If a remote other
1008 than the tracking remote is desired, it can be specified as the first
1009 argument.
1010 """)
1011 def cmd_fetch(self, repo=None):
1012 "<repo>"
1013 self._check_git()
1014 current = get_output("git symbolic-ref HEAD")
1015 if not current:
1016 raise YapError("Not on a branch!")
1018 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
1019 raise YapError("No such repository: %s" % repo)
1020 if repo is None:
1021 current = current[0].replace('refs/heads/', '')
1022 remote = get_output("git config branch.%s.remote" % current)
1023 if remote:
1024 repo = remote[0]
1025 if repo is None:
1026 raise YapError("No tracking branch configured; specify a repository")
1027 os.system("git fetch %s" % repo)
1029 @short_help("update the current branch relative to its tracking branch")
1030 @long_help("""
1031 Updates the current branch relative to its remote tracking branch. This
1032 command requires that the current branch have a remote tracking branch
1033 configured. If any conflicts occur while applying your changes to the
1034 updated remote, the command will pause to allow you to fix them. Once
1035 that is done, run "update" with the "continue" subcommand. Alternately,
1036 the "skip" subcommand can be used to discard the conflicting changes.
1037 """)
1038 def cmd_update(self, subcmd=None):
1039 "[continue | skip]"
1040 self._check_git()
1041 if subcmd and subcmd not in ["continue", "skip"]:
1042 raise TypeError
1044 resolvemsg = """
1045 When you have resolved the conflicts run \"yap update continue\".
1046 To skip the problematic patch, run \"yap update skip\"."""
1048 if subcmd == "continue":
1049 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
1050 return
1051 if subcmd == "skip":
1052 os.system("git reset --hard")
1053 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
1054 return
1056 self._check_rebasing()
1057 if self._get_unstaged_files() or self._get_staged_files():
1058 raise YapError("You have uncommitted changes. Commit them first")
1060 current = get_output("git symbolic-ref HEAD")
1061 if not current:
1062 raise YapError("Not on a branch!")
1064 current = current[0].replace('refs/heads/', '')
1065 remote, merge = self._get_tracking(current)
1066 merge = merge.replace('refs/heads/', '')
1068 self.cmd_fetch(remote)
1069 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
1071 try:
1072 fd, tmpfile = tempfile.mkstemp("yap")
1073 os.close(fd)
1074 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
1075 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
1077 stat = os.stat(tmpfile)
1078 size = stat[6]
1079 if size > 0:
1080 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
1081 if (rc):
1082 raise YapError("Failed to apply changes")
1083 finally:
1084 os.unlink(tmpfile)
1086 @short_help("query and configure remote branch tracking")
1087 @long_help("""
1088 When invoked with no arguments, the command displays the tracking
1089 information for the current branch. To configure the tracking
1090 information, two arguments for the remote repository and remote branch
1091 are given. The tracking information is used to provide defaults for
1092 where to push local changes and from where to get updates to the branch.
1093 """)
1094 def cmd_track(self, repo=None, branch=None):
1095 "[<repo> <branch>]"
1096 self._check_git()
1098 current = get_output("git symbolic-ref HEAD")
1099 if not current:
1100 raise YapError("Not on a branch!")
1101 current = current[0].replace('refs/heads/', '')
1103 if repo is None and branch is None:
1104 repo, merge = self._get_tracking(current)
1105 merge = merge.replace('refs/heads/', '')
1106 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
1107 return
1109 if repo is None or branch is None:
1110 raise TypeError
1112 if repo not in [ x[0] for x in self._list_remotes() ]:
1113 raise YapError("No such repository: %s" % repo)
1115 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
1116 raise YapError("No such branch '%s' on repository '%s'" % (branch, repo))
1118 os.system("git config branch.%s.remote '%s'" % (current, repo))
1119 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
1120 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
1122 @short_help("mark files with conflicts as resolved")
1123 @long_help("""
1124 The arguments are the files to be marked resolved. When a conflict
1125 occurs while merging changes to a file, that file is marked as
1126 "unmerged." Until the file(s) with conflicts are marked resolved,
1127 commits cannot be made.
1128 """)
1129 def cmd_resolved(self, *args):
1130 "<file>..."
1131 self._check_git()
1132 if not files:
1133 raise TypeError
1135 for f in files:
1136 self._stage_one(f, True)
1137 self.cmd_status()
1139 @short_help("show information about loaded plugins")
1140 def cmd_plugins(self):
1142 if not self.plugins:
1143 print >>sys.stderr, "No plugins loaded."
1144 for k, v in self.plugins.items():
1145 doc = v.__doc__
1146 if doc is None:
1147 doc = "No description"
1148 print "%-20s%s" % (k, doc)
1149 first = True
1150 for func in dir(v):
1151 if not func.startswith('cmd_'):
1152 continue
1153 if first is True:
1154 print "\tOverrides:"
1155 first = False
1156 print "\t%s" % func
1158 def cmd_help(self, cmd=None):
1159 if cmd is not None:
1160 cmd = "cmd_" + cmd.replace('-', '_')
1161 try:
1162 attr = self.__getattribute__(cmd)
1163 except AttributeError:
1164 raise YapError("No such command: %s" % cmd)
1165 try:
1166 help = attr.long_help
1167 except AttributeError:
1168 attr = super(Yap, self).__getattribute__(cmd)
1169 try:
1170 help = attr.long_help
1171 except AttributeError:
1172 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
1174 print >>sys.stderr, "The '%s' command" % cmd
1175 print >>sys.stderr, "\tyap %s %s" % (cmd, attr.__doc__)
1176 print >>sys.stderr, "%s" % help
1177 return
1179 print >> sys.stderr, "Yet Another (Git) Porcelein"
1180 print >> sys.stderr
1182 for name in dir(self):
1183 if not name.startswith('cmd_'):
1184 continue
1185 attr = self.__getattribute__(name)
1186 if not callable(attr):
1187 continue
1189 try:
1190 short_msg = attr.short_help
1191 except AttributeError:
1192 try:
1193 default_meth = super(Yap, self).__getattribute__(name)
1194 short_msg = default_meth.short_help
1195 except AttributeError:
1196 continue
1198 name = name.replace('cmd_', '')
1199 name = name.replace('_', '-')
1200 print >> sys.stderr, "%-16s%s" % (name, short_msg)
1202 print >> sys.stderr
1203 print >> sys.stderr, "Commands provided by plugins:"
1204 for k, v in self.plugins.items():
1205 for name in dir(v):
1206 if not name.startswith('cmd_'):
1207 continue
1208 try:
1209 attr = self.__getattribute__(name)
1210 short_msg = attr.short_help
1211 except AttributeError:
1212 continue
1213 name = name.replace('cmd_', '')
1214 name = name.replace('_', '-')
1215 print >> sys.stderr, "%-8s(%s) %s" % (name, k, short_msg)
1217 print >> sys.stderr
1218 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
1220 def cmd_usage(self):
1221 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
1222 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 plugins version"
1224 def main(self, args):
1225 if len(args) < 1:
1226 self.cmd_usage()
1227 sys.exit(2)
1229 command = args[0]
1230 args = args[1:]
1232 if run_command("git --version"):
1233 print >>sys.stderr, "Failed to run git; is it installed?"
1234 sys.exit(1)
1236 debug = os.getenv('YAP_DEBUG')
1238 try:
1239 command = command.replace('-', '_')
1241 meth = self.__getattribute__("cmd_"+command)
1242 try:
1243 default_meth = super(Yap, self).__getattribute__("cmd_"+command)
1244 except AttributeError:
1245 default_meth = None
1247 if meth.__doc__ is not None:
1248 doc = meth.__doc__
1249 elif default_meth is not None:
1250 doc = default_meth.__doc__
1251 else:
1252 doc = ""
1254 try:
1255 options = ""
1256 if "options" in meth.__dict__:
1257 options = meth.options
1258 if default_meth and "options" in default_meth.__dict__:
1259 options += default_meth.options
1260 if options:
1261 flags, args = getopt.getopt(args, options)
1262 flags = dict(flags)
1263 else:
1264 flags = dict()
1266 # cast args to a mutable type. this lets the pre-hooks act as
1267 # filters on the arguments
1268 args = list(args)
1270 # invoke pre-hooks
1271 for p in self.plugins.values():
1272 try:
1273 pre_meth = p.__getattribute__("pre_"+command)
1274 except AttributeError:
1275 continue
1276 pre_meth(args, flags)
1278 meth(*args, **flags)
1280 # invoke post-hooks
1281 for p in self.plugins.values():
1282 try:
1283 meth = p.__getattribute__("post_"+command)
1284 except AttributeError:
1285 continue
1286 meth()
1288 except (TypeError, getopt.GetoptError):
1289 if debug:
1290 raise
1291 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1292 except YapError, e:
1293 if debug:
1294 raise
1295 print >> sys.stderr, e
1296 sys.exit(1)
1297 except AttributeError:
1298 if debug:
1299 raise
1300 self.cmd_usage()
1301 sys.exit(2)