clear_state: don't bail out if one of the files doesn't exist
[yap.git] / yap / yap.py
blobb281e23eb7d2305d33abb31f9fec08feca36fcde
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 _clear_new_files(self):
66 repo = get_output('git rev-parse --git-dir')[0]
67 path = os.path.join(repo, 'yap', 'new-files')
68 os.unlink(path)
70 def _assert_file_exists(self, file):
71 if not os.access(file, os.R_OK):
72 raise YapError("No such file: %s" % file)
74 def _get_staged_files(self):
75 if run_command("git rev-parse HEAD"):
76 files = get_output("git ls-files --cached")
77 else:
78 files = get_output("git diff-index --cached --name-only HEAD")
79 unmerged = self._get_unmerged_files()
80 if unmerged:
81 unmerged = set(unmerged)
82 files = set(files).difference(unmerged)
83 files = list(files)
84 return files
86 def _get_unstaged_files(self):
87 files = get_output("git ls-files -m")
88 prefix = get_output("git rev-parse --show-prefix")
89 if prefix:
90 files = [ os.path.join(prefix[0], x) for x in files ]
91 files += self._get_new_files()
92 unmerged = self._get_unmerged_files()
93 if unmerged:
94 unmerged = set(unmerged)
95 files = set(files).difference(unmerged)
96 files = list(files)
97 return files
99 def _get_unmerged_files(self):
100 files = get_output("git ls-files -u")
101 files = [ x.replace('\t', ' ').split(' ')[3] for x in files ]
102 prefix = get_output("git rev-parse --show-prefix")
103 if prefix:
104 files = [ os.path.join(prefix[0], x) for x in files ]
105 return list(set(files))
107 def _delete_branch(self, branch, force):
108 current = get_output("git symbolic-ref HEAD")
109 if current:
110 current = current[0].replace('refs/heads/', '')
111 if branch == current:
112 raise YapError("Can't delete current branch")
114 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
115 if not ref:
116 raise YapError("No such branch: %s" % branch)
117 run_safely("git update-ref -d 'refs/heads/%s' '%s'" % (branch, ref[0]))
119 if not force:
120 name = get_output("git name-rev --name-only '%s'" % ref[0])[0]
121 if name == 'undefined':
122 run_command("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
123 raise YapError("Refusing to delete leaf branch (use -f to force)")
124 def _get_pager_cmd(self):
125 if 'YAP_PAGER' in os.environ:
126 return os.environ['YAP_PAGER']
127 elif 'GIT_PAGER' in os.environ:
128 return os.environ['GIT_PAGER']
129 elif 'PAGER' in os.environ:
130 return os.environ['PAGER']
131 else:
132 return "more"
134 def _add_one(self, file):
135 self._assert_file_exists(file)
136 x = get_output("git ls-files '%s'" % file)
137 if x != []:
138 raise YapError("File '%s' already in repository" % file)
139 self._add_new_file(file)
141 def _rm_one(self, file):
142 self._assert_file_exists(file)
143 if get_output("git ls-files '%s'" % file) != []:
144 run_safely("git rm --cached '%s'" % file)
145 self._remove_new_file(file)
147 def _stage_one(self, file, allow_unmerged=False):
148 self._assert_file_exists(file)
149 prefix = get_output("git rev-parse --show-prefix")
150 if prefix:
151 tmp = os.path.normpath(os.path.join(prefix[0], file))
152 else:
153 tmp = file
154 if not allow_unmerged and tmp in self._get_unmerged_files():
155 raise YapError("Refusing to stage conflicted file: %s" % file)
156 run_safely("git update-index --add '%s'" % file)
158 def _unstage_one(self, file):
159 self._assert_file_exists(file)
160 if run_command("git rev-parse HEAD"):
161 rc = run_command("git update-index --force-remove '%s'" % file)
162 else:
163 rc = run_command("git diff-index --cached -p HEAD '%s' | git apply -R --cached" % file)
164 if rc:
165 raise YapError("Failed to unstage")
167 def _revert_one(self, file):
168 self._assert_file_exists(file)
169 try:
170 self._unstage_one(file)
171 except YapError:
172 pass
173 run_safely("git checkout-index -u -f '%s'" % file)
175 def _parse_commit(self, commit):
176 lines = get_output("git cat-file commit '%s'" % commit)
177 commit = {}
179 mode = None
180 for l in lines:
181 if mode != 'commit' and l.strip() == "":
182 mode = 'commit'
183 commit['log'] = []
184 continue
185 if mode == 'commit':
186 commit['log'].append(l)
187 continue
189 x = l.split(' ')
190 k = x[0]
191 v = ' '.join(x[1:])
192 commit[k] = v
193 commit['log'] = '\n'.join(commit['log'])
194 return commit
196 def _check_commit(self, **flags):
197 if '-a' in flags and '-d' in flags:
198 raise YapError("Conflicting flags: -a and -d")
200 if '-d' not in flags and self._get_unstaged_files():
201 if '-a' not in flags and self._get_staged_files():
202 raise YapError("Staged and unstaged changes present. Specify what to commit")
203 os.system("git diff-files -p | git apply --cached")
204 for f in self._get_new_files():
205 self._stage_one(f)
207 def _do_uncommit(self):
208 commit = self._parse_commit("HEAD")
209 repo = get_output('git rev-parse --git-dir')[0]
210 dir = os.path.join(repo, 'yap')
211 try:
212 os.mkdir(dir)
213 except OSError:
214 pass
215 msg_file = os.path.join(dir, 'msg')
216 fd = file(msg_file, 'w')
217 print >>fd, commit['log']
218 fd.close()
220 tree = get_output("git rev-parse --verify HEAD^")
221 run_safely("git update-ref -m uncommit HEAD '%s'" % tree[0])
223 def _do_commit(self, msg=None):
224 tree = get_output("git write-tree")[0]
226 repo = get_output('git rev-parse --git-dir')[0]
227 head_file = os.path.join(repo, 'yap', 'merge')
228 try:
229 parent = pickle.load(file(head_file))
230 except IOError:
231 parent = get_output("git rev-parse --verify HEAD 2> /dev/null")
233 if os.environ.has_key('YAP_EDITOR'):
234 editor = os.environ['YAP_EDITOR']
235 elif os.environ.has_key('GIT_EDITOR'):
236 editor = os.environ['GIT_EDITOR']
237 elif os.environ.has_key('EDITOR'):
238 editor = os.environ['EDITOR']
239 else:
240 editor = "vi"
242 fd, tmpfile = tempfile.mkstemp("yap")
243 os.close(fd)
246 if msg is None:
247 msg_file = os.path.join(repo, 'yap', 'msg')
248 if os.access(msg_file, os.R_OK):
249 fd1 = file(msg_file)
250 fd2 = file(tmpfile, 'w')
251 for l in fd1.xreadlines():
252 print >>fd2, l.strip()
253 fd2.close()
254 os.unlink(msg_file)
255 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
256 raise YapError("Editing commit message failed")
257 fd = file(tmpfile)
258 msg = fd.readlines()
259 msg = ''.join(msg)
261 msg = msg.strip()
262 if not msg:
263 raise YapError("Refusing to use empty commit message")
265 (fd_w, fd_r) = os.popen2("git stripspace > %s" % tmpfile)
266 print >>fd_w, msg,
267 fd_w.close()
268 fd_r.close()
270 if parent:
271 parent = ' -p '.join(parent)
272 commit = get_output("git commit-tree '%s' -p %s < '%s'" % (tree, parent, tmpfile))
273 else:
274 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
276 os.unlink(tmpfile)
277 run_safely("git update-ref HEAD '%s'" % commit[0])
278 self._clear_state()
280 def _check_rebasing(self):
281 repo = get_output('git rev-parse --git-dir')[0]
282 dotest = os.path.join(repo, '.dotest')
283 if os.access(dotest, os.R_OK):
284 raise YapError("A git operation is in progress. Complete it first")
285 dotest = os.path.join(repo, '..', '.dotest')
286 if os.access(dotest, os.R_OK):
287 raise YapError("A git operation is in progress. Complete it first")
289 def _check_git(self):
290 if run_command("git rev-parse --git-dir"):
291 raise YapError("That command must be run from inside a git repository")
293 def _list_remotes(self):
294 remotes = get_output("git config --get-regexp '^remote.*.url'")
295 for x in remotes:
296 remote, url = x.split(' ')
297 remote = remote.replace('remote.', '')
298 remote = remote.replace('.url', '')
299 yield remote, url
301 def _unstage_all(self):
302 try:
303 run_safely("git read-tree -m HEAD")
304 except ShellError:
305 run_safely("git read-tree HEAD")
306 run_safely("git update-index -q --refresh")
308 def _get_tracking(self, current):
309 remote = get_output("git config branch.%s.remote" % current)
310 if not remote:
311 raise YapError("No tracking branch configured for '%s'" % current)
313 merge = get_output("git config branch.%s.merge" % current)
314 if not merge:
315 raise YapError("No tracking branch configured for '%s'" % current)
316 return remote[0], merge[0]
318 def _confirm_push(self, current, rhs, repo):
319 print "About to push local branch '%s' to '%s' on '%s'" % (current, rhs, repo)
320 print "Continue (y/n)? ",
321 sys.stdout.flush()
322 ans = sys.stdin.readline().strip()
324 if ans.lower() != 'y' and ans.lower() != 'yes':
325 raise YapError("Aborted.")
327 def _clear_state(self):
328 repo = get_output('git rev-parse --git-dir')[0]
329 dir = os.path.join(repo, 'yap')
330 for f in "new-files", "merge", "msg":
331 try:
332 os.unlink(os.path.join(dir, f))
333 except OSError:
334 pass
336 def _get_attr(self, name, attr):
337 val = None
338 for c in self.__class__.__bases__:
339 try:
340 m2 = c.__dict__[name]
341 except KeyError:
342 continue
343 try:
344 val = m2.__getattribute__(attr)
345 except AttributeError:
346 continue
347 return val
349 @short_help("make a local copy of an existing repository")
350 @long_help("""
351 The first argument is a URL to the existing repository. This can be an
352 absolute path if the repository is local, or a URL with the git://,
353 ssh://, or http:// schemes. By default, the directory used is the last
354 component of the URL, sans '.git'. This can be overridden by providing
355 a second argument.
356 """)
357 def cmd_clone(self, url, directory=None):
358 "<url> [directory]"
360 if '://' not in url and url[0] != '/':
361 url = os.path.join(os.getcwd(), url)
363 url = url.rstrip('/')
364 if directory is None:
365 directory = url.rsplit('/')[-1]
366 directory = directory.replace('.git', '')
368 try:
369 os.mkdir(directory)
370 except OSError:
371 raise YapError("Directory exists: %s" % directory)
372 os.chdir(directory)
373 self.cmd_init()
374 self.cmd_repo("origin", url)
375 self.cmd_fetch("origin")
377 branch = None
378 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
379 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
380 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
381 if get_output("git rev-parse %s" % b)[0] == hash:
382 branch = b
383 break
384 if branch is None:
385 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
386 branch = "refs/remotes/origin/master"
387 if branch is None:
388 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'")
389 branch = branch[0]
391 hash = get_output("git rev-parse %s" % branch)
392 assert hash
393 branch = branch.replace('refs/remotes/origin/', '')
394 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
395 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
396 self.cmd_revert(**{'-a': 1})
398 @short_help("turn a directory into a repository")
399 @long_help("""
400 Converts the current working directory into a repository. The primary
401 side-effect of this command is the creation of a '.git' subdirectory.
402 No files are added nor commits made.
403 """)
404 def cmd_init(self):
405 os.system("git init")
407 @short_help("add a new file to the repository")
408 @long_help("""
409 The arguments are the files to be added to the repository. Once added,
410 the files will show as "unstaged changes" in the output of 'status'. To
411 reverse the effects of this command, see 'rm'.
412 """)
413 def cmd_add(self, *files):
414 "<file>..."
415 self._check_git()
417 if not files:
418 raise TypeError
420 for f in files:
421 self._add_one(f)
422 self.cmd_status()
424 @short_help("delete a file from the repository")
425 @long_help("""
426 The arguments are the files to be removed from the current revision of
427 the repository. The files will still exist in any past commits that the
428 files may have been a part of. The file is not actually deleted, it is
429 just no longer tracked as part of the repository.
430 """)
431 def cmd_rm(self, *files):
432 "<file>..."
433 self._check_git()
434 if not files:
435 raise TypeError
437 for f in files:
438 self._rm_one(f)
439 self.cmd_status()
441 @short_help("stage changes in a file for commit")
442 @long_help("""
443 The arguments are the files to be staged. Staging changes is a way to
444 build up a commit when you do not want to commit all changes at once.
445 To commit only staged changes, use the '-d' flag to 'commit.' To
446 reverse the effects of this command, see 'unstage'. Once staged, the
447 files will show as "staged changes" in the output of 'status'.
448 """)
449 def cmd_stage(self, *files):
450 "<file>..."
451 self._check_git()
452 if not files:
453 raise TypeError
455 for f in files:
456 self._stage_one(f)
457 self.cmd_status()
459 @short_help("unstage changes in a file")
460 @long_help("""
461 The arguments are the files to be unstaged. Once unstaged, the files
462 will show as "unstaged changes" in the output of 'status'. The '-a'
463 flag can be used to unstage all staged changes at once.
464 """)
465 @takes_options("a")
466 def cmd_unstage(self, *files, **flags):
467 "[-a] | <file>..."
468 self._check_git()
469 if '-a' in flags:
470 files = self._get_staged_files()
472 if not files:
473 raise TypeError
475 for f in files:
476 self._unstage_one(f)
477 self.cmd_status()
479 @short_help("show files with staged and unstaged changes")
480 @long_help("""
481 Show the files in the repository with changes since the last commit,
482 categorized based on whether the changes are staged or not. A file may
483 appear under each heading if the same file has both staged and unstaged
484 changes.
485 """)
486 def cmd_status(self):
488 self._check_git()
489 branch = get_output("git symbolic-ref HEAD")
490 if branch:
491 branch = branch[0].replace('refs/heads/', '')
492 else:
493 branch = "DETACHED"
494 print "Current branch: %s" % branch
496 print "Files with staged changes:"
497 files = self._get_staged_files()
498 for f in files:
499 print "\t%s" % f
500 if not files:
501 print "\t(none)"
503 print "Files with unstaged changes:"
504 files = self._get_unstaged_files()
505 for f in files:
506 print "\t%s" % f
507 if not files:
508 print "\t(none)"
510 files = self._get_unmerged_files()
511 if files:
512 print "Files with conflicts:"
513 for f in files:
514 print "\t%s" % f
516 @short_help("remove uncommitted changes from a file (*)")
517 @long_help("""
518 The arguments are the files whose changes will be reverted. If the '-a'
519 flag is given, then all files will have uncommitted changes removed.
520 Note that there is no way to reverse this command short of manually
521 editing each file again.
522 """)
523 @takes_options("a")
524 def cmd_revert(self, *files, **flags):
525 "(-a | <file>)"
526 self._check_git()
527 if '-a' in flags:
528 self._unstage_all()
529 run_safely("git checkout-index -u -f -a")
530 self._clear_state()
531 self.cmd_status()
532 return
534 if not files:
535 raise TypeError
537 for f in files:
538 self._revert_one(f)
539 self.cmd_status()
541 @short_help("record changes to files as a new commit")
542 @long_help("""
543 Create a new commit recording changes since the last commit. If there
544 are only unstaged changes, those will be recorded. If there are only
545 staged changes, those will be recorded. Otherwise, you will have to
546 specify either the '-a' flag or the '-d' flag to commit all changes or
547 only staged changes, respectively. To reverse the effects of this
548 command, see 'uncommit'.
549 """)
550 @takes_options("adm:")
551 def cmd_commit(self, **flags):
552 "[-a | -d] [-m <msg>]"
553 self._check_git()
554 self._check_rebasing()
555 self._check_commit(**flags)
556 if not self._get_staged_files():
557 raise YapError("No changes to commit")
558 msg = flags.get('-m', None)
559 self._do_commit(msg)
560 self.cmd_status()
562 @short_help("reverse the actions of the last commit")
563 @long_help("""
564 Reverse the effects of the last 'commit' operation. The changes that
565 were part of the previous commit will show as "staged changes" in the
566 output of 'status'. This means that if no files were changed since the
567 last commit was created, 'uncommit' followed by 'commit' is a lossless
568 operation.
569 """)
570 def cmd_uncommit(self):
572 self._check_git()
573 self._do_uncommit()
574 self.cmd_status()
576 @short_help("report the current version of yap")
577 def cmd_version(self):
578 print "Yap version 0.1"
580 @short_help("show the changelog for particular versions or files")
581 @long_help("""
582 The arguments are the files with which to filter history. If none are
583 given, all changes are listed. Otherwise only commits that affected one
584 or more of the given files are listed. The -r option changes the
585 starting revision for traversing history. By default, history is listed
586 starting at HEAD.
587 """)
588 @takes_options("pr:")
589 def cmd_log(self, *paths, **flags):
590 "[-p] [-r <rev>] <path>..."
591 self._check_git()
592 rev = flags.get('-r', 'HEAD')
594 if '-p' in flags:
595 flags['-p'] = '-p'
597 if len(paths) == 1:
598 follow = "--follow"
599 else:
600 follow = ""
601 paths = ' '.join(paths)
602 os.system("git log -M -C %s %s '%s' -- %s"
603 % (follow, flags.get('-p', '--name-status'), rev, paths))
605 @short_help("show staged, unstaged, or all uncommitted changes")
606 @long_help("""
607 Show staged, unstaged, or all uncommitted changes. By default, all
608 changes are shown. The '-u' flag causes only unstaged changes to be
609 shown. The '-d' flag causes only staged changes to be shown.
610 """)
611 @takes_options("ud")
612 def cmd_diff(self, **flags):
613 "[ -u | -d ]"
614 self._check_git()
615 if '-u' in flags and '-d' in flags:
616 raise YapError("Conflicting flags: -u and -d")
618 pager = self._get_pager_cmd()
620 if '-u' in flags:
621 os.system("git diff-files -p | %s" % pager)
622 elif '-d' in flags:
623 os.system("git diff-index --cached -p HEAD | %s" % pager)
624 else:
625 os.system("git diff-index -p HEAD | %s" % pager)
627 @short_help("list, create, or delete branches")
628 @long_help("""
629 If no arguments are specified, a list of local branches is given. The
630 current branch is indicated by a "*" next to the name. If an argument
631 is given, it is taken as the name of a new branch to create. The branch
632 will start pointing at the current HEAD. See 'point' for details on
633 changing the revision of the new branch. Note that this command does
634 not switch the current working branch. See 'switch' for details on
635 changing the current working branch.
637 The '-d' flag can be used to delete local branches. If the delete
638 operation would remove the last branch reference to a given line of
639 history (colloquially referred to as "dangling commits"), yap will
640 report an error and abort. The '-f' flag can be used to force the delete
641 in spite of this.
642 """)
643 @takes_options("fd:")
644 def cmd_branch(self, branch=None, **flags):
645 "[ [-f] -d <branch> | <branch> ]"
646 self._check_git()
647 force = '-f' in flags
648 if '-d' in flags:
649 self._delete_branch(flags['-d'], force)
650 self.cmd_branch()
651 return
653 if branch is not None:
654 ref = get_output("git rev-parse --verify HEAD")
655 if not ref:
656 raise YapError("No branch point yet. Make a commit")
657 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
659 current = get_output("git symbolic-ref HEAD")
660 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
661 for b in branches:
662 if current and b == current[0]:
663 print "* ",
664 else:
665 print " ",
666 b = b.replace('refs/heads/', '')
667 print b
669 @short_help("change the current working branch")
670 @long_help("""
671 The argument is the name of the branch to make the current working
672 branch. This command will fail if there are uncommitted changes to any
673 files. Otherwise, the contents of the files in the working directory
674 are updated to reflect their state in the new branch. Additionally, any
675 future commits are added to the new branch instead of the previous line
676 of history.
677 """)
678 @takes_options("f")
679 def cmd_switch(self, branch, **flags):
680 "[-f] <branch>"
681 self._check_git()
682 self._check_rebasing()
683 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
684 if not ref:
685 raise YapError("No such branch: %s" % branch)
687 if '-f' not in flags:
688 if (self._get_staged_files()
689 or (self._get_unstaged_files()
690 and run_command("git update-index --refresh"))):
691 raise YapError("You have uncommitted changes. Use -f to continue anyway")
693 if self._get_unstaged_files() and self._get_staged_files():
694 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
696 staged = bool(self._get_staged_files())
698 run_command("git diff-files -p | git apply --cached")
699 for f in self._get_new_files():
700 self._stage_one(f)
702 idx = get_output("git write-tree")
703 new = get_output("git rev-parse refs/heads/%s" % branch)
704 readtree = "git read-tree --aggressive -u -m HEAD %s %s" % (idx[0], new[0])
705 if run_command(readtree):
706 run_command("git update-index --refresh")
707 if os.system(readtree):
708 raise YapError("Failed to switch")
709 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
711 if '-f' not in flags:
712 self._clear_state()
714 if not staged:
715 self._unstage_all()
716 self.cmd_status()
718 @short_help("move the current branch to a different revision")
719 @long_help("""
720 The argument is the hash of the commit to which the current branch
721 should point, or alternately a branch or tag (a.k.a, "committish"). If
722 moving the branch would create "dangling commits" (see 'branch'), yap
723 will report an error and abort. The '-f' flag can be used to force the
724 operation in spite of this.
725 """)
726 @takes_options("f")
727 def cmd_point(self, where, **flags):
728 "[-f] <where>"
729 self._check_git()
730 self._check_rebasing()
732 head = get_output("git rev-parse --verify HEAD")
733 if not head:
734 raise YapError("No commit yet; nowhere to point")
736 ref = get_output("git rev-parse --verify '%s^{commit}'" % where)
737 if not ref:
738 raise YapError("Not a valid ref: %s" % where)
740 if self._get_unstaged_files() or self._get_staged_files():
741 raise YapError("You have uncommitted changes. Commit them first")
743 run_safely("git update-ref HEAD '%s'" % ref[0])
745 if '-f' not in flags:
746 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
747 if name == "undefined":
748 os.system("git update-ref HEAD '%s'" % head[0])
749 raise YapError("Pointing there will lose commits. Use -f to force")
751 try:
752 run_safely("git read-tree -u -m HEAD")
753 except ShellError:
754 run_safely("git read-tree HEAD")
755 run_safely("git checkout-index -u -f -a")
756 self._clear_state()
758 @short_help("alter history by dropping or amending commits")
759 @long_help("""
760 This command operates in two distinct modes, "amend" and "drop" mode.
761 In drop mode, the given commit is removed from the history of the
762 current branch, as though that commit never happened. By default the
763 commit used is HEAD.
765 In amend mode, the uncommitted changes present are merged into a
766 previous commit. This is useful for correcting typos or adding missed
767 files into past commits. By default the commit used is HEAD.
769 While rewriting history it is possible that conflicts will arise. If
770 this happens, the rewrite will pause and you will be prompted to resolve
771 the conflicts and stage them. Once that is done, you will run "yap
772 history continue." If instead you want the conflicting commit removed
773 from history (perhaps your changes supercede that commit) you can run
774 "yap history skip". Once the rewrite completes, your branch will be on
775 the same commit as when the rewrite started.
776 """)
777 def cmd_history(self, subcmd, *args):
778 "amend | drop <commit>"
779 self._check_git()
781 if subcmd not in ("amend", "drop", "continue", "skip"):
782 raise TypeError
784 resolvemsg = """
785 When you have resolved the conflicts run \"yap history continue\".
786 To skip the problematic patch, run \"yap history skip\"."""
788 if subcmd == "continue":
789 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
790 return
791 if subcmd == "skip":
792 os.system("git reset --hard")
793 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
794 return
796 if subcmd == "amend":
797 flags, args = getopt.getopt(args, "ad")
798 flags = dict(flags)
800 if len(args) > 1:
801 raise TypeError
802 if args:
803 commit = args[0]
804 else:
805 commit = "HEAD"
807 if run_command("git rev-parse --verify '%s'" % commit):
808 raise YapError("Not a valid commit: %s" % commit)
810 self._check_rebasing()
812 if subcmd == "amend":
813 self._check_commit(**flags)
814 if self._get_unstaged_files():
815 # XXX: handle unstaged changes better
816 raise YapError("Commit away changes that you aren't amending")
818 self._unstage_all()
820 start = get_output("git rev-parse HEAD")
821 stash = get_output("git stash create")
822 run_command("git reset --hard")
823 try:
824 fd, tmpfile = tempfile.mkstemp("yap")
825 try:
826 try:
827 os.close(fd)
828 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
829 if subcmd == "amend":
830 self.cmd_point(commit, **{'-f': True})
831 finally:
832 if subcmd == "amend":
833 if stash:
834 rc = os.system("git stash apply %s" % stash[0])
835 if rc:
836 self.cmd_point(start[0], **{'-f': True})
837 os.system("git stash apply %s" % stash[0])
838 raise YapError("Failed to apply stash")
839 stash = None
841 if subcmd == "amend":
842 self._do_uncommit()
843 self._check_commit(**{'-a': True})
844 self._do_commit()
845 else:
846 self.cmd_point("%s^" % commit, **{'-f': True})
848 stat = os.stat(tmpfile)
849 size = stat[6]
850 if size > 0:
851 run_safely("git update-index --refresh")
852 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
853 if (rc):
854 raise YapError("Failed to apply changes")
855 finally:
856 os.unlink(tmpfile)
857 finally:
858 if stash:
859 run_command("git stash apply %s" % stash[0])
860 self.cmd_status()
862 @short_help("show the changes introduced by a given commit")
863 @long_help("""
864 By default, the changes in the last commit are shown. To override this,
865 specify a hash, branch, or tag (committish). The hash of the commit,
866 the commit's author, log message, and a diff of the changes are shown.
867 """)
868 def cmd_show(self, commit="HEAD"):
869 "[commit]"
870 self._check_git()
871 os.system("git show '%s'" % commit)
873 @short_help("apply the changes in a given commit to the current branch")
874 @long_help("""
875 The argument is the hash, branch, or tag (committish) of the commit to
876 be applied. In general, it only makes sense to apply commits that
877 happened on another branch. The '-r' flag can be used to have the
878 changes in the given commit reversed from the current branch. In
879 general, this only makes sense for commits that happened on the current
880 branch.
881 """)
882 @takes_options("r")
883 def cmd_cherry_pick(self, commit, **flags):
884 "[-r] <commit>"
885 self._check_git()
886 if '-r' in flags:
887 os.system("git revert '%s'" % commit)
888 else:
889 os.system("git cherry-pick '%s'" % commit)
891 @short_help("list, add, or delete configured remote repositories")
892 @long_help("""
893 When invoked with no arguments, this command will show the list of
894 currently configured remote repositories, giving both the name and URL
895 of each. To add a new repository, give the desired name as the first
896 argument and the URL as the second. The '-d' flag can be used to remove
897 a previously added repository.
898 """)
899 @takes_options("d:")
900 def cmd_repo(self, name=None, url=None, **flags):
901 "[<name> <url> | -d <name>]"
902 self._check_git()
903 if name is not None and url is None:
904 raise TypeError
906 if '-d' in flags:
907 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
908 raise YapError("No such repository: %s" % flags['-d'])
909 os.system("git config --unset remote.%s.url" % flags['-d'])
910 os.system("git config --unset remote.%s.fetch" % flags['-d'])
912 if name:
913 if name in [ x[0] for x in self._list_remotes() ]:
914 raise YapError("Repository '%s' already exists" % flags['-d'])
915 os.system("git config remote.%s.url %s" % (name, url))
916 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
918 for remote, url in self._list_remotes():
919 print "%-20s %s" % (remote, url)
921 @short_help("send local commits to a remote repository (*)")
922 @long_help("""
923 When invoked with no arguments, the current branch is synchronized to
924 the tracking branch of the tracking remote. If no tracking remote is
925 specified, the repository will have to be specified on the command line.
926 In that case, the default is to push to a branch with the same name as
927 the current branch. This behavior can be overridden by giving a second
928 argument to specify the remote branch.
930 If the remote branch does not currently exist, the command will abort
931 unless the -c flag is provided. If the remote branch is not a direct
932 descendent of the local branch, the command will abort unless the -f
933 flag is provided. Forcing a push in this way can be problematic to
934 other users of the repository if they are not expecting it.
936 To delete a branch on the remote repository, use the -d flag.
937 """)
938 @takes_options("cdf")
939 def cmd_push(self, repo=None, rhs=None, **flags):
940 "[-c | -d] <repo>"
941 self._check_git()
942 if '-c' in flags and '-d' in flags:
943 raise TypeError
945 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
946 raise YapError("No such repository: %s" % repo)
948 current = get_output("git symbolic-ref HEAD")
949 if not current:
950 raise YapError("Not on a branch!")
952 self._check_rebasing()
954 current = current[0].replace('refs/heads/', '')
955 remote = get_output("git config branch.%s.remote" % current)
956 if repo is None and remote:
957 repo = remote[0]
959 if repo is None:
960 raise YapError("No tracking branch configured; specify destination repository")
962 if rhs is None and remote and remote[0] == repo:
963 merge = get_output("git config branch.%s.merge" % current)
964 if merge:
965 rhs = merge[0]
967 if rhs is None:
968 rhs = "refs/heads/%s" % current
970 if '-c' not in flags and '-d' not in flags:
971 if run_command("git rev-parse --verify refs/remotes/%s/%s"
972 % (repo, rhs.replace('refs/heads/', ''))):
973 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
974 if '-f' not in flags:
975 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo, rhs.replace('refs/heads/', '')))
976 base = get_output("git merge-base HEAD %s" % hash[0])
977 assert base
978 if base[0] != hash[0]:
979 raise YapError("Branch not up-to-date with remote. Update or use -f")
981 self._confirm_push(current, rhs, repo)
982 if '-f' in flags:
983 flags['-f'] = '-f'
985 if '-d' in flags:
986 lhs = ""
987 else:
988 lhs = "refs/heads/%s" % current
989 rc = os.system("git push %s %s %s:%s" % (flags.get('-f', ''), repo, lhs, rhs))
990 if rc:
991 raise YapError("Push failed.")
993 @short_help("retrieve commits from a remote repository")
994 @long_help("""
995 When run with no arguments, the command will retrieve new commits from
996 the remote tracking repository. Note that this does not in any way
997 alter the current branch. For that, see "update". If a remote other
998 than the tracking remote is desired, it can be specified as the first
999 argument.
1000 """)
1001 def cmd_fetch(self, repo=None):
1002 "<repo>"
1003 self._check_git()
1004 current = get_output("git symbolic-ref HEAD")
1005 if not current:
1006 raise YapError("Not on a branch!")
1008 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
1009 raise YapError("No such repository: %s" % repo)
1010 if repo is None:
1011 current = current[0].replace('refs/heads/', '')
1012 remote = get_output("git config branch.%s.remote" % current)
1013 if remote:
1014 repo = remote[0]
1015 if repo is None:
1016 raise YapError("No tracking branch configured; specify a repository")
1017 os.system("git fetch %s" % repo)
1019 @short_help("update the current branch relative to its tracking branch")
1020 @long_help("""
1021 Updates the current branch relative to its remote tracking branch. This
1022 command requires that the current branch have a remote tracking branch
1023 configured. If any conflicts occur while applying your changes to the
1024 updated remote, the command will pause to allow you to fix them. Once
1025 that is done, run "update" with the "continue" subcommand. Alternately,
1026 the "skip" subcommand can be used to discard the conflicting changes.
1027 """)
1028 def cmd_update(self, subcmd=None):
1029 "[continue | skip]"
1030 self._check_git()
1031 if subcmd and subcmd not in ["continue", "skip"]:
1032 raise TypeError
1034 resolvemsg = """
1035 When you have resolved the conflicts run \"yap update continue\".
1036 To skip the problematic patch, run \"yap update skip\"."""
1038 if subcmd == "continue":
1039 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
1040 return
1041 if subcmd == "skip":
1042 os.system("git reset --hard")
1043 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
1044 return
1046 self._check_rebasing()
1047 if self._get_unstaged_files() or self._get_staged_files():
1048 raise YapError("You have uncommitted changes. Commit them first")
1050 current = get_output("git symbolic-ref HEAD")
1051 if not current:
1052 raise YapError("Not on a branch!")
1054 current = current[0].replace('refs/heads/', '')
1055 remote, merge = self._get_tracking(current)
1056 merge = merge.replace('refs/heads/', '')
1058 self.cmd_fetch(remote)
1059 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
1061 try:
1062 fd, tmpfile = tempfile.mkstemp("yap")
1063 os.close(fd)
1064 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
1065 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
1067 stat = os.stat(tmpfile)
1068 size = stat[6]
1069 if size > 0:
1070 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
1071 if (rc):
1072 raise YapError("Failed to apply changes")
1073 finally:
1074 os.unlink(tmpfile)
1076 @short_help("query and configure remote branch tracking")
1077 @long_help("""
1078 When invoked with no arguments, the command displays the tracking
1079 information for the current branch. To configure the tracking
1080 information, two arguments for the remote repository and remote branch
1081 are given. The tracking information is used to provide defaults for
1082 where to push local changes and from where to get updates to the branch.
1083 """)
1084 def cmd_track(self, repo=None, branch=None):
1085 "[<repo> <branch>]"
1086 self._check_git()
1088 current = get_output("git symbolic-ref HEAD")
1089 if not current:
1090 raise YapError("Not on a branch!")
1091 current = current[0].replace('refs/heads/', '')
1093 if repo is None and branch is None:
1094 repo, merge = self._get_tracking(current)
1095 merge = merge.replace('refs/heads/', '')
1096 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
1097 return
1099 if repo is None or branch is None:
1100 raise TypeError
1102 if repo not in [ x[0] for x in self._list_remotes() ]:
1103 raise YapError("No such repository: %s" % repo)
1105 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
1106 raise YapError("No such branch '%s' on repository '%s'" % (branch, repo))
1108 os.system("git config branch.%s.remote '%s'" % (current, repo))
1109 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
1110 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
1112 @short_help("mark files with conflicts as resolved")
1113 @long_help("""
1114 The arguments are the files to be marked resolved. When a conflict
1115 occurs while merging changes to a file, that file is marked as
1116 "unmerged." Until the file(s) with conflicts are marked resolved,
1117 commits cannot be made.
1118 """)
1119 def cmd_resolved(self, *files):
1120 "<file>..."
1121 self._check_git()
1122 if not files:
1123 raise TypeError
1125 for f in files:
1126 self._stage_one(f, True)
1127 self.cmd_status()
1129 @short_help("merge a branch into the current branch")
1130 def cmd_merge(self, branch):
1131 "<branch>"
1132 self._check_git()
1134 branch_name = branch
1135 branch = get_output("git rev-parse --verify %s" % branch)
1136 if not branch:
1137 raise YapError("No such branch: %s" % branch)
1138 branch = branch[0]
1140 base = get_output("git merge-base HEAD %s" % branch)
1141 if not base:
1142 raise YapError("Branch '%s' is not a fork of the current branch"
1143 % branch)
1145 readtree = ("git read-tree --aggressive -u -m %s HEAD %s"
1146 % (base[0], branch))
1147 if run_command(readtree):
1148 run_command("git update-index --refresh")
1149 if os.system(readtree):
1150 raise YapError("Failed to merge")
1152 repo = get_output('git rev-parse --git-dir')[0]
1153 dir = os.path.join(repo, 'yap')
1154 try:
1155 os.mkdir(dir)
1156 except OSError:
1157 pass
1158 msg_file = os.path.join(dir, 'msg')
1159 msg = file(msg_file, 'w')
1160 print >>msg, "Merge branch '%s'" % branch_name
1161 msg.close()
1163 head = get_output("git rev-parse --verify HEAD")
1164 assert head
1165 heads = [head[0], branch]
1166 head_file = os.path.join(dir, 'merge')
1167 pickle.dump(heads, file(head_file, 'w'))
1169 self._merge_index(branch, base[0])
1170 if self._get_unmerged_files():
1171 self.cmd_status()
1172 raise YapError("Fix conflicts then commit")
1174 self._do_commit()
1176 def _merge_index(self, branch, base):
1177 for f in self._get_unmerged_files():
1178 fd, bfile = tempfile.mkstemp("yap")
1179 os.close(fd)
1180 rc = os.system("git show %s:%s > %s" % (base, f, bfile))
1181 assert rc == 0
1183 fd, ofile = tempfile.mkstemp("yap")
1184 os.close(fd)
1185 rc = os.system("git show %s:%s > %s" % (branch, f, ofile))
1186 assert rc == 0
1188 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)
1189 rc = os.system(command)
1190 os.unlink(ofile)
1191 os.unlink(bfile)
1193 assert rc >= 0
1194 if rc == 0:
1195 self._stage_one(f, True)
1197 def cmd_help(self, cmd=None):
1198 if cmd is not None:
1199 oldcmd = cmd
1200 cmd = "cmd_" + cmd.replace('-', '_')
1201 try:
1202 attr = self.__getattribute__(cmd)
1203 except AttributeError:
1204 raise YapError("No such command: %s" % cmd)
1206 try:
1207 help = self._get_attr(cmd, "long_help")
1208 except AttributeError:
1209 raise
1210 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
1212 print >>sys.stderr, "The '%s' command" % oldcmd
1213 print >>sys.stderr, "\tyap %s %s" % (oldcmd, attr.__doc__)
1214 print >>sys.stderr, "%s" % help
1215 return
1217 print >> sys.stderr, "Yet Another (Git) Porcelein"
1218 print >> sys.stderr
1220 for name in dir(self):
1221 if not name.startswith('cmd_'):
1222 continue
1223 attr = self.__getattribute__(name)
1224 if not callable(attr):
1225 continue
1227 try:
1228 short_msg = self._get_attr(name, "short_help")
1229 except AttributeError:
1230 continue
1232 name = name.replace('cmd_', '')
1233 name = name.replace('_', '-')
1234 print >> sys.stderr, "%-16s%s" % (name, short_msg)
1236 print >> sys.stderr
1237 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
1239 @short_help("show information about loaded plugins")
1240 def cmd_plugins(self):
1242 print >> sys.stderr, "Loaded plugins:"
1243 plugins = load_plugins()
1244 for name, cls in plugins.items():
1245 print "\t%-16s: %s" % (name, cls.__doc__)
1246 if not plugins:
1247 print "\t%-16s" % "None"
1249 def cmd_usage(self):
1250 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
1251 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"
1253 def load_plugins():
1254 plugindir = os.path.join("~", ".yap", "plugins")
1255 plugindir = os.path.expanduser(plugindir)
1256 plugindir = os.path.join(plugindir, "*.py")
1258 plugins = dict()
1259 for p in glob.glob(os.path.expanduser(plugindir)):
1260 plugin = os.path.basename(p).replace('.py', '')
1261 m = __import__(plugin)
1262 for k in dir(m):
1263 cls = m.__dict__[k]
1264 if not type(cls) == type:
1265 continue
1266 if not issubclass(cls, YapCore):
1267 continue
1268 if cls is YapCore:
1269 continue
1270 plugins[k] = cls
1271 return plugins
1273 def yap_metaclass(name, bases, dct):
1274 plugindir = os.path.join("~", ".yap", "plugins")
1275 plugindir = os.path.expanduser(plugindir)
1276 sys.path.insert(0, plugindir)
1278 plugins = set(load_plugins().values())
1279 p2 = plugins.copy()
1280 for cls in plugins:
1281 p2 -= set(cls.__bases__)
1282 plugins = p2
1283 bases = list(plugins) + list(bases)
1284 return type(name, tuple(bases), dct)
1286 class Yap(YapCore):
1287 __metaclass__ = yap_metaclass
1289 def main(self, args):
1290 if len(args) < 1:
1291 self.cmd_usage()
1292 sys.exit(2)
1294 command = args[0]
1295 args = args[1:]
1297 if run_command("git --version"):
1298 print >>sys.stderr, "Failed to run git; is it installed?"
1299 sys.exit(1)
1301 debug = os.getenv('YAP_DEBUG')
1303 try:
1304 command = command.replace('-', '_')
1305 meth = self.__getattribute__("cmd_"+command)
1306 doc = self._get_attr("cmd_"+command, "__doc__")
1308 try:
1309 options = ""
1310 for c in self.__class__.__bases__:
1311 try:
1312 t = c.__dict__["cmd_"+command]
1313 except KeyError:
1314 continue
1315 if "options" in t.__dict__:
1316 options += t.options
1318 if options:
1319 try:
1320 flags, args = getopt.getopt(args, options)
1321 flags = dict(flags)
1322 except getopt.GetoptError, e:
1323 if debug:
1324 raise
1325 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1326 print e
1327 sys.exit(2)
1328 else:
1329 flags = dict()
1331 meth(*args, **flags)
1332 except (TypeError, getopt.GetoptError):
1333 if debug:
1334 raise
1335 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1336 except YapError, e:
1337 if debug:
1338 raise
1339 print >> sys.stderr, e
1340 sys.exit(1)
1341 except AttributeError:
1342 if debug:
1343 raise
1344 self.cmd_usage()
1345 sys.exit(2)