cmd_switch: don't fail if the index differs only by timestamp
[yap.git] / yap / yap.py
blob9e1a01a0d170c252e83ed8221620896de0c2bdbe
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 = dict()
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 k, cls in glbls.items():
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)
43 for func in dir(x):
44 if not func.startswith('cmd_'):
45 continue
46 if func in self.overrides:
47 print >>sys.stderr, "Plugin %s overrides already overridden function %s. Disabling" % (p, func)
48 break
49 self.plugins[k] = x
51 def _add_new_file(self, file):
52 repo = get_output('git rev-parse --git-dir')[0]
53 dir = os.path.join(repo, 'yap')
54 try:
55 os.mkdir(dir)
56 except OSError:
57 pass
58 files = self._get_new_files()
59 files.append(file)
60 path = os.path.join(dir, 'new-files')
61 pickle.dump(files, open(path, 'w'))
63 def _get_new_files(self):
64 repo = get_output('git rev-parse --git-dir')[0]
65 path = os.path.join(repo, 'yap', 'new-files')
66 try:
67 files = pickle.load(file(path))
68 except IOError:
69 files = []
71 x = []
72 for f in files:
73 # if f in the index
74 if get_output("git ls-files --cached '%s'" % f) != []:
75 continue
76 x.append(f)
77 return x
79 def _remove_new_file(self, file):
80 files = self._get_new_files()
81 files = filter(lambda x: x != file, files)
83 repo = get_output('git rev-parse --git-dir')[0]
84 path = os.path.join(repo, 'yap', 'new-files')
85 pickle.dump(files, open(path, 'w'))
87 def _clear_new_files(self):
88 repo = get_output('git rev-parse --git-dir')[0]
89 path = os.path.join(repo, 'yap', 'new-files')
90 os.unlink(path)
92 def _assert_file_exists(self, file):
93 if not os.access(file, os.R_OK):
94 raise YapError("No such file: %s" % file)
96 def _get_staged_files(self):
97 if run_command("git rev-parse HEAD"):
98 files = get_output("git ls-files --cached")
99 else:
100 files = get_output("git diff-index --cached --name-only HEAD")
101 unmerged = self._get_unmerged_files()
102 if unmerged:
103 unmerged = set(unmerged)
104 files = set(files).difference(unmerged)
105 files = list(files)
106 return files
108 def _get_unstaged_files(self):
109 files = get_output("git ls-files -m")
110 prefix = get_output("git rev-parse --show-prefix")
111 if prefix:
112 files = [ os.path.join(prefix[0], x) for x in files ]
113 files += self._get_new_files()
114 unmerged = self._get_unmerged_files()
115 if unmerged:
116 unmerged = set(unmerged)
117 files = set(files).difference(unmerged)
118 files = list(files)
119 return files
121 def _get_unmerged_files(self):
122 files = get_output("git ls-files -u")
123 files = [ x.replace('\t', ' ').split(' ')[3] for x in files ]
124 prefix = get_output("git rev-parse --show-prefix")
125 if prefix:
126 files = [ os.path.join(prefix[0], x) for x in files ]
127 return list(set(files))
129 def _delete_branch(self, branch, force):
130 current = get_output("git symbolic-ref HEAD")
131 if current:
132 current = current[0].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 _check_git(self):
300 if run_command("git rev-parse --git-dir"):
301 raise YapError("That command must be run from inside a git repository")
303 def _list_remotes(self):
304 remotes = get_output("git config --get-regexp '^remote.*.url'")
305 for x in remotes:
306 remote, url = x.split(' ')
307 remote = remote.replace('remote.', '')
308 remote = remote.replace('.url', '')
309 yield remote, url
311 def _unstage_all(self):
312 try:
313 run_safely("git read-tree -m HEAD")
314 except ShellError:
315 run_safely("git read-tree HEAD")
316 run_safely("git update-index -q --refresh")
318 def _get_tracking(self, current):
319 remote = get_output("git config branch.%s.remote" % current)
320 if not remote:
321 raise YapError("No tracking branch configured for '%s'" % current)
323 merge = get_output("git config branch.%s.merge" % current)
324 if not merge:
325 raise YapError("No tracking branch configured for '%s'" % current)
326 return remote[0], merge
328 @short_help("make a local copy of an existing repository")
329 @long_help("""
330 The first argument is a URL to the existing repository. This can be an
331 absolute path if the repository is local, or a URL with the git://,
332 ssh://, or http:// schemes. By default, the directory used is the last
333 component of the URL, sans '.git'. This can be overridden by providing
334 a second argument.
335 """)
336 def cmd_clone(self, url, directory=None):
337 "<url> [directory]"
339 if '://' not in url and url[0] != '/':
340 url = os.path.join(os.getcwd(), url)
342 url = url.rstrip('/')
343 if directory is None:
344 directory = url.rsplit('/')[-1]
345 directory = directory.replace('.git', '')
347 try:
348 os.mkdir(directory)
349 except OSError:
350 raise YapError("Directory exists: %s" % directory)
351 os.chdir(directory)
352 self.cmd_init()
353 self.cmd_repo("origin", url)
354 self.cmd_fetch("origin")
356 branch = None
357 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
358 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
359 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
360 if get_output("git rev-parse %s" % b)[0] == hash:
361 branch = b
362 break
363 if branch is None:
364 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
365 branch = "refs/remotes/origin/master"
366 if branch is None:
367 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'")
368 branch = branch[0]
370 hash = get_output("git rev-parse %s" % branch)
371 assert hash
372 branch = branch.replace('refs/remotes/origin/', '')
373 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
374 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
375 self.cmd_revert(**{'-a': 1})
377 @short_help("turn a directory into a repository")
378 @long_help("""
379 Converts the current working directory into a repository. The primary
380 side-effect of this command is the creation of a '.git' subdirectory.
381 No files are added nor commits made.
382 """)
383 def cmd_init(self):
384 os.system("git init")
386 @short_help("add a new file to the repository")
387 @long_help("""
388 The arguments are the files to be added to the repository. Once added,
389 the files will show as "unstaged changes" in the output of 'status'. To
390 reverse the effects of this command, see 'rm'.
391 """)
392 def cmd_add(self, *files):
393 "<file>..."
394 self._check_git()
396 if not files:
397 raise TypeError
399 for f in files:
400 self._add_one(f)
401 self.cmd_status()
403 @short_help("delete a file from the repository")
404 @long_help("""
405 The arguments are the files to be removed from the current revision of
406 the repository. The files will still exist in any past commits that the
407 files may have been a part of. The file is not actually deleted, it is
408 just no longer tracked as part of the repository.
409 """)
410 def cmd_rm(self, *files):
411 "<file>..."
412 self._check_git()
413 if not files:
414 raise TypeError
416 for f in files:
417 self._rm_one(f)
418 self.cmd_status()
420 @short_help("stage changes in a file for commit")
421 @long_help("""
422 The arguments are the files to be staged. Staging changes is a way to
423 build up a commit when you do not want to commit all changes at once.
424 To commit only staged changes, use the '-d' flag to 'commit.' To
425 reverse the effects of this command, see 'unstage'. Once staged, the
426 files will show as "staged changes" in the output of 'status'.
427 """)
428 def cmd_stage(self, *files):
429 "<file>..."
430 self._check_git()
431 if not files:
432 raise TypeError
434 for f in files:
435 self._stage_one(f)
436 self.cmd_status()
438 @short_help("unstage changes in a file")
439 @long_help("""
440 The arguments are the files to be unstaged. Once unstaged, the files
441 will show as "unstaged changes" in the output of 'status'. The '-a'
442 flag can be used to unstage all staged changes at once.
443 """)
444 @takes_options("a")
445 def cmd_unstage(self, *files, **flags):
446 "[-a] | <file>..."
447 self._check_git()
448 if '-a' in flags:
449 self._unstage_all()
450 self.cmd_status()
451 return
453 if not files:
454 raise TypeError
456 for f in files:
457 self._unstage_one(f)
458 self.cmd_status()
460 @short_help("show files with staged and unstaged changes")
461 @long_help("""
462 Show the files in the repository with changes since the last commit,
463 categorized based on whether the changes are staged or not. A file may
464 appear under each heading if the same file has both staged and unstaged
465 changes.
466 """)
467 def cmd_status(self):
469 self._check_git()
470 branch = get_output("git symbolic-ref HEAD")
471 if branch:
472 branch = branch[0].replace('refs/heads/', '')
473 else:
474 branch = "DETACHED"
475 print "Current branch: %s" % branch
477 print "Files with staged changes:"
478 files = self._get_staged_files()
479 for f in files:
480 print "\t%s" % f
481 if not files:
482 print "\t(none)"
484 print "Files with unstaged changes:"
485 files = self._get_unstaged_files()
486 for f in files:
487 print "\t%s" % f
488 if not files:
489 print "\t(none)"
491 files = self._get_unmerged_files()
492 if files:
493 print "Files with conflicts:"
494 for f in files:
495 print "\t%s" % f
497 @short_help("remove uncommitted changes from a file (*)")
498 @long_help("""
499 The arguments are the files whose changes will be reverted. If the '-a'
500 flag is given, then all files will have uncommitted changes removed.
501 Note that there is no way to reverse this command short of manually
502 editing each file again.
503 """)
504 @takes_options("a")
505 def cmd_revert(self, *files, **flags):
506 "(-a | <file>)"
507 self._check_git()
508 if '-a' in flags:
509 self._unstage_all()
510 run_safely("git checkout-index -u -f -a")
511 self.cmd_status()
512 return
514 if not files:
515 raise TypeError
517 for f in files:
518 self._revert_one(f)
519 self.cmd_status()
521 @short_help("record changes to files as a new commit")
522 @long_help("""
523 Create a new commit recording changes since the last commit. If there
524 are only unstaged changes, those will be recorded. If there are only
525 staged changes, those will be recorded. Otherwise, you will have to
526 specify either the '-a' flag or the '-d' flag to commit all changes or
527 only staged changes, respectively. To reverse the effects of this
528 command, see 'uncommit'.
529 """)
530 @takes_options("adm:")
531 def cmd_commit(self, **flags):
532 "[-a | -d] [-m <msg>]"
533 self._check_git()
534 self._check_rebasing()
535 self._check_commit(**flags)
536 if not self._get_staged_files():
537 raise YapError("No changes to commit")
538 msg = flags.get('-m', None)
539 self._do_commit(msg)
540 self.cmd_status()
542 @short_help("reverse the actions of the last commit")
543 @long_help("""
544 Reverse the effects of the last 'commit' operation. The changes that
545 were part of the previous commit will show as "staged changes" in the
546 output of 'status'. This means that if no files were changed since the
547 last commit was created, 'uncommit' followed by 'commit' is a lossless
548 operation.
549 """)
550 def cmd_uncommit(self):
552 self._check_git()
553 self._do_uncommit()
554 self.cmd_status()
556 @short_help("report the current version of yap")
557 def cmd_version(self):
558 print "Yap version 0.1"
560 @short_help("show the changelog for particular versions or files")
561 @long_help("""
562 The arguments are the files with which to filter history. If none are
563 given, all changes are listed. Otherwise only commits that affected one
564 or more of the given files are listed. The -r option changes the
565 starting revision for traversing history. By default, history is listed
566 starting at HEAD.
567 """)
568 @takes_options("pr:")
569 def cmd_log(self, *paths, **flags):
570 "[-p] [-r <rev>] <path>..."
571 self._check_git()
572 rev = flags.get('-r', 'HEAD')
574 if '-p' in flags:
575 flags['-p'] = '-p'
577 if len(paths) == 1:
578 follow = "--follow"
579 else:
580 follow = ""
581 paths = ' '.join(paths)
582 os.system("git log -M -C %s %s '%s' -- %s"
583 % (follow, flags.get('-p', '--name-status'), rev, paths))
585 @short_help("show staged, unstaged, or all uncommitted changes")
586 @long_help("""
587 Show staged, unstaged, or all uncommitted changes. By default, all
588 changes are shown. The '-u' flag causes only unstaged changes to be
589 shown. The '-d' flag causes only staged changes to be shown.
590 """)
591 @takes_options("ud")
592 def cmd_diff(self, **flags):
593 "[ -u | -d ]"
594 self._check_git()
595 if '-u' in flags and '-d' in flags:
596 raise YapError("Conflicting flags: -u and -d")
598 pager = self._get_pager_cmd()
600 if '-u' in flags:
601 os.system("git diff-files -p | %s" % pager)
602 elif '-d' in flags:
603 os.system("git diff-index --cached -p HEAD | %s" % pager)
604 else:
605 os.system("git diff-index -p HEAD | %s" % pager)
607 @short_help("list, create, or delete branches")
608 @long_help("""
609 If no arguments are specified, a list of local branches is given. The
610 current branch is indicated by a "*" next to the name. If an argument
611 is given, it is taken as the name of a new branch to create. The branch
612 will start pointing at the current HEAD. See 'point' for details on
613 changing the revision of the new branch. Note that this command does
614 not switch the current working branch. See 'switch' for details on
615 changing the current working branch.
617 The '-d' flag can be used to delete local branches. If the delete
618 operation would remove the last branch reference to a given line of
619 history (colloquially referred to as "dangling commits"), yap will
620 report an error and abort. The '-f' flag can be used to force the delete
621 in spite of this.
622 """)
623 @takes_options("fd:")
624 def cmd_branch(self, branch=None, **flags):
625 "[ [-f] -d <branch> | <branch> ]"
626 self._check_git()
627 force = '-f' in flags
628 if '-d' in flags:
629 self._delete_branch(flags['-d'], force)
630 self.cmd_branch()
631 return
633 if branch is not None:
634 ref = get_output("git rev-parse --verify HEAD")
635 if not ref:
636 raise YapError("No branch point yet. Make a commit")
637 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
639 current = get_output("git symbolic-ref HEAD")
640 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
641 for b in branches:
642 if current and b == current[0]:
643 print "* ",
644 else:
645 print " ",
646 b = b.replace('refs/heads/', '')
647 print b
649 @short_help("change the current working branch")
650 @long_help("""
651 The argument is the name of the branch to make the current working
652 branch. This command will fail if there are uncommitted changes to any
653 files. Otherwise, the contents of the files in the working directory
654 are updated to reflect their state in the new branch. Additionally, any
655 future commits are added to the new branch instead of the previous line
656 of history.
657 """)
658 @takes_options("f")
659 def cmd_switch(self, branch, **flags):
660 "[-f] <branch>"
661 self._check_git()
662 self._check_rebasing()
663 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
664 if not ref:
665 raise YapError("No such branch: %s" % branch)
667 if '-f' not in flags and (self._get_unstaged_files() or self._get_staged_files()):
668 raise YapError("You have uncommitted changes. Use -f to continue anyway")
670 if self._get_unstaged_files() and self._get_staged_files():
671 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
673 staged = bool(self._get_staged_files())
675 run_command("git diff-files -p | git apply --cached")
676 for f in self._get_new_files():
677 self._stage_one(f)
679 idx = get_output("git write-tree")
680 new = get_output("git rev-parse refs/heads/%s" % branch)
681 readtree = "git read-tree --aggressive -u -m HEAD %s %s" % (idx[0], new[0])
682 if run_command(readtree):
683 run_command("git update-index --refresh")
684 if os.system(readtree):
685 raise YapError("Failed to switch")
686 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
688 if not staged:
689 self._unstage_all()
690 self.cmd_status()
692 @short_help("move the current branch to a different revision")
693 @long_help("""
694 The argument is the hash of the commit to which the current branch
695 should point, or alternately a branch or tag (a.k.a, "committish"). If
696 moving the branch would create "dangling commits" (see 'branch'), yap
697 will report an error and abort. The '-f' flag can be used to force the
698 operation in spite of this.
699 """)
700 @takes_options("f")
701 def cmd_point(self, where, **flags):
702 "[-f] <where>"
703 self._check_git()
704 self._check_rebasing()
706 head = get_output("git rev-parse --verify HEAD")
707 if not head:
708 raise YapError("No commit yet; nowhere to point")
710 ref = get_output("git rev-parse --verify '%s'" % where)
711 if not ref:
712 raise YapError("Not a valid ref: %s" % where)
714 if self._get_unstaged_files() or self._get_staged_files():
715 raise YapError("You have uncommitted changes. Commit them first")
717 type = get_output("git cat-file -t '%s'" % ref[0])
718 if type and type[0] == "tag":
719 tag = get_output("git cat-file tag '%s'" % ref[0])
720 ref[0] = tag[0].split(' ')[1]
722 run_safely("git update-ref HEAD '%s'" % ref[0])
724 if '-f' not in flags:
725 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
726 if name == "undefined":
727 os.system("git update-ref HEAD '%s'" % head[0])
728 raise YapError("Pointing there will lose commits. Use -f to force")
730 try:
731 run_safely("git read-tree -u -m HEAD")
732 except ShellError:
733 run_safely("git read-tree HEAD")
734 run_safely("git checkout-index -u -f -a")
736 @short_help("alter history by dropping or amending commits")
737 @long_help("""
738 This command operates in two distinct modes, "amend" and "drop" mode.
739 In drop mode, the given commit is removed from the history of the
740 current branch, as though that commit never happened. By default the
741 commit used is HEAD.
743 In amend mode, the uncommitted changes present are merged into a
744 previous commit. This is useful for correcting typos or adding missed
745 files into past commits. By default the commit used is HEAD.
747 While rewriting history it is possible that conflicts will arise. If
748 this happens, the rewrite will pause and you will be prompted to resolve
749 the conflicts and stage them. Once that is done, you will run "yap
750 history continue." If instead you want the conflicting commit removed
751 from history (perhaps your changes supercede that commit) you can run
752 "yap history skip". Once the rewrite completes, your branch will be on
753 the same commit as when the rewrite started.
754 """)
755 def cmd_history(self, subcmd, *args):
756 "amend | drop <commit>"
757 self._check_git()
759 if subcmd not in ("amend", "drop", "continue", "skip"):
760 raise TypeError
762 resolvemsg = """
763 When you have resolved the conflicts run \"yap history continue\".
764 To skip the problematic patch, run \"yap history skip\"."""
766 if subcmd == "continue":
767 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
768 return
769 if subcmd == "skip":
770 os.system("git reset --hard")
771 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
772 return
774 if subcmd == "amend":
775 flags, args = getopt.getopt(args, "ad")
776 flags = dict(flags)
778 if len(args) > 1:
779 raise TypeError
780 if args:
781 commit = args[0]
782 else:
783 commit = "HEAD"
785 if run_command("git rev-parse --verify '%s'" % commit):
786 raise YapError("Not a valid commit: %s" % commit)
788 self._check_rebasing()
790 if subcmd == "amend":
791 self._check_commit(**flags)
792 if self._get_unstaged_files():
793 # XXX: handle unstaged changes better
794 raise YapError("Commit away changes that you aren't amending")
796 self._unstage_all()
798 start = get_output("git rev-parse HEAD")
799 stash = get_output("git stash create")
800 run_command("git reset --hard")
801 try:
802 fd, tmpfile = tempfile.mkstemp("yap")
803 try:
804 try:
805 os.close(fd)
806 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
807 if subcmd == "amend":
808 self.cmd_point(commit, **{'-f': True})
809 finally:
810 if subcmd == "amend":
811 if stash:
812 rc = os.system("git stash apply %s" % stash[0])
813 if rc:
814 self.cmd_point(start[0], **{'-f': True})
815 os.system("git stash apply %s" % stash[0])
816 raise YapError("Failed to apply stash")
817 stash = None
819 if subcmd == "amend":
820 self._do_uncommit()
821 for f in self._get_unstaged_files():
822 self._stage_one(f)
823 self._do_commit()
824 else:
825 self.cmd_point("%s^" % commit, **{'-f': True})
827 stat = os.stat(tmpfile)
828 size = stat[6]
829 if size > 0:
830 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
831 if (rc):
832 raise YapError("Failed to apply changes")
833 finally:
834 os.unlink(tmpfile)
835 finally:
836 if stash:
837 run_command("git stash apply %s" % stash[0])
838 self.cmd_status()
840 @short_help("show the changes introduced by a given commit")
841 @long_help("""
842 By default, the changes in the last commit are shown. To override this,
843 specify a hash, branch, or tag (committish). The hash of the commit,
844 the commit's author, log message, and a diff of the changes are shown.
845 """)
846 def cmd_show(self, commit="HEAD"):
847 "[commit]"
848 self._check_git()
849 os.system("git show '%s'" % commit)
851 @short_help("apply the changes in a given commit to the current branch")
852 @long_help("""
853 The argument is the hash, branch, or tag (committish) of the commit to
854 be applied. In general, it only makes sense to apply commits that
855 happened on another branch. The '-r' flag can be used to have the
856 changes in the given commit reversed from the current branch. In
857 general, this only makes sense for commits that happened on the current
858 branch.
859 """)
860 @takes_options("r")
861 def cmd_cherry_pick(self, commit, **flags):
862 "[-r] <commit>"
863 self._check_git()
864 if '-r' in flags:
865 os.system("git revert '%s'" % commit)
866 else:
867 os.system("git cherry-pick '%s'" % commit)
869 @short_help("list, add, or delete configured remote repositories")
870 @long_help("""
871 When invoked with no arguments, this command will show the list of
872 currently configured remote repositories, giving both the name and URL
873 of each. To add a new repository, give the desired name as the first
874 argument and the URL as the second. The '-d' flag can be used to remove
875 a previously added repository.
876 """)
877 @takes_options("d:")
878 def cmd_repo(self, name=None, url=None, **flags):
879 "[<name> <url> | -d <name>]"
880 self._check_git()
881 if name is not None and url is None:
882 raise TypeError
884 if '-d' in flags:
885 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
886 raise YapError("No such repository: %s" % flags['-d'])
887 os.system("git config --unset remote.%s.url" % flags['-d'])
888 os.system("git config --unset remote.%s.fetch" % flags['-d'])
890 if name:
891 if name in [ x[0] for x in self._list_remotes() ]:
892 raise YapError("Repository '%s' already exists" % flags['-d'])
893 os.system("git config remote.%s.url %s" % (name, url))
894 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
896 for remote, url in self._list_remotes():
897 print "%-20s %s" % (remote, url)
899 @short_help("send local commits to a remote repository")
900 @long_help("""
901 When invoked with no arguments, the current branch is synchronized to
902 the tracking branch of the tracking remote. If no tracking remote is
903 specified, the repository will have to be specified on the command line.
904 In that case, the default is to push to a branch with the same name as
905 the current branch. This behavior can be overridden by giving a second
906 argument to specify the remote branch.
908 If the remote branch does not currently exist, the command will abort
909 unless the -c flag is provided. If the remote branch is not a direct
910 descendent of the local branch, the command will abort unless the -f
911 flag is provided. Forcing a push in this way can be problematic to
912 other users of the repository if they are not expecting it.
914 To delete a branch on the remote repository, use the -d flag.
915 """)
916 @takes_options("cdf")
917 def cmd_push(self, repo=None, rhs=None, **flags):
918 "[-c | -d] <repo>"
919 self._check_git()
920 if '-c' in flags and '-d' in flags:
921 raise TypeError
923 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
924 raise YapError("No such repository: %s" % repo)
926 current = get_output("git symbolic-ref HEAD")
927 if not current:
928 raise YapError("Not on a branch!")
930 self._check_rebasing()
932 current = current[0].replace('refs/heads/', '')
933 remote = get_output("git config branch.%s.remote" % current)
934 if repo is None and remote:
935 repo = remote[0]
937 if repo is None:
938 raise YapError("No tracking branch configured; specify destination repository")
940 if rhs is None and remote and remote[0] == repo:
941 merge = get_output("git config branch.%s.merge" % current)
942 if merge:
943 rhs = merge[0]
945 if rhs is None:
946 rhs = "refs/heads/%s" % current
948 if '-c' not in flags and '-d' not in flags:
949 if run_command("git rev-parse --verify refs/remotes/%s/%s"
950 % (repo, rhs.replace('refs/heads/', ''))):
951 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
952 if '-f' not in flags:
953 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo, rhs.replace('refs/heads/', '')))
954 base = get_output("git merge-base HEAD %s" % hash[0])
955 assert base
956 if base[0] != hash[0]:
957 raise YapError("Branch not up-to-date with remote. Update or use -f")
959 print "About to push local branch '%s' to '%s' on '%s'" % (current, rhs, repo)
960 print "Continue (y/n)? ",
961 sys.stdout.flush()
962 ans = sys.stdin.readline().strip()
964 if ans.lower() != 'y' and ans.lower() != 'yes':
965 raise YapError("Aborted.")
967 if '-f' in flags:
968 flags['-f'] = '-f'
970 if '-d' in flags:
971 lhs = ""
972 else:
973 lhs = "refs/heads/%s" % current
974 rc = os.system("git push %s %s %s:%s" % (flags.get('-f', ''), repo, lhs, rhs))
975 if rc:
976 raise YapError("Push failed.")
978 @short_help("retrieve commits from a remote repository")
979 @long_help("""
980 When run with no arguments, the command will retrieve new commits from
981 the remote tracking repository. Note that this does not in any way
982 alter the current branch. For that, see "update". If a remote other
983 than the tracking remote is desired, it can be specified as the first
984 argument.
985 """)
986 def cmd_fetch(self, repo=None):
987 "<repo>"
988 self._check_git()
989 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
990 raise YapError("No such repository: %s" % repo)
991 if repo is None:
992 remote = get_output("git config branch.%s.remote" % current)
993 repo = remote[0]
994 if repo is None:
995 raise YapError("No tracking branch configured; specify a repository")
996 os.system("git fetch %s" % repo)
998 @short_help("update the current branch relative to its tracking branch")
999 @long_help("""
1000 Updates the current branch relative to its remote tracking branch. This
1001 command requires that the current branch have a remote tracking branch
1002 configured. If any conflicts occur while applying your changes to the
1003 updated remote, the command will pause to allow you to fix them. Once
1004 that is done, run "update" with the "continue" subcommand. Alternately,
1005 the "skip" subcommand can be used to discard the conflicting changes.
1006 """)
1007 def cmd_update(self, subcmd=None):
1008 "[continue | skip]"
1009 self._check_git()
1010 if subcmd and subcmd not in ["continue", "skip"]:
1011 raise TypeError
1013 resolvemsg = """
1014 When you have resolved the conflicts run \"yap update continue\".
1015 To skip the problematic patch, run \"yap update skip\"."""
1017 if subcmd == "continue":
1018 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
1019 return
1020 if subcmd == "skip":
1021 os.system("git reset --hard")
1022 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
1023 return
1025 self._check_rebasing()
1026 if self._get_unstaged_files() or self._get_staged_files():
1027 raise YapError("You have uncommitted changes. Commit them first")
1029 current = get_output("git symbolic-ref HEAD")
1030 if not current:
1031 raise YapError("Not on a branch!")
1033 current = current[0].replace('refs/heads/', '')
1034 remote, merge = self._get_tracking(current)
1035 merge = merge[0].replace('refs/heads/', '')
1037 self.cmd_fetch(remote)
1038 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
1040 try:
1041 fd, tmpfile = tempfile.mkstemp("yap")
1042 os.close(fd)
1043 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
1044 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
1046 stat = os.stat(tmpfile)
1047 size = stat[6]
1048 if size > 0:
1049 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
1050 if (rc):
1051 raise YapError("Failed to apply changes")
1052 finally:
1053 os.unlink(tmpfile)
1055 @short_help("query and configure remote branch tracking")
1056 @long_help("""
1057 When invoked with no arguments, the command displays the tracking
1058 information for the current branch. To configure the tracking
1059 information, two arguments for the remote repository and remote branch
1060 are given. The tracking information is used to provide defaults for
1061 where to push local changes and from where to get updates to the branch.
1062 """)
1063 def cmd_track(self, repo=None, branch=None):
1064 "[<repo> <branch>]"
1065 self._check_git()
1067 current = get_output("git symbolic-ref HEAD")
1068 if not current:
1069 raise YapError("Not on a branch!")
1070 current = current[0].replace('refs/heads/', '')
1072 if repo is None and branch is None:
1073 repo, merge = self._get_tracking(current)
1074 merge = merge[0].replace('refs/heads/', '')
1075 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
1076 return
1078 if repo is None or branch is None:
1079 raise TypeError
1081 if repo not in [ x[0] for x in self._list_remotes() ]:
1082 raise YapError("No such repository: %s" % repo)
1084 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
1085 raise YapError("No such branch '%s' on repository '%s'" % (repo, branch))
1087 os.system("git config branch.%s.remote '%s'" % (current, repo))
1088 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
1089 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
1091 @short_help("mark files with conflicts as resolved")
1092 @long_help("""
1093 The arguments are the files to be marked resolved. When a conflict
1094 occurs while merging changes to a file, that file is marked as
1095 "unmerged." Until the file(s) with conflicts are marked resolved,
1096 commits cannot be made.
1097 """)
1098 def cmd_resolved(self, *args):
1099 "<file>..."
1100 self._check_git()
1101 if not files:
1102 raise TypeError
1104 for f in files:
1105 self._stage_one(f, True)
1106 self.cmd_status()
1108 @short_help("show information about loaded plugins")
1109 def cmd_plugins(self):
1111 if not self.plugins:
1112 print >>sys.stderr, "No plugins loaded."
1113 for k, v in self.plugins.items():
1114 doc = v.__doc__
1115 if doc is None:
1116 doc = "No description"
1117 print "%-20s%s" % (k, doc)
1118 first = True
1119 for func in dir(v):
1120 if not func.startswith('cmd_'):
1121 continue
1122 if first is True:
1123 print "\tOverrides:"
1124 first = False
1125 print "\t%s" % func
1127 def cmd_help(self, cmd=None):
1128 if cmd is not None:
1129 try:
1130 attr = self.__getattribute__("cmd_"+cmd.replace('-', '_'))
1131 except AttributeError:
1132 raise YapError("No such command: %s" % cmd)
1133 try:
1134 help = attr.long_help
1135 except AttributeError:
1136 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
1138 print >>sys.stderr, "The '%s' command" % cmd
1139 print >>sys.stderr, "\tyap %s %s" % (cmd, attr.__doc__)
1140 print >>sys.stderr, "%s" % help
1141 return
1143 print >> sys.stderr, "Yet Another (Git) Porcelein"
1144 print >> sys.stderr
1146 for name in dir(self):
1147 if not name.startswith('cmd_'):
1148 continue
1149 attr = self.__getattribute__(name)
1150 if not callable(attr):
1151 continue
1152 try:
1153 short_msg = attr.short_help
1154 except AttributeError:
1155 continue
1157 name = name.replace('cmd_', '')
1158 name = name.replace('_', '-')
1159 print >> sys.stderr, "%-16s%s" % (name, short_msg)
1160 print >> sys.stderr
1161 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
1163 def cmd_usage(self):
1164 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
1165 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 plugins version"
1167 def main(self, args):
1168 if len(args) < 1:
1169 self.cmd_usage()
1170 sys.exit(2)
1172 command = args[0]
1173 args = args[1:]
1175 if run_command("git --version"):
1176 print >>sys.stderr, "Failed to run git; is it installed?"
1177 sys.exit(1)
1179 debug = os.getenv('YAP_DEBUG')
1181 try:
1182 command = command.replace('-', '_')
1184 meth = None
1185 for p in self.plugins.values():
1186 try:
1187 meth = p.__getattribute__("cmd_"+command)
1188 except AttributeError:
1189 continue
1191 try:
1192 default_meth = self.__getattribute__("cmd_"+command)
1193 except AttributeError:
1194 default_meth = None
1196 if meth is None:
1197 meth = default_meth
1198 if meth is None:
1199 raise AttributeError
1201 if meth.__doc__ is not None:
1202 doc = meth.__doc__
1203 elif default_meth is not None:
1204 doc = default_meth.__doc__
1205 else:
1206 doc = ""
1208 try:
1209 if "options" in meth.__dict__:
1210 options = meth.options
1211 if default_meth and "options" in default_meth.__dict__:
1212 options += default_meth.options
1213 flags, args = getopt.getopt(args, options)
1214 flags = dict(flags)
1215 else:
1216 flags = dict()
1218 # invoke pre-hooks
1219 for p in self.plugins.values():
1220 try:
1221 meth = p.__getattribute__("pre_"+command)
1222 except AttributeError:
1223 continue
1224 meth(*args, **flags)
1226 meth(*args, **flags)
1228 # invoke post-hooks
1229 for p in self.plugins.values():
1230 try:
1231 meth = p.__getattribute__("post_"+command)
1232 except AttributeError:
1233 continue
1234 meth()
1236 except (TypeError, getopt.GetoptError):
1237 if debug:
1238 raise
1239 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1240 except YapError, e:
1241 print >> sys.stderr, e
1242 sys.exit(1)
1243 except AttributeError:
1244 if debug:
1245 raise
1246 self.cmd_usage()
1247 sys.exit(2)