cmd_switch: show failure message from read-tree
[yap.git] / yap / yap.py
blob8c1ad66f87b29fc6ee5db47b3d61fd63e10952f0
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 _list_remotes(self):
301 remotes = get_output("git config --get-regexp '^remote.*.url'")
302 for x in remotes:
303 remote, url = x.split(' ')
304 remote = remote.replace('remote.', '')
305 remote = remote.replace('.url', '')
306 yield remote, url
308 def _unstage_all(self):
309 try:
310 run_safely("git read-tree -m HEAD")
311 except ShellError:
312 run_safely("git read-tree HEAD")
313 run_safely("git update-index -q --refresh")
315 def _get_tracking(self, current):
316 remote = get_output("git config branch.%s.remote" % current)
317 if not remote:
318 raise YapError("No tracking branch configured for '%s'" % current)
320 merge = get_output("git config branch.%s.merge" % current)
321 if not merge:
322 raise YapError("No tracking branch configured for '%s'" % current)
323 return remote[0], merge
325 @short_help("make a local copy of an existing repository")
326 @long_help("""
327 The first argument is a URL to the existing repository. This can be an
328 absolute path if the repository is local, or a URL with the git://,
329 ssh://, or http:// schemes. By default, the directory used is the last
330 component of the URL, sans '.git'. This can be overridden by providing
331 a second argument.
332 """)
333 def cmd_clone(self, url, directory=None):
334 "<url> [directory]"
336 if '://' not in url and url[0] != '/':
337 url = os.path.join(os.getcwd(), url)
339 url = url.rstrip('/')
340 if directory is None:
341 directory = url.rsplit('/')[-1]
342 directory = directory.replace('.git', '')
344 try:
345 os.mkdir(directory)
346 except OSError:
347 raise YapError("Directory exists: %s" % directory)
348 os.chdir(directory)
349 self.cmd_init()
350 self.cmd_repo("origin", url)
351 self.cmd_fetch("origin")
353 branch = None
354 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
355 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
356 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
357 if get_output("git rev-parse %s" % b)[0] == hash:
358 branch = b
359 break
360 if branch is None:
361 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
362 branch = "refs/remotes/origin/master"
363 if branch is None:
364 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'")
365 branch = branch[0]
367 hash = get_output("git rev-parse %s" % branch)
368 assert hash
369 branch = branch.replace('refs/remotes/origin/', '')
370 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
371 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
372 self.cmd_revert(**{'-a': 1})
374 @short_help("turn a directory into a repository")
375 @long_help("""
376 Converts the current working directory into a repository. The primary
377 side-effect of this command is the creation of a '.git' subdirectory.
378 No files are added nor commits made.
379 """)
380 def cmd_init(self):
381 os.system("git init")
383 @short_help("add a new file to the repository")
384 @long_help("""
385 The arguments are the files to be added to the repository. Once added,
386 the files will show as "unstaged changes" in the output of 'status'. To
387 reverse the effects of this command, see 'rm'.
388 """)
389 def cmd_add(self, *files):
390 "<file>..."
391 if not files:
392 raise TypeError
394 for f in files:
395 self._add_one(f)
396 self.cmd_status()
398 @short_help("delete a file from the repository")
399 @long_help("""
400 The arguments are the files to be removed from the current revision of
401 the repository. The files will still exist in any past commits that the
402 files may have been a part of. The file is not actually deleted, it is
403 just no longer tracked as part of the repository.
404 """)
405 def cmd_rm(self, *files):
406 "<file>..."
407 if not files:
408 raise TypeError
410 for f in files:
411 self._rm_one(f)
412 self.cmd_status()
414 @short_help("stage changes in a file for commit")
415 @long_help("""
416 The arguments are the files to be staged. Staging changes is a way to
417 build up a commit when you do not want to commit all changes at once.
418 To commit only staged changes, use the '-d' flag to 'commit.' To
419 reverse the effects of this command, see 'unstage'. Once staged, the
420 files will show as "staged changes" in the output of 'status'.
421 """)
422 def cmd_stage(self, *files):
423 "<file>..."
424 if not files:
425 raise TypeError
427 for f in files:
428 self._stage_one(f)
429 self.cmd_status()
431 @short_help("unstage changes in a file")
432 @long_help("""
433 The arguments are the files to be unstaged. Once unstaged, the files
434 will show as "unstaged changes" in the output of 'status'. The '-a'
435 flag can be used to unstage all staged changes at once.
436 """)
437 @takes_options("a")
438 def cmd_unstage(self, *files, **flags):
439 "[-a] | <file>..."
440 if '-a' in flags:
441 self._unstage_all()
442 self.cmd_status()
443 return
445 if not files:
446 raise TypeError
448 for f in files:
449 self._unstage_one(f)
450 self.cmd_status()
452 @short_help("show files with staged and unstaged changes")
453 @long_help("""
454 Show the files in the repository with changes since the last commit,
455 categorized based on whether the changes are staged or not. A file may
456 appear under each heading if the same file has both staged and unstaged
457 changes.
458 """)
459 def cmd_status(self):
461 branch = get_output("git symbolic-ref HEAD")
462 if branch:
463 branch = branch[0].replace('refs/heads/', '')
464 else:
465 branch = "DETACHED"
466 print "Current branch: %s" % branch
468 print "Files with staged changes:"
469 files = self._get_staged_files()
470 for f in files:
471 print "\t%s" % f
472 if not files:
473 print "\t(none)"
475 print "Files with unstaged changes:"
476 files = self._get_unstaged_files()
477 for f in files:
478 print "\t%s" % f
479 if not files:
480 print "\t(none)"
482 files = self._get_unmerged_files()
483 if files:
484 print "Files with conflicts:"
485 for f in files:
486 print "\t%s" % f
488 @short_help("remove uncommitted changes from a file (*)")
489 @long_help("""
490 The arguments are the files whose changes will be reverted. If the '-a'
491 flag is given, then all files will have uncommitted changes removed.
492 Note that there is no way to reverse this command short of manually
493 editing each file again.
494 """)
495 @takes_options("a")
496 def cmd_revert(self, *files, **flags):
497 "(-a | <file>)"
498 if '-a' in flags:
499 self._unstage_all()
500 run_safely("git checkout-index -u -f -a")
501 self.cmd_status()
502 return
504 if not files:
505 raise TypeError
507 for f in files:
508 self._revert_one(f)
509 self.cmd_status()
511 @short_help("record changes to files as a new commit")
512 @long_help("""
513 Create a new commit recording changes since the last commit. If there
514 are only unstaged changes, those will be recorded. If there are only
515 staged changes, those will be recorded. Otherwise, you will have to
516 specify either the '-a' flag or the '-d' flag to commit all changes or
517 only staged changes, respectively. To reverse the effects of this
518 command, see 'uncommit'.
519 """)
520 @takes_options("adm:")
521 def cmd_commit(self, **flags):
522 "[-a | -d]"
523 self._check_rebasing()
524 self._check_commit(**flags)
525 if not self._get_staged_files():
526 raise YapError("No changes to commit")
527 msg = flags.get('-m', None)
528 self._do_commit(msg)
529 self.cmd_status()
531 @short_help("reverse the actions of the last commit")
532 @long_help("""
533 Reverse the effects of the last 'commit' operation. The changes that
534 were part of the previous commit will show as "staged changes" in the
535 output of 'status'. This means that if no files were changed since the
536 last commit was created, 'uncommit' followed by 'commit' is a lossless
537 operation.
538 """)
539 def cmd_uncommit(self):
541 self._do_uncommit()
542 self.cmd_status()
544 @short_help("report the current version of yap")
545 def cmd_version(self):
546 print "Yap version 0.1"
548 @short_help("show the changelog for particular versions or files")
549 @long_help("""
550 The arguments are the files with which to filter history. If none are
551 given, all changes are listed. Otherwise only commits that affected one
552 or more of the given files are listed. The -r option changes the
553 starting revision for traversing history. By default, history is listed
554 starting at HEAD.
555 """)
556 @takes_options("pr:")
557 def cmd_log(self, *paths, **flags):
558 "[-p] [-r <rev>] <path>..."
559 rev = flags.get('-r', 'HEAD')
560 paths = ' '.join(paths)
561 if '-p' in flags:
562 flags['-p'] = '-p'
563 os.system("git log %s '%s' -- %s"
564 % (flags.get('-p', '--name-status'), rev, paths))
566 @short_help("show staged, unstaged, or all uncommitted changes")
567 @long_help("""
568 Show staged, unstaged, or all uncommitted changes. By default, all
569 changes are shown. The '-u' flag causes only unstaged changes to be
570 shown. The '-d' flag causes only staged changes to be shown.
571 """)
572 @takes_options("ud")
573 def cmd_diff(self, **flags):
574 "[ -u | -d ]"
575 if '-u' in flags and '-d' in flags:
576 raise YapError("Conflicting flags: -u and -d")
578 pager = self._get_pager_cmd()
580 if '-u' in flags:
581 os.system("git diff-files -p | %s" % pager)
582 elif '-d' in flags:
583 os.system("git diff-index --cached -p HEAD | %s" % pager)
584 else:
585 os.system("git diff-index -p HEAD | %s" % pager)
587 @short_help("list, create, or delete branches")
588 @long_help("""
589 If no arguments are specified, a list of local branches is given. The
590 current branch is indicated by a "*" next to the name. If an argument
591 is given, it is taken as the name of a new branch to create. The branch
592 will start pointing at the current HEAD. See 'point' for details on
593 changing the revision of the new branch. Note that this command does
594 not switch the current working branch. See 'switch' for details on
595 changing the current working branch.
597 The '-d' flag can be used to delete local branches. If the delete
598 operation would remove the last branch reference to a given line of
599 history (colloquially referred to as "dangling commits"), yap will
600 report an error and abort. The '-f' flag can be used to force the delete
601 in spite of this.
602 """)
603 @takes_options("fd:")
604 def cmd_branch(self, branch=None, **flags):
605 "[ [-f] -d <branch> | <branch> ]"
606 force = '-f' in flags
607 if '-d' in flags:
608 self._delete_branch(flags['-d'], force)
609 self.cmd_branch()
610 return
612 if branch is not None:
613 ref = get_output("git rev-parse --verify HEAD")
614 if not ref:
615 raise YapError("No branch point yet. Make a commit")
616 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
618 current = get_output("git symbolic-ref HEAD")
619 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
620 for b in branches:
621 if current and b == current[0]:
622 print "* ",
623 else:
624 print " ",
625 b = b.replace('refs/heads/', '')
626 print b
628 @short_help("change the current working branch")
629 @long_help("""
630 The argument is the name of the branch to make the current working
631 branch. This command will fail if there are uncommitted changes to any
632 files. Otherwise, the contents of the files in the working directory
633 are updated to reflect their state in the new branch. Additionally, any
634 future commits are added to the new branch instead of the previous line
635 of history.
636 """)
637 @takes_options("f")
638 def cmd_switch(self, branch, **flags):
639 "[-f] <branch>"
640 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
641 if not ref:
642 raise YapError("No such branch: %s" % branch)
644 if '-f' not in flags and (self._get_unstaged_files() or self._get_staged_files()):
645 raise YapError("You have uncommitted changes. Use -f to continue anyway")
647 if self._get_unstaged_files() and self._get_staged_files():
648 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
650 staged = bool(self._get_staged_files())
652 run_command("git diff-files -p | git apply --cached")
653 for f in self._get_new_files():
654 self._stage_one(f)
656 idx = get_output("git write-tree")
657 new = get_output("git rev-parse refs/heads/%s" % branch)
658 if os.system("git read-tree --aggressive -u -m HEAD %s %s" % (idx[0], new[0])):
659 raise YapError("Failed to switch")
660 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
662 if not staged:
663 self._unstage_all()
664 self.cmd_status()
666 @short_help("move the current branch to a different revision")
667 @long_help("""
668 The argument is the hash of the commit to which the current branch
669 should point, or alternately a branch or tag (a.k.a, "committish"). If
670 moving the branch would create "dangling commits" (see 'branch'), yap
671 will report an error and abort. The '-f' flag can be used to force the
672 operation in spite of this.
673 """)
674 @takes_options("f")
675 def cmd_point(self, where, **flags):
676 "[-f] <where>"
677 head = get_output("git rev-parse --verify HEAD")
678 if not head:
679 raise YapError("No commit yet; nowhere to point")
681 ref = get_output("git rev-parse --verify '%s'" % where)
682 if not ref:
683 raise YapError("Not a valid ref: %s" % where)
685 if self._get_unstaged_files() or self._get_staged_files():
686 raise YapError("You have uncommitted changes. Commit them first")
688 type = get_output("git cat-file -t '%s'" % ref[0])
689 if type and type[0] == "tag":
690 tag = get_output("git cat-file tag '%s'" % ref[0])
691 ref[0] = tag[0].split(' ')[1]
693 run_safely("git update-ref HEAD '%s'" % ref[0])
695 if '-f' not in flags:
696 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
697 if name == "undefined":
698 os.system("git update-ref HEAD '%s'" % head[0])
699 raise YapError("Pointing there will lose commits. Use -f to force")
701 try:
702 run_safely("git read-tree -u -m HEAD")
703 except ShellError:
704 run_safely("git read-tree HEAD")
705 run_safely("git checkout-index -u -f -a")
707 @short_help("alter history by dropping or amending commits")
708 @long_help("""
709 This command operates in two distinct modes, "amend" and "drop" mode.
710 In drop mode, the given commit is removed from the history of the
711 current branch, as though that commit never happened. By default the
712 commit used is HEAD.
714 In amend mode, the uncommitted changes present are merged into a
715 previous commit. This is useful for correcting typos or adding missed
716 files into past commits. By default the commit used is HEAD.
718 While rewriting history it is possible that conflicts will arise. If
719 this happens, the rewrite will pause and you will be prompted to resolve
720 the conflicts and stage them. Once that is done, you will run "yap
721 history continue." If instead you want the conflicting commit removed
722 from history (perhaps your changes supercede that commit) you can run
723 "yap history skip". Once the rewrite completes, your branch will be on
724 the same commit as when the rewrite started.
725 """)
726 def cmd_history(self, subcmd, *args):
727 "amend | drop <commit>"
729 if subcmd not in ("amend", "drop", "continue", "skip"):
730 raise TypeError
732 resolvemsg = """
733 When you have resolved the conflicts run \"yap history continue\".
734 To skip the problematic patch, run \"yap history skip\"."""
736 if subcmd == "continue":
737 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
738 return
739 if subcmd == "skip":
740 os.system("git reset --hard")
741 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
742 return
744 if subcmd == "amend":
745 flags, args = getopt.getopt(args, "ad")
746 flags = dict(flags)
748 if len(args) > 1:
749 raise TypeError
750 if args:
751 commit = args[0]
752 else:
753 commit = "HEAD"
755 if run_command("git rev-parse --verify '%s'" % commit):
756 raise YapError("Not a valid commit: %s" % commit)
758 self._check_rebasing()
760 if subcmd == "amend":
761 self._check_commit(**flags)
762 if self._get_unstaged_files():
763 # XXX: handle unstaged changes better
764 raise YapError("Commit away changes that you aren't amending")
766 self._unstage_all()
768 start = get_output("git rev-parse HEAD")
769 stash = get_output("git stash create")
770 run_command("git reset --hard")
771 try:
772 fd, tmpfile = tempfile.mkstemp("yap")
773 try:
774 try:
775 os.close(fd)
776 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
777 if subcmd == "amend":
778 self.cmd_point(commit, **{'-f': True})
779 finally:
780 if subcmd == "amend":
781 if stash:
782 rc = os.system("git stash apply %s" % stash[0])
783 if rc:
784 self.cmd_point(start[0], **{'-f': True})
785 os.system("git stash apply %s" % stash[0])
786 raise YapError("Failed to apply stash")
787 stash = None
789 if subcmd == "amend":
790 self._do_uncommit()
791 for f in self._get_unstaged_files():
792 self._stage_one(f)
793 self._do_commit()
794 else:
795 self.cmd_point("%s^" % commit, **{'-f': True})
797 stat = os.stat(tmpfile)
798 size = stat[6]
799 if size > 0:
800 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
801 if (rc):
802 raise YapError("Failed to apply changes")
803 finally:
804 os.unlink(tmpfile)
805 finally:
806 if stash:
807 run_command("git stash apply %s" % stash[0])
808 self.cmd_status()
810 @short_help("show the changes introduced by a given commit")
811 @long_help("""
812 By default, the changes in the last commit are shown. To override this,
813 specify a hash, branch, or tag (committish). The hash of the commit,
814 the commit's author, log message, and a diff of the changes are shown.
815 """)
816 def cmd_show(self, commit="HEAD"):
817 "[commit]"
818 os.system("git show '%s'" % commit)
820 @short_help("apply the changes in a given commit to the current branch")
821 @long_help("""
822 The argument is the hash, branch, or tag (committish) of the commit to
823 be applied. In general, it only makes sense to apply commits that
824 happened on another branch. The '-r' flag can be used to have the
825 changes in the given commit reversed from the current branch. In
826 general, this only makes sense for commits that happened on the current
827 branch.
828 """)
829 @takes_options("r")
830 def cmd_cherry_pick(self, commit, **flags):
831 "[-r] <commit>"
832 if '-r' in flags:
833 os.system("git revert '%s'" % commit)
834 else:
835 os.system("git cherry-pick '%s'" % commit)
837 @short_help("list, add, or delete configured remote repositories")
838 @long_help("""
839 When invoked with no arguments, this command will show the list of
840 currently configured remote repositories, giving both the name and URL
841 of each. To add a new repository, give the desired name as the first
842 argument and the URL as the second. The '-d' flag can be used to remove
843 a previously added repository.
844 """)
845 @takes_options("d:")
846 def cmd_repo(self, name=None, url=None, **flags):
847 "[<name> <url> | -d <name>]"
848 if name is not None and url is None:
849 raise TypeError
851 if '-d' in flags:
852 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
853 raise YapError("No such repository: %s" % flags['-d'])
854 os.system("git config --unset remote.%s.url" % flags['-d'])
855 os.system("git config --unset remote.%s.fetch" % flags['-d'])
857 if name:
858 if name in [ x[0] for x in self._list_remotes() ]:
859 raise YapError("Repository '%s' already exists" % flags['-d'])
860 os.system("git config remote.%s.url %s" % (name, url))
861 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
863 for remote, url in self._list_remotes():
864 print "%-20s %s" % (remote, url)
866 @takes_options("cdf")
867 @short_help("send local commits to a remote repository")
868 def cmd_push(self, repo=None, rhs=None, **flags):
869 "[-c | -d] <repo>"
871 if '-c' in flags and '-d' in flags:
872 raise TypeError
874 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
875 raise YapError("No such repository: %s" % repo)
877 current = get_output("git symbolic-ref HEAD")
878 if not current:
879 raise YapError("Not on a branch!")
881 current = current[0].replace('refs/heads/', '')
882 remote = get_output("git config branch.%s.remote" % current)
883 if repo is None and remote:
884 repo = remote[0]
886 if repo is None:
887 raise YapError("No tracking branch configured; specify destination repository")
889 if rhs is None and remote and remote[0] == repo:
890 merge = get_output("git config branch.%s.merge" % current)
891 if merge:
892 rhs = merge[0]
894 if rhs is None:
895 rhs = "refs/heads/%s" % current
897 if '-c' not in flags and '-d' not in flags:
898 if run_command("git rev-parse --verify refs/remotes/%s/%s"
899 % (repo, rhs.replace('refs/heads/', ''))):
900 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
901 if '-f' not in flags:
902 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo, rhs.replace('refs/heads/', '')))
903 base = get_output("git merge-base HEAD %s" % hash[0])
904 assert base
905 if base[0] != hash[0]:
906 raise YapError("Branch not up-to-date with remote. Update or use -f")
908 print "About to push local branch '%s' to '%s' on '%s'" % (current, rhs, repo)
909 print "Continue (y/n)? ",
910 sys.stdout.flush()
911 ans = sys.stdin.readline().strip()
913 if ans.lower() != 'y' and ans.lower() != 'yes':
914 raise YapError("Aborted.")
916 if '-f' in flags:
917 flags['-f'] = '-f'
919 if '-d' in flags:
920 lhs = ""
921 else:
922 lhs = "refs/heads/%s" % current
923 rc = os.system("git push %s %s %s:%s" % (flags.get('-f', ''), repo, lhs, rhs))
924 if rc:
925 raise YapError("Push failed.")
927 @short_help("retrieve commits from a remote repository")
928 def cmd_fetch(self, repo=None):
929 "<repo>"
930 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
931 raise YapError("No such repository: %s" % repo)
932 if repo is None:
933 remote = get_output("git config branch.%s.remote" % current)
934 repo = remote[0]
935 if repo is None:
936 raise YapError("No tracking branch configured; specify a repository")
937 os.system("git fetch %s" % repo)
939 @short_help("update the current branch relative to its tracking branch")
940 def cmd_update(self, subcmd=None):
941 "[continue | skip]"
942 if subcmd and subcmd not in ["continue", "skip"]:
943 raise TypeError
945 resolvemsg = """
946 When you have resolved the conflicts run \"yap update continue\".
947 To skip the problematic patch, run \"yap update skip\"."""
949 if subcmd == "continue":
950 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
951 return
952 if subcmd == "skip":
953 os.system("git reset --hard")
954 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
955 return
957 self._check_rebasing()
958 if self._get_unstaged_files() or self._get_staged_files():
959 raise YapError("You have uncommitted changes. Commit them first")
961 current = get_output("git symbolic-ref HEAD")
962 if not current:
963 raise YapError("Not on a branch!")
965 current = current[0].replace('refs/heads/', '')
966 remote, merge = self._get_tracking(current)
967 merge = merge[0].replace('refs/heads/', '')
969 self.cmd_fetch(remote)
970 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
972 try:
973 fd, tmpfile = tempfile.mkstemp("yap")
974 os.close(fd)
975 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
976 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
978 stat = os.stat(tmpfile)
979 size = stat[6]
980 if size > 0:
981 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
982 if (rc):
983 raise YapError("Failed to apply changes")
984 finally:
985 os.unlink(tmpfile)
987 @short_help("query and configure remote branch tracking")
988 def cmd_track(self, repo=None, branch=None):
989 "[<repo> <branch>]"
991 current = get_output("git symbolic-ref HEAD")
992 if not current:
993 raise YapError("Not on a branch!")
994 current = current[0].replace('refs/heads/', '')
996 if repo is None and branch is None:
997 repo, merge = self._get_tracking(current)
998 merge = merge[0].replace('refs/heads/', '')
999 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
1000 return
1002 if repo is None or branch is None:
1003 raise TypeError
1005 if repo not in [ x[0] for x in self._list_remotes() ]:
1006 raise YapError("No such repository: %s" % repo)
1008 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
1009 raise YapError("No such branch '%s' on repository '%s'" % (repo, branch))
1011 os.system("git config branch.%s.remote '%s'" % (current, repo))
1012 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
1013 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
1015 @short_help("mark files with conflicts as resolved")
1016 def cmd_resolved(self, *args):
1017 "<file>..."
1018 if not files:
1019 raise TypeError
1021 for f in files:
1022 self._stage_one(f, True)
1023 self.cmd_status()
1025 def cmd_help(self, cmd=None):
1026 if cmd is not None:
1027 try:
1028 attr = self.__getattribute__("cmd_"+cmd.replace('-', '_'))
1029 except AttributeError:
1030 raise YapError("No such command: %s" % cmd)
1031 try:
1032 help = attr.long_help
1033 except AttributeError:
1034 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
1036 print >>sys.stderr, "The '%s' command" % cmd
1037 print >>sys.stderr, "\tyap %s %s" % (cmd, attr.__doc__)
1038 print >>sys.stderr, "%s" % help
1039 return
1041 print >> sys.stderr, "Yet Another (Git) Porcelein"
1042 print >> sys.stderr
1044 for name in dir(self):
1045 if not name.startswith('cmd_'):
1046 continue
1047 attr = self.__getattribute__(name)
1048 if not callable(attr):
1049 continue
1050 try:
1051 short_msg = attr.short_help
1052 except AttributeError:
1053 continue
1055 name = name.replace('cmd_', '')
1056 name = name.replace('_', '-')
1057 print >> sys.stderr, "%-16s%s" % (name, short_msg)
1058 print >> sys.stderr
1059 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
1061 def cmd_usage(self):
1062 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
1063 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"
1065 def main(self, args):
1066 if len(args) < 1:
1067 self.cmd_usage()
1068 sys.exit(2)
1070 command = args[0]
1071 args = args[1:]
1073 debug = os.getenv('YAP_DEBUG')
1075 try:
1076 command = command.replace('-', '_')
1078 meth = None
1079 for p in self.plugins:
1080 try:
1081 meth = p.__getattribute__("cmd_"+command)
1082 except AttributeError:
1083 continue
1085 try:
1086 default_meth = self.__getattribute__("cmd_"+command)
1087 except AttributeError:
1088 default_meth = None
1090 if meth is None:
1091 meth = default_meth
1092 if meth is None:
1093 raise AttributeError
1095 try:
1096 if "options" in meth.__dict__:
1097 options = meth.options
1098 if default_meth and "options" in default_meth.__dict__:
1099 options += default_meth.options
1100 flags, args = getopt.getopt(args, options)
1101 flags = dict(flags)
1102 else:
1103 flags = dict()
1105 # invoke pre-hooks
1106 for p in self.plugins:
1107 try:
1108 meth = p.__getattribute__("pre_"+command)
1109 except AttributeError:
1110 continue
1111 meth(*args, **flags)
1113 meth(*args, **flags)
1115 # invoke post-hooks
1116 for p in self.plugins:
1117 try:
1118 meth = p.__getattribute__("post_"+command)
1119 except AttributeError:
1120 continue
1121 meth()
1123 except (TypeError, getopt.GetoptError):
1124 if debug:
1125 raise
1126 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, meth.__doc__)
1127 except YapError, e:
1128 print >> sys.stderr, e
1129 sys.exit(1)
1130 except AttributeError:
1131 if debug:
1132 raise
1133 self.cmd_usage()
1134 sys.exit(2)