Clear out awkward manual inheritance system
[yap.git] / yap / yap.py
blob543ad62be34f7739763f2e02b8a316b6913f3f13
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 Yap(object):
26 def __init__(self):
27 pass
29 def _add_new_file(self, file):
30 repo = get_output('git rev-parse --git-dir')[0]
31 dir = os.path.join(repo, 'yap')
32 try:
33 os.mkdir(dir)
34 except OSError:
35 pass
36 files = self._get_new_files()
37 files.append(file)
38 path = os.path.join(dir, 'new-files')
39 pickle.dump(files, open(path, 'w'))
41 def _get_new_files(self):
42 repo = get_output('git rev-parse --git-dir')[0]
43 path = os.path.join(repo, 'yap', 'new-files')
44 try:
45 files = pickle.load(file(path))
46 except IOError:
47 files = []
49 x = []
50 for f in files:
51 # if f in the index
52 if get_output("git ls-files --cached '%s'" % f) != []:
53 continue
54 x.append(f)
55 return x
57 def _remove_new_file(self, file):
58 files = self._get_new_files()
59 files = filter(lambda x: x != file, files)
61 repo = get_output('git rev-parse --git-dir')[0]
62 path = os.path.join(repo, 'yap', 'new-files')
63 try:
64 pickle.dump(files, open(path, 'w'))
65 except IOError:
66 pass
68 def _clear_new_files(self):
69 repo = get_output('git rev-parse --git-dir')[0]
70 path = os.path.join(repo, 'yap', 'new-files')
71 os.unlink(path)
73 def _assert_file_exists(self, file):
74 if not os.access(file, os.R_OK):
75 raise YapError("No such file: %s" % file)
77 def _get_staged_files(self):
78 if run_command("git rev-parse HEAD"):
79 files = get_output("git ls-files --cached")
80 else:
81 files = get_output("git diff-index --cached --name-only HEAD")
82 unmerged = self._get_unmerged_files()
83 if unmerged:
84 unmerged = set(unmerged)
85 files = set(files).difference(unmerged)
86 files = list(files)
87 return files
89 def _get_unstaged_files(self):
90 files = get_output("git ls-files -m")
91 prefix = get_output("git rev-parse --show-prefix")
92 if prefix:
93 files = [ os.path.join(prefix[0], x) for x in files ]
94 files += self._get_new_files()
95 unmerged = self._get_unmerged_files()
96 if unmerged:
97 unmerged = set(unmerged)
98 files = set(files).difference(unmerged)
99 files = list(files)
100 return files
102 def _get_unmerged_files(self):
103 files = get_output("git ls-files -u")
104 files = [ x.replace('\t', ' ').split(' ')[3] for x in files ]
105 prefix = get_output("git rev-parse --show-prefix")
106 if prefix:
107 files = [ os.path.join(prefix[0], x) for x in files ]
108 return list(set(files))
110 def _delete_branch(self, branch, force):
111 current = get_output("git symbolic-ref HEAD")
112 if current:
113 current = current[0].replace('refs/heads/', '')
114 if branch == current:
115 raise YapError("Can't delete current branch")
117 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
118 if not ref:
119 raise YapError("No such branch: %s" % branch)
120 run_safely("git update-ref -d 'refs/heads/%s' '%s'" % (branch, ref[0]))
122 if not force:
123 name = get_output("git name-rev --name-only '%s'" % ref[0])[0]
124 if name == 'undefined':
125 run_command("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
126 raise YapError("Refusing to delete leaf branch (use -f to force)")
127 def _get_pager_cmd(self):
128 if 'YAP_PAGER' in os.environ:
129 return os.environ['YAP_PAGER']
130 elif 'GIT_PAGER' in os.environ:
131 return os.environ['GIT_PAGER']
132 elif 'PAGER' in os.environ:
133 return os.environ['PAGER']
134 else:
135 return "more"
137 def _add_one(self, file):
138 self._assert_file_exists(file)
139 x = get_output("git ls-files '%s'" % file)
140 if x != []:
141 raise YapError("File '%s' already in repository" % file)
142 self._add_new_file(file)
144 def _rm_one(self, file):
145 self._assert_file_exists(file)
146 if get_output("git ls-files '%s'" % file) != []:
147 run_safely("git rm --cached '%s'" % file)
148 self._remove_new_file(file)
150 def _stage_one(self, file, allow_unmerged=False):
151 self._assert_file_exists(file)
152 prefix = get_output("git rev-parse --show-prefix")
153 if prefix:
154 tmp = os.path.normpath(os.path.join(prefix[0], file))
155 else:
156 tmp = file
157 if not allow_unmerged and tmp in self._get_unmerged_files():
158 raise YapError("Refusing to stage conflicted file: %s" % file)
159 run_safely("git update-index --add '%s'" % file)
161 def _unstage_one(self, file):
162 self._assert_file_exists(file)
163 if run_command("git rev-parse HEAD"):
164 rc = run_command("git update-index --force-remove '%s'" % file)
165 else:
166 rc = run_command("git diff-index -p HEAD '%s' | git apply -R --cached" % file)
167 if rc:
168 raise YapError("Failed to unstage")
170 def _revert_one(self, file):
171 self._assert_file_exists(file)
172 try:
173 self._unstage_one(file)
174 except YapError:
175 pass
176 run_safely("git checkout-index -u -f '%s'" % file)
178 def _parse_commit(self, commit):
179 lines = get_output("git cat-file commit '%s'" % commit)
180 commit = {}
182 mode = None
183 for l in lines:
184 if mode != 'commit' and l.strip() == "":
185 mode = 'commit'
186 commit['log'] = []
187 continue
188 if mode == 'commit':
189 commit['log'].append(l)
190 continue
192 x = l.split(' ')
193 k = x[0]
194 v = ' '.join(x[1:])
195 commit[k] = v
196 commit['log'] = '\n'.join(commit['log'])
197 return commit
199 def _check_commit(self, **flags):
200 if '-a' in flags and '-d' in flags:
201 raise YapError("Conflicting flags: -a and -d")
203 if '-d' not in flags and self._get_unstaged_files():
204 if '-a' not in flags and self._get_staged_files():
205 raise YapError("Staged and unstaged changes present. Specify what to commit")
206 os.system("git diff-files -p | git apply --cached")
207 for f in self._get_new_files():
208 self._stage_one(f)
210 def _do_uncommit(self):
211 commit = self._parse_commit("HEAD")
212 repo = get_output('git rev-parse --git-dir')[0]
213 dir = os.path.join(repo, 'yap')
214 try:
215 os.mkdir(dir)
216 except OSError:
217 pass
218 msg_file = os.path.join(dir, 'msg')
219 fd = file(msg_file, 'w')
220 print >>fd, commit['log']
221 fd.close()
223 tree = get_output("git rev-parse --verify HEAD^")
224 run_safely("git update-ref -m uncommit HEAD '%s'" % tree[0])
226 def _do_commit(self, msg=None):
227 tree = get_output("git write-tree")[0]
228 parent = get_output("git rev-parse --verify HEAD 2> /dev/null")
230 if os.environ.has_key('YAP_EDITOR'):
231 editor = os.environ['YAP_EDITOR']
232 elif os.environ.has_key('GIT_EDITOR'):
233 editor = os.environ['GIT_EDITOR']
234 elif os.environ.has_key('EDITOR'):
235 editor = os.environ['EDITOR']
236 else:
237 editor = "vi"
239 fd, tmpfile = tempfile.mkstemp("yap")
240 os.close(fd)
243 if msg is None:
244 repo = get_output('git rev-parse --git-dir')[0]
245 msg_file = os.path.join(repo, 'yap', 'msg')
246 if os.access(msg_file, os.R_OK):
247 fd1 = file(msg_file)
248 fd2 = file(tmpfile, 'w')
249 for l in fd1.xreadlines():
250 print >>fd2, l.strip()
251 fd2.close()
252 os.unlink(msg_file)
253 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
254 raise YapError("Editing commit message failed")
255 fd = file(tmpfile)
256 msg = fd.readlines()
257 msg = ''.join(msg)
259 msg = msg.strip()
260 if not msg:
261 raise YapError("Refusing to use empty commit message")
263 (fd_w, fd_r) = os.popen2("git stripspace > %s" % tmpfile)
264 print >>fd_w, msg,
265 fd_w.close()
266 fd_r.close()
268 if parent:
269 commit = get_output("git commit-tree '%s' -p '%s' < '%s'" % (tree, parent[0], tmpfile))
270 else:
271 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
273 os.unlink(tmpfile)
274 run_safely("git update-ref HEAD '%s'" % commit[0])
276 def _check_rebasing(self):
277 repo = get_output('git rev-parse --git-dir')[0]
278 dotest = os.path.join(repo, '.dotest')
279 if os.access(dotest, os.R_OK):
280 raise YapError("A git operation is in progress. Complete it first")
281 dotest = os.path.join(repo, '..', '.dotest')
282 if os.access(dotest, os.R_OK):
283 raise YapError("A git operation is in progress. Complete it first")
285 def _check_git(self):
286 if run_command("git rev-parse --git-dir"):
287 raise YapError("That command must be run from inside a git repository")
289 def _list_remotes(self):
290 remotes = get_output("git config --get-regexp '^remote.*.url'")
291 for x in remotes:
292 remote, url = x.split(' ')
293 remote = remote.replace('remote.', '')
294 remote = remote.replace('.url', '')
295 yield remote, url
297 def _unstage_all(self):
298 try:
299 run_safely("git read-tree -m HEAD")
300 except ShellError:
301 run_safely("git read-tree HEAD")
302 run_safely("git update-index -q --refresh")
304 def _get_tracking(self, current):
305 remote = get_output("git config branch.%s.remote" % current)
306 if not remote:
307 raise YapError("No tracking branch configured for '%s'" % current)
309 merge = get_output("git config branch.%s.merge" % current)
310 if not merge:
311 raise YapError("No tracking branch configured for '%s'" % current)
312 return remote[0], merge[0]
314 def _confirm_push(self, current, rhs, repo):
315 print "About to push local branch '%s' to '%s' on '%s'" % (current, rhs, repo)
316 print "Continue (y/n)? ",
317 sys.stdout.flush()
318 ans = sys.stdin.readline().strip()
320 if ans.lower() != 'y' and ans.lower() != 'yes':
321 raise YapError("Aborted.")
323 @short_help("make a local copy of an existing repository")
324 @long_help("""
325 The first argument is a URL to the existing repository. This can be an
326 absolute path if the repository is local, or a URL with the git://,
327 ssh://, or http:// schemes. By default, the directory used is the last
328 component of the URL, sans '.git'. This can be overridden by providing
329 a second argument.
330 """)
331 def cmd_clone(self, url, directory=None):
332 "<url> [directory]"
334 if '://' not in url and url[0] != '/':
335 url = os.path.join(os.getcwd(), url)
337 url = url.rstrip('/')
338 if directory is None:
339 directory = url.rsplit('/')[-1]
340 directory = directory.replace('.git', '')
342 try:
343 os.mkdir(directory)
344 except OSError:
345 raise YapError("Directory exists: %s" % directory)
346 os.chdir(directory)
347 self.cmd_init()
348 self.cmd_repo("origin", url)
349 self.cmd_fetch("origin")
351 branch = None
352 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
353 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
354 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
355 if get_output("git rev-parse %s" % b)[0] == hash:
356 branch = b
357 break
358 if branch is None:
359 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
360 branch = "refs/remotes/origin/master"
361 if branch is None:
362 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'")
363 branch = branch[0]
365 hash = get_output("git rev-parse %s" % branch)
366 assert hash
367 branch = branch.replace('refs/remotes/origin/', '')
368 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
369 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
370 self.cmd_revert(**{'-a': 1})
372 @short_help("turn a directory into a repository")
373 @long_help("""
374 Converts the current working directory into a repository. The primary
375 side-effect of this command is the creation of a '.git' subdirectory.
376 No files are added nor commits made.
377 """)
378 def cmd_init(self):
379 os.system("git init")
381 @short_help("add a new file to the repository")
382 @long_help("""
383 The arguments are the files to be added to the repository. Once added,
384 the files will show as "unstaged changes" in the output of 'status'. To
385 reverse the effects of this command, see 'rm'.
386 """)
387 def cmd_add(self, *files):
388 "<file>..."
389 self._check_git()
391 if not files:
392 raise TypeError
394 for f in files:
395 self._add_one(f)
396 self.cmd_status()
398 @short_help("delete a file from the repository")
399 @long_help("""
400 The arguments are the files to be removed from the current revision of
401 the repository. The files will still exist in any past commits that the
402 files may have been a part of. The file is not actually deleted, it is
403 just no longer tracked as part of the repository.
404 """)
405 def cmd_rm(self, *files):
406 "<file>..."
407 self._check_git()
408 if not files:
409 raise TypeError
411 for f in files:
412 self._rm_one(f)
413 self.cmd_status()
415 @short_help("stage changes in a file for commit")
416 @long_help("""
417 The arguments are the files to be staged. Staging changes is a way to
418 build up a commit when you do not want to commit all changes at once.
419 To commit only staged changes, use the '-d' flag to 'commit.' To
420 reverse the effects of this command, see 'unstage'. Once staged, the
421 files will show as "staged changes" in the output of 'status'.
422 """)
423 def cmd_stage(self, *files):
424 "<file>..."
425 self._check_git()
426 if not files:
427 raise TypeError
429 for f in files:
430 self._stage_one(f)
431 self.cmd_status()
433 @short_help("unstage changes in a file")
434 @long_help("""
435 The arguments are the files to be unstaged. Once unstaged, the files
436 will show as "unstaged changes" in the output of 'status'. The '-a'
437 flag can be used to unstage all staged changes at once.
438 """)
439 @takes_options("a")
440 def cmd_unstage(self, *files, **flags):
441 "[-a] | <file>..."
442 self._check_git()
443 if '-a' in flags:
444 self._unstage_all()
445 self.cmd_status()
446 return
448 if not files:
449 raise TypeError
451 for f in files:
452 self._unstage_one(f)
453 self.cmd_status()
455 @short_help("show files with staged and unstaged changes")
456 @long_help("""
457 Show the files in the repository with changes since the last commit,
458 categorized based on whether the changes are staged or not. A file may
459 appear under each heading if the same file has both staged and unstaged
460 changes.
461 """)
462 def cmd_status(self):
464 self._check_git()
465 branch = get_output("git symbolic-ref HEAD")
466 if branch:
467 branch = branch[0].replace('refs/heads/', '')
468 else:
469 branch = "DETACHED"
470 print "Current branch: %s" % branch
472 print "Files with staged changes:"
473 files = self._get_staged_files()
474 for f in files:
475 print "\t%s" % f
476 if not files:
477 print "\t(none)"
479 print "Files with unstaged changes:"
480 files = self._get_unstaged_files()
481 for f in files:
482 print "\t%s" % f
483 if not files:
484 print "\t(none)"
486 files = self._get_unmerged_files()
487 if files:
488 print "Files with conflicts:"
489 for f in files:
490 print "\t%s" % f
492 @short_help("remove uncommitted changes from a file (*)")
493 @long_help("""
494 The arguments are the files whose changes will be reverted. If the '-a'
495 flag is given, then all files will have uncommitted changes removed.
496 Note that there is no way to reverse this command short of manually
497 editing each file again.
498 """)
499 @takes_options("a")
500 def cmd_revert(self, *files, **flags):
501 "(-a | <file>)"
502 self._check_git()
503 if '-a' in flags:
504 self._unstage_all()
505 run_safely("git checkout-index -u -f -a")
506 self.cmd_status()
507 return
509 if not files:
510 raise TypeError
512 for f in files:
513 self._revert_one(f)
514 self.cmd_status()
516 @short_help("record changes to files as a new commit")
517 @long_help("""
518 Create a new commit recording changes since the last commit. If there
519 are only unstaged changes, those will be recorded. If there are only
520 staged changes, those will be recorded. Otherwise, you will have to
521 specify either the '-a' flag or the '-d' flag to commit all changes or
522 only staged changes, respectively. To reverse the effects of this
523 command, see 'uncommit'.
524 """)
525 @takes_options("adm:")
526 def cmd_commit(self, **flags):
527 "[-a | -d] [-m <msg>]"
528 self._check_git()
529 self._check_rebasing()
530 self._check_commit(**flags)
531 if not self._get_staged_files():
532 raise YapError("No changes to commit")
533 msg = flags.get('-m', None)
534 self._do_commit(msg)
535 self.cmd_status()
537 @short_help("reverse the actions of the last commit")
538 @long_help("""
539 Reverse the effects of the last 'commit' operation. The changes that
540 were part of the previous commit will show as "staged changes" in the
541 output of 'status'. This means that if no files were changed since the
542 last commit was created, 'uncommit' followed by 'commit' is a lossless
543 operation.
544 """)
545 def cmd_uncommit(self):
547 self._check_git()
548 self._do_uncommit()
549 self.cmd_status()
551 @short_help("report the current version of yap")
552 def cmd_version(self):
553 print "Yap version 0.1"
555 @short_help("show the changelog for particular versions or files")
556 @long_help("""
557 The arguments are the files with which to filter history. If none are
558 given, all changes are listed. Otherwise only commits that affected one
559 or more of the given files are listed. The -r option changes the
560 starting revision for traversing history. By default, history is listed
561 starting at HEAD.
562 """)
563 @takes_options("pr:")
564 def cmd_log(self, *paths, **flags):
565 "[-p] [-r <rev>] <path>..."
566 self._check_git()
567 rev = flags.get('-r', 'HEAD')
569 if '-p' in flags:
570 flags['-p'] = '-p'
572 if len(paths) == 1:
573 follow = "--follow"
574 else:
575 follow = ""
576 paths = ' '.join(paths)
577 os.system("git log -M -C %s %s '%s' -- %s"
578 % (follow, flags.get('-p', '--name-status'), rev, paths))
580 @short_help("show staged, unstaged, or all uncommitted changes")
581 @long_help("""
582 Show staged, unstaged, or all uncommitted changes. By default, all
583 changes are shown. The '-u' flag causes only unstaged changes to be
584 shown. The '-d' flag causes only staged changes to be shown.
585 """)
586 @takes_options("ud")
587 def cmd_diff(self, **flags):
588 "[ -u | -d ]"
589 self._check_git()
590 if '-u' in flags and '-d' in flags:
591 raise YapError("Conflicting flags: -u and -d")
593 pager = self._get_pager_cmd()
595 if '-u' in flags:
596 os.system("git diff-files -p | %s" % pager)
597 elif '-d' in flags:
598 os.system("git diff-index --cached -p HEAD | %s" % pager)
599 else:
600 os.system("git diff-index -p HEAD | %s" % pager)
602 @short_help("list, create, or delete branches")
603 @long_help("""
604 If no arguments are specified, a list of local branches is given. The
605 current branch is indicated by a "*" next to the name. If an argument
606 is given, it is taken as the name of a new branch to create. The branch
607 will start pointing at the current HEAD. See 'point' for details on
608 changing the revision of the new branch. Note that this command does
609 not switch the current working branch. See 'switch' for details on
610 changing the current working branch.
612 The '-d' flag can be used to delete local branches. If the delete
613 operation would remove the last branch reference to a given line of
614 history (colloquially referred to as "dangling commits"), yap will
615 report an error and abort. The '-f' flag can be used to force the delete
616 in spite of this.
617 """)
618 @takes_options("fd:")
619 def cmd_branch(self, branch=None, **flags):
620 "[ [-f] -d <branch> | <branch> ]"
621 self._check_git()
622 force = '-f' in flags
623 if '-d' in flags:
624 self._delete_branch(flags['-d'], force)
625 self.cmd_branch()
626 return
628 if branch is not None:
629 ref = get_output("git rev-parse --verify HEAD")
630 if not ref:
631 raise YapError("No branch point yet. Make a commit")
632 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
634 current = get_output("git symbolic-ref HEAD")
635 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
636 for b in branches:
637 if current and b == current[0]:
638 print "* ",
639 else:
640 print " ",
641 b = b.replace('refs/heads/', '')
642 print b
644 @short_help("change the current working branch")
645 @long_help("""
646 The argument is the name of the branch to make the current working
647 branch. This command will fail if there are uncommitted changes to any
648 files. Otherwise, the contents of the files in the working directory
649 are updated to reflect their state in the new branch. Additionally, any
650 future commits are added to the new branch instead of the previous line
651 of history.
652 """)
653 @takes_options("f")
654 def cmd_switch(self, branch, **flags):
655 "[-f] <branch>"
656 self._check_git()
657 self._check_rebasing()
658 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
659 if not ref:
660 raise YapError("No such branch: %s" % branch)
662 if '-f' not in flags:
663 if (self._get_staged_files()
664 or (self._get_unstaged_files()
665 and run_command("git update-index --refresh"))):
666 raise YapError("You have uncommitted changes. Use -f to continue anyway")
668 if self._get_unstaged_files() and self._get_staged_files():
669 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
671 staged = bool(self._get_staged_files())
673 run_command("git diff-files -p | git apply --cached")
674 for f in self._get_new_files():
675 self._stage_one(f)
677 idx = get_output("git write-tree")
678 new = get_output("git rev-parse refs/heads/%s" % branch)
679 readtree = "git read-tree --aggressive -u -m HEAD %s %s" % (idx[0], new[0])
680 if run_command(readtree):
681 run_command("git update-index --refresh")
682 if os.system(readtree):
683 raise YapError("Failed to switch")
684 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
686 if not staged:
687 self._unstage_all()
688 self.cmd_status()
690 @short_help("move the current branch to a different revision")
691 @long_help("""
692 The argument is the hash of the commit to which the current branch
693 should point, or alternately a branch or tag (a.k.a, "committish"). If
694 moving the branch would create "dangling commits" (see 'branch'), yap
695 will report an error and abort. The '-f' flag can be used to force the
696 operation in spite of this.
697 """)
698 @takes_options("f")
699 def cmd_point(self, where, **flags):
700 "[-f] <where>"
701 self._check_git()
702 self._check_rebasing()
704 head = get_output("git rev-parse --verify HEAD")
705 if not head:
706 raise YapError("No commit yet; nowhere to point")
708 ref = get_output("git rev-parse --verify '%s^{commit}'" % where)
709 if not ref:
710 raise YapError("Not a valid ref: %s" % where)
712 if self._get_unstaged_files() or self._get_staged_files():
713 raise YapError("You have uncommitted changes. Commit them first")
715 run_safely("git update-ref HEAD '%s'" % ref[0])
717 if '-f' not in flags:
718 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
719 if name == "undefined":
720 os.system("git update-ref HEAD '%s'" % head[0])
721 raise YapError("Pointing there will lose commits. Use -f to force")
723 try:
724 run_safely("git read-tree -u -m HEAD")
725 except ShellError:
726 run_safely("git read-tree HEAD")
727 run_safely("git checkout-index -u -f -a")
729 @short_help("alter history by dropping or amending commits")
730 @long_help("""
731 This command operates in two distinct modes, "amend" and "drop" mode.
732 In drop mode, the given commit is removed from the history of the
733 current branch, as though that commit never happened. By default the
734 commit used is HEAD.
736 In amend mode, the uncommitted changes present are merged into a
737 previous commit. This is useful for correcting typos or adding missed
738 files into past commits. By default the commit used is HEAD.
740 While rewriting history it is possible that conflicts will arise. If
741 this happens, the rewrite will pause and you will be prompted to resolve
742 the conflicts and stage them. Once that is done, you will run "yap
743 history continue." If instead you want the conflicting commit removed
744 from history (perhaps your changes supercede that commit) you can run
745 "yap history skip". Once the rewrite completes, your branch will be on
746 the same commit as when the rewrite started.
747 """)
748 def cmd_history(self, subcmd, *args):
749 "amend | drop <commit>"
750 self._check_git()
752 if subcmd not in ("amend", "drop", "continue", "skip"):
753 raise TypeError
755 resolvemsg = """
756 When you have resolved the conflicts run \"yap history continue\".
757 To skip the problematic patch, run \"yap history skip\"."""
759 if subcmd == "continue":
760 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
761 return
762 if subcmd == "skip":
763 os.system("git reset --hard")
764 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
765 return
767 if subcmd == "amend":
768 flags, args = getopt.getopt(args, "ad")
769 flags = dict(flags)
771 if len(args) > 1:
772 raise TypeError
773 if args:
774 commit = args[0]
775 else:
776 commit = "HEAD"
778 if run_command("git rev-parse --verify '%s'" % commit):
779 raise YapError("Not a valid commit: %s" % commit)
781 self._check_rebasing()
783 if subcmd == "amend":
784 self._check_commit(**flags)
785 if self._get_unstaged_files():
786 # XXX: handle unstaged changes better
787 raise YapError("Commit away changes that you aren't amending")
789 self._unstage_all()
791 start = get_output("git rev-parse HEAD")
792 stash = get_output("git stash create")
793 run_command("git reset --hard")
794 try:
795 fd, tmpfile = tempfile.mkstemp("yap")
796 try:
797 try:
798 os.close(fd)
799 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
800 if subcmd == "amend":
801 self.cmd_point(commit, **{'-f': True})
802 finally:
803 if subcmd == "amend":
804 if stash:
805 rc = os.system("git stash apply %s" % stash[0])
806 if rc:
807 self.cmd_point(start[0], **{'-f': True})
808 os.system("git stash apply %s" % stash[0])
809 raise YapError("Failed to apply stash")
810 stash = None
812 if subcmd == "amend":
813 self._do_uncommit()
814 self._check_commit(**{'-a': True})
815 self._do_commit()
816 else:
817 self.cmd_point("%s^" % commit, **{'-f': True})
819 stat = os.stat(tmpfile)
820 size = stat[6]
821 if size > 0:
822 run_safely("git update-index --refresh")
823 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
824 if (rc):
825 raise YapError("Failed to apply changes")
826 finally:
827 os.unlink(tmpfile)
828 finally:
829 if stash:
830 run_command("git stash apply %s" % stash[0])
831 self.cmd_status()
833 @short_help("show the changes introduced by a given commit")
834 @long_help("""
835 By default, the changes in the last commit are shown. To override this,
836 specify a hash, branch, or tag (committish). The hash of the commit,
837 the commit's author, log message, and a diff of the changes are shown.
838 """)
839 def cmd_show(self, commit="HEAD"):
840 "[commit]"
841 self._check_git()
842 os.system("git show '%s'" % commit)
844 @short_help("apply the changes in a given commit to the current branch")
845 @long_help("""
846 The argument is the hash, branch, or tag (committish) of the commit to
847 be applied. In general, it only makes sense to apply commits that
848 happened on another branch. The '-r' flag can be used to have the
849 changes in the given commit reversed from the current branch. In
850 general, this only makes sense for commits that happened on the current
851 branch.
852 """)
853 @takes_options("r")
854 def cmd_cherry_pick(self, commit, **flags):
855 "[-r] <commit>"
856 self._check_git()
857 if '-r' in flags:
858 os.system("git revert '%s'" % commit)
859 else:
860 os.system("git cherry-pick '%s'" % commit)
862 @short_help("list, add, or delete configured remote repositories")
863 @long_help("""
864 When invoked with no arguments, this command will show the list of
865 currently configured remote repositories, giving both the name and URL
866 of each. To add a new repository, give the desired name as the first
867 argument and the URL as the second. The '-d' flag can be used to remove
868 a previously added repository.
869 """)
870 @takes_options("d:")
871 def cmd_repo(self, name=None, url=None, **flags):
872 "[<name> <url> | -d <name>]"
873 self._check_git()
874 if name is not None and url is None:
875 raise TypeError
877 if '-d' in flags:
878 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
879 raise YapError("No such repository: %s" % flags['-d'])
880 os.system("git config --unset remote.%s.url" % flags['-d'])
881 os.system("git config --unset remote.%s.fetch" % flags['-d'])
883 if name:
884 if name in [ x[0] for x in self._list_remotes() ]:
885 raise YapError("Repository '%s' already exists" % flags['-d'])
886 os.system("git config remote.%s.url %s" % (name, url))
887 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
889 for remote, url in self._list_remotes():
890 print "%-20s %s" % (remote, url)
892 @short_help("send local commits to a remote repository (*)")
893 @long_help("""
894 When invoked with no arguments, the current branch is synchronized to
895 the tracking branch of the tracking remote. If no tracking remote is
896 specified, the repository will have to be specified on the command line.
897 In that case, the default is to push to a branch with the same name as
898 the current branch. This behavior can be overridden by giving a second
899 argument to specify the remote branch.
901 If the remote branch does not currently exist, the command will abort
902 unless the -c flag is provided. If the remote branch is not a direct
903 descendent of the local branch, the command will abort unless the -f
904 flag is provided. Forcing a push in this way can be problematic to
905 other users of the repository if they are not expecting it.
907 To delete a branch on the remote repository, use the -d flag.
908 """)
909 @takes_options("cdf")
910 def cmd_push(self, repo=None, rhs=None, **flags):
911 "[-c | -d] <repo>"
912 self._check_git()
913 if '-c' in flags and '-d' in flags:
914 raise TypeError
916 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
917 raise YapError("No such repository: %s" % repo)
919 current = get_output("git symbolic-ref HEAD")
920 if not current:
921 raise YapError("Not on a branch!")
923 self._check_rebasing()
925 current = current[0].replace('refs/heads/', '')
926 remote = get_output("git config branch.%s.remote" % current)
927 if repo is None and remote:
928 repo = remote[0]
930 if repo is None:
931 raise YapError("No tracking branch configured; specify destination repository")
933 if rhs is None and remote and remote[0] == repo:
934 merge = get_output("git config branch.%s.merge" % current)
935 if merge:
936 rhs = merge[0]
938 if rhs is None:
939 rhs = "refs/heads/%s" % current
941 if '-c' not in flags and '-d' not in flags:
942 if run_command("git rev-parse --verify refs/remotes/%s/%s"
943 % (repo, rhs.replace('refs/heads/', ''))):
944 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
945 if '-f' not in flags:
946 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo, rhs.replace('refs/heads/', '')))
947 base = get_output("git merge-base HEAD %s" % hash[0])
948 assert base
949 if base[0] != hash[0]:
950 raise YapError("Branch not up-to-date with remote. Update or use -f")
952 self._confirm_push(current, rhs, repo)
953 if '-f' in flags:
954 flags['-f'] = '-f'
956 if '-d' in flags:
957 lhs = ""
958 else:
959 lhs = "refs/heads/%s" % current
960 rc = os.system("git push %s %s %s:%s" % (flags.get('-f', ''), repo, lhs, rhs))
961 if rc:
962 raise YapError("Push failed.")
964 @short_help("retrieve commits from a remote repository")
965 @long_help("""
966 When run with no arguments, the command will retrieve new commits from
967 the remote tracking repository. Note that this does not in any way
968 alter the current branch. For that, see "update". If a remote other
969 than the tracking remote is desired, it can be specified as the first
970 argument.
971 """)
972 def cmd_fetch(self, repo=None):
973 "<repo>"
974 self._check_git()
975 current = get_output("git symbolic-ref HEAD")
976 if not current:
977 raise YapError("Not on a branch!")
979 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
980 raise YapError("No such repository: %s" % repo)
981 if repo is None:
982 current = current[0].replace('refs/heads/', '')
983 remote = get_output("git config branch.%s.remote" % current)
984 if remote:
985 repo = remote[0]
986 if repo is None:
987 raise YapError("No tracking branch configured; specify a repository")
988 os.system("git fetch %s" % repo)
990 @short_help("update the current branch relative to its tracking branch")
991 @long_help("""
992 Updates the current branch relative to its remote tracking branch. This
993 command requires that the current branch have a remote tracking branch
994 configured. If any conflicts occur while applying your changes to the
995 updated remote, the command will pause to allow you to fix them. Once
996 that is done, run "update" with the "continue" subcommand. Alternately,
997 the "skip" subcommand can be used to discard the conflicting changes.
998 """)
999 def cmd_update(self, subcmd=None):
1000 "[continue | skip]"
1001 self._check_git()
1002 if subcmd and subcmd not in ["continue", "skip"]:
1003 raise TypeError
1005 resolvemsg = """
1006 When you have resolved the conflicts run \"yap update continue\".
1007 To skip the problematic patch, run \"yap update skip\"."""
1009 if subcmd == "continue":
1010 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
1011 return
1012 if subcmd == "skip":
1013 os.system("git reset --hard")
1014 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
1015 return
1017 self._check_rebasing()
1018 if self._get_unstaged_files() or self._get_staged_files():
1019 raise YapError("You have uncommitted changes. Commit them first")
1021 current = get_output("git symbolic-ref HEAD")
1022 if not current:
1023 raise YapError("Not on a branch!")
1025 current = current[0].replace('refs/heads/', '')
1026 remote, merge = self._get_tracking(current)
1027 merge = merge.replace('refs/heads/', '')
1029 self.cmd_fetch(remote)
1030 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
1032 try:
1033 fd, tmpfile = tempfile.mkstemp("yap")
1034 os.close(fd)
1035 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
1036 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
1038 stat = os.stat(tmpfile)
1039 size = stat[6]
1040 if size > 0:
1041 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
1042 if (rc):
1043 raise YapError("Failed to apply changes")
1044 finally:
1045 os.unlink(tmpfile)
1047 @short_help("query and configure remote branch tracking")
1048 @long_help("""
1049 When invoked with no arguments, the command displays the tracking
1050 information for the current branch. To configure the tracking
1051 information, two arguments for the remote repository and remote branch
1052 are given. The tracking information is used to provide defaults for
1053 where to push local changes and from where to get updates to the branch.
1054 """)
1055 def cmd_track(self, repo=None, branch=None):
1056 "[<repo> <branch>]"
1057 self._check_git()
1059 current = get_output("git symbolic-ref HEAD")
1060 if not current:
1061 raise YapError("Not on a branch!")
1062 current = current[0].replace('refs/heads/', '')
1064 if repo is None and branch is None:
1065 repo, merge = self._get_tracking(current)
1066 merge = merge.replace('refs/heads/', '')
1067 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
1068 return
1070 if repo is None or branch is None:
1071 raise TypeError
1073 if repo not in [ x[0] for x in self._list_remotes() ]:
1074 raise YapError("No such repository: %s" % repo)
1076 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
1077 raise YapError("No such branch '%s' on repository '%s'" % (branch, repo))
1079 os.system("git config branch.%s.remote '%s'" % (current, repo))
1080 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
1081 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
1083 @short_help("mark files with conflicts as resolved")
1084 @long_help("""
1085 The arguments are the files to be marked resolved. When a conflict
1086 occurs while merging changes to a file, that file is marked as
1087 "unmerged." Until the file(s) with conflicts are marked resolved,
1088 commits cannot be made.
1089 """)
1090 def cmd_resolved(self, *args):
1091 "<file>..."
1092 self._check_git()
1093 if not files:
1094 raise TypeError
1096 for f in files:
1097 self._stage_one(f, True)
1098 self.cmd_status()
1100 def cmd_help(self, cmd=None):
1101 if cmd is not None:
1102 cmd = "cmd_" + cmd.replace('-', '_')
1103 try:
1104 attr = self.__getattribute__(cmd)
1105 except AttributeError:
1106 raise YapError("No such command: %s" % cmd)
1107 try:
1108 help = attr.long_help
1109 except AttributeError:
1110 attr = super(Yap, self).__getattribute__(cmd)
1111 try:
1112 help = attr.long_help
1113 except AttributeError:
1114 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
1116 print >>sys.stderr, "The '%s' command" % cmd
1117 print >>sys.stderr, "\tyap %s %s" % (cmd, attr.__doc__)
1118 print >>sys.stderr, "%s" % help
1119 return
1121 print >> sys.stderr, "Yet Another (Git) Porcelein"
1122 print >> sys.stderr
1124 for name in dir(self):
1125 if not name.startswith('cmd_'):
1126 continue
1127 attr = self.__getattribute__(name)
1128 if not callable(attr):
1129 continue
1131 try:
1132 short_msg = attr.short_help
1133 except AttributeError:
1134 try:
1135 default_meth = super(Yap, self).__getattribute__(name)
1136 short_msg = default_meth.short_help
1137 except AttributeError:
1138 continue
1140 name = name.replace('cmd_', '')
1141 name = name.replace('_', '-')
1142 print >> sys.stderr, "%-16s%s" % (name, short_msg)
1144 print >> sys.stderr
1145 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
1147 def cmd_usage(self):
1148 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
1149 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"
1151 def main(self, args):
1152 if len(args) < 1:
1153 self.cmd_usage()
1154 sys.exit(2)
1156 command = args[0]
1157 args = args[1:]
1159 if run_command("git --version"):
1160 print >>sys.stderr, "Failed to run git; is it installed?"
1161 sys.exit(1)
1163 debug = os.getenv('YAP_DEBUG')
1165 try:
1166 command = command.replace('-', '_')
1168 meth = self.__getattribute__("cmd_"+command)
1169 doc = meth.__doc__
1171 try:
1172 options = ""
1173 if "options" in meth.__dict__:
1174 options = meth.options
1175 if options:
1176 flags, args = getopt.getopt(args, options)
1177 flags = dict(flags)
1178 else:
1179 flags = dict()
1181 meth(*args, **flags)
1182 except (TypeError, getopt.GetoptError):
1183 if debug:
1184 raise
1185 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1186 except YapError, e:
1187 if debug:
1188 raise
1189 print >> sys.stderr, e
1190 sys.exit(1)
1191 except AttributeError:
1192 if debug:
1193 raise
1194 self.cmd_usage()
1195 sys.exit(2)