cmd_unstage: don't mark conflicting files as resolved
[yap.git] / yap / yap.py
blob60b9ec8204f15fe91e4db1a7a708d22fc27ea2ae
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 files = self._get_staged_files()
502 if not files:
503 raise TypeError
505 for f in files:
506 self._unstage_one(f)
507 self.cmd_status()
509 @short_help("show files with staged and unstaged changes")
510 @long_help("""
511 Show the files in the repository with changes since the last commit,
512 categorized based on whether the changes are staged or not. A file may
513 appear under each heading if the same file has both staged and unstaged
514 changes.
515 """)
516 def cmd_status(self):
518 self._check_git()
519 branch = get_output("git symbolic-ref HEAD")
520 if branch:
521 branch = branch[0].replace('refs/heads/', '')
522 else:
523 branch = "DETACHED"
524 print "Current branch: %s" % branch
526 print "Files with staged changes:"
527 files = self._get_staged_files()
528 for f in files:
529 print "\t%s" % f
530 if not files:
531 print "\t(none)"
533 print "Files with unstaged changes:"
534 files = self._get_unstaged_files()
535 for f in files:
536 print "\t%s" % f
537 if not files:
538 print "\t(none)"
540 files = self._get_unmerged_files()
541 if files:
542 print "Files with conflicts:"
543 for f in files:
544 print "\t%s" % f
546 @short_help("remove uncommitted changes from a file (*)")
547 @long_help("""
548 The arguments are the files whose changes will be reverted. If the '-a'
549 flag is given, then all files will have uncommitted changes removed.
550 Note that there is no way to reverse this command short of manually
551 editing each file again.
552 """)
553 @takes_options("a")
554 def cmd_revert(self, *files, **flags):
555 "(-a | <file>)"
556 self._check_git()
557 if '-a' in flags:
558 self._unstage_all()
559 run_safely("git checkout-index -u -f -a")
560 self._clear_state()
561 self.cmd_status()
562 return
564 if not files:
565 raise TypeError
567 for f in files:
568 self._revert_one(f)
569 self.cmd_status()
571 @short_help("record changes to files as a new commit")
572 @long_help("""
573 Create a new commit recording changes since the last commit. If there
574 are only unstaged changes, those will be recorded. If there are only
575 staged changes, those will be recorded. Otherwise, you will have to
576 specify either the '-a' flag or the '-d' flag to commit all changes or
577 only staged changes, respectively. To reverse the effects of this
578 command, see 'uncommit'.
579 """)
580 @takes_options("adm:")
581 def cmd_commit(self, **flags):
582 "[-a | -d] [-m <msg>]"
583 self._check_git()
584 self._check_rebasing()
585 self._check_commit(**flags)
586 if not self._get_staged_files():
587 raise YapError("No changes to commit")
588 msg = flags.get('-m', None)
589 self._do_commit(msg)
590 self.cmd_status()
592 @short_help("reverse the actions of the last commit")
593 @long_help("""
594 Reverse the effects of the last 'commit' operation. The changes that
595 were part of the previous commit will show as "staged changes" in the
596 output of 'status'. This means that if no files were changed since the
597 last commit was created, 'uncommit' followed by 'commit' is a lossless
598 operation.
599 """)
600 def cmd_uncommit(self):
602 self._check_git()
603 self._do_uncommit()
604 self.cmd_status()
606 @short_help("report the current version of yap")
607 def cmd_version(self):
608 print "Yap version 0.1"
610 @short_help("show the changelog for particular versions or files")
611 @long_help("""
612 The arguments are the files with which to filter history. If none are
613 given, all changes are listed. Otherwise only commits that affected one
614 or more of the given files are listed. The -r option changes the
615 starting revision for traversing history. By default, history is listed
616 starting at HEAD.
617 """)
618 @takes_options("pr:")
619 def cmd_log(self, *paths, **flags):
620 "[-p] [-r <rev>] <path>..."
621 self._check_git()
622 rev = flags.get('-r', 'HEAD')
624 if '-p' in flags:
625 flags['-p'] = '-p'
627 if len(paths) == 1:
628 follow = "--follow"
629 else:
630 follow = ""
631 paths = ' '.join(paths)
632 os.system("git log -M -C %s %s '%s' -- %s"
633 % (follow, flags.get('-p', '--name-status'), rev, paths))
635 @short_help("show staged, unstaged, or all uncommitted changes")
636 @long_help("""
637 Show staged, unstaged, or all uncommitted changes. By default, all
638 changes are shown. The '-u' flag causes only unstaged changes to be
639 shown. The '-d' flag causes only staged changes to be shown.
640 """)
641 @takes_options("ud")
642 def cmd_diff(self, **flags):
643 "[ -u | -d ]"
644 self._check_git()
645 if '-u' in flags and '-d' in flags:
646 raise YapError("Conflicting flags: -u and -d")
648 pager = self._get_pager_cmd()
650 if '-u' in flags:
651 os.system("git diff-files -p | %s" % pager)
652 elif '-d' in flags:
653 os.system("git diff-index --cached -p HEAD | %s" % pager)
654 else:
655 os.system("git diff-index -p HEAD | %s" % pager)
657 @short_help("list, create, or delete branches")
658 @long_help("""
659 If no arguments are specified, a list of local branches is given. The
660 current branch is indicated by a "*" next to the name. If an argument
661 is given, it is taken as the name of a new branch to create. The branch
662 will start pointing at the current HEAD. See 'point' for details on
663 changing the revision of the new branch. Note that this command does
664 not switch the current working branch. See 'switch' for details on
665 changing the current working branch.
667 The '-d' flag can be used to delete local branches. If the delete
668 operation would remove the last branch reference to a given line of
669 history (colloquially referred to as "dangling commits"), yap will
670 report an error and abort. The '-f' flag can be used to force the delete
671 in spite of this.
672 """)
673 @takes_options("fd:")
674 def cmd_branch(self, branch=None, **flags):
675 "[ [-f] -d <branch> | <branch> ]"
676 self._check_git()
677 force = '-f' in flags
678 if '-d' in flags:
679 self._delete_branch(flags['-d'], force)
680 self.cmd_branch()
681 return
683 if branch is not None:
684 ref = get_output("git rev-parse --verify HEAD")
685 if not ref:
686 raise YapError("No branch point yet. Make a commit")
687 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
689 current = get_output("git symbolic-ref HEAD")
690 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
691 for b in branches:
692 if current and b == current[0]:
693 print "* ",
694 else:
695 print " ",
696 b = b.replace('refs/heads/', '')
697 print b
699 @short_help("change the current working branch")
700 @long_help("""
701 The argument is the name of the branch to make the current working
702 branch. This command will fail if there are uncommitted changes to any
703 files. Otherwise, the contents of the files in the working directory
704 are updated to reflect their state in the new branch. Additionally, any
705 future commits are added to the new branch instead of the previous line
706 of history.
707 """)
708 @takes_options("f")
709 def cmd_switch(self, branch, **flags):
710 "[-f] <branch>"
711 self._check_git()
712 self._check_rebasing()
713 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
714 if not ref:
715 raise YapError("No such branch: %s" % branch)
717 if '-f' not in flags:
718 if (self._get_staged_files()
719 or (self._get_unstaged_files()
720 and run_command("git update-index --refresh"))):
721 raise YapError("You have uncommitted changes. Use -f to continue anyway")
723 if self._get_unstaged_files() and self._get_staged_files():
724 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
726 staged = bool(self._get_staged_files())
728 run_command("git diff-files -p | git apply --cached")
729 for f in self._get_new_files():
730 self._stage_one(f)
732 idx = get_output("git write-tree")
733 new = get_output("git rev-parse refs/heads/%s" % branch)
734 readtree = "git read-tree --aggressive -u -m HEAD %s %s" % (idx[0], new[0])
735 if run_command(readtree):
736 run_command("git update-index --refresh")
737 if os.system(readtree):
738 raise YapError("Failed to switch")
739 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
741 if '-f' not in flags:
742 self._clear_state()
744 if not staged:
745 self._unstage_all()
746 self.cmd_status()
748 @short_help("move the current branch to a different revision")
749 @long_help("""
750 The argument is the hash of the commit to which the current branch
751 should point, or alternately a branch or tag (a.k.a, "committish"). If
752 moving the branch would create "dangling commits" (see 'branch'), yap
753 will report an error and abort. The '-f' flag can be used to force the
754 operation in spite of this.
755 """)
756 @takes_options("f")
757 def cmd_point(self, where, **flags):
758 "[-f] <where>"
759 self._check_git()
760 self._check_rebasing()
762 head = get_output("git rev-parse --verify HEAD")
763 if not head:
764 raise YapError("No commit yet; nowhere to point")
766 ref = get_output("git rev-parse --verify '%s^{commit}'" % where)
767 if not ref:
768 raise YapError("Not a valid ref: %s" % where)
770 if self._get_unstaged_files() or self._get_staged_files():
771 raise YapError("You have uncommitted changes. Commit them first")
773 run_safely("git update-ref HEAD '%s'" % ref[0])
775 if '-f' not in flags:
776 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
777 if name == "undefined":
778 os.system("git update-ref HEAD '%s'" % head[0])
779 raise YapError("Pointing there will lose commits. Use -f to force")
781 try:
782 run_safely("git read-tree -u -m HEAD")
783 except ShellError:
784 run_safely("git read-tree HEAD")
785 run_safely("git checkout-index -u -f -a")
786 self._clear_state()
788 @short_help("alter history by dropping or amending commits")
789 @long_help("""
790 This command operates in two distinct modes, "amend" and "drop" mode.
791 In drop mode, the given commit is removed from the history of the
792 current branch, as though that commit never happened. By default the
793 commit used is HEAD.
795 In amend mode, the uncommitted changes present are merged into a
796 previous commit. This is useful for correcting typos or adding missed
797 files into past commits. By default the commit used is HEAD.
799 While rewriting history it is possible that conflicts will arise. If
800 this happens, the rewrite will pause and you will be prompted to resolve
801 the conflicts and stage them. Once that is done, you will run "yap
802 history continue." If instead you want the conflicting commit removed
803 from history (perhaps your changes supercede that commit) you can run
804 "yap history skip". Once the rewrite completes, your branch will be on
805 the same commit as when the rewrite started.
806 """)
807 def cmd_history(self, subcmd, *args):
808 "amend | drop <commit>"
809 self._check_git()
811 if subcmd not in ("amend", "drop", "continue", "skip"):
812 raise TypeError
814 resolvemsg = """
815 When you have resolved the conflicts run \"yap history continue\".
816 To skip the problematic patch, run \"yap history skip\"."""
818 if subcmd == "continue":
819 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
820 return
821 if subcmd == "skip":
822 os.system("git reset --hard")
823 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
824 return
826 if subcmd == "amend":
827 flags, args = getopt.getopt(args, "ad")
828 flags = dict(flags)
830 if len(args) > 1:
831 raise TypeError
832 if args:
833 commit = args[0]
834 else:
835 commit = "HEAD"
837 if run_command("git rev-parse --verify '%s'" % commit):
838 raise YapError("Not a valid commit: %s" % commit)
840 self._check_rebasing()
842 if subcmd == "amend":
843 self._check_commit(**flags)
844 if self._get_unstaged_files():
845 # XXX: handle unstaged changes better
846 raise YapError("Commit away changes that you aren't amending")
848 self._unstage_all()
850 start = get_output("git rev-parse HEAD")
851 stash = get_output("git stash create")
852 run_command("git reset --hard")
853 try:
854 fd, tmpfile = tempfile.mkstemp("yap")
855 try:
856 try:
857 os.close(fd)
858 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
859 if subcmd == "amend":
860 self.cmd_point(commit, **{'-f': True})
861 finally:
862 if subcmd == "amend":
863 if stash:
864 rc = os.system("git stash apply %s" % stash[0])
865 if rc:
866 self.cmd_point(start[0], **{'-f': True})
867 os.system("git stash apply %s" % stash[0])
868 raise YapError("Failed to apply stash")
869 stash = None
871 if subcmd == "amend":
872 self._do_uncommit()
873 self._check_commit(**{'-a': True})
874 self._do_commit()
875 else:
876 self.cmd_point("%s^" % commit, **{'-f': True})
878 stat = os.stat(tmpfile)
879 size = stat[6]
880 if size > 0:
881 run_safely("git update-index --refresh")
882 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
883 if (rc):
884 raise YapError("Failed to apply changes")
885 finally:
886 os.unlink(tmpfile)
887 finally:
888 if stash:
889 run_command("git stash apply %s" % stash[0])
890 self.cmd_status()
892 @short_help("show the changes introduced by a given commit")
893 @long_help("""
894 By default, the changes in the last commit are shown. To override this,
895 specify a hash, branch, or tag (committish). The hash of the commit,
896 the commit's author, log message, and a diff of the changes are shown.
897 """)
898 def cmd_show(self, commit="HEAD"):
899 "[commit]"
900 self._check_git()
901 os.system("git show '%s'" % commit)
903 @short_help("apply the changes in a given commit to the current branch")
904 @long_help("""
905 The argument is the hash, branch, or tag (committish) of the commit to
906 be applied. In general, it only makes sense to apply commits that
907 happened on another branch. The '-r' flag can be used to have the
908 changes in the given commit reversed from the current branch. In
909 general, this only makes sense for commits that happened on the current
910 branch.
911 """)
912 @takes_options("r")
913 def cmd_cherry_pick(self, commit, **flags):
914 "[-r] <commit>"
915 self._check_git()
916 if '-r' in flags:
917 os.system("git revert '%s'" % commit)
918 else:
919 os.system("git cherry-pick '%s'" % commit)
921 @short_help("list, add, or delete configured remote repositories")
922 @long_help("""
923 When invoked with no arguments, this command will show the list of
924 currently configured remote repositories, giving both the name and URL
925 of each. To add a new repository, give the desired name as the first
926 argument and the URL as the second. The '-d' flag can be used to remove
927 a previously added repository.
928 """)
929 @takes_options("d:")
930 def cmd_repo(self, name=None, url=None, **flags):
931 "[<name> <url> | -d <name>]"
932 self._check_git()
933 if name is not None and url is None:
934 raise TypeError
936 if '-d' in flags:
937 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
938 raise YapError("No such repository: %s" % flags['-d'])
939 os.system("git config --unset remote.%s.url" % flags['-d'])
940 os.system("git config --unset remote.%s.fetch" % flags['-d'])
942 if name:
943 if name in [ x[0] for x in self._list_remotes() ]:
944 raise YapError("Repository '%s' already exists" % flags['-d'])
945 os.system("git config remote.%s.url %s" % (name, url))
946 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
948 for remote, url in self._list_remotes():
949 print "%-20s %s" % (remote, url)
951 @short_help("send local commits to a remote repository (*)")
952 @long_help("""
953 When invoked with no arguments, the current branch is synchronized to
954 the tracking branch of the tracking remote. If no tracking remote is
955 specified, the repository will have to be specified on the command line.
956 In that case, the default is to push to a branch with the same name as
957 the current branch. This behavior can be overridden by giving a second
958 argument to specify the remote branch.
960 If the remote branch does not currently exist, the command will abort
961 unless the -c flag is provided. If the remote branch is not a direct
962 descendent of the local branch, the command will abort unless the -f
963 flag is provided. Forcing a push in this way can be problematic to
964 other users of the repository if they are not expecting it.
966 To delete a branch on the remote repository, use the -d flag.
967 """)
968 @takes_options("cdf")
969 def cmd_push(self, repo=None, rhs=None, **flags):
970 "[-c | -d] <repo>"
971 self._check_git()
972 if '-c' in flags and '-d' in flags:
973 raise TypeError
975 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
976 raise YapError("No such repository: %s" % repo)
978 current = get_output("git symbolic-ref HEAD")
979 if not current:
980 raise YapError("Not on a branch!")
982 self._check_rebasing()
984 current = current[0].replace('refs/heads/', '')
985 remote = get_output("git config branch.%s.remote" % current)
986 if repo is None and remote:
987 repo = remote[0]
989 if repo is None:
990 raise YapError("No tracking branch configured; specify destination repository")
992 if rhs is None and remote and remote[0] == repo:
993 merge = get_output("git config branch.%s.merge" % current)
994 if merge:
995 rhs = merge[0]
997 if rhs is None:
998 rhs = "refs/heads/%s" % current
1000 if '-c' not in flags and '-d' not in flags:
1001 if run_command("git rev-parse --verify refs/remotes/%s/%s"
1002 % (repo, rhs.replace('refs/heads/', ''))):
1003 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
1004 if '-f' not in flags:
1005 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo, rhs.replace('refs/heads/', '')))
1006 base = get_output("git merge-base HEAD %s" % hash[0])
1007 assert base
1008 if base[0] != hash[0]:
1009 raise YapError("Branch not up-to-date with remote. Update or use -f")
1011 self._confirm_push(current, rhs, repo)
1012 if '-f' in flags:
1013 flags['-f'] = '-f'
1015 if '-d' in flags:
1016 lhs = ""
1017 else:
1018 lhs = "refs/heads/%s" % current
1019 rc = os.system("git push %s %s %s:%s" % (flags.get('-f', ''), repo, lhs, rhs))
1020 if rc:
1021 raise YapError("Push failed.")
1023 @short_help("retrieve commits from a remote repository")
1024 @long_help("""
1025 When run with no arguments, the command will retrieve new commits from
1026 the remote tracking repository. Note that this does not in any way
1027 alter the current branch. For that, see "update". If a remote other
1028 than the tracking remote is desired, it can be specified as the first
1029 argument.
1030 """)
1031 def cmd_fetch(self, repo=None):
1032 "<repo>"
1033 self._check_git()
1034 current = get_output("git symbolic-ref HEAD")
1035 if not current:
1036 raise YapError("Not on a branch!")
1038 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
1039 raise YapError("No such repository: %s" % repo)
1040 if repo is None:
1041 current = current[0].replace('refs/heads/', '')
1042 remote = get_output("git config branch.%s.remote" % current)
1043 if remote:
1044 repo = remote[0]
1045 if repo is None:
1046 raise YapError("No tracking branch configured; specify a repository")
1047 os.system("git fetch %s" % repo)
1049 @short_help("update the current branch relative to its tracking branch")
1050 @long_help("""
1051 Updates the current branch relative to its remote tracking branch. This
1052 command requires that the current branch have a remote tracking branch
1053 configured. If any conflicts occur while applying your changes to the
1054 updated remote, the command will pause to allow you to fix them. Once
1055 that is done, run "update" with the "continue" subcommand. Alternately,
1056 the "skip" subcommand can be used to discard the conflicting changes.
1057 """)
1058 def cmd_update(self, subcmd=None):
1059 "[continue | skip]"
1060 self._check_git()
1061 if subcmd and subcmd not in ["continue", "skip"]:
1062 raise TypeError
1064 resolvemsg = """
1065 When you have resolved the conflicts run \"yap update continue\".
1066 To skip the problematic patch, run \"yap update skip\"."""
1068 if subcmd == "continue":
1069 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
1070 return
1071 if subcmd == "skip":
1072 os.system("git reset --hard")
1073 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
1074 return
1076 self._check_rebasing()
1077 if self._get_unstaged_files() or self._get_staged_files():
1078 raise YapError("You have uncommitted changes. Commit them first")
1080 current = get_output("git symbolic-ref HEAD")
1081 if not current:
1082 raise YapError("Not on a branch!")
1084 current = current[0].replace('refs/heads/', '')
1085 remote, merge = self._get_tracking(current)
1086 merge = merge.replace('refs/heads/', '')
1088 self.cmd_fetch(remote)
1089 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
1091 try:
1092 fd, tmpfile = tempfile.mkstemp("yap")
1093 os.close(fd)
1094 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
1095 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
1097 stat = os.stat(tmpfile)
1098 size = stat[6]
1099 if size > 0:
1100 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
1101 if (rc):
1102 raise YapError("Failed to apply changes")
1103 finally:
1104 os.unlink(tmpfile)
1106 @short_help("query and configure remote branch tracking")
1107 @long_help("""
1108 When invoked with no arguments, the command displays the tracking
1109 information for the current branch. To configure the tracking
1110 information, two arguments for the remote repository and remote branch
1111 are given. The tracking information is used to provide defaults for
1112 where to push local changes and from where to get updates to the branch.
1113 """)
1114 def cmd_track(self, repo=None, branch=None):
1115 "[<repo> <branch>]"
1116 self._check_git()
1118 current = get_output("git symbolic-ref HEAD")
1119 if not current:
1120 raise YapError("Not on a branch!")
1121 current = current[0].replace('refs/heads/', '')
1123 if repo is None and branch is None:
1124 repo, merge = self._get_tracking(current)
1125 merge = merge.replace('refs/heads/', '')
1126 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
1127 return
1129 if repo is None or branch is None:
1130 raise TypeError
1132 if repo not in [ x[0] for x in self._list_remotes() ]:
1133 raise YapError("No such repository: %s" % repo)
1135 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
1136 raise YapError("No such branch '%s' on repository '%s'" % (branch, repo))
1138 os.system("git config branch.%s.remote '%s'" % (current, repo))
1139 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
1140 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
1142 @short_help("mark files with conflicts as resolved")
1143 @long_help("""
1144 The arguments are the files to be marked resolved. When a conflict
1145 occurs while merging changes to a file, that file is marked as
1146 "unmerged." Until the file(s) with conflicts are marked resolved,
1147 commits cannot be made.
1148 """)
1149 def cmd_resolved(self, *files):
1150 "<file>..."
1151 self._check_git()
1152 if not files:
1153 raise TypeError
1155 for f in files:
1156 self._stage_one(f, True)
1157 self.cmd_status()
1159 @short_help("merge a branch into the current branch")
1160 def cmd_merge(self, branch):
1161 "<branch>"
1162 self._check_git()
1164 branch_name = branch
1165 branch = get_output("git rev-parse --verify %s" % branch)
1166 if not branch:
1167 raise YapError("No such branch: %s" % branch)
1168 branch = branch[0]
1170 base = get_output("git merge-base HEAD %s" % branch)
1171 if not base:
1172 raise YapError("Branch '%s' is not a fork of the current branch"
1173 % branch)
1175 readtree = ("git read-tree --aggressive -u -m %s HEAD %s"
1176 % (base[0], branch))
1177 if run_command(readtree):
1178 run_command("git update-index --refresh")
1179 if os.system(readtree):
1180 raise YapError("Failed to merge")
1182 repo = get_output('git rev-parse --git-dir')[0]
1183 dir = os.path.join(repo, 'yap')
1184 try:
1185 os.mkdir(dir)
1186 except OSError:
1187 pass
1188 msg_file = os.path.join(dir, 'msg')
1189 msg = file(msg_file, 'w')
1190 print >>msg, "Merge branch '%s'" % branch_name
1191 msg.close()
1193 head = get_output("git rev-parse --verify HEAD")
1194 assert head
1195 heads = [head[0], branch]
1196 head_file = os.path.join(dir, 'merge')
1197 pickle.dump(heads, file(head_file, 'w'))
1199 self._merge_index(branch, base[0])
1200 if self._get_unmerged_files():
1201 self.cmd_status()
1202 raise YapError("Fix conflicts then commit")
1204 self._do_commit()
1206 def _merge_index(self, branch, base):
1207 for f in self._get_unmerged_files():
1208 fd, bfile = tempfile.mkstemp("yap")
1209 os.close(fd)
1210 rc = os.system("git show %s:%s > %s" % (base, f, bfile))
1211 assert rc == 0
1213 fd, ofile = tempfile.mkstemp("yap")
1214 os.close(fd)
1215 rc = os.system("git show %s:%s > %s" % (branch, f, ofile))
1216 assert rc == 0
1218 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)
1219 rc = os.system(command)
1220 os.unlink(ofile)
1221 os.unlink(bfile)
1223 assert rc >= 0
1224 if rc == 0:
1225 self._stage_one(f, True)
1227 @short_help("show information about loaded plugins")
1228 def cmd_plugins(self):
1230 if not self.plugins:
1231 print >>sys.stderr, "No plugins loaded."
1232 for k, v in self.plugins.items():
1233 doc = v.__doc__
1234 if doc is None:
1235 doc = "No description"
1236 print "%-20s%s" % (k, doc)
1237 first = True
1238 for func in dir(v):
1239 if not func.startswith('cmd_'):
1240 continue
1241 if first is True:
1242 print "\tOverrides:"
1243 first = False
1244 print "\t%s" % func
1246 def cmd_help(self, cmd=None):
1247 if cmd is not None:
1248 cmd = "cmd_" + cmd.replace('-', '_')
1249 try:
1250 attr = self.__getattribute__(cmd)
1251 except AttributeError:
1252 raise YapError("No such command: %s" % cmd)
1253 try:
1254 help = attr.long_help
1255 except AttributeError:
1256 attr = super(Yap, self).__getattribute__(cmd)
1257 try:
1258 help = attr.long_help
1259 except AttributeError:
1260 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
1262 print >>sys.stderr, "The '%s' command" % cmd
1263 print >>sys.stderr, "\tyap %s %s" % (cmd, attr.__doc__)
1264 print >>sys.stderr, "%s" % help
1265 return
1267 print >> sys.stderr, "Yet Another (Git) Porcelein"
1268 print >> sys.stderr
1270 for name in dir(self):
1271 if not name.startswith('cmd_'):
1272 continue
1273 attr = self.__getattribute__(name)
1274 if not callable(attr):
1275 continue
1277 try:
1278 short_msg = attr.short_help
1279 except AttributeError:
1280 try:
1281 default_meth = super(Yap, self).__getattribute__(name)
1282 short_msg = default_meth.short_help
1283 except AttributeError:
1284 continue
1286 name = name.replace('cmd_', '')
1287 name = name.replace('_', '-')
1288 print >> sys.stderr, "%-16s%s" % (name, short_msg)
1290 print >> sys.stderr
1291 print >> sys.stderr, "Commands provided by plugins:"
1292 for k, v in self.plugins.items():
1293 for name in dir(v):
1294 if not name.startswith('cmd_'):
1295 continue
1296 try:
1297 attr = self.__getattribute__(name)
1298 short_msg = attr.short_help
1299 except AttributeError:
1300 continue
1301 name = name.replace('cmd_', '')
1302 name = name.replace('_', '-')
1303 print >> sys.stderr, "%-8s(%s) %s" % (name, k, short_msg)
1305 print >> sys.stderr
1306 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
1308 def cmd_usage(self):
1309 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
1310 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"
1312 def main(self, args):
1313 if len(args) < 1:
1314 self.cmd_usage()
1315 sys.exit(2)
1317 command = args[0]
1318 args = args[1:]
1320 if run_command("git --version"):
1321 print >>sys.stderr, "Failed to run git; is it installed?"
1322 sys.exit(1)
1324 debug = os.getenv('YAP_DEBUG')
1326 try:
1327 command = command.replace('-', '_')
1329 meth = self.__getattribute__("cmd_"+command)
1330 try:
1331 default_meth = super(Yap, self).__getattribute__("cmd_"+command)
1332 except AttributeError:
1333 default_meth = None
1335 if meth.__doc__ is not None:
1336 doc = meth.__doc__
1337 elif default_meth is not None:
1338 doc = default_meth.__doc__
1339 else:
1340 doc = ""
1342 try:
1343 options = ""
1344 if "options" in meth.__dict__:
1345 options = meth.options
1346 if default_meth and "options" in default_meth.__dict__:
1347 options += default_meth.options
1348 if options:
1349 flags, args = getopt.getopt(args, options)
1350 flags = dict(flags)
1351 else:
1352 flags = dict()
1354 # cast args to a mutable type. this lets the pre-hooks act as
1355 # filters on the arguments
1356 args = list(args)
1358 # invoke pre-hooks
1359 for p in self.plugins.values():
1360 try:
1361 pre_meth = p.__getattribute__("pre_"+command)
1362 except AttributeError:
1363 continue
1364 pre_meth(args, flags)
1366 meth(*args, **flags)
1368 # invoke post-hooks
1369 for p in self.plugins.values():
1370 try:
1371 meth = p.__getattribute__("post_"+command)
1372 except AttributeError:
1373 continue
1374 meth()
1376 except (TypeError, getopt.GetoptError):
1377 if debug:
1378 raise
1379 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1380 except YapError, e:
1381 if debug:
1382 raise
1383 print >> sys.stderr, e
1384 sys.exit(1)
1385 except AttributeError:
1386 if debug:
1387 raise
1388 self.cmd_usage()
1389 sys.exit(2)