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