cmd_log: accept -p to show patches
[yap.git] / yap / yap.py
blob28ccb1bb2848902505d0b9326a97342174e640e2
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")[0]
132 current = current.replace('refs/heads/', '')
133 if branch == current:
134 raise YapError("Can't delete current branch")
136 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
137 if not ref:
138 raise YapError("No such branch: %s" % branch)
139 run_safely("git update-ref -d 'refs/heads/%s' '%s'" % (branch, ref[0]))
141 if not force:
142 name = get_output("git name-rev --name-only '%s'" % ref[0])[0]
143 if name == 'undefined':
144 run_command("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
145 raise YapError("Refusing to delete leaf branch (use -f to force)")
146 def _get_pager_cmd(self):
147 if 'YAP_PAGER' in os.environ:
148 return os.environ['YAP_PAGER']
149 elif 'GIT_PAGER' in os.environ:
150 return os.environ['GIT_PAGER']
151 elif 'PAGER' in os.environ:
152 return os.environ['PAGER']
153 else:
154 return "more"
156 def _add_one(self, file):
157 self._assert_file_exists(file)
158 x = get_output("git ls-files '%s'" % file)
159 if x != []:
160 raise YapError("File '%s' already in repository" % file)
161 self._add_new_file(file)
163 def _rm_one(self, file):
164 self._assert_file_exists(file)
165 if get_output("git ls-files '%s'" % file) != []:
166 run_safely("git rm --cached '%s'" % file)
167 self._remove_new_file(file)
169 def _stage_one(self, file, allow_unmerged=False):
170 self._assert_file_exists(file)
171 prefix = get_output("git rev-parse --show-prefix")
172 if prefix:
173 tmp = os.path.normpath(os.path.join(prefix[0], file))
174 else:
175 tmp = file
176 if not allow_unmerged and tmp in self._get_unmerged_files():
177 raise YapError("Refusing to stage conflicted file: %s" % file)
178 run_safely("git update-index --add '%s'" % file)
180 def _unstage_one(self, file):
181 self._assert_file_exists(file)
182 if run_command("git rev-parse HEAD"):
183 run_safely("git update-index --force-remove '%s'" % file)
184 else:
185 run_safely("git diff-index -p HEAD '%s' | git apply -R --cached" % file)
187 def _revert_one(self, file):
188 self._assert_file_exists(file)
189 self._unstage_one(file)
190 run_safely("git checkout-index -u -f '%s'" % file)
192 def _parse_commit(self, commit):
193 lines = get_output("git cat-file commit '%s'" % commit)
194 commit = {}
196 mode = None
197 for l in lines:
198 if mode != 'commit' and l.strip() == "":
199 mode = 'commit'
200 commit['log'] = []
201 continue
202 if mode == 'commit':
203 commit['log'].append(l)
204 continue
206 x = l.split(' ')
207 k = x[0]
208 v = ' '.join(x[1:])
209 commit[k] = v
210 commit['log'] = '\n'.join(commit['log'])
211 return commit
213 def _check_commit(self, **flags):
214 if '-a' in flags and '-d' in flags:
215 raise YapError("Conflicting flags: -a and -d")
217 if '-d' not in flags and self._get_unstaged_files():
218 if '-a' not in flags and self._get_staged_files():
219 raise YapError("Staged and unstaged changes present. Specify what to commit")
220 os.system("git diff-files -p | git apply --cached")
221 for f in self._get_new_files():
222 self._stage_one(f)
224 def _do_uncommit(self):
225 commit = self._parse_commit("HEAD")
226 repo = get_output('git rev-parse --git-dir')[0]
227 dir = os.path.join(repo, 'yap')
228 try:
229 os.mkdir(dir)
230 except OSError:
231 pass
232 msg_file = os.path.join(dir, 'msg')
233 fd = file(msg_file, 'w')
234 print >>fd, commit['log']
235 fd.close()
237 tree = get_output("git rev-parse --verify HEAD^")
238 run_safely("git update-ref -m uncommit HEAD '%s'" % tree[0])
240 def _do_commit(self, msg=None):
241 tree = get_output("git write-tree")[0]
242 parent = get_output("git rev-parse --verify HEAD 2> /dev/null")[0]
244 if os.environ.has_key('YAP_EDITOR'):
245 editor = os.environ['YAP_EDITOR']
246 elif os.environ.has_key('GIT_EDITOR'):
247 editor = os.environ['GIT_EDITOR']
248 elif os.environ.has_key('EDITOR'):
249 editor = os.environ['EDITOR']
250 else:
251 editor = "vi"
253 fd, tmpfile = tempfile.mkstemp("yap")
254 os.close(fd)
257 if msg is None:
258 repo = get_output('git rev-parse --git-dir')[0]
259 msg_file = os.path.join(repo, 'yap', 'msg')
260 if os.access(msg_file, os.R_OK):
261 fd1 = file(msg_file)
262 fd2 = file(tmpfile, 'w')
263 for l in fd1.xreadlines():
264 print >>fd2, l.strip()
265 fd2.close()
266 os.unlink(msg_file)
267 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
268 raise YapError("Editing commit message failed")
269 fd = file(tmpfile)
270 msg = fd.readlines()
271 msg = ''.join(msg)
273 msg = msg.strip()
274 if not msg:
275 raise YapError("Refusing to use empty commit message")
277 (fd_w, fd_r) = os.popen2("git stripspace > %s" % tmpfile)
278 print >>fd_w, msg,
279 fd_w.close()
280 fd_r.close()
282 if parent != 'HEAD':
283 commit = get_output("git commit-tree '%s' -p '%s' < '%s'" % (tree, parent, tmpfile))
284 else:
285 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
287 os.unlink(tmpfile)
288 run_safely("git update-ref HEAD '%s'" % commit[0])
290 def _check_rebasing(self):
291 repo = get_output('git rev-parse --git-dir')[0]
292 dotest = os.path.join(repo, '.dotest')
293 if os.access(dotest, os.R_OK):
294 raise YapError("A git operation is in progress. Complete it first")
295 dotest = os.path.join(repo, '..', '.dotest')
296 if os.access(dotest, os.R_OK):
297 raise YapError("A git operation is in progress. Complete it first")
299 def _list_remotes(self):
300 remotes = get_output("git config --get-regexp '^remote.*.url'")
301 for x in remotes:
302 remote, url = x.split(' ')
303 remote = remote.replace('remote.', '')
304 remote = remote.replace('.url', '')
305 yield remote, url
307 def _unstage_all(self):
308 try:
309 run_safely("git read-tree -m HEAD")
310 except ShellError:
311 run_safely("git read-tree HEAD")
312 run_safely("git update-index -q --refresh")
314 def _get_tracking(self, current):
315 remote = get_output("git config branch.%s.remote" % current)
316 if not remote:
317 raise YapError("No tracking branch configured for '%s'" % current)
319 merge = get_output("git config branch.%s.merge" % current)
320 if not merge:
321 raise YapError("No tracking branch configured for '%s'" % current)
322 return remote[0], merge
324 @short_help("make a local copy of an existing repository")
325 @long_help("""
326 The first argument is a URL to the existing repository. This can be an
327 absolute path if the repository is local, or a URL with the git://,
328 ssh://, or http:// schemes. By default, the directory used is the last
329 component of the URL, sans '.git'. This can be overridden by providing
330 a second argument.
331 """)
332 def cmd_clone(self, url, directory=None):
333 "<url> [directory]"
335 if '://' not in url and url[0] != '/':
336 url = os.path.join(os.getcwd(), url)
338 url = url.rstrip('/')
339 if directory is None:
340 directory = url.rsplit('/')[-1]
341 directory = directory.replace('.git', '')
343 try:
344 os.mkdir(directory)
345 except OSError:
346 raise YapError("Directory exists: %s" % directory)
347 os.chdir(directory)
348 self.cmd_init()
349 self.cmd_repo("origin", url)
350 self.cmd_fetch("origin")
352 branch = None
353 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
354 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
355 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
356 if get_output("git rev-parse %s" % b)[0] == hash:
357 branch = b
358 break
359 if branch is None:
360 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
361 branch = "refs/remotes/origin/master"
362 if branch is None:
363 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'")
364 branch = branch[0]
366 hash = get_output("git rev-parse %s" % branch)
367 assert hash
368 branch = branch.replace('refs/remotes/origin/', '')
369 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
370 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
371 self.cmd_revert(**{'-a': 1})
373 @short_help("turn a directory into a repository")
374 @long_help("""
375 Converts the current working directory into a repository. The primary
376 side-effect of this command is the creation of a '.git' subdirectory.
377 No files are added nor commits made.
378 """)
379 def cmd_init(self):
380 os.system("git init")
382 @short_help("add a new file to the repository")
383 @long_help("""
384 The arguments are the files to be added to the repository. Once added,
385 the files will show as "unstaged changes" in the output of 'status'. To
386 reverse the effects of this command, see 'rm'.
387 """)
388 def cmd_add(self, *files):
389 "<file>..."
390 if not files:
391 raise TypeError
393 for f in files:
394 self._add_one(f)
395 self.cmd_status()
397 @short_help("delete a file from the repository")
398 @long_help("""
399 The arguments are the files to be removed from the current revision of
400 the repository. The files will still exist in any past commits that the
401 files may have been a part of. The file is not actually deleted, it is
402 just no longer tracked as part of the repository.
403 """)
404 def cmd_rm(self, *files):
405 "<file>..."
406 if not files:
407 raise TypeError
409 for f in files:
410 self._rm_one(f)
411 self.cmd_status()
413 @short_help("stage changes in a file for commit")
414 @long_help("""
415 The arguments are the files to be staged. Staging changes is a way to
416 build up a commit when you do not want to commit all changes at once.
417 To commit only staged changes, use the '-d' flag to 'commit.' To
418 reverse the effects of this command, see 'unstage'. Once staged, the
419 files will show as "staged changes" in the output of 'status'.
420 """)
421 def cmd_stage(self, *files):
422 "<file>..."
423 if not files:
424 raise TypeError
426 for f in files:
427 self._stage_one(f)
428 self.cmd_status()
430 @short_help("unstage changes in a file")
431 @long_help("""
432 The arguments are the files to be unstaged. Once unstaged, the files
433 will show as "unstaged changes" in the output of 'status'. The '-a'
434 flag can be used to unstage all staged changes at once.
435 """)
436 @takes_options("a")
437 def cmd_unstage(self, *files, **flags):
438 "[-a] | <file>..."
439 if '-a' in flags:
440 self._unstage_all()
441 self.cmd_status()
442 return
444 if not files:
445 raise TypeError
447 for f in files:
448 self._unstage_one(f)
449 self.cmd_status()
451 @short_help("show files with staged and unstaged changes")
452 @long_help("""
453 Show the files in the repository with changes since the last commit,
454 categorized based on whether the changes are staged or not. A file may
455 appear under each heading if the same file has both staged and unstaged
456 changes.
457 """)
458 def cmd_status(self):
460 branch = get_output("git symbolic-ref HEAD")[0]
461 branch = branch.replace('refs/heads/', '')
462 print "Current branch: %s" % branch
464 print "Files with staged changes:"
465 files = self._get_staged_files()
466 for f in files:
467 print "\t%s" % f
468 if not files:
469 print "\t(none)"
471 print "Files with unstaged changes:"
472 files = self._get_unstaged_files()
473 for f in files:
474 print "\t%s" % f
475 if not files:
476 print "\t(none)"
478 files = self._get_unmerged_files()
479 if files:
480 print "Files with conflicts:"
481 for f in files:
482 print "\t%s" % f
484 @short_help("remove uncommitted changes from a file (*)")
485 @long_help("""
486 The arguments are the files whose changes will be reverted. If the '-a'
487 flag is given, then all files will have uncommitted changes removed.
488 Note that there is no way to reverse this command short of manually
489 editing each file again.
490 """)
491 @takes_options("a")
492 def cmd_revert(self, *files, **flags):
493 "(-a | <file>)"
494 if '-a' in flags:
495 self._unstage_all()
496 run_safely("git checkout-index -u -f -a")
497 self.cmd_status()
498 return
500 if not files:
501 raise TypeError
503 for f in files:
504 self._revert_one(f)
505 self.cmd_status()
507 @short_help("record changes to files as a new commit")
508 @long_help("""
509 Create a new commit recording changes since the last commit. If there
510 are only unstaged changes, those will be recorded. If there are only
511 staged changes, those will be recorded. Otherwise, you will have to
512 specify either the '-a' flag or the '-d' flag to commit all changes or
513 only staged changes, respectively. To reverse the effects of this
514 command, see 'uncommit'.
515 """)
516 @takes_options("adm:")
517 def cmd_commit(self, **flags):
518 "[-a | -d]"
519 self._check_rebasing()
520 self._check_commit(**flags)
521 if not self._get_staged_files():
522 raise YapError("No changes to commit")
523 msg = flags.get('-m', None)
524 self._do_commit(msg)
525 self.cmd_status()
527 @short_help("reverse the actions of the last commit")
528 @long_help("""
529 Reverse the effects of the last 'commit' operation. The changes that
530 were part of the previous commit will show as "staged changes" in the
531 output of 'status'. This means that if no files were changed since the
532 last commit was created, 'uncommit' followed by 'commit' is a lossless
533 operation.
534 """)
535 def cmd_uncommit(self):
537 self._do_uncommit()
538 self.cmd_status()
540 @short_help("report the current version of yap")
541 def cmd_version(self):
542 print "Yap version 0.1"
544 @short_help("show the changelog for particular versions or files")
545 @long_help("""
546 The arguments are the files with which to filter history. If none are
547 given, all changes are listed. Otherwise only commits that affected one
548 or more of the given files are listed. The -r option changes the
549 starting revision for traversing history. By default, history is listed
550 starting at HEAD.
551 """)
552 @takes_options("pr:")
553 def cmd_log(self, *paths, **flags):
554 "[-p] [-r <rev>] <path>..."
555 rev = flags.get('-r', 'HEAD')
556 paths = ' '.join(paths)
557 if '-p' in flags:
558 flags['-p'] = '-p'
559 os.system("git log %s '%s' -- %s"
560 % (flags.get('-p', '--name-status'), rev, paths))
562 @short_help("show staged, unstaged, or all uncommitted changes")
563 @long_help("""
564 Show staged, unstaged, or all uncommitted changes. By default, all
565 changes are shown. The '-u' flag causes only unstaged changes to be
566 shown. The '-d' flag causes only staged changes to be shown.
567 """)
568 @takes_options("ud")
569 def cmd_diff(self, **flags):
570 "[ -u | -d ]"
571 if '-u' in flags and '-d' in flags:
572 raise YapError("Conflicting flags: -u and -d")
574 pager = self._get_pager_cmd()
576 if '-u' in flags:
577 os.system("git diff-files -p | %s" % pager)
578 elif '-d' in flags:
579 os.system("git diff-index --cached -p HEAD | %s" % pager)
580 else:
581 os.system("git diff-index -p HEAD | %s" % pager)
583 @short_help("list, create, or delete branches")
584 @long_help("""
585 If no arguments are specified, a list of local branches is given. The
586 current branch is indicated by a "*" next to the name. If an argument
587 is given, it is taken as the name of a new branch to create. The branch
588 will start pointing at the current HEAD. See 'point' for details on
589 changing the revision of the new branch. Note that this command does
590 not switch the current working branch. See 'switch' for details on
591 changing the current working branch.
593 The '-d' flag can be used to delete local branches. If the delete
594 operation would remove the last branch reference to a given line of
595 history (colloquially referred to as "dangling commits"), yap will
596 report an error and abort. The '-f' flag can be used to force the delete
597 in spite of this.
598 """)
599 @takes_options("fd:")
600 def cmd_branch(self, branch=None, **flags):
601 "[ [-f] -d <branch> | <branch> ]"
602 force = '-f' in flags
603 if '-d' in flags:
604 self._delete_branch(flags['-d'], force)
605 self.cmd_branch()
606 return
608 if branch is not None:
609 ref = get_output("git rev-parse --verify HEAD")
610 if not ref:
611 raise YapError("No branch point yet. Make a commit")
612 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
614 current = get_output("git symbolic-ref HEAD")[0]
615 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
616 for b in branches:
617 if b == current:
618 print "* ",
619 else:
620 print " ",
621 b = b.replace('refs/heads/', '')
622 print b
624 @short_help("change the current working branch")
625 @long_help("""
626 The argument is the name of the branch to make the current working
627 branch. This command will fail if there are uncommitted changes to any
628 files. Otherwise, the contents of the files in the working directory
629 are updated to reflect their state in the new branch. Additionally, any
630 future commits are added to the new branch instead of the previous line
631 of history.
632 """)
633 @takes_options("f")
634 def cmd_switch(self, branch, **flags):
635 "[-f] <branch>"
636 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
637 if not ref:
638 raise YapError("No such branch: %s" % branch)
640 if '-f' not in flags and (self._get_unstaged_files() or self._get_staged_files()):
641 raise YapError("You have uncommitted changes. Use -f to continue anyway")
643 if self._get_unstaged_files() and self._get_staged_files():
644 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
646 staged = bool(self._get_staged_files())
648 run_command("git diff-files -p | git apply --cached")
649 for f in self._get_new_files():
650 self._stage_one(f)
652 idx = get_output("git write-tree")
653 new = get_output("git rev-parse refs/heads/%s" % branch)
654 run_safely("git read-tree --aggressive -u -m HEAD %s %s" % (idx[0], new[0]))
655 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
657 if not staged:
658 self._unstage_all()
659 self.cmd_status()
661 @short_help("move the current branch to a different revision")
662 @long_help("""
663 The argument is the hash of the commit to which the current branch
664 should point, or alternately a branch or tag (a.k.a, "committish"). If
665 moving the branch would create "dangling commits" (see 'branch'), yap
666 will report an error and abort. The '-f' flag can be used to force the
667 operation in spite of this.
668 """)
669 @takes_options("f")
670 def cmd_point(self, where, **flags):
671 "[-f] <where>"
672 head = get_output("git rev-parse --verify HEAD")
673 if not head:
674 raise YapError("No commit yet; nowhere to point")
676 ref = get_output("git rev-parse --verify '%s'" % where)
677 if not ref:
678 raise YapError("Not a valid ref: %s" % where)
680 if self._get_unstaged_files() or self._get_staged_files():
681 raise YapError("You have uncommitted changes. Commit them first")
683 type = get_output("git cat-file -t '%s'" % ref[0])
684 if type and type[0] == "tag":
685 tag = get_output("git cat-file tag '%s'" % ref[0])
686 ref[0] = tag[0].split(' ')[1]
688 run_safely("git update-ref HEAD '%s'" % ref[0])
690 if '-f' not in flags:
691 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
692 if name == "undefined":
693 os.system("git update-ref HEAD '%s'" % head[0])
694 raise YapError("Pointing there will lose commits. Use -f to force")
696 try:
697 run_safely("git read-tree -u -m HEAD")
698 except ShellError:
699 run_safely("git read-tree HEAD")
700 run_safely("git checkout-index -u -f -a")
702 @short_help("alter history by dropping or amending commits")
703 @long_help("""
704 This command operates in two distinct modes, "amend" and "drop" mode.
705 In drop mode, the given commit is removed from the history of the
706 current branch, as though that commit never happened. By default the
707 commit used is HEAD.
709 In amend mode, the uncommitted changes present are merged into a
710 previous commit. This is useful for correcting typos or adding missed
711 files into past commits. By default the commit used is HEAD.
713 While rewriting history it is possible that conflicts will arise. If
714 this happens, the rewrite will pause and you will be prompted to resolve
715 the conflicts and stage them. Once that is done, you will run "yap
716 history continue." If instead you want the conflicting commit removed
717 from history (perhaps your changes supercede that commit) you can run
718 "yap history skip". Once the rewrite completes, your branch will be on
719 the same commit as when the rewrite started.
720 """)
721 def cmd_history(self, subcmd, *args):
722 "amend | drop <commit>"
724 if subcmd not in ("amend", "drop", "continue", "skip"):
725 raise TypeError
727 resolvemsg = """
728 When you have resolved the conflicts run \"yap history continue\".
729 To skip the problematic patch, run \"yap history skip\"."""
731 if subcmd == "continue":
732 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
733 return
734 if subcmd == "skip":
735 os.system("git reset --hard")
736 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
737 return
739 if subcmd == "amend":
740 flags, args = getopt.getopt(args, "ad")
741 flags = dict(flags)
743 if len(args) > 1:
744 raise TypeError
745 if args:
746 commit = args[0]
747 else:
748 commit = "HEAD"
750 if run_command("git rev-parse --verify '%s'" % commit):
751 raise YapError("Not a valid commit: %s" % commit)
753 self._check_rebasing()
755 if subcmd == "amend":
756 self._check_commit(**flags)
757 if self._get_unstaged_files():
758 # XXX: handle unstaged changes better
759 raise YapError("Commit away changes that you aren't amending")
761 self._unstage_all()
763 start = get_output("git rev-parse HEAD")
764 stash = get_output("git stash create")
765 run_command("git reset --hard")
766 try:
767 fd, tmpfile = tempfile.mkstemp("yap")
768 try:
769 try:
770 os.close(fd)
771 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
772 if subcmd == "amend":
773 self.cmd_point(commit, **{'-f': True})
774 finally:
775 if subcmd == "amend":
776 if stash:
777 rc = os.system("git stash apply %s" % stash[0])
778 if rc:
779 self.cmd_point(start[0], **{'-f': True})
780 os.system("git stash apply %s" % stash[0])
781 raise YapError("Failed to apply stash")
782 stash = None
784 if subcmd == "amend":
785 self._do_uncommit()
786 for f in self._get_unstaged_files():
787 self._stage_one(f)
788 self._do_commit()
789 else:
790 self.cmd_point("%s^" % commit, **{'-f': True})
792 stat = os.stat(tmpfile)
793 size = stat[6]
794 if size > 0:
795 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
796 if (rc):
797 raise YapError("Failed to apply changes")
798 finally:
799 os.unlink(tmpfile)
800 finally:
801 if stash:
802 run_command("git stash apply %s" % stash[0])
803 self.cmd_status()
805 @short_help("show the changes introduced by a given commit")
806 @long_help("""
807 By default, the changes in the last commit are shown. To override this,
808 specify a hash, branch, or tag (committish). The hash of the commit,
809 the commit's author, log message, and a diff of the changes are shown.
810 """)
811 def cmd_show(self, commit="HEAD"):
812 "[commit]"
813 os.system("git show '%s'" % commit)
815 @short_help("apply the changes in a given commit to the current branch")
816 @long_help("""
817 The argument is the hash, branch, or tag (committish) of the commit to
818 be applied. In general, it only makes sense to apply commits that
819 happened on another branch. The '-r' flag can be used to have the
820 changes in the given commit reversed from the current branch. In
821 general, this only makes sense for commits that happened on the current
822 branch.
823 """)
824 @takes_options("r")
825 def cmd_cherry_pick(self, commit, **flags):
826 "[-r] <commit>"
827 if '-r' in flags:
828 os.system("git revert '%s'" % commit)
829 else:
830 os.system("git cherry-pick '%s'" % commit)
832 @short_help("list, add, or delete configured remote repositories")
833 @long_help("""
834 When invoked with no arguments, this command will show the list of
835 currently configured remote repositories, giving both the name and URL
836 of each. To add a new repository, give the desired name as the first
837 argument and the URL as the second. The '-d' flag can be used to remove
838 a previously added repository.
839 """)
840 @takes_options("d:")
841 def cmd_repo(self, name=None, url=None, **flags):
842 "[<name> <url> | -d <name>]"
843 if name is not None and url is None:
844 raise TypeError
846 if '-d' in flags:
847 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
848 raise YapError("No such repository: %s" % flags['-d'])
849 os.system("git config --unset remote.%s.url" % flags['-d'])
850 os.system("git config --unset remote.%s.fetch" % flags['-d'])
852 if name:
853 if name in [ x[0] for x in self._list_remotes() ]:
854 raise YapError("Repository '%s' already exists" % flags['-d'])
855 os.system("git config remote.%s.url %s" % (name, url))
856 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
858 for remote, url in self._list_remotes():
859 print "%-20s %s" % (remote, url)
861 @takes_options("cdf")
862 @short_help("send local commits to a remote repository")
863 def cmd_push(self, repo=None, rhs=None, **flags):
864 "[-c | -d] <repo>"
866 if '-c' in flags and '-d' in flags:
867 raise TypeError
869 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
870 raise YapError("No such repository: %s" % repo)
872 current = get_output("git symbolic-ref HEAD")
873 if not current:
874 raise YapError("Not on a branch!")
876 current = current[0].replace('refs/heads/', '')
877 remote = get_output("git config branch.%s.remote" % current)
878 if repo is None and remote:
879 repo = remote[0]
881 if repo is None:
882 raise YapError("No tracking branch configured; specify destination repository")
884 if rhs is None and remote and remote[0] == repo:
885 merge = get_output("git config branch.%s.merge" % current)
886 if merge:
887 rhs = merge[0]
889 if rhs is None:
890 rhs = "refs/heads/%s" % current
892 if '-c' not in flags and '-d' not in flags:
893 if run_command("git rev-parse --verify refs/remotes/%s/%s"
894 % (repo, rhs.replace('refs/heads/', ''))):
895 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
896 if '-f' not in flags:
897 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo, rhs.replace('refs/heads/', '')))
898 base = get_output("git merge-base HEAD %s" % hash[0])
899 assert base
900 if base[0] != hash[0]:
901 raise YapError("Branch not up-to-date with remote. Update or use -f")
903 print "About to push local branch '%s' to '%s' on '%s'" % (current, rhs, repo)
904 print "Continue (y/n)? ",
905 sys.stdout.flush()
906 ans = sys.stdin.readline().strip()
908 if ans.lower() != 'y' and ans.lower() != 'yes':
909 raise YapError("Aborted.")
911 if '-f' in flags:
912 flags['-f'] = '-f'
914 if '-d' in flags:
915 lhs = ""
916 else:
917 lhs = "refs/heads/%s" % current
918 rc = os.system("git push %s %s %s:%s" % (flags.get('-f', ''), repo, lhs, rhs))
919 if rc:
920 raise YapError("Push failed.")
922 @short_help("retrieve commits from a remote repository")
923 def cmd_fetch(self, repo=None):
924 "<repo>"
925 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
926 raise YapError("No such repository: %s" % repo)
927 if repo is None:
928 remote = get_output("git config branch.%s.remote" % current)
929 repo = remote[0]
930 if repo is None:
931 raise YapError("No tracking branch configured; specify a repository")
932 os.system("git fetch %s" % repo)
934 @short_help("update the current branch relative to its tracking branch")
935 def cmd_update(self, subcmd=None):
936 "[continue | skip]"
937 if subcmd and subcmd not in ["continue", "skip"]:
938 raise TypeError
940 resolvemsg = """
941 When you have resolved the conflicts run \"yap update continue\".
942 To skip the problematic patch, run \"yap update skip\"."""
944 if subcmd == "continue":
945 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
946 return
947 if subcmd == "skip":
948 os.system("git reset --hard")
949 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
950 return
952 self._check_rebasing()
953 if self._get_unstaged_files() or self._get_staged_files():
954 raise YapError("You have uncommitted changes. Commit them first")
956 current = get_output("git symbolic-ref HEAD")
957 if not current:
958 raise YapError("Not on a branch!")
960 current = current[0].replace('refs/heads/', '')
961 remote, merge = self._get_tracking(current)
962 merge = merge[0].replace('refs/heads/', '')
964 self.cmd_fetch(remote)
965 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
967 try:
968 fd, tmpfile = tempfile.mkstemp("yap")
969 os.close(fd)
970 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
971 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
973 stat = os.stat(tmpfile)
974 size = stat[6]
975 if size > 0:
976 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
977 if (rc):
978 raise YapError("Failed to apply changes")
979 finally:
980 os.unlink(tmpfile)
982 @short_help("query and configure remote branch tracking")
983 def cmd_track(self, repo=None, branch=None):
984 "[<repo> <branch>]"
986 current = get_output("git symbolic-ref HEAD")
987 if not current:
988 raise YapError("Not on a branch!")
989 current = current[0].replace('refs/heads/', '')
991 if repo is None and branch is None:
992 repo, merge = self._get_tracking(current)
993 merge = merge[0].replace('refs/heads/', '')
994 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
995 return
997 if repo is None or branch is None:
998 raise TypeError
1000 if repo not in [ x[0] for x in self._list_remotes() ]:
1001 raise YapError("No such repository: %s" % repo)
1003 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
1004 raise YapError("No such branch '%s' on repository '%s'" % (repo, branch))
1006 os.system("git config branch.%s.remote '%s'" % (current, repo))
1007 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
1008 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
1010 @short_help("mark files with conflicts as resolved")
1011 def cmd_resolved(self, *args):
1012 "<file>..."
1013 if not files:
1014 raise TypeError
1016 for f in files:
1017 self._stage_one(f, True)
1018 self.cmd_status()
1020 def cmd_help(self, cmd=None):
1021 if cmd is not None:
1022 try:
1023 attr = self.__getattribute__("cmd_"+cmd.replace('-', '_'))
1024 except AttributeError:
1025 raise YapError("No such command: %s" % cmd)
1026 try:
1027 help = attr.long_help
1028 except AttributeError:
1029 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
1031 print >>sys.stderr, "The '%s' command" % cmd
1032 print >>sys.stderr, "\tyap %s %s" % (cmd, attr.__doc__)
1033 print >>sys.stderr, "%s" % help
1034 return
1036 print >> sys.stderr, "Yet Another (Git) Porcelein"
1037 print >> sys.stderr
1039 for name in dir(self):
1040 if not name.startswith('cmd_'):
1041 continue
1042 attr = self.__getattribute__(name)
1043 if not callable(attr):
1044 continue
1045 try:
1046 short_msg = attr.short_help
1047 except AttributeError:
1048 continue
1050 name = name.replace('cmd_', '')
1051 name = name.replace('_', '-')
1052 print >> sys.stderr, "%-16s%s" % (name, short_msg)
1053 print >> sys.stderr
1054 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
1056 def cmd_usage(self):
1057 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
1058 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"
1060 def main(self, args):
1061 if len(args) < 1:
1062 self.cmd_usage()
1063 sys.exit(2)
1065 command = args[0]
1066 args = args[1:]
1068 debug = os.getenv('YAP_DEBUG')
1070 try:
1071 command = command.replace('-', '_')
1073 meth = None
1074 for p in self.plugins:
1075 try:
1076 meth = p.__getattribute__("cmd_"+command)
1077 except AttributeError:
1078 continue
1080 try:
1081 default_meth = self.__getattribute__("cmd_"+command)
1082 except AttributeError:
1083 default_meth = None
1085 if meth is None:
1086 meth = default_meth
1087 if meth is None:
1088 raise AttributeError
1090 try:
1091 if "options" in meth.__dict__:
1092 options = meth.options
1093 if default_meth and "options" in default_meth.__dict__:
1094 options += default_meth.options
1095 flags, args = getopt.getopt(args, options)
1096 flags = dict(flags)
1097 else:
1098 flags = dict()
1100 # invoke pre-hooks
1101 for p in self.plugins:
1102 try:
1103 meth = p.__getattribute__("pre_"+command)
1104 except AttributeError:
1105 continue
1106 meth(*args, **flags)
1108 meth(*args, **flags)
1110 # invoke post-hooks
1111 for p in self.plugins:
1112 try:
1113 meth = p.__getattribute__("post_"+command)
1114 except AttributeError:
1115 continue
1116 meth()
1118 except (TypeError, getopt.GetoptError):
1119 if debug:
1120 raise
1121 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, meth.__doc__)
1122 except YapError, e:
1123 print >> sys.stderr, e
1124 sys.exit(1)
1125 except AttributeError:
1126 if debug:
1127 raise
1128 self.cmd_usage()
1129 sys.exit(2)