Implement cmd_update
[yap.git] / yap / yap.py
blobee31bb54d909782e66bd42179bccd55bfb754b57
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 @short_help("make a local copy of an existing repository")
287 @long_help("""
288 The first argument is a URL to the existing repository. This can be an
289 absolute path if the repository is local, or a URL with the git://,
290 ssh://, or http:// schemes. By default, the directory used is the last
291 component of the URL, sans '.git'. This can be overridden by providing
292 a second argument.
293 """)
294 def cmd_clone(self, url, directory=None):
295 "<url> [directory]"
297 if '://' not in url and url[0] != '/':
298 url = os.path.join(os.getcwd(), url)
300 if directory is None:
301 directory = url.rsplit('/')[-1]
302 directory = directory.replace('.git', '')
304 os.mkdir(directory)
305 os.chdir(directory)
306 self.cmd_init()
307 self.cmd_repo("origin", url)
308 self.cmd_fetch("origin")
310 branch = None
311 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
312 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
313 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
314 if get_output("git rev-parse %s" % b)[0] == hash:
315 branch = b
316 break
317 if branch is None:
318 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
319 branch = "refs/remotes/origin/master"
320 if branch is None:
321 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'")
322 branch = branch[0]
324 hash = get_output("git rev-parse %s" % branch)
325 assert hash
326 branch = branch.replace('refs/remotes/origin/', '')
327 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
328 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
329 self.cmd_revert(**{'-a': 1})
331 @short_help("turn a directory into a repository")
332 @long_help("""
333 Converts the current working directory into a repository. The primary
334 side-effect of this command is the creation of a '.git' subdirectory.
335 No files are added nor commits made.
336 """)
337 def cmd_init(self):
338 os.system("git init")
340 @short_help("add a new file to the repository")
341 @long_help("""
342 The arguments are the files to be added to the repository. Once added,
343 the files will show as "unstaged changes" in the output of 'status'. To
344 reverse the effects of this command, see 'rm'.
345 """)
346 def cmd_add(self, *files):
347 "<file>..."
348 if not files:
349 raise TypeError
351 for f in files:
352 self._add_one(f)
353 self.cmd_status()
355 @short_help("delete a file from the repository")
356 @long_help("""
357 The arguments are the files to be removed from the current revision of
358 the repository. The files will still exist in any past commits that the
359 files may have been a part of. The file is not actually deleted, it is
360 just no longer tracked as part of the repository.
361 """)
362 def cmd_rm(self, *files):
363 "<file>..."
364 if not files:
365 raise TypeError
367 for f in files:
368 self._rm_one(f)
369 self.cmd_status()
371 @short_help("stage changes in a file for commit")
372 @long_help("""
373 The arguments are the files to be staged. Staging changes is a way to
374 build up a commit when you do not want to commit all changes at once.
375 To commit only staged changes, use the '-d' flag to 'commit.' To
376 reverse the effects of this command, see 'unstage'. Once staged, the
377 files will show as "staged changes" in the output of 'status'.
378 """)
379 def cmd_stage(self, *files):
380 "<file>..."
381 if not files:
382 raise TypeError
384 for f in files:
385 self._stage_one(f)
386 self.cmd_status()
388 @short_help("unstage changes in a file")
389 @long_help("""
390 The arguments are the files to be unstaged. Once unstaged, the files
391 will show as "unstaged changes" in the output of 'status'. The '-a'
392 flag can be used to unstage all staged changes at once.
393 """)
394 @takes_options("a")
395 def cmd_unstage(self, *files, **flags):
396 "[-a] | <file>..."
397 if '-a' in flags:
398 self._unstage_all()
399 self.cmd_status()
400 return
402 if not files:
403 raise TypeError
405 for f in files:
406 self._unstage_one(f)
407 self.cmd_status()
409 @short_help("show files with staged and unstaged changes")
410 @long_help("""
411 Show the files in the repository with changes since the last commit,
412 categorized based on whether the changes are staged or not. A file may
413 appear under each heading if the same file has both staged and unstaged
414 changes.
415 """)
416 def cmd_status(self):
418 branch = get_output("git symbolic-ref HEAD")[0]
419 branch = branch.replace('refs/heads/', '')
420 print "Current branch: %s" % branch
422 print "Files with staged changes:"
423 files = self._get_staged_files()
424 for f in files:
425 print "\t%s" % f
426 if not files:
427 print "\t(none)"
429 print "Files with unstaged changes:"
430 prefix = get_output("git rev-parse --show-prefix")
431 files = self._get_unstaged_files()
432 for f in files:
433 if prefix:
434 f = os.path.join(prefix[0], f)
435 print "\t%s" % f
436 if not files:
437 print "\t(none)"
439 @short_help("remove uncommitted changes from a file (*)")
440 @long_help("""
441 The arguments are the files whose changes will be reverted. If the '-a'
442 flag is given, then all files will have uncommitted changes removed.
443 Note that there is no way to reverse this command short of manually
444 editing each file again.
445 """)
446 @takes_options("a")
447 def cmd_revert(self, *files, **flags):
448 "(-a | <file>)"
449 if '-a' in flags:
450 self._unstage_all()
451 run_safely("git checkout-index -u -f -a")
452 self.cmd_status()
453 return
455 if not files:
456 raise TypeError
458 for f in files:
459 self._revert_one(f)
460 self.cmd_status()
462 @short_help("record changes to files as a new commit")
463 @long_help("""
464 Create a new commit recording changes since the last commit. If there
465 are only unstaged changes, those will be recorded. If there are only
466 staged changes, those will be recorded. Otherwise, you will have to
467 specify either the '-a' flag or the '-d' flag to commit all changes or
468 only staged changes, respectively. To reverse the effects of this
469 command, see 'uncommit'.
470 """)
471 @takes_options("adm:")
472 def cmd_commit(self, **flags):
473 "[-a | -d]"
474 self._check_rebasing()
475 self._check_commit(**flags)
476 if not self._get_staged_files():
477 raise YapError("No changes to commit")
478 msg = flags.get('-m', None)
479 self._do_commit(msg)
480 self.cmd_status()
482 @short_help("reverse the actions of the last commit")
483 @long_help("""
484 Reverse the effects of the last 'commit' operation. The changes that
485 were part of the previous commit will show as "staged changes" in the
486 output of 'status'. This means that if no files were changed since the
487 last commit was created, 'uncommit' followed by 'commit' is a lossless
488 operation.
489 """)
490 def cmd_uncommit(self):
492 self._do_uncommit()
493 self.cmd_status()
495 @short_help("report the current version of yap")
496 def cmd_version(self):
497 print "Yap version 0.1"
499 @short_help("show the changelog for particular versions or files")
500 @long_help("""
501 The arguments are the files with which to filter history. If none are
502 given, all changes are listed. Otherwise only commits that affected one
503 or more of the given files are listed. The -r option changes the
504 starting revision for traversing history. By default, history is listed
505 starting at HEAD.
506 """)
507 @takes_options("r:")
508 def cmd_log(self, *paths, **flags):
509 "[-r <rev>] <path>..."
510 rev = flags.get('-r', 'HEAD')
511 paths = ' '.join(paths)
512 os.system("git log --name-status '%s' -- %s" % (rev, paths))
514 @short_help("show staged, unstaged, or all uncommitted changes")
515 @long_help("""
516 Show staged, unstaged, or all uncommitted changes. By default, all
517 changes are shown. The '-u' flag causes only unstaged changes to be
518 shown. The '-d' flag causes only staged changes to be shown.
519 """)
520 @takes_options("ud")
521 def cmd_diff(self, **flags):
522 "[ -u | -d ]"
523 if '-u' in flags and '-d' in flags:
524 raise YapError("Conflicting flags: -u and -d")
526 pager = self._get_pager_cmd()
528 if '-u' in flags:
529 os.system("git diff-files -p | %s" % pager)
530 elif '-d' in flags:
531 os.system("git diff-index --cached -p HEAD | %s" % pager)
532 else:
533 os.system("git diff-index -p HEAD | %s" % pager)
535 @short_help("list, create, or delete branches")
536 @long_help("""
537 If no arguments are specified, a list of local branches is given. The
538 current branch is indicated by a "*" next to the name. If an argument
539 is given, it is taken as the name of a new branch to create. The branch
540 will start pointing at the current HEAD. See 'point' for details on
541 changing the revision of the new branch. Note that this command does
542 not switch the current working branch. See 'switch' for details on
543 changing the current working branch.
545 The '-d' flag can be used to delete local branches. If the delete
546 operation would remove the last branch reference to a given line of
547 history (colloquially referred to as "dangling commits"), yap will
548 report an error and abort. The '-f' flag can be used to force the delete
549 in spite of this.
550 """)
551 @takes_options("fd:")
552 def cmd_branch(self, branch=None, **flags):
553 "[ [-f] -d <branch> | <branch> ]"
554 force = '-f' in flags
555 if '-d' in flags:
556 self._delete_branch(flags['-d'], force)
557 self.cmd_branch()
558 return
560 if branch is not None:
561 ref = get_output("git rev-parse --verify HEAD")
562 if not ref:
563 raise YapError("No branch point yet. Make a commit")
564 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
566 current = get_output("git symbolic-ref HEAD")[0]
567 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
568 for b in branches:
569 if b == current:
570 print "* ",
571 else:
572 print " ",
573 b = b.replace('refs/heads/', '')
574 print b
576 @short_help("change the current working branch")
577 @long_help("""
578 The argument is the name of the branch to make the current working
579 branch. This command will fail if there are uncommitted changes to any
580 files. Otherwise, the contents of the files in the working directory
581 are updated to reflect their state in the new branch. Additionally, any
582 future commits are added to the new branch instead of the previous line
583 of history.
584 """)
585 def cmd_switch(self, branch):
586 "<branch>"
587 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
588 if not ref:
589 raise YapError("No such branch: %s" % branch)
591 # XXX: support merging like git-checkout
592 if self._get_unstaged_files() or self._get_staged_files():
593 raise YapError("You have uncommitted changes. Commit them first")
595 run_safely("git symbolic-ref HEAD refs/heads/'%s'" % branch)
596 run_safely("git read-tree -u -m HEAD")
597 run_safely("git checkout-index -u -f -a")
598 self.cmd_branch()
600 @short_help("move the current branch to a different revision")
601 @long_help("""
602 The argument is the hash of the commit to which the current branch
603 should point, or alternately a branch or tag (a.k.a, "committish"). If
604 moving the branch would create "dangling commits" (see 'branch'), yap
605 will report an error and abort. The '-f' flag can be used to force the
606 operation in spite of this.
607 """)
608 @takes_options("f")
609 def cmd_point(self, where, **flags):
610 "<where>"
611 head = get_output("git rev-parse --verify HEAD")
612 if not head:
613 raise YapError("No commit yet; nowhere to point")
615 ref = get_output("git rev-parse --verify '%s'" % where)
616 if not ref:
617 raise YapError("Not a valid ref: %s" % where)
619 if self._get_unstaged_files() or self._get_staged_files():
620 raise YapError("You have uncommitted changes. Commit them first")
622 type = get_output("git cat-file -t '%s'" % ref[0])
623 if type and type[0] == "tag":
624 tag = get_output("git cat-file tag '%s'" % ref[0])
625 ref[0] = tag[0].split(' ')[1]
627 run_safely("git update-ref HEAD '%s'" % ref[0])
629 if '-f' not in flags:
630 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
631 if name == "undefined":
632 os.system("git update-ref HEAD '%s'" % head[0])
633 raise YapError("Pointing there will lose commits. Use -f to force")
635 run_safely("git read-tree -u -m HEAD")
636 run_safely("git checkout-index -u -f -a")
638 @short_help("alter history by dropping or amending commits")
639 @long_help("""
640 This command operates in two distinct modes, "amend" and "drop" mode.
641 In drop mode, the given commit is removed from the history of the
642 current branch, as though that commit never happened. By default the
643 commit used is HEAD.
645 In amend mode, the uncommitted changes present are merged into a
646 previous commit. This is useful for correcting typos or adding missed
647 files into past commits. By default the commit used is HEAD.
649 While rewriting history it is possible that conflicts will arise. If
650 this happens, the rewrite will pause and you will be prompted to resolve
651 the conflicts and stage them. Once that is done, you will run "yap
652 history continue." If instead you want the conflicting commit removed
653 from history (perhaps your changes supercede that commit) you can run
654 "yap history skip". Once the rewrite completes, your branch will be on
655 the same commit as when the rewrite started.
656 """)
657 def cmd_history(self, subcmd, *args):
658 "amend | drop <commit>"
660 if subcmd not in ("amend", "drop", "continue", "skip"):
661 raise TypeError
663 resolvemsg = """
664 When you have resolved the conflicts run \"yap history continue\".
665 To skip the problematic patch, run \"yap history skip\"."""
667 if subcmd == "continue":
668 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
669 return
670 if subcmd == "skip":
671 os.system("git reset --hard")
672 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
673 return
675 if subcmd == "amend":
676 flags, args = getopt.getopt(args, "ad")
677 flags = dict(flags)
679 if len(args) > 1:
680 raise TypeError
681 if args:
682 commit = args[0]
683 else:
684 commit = "HEAD"
686 if run_command("git rev-parse --verify '%s'" % commit):
687 raise YapError("Not a valid commit: %s" % commit)
689 self._check_rebasing()
691 if subcmd == "amend":
692 self._check_commit(**flags)
693 if self._get_unstaged_files():
694 # XXX: handle unstaged changes better
695 raise YapError("Commit away changes that you aren't amending")
697 try:
698 stash = get_output("git stash create")
699 run_command("git reset --hard")
700 if subcmd == "amend" and not stash:
701 raise YapError("Failed to stash; no changes?")
703 try:
704 fd, tmpfile = tempfile.mkstemp("yap")
705 os.close(fd)
706 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
707 if subcmd == "amend":
708 self.cmd_point(commit, **{'-f': True})
709 finally:
710 if subcmd == "amend":
711 rc = os.system("git stash apply --index %s" % stash[0])
712 if rc:
713 raise YapError("Failed to apply stash")
715 try:
716 if subcmd == "amend":
717 self._do_uncommit()
718 self._do_commit()
719 else:
720 self.cmd_point("%s^" % commit, **{'-f': True})
722 stat = os.stat(tmpfile)
723 size = stat[6]
724 if size > 0:
725 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
726 if (rc):
727 raise YapError("Failed to apply changes")
728 finally:
729 if stash:
730 run_command("git stash apply --index %s" % stash[0])
731 finally:
732 os.unlink(tmpfile)
733 self.cmd_status()
735 @short_help("show the changes introduced by a given commit")
736 @long_help("""
737 By default, the changes in the last commit are shown. To override this,
738 specify a hash, branch, or tag (committish). The hash of the commit,
739 the commit's author, log message, and a diff of the changes are shown.
740 """)
741 def cmd_show(self, commit="HEAD"):
742 "[commit]"
743 os.system("git show '%s'" % commit)
745 @short_help("apply the changes in a given commit to the current branch")
746 @long_help("""
747 The argument is the hash, branch, or tag (committish) of the commit to
748 be applied. In general, it only makes sense to apply commits that
749 happened on another branch. The '-r' flag can be used to have the
750 changes in the given commit reversed from the current branch. In
751 general, this only makes sense for commits that happened on the current
752 branch.
753 """)
754 @takes_options("r")
755 def cmd_cherry_pick(self, commit, **flags):
756 "[-r] <commit>"
757 if '-r' in flags:
758 os.system("git revert '%s'" % commit)
759 else:
760 os.system("git cherry-pick '%s'" % commit)
762 @short_help("list, add, or delete configured remote repositories")
763 @long_help("""
764 When invoked with no arguments, this command will show the list of
765 currently configured remote repositories, giving both the name and URL
766 of each. To add a new repository, give the desired name as the first
767 argument and the URL as the second. The '-d' flag can be used to remove
768 a previously added repository.
769 """)
770 @takes_options("d:")
771 def cmd_repo(self, name=None, url=None, **flags):
772 "[<name> <url> | -d <name>]"
773 if name is not None and url is None:
774 raise TypeError
776 if '-d' in flags:
777 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
778 raise YapError("No such repository: %s" % flags['-d'])
779 os.system("git config --unset remote.%s.url" % flags['-d'])
780 os.system("git config --unset remote.%s.fetch" % flags['-d'])
782 if name:
783 if name in [ x[0] for x in self._list_remotes() ]:
784 raise YapError("Repository '%s' already exists" % flags['-d'])
785 os.system("git config remote.%s.url %s" % (name, url))
786 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
788 for remote, url in self._list_remotes():
789 print "%-20s %s" % (remote, url)
791 @takes_options("cd")
792 def cmd_push(self, repo, **flags):
793 "[-c | -d] <repo>"
795 if repo not in [ x[0] for x in self._list_remotes() ]:
796 raise YapError("No such repository: %s" % repo)
798 current = get_output("git symbolic-ref HEAD")
799 if not current:
800 raise YapError("Not on a branch!")
801 ref = current[0]
802 current = current[0].replace('refs/heads/', '')
803 remote = get_output("git config branch.%s.remote" % current)
804 if remote and remote[0] == repo:
805 merge = get_output("git config branch.%s.merge" % current)
806 if merge:
807 ref = merge[0]
809 if '-c' not in flags and '-d' not in flags:
810 if run_command("git rev-parse --verify refs/remotes/%s/%s"
811 % (repo, ref.replace('refs/heads/', ''))):
812 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
814 if '-d' in flags:
815 lhs = ""
816 else:
817 lhs = "refs/heads/%s" % current
818 rc = os.system("git push %s %s:%s" % (repo, lhs, ref))
819 if rc:
820 raise YapError("Push failed.")
822 def cmd_fetch(self, repo):
823 "<repo>"
824 # XXX allow defaulting of repo? yap.default
825 if repo not in [ x[0] for x in self._list_remotes() ]:
826 raise YapError("No such repository: %s" % repo)
827 os.system("git fetch %s" % repo)
829 def cmd_update(self, subcmd=None):
830 "[continue | skip]"
831 if subcmd and subcmd not in ["continue", "skip"]:
832 raise TypeError
834 resolvemsg = """
835 When you have resolved the conflicts run \"yap history continue\".
836 To skip the problematic patch, run \"yap history skip\"."""
838 if subcmd == "continue":
839 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
840 return
841 if subcmd == "skip":
842 os.system("git reset --hard")
843 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
844 return
846 self._check_rebasing()
847 if self._get_unstaged_files() or self._get_staged_files():
848 raise YapError("You have uncommitted changes. Commit them first")
850 current = get_output("git symbolic-ref HEAD")
851 if not current:
852 raise YapError("Not on a branch!")
854 current = current[0].replace('refs/heads/', '')
855 remote = get_output("git config branch.%s.remote" % current)
856 if not remote:
857 raise YapError("No tracking branch configured for '%s'" % current)
859 merge = get_output("git config branch.%s.merge" % current)
860 if not merge:
861 raise YapError("No tracking branch configured for '%s'" % current)
862 merge = merge[0].replace('refs/heads/', '')
864 self.cmd_fetch(remote[0])
865 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote[0], merge))
867 try:
868 fd, tmpfile = tempfile.mkstemp("yap")
869 os.close(fd)
870 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
871 self.cmd_point("refs/remotes/%s/%s" % (remote[0], merge), **{'-f': True})
873 stat = os.stat(tmpfile)
874 size = stat[6]
875 if size > 0:
876 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
877 if (rc):
878 raise YapError("Failed to apply changes")
879 finally:
880 os.unlink(tmpfile)
882 def cmd_help(self, cmd=None):
883 if cmd is not None:
884 try:
885 attr = self.__getattribute__("cmd_"+cmd.replace('-', '_'))
886 except AttributeError:
887 raise YapError("No such command: %s" % cmd)
888 try:
889 help = attr.long_help
890 except AttributeError:
891 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
893 print >>sys.stderr, "The '%s' command" % cmd
894 print >>sys.stderr, "\tyap %s %s" % (cmd, attr.__doc__)
895 print >>sys.stderr, "%s" % help
896 return
898 print >> sys.stderr, "Yet Another (Git) Porcelein"
899 print >> sys.stderr
901 for name in dir(self):
902 if not name.startswith('cmd_'):
903 continue
904 attr = self.__getattribute__(name)
905 if not callable(attr):
906 continue
907 try:
908 short_msg = attr.short_help
909 except AttributeError:
910 continue
912 name = name.replace('cmd_', '')
913 name = name.replace('_', '-')
914 print >> sys.stderr, "%-16s%s" % (name, short_msg)
915 print >> sys.stderr
916 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
918 def cmd_usage(self):
919 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
920 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 history version"
922 def main(self, args):
923 if len(args) < 1:
924 self.cmd_usage()
925 sys.exit(2)
927 command = args[0]
928 args = args[1:]
930 debug = os.getenv('YAP_DEBUG')
932 try:
933 command = command.replace('-', '_')
935 meth = None
936 for p in self.plugins:
937 try:
938 meth = p.__getattribute__("cmd_"+command)
939 except AttributeError:
940 continue
942 try:
943 default_meth = self.__getattribute__("cmd_"+command)
944 except AttributeError:
945 default_meth = None
947 if meth is None:
948 meth = default_meth
949 if meth is None:
950 raise AttributeError
952 try:
953 if "options" in meth.__dict__:
954 options = meth.options
955 if default_meth and "options" in default_meth.__dict__:
956 options += default_meth.options
957 flags, args = getopt.getopt(args, options)
958 flags = dict(flags)
959 else:
960 flags = dict()
962 # invoke pre-hooks
963 for p in self.plugins:
964 try:
965 meth = p.__getattribute__("pre_"+command)
966 except AttributeError:
967 continue
968 meth(*args, **flags)
970 meth(*args, **flags)
972 # invoke post-hooks
973 for p in self.plugins:
974 try:
975 meth = p.__getattribute__("post_"+command)
976 except AttributeError:
977 continue
978 meth()
980 except (TypeError, getopt.GetoptError):
981 if debug:
982 raise
983 print "%s %s %s" % (sys.argv[0], command, meth.__doc__)
984 except YapError, e:
985 print >> sys.stderr, e
986 sys.exit(1)
987 except AttributeError:
988 if debug:
989 raise
990 self.cmd_usage()
991 sys.exit(2)