Refactor "rev-parse --show-cdup" into a utility function
[yap.git] / yap / yap.py
bloba30c88b3a552d64954d698e2e61fd2d7a5874690
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
74 prefix = [ prefix[0] ]
75 while True:
76 head, tail = os.path.split(prefix[0])
77 if not head:
78 break
79 prefix[0] = head
80 if tail:
81 prefix.insert(1, tail)
83 path = [ path ]
84 while True:
85 head, tail = os.path.split(path[0])
86 if not head:
87 break
88 path[0] = head
89 if tail:
90 path.insert(1, tail)
92 common = 0
93 for a, b in zip(prefix, path):
94 if a != b:
95 break
96 common += 1
98 path = path[common:]
99 cdup = [".."] * (len(prefix) - common)
100 path = cdup + list(path)
101 path = os.path.join(*path)
102 return path
104 def _get_staged_files(self):
105 if run_command("git rev-parse HEAD"):
106 files = get_output("git ls-files --cached")
107 else:
108 files = get_output("git diff-index --cached --name-only HEAD")
109 unmerged = self._get_unmerged_files()
110 if unmerged:
111 unmerged = set(unmerged)
112 files = set(files).difference(unmerged)
113 files = list(files)
114 return files
116 def _get_unstaged_files(self):
117 cwd = os.getcwd()
118 cdup = self._get_cdup()
119 os.chdir(cdup)
120 files = get_output("git ls-files -m")
121 os.chdir(cwd)
123 new_files = self._get_new_files()
124 if new_files:
125 staged = self._get_staged_files()
126 if staged:
127 staged = set(staged)
128 new_files = set(new_files).difference(staged)
129 new_files = list(new_files)
130 files += new_files
131 unmerged = self._get_unmerged_files()
132 if unmerged:
133 unmerged = set(unmerged)
134 files = set(files).difference(unmerged)
135 files = list(files)
136 return files
138 def _get_unmerged_files(self):
139 cwd = os.getcwd()
140 cdup = self._get_cdup()
141 os.chdir(cdup)
142 files = get_output("git ls-files -u")
143 os.chdir(cwd)
144 files = [ x.replace('\t', ' ').split(' ')[3] for x in files ]
145 return list(set(files))
147 def _resolve_rev(self, rev):
148 ref = get_output("git rev-parse --verify %s 2>/dev/null" % rev)
149 if not ref:
150 raise YapError("No such revision: %s" % rev)
151 return ref[0]
153 def _delete_branch(self, branch, force):
154 current = get_output("git symbolic-ref HEAD")
155 if current:
156 current = current[0].replace('refs/heads/', '')
157 if branch == current:
158 raise YapError("Can't delete current branch")
160 ref = self._resolve_rev('refs/heads/'+branch)
161 run_safely("git update-ref -d 'refs/heads/%s' '%s'" % (branch, ref))
163 if not force:
164 name = get_output("git name-rev --name-only '%s'" % ref)[0]
165 if name == 'undefined':
166 run_command("git update-ref 'refs/heads/%s' '%s'" % (branch, ref))
167 raise YapError("Refusing to delete leaf branch (use -f to force)")
168 def _get_pager_cmd(self):
169 if 'YAP_PAGER' in os.environ:
170 return os.environ['YAP_PAGER']
171 elif 'GIT_PAGER' in os.environ:
172 return os.environ['GIT_PAGER']
173 elif 'PAGER' in os.environ:
174 return os.environ['PAGER']
175 else:
176 return "less"
178 def _add_one(self, file):
179 self._assert_file_exists(file)
180 x = get_output("git ls-files '%s'" % file)
181 if x != [] or file in self._get_new_files():
182 raise YapError("File '%s' already in repository" % file)
183 self._add_new_file(file)
185 def _rm_one(self, file):
186 self._assert_file_exists(file)
187 if get_output("git ls-files '%s'" % file) != []:
188 run_safely("git rm --cached '%s'" % file)
189 self._remove_new_file(file)
191 def _stage_one(self, file, allow_unmerged=False):
192 self._assert_file_exists(file)
193 prefix = get_output("git rev-parse --show-prefix")
194 if prefix:
195 tmp = os.path.normpath(os.path.join(prefix[0], file))
196 else:
197 tmp = file
198 if not allow_unmerged and tmp in self._get_unmerged_files():
199 raise YapError("Refusing to stage conflicted file: %s" % file)
200 run_safely("git update-index --add '%s'" % file)
202 def _get_cdup(self):
203 cdup = get_output("git rev-parse --show-cdup")
204 assert cdup
205 if cdup[0]:
206 cdup = cdup[0]
207 else:
208 cdup = '.'
209 return cdup
211 def _unstage_one(self, file):
212 self._assert_file_exists(file)
213 if run_command("git rev-parse HEAD"):
214 rc = run_command("git update-index --force-remove '%s'" % file)
215 else:
216 cdup = self._get_cdup()
217 rc = run_command("git diff-index --cached -p HEAD '%s' | (cd %s; git apply -R --cached)" % (file, cdup))
218 if rc:
219 raise YapError("Failed to unstage")
221 def _revert_one(self, file):
222 self._assert_file_exists(file)
223 try:
224 self._unstage_one(file)
225 except YapError:
226 pass
227 run_safely("git checkout-index -u -f '%s'" % file)
229 def _parse_commit(self, commit):
230 lines = get_output("git cat-file commit '%s'" % commit)
231 commit = {}
233 mode = None
234 for l in lines:
235 if mode != 'commit' and l.strip() == "":
236 mode = 'commit'
237 commit['log'] = []
238 continue
239 if mode == 'commit':
240 commit['log'].append(l)
241 continue
243 x = l.split(' ')
244 k = x[0]
245 v = ' '.join(x[1:])
246 commit[k] = v
247 commit['log'] = '\n'.join(commit['log'])
248 return commit
250 def _check_commit(self, **flags):
251 if '-a' in flags and '-d' in flags:
252 raise YapError("Conflicting flags: -a and -d")
254 if '-d' not in flags and self._get_unstaged_files():
255 if '-a' not in flags and self._get_staged_files():
256 raise YapError("Staged and unstaged changes present. Specify what to commit")
257 os.system("git diff-files -p | git apply --cached")
258 for f in self._get_new_files():
259 self._stage_one(f)
261 def _do_uncommit(self):
262 commit = self._parse_commit("HEAD")
263 repo = get_output('git rev-parse --git-dir')[0]
264 dir = os.path.join(repo, 'yap')
265 try:
266 os.mkdir(dir)
267 except OSError:
268 pass
269 msg_file = os.path.join(dir, 'msg')
270 fd = file(msg_file, 'w')
271 print >>fd, commit['log']
272 fd.close()
274 tree = get_output("git rev-parse --verify HEAD^")
275 run_safely("git update-ref -m uncommit HEAD '%s'" % tree[0])
277 def _do_commit(self, msg=None):
278 tree = get_output("git write-tree")[0]
280 repo = get_output('git rev-parse --git-dir')[0]
281 head_file = os.path.join(repo, 'yap', 'merge')
282 try:
283 parent = pickle.load(file(head_file))
284 except IOError:
285 parent = get_output("git rev-parse --verify HEAD 2> /dev/null")
287 if os.environ.has_key('YAP_EDITOR'):
288 editor = os.environ['YAP_EDITOR']
289 elif os.environ.has_key('GIT_EDITOR'):
290 editor = os.environ['GIT_EDITOR']
291 elif os.environ.has_key('EDITOR'):
292 editor = os.environ['EDITOR']
293 else:
294 editor = "vi"
296 fd, tmpfile = tempfile.mkstemp("yap")
297 os.close(fd)
299 if msg is None:
300 msg_file = os.path.join(repo, 'yap', 'msg')
301 if os.access(msg_file, os.R_OK):
302 fd1 = file(msg_file)
303 fd2 = file(tmpfile, 'w')
304 for l in fd1.xreadlines():
305 print >>fd2, l.strip()
306 fd2.close()
307 os.unlink(msg_file)
308 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
309 raise YapError("Editing commit message failed")
310 fd = file(tmpfile)
311 msg = fd.readlines()
312 msg = ''.join(msg)
314 msg = msg.strip()
315 if not msg:
316 raise YapError("Refusing to use empty commit message")
318 fd = os.popen("git stripspace > %s" % tmpfile, 'w')
319 print >>fd, msg,
320 fd.close()
322 if parent:
323 parent = ' -p '.join(parent)
324 commit = get_output("git commit-tree '%s' -p %s < '%s'" % (tree, parent, tmpfile))
325 else:
326 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
328 os.unlink(tmpfile)
329 run_safely("git update-ref HEAD '%s'" % commit[0])
330 self._clear_state()
332 def _check_rebasing(self):
333 repo = get_output('git rev-parse --git-dir')[0]
334 dotest = os.path.join(repo, '.dotest')
335 if os.access(dotest, os.R_OK):
336 raise YapError("A git operation is in progress. Complete it first")
337 dotest = os.path.join(repo, '..', '.dotest')
338 if os.access(dotest, os.R_OK):
339 raise YapError("A git operation is in progress. Complete it first")
341 def _check_git(self):
342 if run_command("git rev-parse --git-dir"):
343 raise YapError("That command must be run from inside a git repository")
345 def _list_remotes(self):
346 remotes = get_output("git config --get-regexp '^remote.*.url'")
347 for x in remotes:
348 remote, url = x.split(' ')
349 remote = remote.replace('remote.', '')
350 remote = remote.replace('.url', '')
351 yield remote, url
353 def _unstage_all(self):
354 try:
355 run_safely("git read-tree -m HEAD")
356 except ShellError:
357 run_safely("git read-tree HEAD")
358 run_safely("git update-index -q --refresh")
360 def _get_tracking(self, current):
361 remote = get_output("git config branch.%s.remote" % current)
362 if not remote:
363 raise YapError("No tracking branch configured for '%s'" % current)
365 merge = get_output("git config branch.%s.merge" % current)
366 if not merge:
367 raise YapError("No tracking branch configured for '%s'" % current)
368 return remote[0], merge[0]
370 def _confirm_push(self, current, rhs, repo):
371 print "About to push local branch '%s' to '%s' on '%s'" % (current, rhs, repo)
372 print "Continue (y/n)? ",
373 sys.stdout.flush()
374 ans = sys.stdin.readline().strip()
376 if ans.lower() != 'y' and ans.lower() != 'yes':
377 raise YapError("Aborted.")
379 def _clear_state(self):
380 repo = get_output('git rev-parse --git-dir')[0]
381 dir = os.path.join(repo, 'yap')
382 for f in "new-files", "merge", "msg":
383 try:
384 os.unlink(os.path.join(dir, f))
385 except OSError:
386 pass
388 def _get_attr(self, name, attr):
389 val = None
390 for c in self.__class__.__bases__:
391 try:
392 m2 = c.__dict__[name]
393 except KeyError:
394 continue
395 try:
396 val = m2.__getattribute__(attr)
397 except AttributeError:
398 continue
399 return val
401 def _filter_log(self, commit):
402 return commit
404 def _check_rename(self, rev, path):
405 renames = get_output("git diff-tree -C -M --diff-filter=R %s %s^"
406 % (rev, rev))
407 for r in renames:
408 r = r.replace('\t', ' ')
409 fields = r.split(' ')
410 mode1, mode2, hash1, hash2, rename, dst, src = fields
411 if dst == path:
412 return src
413 return None
415 @short_help("make a local copy of an existing repository")
416 @long_help("""
417 The first argument is a URL to the existing repository. This can be an
418 absolute path if the repository is local, or a URL with the git://,
419 ssh://, or http:// schemes. By default, the directory used is the last
420 component of the URL, sans '.git'. This can be overridden by providing
421 a second argument.
422 """)
423 def cmd_clone(self, url, directory=None):
424 "<url> [directory]"
426 if '://' not in url and url[0] != '/':
427 url = os.path.join(os.getcwd(), url)
429 url = url.rstrip('/')
430 if directory is None:
431 directory = url.rsplit('/')[-1]
432 directory = directory.replace('.git', '')
434 try:
435 os.mkdir(directory)
436 except OSError:
437 raise YapError("Directory exists: %s" % directory)
438 os.chdir(directory)
439 self.cmd_init()
440 self.cmd_repo("origin", url)
441 self.cmd_fetch("origin")
443 branch = None
444 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
445 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
446 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin'"):
447 if get_output("git rev-parse %s" % b)[0] == hash:
448 branch = b
449 break
450 if branch is None:
451 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
452 branch = "refs/remotes/origin/master"
453 if branch is None:
454 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin'")
455 branch = branch[0]
457 hash = get_output("git rev-parse %s" % branch)
458 assert hash
459 branch = branch.replace('refs/remotes/origin/', '')
460 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
461 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
462 self.cmd_revert(**{'-a': 1})
464 @short_help("turn a directory into a repository")
465 @long_help("""
466 Converts the current working directory into a repository. The primary
467 side-effect of this command is the creation of a '.git' subdirectory.
468 No files are added nor commits made.
469 """)
470 def cmd_init(self):
471 os.system("git init")
473 @short_help("add a new file to the repository")
474 @long_help("""
475 The arguments are the files to be added to the repository. Once added,
476 the files will show as "unstaged changes" in the output of 'status'. To
477 reverse the effects of this command, see 'rm'.
478 """)
479 def cmd_add(self, *files):
480 "<file>..."
481 self._check_git()
483 if not files:
484 raise TypeError
486 for f in files:
487 self._add_one(f)
488 self.cmd_status()
490 @short_help("delete a file from the repository")
491 @long_help("""
492 The arguments are the files to be removed from the current revision of
493 the repository. The files will still exist in any past commits that the
494 files may have been a part of. The file is not actually deleted, it is
495 just no longer tracked as part of the repository.
496 """)
497 def cmd_rm(self, *files):
498 "<file>..."
499 self._check_git()
500 if not files:
501 raise TypeError
503 for f in files:
504 self._rm_one(f)
505 self.cmd_status()
507 @short_help("stage changes in a file for commit")
508 @long_help("""
509 The arguments are the files to be staged. Staging changes is a way to
510 build up a commit when you do not want to commit all changes at once.
511 To commit only staged changes, use the '-d' flag to 'commit.' To
512 reverse the effects of this command, see 'unstage'. Once staged, the
513 files will show as "staged changes" in the output of 'status'.
514 """)
515 def cmd_stage(self, *files):
516 "<file>..."
517 self._check_git()
518 if not files:
519 raise TypeError
521 for f in files:
522 self._stage_one(f)
523 self.cmd_status()
525 @short_help("unstage changes in a file")
526 @long_help("""
527 The arguments are the files to be unstaged. Once unstaged, the files
528 will show as "unstaged changes" in the output of 'status'. The '-a'
529 flag can be used to unstage all staged changes at once.
530 """)
531 @takes_options("a")
532 def cmd_unstage(self, *files, **flags):
533 "[-a] | <file>..."
534 self._check_git()
535 if '-a' in flags:
536 files = self._get_staged_files()
538 if not files:
539 raise YapError("Nothing to do")
541 for f in files:
542 self._unstage_one(f)
543 self.cmd_status()
545 @short_help("show files with staged and unstaged changes")
546 @long_help("""
547 Show the files in the repository with changes since the last commit,
548 categorized based on whether the changes are staged or not. A file may
549 appear under each heading if the same file has both staged and unstaged
550 changes.
551 """)
552 def cmd_status(self):
554 self._check_git()
555 branch = get_output("git symbolic-ref HEAD")
556 if branch:
557 branch = branch[0].replace('refs/heads/', '')
558 else:
559 branch = "DETACHED"
560 print "Current branch: %s" % branch
562 print "Files with staged changes:"
563 files = self._get_staged_files()
564 for f in files:
565 print "\t%s" % self._repo_path_to_rel(f)
566 if not files:
567 print "\t(none)"
569 print "Files with unstaged changes:"
570 files = self._get_unstaged_files()
571 for f in files:
572 print "\t%s" % self._repo_path_to_rel(f)
573 if not files:
574 print "\t(none)"
576 files = self._get_unmerged_files()
577 if files:
578 print "Files with conflicts:"
579 for f in files:
580 print "\t%s" % self._repo_path_to_rel(f)
582 @short_help("remove uncommitted changes from a file (*)")
583 @long_help("""
584 The arguments are the files whose changes will be reverted. If the '-a'
585 flag is given, then all files will have uncommitted changes removed.
586 Note that there is no way to reverse this command short of manually
587 editing each file again.
588 """)
589 @takes_options("a")
590 def cmd_revert(self, *files, **flags):
591 "(-a | <file>)"
592 self._check_git()
593 if '-a' in flags:
594 self._unstage_all()
595 run_safely("git checkout-index -u -f -a")
596 self._clear_state()
597 self.cmd_status()
598 return
600 if not files:
601 raise TypeError
603 for f in files:
604 self._revert_one(f)
605 self.cmd_status()
607 @short_help("record changes to files as a new commit")
608 @long_help("""
609 Create a new commit recording changes since the last commit. If there
610 are only unstaged changes, those will be recorded. If there are only
611 staged changes, those will be recorded. Otherwise, you will have to
612 specify either the '-a' flag or the '-d' flag to commit all changes or
613 only staged changes, respectively. To reverse the effects of this
614 command, see 'uncommit'.
615 """)
616 @takes_options("adm:")
617 def cmd_commit(self, **flags):
618 "[-a | -d] [-m <msg>]"
619 self._check_git()
620 self._check_rebasing()
621 self._check_commit(**flags)
622 if not self._get_staged_files():
623 raise YapError("No changes to commit")
624 msg = flags.get('-m', None)
625 self._do_commit(msg)
626 self.cmd_status()
628 @short_help("reverse the actions of the last commit")
629 @long_help("""
630 Reverse the effects of the last 'commit' operation. The changes that
631 were part of the previous commit will show as "staged changes" in the
632 output of 'status'. This means that if no files were changed since the
633 last commit was created, 'uncommit' followed by 'commit' is a lossless
634 operation.
635 """)
636 def cmd_uncommit(self):
638 self._check_git()
639 self._do_uncommit()
640 self.cmd_status()
642 @short_help("report the current version of yap")
643 def cmd_version(self):
644 print "Yap version %s" % self.version
646 @short_help("show the changelog for particular versions or files")
647 @long_help("""
648 The arguments are the files with which to filter history. If none are
649 given, all changes are listed. Otherwise only commits that affected one
650 or more of the given files are listed. The -r option changes the
651 starting revision for traversing history. By default, history is listed
652 starting at HEAD.
653 """)
654 @takes_options("pr:")
655 def cmd_log(self, *paths, **flags):
656 "[-p] [-r <rev>] <path>..."
657 self._check_git()
658 rev = flags.get('-r', 'HEAD')
659 rev = self._resolve_rev(rev)
660 paths = list(paths)
662 if '-p' in flags:
663 flags['-p'] = '-p'
665 try:
666 pager = os.popen(self._get_pager_cmd(), 'w')
667 rename = False
668 while True:
669 for hash in yield_output("git rev-list '%s' -- %s"
670 % (rev, ' '.join(paths))):
671 commit = get_output("git show -M -C %s %s"
672 % (flags.get('-p', '--name-status'), hash),
673 strip=False)
674 commit = self._filter_log(commit)
675 print >>pager, ''.join(commit)
677 # Check for renames
678 if len(paths) == 1:
679 src = self._check_rename(hash, paths[0])
680 if src is not None:
681 paths[0] = src
682 rename = True
683 rev = hash+"^"
684 break
685 if not rename:
686 break
687 rename = False
688 except (IOError, KeyboardInterrupt):
689 pass
691 @short_help("show staged, unstaged, or all uncommitted changes")
692 @long_help("""
693 Show staged, unstaged, or all uncommitted changes. By default, all
694 changes are shown. The '-u' flag causes only unstaged changes to be
695 shown. The '-d' flag causes only staged changes to be shown.
696 """)
697 @takes_options("ud")
698 def cmd_diff(self, **flags):
699 "[ -u | -d ]"
700 self._check_git()
701 if '-u' in flags and '-d' in flags:
702 raise YapError("Conflicting flags: -u and -d")
704 pager = self._get_pager_cmd()
706 if '-u' in flags:
707 os.system("git diff-files -p | %s" % pager)
708 elif '-d' in flags:
709 os.system("git diff-index --cached -p HEAD | %s" % pager)
710 else:
711 os.system("git diff-index -p HEAD | %s" % pager)
713 @short_help("list, create, or delete branches")
714 @long_help("""
715 If no arguments are specified, a list of local branches is given. The
716 current branch is indicated by a "*" next to the name. If an argument
717 is given, it is taken as the name of a new branch to create. The branch
718 will start pointing at the current HEAD. See 'point' for details on
719 changing the revision of the new branch. Note that this command does
720 not switch the current working branch. See 'switch' for details on
721 changing the current working branch.
723 The '-d' flag can be used to delete local branches. If the delete
724 operation would remove the last branch reference to a given line of
725 history (colloquially referred to as "dangling commits"), yap will
726 report an error and abort. The '-f' flag can be used to force the delete
727 in spite of this.
728 """)
729 @takes_options("fd:")
730 def cmd_branch(self, branch=None, **flags):
731 "[ [-f] -d <branch> | <branch> ]"
732 self._check_git()
733 force = '-f' in flags
734 if '-d' in flags:
735 self._delete_branch(flags['-d'], force)
736 self.cmd_branch()
737 return
739 if branch is not None:
740 ref = get_output("git rev-parse --verify HEAD")
741 if not ref:
742 raise YapError("No branch point yet. Make a commit")
743 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
745 current = get_output("git symbolic-ref HEAD")
746 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads'")
747 for b in branches:
748 if current and b == current[0]:
749 print "* ",
750 else:
751 print " ",
752 b = b.replace('refs/heads/', '')
753 print b
755 @short_help("change the current working branch")
756 @long_help("""
757 The argument is the name of the branch to make the current working
758 branch. This command will fail if there are uncommitted changes to any
759 files. Otherwise, the contents of the files in the working directory
760 are updated to reflect their state in the new branch. Additionally, any
761 future commits are added to the new branch instead of the previous line
762 of history.
763 """)
764 @takes_options("f")
765 def cmd_switch(self, branch, **flags):
766 "[-f] <branch>"
767 self._check_git()
768 self._check_rebasing()
769 ref = self._resolve_rev('refs/heads/'+branch)
771 if '-f' not in flags:
772 if (self._get_staged_files()
773 or (self._get_unstaged_files()
774 and run_command("git update-index --refresh"))):
775 raise YapError("You have uncommitted changes. Use -f to continue anyway")
777 if self._get_unstaged_files() and self._get_staged_files():
778 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
780 staged = bool(self._get_staged_files())
782 run_command("git diff-files -p | git apply --cached")
783 for f in self._get_new_files():
784 self._stage_one(f)
786 idx = get_output("git write-tree")
787 new = self._resolve_rev('refs/heads/'+branch)
789 run_command("git update-index --refresh")
790 readtree = "git read-tree -v --aggressive -u -m HEAD %s %s" % (idx[0], new)
791 if os.system(readtree):
792 raise YapError("Failed to switch")
793 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
795 if '-f' not in flags:
796 self._clear_state()
798 if not staged:
799 self._unstage_all()
800 self.cmd_status()
802 @short_help("move the current branch to a different revision")
803 @long_help("""
804 The argument is the hash of the commit to which the current branch
805 should point, or alternately a branch or tag (a.k.a, "committish"). If
806 moving the branch would create "dangling commits" (see 'branch'), yap
807 will report an error and abort. The '-f' flag can be used to force the
808 operation in spite of this.
809 """)
810 @takes_options("f")
811 def cmd_point(self, where, **flags):
812 "[-f] <where>"
813 self._check_git()
814 self._check_rebasing()
816 head = get_output("git rev-parse --verify HEAD")
817 if not head:
818 raise YapError("No commit yet; nowhere to point")
820 ref = self._resolve_rev(where)
821 ref = get_output("git rev-parse --verify '%s^{commit}'" % ref)
822 if not ref:
823 raise YapError("Not a commit: %s" % where)
825 if self._get_unstaged_files() or self._get_staged_files():
826 raise YapError("You have uncommitted changes. Commit them first")
828 run_safely("git update-ref HEAD '%s'" % ref[0])
830 if '-f' not in flags:
831 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
832 if name == "undefined":
833 os.system("git update-ref HEAD '%s'" % head[0])
834 raise YapError("Pointing there will lose commits. Use -f to force")
836 run_command("git update-index --refresh")
837 rc = os.system("git read-tree -v --reset -u HEAD")
838 if rc:
839 raise YapError("checkout-index failed")
840 self._clear_state()
842 @short_help("alter history by dropping or amending commits")
843 @long_help("""
844 This command operates in two distinct modes, "amend" and "drop" mode.
845 In drop mode, the given commit is removed from the history of the
846 current branch, as though that commit never happened. By default the
847 commit used is HEAD.
849 In amend mode, the uncommitted changes present are merged into a
850 previous commit. This is useful for correcting typos or adding missed
851 files into past commits. By default the commit used is HEAD.
853 While rewriting history it is possible that conflicts will arise. If
854 this happens, the rewrite will pause and you will be prompted to resolve
855 the conflicts and stage them. Once that is done, you will run "yap
856 history continue." If instead you want the conflicting commit removed
857 from history (perhaps your changes supercede that commit) you can run
858 "yap history skip". Once the rewrite completes, your branch will be on
859 the same commit as when the rewrite started.
860 """)
861 def cmd_history(self, subcmd, *args):
862 "amend | drop <commit>"
863 self._check_git()
865 if subcmd not in ("amend", "drop", "continue", "skip"):
866 raise TypeError
868 resolvemsg = """
869 When you have resolved the conflicts run \"yap history continue\".
870 To skip the problematic patch, run \"yap history skip\"."""
872 if subcmd == "continue":
873 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
874 return
875 if subcmd == "skip":
876 os.system("git reset --hard")
877 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
878 return
880 if subcmd == "amend":
881 flags, args = getopt.getopt(args, "ad")
882 flags = dict(flags)
884 if len(args) > 1:
885 raise TypeError
886 if args:
887 commit = args[0]
888 else:
889 commit = "HEAD"
891 self._resolve_rev(commit)
892 self._check_rebasing()
894 if subcmd == "amend":
895 self._check_commit(**flags)
896 if self._get_unstaged_files():
897 # XXX: handle unstaged changes better
898 raise YapError("Commit away changes that you aren't amending")
900 self._unstage_all()
902 start = get_output("git rev-parse HEAD")
903 stash = get_output("git stash create")
904 run_command("git reset --hard")
905 try:
906 fd, tmpfile = tempfile.mkstemp("yap")
907 try:
908 try:
909 os.close(fd)
910 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
911 if subcmd == "amend":
912 self.cmd_point(commit, **{'-f': True})
913 finally:
914 if subcmd == "amend":
915 if stash:
916 rc = os.system("git stash apply %s" % stash[0])
917 if rc:
918 self.cmd_point(start[0], **{'-f': True})
919 os.system("git stash apply %s" % stash[0])
920 raise YapError("Failed to apply stash")
921 stash = None
923 if subcmd == "amend":
924 self._do_uncommit()
925 self._check_commit(**{'-a': True})
926 self._do_commit()
927 else:
928 self.cmd_point("%s^" % commit, **{'-f': True})
930 stat = os.stat(tmpfile)
931 size = stat[6]
932 if size > 0:
933 run_safely("git update-index --refresh")
934 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
935 if (rc):
936 raise YapError("Failed to apply changes")
937 finally:
938 os.unlink(tmpfile)
939 finally:
940 if stash:
941 run_command("git stash apply %s" % stash[0])
942 self.cmd_status()
944 @short_help("show the changes introduced by a given commit")
945 @long_help("""
946 By default, the changes in the last commit are shown. To override this,
947 specify a hash, branch, or tag (committish). The hash of the commit,
948 the commit's author, log message, and a diff of the changes are shown.
949 """)
950 def cmd_show(self, commit="HEAD"):
951 "[commit]"
952 self._check_git()
953 commit = self._resolve_rev(commit)
954 os.system("git show '%s'" % commit)
956 @short_help("apply the changes in a given commit to the current branch")
957 @long_help("""
958 The argument is the hash, branch, or tag (committish) of the commit to
959 be applied. In general, it only makes sense to apply commits that
960 happened on another branch. The '-r' flag can be used to have the
961 changes in the given commit reversed from the current branch. In
962 general, this only makes sense for commits that happened on the current
963 branch.
964 """)
965 @takes_options("r")
966 def cmd_cherry_pick(self, commit, **flags):
967 "[-r] <commit>"
968 self._check_git()
969 commit = self._resolve_rev(commit)
970 if '-r' in flags:
971 os.system("git revert '%s'" % commit)
972 else:
973 os.system("git cherry-pick '%s'" % commit)
975 @short_help("list, add, or delete configured remote repositories")
976 @long_help("""
977 When invoked with no arguments, this command will show the list of
978 currently configured remote repositories, giving both the name and URL
979 of each. To add a new repository, give the desired name as the first
980 argument and the URL as the second. The '-d' flag can be used to remove
981 a previously added repository.
982 """)
983 @takes_options("d:")
984 def cmd_repo(self, name=None, url=None, **flags):
985 "[<name> <url> | -d <name>]"
986 self._check_git()
987 if name is not None and url is None:
988 raise TypeError
990 if '-d' in flags:
991 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
992 raise YapError("No such repository: %s" % flags['-d'])
993 os.system("git config --unset remote.%s.url" % flags['-d'])
994 os.system("git config --unset remote.%s.fetch" % flags['-d'])
995 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin'"):
996 hash = get_output("git rev-parse %s" % b)
997 assert hash
998 run_safely("git update-ref -d %s %s" % (b, hash[0]))
1000 if name:
1001 if name in [ x[0] for x in self._list_remotes() ]:
1002 raise YapError("Repository '%s' already exists" % name)
1003 os.system("git config remote.%s.url %s" % (name, url))
1004 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
1006 for remote, url in self._list_remotes():
1007 print "%s" % remote
1008 print " URL: %s" % url
1009 first = True
1010 for b in get_output("git for-each-ref --format='%%(refname)' 'refs/remotes/%s'" % remote):
1011 b = b.replace('refs/remotes/', '')
1012 if first:
1013 branches = "Branches: "
1014 else:
1015 branches = " "
1016 print " %s%s" % (branches, b)
1017 first = False
1019 @short_help("send local commits to a remote repository (*)")
1020 @long_help("""
1021 When invoked with no arguments, the current branch is synchronized to
1022 the tracking branch of the tracking remote. If no tracking remote is
1023 specified, the repository will have to be specified on the command line.
1024 In that case, the default is to push to a branch with the same name as
1025 the current branch. This behavior can be overridden by giving a second
1026 argument to specify the remote branch.
1028 If the remote branch does not currently exist, the command will abort
1029 unless the -c flag is provided. If the remote branch is not a direct
1030 descendent of the local branch, the command will abort unless the -f
1031 flag is provided. Forcing a push in this way can be problematic to
1032 other users of the repository if they are not expecting it.
1034 To delete a branch on the remote repository, use the -d flag.
1035 """)
1036 @takes_options("cdf")
1037 def cmd_push(self, repo=None, rhs=None, **flags):
1038 "[-c | -d] <repo>"
1039 self._check_git()
1040 if '-c' in flags and '-d' in flags:
1041 raise TypeError
1043 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
1044 raise YapError("No such repository: %s" % repo)
1046 current = get_output("git symbolic-ref HEAD")
1047 if not current:
1048 raise YapError("Not on a branch!")
1050 self._check_rebasing()
1052 current = current[0].replace('refs/heads/', '')
1053 remote = get_output("git config branch.%s.remote" % current)
1054 if repo is None and remote:
1055 repo = remote[0]
1057 if repo is None:
1058 raise YapError("No tracking branch configured; specify destination repository")
1060 if rhs is None and remote and remote[0] == repo:
1061 merge = get_output("git config branch.%s.merge" % current)
1062 if merge:
1063 rhs = merge[0]
1065 if rhs is None:
1066 rhs = "refs/heads/%s" % current
1068 if '-c' not in flags and '-d' not in flags:
1069 if run_command("git rev-parse --verify refs/remotes/%s/%s"
1070 % (repo, rhs.replace('refs/heads/', ''))):
1071 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
1072 if '-f' not in flags:
1073 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo, rhs.replace('refs/heads/', '')))
1074 base = get_output("git merge-base HEAD %s" % hash[0])
1075 assert base
1076 if base[0] != hash[0]:
1077 raise YapError("Branch not up-to-date with remote. Update or use -f")
1079 self._confirm_push(current, rhs, repo)
1080 if '-f' in flags:
1081 flags['-f'] = '-f'
1083 if '-d' in flags:
1084 lhs = ""
1085 else:
1086 lhs = "refs/heads/%s" % current
1087 rc = os.system("git push %s %s %s:%s" % (flags.get('-f', ''), repo, lhs, rhs))
1088 if rc:
1089 raise YapError("Push failed.")
1091 @short_help("retrieve commits from a remote repository")
1092 @long_help("""
1093 When run with no arguments, the command will retrieve new commits from
1094 the remote tracking repository. Note that this does not in any way
1095 alter the current branch. For that, see "update". If a remote other
1096 than the tracking remote is desired, it can be specified as the first
1097 argument.
1098 """)
1099 def cmd_fetch(self, repo=None):
1100 "<repo>"
1101 self._check_git()
1102 current = get_output("git symbolic-ref HEAD")
1103 if not current:
1104 raise YapError("Not on a branch!")
1106 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
1107 raise YapError("No such repository: %s" % repo)
1108 if repo is None:
1109 current = current[0].replace('refs/heads/', '')
1110 remote = get_output("git config branch.%s.remote" % current)
1111 if remote:
1112 repo = remote[0]
1113 if repo is None:
1114 raise YapError("No tracking branch configured; specify a repository")
1115 os.system("git fetch %s" % repo)
1117 @short_help("update the current branch relative to its tracking branch")
1118 @long_help("""
1119 Updates the current branch relative to its remote tracking branch. This
1120 command requires that the current branch have a remote tracking branch
1121 configured. If any conflicts occur while applying your changes to the
1122 updated remote, the command will pause to allow you to fix them. Once
1123 that is done, run "update" with the "continue" subcommand. Alternately,
1124 the "skip" subcommand can be used to discard the conflicting changes.
1125 """)
1126 def cmd_update(self, subcmd=None):
1127 "[continue | skip]"
1128 self._check_git()
1129 if subcmd and subcmd not in ["continue", "skip"]:
1130 raise TypeError
1132 resolvemsg = """
1133 When you have resolved the conflicts run \"yap update continue\".
1134 To skip the problematic patch, run \"yap update skip\"."""
1136 if subcmd == "continue":
1137 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
1138 return
1139 if subcmd == "skip":
1140 os.system("git reset --hard")
1141 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
1142 return
1144 self._check_rebasing()
1145 if self._get_unstaged_files() or self._get_staged_files():
1146 raise YapError("You have uncommitted changes. Commit them first")
1148 current = get_output("git symbolic-ref HEAD")
1149 if not current:
1150 raise YapError("Not on a branch!")
1152 current = current[0].replace('refs/heads/', '')
1153 remote, merge = self._get_tracking(current)
1154 merge = merge.replace('refs/heads/', '')
1156 self.cmd_fetch(remote)
1157 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
1159 try:
1160 fd, tmpfile = tempfile.mkstemp("yap")
1161 os.close(fd)
1162 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
1163 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
1165 stat = os.stat(tmpfile)
1166 size = stat[6]
1167 if size > 0:
1168 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
1169 if (rc):
1170 raise YapError("Failed to apply changes")
1171 finally:
1172 os.unlink(tmpfile)
1174 @short_help("query and configure remote branch tracking")
1175 @long_help("""
1176 When invoked with no arguments, the command displays the tracking
1177 information for the current branch. To configure the tracking
1178 information, two arguments for the remote repository and remote branch
1179 are given. The tracking information is used to provide defaults for
1180 where to push local changes and from where to get updates to the branch.
1181 """)
1182 def cmd_track(self, repo=None, branch=None):
1183 "[<repo> <branch>]"
1184 self._check_git()
1186 current = get_output("git symbolic-ref HEAD")
1187 if not current:
1188 raise YapError("Not on a branch!")
1189 current = current[0].replace('refs/heads/', '')
1191 if repo is None and branch is None:
1192 repo, merge = self._get_tracking(current)
1193 merge = merge.replace('refs/heads/', '')
1194 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
1195 return
1197 if repo is None or branch is None:
1198 raise TypeError
1200 if repo not in [ x[0] for x in self._list_remotes() ]:
1201 raise YapError("No such repository: %s" % repo)
1203 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
1204 raise YapError("No such branch '%s' on repository '%s'" % (branch, repo))
1206 os.system("git config branch.%s.remote '%s'" % (current, repo))
1207 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
1208 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
1210 @short_help("mark files with conflicts as resolved")
1211 @long_help("""
1212 The arguments are the files to be marked resolved. When a conflict
1213 occurs while merging changes to a file, that file is marked as
1214 "unmerged." Until the file(s) with conflicts are marked resolved,
1215 commits cannot be made.
1216 """)
1217 def cmd_resolved(self, *files):
1218 "<file>..."
1219 self._check_git()
1220 if not files:
1221 raise TypeError
1223 for f in files:
1224 self._stage_one(f, True)
1225 self.cmd_status()
1227 @short_help("merge a branch into the current branch")
1228 def cmd_merge(self, branch):
1229 "<branch>"
1230 self._check_git()
1232 branch_name = branch
1233 branch = self._resolve_rev(branch)
1234 base = get_output("git merge-base HEAD %s" % branch)
1235 if not base:
1236 raise YapError("Branch '%s' is not a fork of the current branch"
1237 % branch)
1239 readtree = ("git read-tree --aggressive -u -m %s HEAD %s"
1240 % (base[0], branch))
1241 if run_command(readtree):
1242 run_command("git update-index --refresh")
1243 if os.system(readtree):
1244 raise YapError("Failed to merge")
1246 repo = get_output('git rev-parse --git-dir')[0]
1247 dir = os.path.join(repo, 'yap')
1248 try:
1249 os.mkdir(dir)
1250 except OSError:
1251 pass
1252 msg_file = os.path.join(dir, 'msg')
1253 msg = file(msg_file, 'w')
1254 print >>msg, "Merge branch '%s'" % branch_name
1255 msg.close()
1257 head = get_output("git rev-parse --verify HEAD")
1258 assert head
1259 heads = [head[0], branch]
1260 head_file = os.path.join(dir, 'merge')
1261 pickle.dump(heads, file(head_file, 'w'))
1263 self._merge_index(branch, base[0])
1264 if self._get_unmerged_files():
1265 self.cmd_status()
1266 raise YapError("Fix conflicts then commit")
1268 self._do_commit()
1270 def _merge_index(self, branch, base):
1271 for f in self._get_unmerged_files():
1272 fd, bfile = tempfile.mkstemp("yap")
1273 os.close(fd)
1274 rc = os.system("git show %s:%s > %s" % (base, f, bfile))
1275 assert rc == 0
1277 fd, ofile = tempfile.mkstemp("yap")
1278 os.close(fd)
1279 rc = os.system("git show %s:%s > %s" % (branch, f, ofile))
1280 assert rc == 0
1282 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)
1283 rc = os.system(command)
1284 os.unlink(ofile)
1285 os.unlink(bfile)
1287 assert rc >= 0
1288 if rc == 0:
1289 self._stage_one(f, True)
1291 def cmd_help(self, cmd=None):
1292 if cmd is not None:
1293 oldcmd = cmd
1294 cmd = "cmd_" + cmd.replace('-', '_')
1295 try:
1296 attr = self.__getattribute__(cmd)
1297 except AttributeError:
1298 raise YapError("No such command: %s" % cmd)
1300 help = self._get_attr(cmd, "long_help")
1301 if help is None:
1302 raise YapError("Sorry, no help for '%s'. Ask Steven." % oldcmd)
1304 print >>sys.stderr, "The '%s' command" % oldcmd
1305 doc = self._get_attr(cmd, "__doc__")
1306 if doc is None:
1307 doc = ""
1308 print >>sys.stderr, "\tyap %s %s" % (oldcmd, doc)
1309 print >>sys.stderr, "%s" % help
1310 return
1312 print >> sys.stderr, "Yet Another (Git) Porcelein"
1313 print >> sys.stderr
1315 for name in dir(self):
1316 if not name.startswith('cmd_'):
1317 continue
1318 attr = self.__getattribute__(name)
1319 if not callable(attr):
1320 continue
1322 short_msg = self._get_attr(name, "short_help")
1323 if short_msg is None:
1324 continue
1326 name = name.replace('cmd_', '')
1327 name = name.replace('_', '-')
1328 print >> sys.stderr, "%-16s%s" % (name, short_msg)
1330 print >> sys.stderr
1331 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
1333 @short_help("show information about loaded plugins")
1334 def cmd_plugins(self):
1336 print >> sys.stderr, "Loaded plugins:"
1337 plugins = load_plugins()
1338 for name, cls in plugins.items():
1339 print "\t%-16s: %s" % (name, cls.__doc__)
1340 if not plugins:
1341 print "\t%-16s" % "None"
1343 def cmd_usage(self):
1344 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
1345 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"
1347 def load_plugins():
1348 plugindir = os.path.join("~", ".yap", "plugins")
1349 plugindir = os.path.expanduser(plugindir)
1350 plugindir = os.path.join(plugindir, "*.py")
1352 plugins = dict()
1353 for p in glob.glob(os.path.expanduser(plugindir)):
1354 plugin = os.path.basename(p).replace('.py', '')
1355 m = __import__(plugin)
1356 for k in dir(m):
1357 cls = m.__dict__[k]
1358 if not type(cls) == type:
1359 continue
1360 if not issubclass(cls, YapCore):
1361 continue
1362 if cls is YapCore:
1363 continue
1364 plugins[k] = cls
1365 return plugins
1367 def yap_metaclass(name, bases, dct):
1368 plugindir = os.path.join("~", ".yap", "plugins")
1369 plugindir = os.path.expanduser(plugindir)
1370 sys.path.insert(0, plugindir)
1372 plugins = set(load_plugins().values())
1373 p2 = plugins.copy()
1374 for cls in plugins:
1375 p2 -= set(cls.__bases__)
1376 plugins = p2
1377 bases = list(plugins) + list(bases)
1378 return type(name, tuple(bases), dct)
1380 class Yap(YapCore):
1381 __metaclass__ = yap_metaclass
1383 def main(self, args):
1384 if len(args) < 1:
1385 self.cmd_usage()
1386 sys.exit(2)
1388 command = args[0]
1389 args = args[1:]
1391 if run_command("git --version"):
1392 print >>sys.stderr, "Failed to run git; is it installed?"
1393 sys.exit(1)
1395 debug = os.getenv('YAP_DEBUG')
1397 try:
1398 command = command.replace('-', '_')
1399 meth = self.__getattribute__("cmd_"+command)
1400 doc = self._get_attr("cmd_"+command, "__doc__")
1402 try:
1403 options = ""
1404 for c in self.__class__.__bases__:
1405 try:
1406 t = c.__dict__["cmd_"+command]
1407 except KeyError:
1408 continue
1409 if "options" in t.__dict__:
1410 options += t.options
1412 if options:
1413 try:
1414 flags, args = getopt.getopt(args, options)
1415 flags = dict(flags)
1416 except getopt.GetoptError, e:
1417 if debug:
1418 raise
1419 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1420 print e
1421 sys.exit(2)
1422 else:
1423 flags = dict()
1425 meth(*args, **flags)
1426 except (TypeError, getopt.GetoptError):
1427 if debug:
1428 raise
1429 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1430 except YapError, e:
1431 if debug:
1432 raise
1433 print >> sys.stderr, e
1434 sys.exit(1)
1435 except AttributeError:
1436 if debug:
1437 raise
1438 self.cmd_usage()
1439 sys.exit(2)