Implement the merge subcommand
[yap.git] / yap / yap.py
blobb1afd7977221de0aba413ea8557f0f2032725cb1
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 try:
86 pickle.dump(files, open(path, 'w'))
87 except IOError:
88 pass
90 def _clear_new_files(self):
91 repo = get_output('git rev-parse --git-dir')[0]
92 path = os.path.join(repo, 'yap', 'new-files')
93 os.unlink(path)
95 def _assert_file_exists(self, file):
96 if not os.access(file, os.R_OK):
97 raise YapError("No such file: %s" % file)
99 def _get_staged_files(self):
100 if run_command("git rev-parse HEAD"):
101 files = get_output("git ls-files --cached")
102 else:
103 files = get_output("git diff-index --cached --name-only HEAD")
104 unmerged = self._get_unmerged_files()
105 if unmerged:
106 unmerged = set(unmerged)
107 files = set(files).difference(unmerged)
108 files = list(files)
109 return files
111 def _get_unstaged_files(self):
112 files = get_output("git ls-files -m")
113 prefix = get_output("git rev-parse --show-prefix")
114 if prefix:
115 files = [ os.path.join(prefix[0], x) for x in files ]
116 files += self._get_new_files()
117 unmerged = self._get_unmerged_files()
118 if unmerged:
119 unmerged = set(unmerged)
120 files = set(files).difference(unmerged)
121 files = list(files)
122 return files
124 def _get_unmerged_files(self):
125 files = get_output("git ls-files -u")
126 files = [ x.replace('\t', ' ').split(' ')[3] for x in files ]
127 prefix = get_output("git rev-parse --show-prefix")
128 if prefix:
129 files = [ os.path.join(prefix[0], x) for x in files ]
130 return list(set(files))
132 def _delete_branch(self, branch, force):
133 current = get_output("git symbolic-ref HEAD")
134 if current:
135 current = current[0].replace('refs/heads/', '')
136 if branch == current:
137 raise YapError("Can't delete current branch")
139 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
140 if not ref:
141 raise YapError("No such branch: %s" % branch)
142 run_safely("git update-ref -d 'refs/heads/%s' '%s'" % (branch, ref[0]))
144 if not force:
145 name = get_output("git name-rev --name-only '%s'" % ref[0])[0]
146 if name == 'undefined':
147 run_command("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
148 raise YapError("Refusing to delete leaf branch (use -f to force)")
149 def _get_pager_cmd(self):
150 if 'YAP_PAGER' in os.environ:
151 return os.environ['YAP_PAGER']
152 elif 'GIT_PAGER' in os.environ:
153 return os.environ['GIT_PAGER']
154 elif 'PAGER' in os.environ:
155 return os.environ['PAGER']
156 else:
157 return "more"
159 def _add_one(self, file):
160 self._assert_file_exists(file)
161 x = get_output("git ls-files '%s'" % file)
162 if x != []:
163 raise YapError("File '%s' already in repository" % file)
164 self._add_new_file(file)
166 def _rm_one(self, file):
167 self._assert_file_exists(file)
168 if get_output("git ls-files '%s'" % file) != []:
169 run_safely("git rm --cached '%s'" % file)
170 self._remove_new_file(file)
172 def _stage_one(self, file, allow_unmerged=False):
173 self._assert_file_exists(file)
174 prefix = get_output("git rev-parse --show-prefix")
175 if prefix:
176 tmp = os.path.normpath(os.path.join(prefix[0], file))
177 else:
178 tmp = file
179 if not allow_unmerged and tmp in self._get_unmerged_files():
180 raise YapError("Refusing to stage conflicted file: %s" % file)
181 run_safely("git update-index --add '%s'" % file)
183 def _unstage_one(self, file):
184 self._assert_file_exists(file)
185 if run_command("git rev-parse HEAD"):
186 rc = run_command("git update-index --force-remove '%s'" % file)
187 else:
188 rc = run_command("git diff-index -p HEAD '%s' | git apply -R --cached" % file)
189 if rc:
190 raise YapError("Failed to unstage")
192 def _revert_one(self, file):
193 self._assert_file_exists(file)
194 try:
195 self._unstage_one(file)
196 except YapError:
197 pass
198 run_safely("git checkout-index -u -f '%s'" % file)
200 def _parse_commit(self, commit):
201 lines = get_output("git cat-file commit '%s'" % commit)
202 commit = {}
204 mode = None
205 for l in lines:
206 if mode != 'commit' and l.strip() == "":
207 mode = 'commit'
208 commit['log'] = []
209 continue
210 if mode == 'commit':
211 commit['log'].append(l)
212 continue
214 x = l.split(' ')
215 k = x[0]
216 v = ' '.join(x[1:])
217 commit[k] = v
218 commit['log'] = '\n'.join(commit['log'])
219 return commit
221 def _check_commit(self, **flags):
222 if '-a' in flags and '-d' in flags:
223 raise YapError("Conflicting flags: -a and -d")
225 if '-d' not in flags and self._get_unstaged_files():
226 if '-a' not in flags and self._get_staged_files():
227 raise YapError("Staged and unstaged changes present. Specify what to commit")
228 os.system("git diff-files -p | git apply --cached")
229 for f in self._get_new_files():
230 self._stage_one(f)
232 def _do_uncommit(self):
233 commit = self._parse_commit("HEAD")
234 repo = get_output('git rev-parse --git-dir')[0]
235 dir = os.path.join(repo, 'yap')
236 try:
237 os.mkdir(dir)
238 except OSError:
239 pass
240 msg_file = os.path.join(dir, 'msg')
241 fd = file(msg_file, 'w')
242 print >>fd, commit['log']
243 fd.close()
245 tree = get_output("git rev-parse --verify HEAD^")
246 run_safely("git update-ref -m uncommit HEAD '%s'" % tree[0])
248 def _do_commit(self, msg=None):
249 tree = get_output("git write-tree")[0]
251 repo = get_output('git rev-parse --git-dir')[0]
252 head_file = os.path.join(repo, 'yap', 'merge')
253 try:
254 parent = pickle.load(file(head_file))
255 except IOError:
256 parent = get_output("git rev-parse --verify HEAD 2> /dev/null")
258 if os.environ.has_key('YAP_EDITOR'):
259 editor = os.environ['YAP_EDITOR']
260 elif os.environ.has_key('GIT_EDITOR'):
261 editor = os.environ['GIT_EDITOR']
262 elif os.environ.has_key('EDITOR'):
263 editor = os.environ['EDITOR']
264 else:
265 editor = "vi"
267 fd, tmpfile = tempfile.mkstemp("yap")
268 os.close(fd)
271 if msg is None:
272 msg_file = os.path.join(repo, 'yap', 'msg')
273 if os.access(msg_file, os.R_OK):
274 fd1 = file(msg_file)
275 fd2 = file(tmpfile, 'w')
276 for l in fd1.xreadlines():
277 print >>fd2, l.strip()
278 fd2.close()
279 os.unlink(msg_file)
280 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
281 raise YapError("Editing commit message failed")
282 fd = file(tmpfile)
283 msg = fd.readlines()
284 msg = ''.join(msg)
286 msg = msg.strip()
287 if not msg:
288 raise YapError("Refusing to use empty commit message")
290 (fd_w, fd_r) = os.popen2("git stripspace > %s" % tmpfile)
291 print >>fd_w, msg,
292 fd_w.close()
293 fd_r.close()
295 if parent:
296 parent = ' -p '.join(parent)
297 commit = get_output("git commit-tree '%s' -p %s < '%s'" % (tree, parent, tmpfile))
298 else:
299 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
301 os.unlink(tmpfile)
302 run_safely("git update-ref HEAD '%s'" % commit[0])
303 self._clear_state()
305 def _check_rebasing(self):
306 repo = get_output('git rev-parse --git-dir')[0]
307 dotest = os.path.join(repo, '.dotest')
308 if os.access(dotest, os.R_OK):
309 raise YapError("A git operation is in progress. Complete it first")
310 dotest = os.path.join(repo, '..', '.dotest')
311 if os.access(dotest, os.R_OK):
312 raise YapError("A git operation is in progress. Complete it first")
314 def _check_git(self):
315 if run_command("git rev-parse --git-dir"):
316 raise YapError("That command must be run from inside a git repository")
318 def _list_remotes(self):
319 remotes = get_output("git config --get-regexp '^remote.*.url'")
320 for x in remotes:
321 remote, url = x.split(' ')
322 remote = remote.replace('remote.', '')
323 remote = remote.replace('.url', '')
324 yield remote, url
326 def _unstage_all(self):
327 try:
328 run_safely("git read-tree -m HEAD")
329 except ShellError:
330 run_safely("git read-tree HEAD")
331 run_safely("git update-index -q --refresh")
333 def _get_tracking(self, current):
334 remote = get_output("git config branch.%s.remote" % current)
335 if not remote:
336 raise YapError("No tracking branch configured for '%s'" % current)
338 merge = get_output("git config branch.%s.merge" % current)
339 if not merge:
340 raise YapError("No tracking branch configured for '%s'" % current)
341 return remote[0], merge[0]
343 def __getattribute__(self, attr):
344 if attr.startswith("cmd_"):
345 meth = None
346 for p in self.plugins.values():
347 try:
348 meth = p.__getattribute__(attr)
349 break
350 except AttributeError:
351 continue
353 if meth:
354 return meth
355 return super(Yap, self).__getattribute__(attr)
357 def _call_base(self, method, *args, **flags):
358 base_method = super(Yap, self).__getattribute__(method)
359 return base_method(*args, **flags)
360 def _confirm_push(self, current, rhs, repo):
361 print "About to push local branch '%s' to '%s' on '%s'" % (current, rhs, repo)
362 print "Continue (y/n)? ",
363 sys.stdout.flush()
364 ans = sys.stdin.readline().strip()
366 if ans.lower() != 'y' and ans.lower() != 'yes':
367 raise YapError("Aborted.")
369 def _clear_state(self):
370 repo = get_output('git rev-parse --git-dir')[0]
371 dir = os.path.join(repo, 'yap')
372 try:
373 os.unlink(os.path.join(dir, 'new-files'))
374 os.unlink(os.path.join(dir, 'merge'))
375 os.unlink(os.path.join(dir, 'msg'))
376 except OSError:
377 pass
379 @short_help("make a local copy of an existing repository")
380 @long_help("""
381 The first argument is a URL to the existing repository. This can be an
382 absolute path if the repository is local, or a URL with the git://,
383 ssh://, or http:// schemes. By default, the directory used is the last
384 component of the URL, sans '.git'. This can be overridden by providing
385 a second argument.
386 """)
387 def cmd_clone(self, url, directory=None):
388 "<url> [directory]"
390 if '://' not in url and url[0] != '/':
391 url = os.path.join(os.getcwd(), url)
393 url = url.rstrip('/')
394 if directory is None:
395 directory = url.rsplit('/')[-1]
396 directory = directory.replace('.git', '')
398 try:
399 os.mkdir(directory)
400 except OSError:
401 raise YapError("Directory exists: %s" % directory)
402 os.chdir(directory)
403 self.cmd_init()
404 self.cmd_repo("origin", url)
405 self.cmd_fetch("origin")
407 branch = None
408 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
409 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
410 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
411 if get_output("git rev-parse %s" % b)[0] == hash:
412 branch = b
413 break
414 if branch is None:
415 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
416 branch = "refs/remotes/origin/master"
417 if branch is None:
418 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'")
419 branch = branch[0]
421 hash = get_output("git rev-parse %s" % branch)
422 assert hash
423 branch = branch.replace('refs/remotes/origin/', '')
424 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
425 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
426 self.cmd_revert(**{'-a': 1})
428 @short_help("turn a directory into a repository")
429 @long_help("""
430 Converts the current working directory into a repository. The primary
431 side-effect of this command is the creation of a '.git' subdirectory.
432 No files are added nor commits made.
433 """)
434 def cmd_init(self):
435 os.system("git init")
437 @short_help("add a new file to the repository")
438 @long_help("""
439 The arguments are the files to be added to the repository. Once added,
440 the files will show as "unstaged changes" in the output of 'status'. To
441 reverse the effects of this command, see 'rm'.
442 """)
443 def cmd_add(self, *files):
444 "<file>..."
445 self._check_git()
447 if not files:
448 raise TypeError
450 for f in files:
451 self._add_one(f)
452 self.cmd_status()
454 @short_help("delete a file from the repository")
455 @long_help("""
456 The arguments are the files to be removed from the current revision of
457 the repository. The files will still exist in any past commits that the
458 files may have been a part of. The file is not actually deleted, it is
459 just no longer tracked as part of the repository.
460 """)
461 def cmd_rm(self, *files):
462 "<file>..."
463 self._check_git()
464 if not files:
465 raise TypeError
467 for f in files:
468 self._rm_one(f)
469 self.cmd_status()
471 @short_help("stage changes in a file for commit")
472 @long_help("""
473 The arguments are the files to be staged. Staging changes is a way to
474 build up a commit when you do not want to commit all changes at once.
475 To commit only staged changes, use the '-d' flag to 'commit.' To
476 reverse the effects of this command, see 'unstage'. Once staged, the
477 files will show as "staged changes" in the output of 'status'.
478 """)
479 def cmd_stage(self, *files):
480 "<file>..."
481 self._check_git()
482 if not files:
483 raise TypeError
485 for f in files:
486 self._stage_one(f)
487 self.cmd_status()
489 @short_help("unstage changes in a file")
490 @long_help("""
491 The arguments are the files to be unstaged. Once unstaged, the files
492 will show as "unstaged changes" in the output of 'status'. The '-a'
493 flag can be used to unstage all staged changes at once.
494 """)
495 @takes_options("a")
496 def cmd_unstage(self, *files, **flags):
497 "[-a] | <file>..."
498 self._check_git()
499 if '-a' in flags:
500 self._unstage_all()
501 self.cmd_status()
502 return
504 if not files:
505 raise TypeError
507 for f in files:
508 self._unstage_one(f)
509 self.cmd_status()
511 @short_help("show files with staged and unstaged changes")
512 @long_help("""
513 Show the files in the repository with changes since the last commit,
514 categorized based on whether the changes are staged or not. A file may
515 appear under each heading if the same file has both staged and unstaged
516 changes.
517 """)
518 def cmd_status(self):
520 self._check_git()
521 branch = get_output("git symbolic-ref HEAD")
522 if branch:
523 branch = branch[0].replace('refs/heads/', '')
524 else:
525 branch = "DETACHED"
526 print "Current branch: %s" % branch
528 print "Files with staged changes:"
529 files = self._get_staged_files()
530 for f in files:
531 print "\t%s" % f
532 if not files:
533 print "\t(none)"
535 print "Files with unstaged changes:"
536 files = self._get_unstaged_files()
537 for f in files:
538 print "\t%s" % f
539 if not files:
540 print "\t(none)"
542 files = self._get_unmerged_files()
543 if files:
544 print "Files with conflicts:"
545 for f in files:
546 print "\t%s" % f
548 @short_help("remove uncommitted changes from a file (*)")
549 @long_help("""
550 The arguments are the files whose changes will be reverted. If the '-a'
551 flag is given, then all files will have uncommitted changes removed.
552 Note that there is no way to reverse this command short of manually
553 editing each file again.
554 """)
555 @takes_options("a")
556 def cmd_revert(self, *files, **flags):
557 "(-a | <file>)"
558 self._check_git()
559 if '-a' in flags:
560 self._unstage_all()
561 run_safely("git checkout-index -u -f -a")
562 self._clear_state()
563 self.cmd_status()
564 return
566 if not files:
567 raise TypeError
569 for f in files:
570 self._revert_one(f)
571 self.cmd_status()
573 @short_help("record changes to files as a new commit")
574 @long_help("""
575 Create a new commit recording changes since the last commit. If there
576 are only unstaged changes, those will be recorded. If there are only
577 staged changes, those will be recorded. Otherwise, you will have to
578 specify either the '-a' flag or the '-d' flag to commit all changes or
579 only staged changes, respectively. To reverse the effects of this
580 command, see 'uncommit'.
581 """)
582 @takes_options("adm:")
583 def cmd_commit(self, **flags):
584 "[-a | -d] [-m <msg>]"
585 self._check_git()
586 self._check_rebasing()
587 self._check_commit(**flags)
588 if not self._get_staged_files():
589 raise YapError("No changes to commit")
590 msg = flags.get('-m', None)
591 self._do_commit(msg)
592 self.cmd_status()
594 @short_help("reverse the actions of the last commit")
595 @long_help("""
596 Reverse the effects of the last 'commit' operation. The changes that
597 were part of the previous commit will show as "staged changes" in the
598 output of 'status'. This means that if no files were changed since the
599 last commit was created, 'uncommit' followed by 'commit' is a lossless
600 operation.
601 """)
602 def cmd_uncommit(self):
604 self._check_git()
605 self._do_uncommit()
606 self.cmd_status()
608 @short_help("report the current version of yap")
609 def cmd_version(self):
610 print "Yap version 0.1"
612 @short_help("show the changelog for particular versions or files")
613 @long_help("""
614 The arguments are the files with which to filter history. If none are
615 given, all changes are listed. Otherwise only commits that affected one
616 or more of the given files are listed. The -r option changes the
617 starting revision for traversing history. By default, history is listed
618 starting at HEAD.
619 """)
620 @takes_options("pr:")
621 def cmd_log(self, *paths, **flags):
622 "[-p] [-r <rev>] <path>..."
623 self._check_git()
624 rev = flags.get('-r', 'HEAD')
626 if '-p' in flags:
627 flags['-p'] = '-p'
629 if len(paths) == 1:
630 follow = "--follow"
631 else:
632 follow = ""
633 paths = ' '.join(paths)
634 os.system("git log -M -C %s %s '%s' -- %s"
635 % (follow, flags.get('-p', '--name-status'), rev, paths))
637 @short_help("show staged, unstaged, or all uncommitted changes")
638 @long_help("""
639 Show staged, unstaged, or all uncommitted changes. By default, all
640 changes are shown. The '-u' flag causes only unstaged changes to be
641 shown. The '-d' flag causes only staged changes to be shown.
642 """)
643 @takes_options("ud")
644 def cmd_diff(self, **flags):
645 "[ -u | -d ]"
646 self._check_git()
647 if '-u' in flags and '-d' in flags:
648 raise YapError("Conflicting flags: -u and -d")
650 pager = self._get_pager_cmd()
652 if '-u' in flags:
653 os.system("git diff-files -p | %s" % pager)
654 elif '-d' in flags:
655 os.system("git diff-index --cached -p HEAD | %s" % pager)
656 else:
657 os.system("git diff-index -p HEAD | %s" % pager)
659 @short_help("list, create, or delete branches")
660 @long_help("""
661 If no arguments are specified, a list of local branches is given. The
662 current branch is indicated by a "*" next to the name. If an argument
663 is given, it is taken as the name of a new branch to create. The branch
664 will start pointing at the current HEAD. See 'point' for details on
665 changing the revision of the new branch. Note that this command does
666 not switch the current working branch. See 'switch' for details on
667 changing the current working branch.
669 The '-d' flag can be used to delete local branches. If the delete
670 operation would remove the last branch reference to a given line of
671 history (colloquially referred to as "dangling commits"), yap will
672 report an error and abort. The '-f' flag can be used to force the delete
673 in spite of this.
674 """)
675 @takes_options("fd:")
676 def cmd_branch(self, branch=None, **flags):
677 "[ [-f] -d <branch> | <branch> ]"
678 self._check_git()
679 force = '-f' in flags
680 if '-d' in flags:
681 self._delete_branch(flags['-d'], force)
682 self.cmd_branch()
683 return
685 if branch is not None:
686 ref = get_output("git rev-parse --verify HEAD")
687 if not ref:
688 raise YapError("No branch point yet. Make a commit")
689 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
691 current = get_output("git symbolic-ref HEAD")
692 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
693 for b in branches:
694 if current and b == current[0]:
695 print "* ",
696 else:
697 print " ",
698 b = b.replace('refs/heads/', '')
699 print b
701 @short_help("change the current working branch")
702 @long_help("""
703 The argument is the name of the branch to make the current working
704 branch. This command will fail if there are uncommitted changes to any
705 files. Otherwise, the contents of the files in the working directory
706 are updated to reflect their state in the new branch. Additionally, any
707 future commits are added to the new branch instead of the previous line
708 of history.
709 """)
710 @takes_options("f")
711 def cmd_switch(self, branch, **flags):
712 "[-f] <branch>"
713 self._check_git()
714 self._check_rebasing()
715 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
716 if not ref:
717 raise YapError("No such branch: %s" % branch)
719 if '-f' not in flags:
720 if (self._get_staged_files()
721 or (self._get_unstaged_files()
722 and run_command("git update-index --refresh"))):
723 raise YapError("You have uncommitted changes. Use -f to continue anyway")
725 if self._get_unstaged_files() and self._get_staged_files():
726 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
728 staged = bool(self._get_staged_files())
730 run_command("git diff-files -p | git apply --cached")
731 for f in self._get_new_files():
732 self._stage_one(f)
734 idx = get_output("git write-tree")
735 new = get_output("git rev-parse refs/heads/%s" % branch)
736 readtree = "git read-tree --aggressive -u -m HEAD %s %s" % (idx[0], new[0])
737 if run_command(readtree):
738 run_command("git update-index --refresh")
739 if os.system(readtree):
740 raise YapError("Failed to switch")
741 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
743 if '-f' not in flags:
744 self._clear_state()
746 if not staged:
747 self._unstage_all()
748 self.cmd_status()
750 @short_help("move the current branch to a different revision")
751 @long_help("""
752 The argument is the hash of the commit to which the current branch
753 should point, or alternately a branch or tag (a.k.a, "committish"). If
754 moving the branch would create "dangling commits" (see 'branch'), yap
755 will report an error and abort. The '-f' flag can be used to force the
756 operation in spite of this.
757 """)
758 @takes_options("f")
759 def cmd_point(self, where, **flags):
760 "[-f] <where>"
761 self._check_git()
762 self._check_rebasing()
764 head = get_output("git rev-parse --verify HEAD")
765 if not head:
766 raise YapError("No commit yet; nowhere to point")
768 ref = get_output("git rev-parse --verify '%s^{commit}'" % where)
769 if not ref:
770 raise YapError("Not a valid ref: %s" % where)
772 if self._get_unstaged_files() or self._get_staged_files():
773 raise YapError("You have uncommitted changes. Commit them first")
775 run_safely("git update-ref HEAD '%s'" % ref[0])
777 if '-f' not in flags:
778 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
779 if name == "undefined":
780 os.system("git update-ref HEAD '%s'" % head[0])
781 raise YapError("Pointing there will lose commits. Use -f to force")
783 try:
784 run_safely("git read-tree -u -m HEAD")
785 except ShellError:
786 run_safely("git read-tree HEAD")
787 run_safely("git checkout-index -u -f -a")
788 self._clear_state()
790 @short_help("alter history by dropping or amending commits")
791 @long_help("""
792 This command operates in two distinct modes, "amend" and "drop" mode.
793 In drop mode, the given commit is removed from the history of the
794 current branch, as though that commit never happened. By default the
795 commit used is HEAD.
797 In amend mode, the uncommitted changes present are merged into a
798 previous commit. This is useful for correcting typos or adding missed
799 files into past commits. By default the commit used is HEAD.
801 While rewriting history it is possible that conflicts will arise. If
802 this happens, the rewrite will pause and you will be prompted to resolve
803 the conflicts and stage them. Once that is done, you will run "yap
804 history continue." If instead you want the conflicting commit removed
805 from history (perhaps your changes supercede that commit) you can run
806 "yap history skip". Once the rewrite completes, your branch will be on
807 the same commit as when the rewrite started.
808 """)
809 def cmd_history(self, subcmd, *args):
810 "amend | drop <commit>"
811 self._check_git()
813 if subcmd not in ("amend", "drop", "continue", "skip"):
814 raise TypeError
816 resolvemsg = """
817 When you have resolved the conflicts run \"yap history continue\".
818 To skip the problematic patch, run \"yap history skip\"."""
820 if subcmd == "continue":
821 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
822 return
823 if subcmd == "skip":
824 os.system("git reset --hard")
825 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
826 return
828 if subcmd == "amend":
829 flags, args = getopt.getopt(args, "ad")
830 flags = dict(flags)
832 if len(args) > 1:
833 raise TypeError
834 if args:
835 commit = args[0]
836 else:
837 commit = "HEAD"
839 if run_command("git rev-parse --verify '%s'" % commit):
840 raise YapError("Not a valid commit: %s" % commit)
842 self._check_rebasing()
844 if subcmd == "amend":
845 self._check_commit(**flags)
846 if self._get_unstaged_files():
847 # XXX: handle unstaged changes better
848 raise YapError("Commit away changes that you aren't amending")
850 self._unstage_all()
852 start = get_output("git rev-parse HEAD")
853 stash = get_output("git stash create")
854 run_command("git reset --hard")
855 try:
856 fd, tmpfile = tempfile.mkstemp("yap")
857 try:
858 try:
859 os.close(fd)
860 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
861 if subcmd == "amend":
862 self.cmd_point(commit, **{'-f': True})
863 finally:
864 if subcmd == "amend":
865 if stash:
866 rc = os.system("git stash apply %s" % stash[0])
867 if rc:
868 self.cmd_point(start[0], **{'-f': True})
869 os.system("git stash apply %s" % stash[0])
870 raise YapError("Failed to apply stash")
871 stash = None
873 if subcmd == "amend":
874 self._do_uncommit()
875 self._check_commit(**{'-a': True})
876 self._do_commit()
877 else:
878 self.cmd_point("%s^" % commit, **{'-f': True})
880 stat = os.stat(tmpfile)
881 size = stat[6]
882 if size > 0:
883 run_safely("git update-index --refresh")
884 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
885 if (rc):
886 raise YapError("Failed to apply changes")
887 finally:
888 os.unlink(tmpfile)
889 finally:
890 if stash:
891 run_command("git stash apply %s" % stash[0])
892 self.cmd_status()
894 @short_help("show the changes introduced by a given commit")
895 @long_help("""
896 By default, the changes in the last commit are shown. To override this,
897 specify a hash, branch, or tag (committish). The hash of the commit,
898 the commit's author, log message, and a diff of the changes are shown.
899 """)
900 def cmd_show(self, commit="HEAD"):
901 "[commit]"
902 self._check_git()
903 os.system("git show '%s'" % commit)
905 @short_help("apply the changes in a given commit to the current branch")
906 @long_help("""
907 The argument is the hash, branch, or tag (committish) of the commit to
908 be applied. In general, it only makes sense to apply commits that
909 happened on another branch. The '-r' flag can be used to have the
910 changes in the given commit reversed from the current branch. In
911 general, this only makes sense for commits that happened on the current
912 branch.
913 """)
914 @takes_options("r")
915 def cmd_cherry_pick(self, commit, **flags):
916 "[-r] <commit>"
917 self._check_git()
918 if '-r' in flags:
919 os.system("git revert '%s'" % commit)
920 else:
921 os.system("git cherry-pick '%s'" % commit)
923 @short_help("list, add, or delete configured remote repositories")
924 @long_help("""
925 When invoked with no arguments, this command will show the list of
926 currently configured remote repositories, giving both the name and URL
927 of each. To add a new repository, give the desired name as the first
928 argument and the URL as the second. The '-d' flag can be used to remove
929 a previously added repository.
930 """)
931 @takes_options("d:")
932 def cmd_repo(self, name=None, url=None, **flags):
933 "[<name> <url> | -d <name>]"
934 self._check_git()
935 if name is not None and url is None:
936 raise TypeError
938 if '-d' in flags:
939 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
940 raise YapError("No such repository: %s" % flags['-d'])
941 os.system("git config --unset remote.%s.url" % flags['-d'])
942 os.system("git config --unset remote.%s.fetch" % flags['-d'])
944 if name:
945 if name in [ x[0] for x in self._list_remotes() ]:
946 raise YapError("Repository '%s' already exists" % flags['-d'])
947 os.system("git config remote.%s.url %s" % (name, url))
948 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
950 for remote, url in self._list_remotes():
951 print "%-20s %s" % (remote, url)
953 @short_help("send local commits to a remote repository (*)")
954 @long_help("""
955 When invoked with no arguments, the current branch is synchronized to
956 the tracking branch of the tracking remote. If no tracking remote is
957 specified, the repository will have to be specified on the command line.
958 In that case, the default is to push to a branch with the same name as
959 the current branch. This behavior can be overridden by giving a second
960 argument to specify the remote branch.
962 If the remote branch does not currently exist, the command will abort
963 unless the -c flag is provided. If the remote branch is not a direct
964 descendent of the local branch, the command will abort unless the -f
965 flag is provided. Forcing a push in this way can be problematic to
966 other users of the repository if they are not expecting it.
968 To delete a branch on the remote repository, use the -d flag.
969 """)
970 @takes_options("cdf")
971 def cmd_push(self, repo=None, rhs=None, **flags):
972 "[-c | -d] <repo>"
973 self._check_git()
974 if '-c' in flags and '-d' in flags:
975 raise TypeError
977 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
978 raise YapError("No such repository: %s" % repo)
980 current = get_output("git symbolic-ref HEAD")
981 if not current:
982 raise YapError("Not on a branch!")
984 self._check_rebasing()
986 current = current[0].replace('refs/heads/', '')
987 remote = get_output("git config branch.%s.remote" % current)
988 if repo is None and remote:
989 repo = remote[0]
991 if repo is None:
992 raise YapError("No tracking branch configured; specify destination repository")
994 if rhs is None and remote and remote[0] == repo:
995 merge = get_output("git config branch.%s.merge" % current)
996 if merge:
997 rhs = merge[0]
999 if rhs is None:
1000 rhs = "refs/heads/%s" % current
1002 if '-c' not in flags and '-d' not in flags:
1003 if run_command("git rev-parse --verify refs/remotes/%s/%s"
1004 % (repo, rhs.replace('refs/heads/', ''))):
1005 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
1006 if '-f' not in flags:
1007 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo, rhs.replace('refs/heads/', '')))
1008 base = get_output("git merge-base HEAD %s" % hash[0])
1009 assert base
1010 if base[0] != hash[0]:
1011 raise YapError("Branch not up-to-date with remote. Update or use -f")
1013 self._confirm_push(current, rhs, repo)
1014 if '-f' in flags:
1015 flags['-f'] = '-f'
1017 if '-d' in flags:
1018 lhs = ""
1019 else:
1020 lhs = "refs/heads/%s" % current
1021 rc = os.system("git push %s %s %s:%s" % (flags.get('-f', ''), repo, lhs, rhs))
1022 if rc:
1023 raise YapError("Push failed.")
1025 @short_help("retrieve commits from a remote repository")
1026 @long_help("""
1027 When run with no arguments, the command will retrieve new commits from
1028 the remote tracking repository. Note that this does not in any way
1029 alter the current branch. For that, see "update". If a remote other
1030 than the tracking remote is desired, it can be specified as the first
1031 argument.
1032 """)
1033 def cmd_fetch(self, repo=None):
1034 "<repo>"
1035 self._check_git()
1036 current = get_output("git symbolic-ref HEAD")
1037 if not current:
1038 raise YapError("Not on a branch!")
1040 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
1041 raise YapError("No such repository: %s" % repo)
1042 if repo is None:
1043 current = current[0].replace('refs/heads/', '')
1044 remote = get_output("git config branch.%s.remote" % current)
1045 if remote:
1046 repo = remote[0]
1047 if repo is None:
1048 raise YapError("No tracking branch configured; specify a repository")
1049 os.system("git fetch %s" % repo)
1051 @short_help("update the current branch relative to its tracking branch")
1052 @long_help("""
1053 Updates the current branch relative to its remote tracking branch. This
1054 command requires that the current branch have a remote tracking branch
1055 configured. If any conflicts occur while applying your changes to the
1056 updated remote, the command will pause to allow you to fix them. Once
1057 that is done, run "update" with the "continue" subcommand. Alternately,
1058 the "skip" subcommand can be used to discard the conflicting changes.
1059 """)
1060 def cmd_update(self, subcmd=None):
1061 "[continue | skip]"
1062 self._check_git()
1063 if subcmd and subcmd not in ["continue", "skip"]:
1064 raise TypeError
1066 resolvemsg = """
1067 When you have resolved the conflicts run \"yap update continue\".
1068 To skip the problematic patch, run \"yap update skip\"."""
1070 if subcmd == "continue":
1071 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
1072 return
1073 if subcmd == "skip":
1074 os.system("git reset --hard")
1075 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
1076 return
1078 self._check_rebasing()
1079 if self._get_unstaged_files() or self._get_staged_files():
1080 raise YapError("You have uncommitted changes. Commit them first")
1082 current = get_output("git symbolic-ref HEAD")
1083 if not current:
1084 raise YapError("Not on a branch!")
1086 current = current[0].replace('refs/heads/', '')
1087 remote, merge = self._get_tracking(current)
1088 merge = merge.replace('refs/heads/', '')
1090 self.cmd_fetch(remote)
1091 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
1093 try:
1094 fd, tmpfile = tempfile.mkstemp("yap")
1095 os.close(fd)
1096 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
1097 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
1099 stat = os.stat(tmpfile)
1100 size = stat[6]
1101 if size > 0:
1102 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
1103 if (rc):
1104 raise YapError("Failed to apply changes")
1105 finally:
1106 os.unlink(tmpfile)
1108 @short_help("query and configure remote branch tracking")
1109 @long_help("""
1110 When invoked with no arguments, the command displays the tracking
1111 information for the current branch. To configure the tracking
1112 information, two arguments for the remote repository and remote branch
1113 are given. The tracking information is used to provide defaults for
1114 where to push local changes and from where to get updates to the branch.
1115 """)
1116 def cmd_track(self, repo=None, branch=None):
1117 "[<repo> <branch>]"
1118 self._check_git()
1120 current = get_output("git symbolic-ref HEAD")
1121 if not current:
1122 raise YapError("Not on a branch!")
1123 current = current[0].replace('refs/heads/', '')
1125 if repo is None and branch is None:
1126 repo, merge = self._get_tracking(current)
1127 merge = merge.replace('refs/heads/', '')
1128 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
1129 return
1131 if repo is None or branch is None:
1132 raise TypeError
1134 if repo not in [ x[0] for x in self._list_remotes() ]:
1135 raise YapError("No such repository: %s" % repo)
1137 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
1138 raise YapError("No such branch '%s' on repository '%s'" % (branch, repo))
1140 os.system("git config branch.%s.remote '%s'" % (current, repo))
1141 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
1142 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
1144 @short_help("mark files with conflicts as resolved")
1145 @long_help("""
1146 The arguments are the files to be marked resolved. When a conflict
1147 occurs while merging changes to a file, that file is marked as
1148 "unmerged." Until the file(s) with conflicts are marked resolved,
1149 commits cannot be made.
1150 """)
1151 def cmd_resolved(self, *files):
1152 "<file>..."
1153 self._check_git()
1154 if not files:
1155 raise TypeError
1157 for f in files:
1158 self._stage_one(f, True)
1159 self.cmd_status()
1161 @short_help("merge a branch into the current branch")
1162 def cmd_merge(self, branch):
1163 "<branch>"
1164 self._check_git()
1166 branch_name = branch
1167 branch = get_output("git rev-parse --verify %s" % branch)
1168 if not branch:
1169 raise YapError("No such branch: %s" % branch)
1170 branch = branch[0]
1172 base = get_output("git merge-base HEAD %s" % branch)
1173 if not base:
1174 raise YapError("Branch '%s' is not a fork of the current branch"
1175 % branch)
1177 readtree = ("git read-tree --aggressive -u -m %s HEAD %s"
1178 % (base[0], branch))
1179 if run_command(readtree):
1180 run_command("git update-index --refresh")
1181 if os.system(readtree):
1182 raise YapError("Failed to merge")
1184 repo = get_output('git rev-parse --git-dir')[0]
1185 dir = os.path.join(repo, 'yap')
1186 try:
1187 os.mkdir(dir)
1188 except OSError:
1189 pass
1190 msg_file = os.path.join(dir, 'msg')
1191 msg = file(msg_file, 'w')
1192 print >>msg, "Merge branch '%s'" % branch_name
1193 msg.close()
1195 head = get_output("git rev-parse --verify HEAD")
1196 assert head
1197 heads = [head[0], branch]
1198 head_file = os.path.join(dir, 'merge')
1199 pickle.dump(heads, file(head_file, 'w'))
1201 self._merge_index(branch, base[0])
1202 if self._get_unmerged_files():
1203 self.cmd_status()
1204 raise YapError("Fix conflicts then commit")
1206 self._do_commit()
1208 def _merge_index(self, branch, base):
1209 for f in self._get_unmerged_files():
1210 fd, bfile = tempfile.mkstemp("yap")
1211 os.close(fd)
1212 rc = os.system("git show %s:%s > %s" % (base, f, bfile))
1213 assert rc == 0
1215 fd, ofile = tempfile.mkstemp("yap")
1216 os.close(fd)
1217 rc = os.system("git show %s:%s > %s" % (branch, f, ofile))
1218 assert rc == 0
1220 command = "git merge-file -L %(file)s -L %(file)s.base -L %(file)s.%(branch)s %(file)s %(base)s %(other)s " % dict(file=f, branch=branch, base=bfile, other=ofile)
1221 rc = os.system(command)
1222 os.unlink(ofile)
1223 os.unlink(bfile)
1225 assert rc >= 0
1226 if rc == 0:
1227 self._stage_one(f, True)
1229 @short_help("show information about loaded plugins")
1230 def cmd_plugins(self):
1232 if not self.plugins:
1233 print >>sys.stderr, "No plugins loaded."
1234 for k, v in self.plugins.items():
1235 doc = v.__doc__
1236 if doc is None:
1237 doc = "No description"
1238 print "%-20s%s" % (k, doc)
1239 first = True
1240 for func in dir(v):
1241 if not func.startswith('cmd_'):
1242 continue
1243 if first is True:
1244 print "\tOverrides:"
1245 first = False
1246 print "\t%s" % func
1248 def cmd_help(self, cmd=None):
1249 if cmd is not None:
1250 cmd = "cmd_" + cmd.replace('-', '_')
1251 try:
1252 attr = self.__getattribute__(cmd)
1253 except AttributeError:
1254 raise YapError("No such command: %s" % cmd)
1255 try:
1256 help = attr.long_help
1257 except AttributeError:
1258 attr = super(Yap, self).__getattribute__(cmd)
1259 try:
1260 help = attr.long_help
1261 except AttributeError:
1262 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
1264 print >>sys.stderr, "The '%s' command" % cmd
1265 print >>sys.stderr, "\tyap %s %s" % (cmd, attr.__doc__)
1266 print >>sys.stderr, "%s" % help
1267 return
1269 print >> sys.stderr, "Yet Another (Git) Porcelein"
1270 print >> sys.stderr
1272 for name in dir(self):
1273 if not name.startswith('cmd_'):
1274 continue
1275 attr = self.__getattribute__(name)
1276 if not callable(attr):
1277 continue
1279 try:
1280 short_msg = attr.short_help
1281 except AttributeError:
1282 try:
1283 default_meth = super(Yap, self).__getattribute__(name)
1284 short_msg = default_meth.short_help
1285 except AttributeError:
1286 continue
1288 name = name.replace('cmd_', '')
1289 name = name.replace('_', '-')
1290 print >> sys.stderr, "%-16s%s" % (name, short_msg)
1292 print >> sys.stderr
1293 print >> sys.stderr, "Commands provided by plugins:"
1294 for k, v in self.plugins.items():
1295 for name in dir(v):
1296 if not name.startswith('cmd_'):
1297 continue
1298 try:
1299 attr = self.__getattribute__(name)
1300 short_msg = attr.short_help
1301 except AttributeError:
1302 continue
1303 name = name.replace('cmd_', '')
1304 name = name.replace('_', '-')
1305 print >> sys.stderr, "%-8s(%s) %s" % (name, k, short_msg)
1307 print >> sys.stderr
1308 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
1310 def cmd_usage(self):
1311 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
1312 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"
1314 def main(self, args):
1315 if len(args) < 1:
1316 self.cmd_usage()
1317 sys.exit(2)
1319 command = args[0]
1320 args = args[1:]
1322 if run_command("git --version"):
1323 print >>sys.stderr, "Failed to run git; is it installed?"
1324 sys.exit(1)
1326 debug = os.getenv('YAP_DEBUG')
1328 try:
1329 command = command.replace('-', '_')
1331 meth = self.__getattribute__("cmd_"+command)
1332 try:
1333 default_meth = super(Yap, self).__getattribute__("cmd_"+command)
1334 except AttributeError:
1335 default_meth = None
1337 if meth.__doc__ is not None:
1338 doc = meth.__doc__
1339 elif default_meth is not None:
1340 doc = default_meth.__doc__
1341 else:
1342 doc = ""
1344 try:
1345 options = ""
1346 if "options" in meth.__dict__:
1347 options = meth.options
1348 if default_meth and "options" in default_meth.__dict__:
1349 options += default_meth.options
1350 if options:
1351 flags, args = getopt.getopt(args, options)
1352 flags = dict(flags)
1353 else:
1354 flags = dict()
1356 # cast args to a mutable type. this lets the pre-hooks act as
1357 # filters on the arguments
1358 args = list(args)
1360 # invoke pre-hooks
1361 for p in self.plugins.values():
1362 try:
1363 pre_meth = p.__getattribute__("pre_"+command)
1364 except AttributeError:
1365 continue
1366 pre_meth(args, flags)
1368 meth(*args, **flags)
1370 # invoke post-hooks
1371 for p in self.plugins.values():
1372 try:
1373 meth = p.__getattribute__("post_"+command)
1374 except AttributeError:
1375 continue
1376 meth()
1378 except (TypeError, getopt.GetoptError):
1379 if debug:
1380 raise
1381 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1382 except YapError, e:
1383 if debug:
1384 raise
1385 print >> sys.stderr, e
1386 sys.exit(1)
1387 except AttributeError:
1388 if debug:
1389 raise
1390 self.cmd_usage()
1391 sys.exit(2)