unstage_one: fail more logically when there are no staged changes
[yap.git] / yap / yap.py
blobbc69c657e0c1c4f67706523b900956ced02428d0
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 rc = run_command("git update-index --force-remove '%s'" % file)
184 else:
185 rc = run_command("git diff-index -p HEAD '%s' | git apply -R --cached" % file)
186 if rc:
187 raise YapError("Failed to unstage")
189 def _revert_one(self, file):
190 self._assert_file_exists(file)
191 try:
192 self._unstage_one(file)
193 except YapError:
194 pass
195 run_safely("git checkout-index -u -f '%s'" % file)
197 def _parse_commit(self, commit):
198 lines = get_output("git cat-file commit '%s'" % commit)
199 commit = {}
201 mode = None
202 for l in lines:
203 if mode != 'commit' and l.strip() == "":
204 mode = 'commit'
205 commit['log'] = []
206 continue
207 if mode == 'commit':
208 commit['log'].append(l)
209 continue
211 x = l.split(' ')
212 k = x[0]
213 v = ' '.join(x[1:])
214 commit[k] = v
215 commit['log'] = '\n'.join(commit['log'])
216 return commit
218 def _check_commit(self, **flags):
219 if '-a' in flags and '-d' in flags:
220 raise YapError("Conflicting flags: -a and -d")
222 if '-d' not in flags and self._get_unstaged_files():
223 if '-a' not in flags and self._get_staged_files():
224 raise YapError("Staged and unstaged changes present. Specify what to commit")
225 os.system("git diff-files -p | git apply --cached")
226 for f in self._get_new_files():
227 self._stage_one(f)
229 def _do_uncommit(self):
230 commit = self._parse_commit("HEAD")
231 repo = get_output('git rev-parse --git-dir')[0]
232 dir = os.path.join(repo, 'yap')
233 try:
234 os.mkdir(dir)
235 except OSError:
236 pass
237 msg_file = os.path.join(dir, 'msg')
238 fd = file(msg_file, 'w')
239 print >>fd, commit['log']
240 fd.close()
242 tree = get_output("git rev-parse --verify HEAD^")
243 run_safely("git update-ref -m uncommit HEAD '%s'" % tree[0])
245 def _do_commit(self, msg=None):
246 tree = get_output("git write-tree")[0]
247 parent = get_output("git rev-parse --verify HEAD 2> /dev/null")
249 if os.environ.has_key('YAP_EDITOR'):
250 editor = os.environ['YAP_EDITOR']
251 elif os.environ.has_key('GIT_EDITOR'):
252 editor = os.environ['GIT_EDITOR']
253 elif os.environ.has_key('EDITOR'):
254 editor = os.environ['EDITOR']
255 else:
256 editor = "vi"
258 fd, tmpfile = tempfile.mkstemp("yap")
259 os.close(fd)
262 if msg is None:
263 repo = get_output('git rev-parse --git-dir')[0]
264 msg_file = os.path.join(repo, 'yap', 'msg')
265 if os.access(msg_file, os.R_OK):
266 fd1 = file(msg_file)
267 fd2 = file(tmpfile, 'w')
268 for l in fd1.xreadlines():
269 print >>fd2, l.strip()
270 fd2.close()
271 os.unlink(msg_file)
272 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
273 raise YapError("Editing commit message failed")
274 fd = file(tmpfile)
275 msg = fd.readlines()
276 msg = ''.join(msg)
278 msg = msg.strip()
279 if not msg:
280 raise YapError("Refusing to use empty commit message")
282 (fd_w, fd_r) = os.popen2("git stripspace > %s" % tmpfile)
283 print >>fd_w, msg,
284 fd_w.close()
285 fd_r.close()
287 if parent:
288 commit = get_output("git commit-tree '%s' -p '%s' < '%s'" % (tree, parent[0], tmpfile))
289 else:
290 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
292 os.unlink(tmpfile)
293 run_safely("git update-ref HEAD '%s'" % commit[0])
295 def _check_rebasing(self):
296 repo = get_output('git rev-parse --git-dir')[0]
297 dotest = os.path.join(repo, '.dotest')
298 if os.access(dotest, os.R_OK):
299 raise YapError("A git operation is in progress. Complete it first")
300 dotest = os.path.join(repo, '..', '.dotest')
301 if os.access(dotest, os.R_OK):
302 raise YapError("A git operation is in progress. Complete it first")
304 def _check_git(self):
305 if run_command("git rev-parse --git-dir"):
306 raise YapError("That command must be run from inside a git repository")
308 def _list_remotes(self):
309 remotes = get_output("git config --get-regexp '^remote.*.url'")
310 for x in remotes:
311 remote, url = x.split(' ')
312 remote = remote.replace('remote.', '')
313 remote = remote.replace('.url', '')
314 yield remote, url
316 def _unstage_all(self):
317 try:
318 run_safely("git read-tree -m HEAD")
319 except ShellError:
320 run_safely("git read-tree HEAD")
321 run_safely("git update-index -q --refresh")
323 def _get_tracking(self, current):
324 remote = get_output("git config branch.%s.remote" % current)
325 if not remote:
326 raise YapError("No tracking branch configured for '%s'" % current)
328 merge = get_output("git config branch.%s.merge" % current)
329 if not merge:
330 raise YapError("No tracking branch configured for '%s'" % current)
331 return remote[0], merge[0]
333 def __getattribute__(self, attr):
334 if attr.startswith("cmd_"):
335 meth = None
336 for p in self.plugins.values():
337 try:
338 meth = p.__getattribute__(attr)
339 break
340 except AttributeError:
341 continue
343 if meth:
344 return meth
345 return super(Yap, self).__getattribute__(attr)
347 def _call_base(self, method, *args, **flags):
348 base_method = super(Yap, self).__getattribute__(method)
349 return base_method(*args, **flags)
350 def _confirm_push(self, current, rhs, repo):
351 print "About to push local branch '%s' to '%s' on '%s'" % (current, rhs, repo)
352 print "Continue (y/n)? ",
353 sys.stdout.flush()
354 ans = sys.stdin.readline().strip()
356 if ans.lower() != 'y' and ans.lower() != 'yes':
357 raise YapError("Aborted.")
359 @short_help("make a local copy of an existing repository")
360 @long_help("""
361 The first argument is a URL to the existing repository. This can be an
362 absolute path if the repository is local, or a URL with the git://,
363 ssh://, or http:// schemes. By default, the directory used is the last
364 component of the URL, sans '.git'. This can be overridden by providing
365 a second argument.
366 """)
367 def cmd_clone(self, url, directory=None):
368 "<url> [directory]"
370 if '://' not in url and url[0] != '/':
371 url = os.path.join(os.getcwd(), url)
373 url = url.rstrip('/')
374 if directory is None:
375 directory = url.rsplit('/')[-1]
376 directory = directory.replace('.git', '')
378 try:
379 os.mkdir(directory)
380 except OSError:
381 raise YapError("Directory exists: %s" % directory)
382 os.chdir(directory)
383 self.cmd_init()
384 self.cmd_repo("origin", url)
385 self.cmd_fetch("origin")
387 branch = None
388 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
389 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
390 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
391 if get_output("git rev-parse %s" % b)[0] == hash:
392 branch = b
393 break
394 if branch is None:
395 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
396 branch = "refs/remotes/origin/master"
397 if branch is None:
398 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'")
399 branch = branch[0]
401 hash = get_output("git rev-parse %s" % branch)
402 assert hash
403 branch = branch.replace('refs/remotes/origin/', '')
404 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
405 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
406 self.cmd_revert(**{'-a': 1})
408 @short_help("turn a directory into a repository")
409 @long_help("""
410 Converts the current working directory into a repository. The primary
411 side-effect of this command is the creation of a '.git' subdirectory.
412 No files are added nor commits made.
413 """)
414 def cmd_init(self):
415 os.system("git init")
417 @short_help("add a new file to the repository")
418 @long_help("""
419 The arguments are the files to be added to the repository. Once added,
420 the files will show as "unstaged changes" in the output of 'status'. To
421 reverse the effects of this command, see 'rm'.
422 """)
423 def cmd_add(self, *files):
424 "<file>..."
425 self._check_git()
427 if not files:
428 raise TypeError
430 for f in files:
431 self._add_one(f)
432 self.cmd_status()
434 @short_help("delete a file from the repository")
435 @long_help("""
436 The arguments are the files to be removed from the current revision of
437 the repository. The files will still exist in any past commits that the
438 files may have been a part of. The file is not actually deleted, it is
439 just no longer tracked as part of the repository.
440 """)
441 def cmd_rm(self, *files):
442 "<file>..."
443 self._check_git()
444 if not files:
445 raise TypeError
447 for f in files:
448 self._rm_one(f)
449 self.cmd_status()
451 @short_help("stage changes in a file for commit")
452 @long_help("""
453 The arguments are the files to be staged. Staging changes is a way to
454 build up a commit when you do not want to commit all changes at once.
455 To commit only staged changes, use the '-d' flag to 'commit.' To
456 reverse the effects of this command, see 'unstage'. Once staged, the
457 files will show as "staged changes" in the output of 'status'.
458 """)
459 def cmd_stage(self, *files):
460 "<file>..."
461 self._check_git()
462 if not files:
463 raise TypeError
465 for f in files:
466 self._stage_one(f)
467 self.cmd_status()
469 @short_help("unstage changes in a file")
470 @long_help("""
471 The arguments are the files to be unstaged. Once unstaged, the files
472 will show as "unstaged changes" in the output of 'status'. The '-a'
473 flag can be used to unstage all staged changes at once.
474 """)
475 @takes_options("a")
476 def cmd_unstage(self, *files, **flags):
477 "[-a] | <file>..."
478 self._check_git()
479 if '-a' in flags:
480 self._unstage_all()
481 self.cmd_status()
482 return
484 if not files:
485 raise TypeError
487 for f in files:
488 self._unstage_one(f)
489 self.cmd_status()
491 @short_help("show files with staged and unstaged changes")
492 @long_help("""
493 Show the files in the repository with changes since the last commit,
494 categorized based on whether the changes are staged or not. A file may
495 appear under each heading if the same file has both staged and unstaged
496 changes.
497 """)
498 def cmd_status(self):
500 self._check_git()
501 branch = get_output("git symbolic-ref HEAD")
502 if branch:
503 branch = branch[0].replace('refs/heads/', '')
504 else:
505 branch = "DETACHED"
506 print "Current branch: %s" % branch
508 print "Files with staged changes:"
509 files = self._get_staged_files()
510 for f in files:
511 print "\t%s" % f
512 if not files:
513 print "\t(none)"
515 print "Files with unstaged changes:"
516 files = self._get_unstaged_files()
517 for f in files:
518 print "\t%s" % f
519 if not files:
520 print "\t(none)"
522 files = self._get_unmerged_files()
523 if files:
524 print "Files with conflicts:"
525 for f in files:
526 print "\t%s" % f
528 @short_help("remove uncommitted changes from a file (*)")
529 @long_help("""
530 The arguments are the files whose changes will be reverted. If the '-a'
531 flag is given, then all files will have uncommitted changes removed.
532 Note that there is no way to reverse this command short of manually
533 editing each file again.
534 """)
535 @takes_options("a")
536 def cmd_revert(self, *files, **flags):
537 "(-a | <file>)"
538 self._check_git()
539 if '-a' in flags:
540 self._unstage_all()
541 run_safely("git checkout-index -u -f -a")
542 self.cmd_status()
543 return
545 if not files:
546 raise TypeError
548 for f in files:
549 self._revert_one(f)
550 self.cmd_status()
552 @short_help("record changes to files as a new commit")
553 @long_help("""
554 Create a new commit recording changes since the last commit. If there
555 are only unstaged changes, those will be recorded. If there are only
556 staged changes, those will be recorded. Otherwise, you will have to
557 specify either the '-a' flag or the '-d' flag to commit all changes or
558 only staged changes, respectively. To reverse the effects of this
559 command, see 'uncommit'.
560 """)
561 @takes_options("adm:")
562 def cmd_commit(self, **flags):
563 "[-a | -d] [-m <msg>]"
564 self._check_git()
565 self._check_rebasing()
566 self._check_commit(**flags)
567 if not self._get_staged_files():
568 raise YapError("No changes to commit")
569 msg = flags.get('-m', None)
570 self._do_commit(msg)
571 self.cmd_status()
573 @short_help("reverse the actions of the last commit")
574 @long_help("""
575 Reverse the effects of the last 'commit' operation. The changes that
576 were part of the previous commit will show as "staged changes" in the
577 output of 'status'. This means that if no files were changed since the
578 last commit was created, 'uncommit' followed by 'commit' is a lossless
579 operation.
580 """)
581 def cmd_uncommit(self):
583 self._check_git()
584 self._do_uncommit()
585 self.cmd_status()
587 @short_help("report the current version of yap")
588 def cmd_version(self):
589 print "Yap version 0.1"
591 @short_help("show the changelog for particular versions or files")
592 @long_help("""
593 The arguments are the files with which to filter history. If none are
594 given, all changes are listed. Otherwise only commits that affected one
595 or more of the given files are listed. The -r option changes the
596 starting revision for traversing history. By default, history is listed
597 starting at HEAD.
598 """)
599 @takes_options("pr:")
600 def cmd_log(self, *paths, **flags):
601 "[-p] [-r <rev>] <path>..."
602 self._check_git()
603 rev = flags.get('-r', 'HEAD')
605 if '-p' in flags:
606 flags['-p'] = '-p'
608 if len(paths) == 1:
609 follow = "--follow"
610 else:
611 follow = ""
612 paths = ' '.join(paths)
613 os.system("git log -M -C %s %s '%s' -- %s"
614 % (follow, flags.get('-p', '--name-status'), rev, paths))
616 @short_help("show staged, unstaged, or all uncommitted changes")
617 @long_help("""
618 Show staged, unstaged, or all uncommitted changes. By default, all
619 changes are shown. The '-u' flag causes only unstaged changes to be
620 shown. The '-d' flag causes only staged changes to be shown.
621 """)
622 @takes_options("ud")
623 def cmd_diff(self, **flags):
624 "[ -u | -d ]"
625 self._check_git()
626 if '-u' in flags and '-d' in flags:
627 raise YapError("Conflicting flags: -u and -d")
629 pager = self._get_pager_cmd()
631 if '-u' in flags:
632 os.system("git diff-files -p | %s" % pager)
633 elif '-d' in flags:
634 os.system("git diff-index --cached -p HEAD | %s" % pager)
635 else:
636 os.system("git diff-index -p HEAD | %s" % pager)
638 @short_help("list, create, or delete branches")
639 @long_help("""
640 If no arguments are specified, a list of local branches is given. The
641 current branch is indicated by a "*" next to the name. If an argument
642 is given, it is taken as the name of a new branch to create. The branch
643 will start pointing at the current HEAD. See 'point' for details on
644 changing the revision of the new branch. Note that this command does
645 not switch the current working branch. See 'switch' for details on
646 changing the current working branch.
648 The '-d' flag can be used to delete local branches. If the delete
649 operation would remove the last branch reference to a given line of
650 history (colloquially referred to as "dangling commits"), yap will
651 report an error and abort. The '-f' flag can be used to force the delete
652 in spite of this.
653 """)
654 @takes_options("fd:")
655 def cmd_branch(self, branch=None, **flags):
656 "[ [-f] -d <branch> | <branch> ]"
657 self._check_git()
658 force = '-f' in flags
659 if '-d' in flags:
660 self._delete_branch(flags['-d'], force)
661 self.cmd_branch()
662 return
664 if branch is not None:
665 ref = get_output("git rev-parse --verify HEAD")
666 if not ref:
667 raise YapError("No branch point yet. Make a commit")
668 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
670 current = get_output("git symbolic-ref HEAD")
671 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
672 for b in branches:
673 if current and b == current[0]:
674 print "* ",
675 else:
676 print " ",
677 b = b.replace('refs/heads/', '')
678 print b
680 @short_help("change the current working branch")
681 @long_help("""
682 The argument is the name of the branch to make the current working
683 branch. This command will fail if there are uncommitted changes to any
684 files. Otherwise, the contents of the files in the working directory
685 are updated to reflect their state in the new branch. Additionally, any
686 future commits are added to the new branch instead of the previous line
687 of history.
688 """)
689 @takes_options("f")
690 def cmd_switch(self, branch, **flags):
691 "[-f] <branch>"
692 self._check_git()
693 self._check_rebasing()
694 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
695 if not ref:
696 raise YapError("No such branch: %s" % branch)
698 if '-f' not in flags:
699 if (self._get_staged_files()
700 or (self._get_unstaged_files()
701 and run_command("git update-index --refresh"))):
702 raise YapError("You have uncommitted changes. Use -f to continue anyway")
704 if self._get_unstaged_files() and self._get_staged_files():
705 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
707 staged = bool(self._get_staged_files())
709 run_command("git diff-files -p | git apply --cached")
710 for f in self._get_new_files():
711 self._stage_one(f)
713 idx = get_output("git write-tree")
714 new = get_output("git rev-parse refs/heads/%s" % branch)
715 readtree = "git read-tree --aggressive -u -m HEAD %s %s" % (idx[0], new[0])
716 if run_command(readtree):
717 run_command("git update-index --refresh")
718 if os.system(readtree):
719 raise YapError("Failed to switch")
720 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
722 if not staged:
723 self._unstage_all()
724 self.cmd_status()
726 @short_help("move the current branch to a different revision")
727 @long_help("""
728 The argument is the hash of the commit to which the current branch
729 should point, or alternately a branch or tag (a.k.a, "committish"). If
730 moving the branch would create "dangling commits" (see 'branch'), yap
731 will report an error and abort. The '-f' flag can be used to force the
732 operation in spite of this.
733 """)
734 @takes_options("f")
735 def cmd_point(self, where, **flags):
736 "[-f] <where>"
737 self._check_git()
738 self._check_rebasing()
740 head = get_output("git rev-parse --verify HEAD")
741 if not head:
742 raise YapError("No commit yet; nowhere to point")
744 ref = get_output("git rev-parse --verify '%s^{commit}'" % where)
745 if not ref:
746 raise YapError("Not a valid ref: %s" % where)
748 if self._get_unstaged_files() or self._get_staged_files():
749 raise YapError("You have uncommitted changes. Commit them first")
751 run_safely("git update-ref HEAD '%s'" % ref[0])
753 if '-f' not in flags:
754 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
755 if name == "undefined":
756 os.system("git update-ref HEAD '%s'" % head[0])
757 raise YapError("Pointing there will lose commits. Use -f to force")
759 try:
760 run_safely("git read-tree -u -m HEAD")
761 except ShellError:
762 run_safely("git read-tree HEAD")
763 run_safely("git checkout-index -u -f -a")
765 @short_help("alter history by dropping or amending commits")
766 @long_help("""
767 This command operates in two distinct modes, "amend" and "drop" mode.
768 In drop mode, the given commit is removed from the history of the
769 current branch, as though that commit never happened. By default the
770 commit used is HEAD.
772 In amend mode, the uncommitted changes present are merged into a
773 previous commit. This is useful for correcting typos or adding missed
774 files into past commits. By default the commit used is HEAD.
776 While rewriting history it is possible that conflicts will arise. If
777 this happens, the rewrite will pause and you will be prompted to resolve
778 the conflicts and stage them. Once that is done, you will run "yap
779 history continue." If instead you want the conflicting commit removed
780 from history (perhaps your changes supercede that commit) you can run
781 "yap history skip". Once the rewrite completes, your branch will be on
782 the same commit as when the rewrite started.
783 """)
784 def cmd_history(self, subcmd, *args):
785 "amend | drop <commit>"
786 self._check_git()
788 if subcmd not in ("amend", "drop", "continue", "skip"):
789 raise TypeError
791 resolvemsg = """
792 When you have resolved the conflicts run \"yap history continue\".
793 To skip the problematic patch, run \"yap history skip\"."""
795 if subcmd == "continue":
796 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
797 return
798 if subcmd == "skip":
799 os.system("git reset --hard")
800 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
801 return
803 if subcmd == "amend":
804 flags, args = getopt.getopt(args, "ad")
805 flags = dict(flags)
807 if len(args) > 1:
808 raise TypeError
809 if args:
810 commit = args[0]
811 else:
812 commit = "HEAD"
814 if run_command("git rev-parse --verify '%s'" % commit):
815 raise YapError("Not a valid commit: %s" % commit)
817 self._check_rebasing()
819 if subcmd == "amend":
820 self._check_commit(**flags)
821 if self._get_unstaged_files():
822 # XXX: handle unstaged changes better
823 raise YapError("Commit away changes that you aren't amending")
825 self._unstage_all()
827 start = get_output("git rev-parse HEAD")
828 stash = get_output("git stash create")
829 run_command("git reset --hard")
830 try:
831 fd, tmpfile = tempfile.mkstemp("yap")
832 try:
833 try:
834 os.close(fd)
835 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
836 if subcmd == "amend":
837 self.cmd_point(commit, **{'-f': True})
838 finally:
839 if subcmd == "amend":
840 if stash:
841 rc = os.system("git stash apply %s" % stash[0])
842 if rc:
843 self.cmd_point(start[0], **{'-f': True})
844 os.system("git stash apply %s" % stash[0])
845 raise YapError("Failed to apply stash")
846 stash = None
848 if subcmd == "amend":
849 self._do_uncommit()
850 for f in self._get_unstaged_files():
851 self._stage_one(f)
852 self._do_commit()
853 else:
854 self.cmd_point("%s^" % commit, **{'-f': True})
856 stat = os.stat(tmpfile)
857 size = stat[6]
858 if size > 0:
859 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
860 if (rc):
861 raise YapError("Failed to apply changes")
862 finally:
863 os.unlink(tmpfile)
864 finally:
865 if stash:
866 run_command("git stash apply %s" % stash[0])
867 self.cmd_status()
869 @short_help("show the changes introduced by a given commit")
870 @long_help("""
871 By default, the changes in the last commit are shown. To override this,
872 specify a hash, branch, or tag (committish). The hash of the commit,
873 the commit's author, log message, and a diff of the changes are shown.
874 """)
875 def cmd_show(self, commit="HEAD"):
876 "[commit]"
877 self._check_git()
878 os.system("git show '%s'" % commit)
880 @short_help("apply the changes in a given commit to the current branch")
881 @long_help("""
882 The argument is the hash, branch, or tag (committish) of the commit to
883 be applied. In general, it only makes sense to apply commits that
884 happened on another branch. The '-r' flag can be used to have the
885 changes in the given commit reversed from the current branch. In
886 general, this only makes sense for commits that happened on the current
887 branch.
888 """)
889 @takes_options("r")
890 def cmd_cherry_pick(self, commit, **flags):
891 "[-r] <commit>"
892 self._check_git()
893 if '-r' in flags:
894 os.system("git revert '%s'" % commit)
895 else:
896 os.system("git cherry-pick '%s'" % commit)
898 @short_help("list, add, or delete configured remote repositories")
899 @long_help("""
900 When invoked with no arguments, this command will show the list of
901 currently configured remote repositories, giving both the name and URL
902 of each. To add a new repository, give the desired name as the first
903 argument and the URL as the second. The '-d' flag can be used to remove
904 a previously added repository.
905 """)
906 @takes_options("d:")
907 def cmd_repo(self, name=None, url=None, **flags):
908 "[<name> <url> | -d <name>]"
909 self._check_git()
910 if name is not None and url is None:
911 raise TypeError
913 if '-d' in flags:
914 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
915 raise YapError("No such repository: %s" % flags['-d'])
916 os.system("git config --unset remote.%s.url" % flags['-d'])
917 os.system("git config --unset remote.%s.fetch" % flags['-d'])
919 if name:
920 if name in [ x[0] for x in self._list_remotes() ]:
921 raise YapError("Repository '%s' already exists" % flags['-d'])
922 os.system("git config remote.%s.url %s" % (name, url))
923 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
925 for remote, url in self._list_remotes():
926 print "%-20s %s" % (remote, url)
928 @short_help("send local commits to a remote repository (*)")
929 @long_help("""
930 When invoked with no arguments, the current branch is synchronized to
931 the tracking branch of the tracking remote. If no tracking remote is
932 specified, the repository will have to be specified on the command line.
933 In that case, the default is to push to a branch with the same name as
934 the current branch. This behavior can be overridden by giving a second
935 argument to specify the remote branch.
937 If the remote branch does not currently exist, the command will abort
938 unless the -c flag is provided. If the remote branch is not a direct
939 descendent of the local branch, the command will abort unless the -f
940 flag is provided. Forcing a push in this way can be problematic to
941 other users of the repository if they are not expecting it.
943 To delete a branch on the remote repository, use the -d flag.
944 """)
945 @takes_options("cdf")
946 def cmd_push(self, repo=None, rhs=None, **flags):
947 "[-c | -d] <repo>"
948 self._check_git()
949 if '-c' in flags and '-d' in flags:
950 raise TypeError
952 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
953 raise YapError("No such repository: %s" % repo)
955 current = get_output("git symbolic-ref HEAD")
956 if not current:
957 raise YapError("Not on a branch!")
959 self._check_rebasing()
961 current = current[0].replace('refs/heads/', '')
962 remote = get_output("git config branch.%s.remote" % current)
963 if repo is None and remote:
964 repo = remote[0]
966 if repo is None:
967 raise YapError("No tracking branch configured; specify destination repository")
969 if rhs is None and remote and remote[0] == repo:
970 merge = get_output("git config branch.%s.merge" % current)
971 if merge:
972 rhs = merge[0]
974 if rhs is None:
975 rhs = "refs/heads/%s" % current
977 if '-c' not in flags and '-d' not in flags:
978 if run_command("git rev-parse --verify refs/remotes/%s/%s"
979 % (repo, rhs.replace('refs/heads/', ''))):
980 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
981 if '-f' not in flags:
982 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo, rhs.replace('refs/heads/', '')))
983 base = get_output("git merge-base HEAD %s" % hash[0])
984 assert base
985 if base[0] != hash[0]:
986 raise YapError("Branch not up-to-date with remote. Update or use -f")
988 self._confirm_push(current, rhs, repo)
989 if '-f' in flags:
990 flags['-f'] = '-f'
992 if '-d' in flags:
993 lhs = ""
994 else:
995 lhs = "refs/heads/%s" % current
996 rc = os.system("git push %s %s %s:%s" % (flags.get('-f', ''), repo, lhs, rhs))
997 if rc:
998 raise YapError("Push failed.")
1000 @short_help("retrieve commits from a remote repository")
1001 @long_help("""
1002 When run with no arguments, the command will retrieve new commits from
1003 the remote tracking repository. Note that this does not in any way
1004 alter the current branch. For that, see "update". If a remote other
1005 than the tracking remote is desired, it can be specified as the first
1006 argument.
1007 """)
1008 def cmd_fetch(self, repo=None):
1009 "<repo>"
1010 self._check_git()
1011 current = get_output("git symbolic-ref HEAD")
1012 if not current:
1013 raise YapError("Not on a branch!")
1015 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
1016 raise YapError("No such repository: %s" % repo)
1017 if repo is None:
1018 current = current[0].replace('refs/heads/', '')
1019 remote = get_output("git config branch.%s.remote" % current)
1020 if remote:
1021 repo = remote[0]
1022 if repo is None:
1023 raise YapError("No tracking branch configured; specify a repository")
1024 os.system("git fetch %s" % repo)
1026 @short_help("update the current branch relative to its tracking branch")
1027 @long_help("""
1028 Updates the current branch relative to its remote tracking branch. This
1029 command requires that the current branch have a remote tracking branch
1030 configured. If any conflicts occur while applying your changes to the
1031 updated remote, the command will pause to allow you to fix them. Once
1032 that is done, run "update" with the "continue" subcommand. Alternately,
1033 the "skip" subcommand can be used to discard the conflicting changes.
1034 """)
1035 def cmd_update(self, subcmd=None):
1036 "[continue | skip]"
1037 self._check_git()
1038 if subcmd and subcmd not in ["continue", "skip"]:
1039 raise TypeError
1041 resolvemsg = """
1042 When you have resolved the conflicts run \"yap update continue\".
1043 To skip the problematic patch, run \"yap update skip\"."""
1045 if subcmd == "continue":
1046 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
1047 return
1048 if subcmd == "skip":
1049 os.system("git reset --hard")
1050 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
1051 return
1053 self._check_rebasing()
1054 if self._get_unstaged_files() or self._get_staged_files():
1055 raise YapError("You have uncommitted changes. Commit them first")
1057 current = get_output("git symbolic-ref HEAD")
1058 if not current:
1059 raise YapError("Not on a branch!")
1061 current = current[0].replace('refs/heads/', '')
1062 remote, merge = self._get_tracking(current)
1063 merge = merge.replace('refs/heads/', '')
1065 self.cmd_fetch(remote)
1066 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
1068 try:
1069 fd, tmpfile = tempfile.mkstemp("yap")
1070 os.close(fd)
1071 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
1072 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
1074 stat = os.stat(tmpfile)
1075 size = stat[6]
1076 if size > 0:
1077 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
1078 if (rc):
1079 raise YapError("Failed to apply changes")
1080 finally:
1081 os.unlink(tmpfile)
1083 @short_help("query and configure remote branch tracking")
1084 @long_help("""
1085 When invoked with no arguments, the command displays the tracking
1086 information for the current branch. To configure the tracking
1087 information, two arguments for the remote repository and remote branch
1088 are given. The tracking information is used to provide defaults for
1089 where to push local changes and from where to get updates to the branch.
1090 """)
1091 def cmd_track(self, repo=None, branch=None):
1092 "[<repo> <branch>]"
1093 self._check_git()
1095 current = get_output("git symbolic-ref HEAD")
1096 if not current:
1097 raise YapError("Not on a branch!")
1098 current = current[0].replace('refs/heads/', '')
1100 if repo is None and branch is None:
1101 repo, merge = self._get_tracking(current)
1102 merge = merge.replace('refs/heads/', '')
1103 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
1104 return
1106 if repo is None or branch is None:
1107 raise TypeError
1109 if repo not in [ x[0] for x in self._list_remotes() ]:
1110 raise YapError("No such repository: %s" % repo)
1112 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
1113 raise YapError("No such branch '%s' on repository '%s'" % (branch, repo))
1115 os.system("git config branch.%s.remote '%s'" % (current, repo))
1116 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
1117 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
1119 @short_help("mark files with conflicts as resolved")
1120 @long_help("""
1121 The arguments are the files to be marked resolved. When a conflict
1122 occurs while merging changes to a file, that file is marked as
1123 "unmerged." Until the file(s) with conflicts are marked resolved,
1124 commits cannot be made.
1125 """)
1126 def cmd_resolved(self, *args):
1127 "<file>..."
1128 self._check_git()
1129 if not files:
1130 raise TypeError
1132 for f in files:
1133 self._stage_one(f, True)
1134 self.cmd_status()
1136 @short_help("show information about loaded plugins")
1137 def cmd_plugins(self):
1139 if not self.plugins:
1140 print >>sys.stderr, "No plugins loaded."
1141 for k, v in self.plugins.items():
1142 doc = v.__doc__
1143 if doc is None:
1144 doc = "No description"
1145 print "%-20s%s" % (k, doc)
1146 first = True
1147 for func in dir(v):
1148 if not func.startswith('cmd_'):
1149 continue
1150 if first is True:
1151 print "\tOverrides:"
1152 first = False
1153 print "\t%s" % func
1155 def cmd_help(self, cmd=None):
1156 if cmd is not None:
1157 cmd = "cmd_" + cmd.replace('-', '_')
1158 try:
1159 attr = self.__getattribute__(cmd)
1160 except AttributeError:
1161 raise YapError("No such command: %s" % cmd)
1162 try:
1163 help = attr.long_help
1164 except AttributeError:
1165 attr = super(Yap, self).__getattribute__(cmd)
1166 try:
1167 help = attr.long_help
1168 except AttributeError:
1169 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
1171 print >>sys.stderr, "The '%s' command" % cmd
1172 print >>sys.stderr, "\tyap %s %s" % (cmd, attr.__doc__)
1173 print >>sys.stderr, "%s" % help
1174 return
1176 print >> sys.stderr, "Yet Another (Git) Porcelein"
1177 print >> sys.stderr
1179 for name in dir(self):
1180 if not name.startswith('cmd_'):
1181 continue
1182 attr = self.__getattribute__(name)
1183 if not callable(attr):
1184 continue
1186 try:
1187 short_msg = attr.short_help
1188 except AttributeError:
1189 try:
1190 default_meth = super(Yap, self).__getattribute__(name)
1191 short_msg = default_meth.short_help
1192 except AttributeError:
1193 continue
1195 name = name.replace('cmd_', '')
1196 name = name.replace('_', '-')
1197 print >> sys.stderr, "%-16s%s" % (name, short_msg)
1198 print >> sys.stderr
1199 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
1201 def cmd_usage(self):
1202 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
1203 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"
1205 def main(self, args):
1206 if len(args) < 1:
1207 self.cmd_usage()
1208 sys.exit(2)
1210 command = args[0]
1211 args = args[1:]
1213 if run_command("git --version"):
1214 print >>sys.stderr, "Failed to run git; is it installed?"
1215 sys.exit(1)
1217 debug = os.getenv('YAP_DEBUG')
1219 try:
1220 command = command.replace('-', '_')
1222 meth = self.__getattribute__("cmd_"+command)
1223 try:
1224 default_meth = super(Yap, self).__getattribute__("cmd_"+command)
1225 except AttributeError:
1226 default_meth = None
1228 if meth.__doc__ is not None:
1229 doc = meth.__doc__
1230 elif default_meth is not None:
1231 doc = default_meth.__doc__
1232 else:
1233 doc = ""
1235 try:
1236 options = ""
1237 if "options" in meth.__dict__:
1238 options = meth.options
1239 if default_meth and "options" in default_meth.__dict__:
1240 options += default_meth.options
1241 if options:
1242 flags, args = getopt.getopt(args, options)
1243 flags = dict(flags)
1244 else:
1245 flags = dict()
1247 # invoke pre-hooks
1248 for p in self.plugins.values():
1249 try:
1250 pre_meth = p.__getattribute__("pre_"+command)
1251 except AttributeError:
1252 continue
1253 pre_meth(*args, **flags)
1255 meth(*args, **flags)
1257 # invoke post-hooks
1258 for p in self.plugins.values():
1259 try:
1260 meth = p.__getattribute__("post_"+command)
1261 except AttributeError:
1262 continue
1263 meth()
1265 except (TypeError, getopt.GetoptError):
1266 if debug:
1267 raise
1268 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1269 except YapError, e:
1270 if debug:
1271 raise
1272 print >> sys.stderr, e
1273 sys.exit(1)
1274 except AttributeError:
1275 if debug:
1276 raise
1277 self.cmd_usage()
1278 sys.exit(2)