cmd_revert: work correctly when run from a subdirectory
[yap.git] / yap / yap.py
blobd4e38898fb4c0298b07e90b394a2aafa1c8d3980
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 cdup = self._get_cdup()
258 os.system("git diff-files -p | (cd %s; git apply --cached)" % cdup)
259 for f in self._get_new_files():
260 self._stage_one(f)
262 def _do_uncommit(self):
263 commit = self._parse_commit("HEAD")
264 repo = get_output('git rev-parse --git-dir')[0]
265 dir = os.path.join(repo, 'yap')
266 try:
267 os.mkdir(dir)
268 except OSError:
269 pass
270 msg_file = os.path.join(dir, 'msg')
271 fd = file(msg_file, 'w')
272 print >>fd, commit['log']
273 fd.close()
275 tree = get_output("git rev-parse --verify HEAD^")
276 run_safely("git update-ref -m uncommit HEAD '%s'" % tree[0])
278 def _do_commit(self, msg=None):
279 tree = get_output("git write-tree")[0]
281 repo = get_output('git rev-parse --git-dir')[0]
282 head_file = os.path.join(repo, 'yap', 'merge')
283 try:
284 parent = pickle.load(file(head_file))
285 except IOError:
286 parent = get_output("git rev-parse --verify HEAD 2> /dev/null")
288 if os.environ.has_key('YAP_EDITOR'):
289 editor = os.environ['YAP_EDITOR']
290 elif os.environ.has_key('GIT_EDITOR'):
291 editor = os.environ['GIT_EDITOR']
292 elif os.environ.has_key('EDITOR'):
293 editor = os.environ['EDITOR']
294 else:
295 editor = "vi"
297 fd, tmpfile = tempfile.mkstemp("yap")
298 os.close(fd)
300 if msg is None:
301 msg_file = os.path.join(repo, 'yap', 'msg')
302 if os.access(msg_file, os.R_OK):
303 fd1 = file(msg_file)
304 fd2 = file(tmpfile, 'w')
305 for l in fd1.xreadlines():
306 print >>fd2, l.strip()
307 fd2.close()
308 os.unlink(msg_file)
309 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
310 raise YapError("Editing commit message failed")
311 fd = file(tmpfile)
312 msg = fd.readlines()
313 msg = ''.join(msg)
315 msg = msg.strip()
316 if not msg:
317 raise YapError("Refusing to use empty commit message")
319 fd = os.popen("git stripspace > %s" % tmpfile, 'w')
320 print >>fd, msg,
321 fd.close()
323 if parent:
324 parent = ' -p '.join(parent)
325 commit = get_output("git commit-tree '%s' -p %s < '%s'" % (tree, parent, tmpfile))
326 else:
327 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
329 os.unlink(tmpfile)
330 run_safely("git update-ref HEAD '%s'" % commit[0])
331 self._clear_state()
333 def _check_rebasing(self):
334 repo = get_output('git rev-parse --git-dir')[0]
335 dotest = os.path.join(repo, '.dotest')
336 if os.access(dotest, os.R_OK):
337 raise YapError("A git operation is in progress. Complete it first")
338 dotest = os.path.join(repo, '..', '.dotest')
339 if os.access(dotest, os.R_OK):
340 raise YapError("A git operation is in progress. Complete it first")
342 def _check_git(self):
343 if run_command("git rev-parse --git-dir"):
344 raise YapError("That command must be run from inside a git repository")
346 def _list_remotes(self):
347 remotes = get_output("git config --get-regexp '^remote.*.url'")
348 for x in remotes:
349 remote, url = x.split(' ')
350 remote = remote.replace('remote.', '')
351 remote = remote.replace('.url', '')
352 yield remote, url
354 def _unstage_all(self):
355 try:
356 run_safely("git read-tree -m HEAD")
357 except ShellError:
358 run_safely("git read-tree HEAD")
359 run_safely("git update-index -q --refresh")
361 def _get_tracking(self, current):
362 remote = get_output("git config branch.%s.remote" % current)
363 if not remote:
364 raise YapError("No tracking branch configured for '%s'" % current)
366 merge = get_output("git config branch.%s.merge" % current)
367 if not merge:
368 raise YapError("No tracking branch configured for '%s'" % current)
369 return remote[0], merge[0]
371 def _confirm_push(self, current, rhs, repo):
372 print "About to push local branch '%s' to '%s' on '%s'" % (current, rhs, repo)
373 print "Continue (y/n)? ",
374 sys.stdout.flush()
375 ans = sys.stdin.readline().strip()
377 if ans.lower() != 'y' and ans.lower() != 'yes':
378 raise YapError("Aborted.")
380 def _clear_state(self):
381 repo = get_output('git rev-parse --git-dir')[0]
382 dir = os.path.join(repo, 'yap')
383 for f in "new-files", "merge", "msg":
384 try:
385 os.unlink(os.path.join(dir, f))
386 except OSError:
387 pass
389 def _get_attr(self, name, attr):
390 val = None
391 for c in self.__class__.__bases__:
392 try:
393 m2 = c.__dict__[name]
394 except KeyError:
395 continue
396 try:
397 val = m2.__getattribute__(attr)
398 except AttributeError:
399 continue
400 return val
402 def _filter_log(self, commit):
403 return commit
405 def _check_rename(self, rev, path):
406 renames = get_output("git diff-tree -C -M --diff-filter=R %s %s^"
407 % (rev, rev))
408 for r in renames:
409 r = r.replace('\t', ' ')
410 fields = r.split(' ')
411 mode1, mode2, hash1, hash2, rename, dst, src = fields
412 if dst == path:
413 return src
414 return None
416 @short_help("make a local copy of an existing repository")
417 @long_help("""
418 The first argument is a URL to the existing repository. This can be an
419 absolute path if the repository is local, or a URL with the git://,
420 ssh://, or http:// schemes. By default, the directory used is the last
421 component of the URL, sans '.git'. This can be overridden by providing
422 a second argument.
423 """)
424 def cmd_clone(self, url, directory=None):
425 "<url> [directory]"
427 if '://' not in url and url[0] != '/':
428 url = os.path.join(os.getcwd(), url)
430 url = url.rstrip('/')
431 if directory is None:
432 directory = url.rsplit('/')[-1]
433 directory = directory.replace('.git', '')
435 try:
436 os.mkdir(directory)
437 except OSError:
438 raise YapError("Directory exists: %s" % directory)
439 os.chdir(directory)
440 self.cmd_init()
441 self.cmd_repo("origin", url)
442 self.cmd_fetch("origin")
444 branch = None
445 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
446 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
447 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin'"):
448 if get_output("git rev-parse %s" % b)[0] == hash:
449 branch = b
450 break
451 if branch is None:
452 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
453 branch = "refs/remotes/origin/master"
454 if branch is None:
455 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin'")
456 branch = branch[0]
458 hash = get_output("git rev-parse %s" % branch)
459 assert hash
460 branch = branch.replace('refs/remotes/origin/', '')
461 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
462 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
463 self.cmd_revert(**{'-a': 1})
465 @short_help("turn a directory into a repository")
466 @long_help("""
467 Converts the current working directory into a repository. The primary
468 side-effect of this command is the creation of a '.git' subdirectory.
469 No files are added nor commits made.
470 """)
471 def cmd_init(self):
472 os.system("git init")
474 @short_help("add a new file to the repository")
475 @long_help("""
476 The arguments are the files to be added to the repository. Once added,
477 the files will show as "unstaged changes" in the output of 'status'. To
478 reverse the effects of this command, see 'rm'.
479 """)
480 def cmd_add(self, *files):
481 "<file>..."
482 self._check_git()
484 if not files:
485 raise TypeError
487 for f in files:
488 self._add_one(f)
489 self.cmd_status()
491 @short_help("delete a file from the repository")
492 @long_help("""
493 The arguments are the files to be removed from the current revision of
494 the repository. The files will still exist in any past commits that the
495 files may have been a part of. The file is not actually deleted, it is
496 just no longer tracked as part of the repository.
497 """)
498 def cmd_rm(self, *files):
499 "<file>..."
500 self._check_git()
501 if not files:
502 raise TypeError
504 for f in files:
505 self._rm_one(f)
506 self.cmd_status()
508 @short_help("stage changes in a file for commit")
509 @long_help("""
510 The arguments are the files to be staged. Staging changes is a way to
511 build up a commit when you do not want to commit all changes at once.
512 To commit only staged changes, use the '-d' flag to 'commit.' To
513 reverse the effects of this command, see 'unstage'. Once staged, the
514 files will show as "staged changes" in the output of 'status'.
515 """)
516 def cmd_stage(self, *files):
517 "<file>..."
518 self._check_git()
519 if not files:
520 raise TypeError
522 for f in files:
523 self._stage_one(f)
524 self.cmd_status()
526 @short_help("unstage changes in a file")
527 @long_help("""
528 The arguments are the files to be unstaged. Once unstaged, the files
529 will show as "unstaged changes" in the output of 'status'. The '-a'
530 flag can be used to unstage all staged changes at once.
531 """)
532 @takes_options("a")
533 def cmd_unstage(self, *files, **flags):
534 "[-a] | <file>..."
535 self._check_git()
536 if '-a' in flags:
537 files = self._get_staged_files()
539 if not files:
540 raise YapError("Nothing to do")
542 files = [ self._repo_path_to_rel(x) for x in files ]
543 for f in files:
544 self._unstage_one(f)
545 self.cmd_status()
547 @short_help("show files with staged and unstaged changes")
548 @long_help("""
549 Show the files in the repository with changes since the last commit,
550 categorized based on whether the changes are staged or not. A file may
551 appear under each heading if the same file has both staged and unstaged
552 changes.
553 """)
554 def cmd_status(self):
556 self._check_git()
557 branch = get_output("git symbolic-ref HEAD")
558 if branch:
559 branch = branch[0].replace('refs/heads/', '')
560 else:
561 branch = "DETACHED"
562 print "Current branch: %s" % branch
564 print "Files with staged changes:"
565 files = self._get_staged_files()
566 for f in files:
567 print "\t%s" % self._repo_path_to_rel(f)
568 if not files:
569 print "\t(none)"
571 print "Files with unstaged changes:"
572 files = self._get_unstaged_files()
573 for f in files:
574 print "\t%s" % self._repo_path_to_rel(f)
575 if not files:
576 print "\t(none)"
578 files = self._get_unmerged_files()
579 if files:
580 print "Files with conflicts:"
581 for f in files:
582 print "\t%s" % self._repo_path_to_rel(f)
584 @short_help("remove uncommitted changes from a file (*)")
585 @long_help("""
586 The arguments are the files whose changes will be reverted. If the '-a'
587 flag is given, then all files will have uncommitted changes removed.
588 Note that there is no way to reverse this command short of manually
589 editing each file again.
590 """)
591 @takes_options("a")
592 def cmd_revert(self, *files, **flags):
593 "(-a | <file>)"
594 self._check_git()
595 if '-a' in flags:
596 self._unstage_all()
597 cdup = self._get_cdup()
598 run_safely("(cd %s; git checkout-index -u -f -a)" % cdup)
599 self._clear_state()
600 self.cmd_status()
601 return
603 if not files:
604 raise TypeError
606 for f in files:
607 self._revert_one(f)
608 self.cmd_status()
610 @short_help("record changes to files as a new commit")
611 @long_help("""
612 Create a new commit recording changes since the last commit. If there
613 are only unstaged changes, those will be recorded. If there are only
614 staged changes, those will be recorded. Otherwise, you will have to
615 specify either the '-a' flag or the '-d' flag to commit all changes or
616 only staged changes, respectively. To reverse the effects of this
617 command, see 'uncommit'.
618 """)
619 @takes_options("adm:")
620 def cmd_commit(self, **flags):
621 "[-a | -d] [-m <msg>]"
622 self._check_git()
623 self._check_rebasing()
624 self._check_commit(**flags)
625 if not self._get_staged_files():
626 raise YapError("No changes to commit")
627 msg = flags.get('-m', None)
628 self._do_commit(msg)
629 self.cmd_status()
631 @short_help("reverse the actions of the last commit")
632 @long_help("""
633 Reverse the effects of the last 'commit' operation. The changes that
634 were part of the previous commit will show as "staged changes" in the
635 output of 'status'. This means that if no files were changed since the
636 last commit was created, 'uncommit' followed by 'commit' is a lossless
637 operation.
638 """)
639 def cmd_uncommit(self):
641 self._check_git()
642 self._do_uncommit()
643 self.cmd_status()
645 @short_help("report the current version of yap")
646 def cmd_version(self):
647 print "Yap version %s" % self.version
649 @short_help("show the changelog for particular versions or files")
650 @long_help("""
651 The arguments are the files with which to filter history. If none are
652 given, all changes are listed. Otherwise only commits that affected one
653 or more of the given files are listed. The -r option changes the
654 starting revision for traversing history. By default, history is listed
655 starting at HEAD.
656 """)
657 @takes_options("pr:")
658 def cmd_log(self, *paths, **flags):
659 "[-p] [-r <rev>] <path>..."
660 self._check_git()
661 rev = flags.get('-r', 'HEAD')
662 rev = self._resolve_rev(rev)
663 paths = list(paths)
665 if '-p' in flags:
666 flags['-p'] = '-p'
668 try:
669 pager = os.popen(self._get_pager_cmd(), 'w')
670 rename = False
671 while True:
672 for hash in yield_output("git rev-list '%s' -- %s"
673 % (rev, ' '.join(paths))):
674 commit = get_output("git show -M -C %s %s"
675 % (flags.get('-p', '--name-status'), hash),
676 strip=False)
677 commit = self._filter_log(commit)
678 print >>pager, ''.join(commit)
680 # Check for renames
681 if len(paths) == 1:
682 src = self._check_rename(hash, paths[0])
683 if src is not None:
684 paths[0] = src
685 rename = True
686 rev = hash+"^"
687 break
688 if not rename:
689 break
690 rename = False
691 except (IOError, KeyboardInterrupt):
692 pass
694 @short_help("show staged, unstaged, or all uncommitted changes")
695 @long_help("""
696 Show staged, unstaged, or all uncommitted changes. By default, all
697 changes are shown. The '-u' flag causes only unstaged changes to be
698 shown. The '-d' flag causes only staged changes to be shown.
699 """)
700 @takes_options("ud")
701 def cmd_diff(self, **flags):
702 "[ -u | -d ]"
703 self._check_git()
704 if '-u' in flags and '-d' in flags:
705 raise YapError("Conflicting flags: -u and -d")
707 pager = self._get_pager_cmd()
709 if '-u' in flags:
710 os.system("git diff-files -p | %s" % pager)
711 elif '-d' in flags:
712 os.system("git diff-index --cached -p HEAD | %s" % pager)
713 else:
714 os.system("git diff-index -p HEAD | %s" % pager)
716 @short_help("list, create, or delete branches")
717 @long_help("""
718 If no arguments are specified, a list of local branches is given. The
719 current branch is indicated by a "*" next to the name. If an argument
720 is given, it is taken as the name of a new branch to create. The branch
721 will start pointing at the current HEAD. See 'point' for details on
722 changing the revision of the new branch. Note that this command does
723 not switch the current working branch. See 'switch' for details on
724 changing the current working branch.
726 The '-d' flag can be used to delete local branches. If the delete
727 operation would remove the last branch reference to a given line of
728 history (colloquially referred to as "dangling commits"), yap will
729 report an error and abort. The '-f' flag can be used to force the delete
730 in spite of this.
731 """)
732 @takes_options("fd:")
733 def cmd_branch(self, branch=None, **flags):
734 "[ [-f] -d <branch> | <branch> ]"
735 self._check_git()
736 force = '-f' in flags
737 if '-d' in flags:
738 self._delete_branch(flags['-d'], force)
739 self.cmd_branch()
740 return
742 if branch is not None:
743 ref = get_output("git rev-parse --verify HEAD")
744 if not ref:
745 raise YapError("No branch point yet. Make a commit")
746 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
748 current = get_output("git symbolic-ref HEAD")
749 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads'")
750 for b in branches:
751 if current and b == current[0]:
752 print "* ",
753 else:
754 print " ",
755 b = b.replace('refs/heads/', '')
756 print b
758 @short_help("change the current working branch")
759 @long_help("""
760 The argument is the name of the branch to make the current working
761 branch. This command will fail if there are uncommitted changes to any
762 files. Otherwise, the contents of the files in the working directory
763 are updated to reflect their state in the new branch. Additionally, any
764 future commits are added to the new branch instead of the previous line
765 of history.
766 """)
767 @takes_options("f")
768 def cmd_switch(self, branch, **flags):
769 "[-f] <branch>"
770 self._check_git()
771 self._check_rebasing()
772 ref = self._resolve_rev('refs/heads/'+branch)
774 if '-f' not in flags:
775 if (self._get_staged_files()
776 or (self._get_unstaged_files()
777 and run_command("git update-index --refresh"))):
778 raise YapError("You have uncommitted changes. Use -f to continue anyway")
780 if self._get_unstaged_files() and self._get_staged_files():
781 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
783 staged = bool(self._get_staged_files())
785 cdup = self._get_cdup()
786 run_command("git diff-files -p | (cd %s; git apply --cached)" % cdup)
787 for f in self._get_new_files():
788 self._stage_one(f)
790 idx = get_output("git write-tree")
791 new = self._resolve_rev('refs/heads/'+branch)
793 run_command("git update-index --refresh")
794 readtree = "git read-tree -v --aggressive -u -m HEAD %s %s" % (idx[0], new)
795 if os.system(readtree):
796 raise YapError("Failed to switch")
797 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
799 if '-f' not in flags:
800 self._clear_state()
802 if not staged:
803 self._unstage_all()
804 self.cmd_status()
806 @short_help("move the current branch to a different revision")
807 @long_help("""
808 The argument is the hash of the commit to which the current branch
809 should point, or alternately a branch or tag (a.k.a, "committish"). If
810 moving the branch would create "dangling commits" (see 'branch'), yap
811 will report an error and abort. The '-f' flag can be used to force the
812 operation in spite of this.
813 """)
814 @takes_options("f")
815 def cmd_point(self, where, **flags):
816 "[-f] <where>"
817 self._check_git()
818 self._check_rebasing()
820 head = get_output("git rev-parse --verify HEAD")
821 if not head:
822 raise YapError("No commit yet; nowhere to point")
824 ref = self._resolve_rev(where)
825 ref = get_output("git rev-parse --verify '%s^{commit}'" % ref)
826 if not ref:
827 raise YapError("Not a commit: %s" % where)
829 if self._get_unstaged_files() or self._get_staged_files():
830 raise YapError("You have uncommitted changes. Commit them first")
832 run_safely("git update-ref HEAD '%s'" % ref[0])
834 if '-f' not in flags:
835 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
836 if name == "undefined":
837 os.system("git update-ref HEAD '%s'" % head[0])
838 raise YapError("Pointing there will lose commits. Use -f to force")
840 run_command("git update-index --refresh")
841 rc = os.system("git read-tree -v --reset -u HEAD")
842 if rc:
843 raise YapError("checkout-index failed")
844 self._clear_state()
846 @short_help("alter history by dropping or amending commits")
847 @long_help("""
848 This command operates in two distinct modes, "amend" and "drop" mode.
849 In drop mode, the given commit is removed from the history of the
850 current branch, as though that commit never happened. By default the
851 commit used is HEAD.
853 In amend mode, the uncommitted changes present are merged into a
854 previous commit. This is useful for correcting typos or adding missed
855 files into past commits. By default the commit used is HEAD.
857 While rewriting history it is possible that conflicts will arise. If
858 this happens, the rewrite will pause and you will be prompted to resolve
859 the conflicts and stage them. Once that is done, you will run "yap
860 history continue." If instead you want the conflicting commit removed
861 from history (perhaps your changes supercede that commit) you can run
862 "yap history skip". Once the rewrite completes, your branch will be on
863 the same commit as when the rewrite started.
864 """)
865 def cmd_history(self, subcmd, *args):
866 "amend | drop <commit>"
867 self._check_git()
869 if subcmd not in ("amend", "drop", "continue", "skip"):
870 raise TypeError
872 resolvemsg = """
873 When you have resolved the conflicts run \"yap history continue\".
874 To skip the problematic patch, run \"yap history skip\"."""
876 if subcmd == "continue":
877 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
878 return
879 if subcmd == "skip":
880 os.system("git reset --hard")
881 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
882 return
884 if subcmd == "amend":
885 flags, args = getopt.getopt(args, "ad")
886 flags = dict(flags)
888 if len(args) > 1:
889 raise TypeError
890 if args:
891 commit = args[0]
892 else:
893 commit = "HEAD"
895 self._resolve_rev(commit)
896 self._check_rebasing()
898 if subcmd == "amend":
899 self._check_commit(**flags)
900 if self._get_unstaged_files():
901 # XXX: handle unstaged changes better
902 raise YapError("Commit away changes that you aren't amending")
904 self._unstage_all()
906 start = get_output("git rev-parse HEAD")
907 stash = get_output("git stash create")
908 run_command("git reset --hard")
909 try:
910 fd, tmpfile = tempfile.mkstemp("yap")
911 try:
912 try:
913 os.close(fd)
914 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
915 if subcmd == "amend":
916 self.cmd_point(commit, **{'-f': True})
917 finally:
918 if subcmd == "amend":
919 if stash:
920 rc = os.system("git stash apply %s" % stash[0])
921 if rc:
922 self.cmd_point(start[0], **{'-f': True})
923 os.system("git stash apply %s" % stash[0])
924 raise YapError("Failed to apply stash")
925 stash = None
927 if subcmd == "amend":
928 self._do_uncommit()
929 self._check_commit(**{'-a': True})
930 self._do_commit()
931 else:
932 self.cmd_point("%s^" % commit, **{'-f': True})
934 stat = os.stat(tmpfile)
935 size = stat[6]
936 if size > 0:
937 run_safely("git update-index --refresh")
938 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
939 if (rc):
940 raise YapError("Failed to apply changes")
941 finally:
942 os.unlink(tmpfile)
943 finally:
944 if stash:
945 run_command("git stash apply %s" % stash[0])
946 self.cmd_status()
948 @short_help("show the changes introduced by a given commit")
949 @long_help("""
950 By default, the changes in the last commit are shown. To override this,
951 specify a hash, branch, or tag (committish). The hash of the commit,
952 the commit's author, log message, and a diff of the changes are shown.
953 """)
954 def cmd_show(self, commit="HEAD"):
955 "[commit]"
956 self._check_git()
957 commit = self._resolve_rev(commit)
958 os.system("git show '%s'" % commit)
960 @short_help("apply the changes in a given commit to the current branch")
961 @long_help("""
962 The argument is the hash, branch, or tag (committish) of the commit to
963 be applied. In general, it only makes sense to apply commits that
964 happened on another branch. The '-r' flag can be used to have the
965 changes in the given commit reversed from the current branch. In
966 general, this only makes sense for commits that happened on the current
967 branch.
968 """)
969 @takes_options("r")
970 def cmd_cherry_pick(self, commit, **flags):
971 "[-r] <commit>"
972 self._check_git()
973 commit = self._resolve_rev(commit)
974 if '-r' in flags:
975 os.system("git revert '%s'" % commit)
976 else:
977 os.system("git cherry-pick '%s'" % commit)
979 @short_help("list, add, or delete configured remote repositories")
980 @long_help("""
981 When invoked with no arguments, this command will show the list of
982 currently configured remote repositories, giving both the name and URL
983 of each. To add a new repository, give the desired name as the first
984 argument and the URL as the second. The '-d' flag can be used to remove
985 a previously added repository.
986 """)
987 @takes_options("d:")
988 def cmd_repo(self, name=None, url=None, **flags):
989 "[<name> <url> | -d <name>]"
990 self._check_git()
991 if name is not None and url is None:
992 raise TypeError
994 if '-d' in flags:
995 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
996 raise YapError("No such repository: %s" % flags['-d'])
997 os.system("git config --unset remote.%s.url" % flags['-d'])
998 os.system("git config --unset remote.%s.fetch" % flags['-d'])
999 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin'"):
1000 hash = get_output("git rev-parse %s" % b)
1001 assert hash
1002 run_safely("git update-ref -d %s %s" % (b, hash[0]))
1004 if name:
1005 if name in [ x[0] for x in self._list_remotes() ]:
1006 raise YapError("Repository '%s' already exists" % name)
1007 os.system("git config remote.%s.url %s" % (name, url))
1008 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
1010 for remote, url in self._list_remotes():
1011 print "%s" % remote
1012 print " URL: %s" % url
1013 first = True
1014 for b in get_output("git for-each-ref --format='%%(refname)' 'refs/remotes/%s'" % remote):
1015 b = b.replace('refs/remotes/', '')
1016 if first:
1017 branches = "Branches: "
1018 else:
1019 branches = " "
1020 print " %s%s" % (branches, b)
1021 first = False
1023 @short_help("send local commits to a remote repository (*)")
1024 @long_help("""
1025 When invoked with no arguments, the current branch is synchronized to
1026 the tracking branch of the tracking remote. If no tracking remote is
1027 specified, the repository will have to be specified on the command line.
1028 In that case, the default is to push to a branch with the same name as
1029 the current branch. This behavior can be overridden by giving a second
1030 argument to specify the remote branch.
1032 If the remote branch does not currently exist, the command will abort
1033 unless the -c flag is provided. If the remote branch is not a direct
1034 descendent of the local branch, the command will abort unless the -f
1035 flag is provided. Forcing a push in this way can be problematic to
1036 other users of the repository if they are not expecting it.
1038 To delete a branch on the remote repository, use the -d flag.
1039 """)
1040 @takes_options("cdf")
1041 def cmd_push(self, repo=None, rhs=None, **flags):
1042 "[-c | -d] <repo>"
1043 self._check_git()
1044 if '-c' in flags and '-d' in flags:
1045 raise TypeError
1047 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
1048 raise YapError("No such repository: %s" % repo)
1050 current = get_output("git symbolic-ref HEAD")
1051 if not current:
1052 raise YapError("Not on a branch!")
1054 self._check_rebasing()
1056 current = current[0].replace('refs/heads/', '')
1057 remote = get_output("git config branch.%s.remote" % current)
1058 if repo is None and remote:
1059 repo = remote[0]
1061 if repo is None:
1062 raise YapError("No tracking branch configured; specify destination repository")
1064 if rhs is None and remote and remote[0] == repo:
1065 merge = get_output("git config branch.%s.merge" % current)
1066 if merge:
1067 rhs = merge[0]
1069 if rhs is None:
1070 rhs = "refs/heads/%s" % current
1072 if '-c' not in flags and '-d' not in flags:
1073 if run_command("git rev-parse --verify refs/remotes/%s/%s"
1074 % (repo, rhs.replace('refs/heads/', ''))):
1075 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
1076 if '-f' not in flags:
1077 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo, rhs.replace('refs/heads/', '')))
1078 base = get_output("git merge-base HEAD %s" % hash[0])
1079 assert base
1080 if base[0] != hash[0]:
1081 raise YapError("Branch not up-to-date with remote. Update or use -f")
1083 self._confirm_push(current, rhs, repo)
1084 if '-f' in flags:
1085 flags['-f'] = '-f'
1087 if '-d' in flags:
1088 lhs = ""
1089 else:
1090 lhs = "refs/heads/%s" % current
1091 rc = os.system("git push %s %s %s:%s" % (flags.get('-f', ''), repo, lhs, rhs))
1092 if rc:
1093 raise YapError("Push failed.")
1095 @short_help("retrieve commits from a remote repository")
1096 @long_help("""
1097 When run with no arguments, the command will retrieve new commits from
1098 the remote tracking repository. Note that this does not in any way
1099 alter the current branch. For that, see "update". If a remote other
1100 than the tracking remote is desired, it can be specified as the first
1101 argument.
1102 """)
1103 def cmd_fetch(self, repo=None):
1104 "<repo>"
1105 self._check_git()
1106 current = get_output("git symbolic-ref HEAD")
1107 if not current:
1108 raise YapError("Not on a branch!")
1110 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
1111 raise YapError("No such repository: %s" % repo)
1112 if repo is None:
1113 current = current[0].replace('refs/heads/', '')
1114 remote = get_output("git config branch.%s.remote" % current)
1115 if remote:
1116 repo = remote[0]
1117 if repo is None:
1118 raise YapError("No tracking branch configured; specify a repository")
1119 os.system("git fetch %s" % repo)
1121 @short_help("update the current branch relative to its tracking branch")
1122 @long_help("""
1123 Updates the current branch relative to its remote tracking branch. This
1124 command requires that the current branch have a remote tracking branch
1125 configured. If any conflicts occur while applying your changes to the
1126 updated remote, the command will pause to allow you to fix them. Once
1127 that is done, run "update" with the "continue" subcommand. Alternately,
1128 the "skip" subcommand can be used to discard the conflicting changes.
1129 """)
1130 def cmd_update(self, subcmd=None):
1131 "[continue | skip]"
1132 self._check_git()
1133 if subcmd and subcmd not in ["continue", "skip"]:
1134 raise TypeError
1136 resolvemsg = """
1137 When you have resolved the conflicts run \"yap update continue\".
1138 To skip the problematic patch, run \"yap update skip\"."""
1140 if subcmd == "continue":
1141 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
1142 return
1143 if subcmd == "skip":
1144 os.system("git reset --hard")
1145 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
1146 return
1148 self._check_rebasing()
1149 if self._get_unstaged_files() or self._get_staged_files():
1150 raise YapError("You have uncommitted changes. Commit them first")
1152 current = get_output("git symbolic-ref HEAD")
1153 if not current:
1154 raise YapError("Not on a branch!")
1156 current = current[0].replace('refs/heads/', '')
1157 remote, merge = self._get_tracking(current)
1158 merge = merge.replace('refs/heads/', '')
1160 self.cmd_fetch(remote)
1161 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
1163 try:
1164 fd, tmpfile = tempfile.mkstemp("yap")
1165 os.close(fd)
1166 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
1167 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
1169 stat = os.stat(tmpfile)
1170 size = stat[6]
1171 if size > 0:
1172 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
1173 if (rc):
1174 raise YapError("Failed to apply changes")
1175 finally:
1176 os.unlink(tmpfile)
1178 @short_help("query and configure remote branch tracking")
1179 @long_help("""
1180 When invoked with no arguments, the command displays the tracking
1181 information for the current branch. To configure the tracking
1182 information, two arguments for the remote repository and remote branch
1183 are given. The tracking information is used to provide defaults for
1184 where to push local changes and from where to get updates to the branch.
1185 """)
1186 def cmd_track(self, repo=None, branch=None):
1187 "[<repo> <branch>]"
1188 self._check_git()
1190 current = get_output("git symbolic-ref HEAD")
1191 if not current:
1192 raise YapError("Not on a branch!")
1193 current = current[0].replace('refs/heads/', '')
1195 if repo is None and branch is None:
1196 repo, merge = self._get_tracking(current)
1197 merge = merge.replace('refs/heads/', '')
1198 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
1199 return
1201 if repo is None or branch is None:
1202 raise TypeError
1204 if repo not in [ x[0] for x in self._list_remotes() ]:
1205 raise YapError("No such repository: %s" % repo)
1207 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
1208 raise YapError("No such branch '%s' on repository '%s'" % (branch, repo))
1210 os.system("git config branch.%s.remote '%s'" % (current, repo))
1211 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
1212 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
1214 @short_help("mark files with conflicts as resolved")
1215 @long_help("""
1216 The arguments are the files to be marked resolved. When a conflict
1217 occurs while merging changes to a file, that file is marked as
1218 "unmerged." Until the file(s) with conflicts are marked resolved,
1219 commits cannot be made.
1220 """)
1221 def cmd_resolved(self, *files):
1222 "<file>..."
1223 self._check_git()
1224 if not files:
1225 raise TypeError
1227 for f in files:
1228 self._stage_one(f, True)
1229 self.cmd_status()
1231 @short_help("merge a branch into the current branch")
1232 def cmd_merge(self, branch):
1233 "<branch>"
1234 self._check_git()
1236 branch_name = branch
1237 branch = self._resolve_rev(branch)
1238 base = get_output("git merge-base HEAD %s" % branch)
1239 if not base:
1240 raise YapError("Branch '%s' is not a fork of the current branch"
1241 % branch)
1243 readtree = ("git read-tree --aggressive -u -m %s HEAD %s"
1244 % (base[0], branch))
1245 if run_command(readtree):
1246 run_command("git update-index --refresh")
1247 if os.system(readtree):
1248 raise YapError("Failed to merge")
1250 repo = get_output('git rev-parse --git-dir')[0]
1251 dir = os.path.join(repo, 'yap')
1252 try:
1253 os.mkdir(dir)
1254 except OSError:
1255 pass
1256 msg_file = os.path.join(dir, 'msg')
1257 msg = file(msg_file, 'w')
1258 print >>msg, "Merge branch '%s'" % branch_name
1259 msg.close()
1261 head = get_output("git rev-parse --verify HEAD")
1262 assert head
1263 heads = [head[0], branch]
1264 head_file = os.path.join(dir, 'merge')
1265 pickle.dump(heads, file(head_file, 'w'))
1267 self._merge_index(branch, base[0])
1268 if self._get_unmerged_files():
1269 self.cmd_status()
1270 raise YapError("Fix conflicts then commit")
1272 self._do_commit()
1274 def _merge_index(self, branch, base):
1275 for f in self._get_unmerged_files():
1276 fd, bfile = tempfile.mkstemp("yap")
1277 os.close(fd)
1278 rc = os.system("git show %s:%s > %s" % (base, f, bfile))
1279 assert rc == 0
1281 fd, ofile = tempfile.mkstemp("yap")
1282 os.close(fd)
1283 rc = os.system("git show %s:%s > %s" % (branch, f, ofile))
1284 assert rc == 0
1286 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)
1287 rc = os.system(command)
1288 os.unlink(ofile)
1289 os.unlink(bfile)
1291 assert rc >= 0
1292 if rc == 0:
1293 self._stage_one(f, True)
1295 def cmd_help(self, cmd=None):
1296 if cmd is not None:
1297 oldcmd = cmd
1298 cmd = "cmd_" + cmd.replace('-', '_')
1299 try:
1300 attr = self.__getattribute__(cmd)
1301 except AttributeError:
1302 raise YapError("No such command: %s" % cmd)
1304 help = self._get_attr(cmd, "long_help")
1305 if help is None:
1306 raise YapError("Sorry, no help for '%s'. Ask Steven." % oldcmd)
1308 print >>sys.stderr, "The '%s' command" % oldcmd
1309 doc = self._get_attr(cmd, "__doc__")
1310 if doc is None:
1311 doc = ""
1312 print >>sys.stderr, "\tyap %s %s" % (oldcmd, doc)
1313 print >>sys.stderr, "%s" % help
1314 return
1316 print >> sys.stderr, "Yet Another (Git) Porcelein"
1317 print >> sys.stderr
1319 for name in dir(self):
1320 if not name.startswith('cmd_'):
1321 continue
1322 attr = self.__getattribute__(name)
1323 if not callable(attr):
1324 continue
1326 short_msg = self._get_attr(name, "short_help")
1327 if short_msg is None:
1328 continue
1330 name = name.replace('cmd_', '')
1331 name = name.replace('_', '-')
1332 print >> sys.stderr, "%-16s%s" % (name, short_msg)
1334 print >> sys.stderr
1335 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
1337 @short_help("show information about loaded plugins")
1338 def cmd_plugins(self):
1340 print >> sys.stderr, "Loaded plugins:"
1341 plugins = load_plugins()
1342 for name, cls in plugins.items():
1343 print "\t%-16s: %s" % (name, cls.__doc__)
1344 if not plugins:
1345 print "\t%-16s" % "None"
1347 def cmd_usage(self):
1348 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
1349 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"
1351 def load_plugins():
1352 plugindir = os.path.join("~", ".yap", "plugins")
1353 plugindir = os.path.expanduser(plugindir)
1354 plugindir = os.path.join(plugindir, "*.py")
1356 plugins = dict()
1357 for p in glob.glob(os.path.expanduser(plugindir)):
1358 plugin = os.path.basename(p).replace('.py', '')
1359 m = __import__(plugin)
1360 for k in dir(m):
1361 cls = m.__dict__[k]
1362 if not type(cls) == type:
1363 continue
1364 if not issubclass(cls, YapCore):
1365 continue
1366 if cls is YapCore:
1367 continue
1368 plugins[k] = cls
1369 return plugins
1371 def yap_metaclass(name, bases, dct):
1372 plugindir = os.path.join("~", ".yap", "plugins")
1373 plugindir = os.path.expanduser(plugindir)
1374 sys.path.insert(0, plugindir)
1376 plugins = set(load_plugins().values())
1377 p2 = plugins.copy()
1378 for cls in plugins:
1379 p2 -= set(cls.__bases__)
1380 plugins = p2
1381 bases = list(plugins) + list(bases)
1382 return type(name, tuple(bases), dct)
1384 class Yap(YapCore):
1385 __metaclass__ = yap_metaclass
1387 def main(self, args):
1388 if len(args) < 1:
1389 self.cmd_usage()
1390 sys.exit(2)
1392 command = args[0]
1393 args = args[1:]
1395 if run_command("git --version"):
1396 print >>sys.stderr, "Failed to run git; is it installed?"
1397 sys.exit(1)
1399 debug = os.getenv('YAP_DEBUG')
1401 try:
1402 command = command.replace('-', '_')
1403 meth = self.__getattribute__("cmd_"+command)
1404 doc = self._get_attr("cmd_"+command, "__doc__")
1406 try:
1407 options = ""
1408 for c in self.__class__.__bases__:
1409 try:
1410 t = c.__dict__["cmd_"+command]
1411 except KeyError:
1412 continue
1413 if "options" in t.__dict__:
1414 options += t.options
1416 if options:
1417 try:
1418 flags, args = getopt.getopt(args, options)
1419 flags = dict(flags)
1420 except getopt.GetoptError, e:
1421 if debug:
1422 raise
1423 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1424 print e
1425 sys.exit(2)
1426 else:
1427 flags = dict()
1429 meth(*args, **flags)
1430 except (TypeError, getopt.GetoptError):
1431 if debug:
1432 raise
1433 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1434 except YapError, e:
1435 if debug:
1436 raise
1437 print >> sys.stderr, e
1438 sys.exit(1)
1439 except AttributeError:
1440 if debug:
1441 raise
1442 self.cmd_usage()
1443 sys.exit(2)