Refactor load_plugins() out of yap_metaclass
[yap.git] / yap / yap.py
blob45edcf4786d83f52d8b66b22c0c258084536c60f
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 -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]
225 parent = get_output("git rev-parse --verify HEAD 2> /dev/null")
227 if os.environ.has_key('YAP_EDITOR'):
228 editor = os.environ['YAP_EDITOR']
229 elif os.environ.has_key('GIT_EDITOR'):
230 editor = os.environ['GIT_EDITOR']
231 elif os.environ.has_key('EDITOR'):
232 editor = os.environ['EDITOR']
233 else:
234 editor = "vi"
236 fd, tmpfile = tempfile.mkstemp("yap")
237 os.close(fd)
240 if msg is None:
241 repo = get_output('git rev-parse --git-dir')[0]
242 msg_file = os.path.join(repo, 'yap', 'msg')
243 if os.access(msg_file, os.R_OK):
244 fd1 = file(msg_file)
245 fd2 = file(tmpfile, 'w')
246 for l in fd1.xreadlines():
247 print >>fd2, l.strip()
248 fd2.close()
249 os.unlink(msg_file)
250 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
251 raise YapError("Editing commit message failed")
252 fd = file(tmpfile)
253 msg = fd.readlines()
254 msg = ''.join(msg)
256 msg = msg.strip()
257 if not msg:
258 raise YapError("Refusing to use empty commit message")
260 (fd_w, fd_r) = os.popen2("git stripspace > %s" % tmpfile)
261 print >>fd_w, msg,
262 fd_w.close()
263 fd_r.close()
265 if parent:
266 commit = get_output("git commit-tree '%s' -p '%s' < '%s'" % (tree, parent[0], tmpfile))
267 else:
268 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
270 os.unlink(tmpfile)
271 run_safely("git update-ref HEAD '%s'" % commit[0])
273 def _check_rebasing(self):
274 repo = get_output('git rev-parse --git-dir')[0]
275 dotest = os.path.join(repo, '.dotest')
276 if os.access(dotest, os.R_OK):
277 raise YapError("A git operation is in progress. Complete it first")
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")
282 def _check_git(self):
283 if run_command("git rev-parse --git-dir"):
284 raise YapError("That command must be run from inside a git repository")
286 def _list_remotes(self):
287 remotes = get_output("git config --get-regexp '^remote.*.url'")
288 for x in remotes:
289 remote, url = x.split(' ')
290 remote = remote.replace('remote.', '')
291 remote = remote.replace('.url', '')
292 yield remote, url
294 def _unstage_all(self):
295 try:
296 run_safely("git read-tree -m HEAD")
297 except ShellError:
298 run_safely("git read-tree HEAD")
299 run_safely("git update-index -q --refresh")
301 def _get_tracking(self, current):
302 remote = get_output("git config branch.%s.remote" % current)
303 if not remote:
304 raise YapError("No tracking branch configured for '%s'" % current)
306 merge = get_output("git config branch.%s.merge" % current)
307 if not merge:
308 raise YapError("No tracking branch configured for '%s'" % current)
309 return remote[0], merge[0]
311 def _confirm_push(self, current, rhs, repo):
312 print "About to push local branch '%s' to '%s' on '%s'" % (current, rhs, repo)
313 print "Continue (y/n)? ",
314 sys.stdout.flush()
315 ans = sys.stdin.readline().strip()
317 if ans.lower() != 'y' and ans.lower() != 'yes':
318 raise YapError("Aborted.")
320 def _get_attr(self, name, attr):
321 val = None
322 for c in self.__class__.__bases__:
323 try:
324 m2 = c.__dict__[name]
325 except KeyError:
326 continue
327 try:
328 val = m2.__getattribute__(attr)
329 except AttributeError:
330 continue
331 return val
333 @short_help("make a local copy of an existing repository")
334 @long_help("""
335 The first argument is a URL to the existing repository. This can be an
336 absolute path if the repository is local, or a URL with the git://,
337 ssh://, or http:// schemes. By default, the directory used is the last
338 component of the URL, sans '.git'. This can be overridden by providing
339 a second argument.
340 """)
341 def cmd_clone(self, url, directory=None):
342 "<url> [directory]"
344 if '://' not in url and url[0] != '/':
345 url = os.path.join(os.getcwd(), url)
347 url = url.rstrip('/')
348 if directory is None:
349 directory = url.rsplit('/')[-1]
350 directory = directory.replace('.git', '')
352 try:
353 os.mkdir(directory)
354 except OSError:
355 raise YapError("Directory exists: %s" % directory)
356 os.chdir(directory)
357 self.cmd_init()
358 self.cmd_repo("origin", url)
359 self.cmd_fetch("origin")
361 branch = None
362 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
363 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
364 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
365 if get_output("git rev-parse %s" % b)[0] == hash:
366 branch = b
367 break
368 if branch is None:
369 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
370 branch = "refs/remotes/origin/master"
371 if branch is None:
372 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'")
373 branch = branch[0]
375 hash = get_output("git rev-parse %s" % branch)
376 assert hash
377 branch = branch.replace('refs/remotes/origin/', '')
378 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
379 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
380 self.cmd_revert(**{'-a': 1})
382 @short_help("turn a directory into a repository")
383 @long_help("""
384 Converts the current working directory into a repository. The primary
385 side-effect of this command is the creation of a '.git' subdirectory.
386 No files are added nor commits made.
387 """)
388 def cmd_init(self):
389 os.system("git init")
391 @short_help("add a new file to the repository")
392 @long_help("""
393 The arguments are the files to be added to the repository. Once added,
394 the files will show as "unstaged changes" in the output of 'status'. To
395 reverse the effects of this command, see 'rm'.
396 """)
397 def cmd_add(self, *files):
398 "<file>..."
399 self._check_git()
401 if not files:
402 raise TypeError
404 for f in files:
405 self._add_one(f)
406 self.cmd_status()
408 @short_help("delete a file from the repository")
409 @long_help("""
410 The arguments are the files to be removed from the current revision of
411 the repository. The files will still exist in any past commits that the
412 files may have been a part of. The file is not actually deleted, it is
413 just no longer tracked as part of the repository.
414 """)
415 def cmd_rm(self, *files):
416 "<file>..."
417 self._check_git()
418 if not files:
419 raise TypeError
421 for f in files:
422 self._rm_one(f)
423 self.cmd_status()
425 @short_help("stage changes in a file for commit")
426 @long_help("""
427 The arguments are the files to be staged. Staging changes is a way to
428 build up a commit when you do not want to commit all changes at once.
429 To commit only staged changes, use the '-d' flag to 'commit.' To
430 reverse the effects of this command, see 'unstage'. Once staged, the
431 files will show as "staged changes" in the output of 'status'.
432 """)
433 def cmd_stage(self, *files):
434 "<file>..."
435 self._check_git()
436 if not files:
437 raise TypeError
439 for f in files:
440 self._stage_one(f)
441 self.cmd_status()
443 @short_help("unstage changes in a file")
444 @long_help("""
445 The arguments are the files to be unstaged. Once unstaged, the files
446 will show as "unstaged changes" in the output of 'status'. The '-a'
447 flag can be used to unstage all staged changes at once.
448 """)
449 @takes_options("a")
450 def cmd_unstage(self, *files, **flags):
451 "[-a] | <file>..."
452 self._check_git()
453 if '-a' in flags:
454 self._unstage_all()
455 self.cmd_status()
456 return
458 if not files:
459 raise TypeError
461 for f in files:
462 self._unstage_one(f)
463 self.cmd_status()
465 @short_help("show files with staged and unstaged changes")
466 @long_help("""
467 Show the files in the repository with changes since the last commit,
468 categorized based on whether the changes are staged or not. A file may
469 appear under each heading if the same file has both staged and unstaged
470 changes.
471 """)
472 def cmd_status(self):
474 self._check_git()
475 branch = get_output("git symbolic-ref HEAD")
476 if branch:
477 branch = branch[0].replace('refs/heads/', '')
478 else:
479 branch = "DETACHED"
480 print "Current branch: %s" % branch
482 print "Files with staged changes:"
483 files = self._get_staged_files()
484 for f in files:
485 print "\t%s" % f
486 if not files:
487 print "\t(none)"
489 print "Files with unstaged changes:"
490 files = self._get_unstaged_files()
491 for f in files:
492 print "\t%s" % f
493 if not files:
494 print "\t(none)"
496 files = self._get_unmerged_files()
497 if files:
498 print "Files with conflicts:"
499 for f in files:
500 print "\t%s" % f
502 @short_help("remove uncommitted changes from a file (*)")
503 @long_help("""
504 The arguments are the files whose changes will be reverted. If the '-a'
505 flag is given, then all files will have uncommitted changes removed.
506 Note that there is no way to reverse this command short of manually
507 editing each file again.
508 """)
509 @takes_options("a")
510 def cmd_revert(self, *files, **flags):
511 "(-a | <file>)"
512 self._check_git()
513 if '-a' in flags:
514 self._unstage_all()
515 run_safely("git checkout-index -u -f -a")
516 self.cmd_status()
517 return
519 if not files:
520 raise TypeError
522 for f in files:
523 self._revert_one(f)
524 self.cmd_status()
526 @short_help("record changes to files as a new commit")
527 @long_help("""
528 Create a new commit recording changes since the last commit. If there
529 are only unstaged changes, those will be recorded. If there are only
530 staged changes, those will be recorded. Otherwise, you will have to
531 specify either the '-a' flag or the '-d' flag to commit all changes or
532 only staged changes, respectively. To reverse the effects of this
533 command, see 'uncommit'.
534 """)
535 @takes_options("adm:")
536 def cmd_commit(self, **flags):
537 "[-a | -d] [-m <msg>]"
538 self._check_git()
539 self._check_rebasing()
540 self._check_commit(**flags)
541 if not self._get_staged_files():
542 raise YapError("No changes to commit")
543 msg = flags.get('-m', None)
544 self._do_commit(msg)
545 self.cmd_status()
547 @short_help("reverse the actions of the last commit")
548 @long_help("""
549 Reverse the effects of the last 'commit' operation. The changes that
550 were part of the previous commit will show as "staged changes" in the
551 output of 'status'. This means that if no files were changed since the
552 last commit was created, 'uncommit' followed by 'commit' is a lossless
553 operation.
554 """)
555 def cmd_uncommit(self):
557 self._check_git()
558 self._do_uncommit()
559 self.cmd_status()
561 @short_help("report the current version of yap")
562 def cmd_version(self):
563 print "Yap version 0.1"
565 @short_help("show the changelog for particular versions or files")
566 @long_help("""
567 The arguments are the files with which to filter history. If none are
568 given, all changes are listed. Otherwise only commits that affected one
569 or more of the given files are listed. The -r option changes the
570 starting revision for traversing history. By default, history is listed
571 starting at HEAD.
572 """)
573 @takes_options("pr:")
574 def cmd_log(self, *paths, **flags):
575 "[-p] [-r <rev>] <path>..."
576 self._check_git()
577 rev = flags.get('-r', 'HEAD')
579 if '-p' in flags:
580 flags['-p'] = '-p'
582 if len(paths) == 1:
583 follow = "--follow"
584 else:
585 follow = ""
586 paths = ' '.join(paths)
587 os.system("git log -M -C %s %s '%s' -- %s"
588 % (follow, flags.get('-p', '--name-status'), rev, paths))
590 @short_help("show staged, unstaged, or all uncommitted changes")
591 @long_help("""
592 Show staged, unstaged, or all uncommitted changes. By default, all
593 changes are shown. The '-u' flag causes only unstaged changes to be
594 shown. The '-d' flag causes only staged changes to be shown.
595 """)
596 @takes_options("ud")
597 def cmd_diff(self, **flags):
598 "[ -u | -d ]"
599 self._check_git()
600 if '-u' in flags and '-d' in flags:
601 raise YapError("Conflicting flags: -u and -d")
603 pager = self._get_pager_cmd()
605 if '-u' in flags:
606 os.system("git diff-files -p | %s" % pager)
607 elif '-d' in flags:
608 os.system("git diff-index --cached -p HEAD | %s" % pager)
609 else:
610 os.system("git diff-index -p HEAD | %s" % pager)
612 @short_help("list, create, or delete branches")
613 @long_help("""
614 If no arguments are specified, a list of local branches is given. The
615 current branch is indicated by a "*" next to the name. If an argument
616 is given, it is taken as the name of a new branch to create. The branch
617 will start pointing at the current HEAD. See 'point' for details on
618 changing the revision of the new branch. Note that this command does
619 not switch the current working branch. See 'switch' for details on
620 changing the current working branch.
622 The '-d' flag can be used to delete local branches. If the delete
623 operation would remove the last branch reference to a given line of
624 history (colloquially referred to as "dangling commits"), yap will
625 report an error and abort. The '-f' flag can be used to force the delete
626 in spite of this.
627 """)
628 @takes_options("fd:")
629 def cmd_branch(self, branch=None, **flags):
630 "[ [-f] -d <branch> | <branch> ]"
631 self._check_git()
632 force = '-f' in flags
633 if '-d' in flags:
634 self._delete_branch(flags['-d'], force)
635 self.cmd_branch()
636 return
638 if branch is not None:
639 ref = get_output("git rev-parse --verify HEAD")
640 if not ref:
641 raise YapError("No branch point yet. Make a commit")
642 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
644 current = get_output("git symbolic-ref HEAD")
645 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
646 for b in branches:
647 if current and b == current[0]:
648 print "* ",
649 else:
650 print " ",
651 b = b.replace('refs/heads/', '')
652 print b
654 @short_help("change the current working branch")
655 @long_help("""
656 The argument is the name of the branch to make the current working
657 branch. This command will fail if there are uncommitted changes to any
658 files. Otherwise, the contents of the files in the working directory
659 are updated to reflect their state in the new branch. Additionally, any
660 future commits are added to the new branch instead of the previous line
661 of history.
662 """)
663 @takes_options("f")
664 def cmd_switch(self, branch, **flags):
665 "[-f] <branch>"
666 self._check_git()
667 self._check_rebasing()
668 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
669 if not ref:
670 raise YapError("No such branch: %s" % branch)
672 if '-f' not in flags:
673 if (self._get_staged_files()
674 or (self._get_unstaged_files()
675 and run_command("git update-index --refresh"))):
676 raise YapError("You have uncommitted changes. Use -f to continue anyway")
678 if self._get_unstaged_files() and self._get_staged_files():
679 raise YapError("You have staged and unstaged changes. Perhaps unstage -a?")
681 staged = bool(self._get_staged_files())
683 run_command("git diff-files -p | git apply --cached")
684 for f in self._get_new_files():
685 self._stage_one(f)
687 idx = get_output("git write-tree")
688 new = get_output("git rev-parse refs/heads/%s" % branch)
689 readtree = "git read-tree --aggressive -u -m HEAD %s %s" % (idx[0], new[0])
690 if run_command(readtree):
691 run_command("git update-index --refresh")
692 if os.system(readtree):
693 raise YapError("Failed to switch")
694 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
696 if not staged:
697 self._unstage_all()
698 self.cmd_status()
700 @short_help("move the current branch to a different revision")
701 @long_help("""
702 The argument is the hash of the commit to which the current branch
703 should point, or alternately a branch or tag (a.k.a, "committish"). If
704 moving the branch would create "dangling commits" (see 'branch'), yap
705 will report an error and abort. The '-f' flag can be used to force the
706 operation in spite of this.
707 """)
708 @takes_options("f")
709 def cmd_point(self, where, **flags):
710 "[-f] <where>"
711 self._check_git()
712 self._check_rebasing()
714 head = get_output("git rev-parse --verify HEAD")
715 if not head:
716 raise YapError("No commit yet; nowhere to point")
718 ref = get_output("git rev-parse --verify '%s^{commit}'" % where)
719 if not ref:
720 raise YapError("Not a valid ref: %s" % where)
722 if self._get_unstaged_files() or self._get_staged_files():
723 raise YapError("You have uncommitted changes. Commit them first")
725 run_safely("git update-ref HEAD '%s'" % ref[0])
727 if '-f' not in flags:
728 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
729 if name == "undefined":
730 os.system("git update-ref HEAD '%s'" % head[0])
731 raise YapError("Pointing there will lose commits. Use -f to force")
733 try:
734 run_safely("git read-tree -u -m HEAD")
735 except ShellError:
736 run_safely("git read-tree HEAD")
737 run_safely("git checkout-index -u -f -a")
739 @short_help("alter history by dropping or amending commits")
740 @long_help("""
741 This command operates in two distinct modes, "amend" and "drop" mode.
742 In drop mode, the given commit is removed from the history of the
743 current branch, as though that commit never happened. By default the
744 commit used is HEAD.
746 In amend mode, the uncommitted changes present are merged into a
747 previous commit. This is useful for correcting typos or adding missed
748 files into past commits. By default the commit used is HEAD.
750 While rewriting history it is possible that conflicts will arise. If
751 this happens, the rewrite will pause and you will be prompted to resolve
752 the conflicts and stage them. Once that is done, you will run "yap
753 history continue." If instead you want the conflicting commit removed
754 from history (perhaps your changes supercede that commit) you can run
755 "yap history skip". Once the rewrite completes, your branch will be on
756 the same commit as when the rewrite started.
757 """)
758 def cmd_history(self, subcmd, *args):
759 "amend | drop <commit>"
760 self._check_git()
762 if subcmd not in ("amend", "drop", "continue", "skip"):
763 raise TypeError
765 resolvemsg = """
766 When you have resolved the conflicts run \"yap history continue\".
767 To skip the problematic patch, run \"yap history skip\"."""
769 if subcmd == "continue":
770 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
771 return
772 if subcmd == "skip":
773 os.system("git reset --hard")
774 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
775 return
777 if subcmd == "amend":
778 flags, args = getopt.getopt(args, "ad")
779 flags = dict(flags)
781 if len(args) > 1:
782 raise TypeError
783 if args:
784 commit = args[0]
785 else:
786 commit = "HEAD"
788 if run_command("git rev-parse --verify '%s'" % commit):
789 raise YapError("Not a valid commit: %s" % commit)
791 self._check_rebasing()
793 if subcmd == "amend":
794 self._check_commit(**flags)
795 if self._get_unstaged_files():
796 # XXX: handle unstaged changes better
797 raise YapError("Commit away changes that you aren't amending")
799 self._unstage_all()
801 start = get_output("git rev-parse HEAD")
802 stash = get_output("git stash create")
803 run_command("git reset --hard")
804 try:
805 fd, tmpfile = tempfile.mkstemp("yap")
806 try:
807 try:
808 os.close(fd)
809 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
810 if subcmd == "amend":
811 self.cmd_point(commit, **{'-f': True})
812 finally:
813 if subcmd == "amend":
814 if stash:
815 rc = os.system("git stash apply %s" % stash[0])
816 if rc:
817 self.cmd_point(start[0], **{'-f': True})
818 os.system("git stash apply %s" % stash[0])
819 raise YapError("Failed to apply stash")
820 stash = None
822 if subcmd == "amend":
823 self._do_uncommit()
824 self._check_commit(**{'-a': True})
825 self._do_commit()
826 else:
827 self.cmd_point("%s^" % commit, **{'-f': True})
829 stat = os.stat(tmpfile)
830 size = stat[6]
831 if size > 0:
832 run_safely("git update-index --refresh")
833 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
834 if (rc):
835 raise YapError("Failed to apply changes")
836 finally:
837 os.unlink(tmpfile)
838 finally:
839 if stash:
840 run_command("git stash apply %s" % stash[0])
841 self.cmd_status()
843 @short_help("show the changes introduced by a given commit")
844 @long_help("""
845 By default, the changes in the last commit are shown. To override this,
846 specify a hash, branch, or tag (committish). The hash of the commit,
847 the commit's author, log message, and a diff of the changes are shown.
848 """)
849 def cmd_show(self, commit="HEAD"):
850 "[commit]"
851 self._check_git()
852 os.system("git show '%s'" % commit)
854 @short_help("apply the changes in a given commit to the current branch")
855 @long_help("""
856 The argument is the hash, branch, or tag (committish) of the commit to
857 be applied. In general, it only makes sense to apply commits that
858 happened on another branch. The '-r' flag can be used to have the
859 changes in the given commit reversed from the current branch. In
860 general, this only makes sense for commits that happened on the current
861 branch.
862 """)
863 @takes_options("r")
864 def cmd_cherry_pick(self, commit, **flags):
865 "[-r] <commit>"
866 self._check_git()
867 if '-r' in flags:
868 os.system("git revert '%s'" % commit)
869 else:
870 os.system("git cherry-pick '%s'" % commit)
872 @short_help("list, add, or delete configured remote repositories")
873 @long_help("""
874 When invoked with no arguments, this command will show the list of
875 currently configured remote repositories, giving both the name and URL
876 of each. To add a new repository, give the desired name as the first
877 argument and the URL as the second. The '-d' flag can be used to remove
878 a previously added repository.
879 """)
880 @takes_options("d:")
881 def cmd_repo(self, name=None, url=None, **flags):
882 "[<name> <url> | -d <name>]"
883 self._check_git()
884 if name is not None and url is None:
885 raise TypeError
887 if '-d' in flags:
888 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
889 raise YapError("No such repository: %s" % flags['-d'])
890 os.system("git config --unset remote.%s.url" % flags['-d'])
891 os.system("git config --unset remote.%s.fetch" % flags['-d'])
893 if name:
894 if name in [ x[0] for x in self._list_remotes() ]:
895 raise YapError("Repository '%s' already exists" % flags['-d'])
896 os.system("git config remote.%s.url %s" % (name, url))
897 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
899 for remote, url in self._list_remotes():
900 print "%-20s %s" % (remote, url)
902 @short_help("send local commits to a remote repository (*)")
903 @long_help("""
904 When invoked with no arguments, the current branch is synchronized to
905 the tracking branch of the tracking remote. If no tracking remote is
906 specified, the repository will have to be specified on the command line.
907 In that case, the default is to push to a branch with the same name as
908 the current branch. This behavior can be overridden by giving a second
909 argument to specify the remote branch.
911 If the remote branch does not currently exist, the command will abort
912 unless the -c flag is provided. If the remote branch is not a direct
913 descendent of the local branch, the command will abort unless the -f
914 flag is provided. Forcing a push in this way can be problematic to
915 other users of the repository if they are not expecting it.
917 To delete a branch on the remote repository, use the -d flag.
918 """)
919 @takes_options("cdf")
920 def cmd_push(self, repo=None, rhs=None, **flags):
921 "[-c | -d] <repo>"
922 self._check_git()
923 if '-c' in flags and '-d' in flags:
924 raise TypeError
926 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
927 raise YapError("No such repository: %s" % repo)
929 current = get_output("git symbolic-ref HEAD")
930 if not current:
931 raise YapError("Not on a branch!")
933 self._check_rebasing()
935 current = current[0].replace('refs/heads/', '')
936 remote = get_output("git config branch.%s.remote" % current)
937 if repo is None and remote:
938 repo = remote[0]
940 if repo is None:
941 raise YapError("No tracking branch configured; specify destination repository")
943 if rhs is None and remote and remote[0] == repo:
944 merge = get_output("git config branch.%s.merge" % current)
945 if merge:
946 rhs = merge[0]
948 if rhs is None:
949 rhs = "refs/heads/%s" % current
951 if '-c' not in flags and '-d' not in flags:
952 if run_command("git rev-parse --verify refs/remotes/%s/%s"
953 % (repo, rhs.replace('refs/heads/', ''))):
954 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
955 if '-f' not in flags:
956 hash = get_output("git rev-parse refs/remotes/%s/%s" % (repo, rhs.replace('refs/heads/', '')))
957 base = get_output("git merge-base HEAD %s" % hash[0])
958 assert base
959 if base[0] != hash[0]:
960 raise YapError("Branch not up-to-date with remote. Update or use -f")
962 self._confirm_push(current, rhs, repo)
963 if '-f' in flags:
964 flags['-f'] = '-f'
966 if '-d' in flags:
967 lhs = ""
968 else:
969 lhs = "refs/heads/%s" % current
970 rc = os.system("git push %s %s %s:%s" % (flags.get('-f', ''), repo, lhs, rhs))
971 if rc:
972 raise YapError("Push failed.")
974 @short_help("retrieve commits from a remote repository")
975 @long_help("""
976 When run with no arguments, the command will retrieve new commits from
977 the remote tracking repository. Note that this does not in any way
978 alter the current branch. For that, see "update". If a remote other
979 than the tracking remote is desired, it can be specified as the first
980 argument.
981 """)
982 def cmd_fetch(self, repo=None):
983 "<repo>"
984 self._check_git()
985 current = get_output("git symbolic-ref HEAD")
986 if not current:
987 raise YapError("Not on a branch!")
989 if repo and repo not in [ x[0] for x in self._list_remotes() ]:
990 raise YapError("No such repository: %s" % repo)
991 if repo is None:
992 current = current[0].replace('refs/heads/', '')
993 remote = get_output("git config branch.%s.remote" % current)
994 if remote:
995 repo = remote[0]
996 if repo is None:
997 raise YapError("No tracking branch configured; specify a repository")
998 os.system("git fetch %s" % repo)
1000 @short_help("update the current branch relative to its tracking branch")
1001 @long_help("""
1002 Updates the current branch relative to its remote tracking branch. This
1003 command requires that the current branch have a remote tracking branch
1004 configured. If any conflicts occur while applying your changes to the
1005 updated remote, the command will pause to allow you to fix them. Once
1006 that is done, run "update" with the "continue" subcommand. Alternately,
1007 the "skip" subcommand can be used to discard the conflicting changes.
1008 """)
1009 def cmd_update(self, subcmd=None):
1010 "[continue | skip]"
1011 self._check_git()
1012 if subcmd and subcmd not in ["continue", "skip"]:
1013 raise TypeError
1015 resolvemsg = """
1016 When you have resolved the conflicts run \"yap update continue\".
1017 To skip the problematic patch, run \"yap update skip\"."""
1019 if subcmd == "continue":
1020 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
1021 return
1022 if subcmd == "skip":
1023 os.system("git reset --hard")
1024 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
1025 return
1027 self._check_rebasing()
1028 if self._get_unstaged_files() or self._get_staged_files():
1029 raise YapError("You have uncommitted changes. Commit them first")
1031 current = get_output("git symbolic-ref HEAD")
1032 if not current:
1033 raise YapError("Not on a branch!")
1035 current = current[0].replace('refs/heads/', '')
1036 remote, merge = self._get_tracking(current)
1037 merge = merge.replace('refs/heads/', '')
1039 self.cmd_fetch(remote)
1040 base = get_output("git merge-base HEAD refs/remotes/%s/%s" % (remote, merge))
1042 try:
1043 fd, tmpfile = tempfile.mkstemp("yap")
1044 os.close(fd)
1045 os.system("git format-patch -k --stdout '%s' > %s" % (base[0], tmpfile))
1046 self.cmd_point("refs/remotes/%s/%s" % (remote, merge), **{'-f': True})
1048 stat = os.stat(tmpfile)
1049 size = stat[6]
1050 if size > 0:
1051 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
1052 if (rc):
1053 raise YapError("Failed to apply changes")
1054 finally:
1055 os.unlink(tmpfile)
1057 @short_help("query and configure remote branch tracking")
1058 @long_help("""
1059 When invoked with no arguments, the command displays the tracking
1060 information for the current branch. To configure the tracking
1061 information, two arguments for the remote repository and remote branch
1062 are given. The tracking information is used to provide defaults for
1063 where to push local changes and from where to get updates to the branch.
1064 """)
1065 def cmd_track(self, repo=None, branch=None):
1066 "[<repo> <branch>]"
1067 self._check_git()
1069 current = get_output("git symbolic-ref HEAD")
1070 if not current:
1071 raise YapError("Not on a branch!")
1072 current = current[0].replace('refs/heads/', '')
1074 if repo is None and branch is None:
1075 repo, merge = self._get_tracking(current)
1076 merge = merge.replace('refs/heads/', '')
1077 print "Branch '%s' tracking refs/remotes/%s/%s" % (current, repo, merge)
1078 return
1080 if repo is None or branch is None:
1081 raise TypeError
1083 if repo not in [ x[0] for x in self._list_remotes() ]:
1084 raise YapError("No such repository: %s" % repo)
1086 if run_command("git rev-parse --verify refs/remotes/%s/%s" % (repo, branch)):
1087 raise YapError("No such branch '%s' on repository '%s'" % (branch, repo))
1089 os.system("git config branch.%s.remote '%s'" % (current, repo))
1090 os.system("git config branch.%s.merge 'refs/heads/%s'" % (current, branch))
1091 print "Branch '%s' now tracking refs/remotes/%s/%s" % (current, repo, branch)
1093 @short_help("mark files with conflicts as resolved")
1094 @long_help("""
1095 The arguments are the files to be marked resolved. When a conflict
1096 occurs while merging changes to a file, that file is marked as
1097 "unmerged." Until the file(s) with conflicts are marked resolved,
1098 commits cannot be made.
1099 """)
1100 def cmd_resolved(self, *args):
1101 "<file>..."
1102 self._check_git()
1103 if not files:
1104 raise TypeError
1106 for f in files:
1107 self._stage_one(f, True)
1108 self.cmd_status()
1110 def cmd_help(self, cmd=None):
1111 if cmd is not None:
1112 oldcmd = cmd
1113 cmd = "cmd_" + cmd.replace('-', '_')
1114 try:
1115 attr = self.__getattribute__(cmd)
1116 except AttributeError:
1117 raise YapError("No such command: %s" % cmd)
1119 try:
1120 help = self._get_attr(cmd, "long_help")
1121 except AttributeError:
1122 raise
1123 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
1125 print >>sys.stderr, "The '%s' command" % oldcmd
1126 print >>sys.stderr, "\tyap %s %s" % (oldcmd, attr.__doc__)
1127 print >>sys.stderr, "%s" % help
1128 return
1130 print >> sys.stderr, "Yet Another (Git) Porcelein"
1131 print >> sys.stderr
1133 for name in dir(self):
1134 if not name.startswith('cmd_'):
1135 continue
1136 attr = self.__getattribute__(name)
1137 if not callable(attr):
1138 continue
1140 try:
1141 short_msg = self._get_attr(name, "short_help")
1142 except AttributeError:
1143 continue
1145 name = name.replace('cmd_', '')
1146 name = name.replace('_', '-')
1147 print >> sys.stderr, "%-16s%s" % (name, short_msg)
1149 print >> sys.stderr
1150 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
1152 def cmd_usage(self):
1153 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
1154 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"
1156 def load_plugins():
1157 plugindir = os.path.join("~", ".yap", "plugins")
1158 plugindir = os.path.expanduser(plugindir)
1159 plugindir = os.path.join(plugindir, "*.py")
1161 plugins = dict()
1162 for p in glob.glob(os.path.expanduser(plugindir)):
1163 plugin = os.path.basename(p).replace('.py', '')
1164 m = __import__(plugin)
1165 for k in dir(m):
1166 cls = m.__dict__[k]
1167 if not type(cls) == type:
1168 continue
1169 if not issubclass(cls, YapCore):
1170 continue
1171 if cls is YapCore:
1172 continue
1173 plugins[k] = cls
1174 return plugins
1176 def yap_metaclass(name, bases, dct):
1177 plugindir = os.path.join("~", ".yap", "plugins")
1178 plugindir = os.path.expanduser(plugindir)
1179 sys.path.insert(0, plugindir)
1181 plugins = set(load_plugins().values())
1182 p2 = plugins.copy()
1183 for cls in plugins:
1184 p2 -= set(cls.__bases__)
1185 plugins = p2
1186 bases = list(plugins) + list(bases)
1187 return type(name, tuple(bases), dct)
1189 class Yap(YapCore):
1190 __metaclass__ = yap_metaclass
1192 def main(self, args):
1193 if len(args) < 1:
1194 self.cmd_usage()
1195 sys.exit(2)
1197 command = args[0]
1198 args = args[1:]
1200 if run_command("git --version"):
1201 print >>sys.stderr, "Failed to run git; is it installed?"
1202 sys.exit(1)
1204 debug = os.getenv('YAP_DEBUG')
1206 try:
1207 command = command.replace('-', '_')
1208 meth = self.__getattribute__("cmd_"+command)
1209 doc = self._get_attr("cmd_"+command, "__doc__")
1211 try:
1212 options = ""
1213 for c in self.__class__.__bases__:
1214 try:
1215 t = c.__dict__["cmd_"+command]
1216 except KeyError:
1217 continue
1218 if "options" in t.__dict__:
1219 options += t.options
1221 if options:
1222 try:
1223 flags, args = getopt.getopt(args, options)
1224 flags = dict(flags)
1225 except getopt.GetoptError, e:
1226 if debug:
1227 raise
1228 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1229 print e
1230 sys.exit(2)
1231 else:
1232 flags = dict()
1234 meth(*args, **flags)
1235 except (TypeError, getopt.GetoptError):
1236 if debug:
1237 raise
1238 print "Usage: %s %s %s" % (os.path.basename(sys.argv[0]), command, doc)
1239 except YapError, e:
1240 if debug:
1241 raise
1242 print >> sys.stderr, e
1243 sys.exit(1)
1244 except AttributeError:
1245 if debug:
1246 raise
1247 self.cmd_usage()
1248 sys.exit(2)