check_commit, cmd_switch: git apply needs to run from repo-root
[yap.git] / yap / yap.py
blob3f3a3d4aeadb7f88a5e522a1f3c9d31e519e9f34
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 for f in files:
543 self._unstage_one(f)
544 self.cmd_status()
546 @short_help("show files with staged and unstaged changes")
547 @long_help("""
548 Show the files in the repository with changes since the last commit,
549 categorized based on whether the changes are staged or not. A file may
550 appear under each heading if the same file has both staged and unstaged
551 changes.
552 """)
553 def cmd_status(self):
555 self._check_git()
556 branch = get_output("git symbolic-ref HEAD")
557 if branch:
558 branch = branch[0].replace('refs/heads/', '')
559 else:
560 branch = "DETACHED"
561 print "Current branch: %s" % branch
563 print "Files with staged changes:"
564 files = self._get_staged_files()
565 for f in files:
566 print "\t%s" % self._repo_path_to_rel(f)
567 if not files:
568 print "\t(none)"
570 print "Files with unstaged changes:"
571 files = self._get_unstaged_files()
572 for f in files:
573 print "\t%s" % self._repo_path_to_rel(f)
574 if not files:
575 print "\t(none)"
577 files = self._get_unmerged_files()
578 if files:
579 print "Files with conflicts:"
580 for f in files:
581 print "\t%s" % self._repo_path_to_rel(f)
583 @short_help("remove uncommitted changes from a file (*)")
584 @long_help("""
585 The arguments are the files whose changes will be reverted. If the '-a'
586 flag is given, then all files will have uncommitted changes removed.
587 Note that there is no way to reverse this command short of manually
588 editing each file again.
589 """)
590 @takes_options("a")
591 def cmd_revert(self, *files, **flags):
592 "(-a | <file>)"
593 self._check_git()
594 if '-a' in flags:
595 self._unstage_all()
596 run_safely("git checkout-index -u -f -a")
597 self._clear_state()
598 self.cmd_status()
599 return
601 if not files:
602 raise TypeError
604 for f in files:
605 self._revert_one(f)
606 self.cmd_status()
608 @short_help("record changes to files as a new commit")
609 @long_help("""
610 Create a new commit recording changes since the last commit. If there
611 are only unstaged changes, those will be recorded. If there are only
612 staged changes, those will be recorded. Otherwise, you will have to
613 specify either the '-a' flag or the '-d' flag to commit all changes or
614 only staged changes, respectively. To reverse the effects of this
615 command, see 'uncommit'.
616 """)
617 @takes_options("adm:")
618 def cmd_commit(self, **flags):
619 "[-a | -d] [-m <msg>]"
620 self._check_git()
621 self._check_rebasing()
622 self._check_commit(**flags)
623 if not self._get_staged_files():
624 raise YapError("No changes to commit")
625 msg = flags.get('-m', None)
626 self._do_commit(msg)
627 self.cmd_status()
629 @short_help("reverse the actions of the last commit")
630 @long_help("""
631 Reverse the effects of the last 'commit' operation. The changes that
632 were part of the previous commit will show as "staged changes" in the
633 output of 'status'. This means that if no files were changed since the
634 last commit was created, 'uncommit' followed by 'commit' is a lossless
635 operation.
636 """)
637 def cmd_uncommit(self):
639 self._check_git()
640 self._do_uncommit()
641 self.cmd_status()
643 @short_help("report the current version of yap")
644 def cmd_version(self):
645 print "Yap version %s" % self.version
647 @short_help("show the changelog for particular versions or files")
648 @long_help("""
649 The arguments are the files with which to filter history. If none are
650 given, all changes are listed. Otherwise only commits that affected one
651 or more of the given files are listed. The -r option changes the
652 starting revision for traversing history. By default, history is listed
653 starting at HEAD.
654 """)
655 @takes_options("pr:")
656 def cmd_log(self, *paths, **flags):
657 "[-p] [-r <rev>] <path>..."
658 self._check_git()
659 rev = flags.get('-r', 'HEAD')
660 rev = self._resolve_rev(rev)
661 paths = list(paths)
663 if '-p' in flags:
664 flags['-p'] = '-p'
666 try:
667 pager = os.popen(self._get_pager_cmd(), 'w')
668 rename = False
669 while True:
670 for hash in yield_output("git rev-list '%s' -- %s"
671 % (rev, ' '.join(paths))):
672 commit = get_output("git show -M -C %s %s"
673 % (flags.get('-p', '--name-status'), hash),
674 strip=False)
675 commit = self._filter_log(commit)
676 print >>pager, ''.join(commit)
678 # Check for renames
679 if len(paths) == 1:
680 src = self._check_rename(hash, paths[0])
681 if src is not None:
682 paths[0] = src
683 rename = True
684 rev = hash+"^"
685 break
686 if not rename:
687 break
688 rename = False
689 except (IOError, KeyboardInterrupt):
690 pass
692 @short_help("show staged, unstaged, or all uncommitted changes")
693 @long_help("""
694 Show staged, unstaged, or all uncommitted changes. By default, all
695 changes are shown. The '-u' flag causes only unstaged changes to be
696 shown. The '-d' flag causes only staged changes to be shown.
697 """)
698 @takes_options("ud")
699 def cmd_diff(self, **flags):
700 "[ -u | -d ]"
701 self._check_git()
702 if '-u' in flags and '-d' in flags:
703 raise YapError("Conflicting flags: -u and -d")
705 pager = self._get_pager_cmd()
707 if '-u' in flags:
708 os.system("git diff-files -p | %s" % pager)
709 elif '-d' in flags:
710 os.system("git diff-index --cached -p HEAD | %s" % pager)
711 else:
712 os.system("git diff-index -p HEAD | %s" % pager)
714 @short_help("list, create, or delete branches")
715 @long_help("""
716 If no arguments are specified, a list of local branches is given. The
717 current branch is indicated by a "*" next to the name. If an argument
718 is given, it is taken as the name of a new branch to create. The branch
719 will start pointing at the current HEAD. See 'point' for details on
720 changing the revision of the new branch. Note that this command does
721 not switch the current working branch. See 'switch' for details on
722 changing the current working branch.
724 The '-d' flag can be used to delete local branches. If the delete
725 operation would remove the last branch reference to a given line of
726 history (colloquially referred to as "dangling commits"), yap will
727 report an error and abort. The '-f' flag can be used to force the delete
728 in spite of this.
729 """)
730 @takes_options("fd:")
731 def cmd_branch(self, branch=None, **flags):
732 "[ [-f] -d <branch> | <branch> ]"
733 self._check_git()
734 force = '-f' in flags
735 if '-d' in flags:
736 self._delete_branch(flags['-d'], force)
737 self.cmd_branch()
738 return
740 if branch is not None:
741 ref = get_output("git rev-parse --verify HEAD")
742 if not ref:
743 raise YapError("No branch point yet. Make a commit")
744 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
746 current = get_output("git symbolic-ref HEAD")
747 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads'")
748 for b in branches:
749 if current and b == current[0]:
750 print "* ",
751 else:
752 print " ",
753 b = b.replace('refs/heads/', '')
754 print b
756 @short_help("change the current working branch")
757 @long_help("""
758 The argument is the name of the branch to make the current working
759 branch. This command will fail if there are uncommitted changes to any
760 files. Otherwise, the contents of the files in the working directory
761 are updated to reflect their state in the new branch. Additionally, any
762 future commits are added to the new branch instead of the previous line
763 of history.
764 """)
765 @takes_options("f")
766 def cmd_switch(self, branch, **flags):
767 "[-f] <branch>"
768 self._check_git()
769 self._check_rebasing()
770 ref = self._resolve_rev('refs/heads/'+branch)
772 if '-f' not in flags:
773 if (self._get_staged_files()
774 or (self._get_unstaged_files()
775 and run_command("git update-index --refresh"))):
776 raise YapError("You have uncommitted changes. Use -f to continue anyway")
778 if self._get_unstaged_files() and self._get_staged_files():
779 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
781 staged = bool(self._get_staged_files())
783 cdup = self._get_cdup()
784 run_command("git diff-files -p | (cd %s; git apply --cached)" % cdup)
785 for f in self._get_new_files():
786 self._stage_one(f)
788 idx = get_output("git write-tree")
789 new = self._resolve_rev('refs/heads/'+branch)
791 run_command("git update-index --refresh")
792 readtree = "git read-tree -v --aggressive -u -m HEAD %s %s" % (idx[0], new)
793 if os.system(readtree):
794 raise YapError("Failed to switch")
795 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
797 if '-f' not in flags:
798 self._clear_state()
800 if not staged:
801 self._unstage_all()
802 self.cmd_status()
804 @short_help("move the current branch to a different revision")
805 @long_help("""
806 The argument is the hash of the commit to which the current branch
807 should point, or alternately a branch or tag (a.k.a, "committish"). If
808 moving the branch would create "dangling commits" (see 'branch'), yap
809 will report an error and abort. The '-f' flag can be used to force the
810 operation in spite of this.
811 """)
812 @takes_options("f")
813 def cmd_point(self, where, **flags):
814 "[-f] <where>"
815 self._check_git()
816 self._check_rebasing()
818 head = get_output("git rev-parse --verify HEAD")
819 if not head:
820 raise YapError("No commit yet; nowhere to point")
822 ref = self._resolve_rev(where)
823 ref = get_output("git rev-parse --verify '%s^{commit}'" % ref)
824 if not ref:
825 raise YapError("Not a commit: %s" % where)
827 if self._get_unstaged_files() or self._get_staged_files():
828 raise YapError("You have uncommitted changes. Commit them first")
830 run_safely("git update-ref HEAD '%s'" % ref[0])
832 if '-f' not in flags:
833 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
834 if name == "undefined":
835 os.system("git update-ref HEAD '%s'" % head[0])
836 raise YapError("Pointing there will lose commits. Use -f to force")
838 run_command("git update-index --refresh")
839 rc = os.system("git read-tree -v --reset -u HEAD")
840 if rc:
841 raise YapError("checkout-index failed")
842 self._clear_state()
844 @short_help("alter history by dropping or amending commits")
845 @long_help("""
846 This command operates in two distinct modes, "amend" and "drop" mode.
847 In drop mode, the given commit is removed from the history of the
848 current branch, as though that commit never happened. By default the
849 commit used is HEAD.
851 In amend mode, the uncommitted changes present are merged into a
852 previous commit. This is useful for correcting typos or adding missed
853 files into past commits. By default the commit used is HEAD.
855 While rewriting history it is possible that conflicts will arise. If
856 this happens, the rewrite will pause and you will be prompted to resolve
857 the conflicts and stage them. Once that is done, you will run "yap
858 history continue." If instead you want the conflicting commit removed
859 from history (perhaps your changes supercede that commit) you can run
860 "yap history skip". Once the rewrite completes, your branch will be on
861 the same commit as when the rewrite started.
862 """)
863 def cmd_history(self, subcmd, *args):
864 "amend | drop <commit>"
865 self._check_git()
867 if subcmd not in ("amend", "drop", "continue", "skip"):
868 raise TypeError
870 resolvemsg = """
871 When you have resolved the conflicts run \"yap history continue\".
872 To skip the problematic patch, run \"yap history skip\"."""
874 if subcmd == "continue":
875 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
876 return
877 if subcmd == "skip":
878 os.system("git reset --hard")
879 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
880 return
882 if subcmd == "amend":
883 flags, args = getopt.getopt(args, "ad")
884 flags = dict(flags)
886 if len(args) > 1:
887 raise TypeError
888 if args:
889 commit = args[0]
890 else:
891 commit = "HEAD"
893 self._resolve_rev(commit)
894 self._check_rebasing()
896 if subcmd == "amend":
897 self._check_commit(**flags)
898 if self._get_unstaged_files():
899 # XXX: handle unstaged changes better
900 raise YapError("Commit away changes that you aren't amending")
902 self._unstage_all()
904 start = get_output("git rev-parse HEAD")
905 stash = get_output("git stash create")
906 run_command("git reset --hard")
907 try:
908 fd, tmpfile = tempfile.mkstemp("yap")
909 try:
910 try:
911 os.close(fd)
912 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
913 if subcmd == "amend":
914 self.cmd_point(commit, **{'-f': True})
915 finally:
916 if subcmd == "amend":
917 if stash:
918 rc = os.system("git stash apply %s" % stash[0])
919 if rc:
920 self.cmd_point(start[0], **{'-f': True})
921 os.system("git stash apply %s" % stash[0])
922 raise YapError("Failed to apply stash")
923 stash = None
925 if subcmd == "amend":
926 self._do_uncommit()
927 self._check_commit(**{'-a': True})
928 self._do_commit()
929 else:
930 self.cmd_point("%s^" % commit, **{'-f': True})
932 stat = os.stat(tmpfile)
933 size = stat[6]
934 if size > 0:
935 run_safely("git update-index --refresh")
936 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
937 if (rc):
938 raise YapError("Failed to apply changes")
939 finally:
940 os.unlink(tmpfile)
941 finally:
942 if stash:
943 run_command("git stash apply %s" % stash[0])
944 self.cmd_status()
946 @short_help("show the changes introduced by a given commit")
947 @long_help("""
948 By default, the changes in the last commit are shown. To override this,
949 specify a hash, branch, or tag (committish). The hash of the commit,
950 the commit's author, log message, and a diff of the changes are shown.
951 """)
952 def cmd_show(self, commit="HEAD"):
953 "[commit]"
954 self._check_git()
955 commit = self._resolve_rev(commit)
956 os.system("git show '%s'" % commit)
958 @short_help("apply the changes in a given commit to the current branch")
959 @long_help("""
960 The argument is the hash, branch, or tag (committish) of the commit to
961 be applied. In general, it only makes sense to apply commits that
962 happened on another branch. The '-r' flag can be used to have the
963 changes in the given commit reversed from the current branch. In
964 general, this only makes sense for commits that happened on the current
965 branch.
966 """)
967 @takes_options("r")
968 def cmd_cherry_pick(self, commit, **flags):
969 "[-r] <commit>"
970 self._check_git()
971 commit = self._resolve_rev(commit)
972 if '-r' in flags:
973 os.system("git revert '%s'" % commit)
974 else:
975 os.system("git cherry-pick '%s'" % commit)
977 @short_help("list, add, or delete configured remote repositories")
978 @long_help("""
979 When invoked with no arguments, this command will show the list of
980 currently configured remote repositories, giving both the name and URL
981 of each. To add a new repository, give the desired name as the first
982 argument and the URL as the second. The '-d' flag can be used to remove
983 a previously added repository.
984 """)
985 @takes_options("d:")
986 def cmd_repo(self, name=None, url=None, **flags):
987 "[<name> <url> | -d <name>]"
988 self._check_git()
989 if name is not None and url is None:
990 raise TypeError
992 if '-d' in flags:
993 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
994 raise YapError("No such repository: %s" % flags['-d'])
995 os.system("git config --unset remote.%s.url" % flags['-d'])
996 os.system("git config --unset remote.%s.fetch" % flags['-d'])
997 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin'"):
998 hash = get_output("git rev-parse %s" % b)
999 assert hash
1000 run_safely("git update-ref -d %s %s" % (b, hash[0]))
1002 if name:
1003 if name in [ x[0] for x in self._list_remotes() ]:
1004 raise YapError("Repository '%s' already exists" % name)
1005 os.system("git config remote.%s.url %s" % (name, url))
1006 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
1008 for remote, url in self._list_remotes():
1009 print "%s" % remote
1010 print " URL: %s" % url
1011 first = True
1012 for b in get_output("git for-each-ref --format='%%(refname)' 'refs/remotes/%s'" % remote):
1013 b = b.replace('refs/remotes/', '')
1014 if first:
1015 branches = "Branches: "
1016 else:
1017 branches = " "
1018 print " %s%s" % (branches, b)
1019 first = False
1021 @short_help("send local commits to a remote repository (*)")
1022 @long_help("""
1023 When invoked with no arguments, the current branch is synchronized to
1024 the tracking branch of the tracking remote. If no tracking remote is
1025 specified, the repository will have to be specified on the command line.
1026 In that case, the default is to push to a branch with the same name as
1027 the current branch. This behavior can be overridden by giving a second
1028 argument to specify the remote branch.
1030 If the remote branch does not currently exist, the command will abort
1031 unless the -c flag is provided. If the remote branch is not a direct
1032 descendent of the local branch, the command will abort unless the -f
1033 flag is provided. Forcing a push in this way can be problematic to
1034 other users of the repository if they are not expecting it.
1036 To delete a branch on the remote repository, use the -d flag.
1037 """)
1038 @takes_options("cdf")
1039 def cmd_push(self, repo=None, rhs=None, **flags):
1040 "[-c | -d] <repo>"
1041 self._check_git()
1042 if '-c' in flags and '-d' in flags:
1043 raise TypeError
1045 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
1046 raise YapError("No such repository: %s" % repo)
1048 current = get_output("git symbolic-ref HEAD")
1049 if not current:
1050 raise YapError("Not on a branch!")
1052 self._check_rebasing()
1054 current = current[0].replace('refs/heads/', '')
1055 remote = get_output("git config branch.%s.remote" % current)
1056 if repo is None and remote:
1057 repo = remote[0]
1059 if repo is None:
1060 raise YapError("No tracking branch configured; specify destination repository")
1062 if rhs is None and remote and remote[0] == repo:
1063 merge = get_output("git config branch.%s.merge" % current)
1064 if merge:
1065 rhs = merge[0]
1067 if rhs is None:
1068 rhs = "refs/heads/%s" % current
1070 if '-c' not in flags and '-d' not in flags:
1071 if run_command("git rev-parse --verify refs/remotes/%s/%s"
1072 % (repo, rhs.replace('refs/heads/', ''))):
1073 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
1074 if '-f' not in flags:
1075 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo, rhs.replace('refs/heads/', '')))
1076 base = get_output("git merge-base HEAD %s" % hash[0])
1077 assert base
1078 if base[0] != hash[0]:
1079 raise YapError("Branch not up-to-date with remote. Update or use -f")
1081 self._confirm_push(current, rhs, repo)
1082 if '-f' in flags:
1083 flags['-f'] = '-f'
1085 if '-d' in flags:
1086 lhs = ""
1087 else:
1088 lhs = "refs/heads/%s" % current
1089 rc = os.system("git push %s %s %s:%s" % (flags.get('-f', ''), repo, lhs, rhs))
1090 if rc:
1091 raise YapError("Push failed.")
1093 @short_help("retrieve commits from a remote repository")
1094 @long_help("""
1095 When run with no arguments, the command will retrieve new commits from
1096 the remote tracking repository. Note that this does not in any way
1097 alter the current branch. For that, see "update". If a remote other
1098 than the tracking remote is desired, it can be specified as the first
1099 argument.
1100 """)
1101 def cmd_fetch(self, repo=None):
1102 "<repo>"
1103 self._check_git()
1104 current = get_output("git symbolic-ref HEAD")
1105 if not current:
1106 raise YapError("Not on a branch!")
1108 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
1109 raise YapError("No such repository: %s" % repo)
1110 if repo is None:
1111 current = current[0].replace('refs/heads/', '')
1112 remote = get_output("git config branch.%s.remote" % current)
1113 if remote:
1114 repo = remote[0]
1115 if repo is None:
1116 raise YapError("No tracking branch configured; specify a repository")
1117 os.system("git fetch %s" % repo)
1119 @short_help("update the current branch relative to its tracking branch")
1120 @long_help("""
1121 Updates the current branch relative to its remote tracking branch. This
1122 command requires that the current branch have a remote tracking branch
1123 configured. If any conflicts occur while applying your changes to the
1124 updated remote, the command will pause to allow you to fix them. Once
1125 that is done, run "update" with the "continue" subcommand. Alternately,
1126 the "skip" subcommand can be used to discard the conflicting changes.
1127 """)
1128 def cmd_update(self, subcmd=None):
1129 "[continue | skip]"
1130 self._check_git()
1131 if subcmd and subcmd not in ["continue", "skip"]:
1132 raise TypeError
1134 resolvemsg = """
1135 When you have resolved the conflicts run \"yap update continue\".
1136 To skip the problematic patch, run \"yap update skip\"."""
1138 if subcmd == "continue":
1139 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
1140 return
1141 if subcmd == "skip":
1142 os.system("git reset --hard")
1143 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
1144 return
1146 self._check_rebasing()
1147 if self._get_unstaged_files() or self._get_staged_files():
1148 raise YapError("You have uncommitted changes. Commit them first")
1150 current = get_output("git symbolic-ref HEAD")
1151 if not current:
1152 raise YapError("Not on a branch!")
1154 current = current[0].replace('refs/heads/', '')
1155 remote, merge = self._get_tracking(current)
1156 merge = merge.replace('refs/heads/', '')
1158 self.cmd_fetch(remote)
1159 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
1161 try:
1162 fd, tmpfile = tempfile.mkstemp("yap")
1163 os.close(fd)
1164 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
1165 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
1167 stat = os.stat(tmpfile)
1168 size = stat[6]
1169 if size > 0:
1170 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
1171 if (rc):
1172 raise YapError("Failed to apply changes")
1173 finally:
1174 os.unlink(tmpfile)
1176 @short_help("query and configure remote branch tracking")
1177 @long_help("""
1178 When invoked with no arguments, the command displays the tracking
1179 information for the current branch. To configure the tracking
1180 information, two arguments for the remote repository and remote branch
1181 are given. The tracking information is used to provide defaults for
1182 where to push local changes and from where to get updates to the branch.
1183 """)
1184 def cmd_track(self, repo=None, branch=None):
1185 "[<repo> <branch>]"
1186 self._check_git()
1188 current = get_output("git symbolic-ref HEAD")
1189 if not current:
1190 raise YapError("Not on a branch!")
1191 current = current[0].replace('refs/heads/', '')
1193 if repo is None and branch is None:
1194 repo, merge = self._get_tracking(current)
1195 merge = merge.replace('refs/heads/', '')
1196 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
1197 return
1199 if repo is None or branch is None:
1200 raise TypeError
1202 if repo not in [ x[0] for x in self._list_remotes() ]:
1203 raise YapError("No such repository: %s" % repo)
1205 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
1206 raise YapError("No such branch '%s' on repository '%s'" % (branch, repo))
1208 os.system("git config branch.%s.remote '%s'" % (current, repo))
1209 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
1210 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
1212 @short_help("mark files with conflicts as resolved")
1213 @long_help("""
1214 The arguments are the files to be marked resolved. When a conflict
1215 occurs while merging changes to a file, that file is marked as
1216 "unmerged." Until the file(s) with conflicts are marked resolved,
1217 commits cannot be made.
1218 """)
1219 def cmd_resolved(self, *files):
1220 "<file>..."
1221 self._check_git()
1222 if not files:
1223 raise TypeError
1225 for f in files:
1226 self._stage_one(f, True)
1227 self.cmd_status()
1229 @short_help("merge a branch into the current branch")
1230 def cmd_merge(self, branch):
1231 "<branch>"
1232 self._check_git()
1234 branch_name = branch
1235 branch = self._resolve_rev(branch)
1236 base = get_output("git merge-base HEAD %s" % branch)
1237 if not base:
1238 raise YapError("Branch '%s' is not a fork of the current branch"
1239 % branch)
1241 readtree = ("git read-tree --aggressive -u -m %s HEAD %s"
1242 % (base[0], branch))
1243 if run_command(readtree):
1244 run_command("git update-index --refresh")
1245 if os.system(readtree):
1246 raise YapError("Failed to merge")
1248 repo = get_output('git rev-parse --git-dir')[0]
1249 dir = os.path.join(repo, 'yap')
1250 try:
1251 os.mkdir(dir)
1252 except OSError:
1253 pass
1254 msg_file = os.path.join(dir, 'msg')
1255 msg = file(msg_file, 'w')
1256 print >>msg, "Merge branch '%s'" % branch_name
1257 msg.close()
1259 head = get_output("git rev-parse --verify HEAD")
1260 assert head
1261 heads = [head[0], branch]
1262 head_file = os.path.join(dir, 'merge')
1263 pickle.dump(heads, file(head_file, 'w'))
1265 self._merge_index(branch, base[0])
1266 if self._get_unmerged_files():
1267 self.cmd_status()
1268 raise YapError("Fix conflicts then commit")
1270 self._do_commit()
1272 def _merge_index(self, branch, base):
1273 for f in self._get_unmerged_files():
1274 fd, bfile = tempfile.mkstemp("yap")
1275 os.close(fd)
1276 rc = os.system("git show %s:%s > %s" % (base, f, bfile))
1277 assert rc == 0
1279 fd, ofile = tempfile.mkstemp("yap")
1280 os.close(fd)
1281 rc = os.system("git show %s:%s > %s" % (branch, f, ofile))
1282 assert rc == 0
1284 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)
1285 rc = os.system(command)
1286 os.unlink(ofile)
1287 os.unlink(bfile)
1289 assert rc >= 0
1290 if rc == 0:
1291 self._stage_one(f, True)
1293 def cmd_help(self, cmd=None):
1294 if cmd is not None:
1295 oldcmd = cmd
1296 cmd = "cmd_" + cmd.replace('-', '_')
1297 try:
1298 attr = self.__getattribute__(cmd)
1299 except AttributeError:
1300 raise YapError("No such command: %s" % cmd)
1302 help = self._get_attr(cmd, "long_help")
1303 if help is None:
1304 raise YapError("Sorry, no help for '%s'. Ask Steven." % oldcmd)
1306 print >>sys.stderr, "The '%s' command" % oldcmd
1307 doc = self._get_attr(cmd, "__doc__")
1308 if doc is None:
1309 doc = ""
1310 print >>sys.stderr, "\tyap %s %s" % (oldcmd, doc)
1311 print >>sys.stderr, "%s" % help
1312 return
1314 print >> sys.stderr, "Yet Another (Git) Porcelein"
1315 print >> sys.stderr
1317 for name in dir(self):
1318 if not name.startswith('cmd_'):
1319 continue
1320 attr = self.__getattribute__(name)
1321 if not callable(attr):
1322 continue
1324 short_msg = self._get_attr(name, "short_help")
1325 if short_msg is None:
1326 continue
1328 name = name.replace('cmd_', '')
1329 name = name.replace('_', '-')
1330 print >> sys.stderr, "%-16s%s" % (name, short_msg)
1332 print >> sys.stderr
1333 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
1335 @short_help("show information about loaded plugins")
1336 def cmd_plugins(self):
1338 print >> sys.stderr, "Loaded plugins:"
1339 plugins = load_plugins()
1340 for name, cls in plugins.items():
1341 print "\t%-16s: %s" % (name, cls.__doc__)
1342 if not plugins:
1343 print "\t%-16s" % "None"
1345 def cmd_usage(self):
1346 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
1347 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"
1349 def load_plugins():
1350 plugindir = os.path.join("~", ".yap", "plugins")
1351 plugindir = os.path.expanduser(plugindir)
1352 plugindir = os.path.join(plugindir, "*.py")
1354 plugins = dict()
1355 for p in glob.glob(os.path.expanduser(plugindir)):
1356 plugin = os.path.basename(p).replace('.py', '')
1357 m = __import__(plugin)
1358 for k in dir(m):
1359 cls = m.__dict__[k]
1360 if not type(cls) == type:
1361 continue
1362 if not issubclass(cls, YapCore):
1363 continue
1364 if cls is YapCore:
1365 continue
1366 plugins[k] = cls
1367 return plugins
1369 def yap_metaclass(name, bases, dct):
1370 plugindir = os.path.join("~", ".yap", "plugins")
1371 plugindir = os.path.expanduser(plugindir)
1372 sys.path.insert(0, plugindir)
1374 plugins = set(load_plugins().values())
1375 p2 = plugins.copy()
1376 for cls in plugins:
1377 p2 -= set(cls.__bases__)
1378 plugins = p2
1379 bases = list(plugins) + list(bases)
1380 return type(name, tuple(bases), dct)
1382 class Yap(YapCore):
1383 __metaclass__ = yap_metaclass
1385 def main(self, args):
1386 if len(args) < 1:
1387 self.cmd_usage()
1388 sys.exit(2)
1390 command = args[0]
1391 args = args[1:]
1393 if run_command("git --version"):
1394 print >>sys.stderr, "Failed to run git; is it installed?"
1395 sys.exit(1)
1397 debug = os.getenv('YAP_DEBUG')
1399 try:
1400 command = command.replace('-', '_')
1401 meth = self.__getattribute__("cmd_"+command)
1402 doc = self._get_attr("cmd_"+command, "__doc__")
1404 try:
1405 options = ""
1406 for c in self.__class__.__bases__:
1407 try:
1408 t = c.__dict__["cmd_"+command]
1409 except KeyError:
1410 continue
1411 if "options" in t.__dict__:
1412 options += t.options
1414 if options:
1415 try:
1416 flags, args = getopt.getopt(args, options)
1417 flags = dict(flags)
1418 except getopt.GetoptError, e:
1419 if debug:
1420 raise
1421 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1422 print e
1423 sys.exit(2)
1424 else:
1425 flags = dict()
1427 meth(*args, **flags)
1428 except (TypeError, getopt.GetoptError):
1429 if debug:
1430 raise
1431 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1432 except YapError, e:
1433 if debug:
1434 raise
1435 print >> sys.stderr, e
1436 sys.exit(1)
1437 except AttributeError:
1438 if debug:
1439 raise
1440 self.cmd_usage()
1441 sys.exit(2)