Write long_help for the new commands
[yap.git] / yap / yap.py
blob43e1d4a69366cd8323203e77a5f773b63a30eace
1 import sys
2 import os
3 import glob
4 import getopt
5 import pickle
6 import tempfile
8 from plugin import YapPlugin
9 from util import *
11 class ShellError(Exception):
12 def __init__(self, cmd, rc):
13 self.cmd = cmd
14 self.rc = rc
16 def __str__(self):
17 return "%s returned %d" % (self.cmd, self.rc)
19 class YapError(Exception):
20 def __init__(self, msg):
21 self.msg = msg
23 def __str__(self):
24 return self.msg
26 class Yap(object):
27 def __init__(self):
28 self.plugins = set()
29 self.overrides = []
30 plugindir = os.path.expanduser("~/.yap/plugins")
31 for p in glob.glob(os.path.join(plugindir, "*.py")):
32 glbls = {}
33 execfile(p, glbls)
34 for cls in glbls.values():
35 if not type(cls) == type:
36 continue
37 if not issubclass(cls, YapPlugin):
38 continue
39 if cls is YapPlugin:
40 continue
41 x = cls(self)
42 self.plugins.add(x)
44 for func in dir(x):
45 if not func.startswith('cmd_'):
46 continue
47 if func in self.overrides:
48 print >>sys.stderr, "Plugin %s overrides already overridden function %s. Disabling" % (p, func)
49 self.plugins.remove(x)
50 break
52 def _add_new_file(self, file):
53 repo = get_output('git rev-parse --git-dir')[0]
54 dir = os.path.join(repo, 'yap')
55 try:
56 os.mkdir(dir)
57 except OSError:
58 pass
59 files = self._get_new_files()
60 files.append(file)
61 path = os.path.join(dir, 'new-files')
62 pickle.dump(files, open(path, 'w'))
64 def _get_new_files(self):
65 repo = get_output('git rev-parse --git-dir')[0]
66 path = os.path.join(repo, 'yap', 'new-files')
67 try:
68 files = pickle.load(file(path))
69 except IOError:
70 files = []
72 x = []
73 for f in files:
74 # if f in the index
75 if get_output("git ls-files --cached '%s'" % f) != []:
76 continue
77 x.append(f)
78 return x
80 def _remove_new_file(self, file):
81 files = self._get_new_files()
82 files = filter(lambda x: x != file, files)
84 repo = get_output('git rev-parse --git-dir')[0]
85 path = os.path.join(repo, 'yap', 'new-files')
86 pickle.dump(files, open(path, 'w'))
88 def _clear_new_files(self):
89 repo = get_output('git rev-parse --git-dir')[0]
90 path = os.path.join(repo, 'yap', 'new-files')
91 os.unlink(path)
93 def _assert_file_exists(self, file):
94 if not os.access(file, os.R_OK):
95 raise YapError("No such file: %s" % file)
97 def _get_staged_files(self):
98 if run_command("git rev-parse HEAD"):
99 files = get_output("git ls-files --cached")
100 else:
101 files = get_output("git diff-index --cached --name-only HEAD")
102 unmerged = self._get_unmerged_files()
103 if unmerged:
104 unmerged = set(unmerged)
105 files = set(files).difference(unmerged)
106 files = list(files)
107 return files
109 def _get_unstaged_files(self):
110 files = get_output("git ls-files -m")
111 prefix = get_output("git rev-parse --show-prefix")
112 if prefix:
113 files = [ os.path.join(prefix[0], x) for x in files ]
114 files += self._get_new_files()
115 unmerged = self._get_unmerged_files()
116 if unmerged:
117 unmerged = set(unmerged)
118 files = set(files).difference(unmerged)
119 files = list(files)
120 return files
122 def _get_unmerged_files(self):
123 files = get_output("git ls-files -u")
124 files = [ x.replace('\t', ' ').split(' ')[3] for x in files ]
125 prefix = get_output("git rev-parse --show-prefix")
126 if prefix:
127 files = [ os.path.join(prefix[0], x) for x in files ]
128 return list(set(files))
130 def _delete_branch(self, branch, force):
131 current = get_output("git symbolic-ref HEAD")
132 if current:
133 current = current[0].replace('refs/heads/', '')
134 if branch == current:
135 raise YapError("Can't delete current branch")
137 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
138 if not ref:
139 raise YapError("No such branch: %s" % branch)
140 run_safely("git update-ref -d 'refs/heads/%s' '%s'" % (branch, ref[0]))
142 if not force:
143 name = get_output("git name-rev --name-only '%s'" % ref[0])[0]
144 if name == 'undefined':
145 run_command("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
146 raise YapError("Refusing to delete leaf branch (use -f to force)")
147 def _get_pager_cmd(self):
148 if 'YAP_PAGER' in os.environ:
149 return os.environ['YAP_PAGER']
150 elif 'GIT_PAGER' in os.environ:
151 return os.environ['GIT_PAGER']
152 elif 'PAGER' in os.environ:
153 return os.environ['PAGER']
154 else:
155 return "more"
157 def _add_one(self, file):
158 self._assert_file_exists(file)
159 x = get_output("git ls-files '%s'" % file)
160 if x != []:
161 raise YapError("File '%s' already in repository" % file)
162 self._add_new_file(file)
164 def _rm_one(self, file):
165 self._assert_file_exists(file)
166 if get_output("git ls-files '%s'" % file) != []:
167 run_safely("git rm --cached '%s'" % file)
168 self._remove_new_file(file)
170 def _stage_one(self, file, allow_unmerged=False):
171 self._assert_file_exists(file)
172 prefix = get_output("git rev-parse --show-prefix")
173 if prefix:
174 tmp = os.path.normpath(os.path.join(prefix[0], file))
175 else:
176 tmp = file
177 if not allow_unmerged and tmp in self._get_unmerged_files():
178 raise YapError("Refusing to stage conflicted file: %s" % file)
179 run_safely("git update-index --add '%s'" % file)
181 def _unstage_one(self, file):
182 self._assert_file_exists(file)
183 if run_command("git rev-parse HEAD"):
184 run_safely("git update-index --force-remove '%s'" % file)
185 else:
186 run_safely("git diff-index -p HEAD '%s' | git apply -R --cached" % file)
188 def _revert_one(self, file):
189 self._assert_file_exists(file)
190 self._unstage_one(file)
191 run_safely("git checkout-index -u -f '%s'" % file)
193 def _parse_commit(self, commit):
194 lines = get_output("git cat-file commit '%s'" % commit)
195 commit = {}
197 mode = None
198 for l in lines:
199 if mode != 'commit' and l.strip() == "":
200 mode = 'commit'
201 commit['log'] = []
202 continue
203 if mode == 'commit':
204 commit['log'].append(l)
205 continue
207 x = l.split(' ')
208 k = x[0]
209 v = ' '.join(x[1:])
210 commit[k] = v
211 commit['log'] = '\n'.join(commit['log'])
212 return commit
214 def _check_commit(self, **flags):
215 if '-a' in flags and '-d' in flags:
216 raise YapError("Conflicting flags: -a and -d")
218 if '-d' not in flags and self._get_unstaged_files():
219 if '-a' not in flags and self._get_staged_files():
220 raise YapError("Staged and unstaged changes present. Specify what to commit")
221 os.system("git diff-files -p | git apply --cached")
222 for f in self._get_new_files():
223 self._stage_one(f)
225 def _do_uncommit(self):
226 commit = self._parse_commit("HEAD")
227 repo = get_output('git rev-parse --git-dir')[0]
228 dir = os.path.join(repo, 'yap')
229 try:
230 os.mkdir(dir)
231 except OSError:
232 pass
233 msg_file = os.path.join(dir, 'msg')
234 fd = file(msg_file, 'w')
235 print >>fd, commit['log']
236 fd.close()
238 tree = get_output("git rev-parse --verify HEAD^")
239 run_safely("git update-ref -m uncommit HEAD '%s'" % tree[0])
241 def _do_commit(self, msg=None):
242 tree = get_output("git write-tree")[0]
243 parent = get_output("git rev-parse --verify HEAD 2> /dev/null")[0]
245 if os.environ.has_key('YAP_EDITOR'):
246 editor = os.environ['YAP_EDITOR']
247 elif os.environ.has_key('GIT_EDITOR'):
248 editor = os.environ['GIT_EDITOR']
249 elif os.environ.has_key('EDITOR'):
250 editor = os.environ['EDITOR']
251 else:
252 editor = "vi"
254 fd, tmpfile = tempfile.mkstemp("yap")
255 os.close(fd)
258 if msg is None:
259 repo = get_output('git rev-parse --git-dir')[0]
260 msg_file = os.path.join(repo, 'yap', 'msg')
261 if os.access(msg_file, os.R_OK):
262 fd1 = file(msg_file)
263 fd2 = file(tmpfile, 'w')
264 for l in fd1.xreadlines():
265 print >>fd2, l.strip()
266 fd2.close()
267 os.unlink(msg_file)
268 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
269 raise YapError("Editing commit message failed")
270 fd = file(tmpfile)
271 msg = fd.readlines()
272 msg = ''.join(msg)
274 msg = msg.strip()
275 if not msg:
276 raise YapError("Refusing to use empty commit message")
278 (fd_w, fd_r) = os.popen2("git stripspace > %s" % tmpfile)
279 print >>fd_w, msg,
280 fd_w.close()
281 fd_r.close()
283 if parent != 'HEAD':
284 commit = get_output("git commit-tree '%s' -p '%s' < '%s'" % (tree, parent, tmpfile))
285 else:
286 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
288 os.unlink(tmpfile)
289 run_safely("git update-ref HEAD '%s'" % commit[0])
291 def _check_rebasing(self):
292 repo = get_output('git rev-parse --git-dir')[0]
293 dotest = os.path.join(repo, '.dotest')
294 if os.access(dotest, os.R_OK):
295 raise YapError("A git operation is in progress. Complete it first")
296 dotest = os.path.join(repo, '..', '.dotest')
297 if os.access(dotest, os.R_OK):
298 raise YapError("A git operation is in progress. Complete it first")
300 def _list_remotes(self):
301 remotes = get_output("git config --get-regexp '^remote.*.url'")
302 for x in remotes:
303 remote, url = x.split(' ')
304 remote = remote.replace('remote.', '')
305 remote = remote.replace('.url', '')
306 yield remote, url
308 def _unstage_all(self):
309 try:
310 run_safely("git read-tree -m HEAD")
311 except ShellError:
312 run_safely("git read-tree HEAD")
313 run_safely("git update-index -q --refresh")
315 def _get_tracking(self, current):
316 remote = get_output("git config branch.%s.remote" % current)
317 if not remote:
318 raise YapError("No tracking branch configured for '%s'" % current)
320 merge = get_output("git config branch.%s.merge" % current)
321 if not merge:
322 raise YapError("No tracking branch configured for '%s'" % current)
323 return remote[0], merge
325 @short_help("make a local copy of an existing repository")
326 @long_help("""
327 The first argument is a URL to the existing repository. This can be an
328 absolute path if the repository is local, or a URL with the git://,
329 ssh://, or http:// schemes. By default, the directory used is the last
330 component of the URL, sans '.git'. This can be overridden by providing
331 a second argument.
332 """)
333 def cmd_clone(self, url, directory=None):
334 "<url> [directory]"
336 if '://' not in url and url[0] != '/':
337 url = os.path.join(os.getcwd(), url)
339 url = url.rstrip('/')
340 if directory is None:
341 directory = url.rsplit('/')[-1]
342 directory = directory.replace('.git', '')
344 try:
345 os.mkdir(directory)
346 except OSError:
347 raise YapError("Directory exists: %s" % directory)
348 os.chdir(directory)
349 self.cmd_init()
350 self.cmd_repo("origin", url)
351 self.cmd_fetch("origin")
353 branch = None
354 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
355 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
356 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
357 if get_output("git rev-parse %s" % b)[0] == hash:
358 branch = b
359 break
360 if branch is None:
361 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
362 branch = "refs/remotes/origin/master"
363 if branch is None:
364 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'")
365 branch = branch[0]
367 hash = get_output("git rev-parse %s" % branch)
368 assert hash
369 branch = branch.replace('refs/remotes/origin/', '')
370 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
371 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
372 self.cmd_revert(**{'-a': 1})
374 @short_help("turn a directory into a repository")
375 @long_help("""
376 Converts the current working directory into a repository. The primary
377 side-effect of this command is the creation of a '.git' subdirectory.
378 No files are added nor commits made.
379 """)
380 def cmd_init(self):
381 os.system("git init")
383 @short_help("add a new file to the repository")
384 @long_help("""
385 The arguments are the files to be added to the repository. Once added,
386 the files will show as "unstaged changes" in the output of 'status'. To
387 reverse the effects of this command, see 'rm'.
388 """)
389 def cmd_add(self, *files):
390 "<file>..."
391 if not files:
392 raise TypeError
394 for f in files:
395 self._add_one(f)
396 self.cmd_status()
398 @short_help("delete a file from the repository")
399 @long_help("""
400 The arguments are the files to be removed from the current revision of
401 the repository. The files will still exist in any past commits that the
402 files may have been a part of. The file is not actually deleted, it is
403 just no longer tracked as part of the repository.
404 """)
405 def cmd_rm(self, *files):
406 "<file>..."
407 if not files:
408 raise TypeError
410 for f in files:
411 self._rm_one(f)
412 self.cmd_status()
414 @short_help("stage changes in a file for commit")
415 @long_help("""
416 The arguments are the files to be staged. Staging changes is a way to
417 build up a commit when you do not want to commit all changes at once.
418 To commit only staged changes, use the '-d' flag to 'commit.' To
419 reverse the effects of this command, see 'unstage'. Once staged, the
420 files will show as "staged changes" in the output of 'status'.
421 """)
422 def cmd_stage(self, *files):
423 "<file>..."
424 if not files:
425 raise TypeError
427 for f in files:
428 self._stage_one(f)
429 self.cmd_status()
431 @short_help("unstage changes in a file")
432 @long_help("""
433 The arguments are the files to be unstaged. Once unstaged, the files
434 will show as "unstaged changes" in the output of 'status'. The '-a'
435 flag can be used to unstage all staged changes at once.
436 """)
437 @takes_options("a")
438 def cmd_unstage(self, *files, **flags):
439 "[-a] | <file>..."
440 if '-a' in flags:
441 self._unstage_all()
442 self.cmd_status()
443 return
445 if not files:
446 raise TypeError
448 for f in files:
449 self._unstage_one(f)
450 self.cmd_status()
452 @short_help("show files with staged and unstaged changes")
453 @long_help("""
454 Show the files in the repository with changes since the last commit,
455 categorized based on whether the changes are staged or not. A file may
456 appear under each heading if the same file has both staged and unstaged
457 changes.
458 """)
459 def cmd_status(self):
461 branch = get_output("git symbolic-ref HEAD")
462 if branch:
463 branch = branch[0].replace('refs/heads/', '')
464 else:
465 branch = "DETACHED"
466 print "Current branch: %s" % branch
468 print "Files with staged changes:"
469 files = self._get_staged_files()
470 for f in files:
471 print "\t%s" % f
472 if not files:
473 print "\t(none)"
475 print "Files with unstaged changes:"
476 files = self._get_unstaged_files()
477 for f in files:
478 print "\t%s" % f
479 if not files:
480 print "\t(none)"
482 files = self._get_unmerged_files()
483 if files:
484 print "Files with conflicts:"
485 for f in files:
486 print "\t%s" % f
488 @short_help("remove uncommitted changes from a file (*)")
489 @long_help("""
490 The arguments are the files whose changes will be reverted. If the '-a'
491 flag is given, then all files will have uncommitted changes removed.
492 Note that there is no way to reverse this command short of manually
493 editing each file again.
494 """)
495 @takes_options("a")
496 def cmd_revert(self, *files, **flags):
497 "(-a | <file>)"
498 if '-a' in flags:
499 self._unstage_all()
500 run_safely("git checkout-index -u -f -a")
501 self.cmd_status()
502 return
504 if not files:
505 raise TypeError
507 for f in files:
508 self._revert_one(f)
509 self.cmd_status()
511 @short_help("record changes to files as a new commit")
512 @long_help("""
513 Create a new commit recording changes since the last commit. If there
514 are only unstaged changes, those will be recorded. If there are only
515 staged changes, those will be recorded. Otherwise, you will have to
516 specify either the '-a' flag or the '-d' flag to commit all changes or
517 only staged changes, respectively. To reverse the effects of this
518 command, see 'uncommit'.
519 """)
520 @takes_options("adm:")
521 def cmd_commit(self, **flags):
522 "[-a | -d]"
523 self._check_rebasing()
524 self._check_commit(**flags)
525 if not self._get_staged_files():
526 raise YapError("No changes to commit")
527 msg = flags.get('-m', None)
528 self._do_commit(msg)
529 self.cmd_status()
531 @short_help("reverse the actions of the last commit")
532 @long_help("""
533 Reverse the effects of the last 'commit' operation. The changes that
534 were part of the previous commit will show as "staged changes" in the
535 output of 'status'. This means that if no files were changed since the
536 last commit was created, 'uncommit' followed by 'commit' is a lossless
537 operation.
538 """)
539 def cmd_uncommit(self):
541 self._do_uncommit()
542 self.cmd_status()
544 @short_help("report the current version of yap")
545 def cmd_version(self):
546 print "Yap version 0.1"
548 @short_help("show the changelog for particular versions or files")
549 @long_help("""
550 The arguments are the files with which to filter history. If none are
551 given, all changes are listed. Otherwise only commits that affected one
552 or more of the given files are listed. The -r option changes the
553 starting revision for traversing history. By default, history is listed
554 starting at HEAD.
555 """)
556 @takes_options("pr:")
557 def cmd_log(self, *paths, **flags):
558 "[-p] [-r <rev>] <path>..."
559 rev = flags.get('-r', 'HEAD')
560 paths = ' '.join(paths)
561 if '-p' in flags:
562 flags['-p'] = '-p'
563 os.system("git log %s '%s' -- %s"
564 % (flags.get('-p', '--name-status'), rev, paths))
566 @short_help("show staged, unstaged, or all uncommitted changes")
567 @long_help("""
568 Show staged, unstaged, or all uncommitted changes. By default, all
569 changes are shown. The '-u' flag causes only unstaged changes to be
570 shown. The '-d' flag causes only staged changes to be shown.
571 """)
572 @takes_options("ud")
573 def cmd_diff(self, **flags):
574 "[ -u | -d ]"
575 if '-u' in flags and '-d' in flags:
576 raise YapError("Conflicting flags: -u and -d")
578 pager = self._get_pager_cmd()
580 if '-u' in flags:
581 os.system("git diff-files -p | %s" % pager)
582 elif '-d' in flags:
583 os.system("git diff-index --cached -p HEAD | %s" % pager)
584 else:
585 os.system("git diff-index -p HEAD | %s" % pager)
587 @short_help("list, create, or delete branches")
588 @long_help("""
589 If no arguments are specified, a list of local branches is given. The
590 current branch is indicated by a "*" next to the name. If an argument
591 is given, it is taken as the name of a new branch to create. The branch
592 will start pointing at the current HEAD. See 'point' for details on
593 changing the revision of the new branch. Note that this command does
594 not switch the current working branch. See 'switch' for details on
595 changing the current working branch.
597 The '-d' flag can be used to delete local branches. If the delete
598 operation would remove the last branch reference to a given line of
599 history (colloquially referred to as "dangling commits"), yap will
600 report an error and abort. The '-f' flag can be used to force the delete
601 in spite of this.
602 """)
603 @takes_options("fd:")
604 def cmd_branch(self, branch=None, **flags):
605 "[ [-f] -d <branch> | <branch> ]"
606 force = '-f' in flags
607 if '-d' in flags:
608 self._delete_branch(flags['-d'], force)
609 self.cmd_branch()
610 return
612 if branch is not None:
613 ref = get_output("git rev-parse --verify HEAD")
614 if not ref:
615 raise YapError("No branch point yet. Make a commit")
616 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
618 current = get_output("git symbolic-ref HEAD")
619 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
620 for b in branches:
621 if current and b == current[0]:
622 print "* ",
623 else:
624 print " ",
625 b = b.replace('refs/heads/', '')
626 print b
628 @short_help("change the current working branch")
629 @long_help("""
630 The argument is the name of the branch to make the current working
631 branch. This command will fail if there are uncommitted changes to any
632 files. Otherwise, the contents of the files in the working directory
633 are updated to reflect their state in the new branch. Additionally, any
634 future commits are added to the new branch instead of the previous line
635 of history.
636 """)
637 @takes_options("f")
638 def cmd_switch(self, branch, **flags):
639 "[-f] <branch>"
640 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
641 if not ref:
642 raise YapError("No such branch: %s" % branch)
644 if '-f' not in flags and (self._get_unstaged_files() or self._get_staged_files()):
645 raise YapError("You have uncommitted changes. Use -f to continue anyway")
647 if self._get_unstaged_files() and self._get_staged_files():
648 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
650 staged = bool(self._get_staged_files())
652 run_command("git diff-files -p | git apply --cached")
653 for f in self._get_new_files():
654 self._stage_one(f)
656 idx = get_output("git write-tree")
657 new = get_output("git rev-parse refs/heads/%s" % branch)
658 if os.system("git read-tree --aggressive -u -m HEAD %s %s" % (idx[0], new[0])):
659 raise YapError("Failed to switch")
660 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
662 if not staged:
663 self._unstage_all()
664 self.cmd_status()
666 @short_help("move the current branch to a different revision")
667 @long_help("""
668 The argument is the hash of the commit to which the current branch
669 should point, or alternately a branch or tag (a.k.a, "committish"). If
670 moving the branch would create "dangling commits" (see 'branch'), yap
671 will report an error and abort. The '-f' flag can be used to force the
672 operation in spite of this.
673 """)
674 @takes_options("f")
675 def cmd_point(self, where, **flags):
676 "[-f] <where>"
677 head = get_output("git rev-parse --verify HEAD")
678 if not head:
679 raise YapError("No commit yet; nowhere to point")
681 ref = get_output("git rev-parse --verify '%s'" % where)
682 if not ref:
683 raise YapError("Not a valid ref: %s" % where)
685 if self._get_unstaged_files() or self._get_staged_files():
686 raise YapError("You have uncommitted changes. Commit them first")
688 type = get_output("git cat-file -t '%s'" % ref[0])
689 if type and type[0] == "tag":
690 tag = get_output("git cat-file tag '%s'" % ref[0])
691 ref[0] = tag[0].split(' ')[1]
693 run_safely("git update-ref HEAD '%s'" % ref[0])
695 if '-f' not in flags:
696 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
697 if name == "undefined":
698 os.system("git update-ref HEAD '%s'" % head[0])
699 raise YapError("Pointing there will lose commits. Use -f to force")
701 try:
702 run_safely("git read-tree -u -m HEAD")
703 except ShellError:
704 run_safely("git read-tree HEAD")
705 run_safely("git checkout-index -u -f -a")
707 @short_help("alter history by dropping or amending commits")
708 @long_help("""
709 This command operates in two distinct modes, "amend" and "drop" mode.
710 In drop mode, the given commit is removed from the history of the
711 current branch, as though that commit never happened. By default the
712 commit used is HEAD.
714 In amend mode, the uncommitted changes present are merged into a
715 previous commit. This is useful for correcting typos or adding missed
716 files into past commits. By default the commit used is HEAD.
718 While rewriting history it is possible that conflicts will arise. If
719 this happens, the rewrite will pause and you will be prompted to resolve
720 the conflicts and stage them. Once that is done, you will run "yap
721 history continue." If instead you want the conflicting commit removed
722 from history (perhaps your changes supercede that commit) you can run
723 "yap history skip". Once the rewrite completes, your branch will be on
724 the same commit as when the rewrite started.
725 """)
726 def cmd_history(self, subcmd, *args):
727 "amend | drop <commit>"
729 if subcmd not in ("amend", "drop", "continue", "skip"):
730 raise TypeError
732 resolvemsg = """
733 When you have resolved the conflicts run \"yap history continue\".
734 To skip the problematic patch, run \"yap history skip\"."""
736 if subcmd == "continue":
737 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
738 return
739 if subcmd == "skip":
740 os.system("git reset --hard")
741 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
742 return
744 if subcmd == "amend":
745 flags, args = getopt.getopt(args, "ad")
746 flags = dict(flags)
748 if len(args) > 1:
749 raise TypeError
750 if args:
751 commit = args[0]
752 else:
753 commit = "HEAD"
755 if run_command("git rev-parse --verify '%s'" % commit):
756 raise YapError("Not a valid commit: %s" % commit)
758 self._check_rebasing()
760 if subcmd == "amend":
761 self._check_commit(**flags)
762 if self._get_unstaged_files():
763 # XXX: handle unstaged changes better
764 raise YapError("Commit away changes that you aren't amending")
766 self._unstage_all()
768 start = get_output("git rev-parse HEAD")
769 stash = get_output("git stash create")
770 run_command("git reset --hard")
771 try:
772 fd, tmpfile = tempfile.mkstemp("yap")
773 try:
774 try:
775 os.close(fd)
776 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
777 if subcmd == "amend":
778 self.cmd_point(commit, **{'-f': True})
779 finally:
780 if subcmd == "amend":
781 if stash:
782 rc = os.system("git stash apply %s" % stash[0])
783 if rc:
784 self.cmd_point(start[0], **{'-f': True})
785 os.system("git stash apply %s" % stash[0])
786 raise YapError("Failed to apply stash")
787 stash = None
789 if subcmd == "amend":
790 self._do_uncommit()
791 for f in self._get_unstaged_files():
792 self._stage_one(f)
793 self._do_commit()
794 else:
795 self.cmd_point("%s^" % commit, **{'-f': True})
797 stat = os.stat(tmpfile)
798 size = stat[6]
799 if size > 0:
800 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
801 if (rc):
802 raise YapError("Failed to apply changes")
803 finally:
804 os.unlink(tmpfile)
805 finally:
806 if stash:
807 run_command("git stash apply %s" % stash[0])
808 self.cmd_status()
810 @short_help("show the changes introduced by a given commit")
811 @long_help("""
812 By default, the changes in the last commit are shown. To override this,
813 specify a hash, branch, or tag (committish). The hash of the commit,
814 the commit's author, log message, and a diff of the changes are shown.
815 """)
816 def cmd_show(self, commit="HEAD"):
817 "[commit]"
818 os.system("git show '%s'" % commit)
820 @short_help("apply the changes in a given commit to the current branch")
821 @long_help("""
822 The argument is the hash, branch, or tag (committish) of the commit to
823 be applied. In general, it only makes sense to apply commits that
824 happened on another branch. The '-r' flag can be used to have the
825 changes in the given commit reversed from the current branch. In
826 general, this only makes sense for commits that happened on the current
827 branch.
828 """)
829 @takes_options("r")
830 def cmd_cherry_pick(self, commit, **flags):
831 "[-r] <commit>"
832 if '-r' in flags:
833 os.system("git revert '%s'" % commit)
834 else:
835 os.system("git cherry-pick '%s'" % commit)
837 @short_help("list, add, or delete configured remote repositories")
838 @long_help("""
839 When invoked with no arguments, this command will show the list of
840 currently configured remote repositories, giving both the name and URL
841 of each. To add a new repository, give the desired name as the first
842 argument and the URL as the second. The '-d' flag can be used to remove
843 a previously added repository.
844 """)
845 @takes_options("d:")
846 def cmd_repo(self, name=None, url=None, **flags):
847 "[<name> <url> | -d <name>]"
848 if name is not None and url is None:
849 raise TypeError
851 if '-d' in flags:
852 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
853 raise YapError("No such repository: %s" % flags['-d'])
854 os.system("git config --unset remote.%s.url" % flags['-d'])
855 os.system("git config --unset remote.%s.fetch" % flags['-d'])
857 if name:
858 if name in [ x[0] for x in self._list_remotes() ]:
859 raise YapError("Repository '%s' already exists" % flags['-d'])
860 os.system("git config remote.%s.url %s" % (name, url))
861 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
863 for remote, url in self._list_remotes():
864 print "%-20s %s" % (remote, url)
866 @short_help("send local commits to a remote repository")
867 @long_help("""
868 When invoked with no arguments, the current branch is synchronized to
869 the tracking branch of the tracking remote. If no tracking remote is
870 specified, the repository will have to be specified on the command line.
871 In that case, the default is to push to a branch with the same name as
872 the current branch. This behavior can be overridden by giving a second
873 argument to specify the remote branch.
875 If the remote branch does not currently exist, the command will abort
876 unless the -c flag is provided. If the remote branch is not a direct
877 descendent of the local branch, the command will abort unless the -f
878 flag is provided. Forcing a push in this way can be problematic to
879 other users of the repository if they are not expecting it.
881 To delete a branch on the remote repository, use the -d flag.
882 """)
883 @takes_options("cdf")
884 def cmd_push(self, repo=None, rhs=None, **flags):
885 "[-c | -d] <repo>"
887 if '-c' in flags and '-d' in flags:
888 raise TypeError
890 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
891 raise YapError("No such repository: %s" % repo)
893 current = get_output("git symbolic-ref HEAD")
894 if not current:
895 raise YapError("Not on a branch!")
897 current = current[0].replace('refs/heads/', '')
898 remote = get_output("git config branch.%s.remote" % current)
899 if repo is None and remote:
900 repo = remote[0]
902 if repo is None:
903 raise YapError("No tracking branch configured; specify destination repository")
905 if rhs is None and remote and remote[0] == repo:
906 merge = get_output("git config branch.%s.merge" % current)
907 if merge:
908 rhs = merge[0]
910 if rhs is None:
911 rhs = "refs/heads/%s" % current
913 if '-c' not in flags and '-d' not in flags:
914 if run_command("git rev-parse --verify refs/remotes/%s/%s"
915 % (repo, rhs.replace('refs/heads/', ''))):
916 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
917 if '-f' not in flags:
918 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo, rhs.replace('refs/heads/', '')))
919 base = get_output("git merge-base HEAD %s" % hash[0])
920 assert base
921 if base[0] != hash[0]:
922 raise YapError("Branch not up-to-date with remote. Update or use -f")
924 print "About to push local branch '%s' to '%s' on '%s'" % (current, rhs, repo)
925 print "Continue (y/n)? ",
926 sys.stdout.flush()
927 ans = sys.stdin.readline().strip()
929 if ans.lower() != 'y' and ans.lower() != 'yes':
930 raise YapError("Aborted.")
932 if '-f' in flags:
933 flags['-f'] = '-f'
935 if '-d' in flags:
936 lhs = ""
937 else:
938 lhs = "refs/heads/%s" % current
939 rc = os.system("git push %s %s %s:%s" % (flags.get('-f', ''), repo, lhs, rhs))
940 if rc:
941 raise YapError("Push failed.")
943 @short_help("retrieve commits from a remote repository")
944 @long_help("""
945 When run with no arguments, the command will retrieve new commits from
946 the remote tracking repository. Note that this does not in any way
947 alter the current branch. For that, see "update". If a remote other
948 than the tracking remote is desired, it can be specified as the first
949 argument.
950 """)
951 def cmd_fetch(self, repo=None):
952 "<repo>"
953 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
954 raise YapError("No such repository: %s" % repo)
955 if repo is None:
956 remote = get_output("git config branch.%s.remote" % current)
957 repo = remote[0]
958 if repo is None:
959 raise YapError("No tracking branch configured; specify a repository")
960 os.system("git fetch %s" % repo)
962 @short_help("update the current branch relative to its tracking branch")
963 @long_help("""
964 Updates the current branch relative to its remote tracking branch. This
965 command requires that the current branch have a remote tracking branch
966 configured. If any conflicts occur while applying your changes to the
967 updated remote, the command will pause to allow you to fix them. Once
968 that is done, run "update" with the "continue" subcommand. Alternately,
969 the "skip" subcommand can be used to discard the conflicting changes.
970 """)
971 def cmd_update(self, subcmd=None):
972 "[continue | skip]"
973 if subcmd and subcmd not in ["continue", "skip"]:
974 raise TypeError
976 resolvemsg = """
977 When you have resolved the conflicts run \"yap update continue\".
978 To skip the problematic patch, run \"yap update skip\"."""
980 if subcmd == "continue":
981 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
982 return
983 if subcmd == "skip":
984 os.system("git reset --hard")
985 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
986 return
988 self._check_rebasing()
989 if self._get_unstaged_files() or self._get_staged_files():
990 raise YapError("You have uncommitted changes. Commit them first")
992 current = get_output("git symbolic-ref HEAD")
993 if not current:
994 raise YapError("Not on a branch!")
996 current = current[0].replace('refs/heads/', '')
997 remote, merge = self._get_tracking(current)
998 merge = merge[0].replace('refs/heads/', '')
1000 self.cmd_fetch(remote)
1001 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
1003 try:
1004 fd, tmpfile = tempfile.mkstemp("yap")
1005 os.close(fd)
1006 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
1007 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
1009 stat = os.stat(tmpfile)
1010 size = stat[6]
1011 if size > 0:
1012 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
1013 if (rc):
1014 raise YapError("Failed to apply changes")
1015 finally:
1016 os.unlink(tmpfile)
1018 @short_help("query and configure remote branch tracking")
1019 @long_help("""
1020 When invoked with no arguments, the command displays the tracking
1021 information for the current branch. To configure the tracking
1022 information, two arguments for the remote repository and remote branch
1023 are given. The tracking information is used to provide defaults for
1024 where to push local changes and from where to get updates to the branch.
1025 """)
1026 def cmd_track(self, repo=None, branch=None):
1027 "[<repo> <branch>]"
1029 current = get_output("git symbolic-ref HEAD")
1030 if not current:
1031 raise YapError("Not on a branch!")
1032 current = current[0].replace('refs/heads/', '')
1034 if repo is None and branch is None:
1035 repo, merge = self._get_tracking(current)
1036 merge = merge[0].replace('refs/heads/', '')
1037 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
1038 return
1040 if repo is None or branch is None:
1041 raise TypeError
1043 if repo not in [ x[0] for x in self._list_remotes() ]:
1044 raise YapError("No such repository: %s" % repo)
1046 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
1047 raise YapError("No such branch '%s' on repository '%s'" % (repo, branch))
1049 os.system("git config branch.%s.remote '%s'" % (current, repo))
1050 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
1051 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
1053 @short_help("mark files with conflicts as resolved")
1054 @long_help("""
1055 The arguments are the files to be marked resolved. When a conflict
1056 occurs while merging changes to a file, that file is marked as
1057 "unmerged." Until the file(s) with conflicts are marked resolved,
1058 commits cannot be made.
1059 """)
1060 def cmd_resolved(self, *args):
1061 "<file>..."
1062 if not files:
1063 raise TypeError
1065 for f in files:
1066 self._stage_one(f, True)
1067 self.cmd_status()
1069 def cmd_help(self, cmd=None):
1070 if cmd is not None:
1071 try:
1072 attr = self.__getattribute__("cmd_"+cmd.replace('-', '_'))
1073 except AttributeError:
1074 raise YapError("No such command: %s" % cmd)
1075 try:
1076 help = attr.long_help
1077 except AttributeError:
1078 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
1080 print >>sys.stderr, "The '%s' command" % cmd
1081 print >>sys.stderr, "\tyap %s %s" % (cmd, attr.__doc__)
1082 print >>sys.stderr, "%s" % help
1083 return
1085 print >> sys.stderr, "Yet Another (Git) Porcelein"
1086 print >> sys.stderr
1088 for name in dir(self):
1089 if not name.startswith('cmd_'):
1090 continue
1091 attr = self.__getattribute__(name)
1092 if not callable(attr):
1093 continue
1094 try:
1095 short_msg = attr.short_help
1096 except AttributeError:
1097 continue
1099 name = name.replace('cmd_', '')
1100 name = name.replace('_', '-')
1101 print >> sys.stderr, "%-16s%s" % (name, short_msg)
1102 print >> sys.stderr
1103 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
1105 def cmd_usage(self):
1106 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
1107 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"
1109 def main(self, args):
1110 if len(args) < 1:
1111 self.cmd_usage()
1112 sys.exit(2)
1114 command = args[0]
1115 args = args[1:]
1117 debug = os.getenv('YAP_DEBUG')
1119 try:
1120 command = command.replace('-', '_')
1122 meth = None
1123 for p in self.plugins:
1124 try:
1125 meth = p.__getattribute__("cmd_"+command)
1126 except AttributeError:
1127 continue
1129 try:
1130 default_meth = self.__getattribute__("cmd_"+command)
1131 except AttributeError:
1132 default_meth = None
1134 if meth is None:
1135 meth = default_meth
1136 if meth is None:
1137 raise AttributeError
1139 try:
1140 if "options" in meth.__dict__:
1141 options = meth.options
1142 if default_meth and "options" in default_meth.__dict__:
1143 options += default_meth.options
1144 flags, args = getopt.getopt(args, options)
1145 flags = dict(flags)
1146 else:
1147 flags = dict()
1149 # invoke pre-hooks
1150 for p in self.plugins:
1151 try:
1152 meth = p.__getattribute__("pre_"+command)
1153 except AttributeError:
1154 continue
1155 meth(*args, **flags)
1157 meth(*args, **flags)
1159 # invoke post-hooks
1160 for p in self.plugins:
1161 try:
1162 meth = p.__getattribute__("post_"+command)
1163 except AttributeError:
1164 continue
1165 meth()
1167 except (TypeError, getopt.GetoptError):
1168 if debug:
1169 raise
1170 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, meth.__doc__)
1171 except YapError, e:
1172 print >> sys.stderr, e
1173 sys.exit(1)
1174 except AttributeError:
1175 if debug:
1176 raise
1177 self.cmd_usage()
1178 sys.exit(2)