cmd_point: show status output from read-tree
[yap.git] / yap / yap.py
blob1c131914f3fff1de2c3b0d97bef6994f3ef8686a
1 import sys
2 import os
3 import glob
4 import getopt
5 import pickle
6 import tempfile
8 from util import *
10 class ShellError(Exception):
11 def __init__(self, cmd, rc):
12 self.cmd = cmd
13 self.rc = rc
15 def __str__(self):
16 return "%s returned %d" % (self.cmd, self.rc)
18 class YapError(Exception):
19 def __init__(self, msg):
20 self.msg = msg
22 def __str__(self):
23 return self.msg
25 class YapCore(object):
26 def _add_new_file(self, file):
27 repo = get_output('git rev-parse --git-dir')[0]
28 dir = os.path.join(repo, 'yap')
29 try:
30 os.mkdir(dir)
31 except OSError:
32 pass
33 files = self._get_new_files()
34 files.append(file)
35 path = os.path.join(dir, 'new-files')
36 pickle.dump(files, open(path, 'w'))
38 def _get_new_files(self):
39 repo = get_output('git rev-parse --git-dir')[0]
40 path = os.path.join(repo, 'yap', 'new-files')
41 try:
42 files = pickle.load(file(path))
43 except IOError:
44 files = []
46 x = []
47 for f in files:
48 # if f in the index
49 if get_output("git ls-files --cached '%s'" % f) != []:
50 continue
51 x.append(f)
52 return x
54 def _remove_new_file(self, file):
55 files = self._get_new_files()
56 files = filter(lambda x: x != file, files)
58 repo = get_output('git rev-parse --git-dir')[0]
59 path = os.path.join(repo, 'yap', 'new-files')
60 try:
61 pickle.dump(files, open(path, 'w'))
62 except IOError:
63 pass
65 def _assert_file_exists(self, file):
66 if not os.access(file, os.R_OK):
67 raise YapError("No such file: %s" % file)
69 def _repo_path_to_rel(self, path):
70 prefix = get_output("git rev-parse --show-prefix")
71 if not prefix:
72 return path
73 prefix = os.path.split(prefix[0])
75 # strip empty components from prefix
76 tmp = []
77 for x in prefix:
78 if not x:
79 continue
80 tmp.append(x)
81 prefix = tmp
83 path = os.path.split(path)
84 common = 0
85 for a, b in zip(prefix, path):
86 if a != b:
87 break
88 common += 1
90 path = path[common:]
91 cdup = [".."] * (len(prefix) - common)
92 path = cdup + list(path)
93 path = os.path.join(*path)
94 return path
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 cwd = os.getcwd()
110 cdup = get_output("git rev-parse --show-cdup")
111 assert cdup
112 if cdup[0]:
113 os.chdir(cdup[0])
114 files = get_output("git ls-files -m")
115 os.chdir(cwd)
117 new_files = self._get_new_files()
118 if new_files:
119 staged = self._get_staged_files()
120 if staged:
121 staged = set(staged)
122 new_files = set(new_files).difference(staged)
123 new_files = list(new_files)
124 files += new_files
125 unmerged = self._get_unmerged_files()
126 if unmerged:
127 unmerged = set(unmerged)
128 files = set(files).difference(unmerged)
129 files = list(files)
130 return files
132 def _get_unmerged_files(self):
133 cwd = os.getcwd()
134 cdup = get_output("git rev-parse --show-cdup")
135 assert cdup
136 if cdup[0]:
137 os.chdir(cdup[0])
138 files = get_output("git ls-files -u")
139 os.chdir(cwd)
140 files = [ x.replace('\t', ' ').split(' ')[3] for x in files ]
141 return list(set(files))
143 def _resolve_rev(self, rev):
144 ref = get_output("git rev-parse --verify %s 2>/dev/null" % rev)
145 if not ref:
146 raise YapError("No such revision: %s" % rev)
147 return ref[0]
149 def _delete_branch(self, branch, force):
150 current = get_output("git symbolic-ref HEAD")
151 if current:
152 current = current[0].replace('refs/heads/', '')
153 if branch == current:
154 raise YapError("Can't delete current branch")
156 ref = self._resolve_rev('refs/heads/'+branch)
157 run_safely("git update-ref -d 'refs/heads/%s' '%s'" % (branch, ref))
159 if not force:
160 name = get_output("git name-rev --name-only '%s'" % ref)[0]
161 if name == 'undefined':
162 run_command("git update-ref 'refs/heads/%s' '%s'" % (branch, ref))
163 raise YapError("Refusing to delete leaf branch (use -f to force)")
164 def _get_pager_cmd(self):
165 if 'YAP_PAGER' in os.environ:
166 return os.environ['YAP_PAGER']
167 elif 'GIT_PAGER' in os.environ:
168 return os.environ['GIT_PAGER']
169 elif 'PAGER' in os.environ:
170 return os.environ['PAGER']
171 else:
172 return "less"
174 def _add_one(self, file):
175 self._assert_file_exists(file)
176 x = get_output("git ls-files '%s'" % file)
177 if x != []:
178 raise YapError("File '%s' already in repository" % file)
179 self._add_new_file(file)
181 def _rm_one(self, file):
182 self._assert_file_exists(file)
183 if get_output("git ls-files '%s'" % file) != []:
184 run_safely("git rm --cached '%s'" % file)
185 self._remove_new_file(file)
187 def _stage_one(self, file, allow_unmerged=False):
188 self._assert_file_exists(file)
189 prefix = get_output("git rev-parse --show-prefix")
190 if prefix:
191 tmp = os.path.normpath(os.path.join(prefix[0], file))
192 else:
193 tmp = file
194 if not allow_unmerged and tmp in self._get_unmerged_files():
195 raise YapError("Refusing to stage conflicted file: %s" % file)
196 run_safely("git update-index --add '%s'" % file)
198 def _unstage_one(self, file):
199 self._assert_file_exists(file)
200 if run_command("git rev-parse HEAD"):
201 rc = run_command("git update-index --force-remove '%s'" % file)
202 else:
203 cdup = get_output("git rev-parse --show-cdup")
204 assert cdup
205 if cdup[0]:
206 cdup = cdup[0]
207 else:
208 cdup = '.'
210 rc = run_command("git diff-index --cached -p HEAD '%s' | (cd %s; git apply -R --cached)" % (file, cdup))
211 if rc:
212 raise YapError("Failed to unstage")
214 def _revert_one(self, file):
215 self._assert_file_exists(file)
216 try:
217 self._unstage_one(file)
218 except YapError:
219 pass
220 run_safely("git checkout-index -u -f '%s'" % file)
222 def _parse_commit(self, commit):
223 lines = get_output("git cat-file commit '%s'" % commit)
224 commit = {}
226 mode = None
227 for l in lines:
228 if mode != 'commit' and l.strip() == "":
229 mode = 'commit'
230 commit['log'] = []
231 continue
232 if mode == 'commit':
233 commit['log'].append(l)
234 continue
236 x = l.split(' ')
237 k = x[0]
238 v = ' '.join(x[1:])
239 commit[k] = v
240 commit['log'] = '\n'.join(commit['log'])
241 return commit
243 def _check_commit(self, **flags):
244 if '-a' in flags and '-d' in flags:
245 raise YapError("Conflicting flags: -a and -d")
247 if '-d' not in flags and self._get_unstaged_files():
248 if '-a' not in flags and self._get_staged_files():
249 raise YapError("Staged and unstaged changes present. Specify what to commit")
250 os.system("git diff-files -p | git apply --cached")
251 for f in self._get_new_files():
252 self._stage_one(f)
254 def _do_uncommit(self):
255 commit = self._parse_commit("HEAD")
256 repo = get_output('git rev-parse --git-dir')[0]
257 dir = os.path.join(repo, 'yap')
258 try:
259 os.mkdir(dir)
260 except OSError:
261 pass
262 msg_file = os.path.join(dir, 'msg')
263 fd = file(msg_file, 'w')
264 print >>fd, commit['log']
265 fd.close()
267 tree = get_output("git rev-parse --verify HEAD^")
268 run_safely("git update-ref -m uncommit HEAD '%s'" % tree[0])
270 def _do_commit(self, msg=None):
271 tree = get_output("git write-tree")[0]
273 repo = get_output('git rev-parse --git-dir')[0]
274 head_file = os.path.join(repo, 'yap', 'merge')
275 try:
276 parent = pickle.load(file(head_file))
277 except IOError:
278 parent = get_output("git rev-parse --verify HEAD 2> /dev/null")
280 if os.environ.has_key('YAP_EDITOR'):
281 editor = os.environ['YAP_EDITOR']
282 elif os.environ.has_key('GIT_EDITOR'):
283 editor = os.environ['GIT_EDITOR']
284 elif os.environ.has_key('EDITOR'):
285 editor = os.environ['EDITOR']
286 else:
287 editor = "vi"
289 fd, tmpfile = tempfile.mkstemp("yap")
290 os.close(fd)
292 if msg is None:
293 msg_file = os.path.join(repo, 'yap', 'msg')
294 if os.access(msg_file, os.R_OK):
295 fd1 = file(msg_file)
296 fd2 = file(tmpfile, 'w')
297 for l in fd1.xreadlines():
298 print >>fd2, l.strip()
299 fd2.close()
300 os.unlink(msg_file)
301 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
302 raise YapError("Editing commit message failed")
303 fd = file(tmpfile)
304 msg = fd.readlines()
305 msg = ''.join(msg)
307 msg = msg.strip()
308 if not msg:
309 raise YapError("Refusing to use empty commit message")
311 fd = os.popen("git stripspace > %s" % tmpfile, 'w')
312 print >>fd, msg,
313 fd.close()
315 if parent:
316 parent = ' -p '.join(parent)
317 commit = get_output("git commit-tree '%s' -p %s < '%s'" % (tree, parent, tmpfile))
318 else:
319 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
321 os.unlink(tmpfile)
322 run_safely("git update-ref HEAD '%s'" % commit[0])
323 self._clear_state()
325 def _check_rebasing(self):
326 repo = get_output('git rev-parse --git-dir')[0]
327 dotest = os.path.join(repo, '.dotest')
328 if os.access(dotest, os.R_OK):
329 raise YapError("A git operation is in progress. Complete it first")
330 dotest = os.path.join(repo, '..', '.dotest')
331 if os.access(dotest, os.R_OK):
332 raise YapError("A git operation is in progress. Complete it first")
334 def _check_git(self):
335 if run_command("git rev-parse --git-dir"):
336 raise YapError("That command must be run from inside a git repository")
338 def _list_remotes(self):
339 remotes = get_output("git config --get-regexp '^remote.*.url'")
340 for x in remotes:
341 remote, url = x.split(' ')
342 remote = remote.replace('remote.', '')
343 remote = remote.replace('.url', '')
344 yield remote, url
346 def _unstage_all(self):
347 try:
348 run_safely("git read-tree -m HEAD")
349 except ShellError:
350 run_safely("git read-tree HEAD")
351 run_safely("git update-index -q --refresh")
353 def _get_tracking(self, current):
354 remote = get_output("git config branch.%s.remote" % current)
355 if not remote:
356 raise YapError("No tracking branch configured for '%s'" % current)
358 merge = get_output("git config branch.%s.merge" % current)
359 if not merge:
360 raise YapError("No tracking branch configured for '%s'" % current)
361 return remote[0], merge[0]
363 def _confirm_push(self, current, rhs, repo):
364 print "About to push local branch '%s' to '%s' on '%s'" % (current, rhs, repo)
365 print "Continue (y/n)? ",
366 sys.stdout.flush()
367 ans = sys.stdin.readline().strip()
369 if ans.lower() != 'y' and ans.lower() != 'yes':
370 raise YapError("Aborted.")
372 def _clear_state(self):
373 repo = get_output('git rev-parse --git-dir')[0]
374 dir = os.path.join(repo, 'yap')
375 for f in "new-files", "merge", "msg":
376 try:
377 os.unlink(os.path.join(dir, f))
378 except OSError:
379 pass
381 def _get_attr(self, name, attr):
382 val = None
383 for c in self.__class__.__bases__:
384 try:
385 m2 = c.__dict__[name]
386 except KeyError:
387 continue
388 try:
389 val = m2.__getattribute__(attr)
390 except AttributeError:
391 continue
392 return val
394 @short_help("make a local copy of an existing repository")
395 @long_help("""
396 The first argument is a URL to the existing repository. This can be an
397 absolute path if the repository is local, or a URL with the git://,
398 ssh://, or http:// schemes. By default, the directory used is the last
399 component of the URL, sans '.git'. This can be overridden by providing
400 a second argument.
401 """)
402 def cmd_clone(self, url, directory=None):
403 "<url> [directory]"
405 if '://' not in url and url[0] != '/':
406 url = os.path.join(os.getcwd(), url)
408 url = url.rstrip('/')
409 if directory is None:
410 directory = url.rsplit('/')[-1]
411 directory = directory.replace('.git', '')
413 try:
414 os.mkdir(directory)
415 except OSError:
416 raise YapError("Directory exists: %s" % directory)
417 os.chdir(directory)
418 self.cmd_init()
419 self.cmd_repo("origin", url)
420 self.cmd_fetch("origin")
422 branch = None
423 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
424 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
425 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
426 if get_output("git rev-parse %s" % b)[0] == hash:
427 branch = b
428 break
429 if branch is None:
430 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
431 branch = "refs/remotes/origin/master"
432 if branch is None:
433 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'")
434 branch = branch[0]
436 hash = get_output("git rev-parse %s" % branch)
437 assert hash
438 branch = branch.replace('refs/remotes/origin/', '')
439 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
440 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
441 self.cmd_revert(**{'-a': 1})
443 @short_help("turn a directory into a repository")
444 @long_help("""
445 Converts the current working directory into a repository. The primary
446 side-effect of this command is the creation of a '.git' subdirectory.
447 No files are added nor commits made.
448 """)
449 def cmd_init(self):
450 os.system("git init")
452 @short_help("add a new file to the repository")
453 @long_help("""
454 The arguments are the files to be added to the repository. Once added,
455 the files will show as "unstaged changes" in the output of 'status'. To
456 reverse the effects of this command, see 'rm'.
457 """)
458 def cmd_add(self, *files):
459 "<file>..."
460 self._check_git()
462 if not files:
463 raise TypeError
465 for f in files:
466 self._add_one(f)
467 self.cmd_status()
469 @short_help("delete a file from the repository")
470 @long_help("""
471 The arguments are the files to be removed from the current revision of
472 the repository. The files will still exist in any past commits that the
473 files may have been a part of. The file is not actually deleted, it is
474 just no longer tracked as part of the repository.
475 """)
476 def cmd_rm(self, *files):
477 "<file>..."
478 self._check_git()
479 if not files:
480 raise TypeError
482 for f in files:
483 self._rm_one(f)
484 self.cmd_status()
486 @short_help("stage changes in a file for commit")
487 @long_help("""
488 The arguments are the files to be staged. Staging changes is a way to
489 build up a commit when you do not want to commit all changes at once.
490 To commit only staged changes, use the '-d' flag to 'commit.' To
491 reverse the effects of this command, see 'unstage'. Once staged, the
492 files will show as "staged changes" in the output of 'status'.
493 """)
494 def cmd_stage(self, *files):
495 "<file>..."
496 self._check_git()
497 if not files:
498 raise TypeError
500 for f in files:
501 self._stage_one(f)
502 self.cmd_status()
504 @short_help("unstage changes in a file")
505 @long_help("""
506 The arguments are the files to be unstaged. Once unstaged, the files
507 will show as "unstaged changes" in the output of 'status'. The '-a'
508 flag can be used to unstage all staged changes at once.
509 """)
510 @takes_options("a")
511 def cmd_unstage(self, *files, **flags):
512 "[-a] | <file>..."
513 self._check_git()
514 if '-a' in flags:
515 files = self._get_staged_files()
517 if not files:
518 raise TypeError
520 for f in files:
521 self._unstage_one(f)
522 self.cmd_status()
524 @short_help("show files with staged and unstaged changes")
525 @long_help("""
526 Show the files in the repository with changes since the last commit,
527 categorized based on whether the changes are staged or not. A file may
528 appear under each heading if the same file has both staged and unstaged
529 changes.
530 """)
531 def cmd_status(self):
533 self._check_git()
534 branch = get_output("git symbolic-ref HEAD")
535 if branch:
536 branch = branch[0].replace('refs/heads/', '')
537 else:
538 branch = "DETACHED"
539 print "Current branch: %s" % branch
541 print "Files with staged changes:"
542 files = self._get_staged_files()
543 for f in files:
544 print "\t%s" % self._repo_path_to_rel(f)
545 if not files:
546 print "\t(none)"
548 print "Files with unstaged changes:"
549 files = self._get_unstaged_files()
550 for f in files:
551 print "\t%s" % self._repo_path_to_rel(f)
552 if not files:
553 print "\t(none)"
555 files = self._get_unmerged_files()
556 if files:
557 print "Files with conflicts:"
558 for f in files:
559 print "\t%s" % self._repo_path_to_rel(f)
561 @short_help("remove uncommitted changes from a file (*)")
562 @long_help("""
563 The arguments are the files whose changes will be reverted. If the '-a'
564 flag is given, then all files will have uncommitted changes removed.
565 Note that there is no way to reverse this command short of manually
566 editing each file again.
567 """)
568 @takes_options("a")
569 def cmd_revert(self, *files, **flags):
570 "(-a | <file>)"
571 self._check_git()
572 if '-a' in flags:
573 self._unstage_all()
574 run_safely("git checkout-index -u -f -a")
575 self._clear_state()
576 self.cmd_status()
577 return
579 if not files:
580 raise TypeError
582 for f in files:
583 self._revert_one(f)
584 self.cmd_status()
586 @short_help("record changes to files as a new commit")
587 @long_help("""
588 Create a new commit recording changes since the last commit. If there
589 are only unstaged changes, those will be recorded. If there are only
590 staged changes, those will be recorded. Otherwise, you will have to
591 specify either the '-a' flag or the '-d' flag to commit all changes or
592 only staged changes, respectively. To reverse the effects of this
593 command, see 'uncommit'.
594 """)
595 @takes_options("adm:")
596 def cmd_commit(self, **flags):
597 "[-a | -d] [-m <msg>]"
598 self._check_git()
599 self._check_rebasing()
600 self._check_commit(**flags)
601 if not self._get_staged_files():
602 raise YapError("No changes to commit")
603 msg = flags.get('-m', None)
604 self._do_commit(msg)
605 self.cmd_status()
607 @short_help("reverse the actions of the last commit")
608 @long_help("""
609 Reverse the effects of the last 'commit' operation. The changes that
610 were part of the previous commit will show as "staged changes" in the
611 output of 'status'. This means that if no files were changed since the
612 last commit was created, 'uncommit' followed by 'commit' is a lossless
613 operation.
614 """)
615 def cmd_uncommit(self):
617 self._check_git()
618 self._do_uncommit()
619 self.cmd_status()
621 @short_help("report the current version of yap")
622 def cmd_version(self):
623 print "Yap version %s" % self.version
625 @short_help("show the changelog for particular versions or files")
626 @long_help("""
627 The arguments are the files with which to filter history. If none are
628 given, all changes are listed. Otherwise only commits that affected one
629 or more of the given files are listed. The -r option changes the
630 starting revision for traversing history. By default, history is listed
631 starting at HEAD.
632 """)
633 @takes_options("pr:")
634 def cmd_log(self, *paths, **flags):
635 "[-p] [-r <rev>] <path>..."
636 self._check_git()
637 rev = flags.get('-r', 'HEAD')
638 rev = self._resolve_rev(rev)
640 if '-p' in flags:
641 flags['-p'] = '-p'
643 if len(paths) == 1:
644 follow = "--follow"
645 else:
646 follow = ""
647 paths = ' '.join(paths)
648 os.system("git log -M -C %s %s '%s' -- %s"
649 % (follow, flags.get('-p', '--name-status'), rev, paths))
651 @short_help("show staged, unstaged, or all uncommitted changes")
652 @long_help("""
653 Show staged, unstaged, or all uncommitted changes. By default, all
654 changes are shown. The '-u' flag causes only unstaged changes to be
655 shown. The '-d' flag causes only staged changes to be shown.
656 """)
657 @takes_options("ud")
658 def cmd_diff(self, **flags):
659 "[ -u | -d ]"
660 self._check_git()
661 if '-u' in flags and '-d' in flags:
662 raise YapError("Conflicting flags: -u and -d")
664 pager = self._get_pager_cmd()
666 if '-u' in flags:
667 os.system("git diff-files -p | %s" % pager)
668 elif '-d' in flags:
669 os.system("git diff-index --cached -p HEAD | %s" % pager)
670 else:
671 os.system("git diff-index -p HEAD | %s" % pager)
673 @short_help("list, create, or delete branches")
674 @long_help("""
675 If no arguments are specified, a list of local branches is given. The
676 current branch is indicated by a "*" next to the name. If an argument
677 is given, it is taken as the name of a new branch to create. The branch
678 will start pointing at the current HEAD. See 'point' for details on
679 changing the revision of the new branch. Note that this command does
680 not switch the current working branch. See 'switch' for details on
681 changing the current working branch.
683 The '-d' flag can be used to delete local branches. If the delete
684 operation would remove the last branch reference to a given line of
685 history (colloquially referred to as "dangling commits"), yap will
686 report an error and abort. The '-f' flag can be used to force the delete
687 in spite of this.
688 """)
689 @takes_options("fd:")
690 def cmd_branch(self, branch=None, **flags):
691 "[ [-f] -d <branch> | <branch> ]"
692 self._check_git()
693 force = '-f' in flags
694 if '-d' in flags:
695 self._delete_branch(flags['-d'], force)
696 self.cmd_branch()
697 return
699 if branch is not None:
700 ref = get_output("git rev-parse --verify HEAD")
701 if not ref:
702 raise YapError("No branch point yet. Make a commit")
703 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
705 current = get_output("git symbolic-ref HEAD")
706 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
707 for b in branches:
708 if current and b == current[0]:
709 print "* ",
710 else:
711 print " ",
712 b = b.replace('refs/heads/', '')
713 print b
715 @short_help("change the current working branch")
716 @long_help("""
717 The argument is the name of the branch to make the current working
718 branch. This command will fail if there are uncommitted changes to any
719 files. Otherwise, the contents of the files in the working directory
720 are updated to reflect their state in the new branch. Additionally, any
721 future commits are added to the new branch instead of the previous line
722 of history.
723 """)
724 @takes_options("f")
725 def cmd_switch(self, branch, **flags):
726 "[-f] <branch>"
727 self._check_git()
728 self._check_rebasing()
729 ref = self._resolve_rev('refs/heads/'+branch)
731 if '-f' not in flags:
732 if (self._get_staged_files()
733 or (self._get_unstaged_files()
734 and run_command("git update-index --refresh"))):
735 raise YapError("You have uncommitted changes. Use -f to continue anyway")
737 if self._get_unstaged_files() and self._get_staged_files():
738 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
740 staged = bool(self._get_staged_files())
742 run_command("git diff-files -p | git apply --cached")
743 for f in self._get_new_files():
744 self._stage_one(f)
746 idx = get_output("git write-tree")
747 new = self._resolve_rev('refs/heads/'+branch)
748 readtree = "git read-tree --aggressive -u -m HEAD %s %s" % (idx[0], new)
749 if run_command(readtree):
750 run_command("git update-index --refresh")
751 if os.system(readtree):
752 raise YapError("Failed to switch")
753 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
755 if '-f' not in flags:
756 self._clear_state()
758 if not staged:
759 self._unstage_all()
760 self.cmd_status()
762 @short_help("move the current branch to a different revision")
763 @long_help("""
764 The argument is the hash of the commit to which the current branch
765 should point, or alternately a branch or tag (a.k.a, "committish"). If
766 moving the branch would create "dangling commits" (see 'branch'), yap
767 will report an error and abort. The '-f' flag can be used to force the
768 operation in spite of this.
769 """)
770 @takes_options("f")
771 def cmd_point(self, where, **flags):
772 "[-f] <where>"
773 self._check_git()
774 self._check_rebasing()
776 head = get_output("git rev-parse --verify HEAD")
777 if not head:
778 raise YapError("No commit yet; nowhere to point")
780 ref = self._resolve_rev(where)
781 ref = get_output("git rev-parse --verify '%s^{commit}'" % ref)
782 if not ref:
783 raise YapError("Not a commit: %s" % where)
785 if self._get_unstaged_files() or self._get_staged_files():
786 raise YapError("You have uncommitted changes. Commit them first")
788 run_safely("git update-ref HEAD '%s'" % ref[0])
790 if '-f' not in flags:
791 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
792 if name == "undefined":
793 os.system("git update-ref HEAD '%s'" % head[0])
794 raise YapError("Pointing there will lose commits. Use -f to force")
796 run_command("git update-index --refresh")
797 rc = os.system("git read-tree -v --reset -u HEAD")
798 if rc:
799 raise YapError("checkout-index failed")
800 self._clear_state()
802 @short_help("alter history by dropping or amending commits")
803 @long_help("""
804 This command operates in two distinct modes, "amend" and "drop" mode.
805 In drop mode, the given commit is removed from the history of the
806 current branch, as though that commit never happened. By default the
807 commit used is HEAD.
809 In amend mode, the uncommitted changes present are merged into a
810 previous commit. This is useful for correcting typos or adding missed
811 files into past commits. By default the commit used is HEAD.
813 While rewriting history it is possible that conflicts will arise. If
814 this happens, the rewrite will pause and you will be prompted to resolve
815 the conflicts and stage them. Once that is done, you will run "yap
816 history continue." If instead you want the conflicting commit removed
817 from history (perhaps your changes supercede that commit) you can run
818 "yap history skip". Once the rewrite completes, your branch will be on
819 the same commit as when the rewrite started.
820 """)
821 def cmd_history(self, subcmd, *args):
822 "amend | drop <commit>"
823 self._check_git()
825 if subcmd not in ("amend", "drop", "continue", "skip"):
826 raise TypeError
828 resolvemsg = """
829 When you have resolved the conflicts run \"yap history continue\".
830 To skip the problematic patch, run \"yap history skip\"."""
832 if subcmd == "continue":
833 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
834 return
835 if subcmd == "skip":
836 os.system("git reset --hard")
837 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
838 return
840 if subcmd == "amend":
841 flags, args = getopt.getopt(args, "ad")
842 flags = dict(flags)
844 if len(args) > 1:
845 raise TypeError
846 if args:
847 commit = args[0]
848 else:
849 commit = "HEAD"
851 self._resolve_rev(commit)
852 self._check_rebasing()
854 if subcmd == "amend":
855 self._check_commit(**flags)
856 if self._get_unstaged_files():
857 # XXX: handle unstaged changes better
858 raise YapError("Commit away changes that you aren't amending")
860 self._unstage_all()
862 start = get_output("git rev-parse HEAD")
863 stash = get_output("git stash create")
864 run_command("git reset --hard")
865 try:
866 fd, tmpfile = tempfile.mkstemp("yap")
867 try:
868 try:
869 os.close(fd)
870 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
871 if subcmd == "amend":
872 self.cmd_point(commit, **{'-f': True})
873 finally:
874 if subcmd == "amend":
875 if stash:
876 rc = os.system("git stash apply %s" % stash[0])
877 if rc:
878 self.cmd_point(start[0], **{'-f': True})
879 os.system("git stash apply %s" % stash[0])
880 raise YapError("Failed to apply stash")
881 stash = None
883 if subcmd == "amend":
884 self._do_uncommit()
885 self._check_commit(**{'-a': True})
886 self._do_commit()
887 else:
888 self.cmd_point("%s^" % commit, **{'-f': True})
890 stat = os.stat(tmpfile)
891 size = stat[6]
892 if size > 0:
893 run_safely("git update-index --refresh")
894 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
895 if (rc):
896 raise YapError("Failed to apply changes")
897 finally:
898 os.unlink(tmpfile)
899 finally:
900 if stash:
901 run_command("git stash apply %s" % stash[0])
902 self.cmd_status()
904 @short_help("show the changes introduced by a given commit")
905 @long_help("""
906 By default, the changes in the last commit are shown. To override this,
907 specify a hash, branch, or tag (committish). The hash of the commit,
908 the commit's author, log message, and a diff of the changes are shown.
909 """)
910 def cmd_show(self, commit="HEAD"):
911 "[commit]"
912 self._check_git()
913 commit = self._resolve_rev(commit)
914 os.system("git show '%s'" % commit)
916 @short_help("apply the changes in a given commit to the current branch")
917 @long_help("""
918 The argument is the hash, branch, or tag (committish) of the commit to
919 be applied. In general, it only makes sense to apply commits that
920 happened on another branch. The '-r' flag can be used to have the
921 changes in the given commit reversed from the current branch. In
922 general, this only makes sense for commits that happened on the current
923 branch.
924 """)
925 @takes_options("r")
926 def cmd_cherry_pick(self, commit, **flags):
927 "[-r] <commit>"
928 self._check_git()
929 commit = self._resolve_rev(commit)
930 if '-r' in flags:
931 os.system("git revert '%s'" % commit)
932 else:
933 os.system("git cherry-pick '%s'" % commit)
935 @short_help("list, add, or delete configured remote repositories")
936 @long_help("""
937 When invoked with no arguments, this command will show the list of
938 currently configured remote repositories, giving both the name and URL
939 of each. To add a new repository, give the desired name as the first
940 argument and the URL as the second. The '-d' flag can be used to remove
941 a previously added repository.
942 """)
943 @takes_options("d:")
944 def cmd_repo(self, name=None, url=None, **flags):
945 "[<name> <url> | -d <name>]"
946 self._check_git()
947 if name is not None and url is None:
948 raise TypeError
950 if '-d' in flags:
951 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
952 raise YapError("No such repository: %s" % flags['-d'])
953 os.system("git config --unset remote.%s.url" % flags['-d'])
954 os.system("git config --unset remote.%s.fetch" % flags['-d'])
955 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
956 hash = get_output("git rev-parse %s" % b)
957 assert hash
958 run_safely("git update-ref -d %s %s" % (b, hash[0]))
960 if name:
961 if name in [ x[0] for x in self._list_remotes() ]:
962 raise YapError("Repository '%s' already exists" % name)
963 os.system("git config remote.%s.url %s" % (name, url))
964 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
966 for remote, url in self._list_remotes():
967 print "%s" % remote
968 print " URL: %s" % url
969 first = True
970 for b in get_output("git for-each-ref --format='%%(refname)' 'refs/remotes/%s/*'" % remote):
971 b = b.replace('refs/remotes/', '')
972 if first:
973 branches = "Branches: "
974 else:
975 branches = " "
976 print " %s%s" % (branches, b)
977 first = False
979 @short_help("send local commits to a remote repository (*)")
980 @long_help("""
981 When invoked with no arguments, the current branch is synchronized to
982 the tracking branch of the tracking remote. If no tracking remote is
983 specified, the repository will have to be specified on the command line.
984 In that case, the default is to push to a branch with the same name as
985 the current branch. This behavior can be overridden by giving a second
986 argument to specify the remote branch.
988 If the remote branch does not currently exist, the command will abort
989 unless the -c flag is provided. If the remote branch is not a direct
990 descendent of the local branch, the command will abort unless the -f
991 flag is provided. Forcing a push in this way can be problematic to
992 other users of the repository if they are not expecting it.
994 To delete a branch on the remote repository, use the -d flag.
995 """)
996 @takes_options("cdf")
997 def cmd_push(self, repo=None, rhs=None, **flags):
998 "[-c | -d] <repo>"
999 self._check_git()
1000 if '-c' in flags and '-d' in flags:
1001 raise TypeError
1003 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
1004 raise YapError("No such repository: %s" % repo)
1006 current = get_output("git symbolic-ref HEAD")
1007 if not current:
1008 raise YapError("Not on a branch!")
1010 self._check_rebasing()
1012 current = current[0].replace('refs/heads/', '')
1013 remote = get_output("git config branch.%s.remote" % current)
1014 if repo is None and remote:
1015 repo = remote[0]
1017 if repo is None:
1018 raise YapError("No tracking branch configured; specify destination repository")
1020 if rhs is None and remote and remote[0] == repo:
1021 merge = get_output("git config branch.%s.merge" % current)
1022 if merge:
1023 rhs = merge[0]
1025 if rhs is None:
1026 rhs = "refs/heads/%s" % current
1028 if '-c' not in flags and '-d' not in flags:
1029 if run_command("git rev-parse --verify refs/remotes/%s/%s"
1030 % (repo, rhs.replace('refs/heads/', ''))):
1031 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
1032 if '-f' not in flags:
1033 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo, rhs.replace('refs/heads/', '')))
1034 base = get_output("git merge-base HEAD %s" % hash[0])
1035 assert base
1036 if base[0] != hash[0]:
1037 raise YapError("Branch not up-to-date with remote. Update or use -f")
1039 self._confirm_push(current, rhs, repo)
1040 if '-f' in flags:
1041 flags['-f'] = '-f'
1043 if '-d' in flags:
1044 lhs = ""
1045 else:
1046 lhs = "refs/heads/%s" % current
1047 rc = os.system("git push %s %s %s:%s" % (flags.get('-f', ''), repo, lhs, rhs))
1048 if rc:
1049 raise YapError("Push failed.")
1051 @short_help("retrieve commits from a remote repository")
1052 @long_help("""
1053 When run with no arguments, the command will retrieve new commits from
1054 the remote tracking repository. Note that this does not in any way
1055 alter the current branch. For that, see "update". If a remote other
1056 than the tracking remote is desired, it can be specified as the first
1057 argument.
1058 """)
1059 def cmd_fetch(self, repo=None):
1060 "<repo>"
1061 self._check_git()
1062 current = get_output("git symbolic-ref HEAD")
1063 if not current:
1064 raise YapError("Not on a branch!")
1066 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
1067 raise YapError("No such repository: %s" % repo)
1068 if repo is None:
1069 current = current[0].replace('refs/heads/', '')
1070 remote = get_output("git config branch.%s.remote" % current)
1071 if remote:
1072 repo = remote[0]
1073 if repo is None:
1074 raise YapError("No tracking branch configured; specify a repository")
1075 os.system("git fetch %s" % repo)
1077 @short_help("update the current branch relative to its tracking branch")
1078 @long_help("""
1079 Updates the current branch relative to its remote tracking branch. This
1080 command requires that the current branch have a remote tracking branch
1081 configured. If any conflicts occur while applying your changes to the
1082 updated remote, the command will pause to allow you to fix them. Once
1083 that is done, run "update" with the "continue" subcommand. Alternately,
1084 the "skip" subcommand can be used to discard the conflicting changes.
1085 """)
1086 def cmd_update(self, subcmd=None):
1087 "[continue | skip]"
1088 self._check_git()
1089 if subcmd and subcmd not in ["continue", "skip"]:
1090 raise TypeError
1092 resolvemsg = """
1093 When you have resolved the conflicts run \"yap update continue\".
1094 To skip the problematic patch, run \"yap update skip\"."""
1096 if subcmd == "continue":
1097 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
1098 return
1099 if subcmd == "skip":
1100 os.system("git reset --hard")
1101 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
1102 return
1104 self._check_rebasing()
1105 if self._get_unstaged_files() or self._get_staged_files():
1106 raise YapError("You have uncommitted changes. Commit them first")
1108 current = get_output("git symbolic-ref HEAD")
1109 if not current:
1110 raise YapError("Not on a branch!")
1112 current = current[0].replace('refs/heads/', '')
1113 remote, merge = self._get_tracking(current)
1114 merge = merge.replace('refs/heads/', '')
1116 self.cmd_fetch(remote)
1117 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
1119 try:
1120 fd, tmpfile = tempfile.mkstemp("yap")
1121 os.close(fd)
1122 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
1123 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
1125 stat = os.stat(tmpfile)
1126 size = stat[6]
1127 if size > 0:
1128 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
1129 if (rc):
1130 raise YapError("Failed to apply changes")
1131 finally:
1132 os.unlink(tmpfile)
1134 @short_help("query and configure remote branch tracking")
1135 @long_help("""
1136 When invoked with no arguments, the command displays the tracking
1137 information for the current branch. To configure the tracking
1138 information, two arguments for the remote repository and remote branch
1139 are given. The tracking information is used to provide defaults for
1140 where to push local changes and from where to get updates to the branch.
1141 """)
1142 def cmd_track(self, repo=None, branch=None):
1143 "[<repo> <branch>]"
1144 self._check_git()
1146 current = get_output("git symbolic-ref HEAD")
1147 if not current:
1148 raise YapError("Not on a branch!")
1149 current = current[0].replace('refs/heads/', '')
1151 if repo is None and branch is None:
1152 repo, merge = self._get_tracking(current)
1153 merge = merge.replace('refs/heads/', '')
1154 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
1155 return
1157 if repo is None or branch is None:
1158 raise TypeError
1160 if repo not in [ x[0] for x in self._list_remotes() ]:
1161 raise YapError("No such repository: %s" % repo)
1163 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
1164 raise YapError("No such branch '%s' on repository '%s'" % (branch, repo))
1166 os.system("git config branch.%s.remote '%s'" % (current, repo))
1167 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
1168 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
1170 @short_help("mark files with conflicts as resolved")
1171 @long_help("""
1172 The arguments are the files to be marked resolved. When a conflict
1173 occurs while merging changes to a file, that file is marked as
1174 "unmerged." Until the file(s) with conflicts are marked resolved,
1175 commits cannot be made.
1176 """)
1177 def cmd_resolved(self, *files):
1178 "<file>..."
1179 self._check_git()
1180 if not files:
1181 raise TypeError
1183 for f in files:
1184 self._stage_one(f, True)
1185 self.cmd_status()
1187 @short_help("merge a branch into the current branch")
1188 def cmd_merge(self, branch):
1189 "<branch>"
1190 self._check_git()
1192 branch_name = branch
1193 branch = self._resolve_rev(branch)
1194 base = get_output("git merge-base HEAD %s" % branch)
1195 if not base:
1196 raise YapError("Branch '%s' is not a fork of the current branch"
1197 % branch)
1199 readtree = ("git read-tree --aggressive -u -m %s HEAD %s"
1200 % (base[0], branch))
1201 if run_command(readtree):
1202 run_command("git update-index --refresh")
1203 if os.system(readtree):
1204 raise YapError("Failed to merge")
1206 repo = get_output('git rev-parse --git-dir')[0]
1207 dir = os.path.join(repo, 'yap')
1208 try:
1209 os.mkdir(dir)
1210 except OSError:
1211 pass
1212 msg_file = os.path.join(dir, 'msg')
1213 msg = file(msg_file, 'w')
1214 print >>msg, "Merge branch '%s'" % branch_name
1215 msg.close()
1217 head = get_output("git rev-parse --verify HEAD")
1218 assert head
1219 heads = [head[0], branch]
1220 head_file = os.path.join(dir, 'merge')
1221 pickle.dump(heads, file(head_file, 'w'))
1223 self._merge_index(branch, base[0])
1224 if self._get_unmerged_files():
1225 self.cmd_status()
1226 raise YapError("Fix conflicts then commit")
1228 self._do_commit()
1230 def _merge_index(self, branch, base):
1231 for f in self._get_unmerged_files():
1232 fd, bfile = tempfile.mkstemp("yap")
1233 os.close(fd)
1234 rc = os.system("git show %s:%s > %s" % (base, f, bfile))
1235 assert rc == 0
1237 fd, ofile = tempfile.mkstemp("yap")
1238 os.close(fd)
1239 rc = os.system("git show %s:%s > %s" % (branch, f, ofile))
1240 assert rc == 0
1242 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)
1243 rc = os.system(command)
1244 os.unlink(ofile)
1245 os.unlink(bfile)
1247 assert rc >= 0
1248 if rc == 0:
1249 self._stage_one(f, True)
1251 def cmd_help(self, cmd=None):
1252 if cmd is not None:
1253 oldcmd = cmd
1254 cmd = "cmd_" + cmd.replace('-', '_')
1255 try:
1256 attr = self.__getattribute__(cmd)
1257 except AttributeError:
1258 raise YapError("No such command: %s" % cmd)
1260 try:
1261 help = self._get_attr(cmd, "long_help")
1262 except AttributeError:
1263 raise
1264 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
1266 print >>sys.stderr, "The '%s' command" % oldcmd
1267 print >>sys.stderr, "\tyap %s %s" % (oldcmd, attr.__doc__)
1268 print >>sys.stderr, "%s" % help
1269 return
1271 print >> sys.stderr, "Yet Another (Git) Porcelein"
1272 print >> sys.stderr
1274 for name in dir(self):
1275 if not name.startswith('cmd_'):
1276 continue
1277 attr = self.__getattribute__(name)
1278 if not callable(attr):
1279 continue
1281 try:
1282 short_msg = self._get_attr(name, "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, "(*) Indicates that the command is not readily reversible"
1293 @short_help("show information about loaded plugins")
1294 def cmd_plugins(self):
1296 print >> sys.stderr, "Loaded plugins:"
1297 plugins = load_plugins()
1298 for name, cls in plugins.items():
1299 print "\t%-16s: %s" % (name, cls.__doc__)
1300 if not plugins:
1301 print "\t%-16s" % "None"
1303 def cmd_usage(self):
1304 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
1305 print >> sys.stderr, " valid commands: help init clone add rm stage unstage status revert commit uncommit log show diff branch switch point cherry-pick repo track push fetch update history resolved version"
1307 def load_plugins():
1308 plugindir = os.path.join("~", ".yap", "plugins")
1309 plugindir = os.path.expanduser(plugindir)
1310 plugindir = os.path.join(plugindir, "*.py")
1312 plugins = dict()
1313 for p in glob.glob(os.path.expanduser(plugindir)):
1314 plugin = os.path.basename(p).replace('.py', '')
1315 m = __import__(plugin)
1316 for k in dir(m):
1317 cls = m.__dict__[k]
1318 if not type(cls) == type:
1319 continue
1320 if not issubclass(cls, YapCore):
1321 continue
1322 if cls is YapCore:
1323 continue
1324 plugins[k] = cls
1325 return plugins
1327 def yap_metaclass(name, bases, dct):
1328 plugindir = os.path.join("~", ".yap", "plugins")
1329 plugindir = os.path.expanduser(plugindir)
1330 sys.path.insert(0, plugindir)
1332 plugins = set(load_plugins().values())
1333 p2 = plugins.copy()
1334 for cls in plugins:
1335 p2 -= set(cls.__bases__)
1336 plugins = p2
1337 bases = list(plugins) + list(bases)
1338 return type(name, tuple(bases), dct)
1340 class Yap(YapCore):
1341 __metaclass__ = yap_metaclass
1343 def main(self, args):
1344 if len(args) < 1:
1345 self.cmd_usage()
1346 sys.exit(2)
1348 command = args[0]
1349 args = args[1:]
1351 if run_command("git --version"):
1352 print >>sys.stderr, "Failed to run git; is it installed?"
1353 sys.exit(1)
1355 debug = os.getenv('YAP_DEBUG')
1357 try:
1358 command = command.replace('-', '_')
1359 meth = self.__getattribute__("cmd_"+command)
1360 doc = self._get_attr("cmd_"+command, "__doc__")
1362 try:
1363 options = ""
1364 for c in self.__class__.__bases__:
1365 try:
1366 t = c.__dict__["cmd_"+command]
1367 except KeyError:
1368 continue
1369 if "options" in t.__dict__:
1370 options += t.options
1372 if options:
1373 try:
1374 flags, args = getopt.getopt(args, options)
1375 flags = dict(flags)
1376 except getopt.GetoptError, e:
1377 if debug:
1378 raise
1379 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1380 print e
1381 sys.exit(2)
1382 else:
1383 flags = dict()
1385 meth(*args, **flags)
1386 except (TypeError, getopt.GetoptError):
1387 if debug:
1388 raise
1389 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1390 except YapError, e:
1391 if debug:
1392 raise
1393 print >> sys.stderr, e
1394 sys.exit(1)
1395 except AttributeError:
1396 if debug:
1397 raise
1398 self.cmd_usage()
1399 sys.exit(2)