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