Update usage
[yap.git] / yap / yap.py
blob4d0ce077a425c7d1a7932414231026f52a7d6a13
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 return files
104 def _get_unstaged_files(self):
105 files = self._get_new_files()
106 files += get_output("git ls-files -m")
107 return files
109 def _delete_branch(self, branch, force):
110 current = get_output("git symbolic-ref HEAD")[0]
111 current = current.replace('refs/heads/', '')
112 if branch == current:
113 raise YapError("Can't delete current branch")
115 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
116 if not ref:
117 raise YapError("No such branch: %s" % branch)
118 run_safely("git update-ref -d 'refs/heads/%s' '%s'" % (branch, ref[0]))
120 if not force:
121 name = get_output("git name-rev --name-only '%s'" % ref[0])[0]
122 if name == 'undefined':
123 run_command("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
124 raise YapError("Refusing to delete leaf branch (use -f to force)")
125 def _get_pager_cmd(self):
126 if 'YAP_PAGER' in os.environ:
127 return os.environ['YAP_PAGER']
128 elif 'GIT_PAGER' in os.environ:
129 return os.environ['GIT_PAGER']
130 elif 'PAGER' in os.environ:
131 return os.environ['PAGER']
132 else:
133 return "more"
135 def _add_one(self, file):
136 self._assert_file_exists(file)
137 x = get_output("git ls-files '%s'" % file)
138 if x != []:
139 raise YapError("File '%s' already in repository" % file)
140 self._add_new_file(file)
142 def _rm_one(self, file):
143 self._assert_file_exists(file)
144 if get_output("git ls-files '%s'" % file) != []:
145 run_safely("git rm --cached '%s'" % file)
146 self._remove_new_file(file)
148 def _stage_one(self, file):
149 self._assert_file_exists(file)
150 run_safely("git update-index --add '%s'" % file)
152 def _unstage_one(self, file):
153 self._assert_file_exists(file)
154 if run_command("git rev-parse HEAD"):
155 run_safely("git update-index --force-remove '%s'" % file)
156 else:
157 run_safely("git diff-index -p HEAD '%s' | git apply -R --cached" % file)
159 def _revert_one(self, file):
160 self._assert_file_exists(file)
161 self._unstage_one(file)
162 run_safely("git checkout-index -u -f '%s'" % file)
164 def _parse_commit(self, commit):
165 lines = get_output("git cat-file commit '%s'" % commit)
166 commit = {}
168 mode = None
169 for l in lines:
170 if mode != 'commit' and l.strip() == "":
171 mode = 'commit'
172 commit['log'] = []
173 continue
174 if mode == 'commit':
175 commit['log'].append(l)
176 continue
178 x = l.split(' ')
179 k = x[0]
180 v = ' '.join(x[1:])
181 commit[k] = v
182 commit['log'] = '\n'.join(commit['log'])
183 return commit
185 def _check_commit(self, **flags):
186 if '-a' in flags and '-d' in flags:
187 raise YapError("Conflicting flags: -a and -d")
189 if '-d' not in flags and self._get_unstaged_files():
190 if '-a' not in flags and self._get_staged_files():
191 raise YapError("Staged and unstaged changes present. Specify what to commit")
192 os.system("git diff-files -p | git apply --cached")
193 for f in self._get_new_files():
194 self._stage_one(f)
196 def _do_uncommit(self):
197 commit = self._parse_commit("HEAD")
198 repo = get_output('git rev-parse --git-dir')[0]
199 dir = os.path.join(repo, 'yap')
200 try:
201 os.mkdir(dir)
202 except OSError:
203 pass
204 msg_file = os.path.join(dir, 'msg')
205 fd = file(msg_file, 'w')
206 print >>fd, commit['log']
207 fd.close()
209 tree = get_output("git rev-parse --verify HEAD^")
210 run_safely("git update-ref -m uncommit HEAD '%s'" % tree[0])
212 def _do_commit(self, msg=None):
213 tree = get_output("git write-tree")[0]
214 parent = get_output("git rev-parse --verify HEAD 2> /dev/null")[0]
216 if os.environ.has_key('YAP_EDITOR'):
217 editor = os.environ['YAP_EDITOR']
218 elif os.environ.has_key('GIT_EDITOR'):
219 editor = os.environ['GIT_EDITOR']
220 elif os.environ.has_key('EDITOR'):
221 editor = os.environ['EDITOR']
222 else:
223 editor = "vi"
225 fd, tmpfile = tempfile.mkstemp("yap")
226 os.close(fd)
229 if msg is None:
230 repo = get_output('git rev-parse --git-dir')[0]
231 msg_file = os.path.join(repo, 'yap', 'msg')
232 if os.access(msg_file, os.R_OK):
233 fd1 = file(msg_file)
234 fd2 = file(tmpfile, 'w')
235 for l in fd1.xreadlines():
236 print >>fd2, l.strip()
237 fd2.close()
238 os.unlink(msg_file)
239 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
240 raise YapError("Editing commit message failed")
241 fd = file(tmpfile)
242 msg = fd.readlines()
243 msg = ''.join(msg)
245 msg = msg.strip()
246 if not msg:
247 raise YapError("Refusing to use empty commit message")
249 (fd_w, fd_r) = os.popen2("git stripspace > %s" % tmpfile)
250 print >>fd_w, msg,
251 fd_w.close()
252 fd_r.close()
254 if parent != 'HEAD':
255 commit = get_output("git commit-tree '%s' -p '%s' < '%s'" % (tree, parent, tmpfile))
256 else:
257 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
259 os.unlink(tmpfile)
260 run_safely("git update-ref HEAD '%s'" % commit[0])
262 def _check_rebasing(self):
263 repo = get_output('git rev-parse --git-dir')[0]
264 dotest = os.path.join(repo, '.dotest')
265 if os.access(dotest, os.R_OK):
266 raise YapError("A git operation is in progress. Complete it first")
267 dotest = os.path.join(repo, '..', '.dotest')
268 if os.access(dotest, os.R_OK):
269 raise YapError("A git operation is in progress. Complete it first")
271 def _list_remotes(self):
272 remotes = get_output("git config --get-regexp '^remote.*.url'")
273 for x in remotes:
274 remote, url = x.split(' ')
275 remote = remote.replace('remote.', '')
276 remote = remote.replace('.url', '')
277 yield remote, url
279 def _unstage_all(self):
280 try:
281 run_safely("git read-tree -m HEAD")
282 except ShellError:
283 run_safely("git read-tree HEAD")
284 run_safely("git update-index -q --refresh")
286 def _get_tracking(self, current):
287 remote = get_output("git config branch.%s.remote" % current)
288 if not remote:
289 raise YapError("No tracking branch configured for '%s'" % current)
291 merge = get_output("git config branch.%s.merge" % current)
292 if not merge:
293 raise YapError("No tracking branch configured for '%s'" % current)
294 return remote[0], merge
296 @short_help("make a local copy of an existing repository")
297 @long_help("""
298 The first argument is a URL to the existing repository. This can be an
299 absolute path if the repository is local, or a URL with the git://,
300 ssh://, or http:// schemes. By default, the directory used is the last
301 component of the URL, sans '.git'. This can be overridden by providing
302 a second argument.
303 """)
304 def cmd_clone(self, url, directory=None):
305 "<url> [directory]"
307 if '://' not in url and url[0] != '/':
308 url = os.path.join(os.getcwd(), url)
310 url = url.rstrip('/')
311 if directory is None:
312 directory = url.rsplit('/')[-1]
313 directory = directory.replace('.git', '')
315 try:
316 os.mkdir(directory)
317 except OSError:
318 raise YapError("Directory exists: %s" % directory)
319 os.chdir(directory)
320 self.cmd_init()
321 self.cmd_repo("origin", url)
322 self.cmd_fetch("origin")
324 branch = None
325 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
326 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
327 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
328 if get_output("git rev-parse %s" % b)[0] == hash:
329 branch = b
330 break
331 if branch is None:
332 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
333 branch = "refs/remotes/origin/master"
334 if branch is None:
335 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'")
336 branch = branch[0]
338 hash = get_output("git rev-parse %s" % branch)
339 assert hash
340 branch = branch.replace('refs/remotes/origin/', '')
341 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
342 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
343 self.cmd_revert(**{'-a': 1})
345 @short_help("turn a directory into a repository")
346 @long_help("""
347 Converts the current working directory into a repository. The primary
348 side-effect of this command is the creation of a '.git' subdirectory.
349 No files are added nor commits made.
350 """)
351 def cmd_init(self):
352 os.system("git init")
354 @short_help("add a new file to the repository")
355 @long_help("""
356 The arguments are the files to be added to the repository. Once added,
357 the files will show as "unstaged changes" in the output of 'status'. To
358 reverse the effects of this command, see 'rm'.
359 """)
360 def cmd_add(self, *files):
361 "<file>..."
362 if not files:
363 raise TypeError
365 for f in files:
366 self._add_one(f)
367 self.cmd_status()
369 @short_help("delete a file from the repository")
370 @long_help("""
371 The arguments are the files to be removed from the current revision of
372 the repository. The files will still exist in any past commits that the
373 files may have been a part of. The file is not actually deleted, it is
374 just no longer tracked as part of the repository.
375 """)
376 def cmd_rm(self, *files):
377 "<file>..."
378 if not files:
379 raise TypeError
381 for f in files:
382 self._rm_one(f)
383 self.cmd_status()
385 @short_help("stage changes in a file for commit")
386 @long_help("""
387 The arguments are the files to be staged. Staging changes is a way to
388 build up a commit when you do not want to commit all changes at once.
389 To commit only staged changes, use the '-d' flag to 'commit.' To
390 reverse the effects of this command, see 'unstage'. Once staged, the
391 files will show as "staged changes" in the output of 'status'.
392 """)
393 def cmd_stage(self, *files):
394 "<file>..."
395 if not files:
396 raise TypeError
398 for f in files:
399 self._stage_one(f)
400 self.cmd_status()
402 @short_help("unstage changes in a file")
403 @long_help("""
404 The arguments are the files to be unstaged. Once unstaged, the files
405 will show as "unstaged changes" in the output of 'status'. The '-a'
406 flag can be used to unstage all staged changes at once.
407 """)
408 @takes_options("a")
409 def cmd_unstage(self, *files, **flags):
410 "[-a] | <file>..."
411 if '-a' in flags:
412 self._unstage_all()
413 self.cmd_status()
414 return
416 if not files:
417 raise TypeError
419 for f in files:
420 self._unstage_one(f)
421 self.cmd_status()
423 @short_help("show files with staged and unstaged changes")
424 @long_help("""
425 Show the files in the repository with changes since the last commit,
426 categorized based on whether the changes are staged or not. A file may
427 appear under each heading if the same file has both staged and unstaged
428 changes.
429 """)
430 def cmd_status(self):
432 branch = get_output("git symbolic-ref HEAD")[0]
433 branch = branch.replace('refs/heads/', '')
434 print "Current branch: %s" % branch
436 print "Files with staged changes:"
437 files = self._get_staged_files()
438 for f in files:
439 print "\t%s" % f
440 if not files:
441 print "\t(none)"
443 print "Files with unstaged changes:"
444 prefix = get_output("git rev-parse --show-prefix")
445 files = self._get_unstaged_files()
446 for f in files:
447 if prefix:
448 f = os.path.join(prefix[0], f)
449 print "\t%s" % f
450 if not files:
451 print "\t(none)"
453 @short_help("remove uncommitted changes from a file (*)")
454 @long_help("""
455 The arguments are the files whose changes will be reverted. If the '-a'
456 flag is given, then all files will have uncommitted changes removed.
457 Note that there is no way to reverse this command short of manually
458 editing each file again.
459 """)
460 @takes_options("a")
461 def cmd_revert(self, *files, **flags):
462 "(-a | <file>)"
463 if '-a' in flags:
464 self._unstage_all()
465 run_safely("git checkout-index -u -f -a")
466 self.cmd_status()
467 return
469 if not files:
470 raise TypeError
472 for f in files:
473 self._revert_one(f)
474 self.cmd_status()
476 @short_help("record changes to files as a new commit")
477 @long_help("""
478 Create a new commit recording changes since the last commit. If there
479 are only unstaged changes, those will be recorded. If there are only
480 staged changes, those will be recorded. Otherwise, you will have to
481 specify either the '-a' flag or the '-d' flag to commit all changes or
482 only staged changes, respectively. To reverse the effects of this
483 command, see 'uncommit'.
484 """)
485 @takes_options("adm:")
486 def cmd_commit(self, **flags):
487 "[-a | -d]"
488 self._check_rebasing()
489 self._check_commit(**flags)
490 if not self._get_staged_files():
491 raise YapError("No changes to commit")
492 msg = flags.get('-m', None)
493 self._do_commit(msg)
494 self.cmd_status()
496 @short_help("reverse the actions of the last commit")
497 @long_help("""
498 Reverse the effects of the last 'commit' operation. The changes that
499 were part of the previous commit will show as "staged changes" in the
500 output of 'status'. This means that if no files were changed since the
501 last commit was created, 'uncommit' followed by 'commit' is a lossless
502 operation.
503 """)
504 def cmd_uncommit(self):
506 self._do_uncommit()
507 self.cmd_status()
509 @short_help("report the current version of yap")
510 def cmd_version(self):
511 print "Yap version 0.1"
513 @short_help("show the changelog for particular versions or files")
514 @long_help("""
515 The arguments are the files with which to filter history. If none are
516 given, all changes are listed. Otherwise only commits that affected one
517 or more of the given files are listed. The -r option changes the
518 starting revision for traversing history. By default, history is listed
519 starting at HEAD.
520 """)
521 @takes_options("r:")
522 def cmd_log(self, *paths, **flags):
523 "[-r <rev>] <path>..."
524 rev = flags.get('-r', 'HEAD')
525 paths = ' '.join(paths)
526 os.system("git log --name-status '%s' -- %s" % (rev, paths))
528 @short_help("show staged, unstaged, or all uncommitted changes")
529 @long_help("""
530 Show staged, unstaged, or all uncommitted changes. By default, all
531 changes are shown. The '-u' flag causes only unstaged changes to be
532 shown. The '-d' flag causes only staged changes to be shown.
533 """)
534 @takes_options("ud")
535 def cmd_diff(self, **flags):
536 "[ -u | -d ]"
537 if '-u' in flags and '-d' in flags:
538 raise YapError("Conflicting flags: -u and -d")
540 pager = self._get_pager_cmd()
542 if '-u' in flags:
543 os.system("git diff-files -p | %s" % pager)
544 elif '-d' in flags:
545 os.system("git diff-index --cached -p HEAD | %s" % pager)
546 else:
547 os.system("git diff-index -p HEAD | %s" % pager)
549 @short_help("list, create, or delete branches")
550 @long_help("""
551 If no arguments are specified, a list of local branches is given. The
552 current branch is indicated by a "*" next to the name. If an argument
553 is given, it is taken as the name of a new branch to create. The branch
554 will start pointing at the current HEAD. See 'point' for details on
555 changing the revision of the new branch. Note that this command does
556 not switch the current working branch. See 'switch' for details on
557 changing the current working branch.
559 The '-d' flag can be used to delete local branches. If the delete
560 operation would remove the last branch reference to a given line of
561 history (colloquially referred to as "dangling commits"), yap will
562 report an error and abort. The '-f' flag can be used to force the delete
563 in spite of this.
564 """)
565 @takes_options("fd:")
566 def cmd_branch(self, branch=None, **flags):
567 "[ [-f] -d <branch> | <branch> ]"
568 force = '-f' in flags
569 if '-d' in flags:
570 self._delete_branch(flags['-d'], force)
571 self.cmd_branch()
572 return
574 if branch is not None:
575 ref = get_output("git rev-parse --verify HEAD")
576 if not ref:
577 raise YapError("No branch point yet. Make a commit")
578 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
580 current = get_output("git symbolic-ref HEAD")[0]
581 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
582 for b in branches:
583 if b == current:
584 print "* ",
585 else:
586 print " ",
587 b = b.replace('refs/heads/', '')
588 print b
590 @short_help("change the current working branch")
591 @long_help("""
592 The argument is the name of the branch to make the current working
593 branch. This command will fail if there are uncommitted changes to any
594 files. Otherwise, the contents of the files in the working directory
595 are updated to reflect their state in the new branch. Additionally, any
596 future commits are added to the new branch instead of the previous line
597 of history.
598 """)
599 def cmd_switch(self, branch):
600 "<branch>"
601 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
602 if not ref:
603 raise YapError("No such branch: %s" % branch)
605 # XXX: support merging like git-checkout
606 if self._get_unstaged_files() or self._get_staged_files():
607 raise YapError("You have uncommitted changes. Commit them first")
609 run_safely("git symbolic-ref HEAD refs/heads/'%s'" % branch)
610 run_safely("git read-tree -u -m HEAD")
611 run_safely("git checkout-index -u -f -a")
612 self.cmd_branch()
614 @short_help("move the current branch to a different revision")
615 @long_help("""
616 The argument is the hash of the commit to which the current branch
617 should point, or alternately a branch or tag (a.k.a, "committish"). If
618 moving the branch would create "dangling commits" (see 'branch'), yap
619 will report an error and abort. The '-f' flag can be used to force the
620 operation in spite of this.
621 """)
622 @takes_options("f")
623 def cmd_point(self, where, **flags):
624 "<where>"
625 head = get_output("git rev-parse --verify HEAD")
626 if not head:
627 raise YapError("No commit yet; nowhere to point")
629 ref = get_output("git rev-parse --verify '%s'" % where)
630 if not ref:
631 raise YapError("Not a valid ref: %s" % where)
633 if self._get_unstaged_files() or self._get_staged_files():
634 raise YapError("You have uncommitted changes. Commit them first")
636 type = get_output("git cat-file -t '%s'" % ref[0])
637 if type and type[0] == "tag":
638 tag = get_output("git cat-file tag '%s'" % ref[0])
639 ref[0] = tag[0].split(' ')[1]
641 run_safely("git update-ref HEAD '%s'" % ref[0])
643 if '-f' not in flags:
644 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
645 if name == "undefined":
646 os.system("git update-ref HEAD '%s'" % head[0])
647 raise YapError("Pointing there will lose commits. Use -f to force")
649 run_safely("git read-tree -u -m HEAD")
650 run_safely("git checkout-index -u -f -a")
652 @short_help("alter history by dropping or amending commits")
653 @long_help("""
654 This command operates in two distinct modes, "amend" and "drop" mode.
655 In drop mode, the given commit is removed from the history of the
656 current branch, as though that commit never happened. By default the
657 commit used is HEAD.
659 In amend mode, the uncommitted changes present are merged into a
660 previous commit. This is useful for correcting typos or adding missed
661 files into past commits. By default the commit used is HEAD.
663 While rewriting history it is possible that conflicts will arise. If
664 this happens, the rewrite will pause and you will be prompted to resolve
665 the conflicts and stage them. Once that is done, you will run "yap
666 history continue." If instead you want the conflicting commit removed
667 from history (perhaps your changes supercede that commit) you can run
668 "yap history skip". Once the rewrite completes, your branch will be on
669 the same commit as when the rewrite started.
670 """)
671 def cmd_history(self, subcmd, *args):
672 "amend | drop <commit>"
674 if subcmd not in ("amend", "drop", "continue", "skip"):
675 raise TypeError
677 resolvemsg = """
678 When you have resolved the conflicts run \"yap history continue\".
679 To skip the problematic patch, run \"yap history skip\"."""
681 if subcmd == "continue":
682 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
683 return
684 if subcmd == "skip":
685 os.system("git reset --hard")
686 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
687 return
689 if subcmd == "amend":
690 flags, args = getopt.getopt(args, "ad")
691 flags = dict(flags)
693 if len(args) > 1:
694 raise TypeError
695 if args:
696 commit = args[0]
697 else:
698 commit = "HEAD"
700 if run_command("git rev-parse --verify '%s'" % commit):
701 raise YapError("Not a valid commit: %s" % commit)
703 self._check_rebasing()
705 if subcmd == "amend":
706 self._check_commit(**flags)
707 if self._get_unstaged_files():
708 # XXX: handle unstaged changes better
709 raise YapError("Commit away changes that you aren't amending")
711 stash = get_output("git stash create")
712 try:
713 run_command("git reset --hard")
714 fd, tmpfile = tempfile.mkstemp("yap")
715 try:
716 try:
717 os.close(fd)
718 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
719 if subcmd == "amend":
720 self.cmd_point(commit, **{'-f': True})
721 finally:
722 if subcmd == "amend":
723 rc = os.system("git stash apply --index %s" % stash[0])
724 if rc:
725 raise YapError("Failed to apply stash")
726 stash = None
728 if subcmd == "amend":
729 self._do_uncommit()
730 self._do_commit()
731 else:
732 self.cmd_point("%s^" % commit, **{'-f': True})
734 stat = os.stat(tmpfile)
735 size = stat[6]
736 if size > 0:
737 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
738 if (rc):
739 raise YapError("Failed to apply changes")
740 finally:
741 os.unlink(tmpfile)
742 finally:
743 if stash:
744 run_command("git stash apply --index %s" % stash[0])
745 self.cmd_status()
747 @short_help("show the changes introduced by a given commit")
748 @long_help("""
749 By default, the changes in the last commit are shown. To override this,
750 specify a hash, branch, or tag (committish). The hash of the commit,
751 the commit's author, log message, and a diff of the changes are shown.
752 """)
753 def cmd_show(self, commit="HEAD"):
754 "[commit]"
755 os.system("git show '%s'" % commit)
757 @short_help("apply the changes in a given commit to the current branch")
758 @long_help("""
759 The argument is the hash, branch, or tag (committish) of the commit to
760 be applied. In general, it only makes sense to apply commits that
761 happened on another branch. The '-r' flag can be used to have the
762 changes in the given commit reversed from the current branch. In
763 general, this only makes sense for commits that happened on the current
764 branch.
765 """)
766 @takes_options("r")
767 def cmd_cherry_pick(self, commit, **flags):
768 "[-r] <commit>"
769 if '-r' in flags:
770 os.system("git revert '%s'" % commit)
771 else:
772 os.system("git cherry-pick '%s'" % commit)
774 @short_help("list, add, or delete configured remote repositories")
775 @long_help("""
776 When invoked with no arguments, this command will show the list of
777 currently configured remote repositories, giving both the name and URL
778 of each. To add a new repository, give the desired name as the first
779 argument and the URL as the second. The '-d' flag can be used to remove
780 a previously added repository.
781 """)
782 @takes_options("d:")
783 def cmd_repo(self, name=None, url=None, **flags):
784 "[<name> <url> | -d <name>]"
785 if name is not None and url is None:
786 raise TypeError
788 if '-d' in flags:
789 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
790 raise YapError("No such repository: %s" % flags['-d'])
791 os.system("git config --unset remote.%s.url" % flags['-d'])
792 os.system("git config --unset remote.%s.fetch" % flags['-d'])
794 if name:
795 if name in [ x[0] for x in self._list_remotes() ]:
796 raise YapError("Repository '%s' already exists" % flags['-d'])
797 os.system("git config remote.%s.url %s" % (name, url))
798 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
800 for remote, url in self._list_remotes():
801 print "%-20s %s" % (remote, url)
803 @takes_options("cd")
804 def cmd_push(self, repo, **flags):
805 "[-c | -d] <repo>"
807 if repo not in [ x[0] for x in self._list_remotes() ]:
808 raise YapError("No such repository: %s" % repo)
810 current = get_output("git symbolic-ref HEAD")
811 if not current:
812 raise YapError("Not on a branch!")
813 ref = current[0]
814 current = current[0].replace('refs/heads/', '')
815 remote = get_output("git config branch.%s.remote" % current)
816 if remote and remote[0] == repo:
817 merge = get_output("git config branch.%s.merge" % current)
818 if merge:
819 ref = merge[0]
821 if '-c' not in flags and '-d' not in flags:
822 if run_command("git rev-parse --verify refs/remotes/%s/%s"
823 % (repo, ref.replace('refs/heads/', ''))):
824 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
826 if '-d' in flags:
827 lhs = ""
828 else:
829 lhs = "refs/heads/%s" % current
830 rc = os.system("git push %s %s:%s" % (repo, lhs, ref))
831 if rc:
832 raise YapError("Push failed.")
834 def cmd_fetch(self, repo):
835 "<repo>"
836 # XXX allow defaulting of repo? yap.default
837 if repo not in [ x[0] for x in self._list_remotes() ]:
838 raise YapError("No such repository: %s" % repo)
839 os.system("git fetch %s" % repo)
841 def cmd_update(self, subcmd=None):
842 "[continue | skip]"
843 if subcmd and subcmd not in ["continue", "skip"]:
844 raise TypeError
846 resolvemsg = """
847 When you have resolved the conflicts run \"yap history continue\".
848 To skip the problematic patch, run \"yap history skip\"."""
850 if subcmd == "continue":
851 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
852 return
853 if subcmd == "skip":
854 os.system("git reset --hard")
855 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
856 return
858 self._check_rebasing()
859 if self._get_unstaged_files() or self._get_staged_files():
860 raise YapError("You have uncommitted changes. Commit them first")
862 current = get_output("git symbolic-ref HEAD")
863 if not current:
864 raise YapError("Not on a branch!")
866 current = current[0].replace('refs/heads/', '')
867 remote, merge = self._get_tracking(current)
868 merge = merge[0].replace('refs/heads/', '')
870 self.cmd_fetch(remote)
871 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
873 try:
874 fd, tmpfile = tempfile.mkstemp("yap")
875 os.close(fd)
876 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
877 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
879 stat = os.stat(tmpfile)
880 size = stat[6]
881 if size > 0:
882 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
883 if (rc):
884 raise YapError("Failed to apply changes")
885 finally:
886 os.unlink(tmpfile)
888 def cmd_track(self, repo=None, branch=None):
889 "[<repo> <branch>]"
891 current = get_output("git symbolic-ref HEAD")
892 if not current:
893 raise YapError("Not on a branch!")
894 current = current[0].replace('refs/heads/', '')
896 if repo is None and branch is None:
897 repo, merge = self._get_tracking(current)
898 merge = merge[0].replace('refs/heads/', '')
899 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
900 return
902 if repo is None or branch is None:
903 raise TypeError
905 if repo not in [ x[0] for x in self._list_remotes() ]:
906 raise YapError("No such repository: %s" % repo)
908 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
909 raise YapError("No such branch '%s' on repository '%s'" % (repo, branch))
911 os.system("git config branch.%s.remote '%s'" % (current, repo))
912 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
913 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
915 def cmd_help(self, cmd=None):
916 if cmd is not None:
917 try:
918 attr = self.__getattribute__("cmd_"+cmd.replace('-', '_'))
919 except AttributeError:
920 raise YapError("No such command: %s" % cmd)
921 try:
922 help = attr.long_help
923 except AttributeError:
924 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
926 print >>sys.stderr, "The '%s' command" % cmd
927 print >>sys.stderr, "\tyap %s %s" % (cmd, attr.__doc__)
928 print >>sys.stderr, "%s" % help
929 return
931 print >> sys.stderr, "Yet Another (Git) Porcelein"
932 print >> sys.stderr
934 for name in dir(self):
935 if not name.startswith('cmd_'):
936 continue
937 attr = self.__getattribute__(name)
938 if not callable(attr):
939 continue
940 try:
941 short_msg = attr.short_help
942 except AttributeError:
943 continue
945 name = name.replace('cmd_', '')
946 name = name.replace('_', '-')
947 print >> sys.stderr, "%-16s%s" % (name, short_msg)
948 print >> sys.stderr
949 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
951 def cmd_usage(self):
952 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
953 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 version"
955 def main(self, args):
956 if len(args) < 1:
957 self.cmd_usage()
958 sys.exit(2)
960 command = args[0]
961 args = args[1:]
963 debug = os.getenv('YAP_DEBUG')
965 try:
966 command = command.replace('-', '_')
968 meth = None
969 for p in self.plugins:
970 try:
971 meth = p.__getattribute__("cmd_"+command)
972 except AttributeError:
973 continue
975 try:
976 default_meth = self.__getattribute__("cmd_"+command)
977 except AttributeError:
978 default_meth = None
980 if meth is None:
981 meth = default_meth
982 if meth is None:
983 raise AttributeError
985 try:
986 if "options" in meth.__dict__:
987 options = meth.options
988 if default_meth and "options" in default_meth.__dict__:
989 options += default_meth.options
990 flags, args = getopt.getopt(args, options)
991 flags = dict(flags)
992 else:
993 flags = dict()
995 # invoke pre-hooks
996 for p in self.plugins:
997 try:
998 meth = p.__getattribute__("pre_"+command)
999 except AttributeError:
1000 continue
1001 meth(*args, **flags)
1003 meth(*args, **flags)
1005 # invoke post-hooks
1006 for p in self.plugins:
1007 try:
1008 meth = p.__getattribute__("post_"+command)
1009 except AttributeError:
1010 continue
1011 meth()
1013 except (TypeError, getopt.GetoptError):
1014 if debug:
1015 raise
1016 print "%s %s %s" % (sys.argv[0], command, meth.__doc__)
1017 except YapError, e:
1018 print >> sys.stderr, e
1019 sys.exit(1)
1020 except AttributeError:
1021 if debug:
1022 raise
1023 self.cmd_usage()
1024 sys.exit(2)