cmd_commit: forbid empty commit messages
[yap.git] / yap / yap.py
blob86af1b2473f3e0657f197e90f411d4011f68cafa
1 import sys
2 import os
3 import glob
4 import getopt
5 import pickle
6 import tempfile
8 from plugin import YapPlugin
9 from util import *
11 class ShellError(Exception):
12 def __init__(self, cmd, rc):
13 self.cmd = cmd
14 self.rc = rc
16 def __str__(self):
17 return "%s returned %d" % (self.cmd, self.rc)
19 class YapError(Exception):
20 def __init__(self, msg):
21 self.msg = msg
23 def __str__(self):
24 return self.msg
26 class Yap(object):
27 def __init__(self):
28 self.plugins = set()
29 self.overrides = []
30 plugindir = os.path.expanduser("~/.yap/plugins")
31 for p in glob.glob(os.path.join(plugindir, "*.py")):
32 glbls = {}
33 execfile(p, glbls)
34 for cls in glbls.values():
35 if not type(cls) == type:
36 continue
37 if not issubclass(cls, YapPlugin):
38 continue
39 if cls is YapPlugin:
40 continue
41 x = cls(self)
42 self.plugins.add(x)
44 for func in dir(x):
45 if not func.startswith('cmd_'):
46 continue
47 if func in self.overrides:
48 print >>sys.stderr, "Plugin %s overrides already overridden function %s. Disabling" % (p, func)
49 self.plugins.remove(x)
50 break
52 def _add_new_file(self, file):
53 repo = get_output('git rev-parse --git-dir')[0]
54 dir = os.path.join(repo, 'yap')
55 try:
56 os.mkdir(dir)
57 except OSError:
58 pass
59 files = self._get_new_files()
60 files.append(file)
61 path = os.path.join(dir, 'new-files')
62 pickle.dump(files, open(path, 'w'))
64 def _get_new_files(self):
65 repo = get_output('git rev-parse --git-dir')[0]
66 path = os.path.join(repo, 'yap', 'new-files')
67 try:
68 files = pickle.load(file(path))
69 except IOError:
70 files = []
72 x = []
73 for f in files:
74 # if f in the index
75 if get_output("git ls-files --cached '%s'" % f) != []:
76 continue
77 x.append(f)
78 return x
80 def _remove_new_file(self, file):
81 files = self._get_new_files()
82 files = filter(lambda x: x != file, files)
84 repo = get_output('git rev-parse --git-dir')[0]
85 path = os.path.join(repo, 'yap', 'new-files')
86 pickle.dump(files, open(path, 'w'))
88 def _clear_new_files(self):
89 repo = get_output('git rev-parse --git-dir')[0]
90 path = os.path.join(repo, 'yap', 'new-files')
91 os.unlink(path)
93 def _assert_file_exists(self, file):
94 if not os.access(file, os.R_OK):
95 raise YapError("No such file: %s" % file)
97 def _get_staged_files(self):
98 if run_command("git rev-parse HEAD"):
99 files = get_output("git ls-files --cached")
100 else:
101 files = get_output("git diff-index --cached --name-only HEAD")
102 return files
104 def _get_unstaged_files(self):
105 files = self._get_new_files()
106 files += get_output("git ls-files -m")
107 return files
109 def _delete_branch(self, branch, force):
110 current = get_output("git symbolic-ref HEAD")[0]
111 current = current.replace('refs/heads/', '')
112 if branch == current:
113 raise YapError("Can't delete current branch")
115 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
116 if not ref:
117 raise YapError("No such branch: %s" % branch)
118 run_safely("git update-ref -d 'refs/heads/%s' '%s'" % (branch, ref[0]))
120 if not force:
121 name = get_output("git name-rev --name-only '%s'" % ref[0])[0]
122 if name == 'undefined':
123 run_command("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
124 raise YapError("Refusing to delete leaf branch (use -f to force)")
125 def _get_pager_cmd(self):
126 if 'YAP_PAGER' in os.environ:
127 return os.environ['YAP_PAGER']
128 elif 'GIT_PAGER' in os.environ:
129 return os.environ['GIT_PAGER']
130 elif 'PAGER' in os.environ:
131 return os.environ['PAGER']
132 else:
133 return "more"
135 def _add_one(self, file):
136 self._assert_file_exists(file)
137 x = get_output("git ls-files '%s'" % file)
138 if x != []:
139 raise YapError("File '%s' already in repository" % file)
140 self._add_new_file(file)
142 def _rm_one(self, file):
143 self._assert_file_exists(file)
144 if get_output("git ls-files '%s'" % file) != []:
145 run_safely("git rm --cached '%s'" % file)
146 self._remove_new_file(file)
148 def _stage_one(self, file):
149 self._assert_file_exists(file)
150 run_safely("git update-index --add '%s'" % file)
152 def _unstage_one(self, file):
153 self._assert_file_exists(file)
154 if run_command("git rev-parse HEAD"):
155 run_safely("git update-index --force-remove '%s'" % file)
156 else:
157 run_safely("git diff-index -p HEAD '%s' | git apply -R --cached" % file)
159 def _revert_one(self, file):
160 self._assert_file_exists(file)
161 self._unstage_one(file)
162 run_safely("git checkout-index -u -f '%s'" % file)
164 def _parse_commit(self, commit):
165 lines = get_output("git cat-file commit '%s'" % commit)
166 commit = {}
168 mode = None
169 for l in lines:
170 if mode != 'commit' and l.strip() == "":
171 mode = 'commit'
172 commit['log'] = []
173 continue
174 if mode == 'commit':
175 commit['log'].append(l)
176 continue
178 x = l.split(' ')
179 k = x[0]
180 v = ' '.join(x[1:])
181 commit[k] = v
182 commit['log'] = '\n'.join(commit['log'])
183 return commit
185 def _check_commit(self, **flags):
186 if '-a' in flags and '-d' in flags:
187 raise YapError("Conflicting flags: -a and -d")
189 if '-d' not in flags and self._get_unstaged_files():
190 if '-a' not in flags and self._get_staged_files():
191 raise YapError("Staged and unstaged changes present. Specify what to commit")
192 os.system("git diff-files -p | git apply --cached")
193 for f in self._get_new_files():
194 self._stage_one(f)
196 def _do_uncommit(self):
197 commit = self._parse_commit("HEAD")
198 repo = get_output('git rev-parse --git-dir')[0]
199 dir = os.path.join(repo, 'yap')
200 try:
201 os.mkdir(dir)
202 except OSError:
203 pass
204 msg_file = os.path.join(dir, 'msg')
205 fd = file(msg_file, 'w')
206 print >>fd, commit['log']
207 fd.close()
209 tree = get_output("git rev-parse --verify HEAD^")
210 run_safely("git update-ref -m uncommit HEAD '%s'" % tree[0])
212 def _do_commit(self, msg=None):
213 tree = get_output("git write-tree")[0]
214 parent = get_output("git rev-parse --verify HEAD 2> /dev/null")[0]
216 if os.environ.has_key('YAP_EDITOR'):
217 editor = os.environ['YAP_EDITOR']
218 elif os.environ.has_key('GIT_EDITOR'):
219 editor = os.environ['GIT_EDITOR']
220 elif os.environ.has_key('EDITOR'):
221 editor = os.environ['EDITOR']
222 else:
223 editor = "vi"
225 fd, tmpfile = tempfile.mkstemp("yap")
226 os.close(fd)
229 if msg is None:
230 repo = get_output('git rev-parse --git-dir')[0]
231 msg_file = os.path.join(repo, 'yap', 'msg')
232 if os.access(msg_file, os.R_OK):
233 fd1 = file(msg_file)
234 fd2 = file(tmpfile, 'w')
235 for l in fd1.xreadlines():
236 print >>fd2, l.strip()
237 fd2.close()
238 os.unlink(msg_file)
239 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
240 raise YapError("Editing commit message failed")
241 fd = file(tmpfile)
242 msg = fd.readlines()
243 msg = ''.join(msg)
245 msg = msg.strip()
246 if not msg:
247 raise YapError("Refusing to use empty commit message")
249 (fd_w, fd_r) = os.popen2("git stripspace > %s" % tmpfile)
250 print >>fd_w, msg,
251 fd_w.close()
252 fd_r.close()
254 if parent != 'HEAD':
255 commit = get_output("git commit-tree '%s' -p '%s' < '%s'" % (tree, parent, tmpfile))
256 else:
257 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
259 os.unlink(tmpfile)
260 run_safely("git update-ref HEAD '%s'" % commit[0])
262 def _check_rebasing(self):
263 repo = get_output('git rev-parse --git-dir')[0]
264 dotest = os.path.join(repo, '.dotest')
265 if os.access(dotest, os.R_OK):
266 raise YapError("A git operation is in progress. Complete it first")
267 dotest = os.path.join(repo, '..', '.dotest')
268 if os.access(dotest, os.R_OK):
269 raise YapError("A git operation is in progress. Complete it first")
271 def _list_remotes(self):
272 remotes = get_output("git config --get-regexp '^remote.*.url'")
273 for x in remotes:
274 remote, url = x.split(' ')
275 remote = remote.replace('remote.', '')
276 remote = remote.replace('.url', '')
277 yield remote, url
279 @short_help("make a local copy of an existing repository")
280 @long_help("""
281 The first argument is a URL to the existing repository. This can be an
282 absolute path if the repository is local, or a URL with the git://,
283 ssh://, or http:// schemes. By default, the directory used is the last
284 component of the URL, sans '.git'. This can be overridden by providing
285 a second argument.
286 """)
287 def cmd_clone(self, url, directory=None):
288 "<url> [directory]"
290 if '://' not in url and url[0] != '/':
291 url = os.path.join(os.getcwd(), url)
293 if directory is None:
294 directory = url.rsplit('/')[-1]
295 directory = directory.replace('.git', '')
297 os.mkdir(directory)
298 os.chdir(directory)
299 self.cmd_init()
300 self.cmd_repo("origin", url)
301 self.cmd_fetch("origin")
303 branch = None
304 if not run_command("git rev-parse --verify refs/remotes/origin/HEAD"):
305 hash = get_output("git rev-parse refs/remotes/origin/HEAD")[0]
306 for b in get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'"):
307 if get_output("git rev-parse %s" % b)[0] == hash:
308 branch = b
309 break
310 if branch is None:
311 if not run_command("git rev-parse --verify refs/remotes/origin/master"):
312 branch = "refs/remotes/origin/master"
313 if branch is None:
314 branch = get_output("git for-each-ref --format='%(refname)' 'refs/remotes/origin/*'")
315 branch = branch[0]
317 hash = get_output("git rev-parse %s" % branch)
318 assert hash
319 branch = branch.replace('refs/remotes/origin/', '')
320 run_safely("git update-ref refs/heads/%s %s" % (branch, hash[0]))
321 run_safely("git symbolic-ref HEAD refs/heads/%s" % branch)
322 self.cmd_revert(**{'-a': 1})
324 @short_help("turn a directory into a repository")
325 @long_help("""
326 Converts the current working directory into a repository. The primary
327 side-effect of this command is the creation of a '.git' subdirectory.
328 No files are added nor commits made.
329 """)
330 def cmd_init(self):
331 os.system("git init")
333 @short_help("add a new file to the repository")
334 @long_help("""
335 The arguments are the files to be added to the repository. Once added,
336 the files will show as "unstaged changes" in the output of 'status'. To
337 reverse the effects of this command, see 'rm'.
338 """)
339 def cmd_add(self, *files):
340 "<file>..."
341 if not files:
342 raise TypeError
344 for f in files:
345 self._add_one(f)
346 self.cmd_status()
348 @short_help("delete a file from the repository")
349 @long_help("""
350 The arguments are the files to be removed from the current revision of
351 the repository. The files will still exist in any past commits that the
352 files may have been a part of. The file is not actually deleted, it is
353 just no longer tracked as part of the repository.
354 """)
355 def cmd_rm(self, *files):
356 "<file>..."
357 if not files:
358 raise TypeError
360 for f in files:
361 self._rm_one(f)
362 self.cmd_status()
364 @short_help("stage changes in a file for commit")
365 @long_help("""
366 The arguments are the files to be staged. Staging changes is a way to
367 build up a commit when you do not want to commit all changes at once.
368 To commit only staged changes, use the '-d' flag to 'commit.' To
369 reverse the effects of this command, see 'unstage'. Once staged, the
370 files will show as "staged changes" in the output of 'status'.
371 """)
372 def cmd_stage(self, *files):
373 "<file>..."
374 if not files:
375 raise TypeError
377 for f in files:
378 self._stage_one(f)
379 self.cmd_status()
381 @short_help("unstage changes in a file")
382 @long_help("""
383 The arguments are the files to be unstaged. Once unstaged, the files
384 will show as "unstaged changes" in the output of 'status'. The '-a'
385 flag can be used to unstage all staged changes at once.
386 """)
387 @takes_options("a")
388 def cmd_unstage(self, *files, **flags):
389 "[-a] | <file>..."
390 if '-a' in flags:
391 try:
392 run_safely("git read-tree -m HEAD")
393 except ShellError:
394 run_safely("git read-tree HEAD")
395 run_safely("git update-index -q --refresh")
396 self.cmd_status()
397 return
399 if not files:
400 raise TypeError
402 for f in files:
403 self._unstage_one(f)
404 self.cmd_status()
406 @short_help("show files with staged and unstaged changes")
407 @long_help("""
408 Show the files in the repository with changes since the last commit,
409 categorized based on whether the changes are staged or not. A file may
410 appear under each heading if the same file has both staged and unstaged
411 changes.
412 """)
413 def cmd_status(self):
415 branch = get_output("git symbolic-ref HEAD")[0]
416 branch = branch.replace('refs/heads/', '')
417 print "Current branch: %s" % branch
419 print "Files with staged changes:"
420 files = self._get_staged_files()
421 for f in files:
422 print "\t%s" % f
423 if not files:
424 print "\t(none)"
426 print "Files with unstaged changes:"
427 prefix = get_output("git rev-parse --show-prefix")
428 files = self._get_unstaged_files()
429 for f in files:
430 if prefix:
431 f = os.path.join(prefix[0], f)
432 print "\t%s" % f
433 if not files:
434 print "\t(none)"
436 @short_help("remove uncommitted changes from a file (*)")
437 @long_help("""
438 The arguments are the files whose changes will be reverted. If the '-a'
439 flag is given, then all files will have uncommitted changes removed.
440 Note that there is no way to reverse this command short of manually
441 editing each file again.
442 """)
443 @takes_options("a")
444 def cmd_revert(self, *files, **flags):
445 "(-a | <file>)"
446 if '-a' in flags:
447 run_safely("git read-tree -u -m HEAD")
448 run_safely("git checkout-index -u -f -a")
449 self.cmd_status()
450 return
452 if not files:
453 raise TypeError
455 for f in files:
456 self._revert_one(f)
457 self.cmd_status()
459 @short_help("record changes to files as a new commit")
460 @long_help("""
461 Create a new commit recording changes since the last commit. If there
462 are only unstaged changes, those will be recorded. If there are only
463 staged changes, those will be recorded. Otherwise, you will have to
464 specify either the '-a' flag or the '-d' flag to commit all changes or
465 only staged changes, respectively. To reverse the effects of this
466 command, see 'uncommit'.
467 """)
468 @takes_options("adm:")
469 def cmd_commit(self, **flags):
470 "[-a | -d]"
471 self._check_rebasing()
472 self._check_commit(**flags)
473 if not self._get_staged_files():
474 raise YapError("No changes to commit")
475 msg = flags.get('-m', None)
476 self._do_commit(msg)
477 self.cmd_status()
479 @short_help("reverse the actions of the last commit")
480 @long_help("""
481 Reverse the effects of the last 'commit' operation. The changes that
482 were part of the previous commit will show as "staged changes" in the
483 output of 'status'. This means that if no files were changed since the
484 last commit was created, 'uncommit' followed by 'commit' is a lossless
485 operation.
486 """)
487 def cmd_uncommit(self):
489 self._do_uncommit()
490 self.cmd_status()
492 @short_help("report the current version of yap")
493 def cmd_version(self):
494 print "Yap version 0.1"
496 @short_help("show the changelog for particular versions or files")
497 @long_help("""
498 The arguments are the files with which to filter history. If none are
499 given, all changes are listed. Otherwise only commits that affected one
500 or more of the given files are listed. The -r option changes the
501 starting revision for traversing history. By default, history is listed
502 starting at HEAD.
503 """)
504 @takes_options("r:")
505 def cmd_log(self, *paths, **flags):
506 "[-r <rev>] <path>..."
507 rev = flags.get('-r', 'HEAD')
508 paths = ' '.join(paths)
509 os.system("git log --name-status '%s' -- %s" % (rev, paths))
511 @short_help("show staged, unstaged, or all uncommitted changes")
512 @long_help("""
513 Show staged, unstaged, or all uncommitted changes. By default, all
514 changes are shown. The '-u' flag causes only unstaged changes to be
515 shown. The '-d' flag causes only staged changes to be shown.
516 """)
517 @takes_options("ud")
518 def cmd_diff(self, **flags):
519 "[ -u | -d ]"
520 if '-u' in flags and '-d' in flags:
521 raise YapError("Conflicting flags: -u and -d")
523 pager = self._get_pager_cmd()
525 if '-u' in flags:
526 os.system("git diff-files -p | %s" % pager)
527 elif '-d' in flags:
528 os.system("git diff-index --cached -p HEAD | %s" % pager)
529 else:
530 os.system("git diff-index -p HEAD | %s" % pager)
532 @short_help("list, create, or delete branches")
533 @long_help("""
534 If no arguments are specified, a list of local branches is given. The
535 current branch is indicated by a "*" next to the name. If an argument
536 is given, it is taken as the name of a new branch to create. The branch
537 will start pointing at the current HEAD. See 'point' for details on
538 changing the revision of the new branch. Note that this command does
539 not switch the current working branch. See 'switch' for details on
540 changing the current working branch.
542 The '-d' flag can be used to delete local branches. If the delete
543 operation would remove the last branch reference to a given line of
544 history (colloquially referred to as "dangling commits"), yap will
545 report an error and abort. The '-f' flag can be used to force the delete
546 in spite of this.
547 """)
548 @takes_options("fd:")
549 def cmd_branch(self, branch=None, **flags):
550 "[ [-f] -d <branch> | <branch> ]"
551 force = '-f' in flags
552 if '-d' in flags:
553 self._delete_branch(flags['-d'], force)
554 self.cmd_branch()
555 return
557 if branch is not None:
558 ref = get_output("git rev-parse --verify HEAD")
559 if not ref:
560 raise YapError("No branch point yet. Make a commit")
561 run_safely("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
563 current = get_output("git symbolic-ref HEAD")[0]
564 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
565 for b in branches:
566 if b == current:
567 print "* ",
568 else:
569 print " ",
570 b = b.replace('refs/heads/', '')
571 print b
573 @short_help("change the current working branch")
574 @long_help("""
575 The argument is the name of the branch to make the current working
576 branch. This command will fail if there are uncommitted changes to any
577 files. Otherwise, the contents of the files in the working directory
578 are updated to reflect their state in the new branch. Additionally, any
579 future commits are added to the new branch instead of the previous line
580 of history.
581 """)
582 def cmd_switch(self, branch):
583 "<branch>"
584 ref = get_output("git rev-parse --verify 'refs/heads/%s'" % branch)
585 if not ref:
586 raise YapError("No such branch: %s" % branch)
588 # XXX: support merging like git-checkout
589 if self._get_unstaged_files() or self._get_staged_files():
590 raise YapError("You have uncommitted changes. Commit them first")
592 run_safely("git symbolic-ref HEAD refs/heads/'%s'" % branch)
593 run_safely("git read-tree -u -m HEAD")
594 run_safely("git checkout-index -u -f -a")
595 self.cmd_branch()
597 @short_help("move the current branch to a different revision")
598 @long_help("""
599 The argument is the hash of the commit to which the current branch
600 should point, or alternately a branch or tag (a.k.a, "committish"). If
601 moving the branch would create "dangling commits" (see 'branch'), yap
602 will report an error and abort. The '-f' flag can be used to force the
603 operation in spite of this.
604 """)
605 @takes_options("f")
606 def cmd_point(self, where, **flags):
607 "<where>"
608 head = get_output("git rev-parse --verify HEAD")
609 if not head:
610 raise YapError("No commit yet; nowhere to point")
612 ref = get_output("git rev-parse --verify '%s'" % where)
613 if not ref:
614 raise YapError("Not a valid ref: %s" % where)
616 if self._get_unstaged_files() or self._get_staged_files():
617 raise YapError("You have uncommitted changes. Commit them first")
619 type = get_output("git cat-file -t '%s'" % ref[0])
620 if type and type[0] == "tag":
621 tag = get_output("git cat-file tag '%s'" % ref[0])
622 ref[0] = tag[0].split(' ')[1]
624 run_safely("git update-ref HEAD '%s'" % ref[0])
626 if '-f' not in flags:
627 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
628 if name == "undefined":
629 os.system("git update-ref HEAD '%s'" % head[0])
630 raise YapError("Pointing there will lose commits. Use -f to force")
632 run_safely("git read-tree -u -m HEAD")
633 run_safely("git checkout-index -u -f -a")
635 @short_help("alter history by dropping or amending commits")
636 @long_help("""
637 This command operates in two distinct modes, "amend" and "drop" mode.
638 In drop mode, the given commit is removed from the history of the
639 current branch, as though that commit never happened. By default the
640 commit used is HEAD.
642 In amend mode, the uncommitted changes present are merged into a
643 previous commit. This is useful for correcting typos or adding missed
644 files into past commits. By default the commit used is HEAD.
646 While rewriting history it is possible that conflicts will arise. If
647 this happens, the rewrite will pause and you will be prompted to resolve
648 the conflicts and stage them. Once that is done, you will run "yap
649 history continue." If instead you want the conflicting commit removed
650 from history (perhaps your changes supercede that commit) you can run
651 "yap history skip". Once the rewrite completes, your branch will be on
652 the same commit as when the rewrite started.
653 """)
654 def cmd_history(self, subcmd, *args):
655 "amend | drop <commit>"
657 if subcmd not in ("amend", "drop", "continue", "skip"):
658 raise TypeError
660 resolvemsg = """
661 When you have resolved the conflicts run \"yap history continue\".
662 To skip the problematic patch, run \"yap history skip\"."""
664 if subcmd == "continue":
665 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
666 return
667 if subcmd == "skip":
668 os.system("git reset --hard")
669 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
670 return
672 if subcmd == "amend":
673 flags, args = getopt.getopt(args, "ad")
674 flags = dict(flags)
676 if len(args) > 1:
677 raise TypeError
678 if args:
679 commit = args[0]
680 else:
681 commit = "HEAD"
683 if run_command("git rev-parse --verify '%s'" % commit):
684 raise YapError("Not a valid commit: %s" % commit)
686 self._check_rebasing()
688 if subcmd == "amend":
689 self._check_commit(**flags)
690 if self._get_unstaged_files():
691 # XXX: handle unstaged changes better
692 raise YapError("Commit away changes that you aren't amending")
694 try:
695 stash = get_output("git stash create")
696 run_command("git reset --hard")
697 if subcmd == "amend" and not stash:
698 raise YapError("Failed to stash; no changes?")
700 try:
701 fd, tmpfile = tempfile.mkstemp("yap")
702 os.close(fd)
703 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
704 if subcmd == "amend":
705 self.cmd_point(commit, **{'-f': True})
706 finally:
707 if subcmd == "amend":
708 rc = os.system("git stash apply --index %s" % stash[0])
709 if rc:
710 raise YapError("Failed to apply stash")
712 try:
713 if subcmd == "amend":
714 self._do_uncommit()
715 self._do_commit()
716 else:
717 self.cmd_point("%s^" % commit, **{'-f': True})
719 stat = os.stat(tmpfile)
720 size = stat[6]
721 if size > 0:
722 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
723 if (rc):
724 raise YapError("Failed to apply changes")
725 finally:
726 if stash:
727 run_command("git stash apply --index %s" % stash[0])
728 finally:
729 os.unlink(tmpfile)
730 self.cmd_status()
732 @short_help("show the changes introduced by a given commit")
733 @long_help("""
734 By default, the changes in the last commit are shown. To override this,
735 specify a hash, branch, or tag (committish). The hash of the commit,
736 the commit's author, log message, and a diff of the changes are shown.
737 """)
738 def cmd_show(self, commit="HEAD"):
739 "[commit]"
740 os.system("git show '%s'" % commit)
742 @short_help("apply the changes in a given commit to the current branch")
743 @long_help("""
744 The argument is the hash, branch, or tag (committish) of the commit to
745 be applied. In general, it only makes sense to apply commits that
746 happened on another branch. The '-r' flag can be used to have the
747 changes in the given commit reversed from the current branch. In
748 general, this only makes sense for commits that happened on the current
749 branch.
750 """)
751 @takes_options("r")
752 def cmd_cherry_pick(self, commit, **flags):
753 "[-r] <commit>"
754 if '-r' in flags:
755 os.system("git revert '%s'" % commit)
756 else:
757 os.system("git cherry-pick '%s'" % commit)
759 @short_help("list, add, or delete configured remote repositories")
760 @long_help("""
761 When invoked with no arguments, this command will show the list of
762 currently configured remote repositories, giving both the name and URL
763 of each. To add a new repository, give the desired name as the first
764 argument and the URL as the second. The '-d' flag can be used to remove
765 a previously added repository.
766 """)
767 @takes_options("d:")
768 def cmd_repo(self, name=None, url=None, **flags):
769 "[<name> <url> | -d <name>]"
770 if name is not None and url is None:
771 raise TypeError
773 if '-d' in flags:
774 if flags['-d'] not in [ x[0] for x in self._list_remotes() ]:
775 raise YapError("No such repository: %s" % flags['-d'])
776 os.system("git config --unset remote.%s.url" % flags['-d'])
777 os.system("git config --unset remote.%s.fetch" % flags['-d'])
779 if name:
780 if name in [ x[0] for x in self._list_remotes() ]:
781 raise YapError("Repository '%s' already exists" % flags['-d'])
782 os.system("git config remote.%s.url %s" % (name, url))
783 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, name))
785 for remote, url in self._list_remotes():
786 print "%-20s %s" % (remote, url)
788 @takes_options("cd")
789 def cmd_push(self, repo, **flags):
790 "[-c | -d] <repo>"
792 if repo not in [ x[0] for x in self._list_remotes() ]:
793 raise YapError("No such repository: %s" % repo)
795 current = get_output("git symbolic-ref HEAD")
796 if not current:
797 raise YapError("Not on a branch!")
798 ref = current[0]
799 current = current[0].replace('refs/heads/', '')
800 remote = get_output("git config branch.%s.remote" % current)
801 if remote and remote[0] == repo:
802 merge = get_output("git config branch.%s.merge" % current)
803 if merge:
804 ref = merge[0]
806 if '-c' not in flags and '-d' not in flags:
807 if run_command("git rev-parse --verify refs/remotes/%s/%s"
808 % (repo, ref.replace('refs/heads/', ''))):
809 raise YapError("No matching branch on that repo. Use -c to create a new branch there.")
811 if '-d' in flags:
812 lhs = ""
813 else:
814 lhs = "refs/heads/%s" % current
815 rc = os.system("git push %s %s:%s" % (repo, lhs, ref))
816 if rc:
817 raise YapError("Push failed.")
819 def cmd_fetch(self, repo):
820 # XXX allow defaulting of repo? yap.default
821 if repo not in [ x[0] for x in self._list_remotes() ]:
822 raise YapError("No such repository: %s" % repo)
823 os.system("git fetch %s" % repo)
825 def cmd_help(self, cmd=None):
826 if cmd is not None:
827 try:
828 attr = self.__getattribute__("cmd_"+cmd.replace('-', '_'))
829 except AttributeError:
830 raise YapError("No such command: %s" % cmd)
831 try:
832 help = attr.long_help
833 except AttributeError:
834 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
836 print >>sys.stderr, "The '%s' command" % cmd
837 print >>sys.stderr, "\tyap %s %s" % (cmd, attr.__doc__)
838 print >>sys.stderr, "%s" % help
839 return
841 print >> sys.stderr, "Yet Another (Git) Porcelein"
842 print >> sys.stderr
844 for name in dir(self):
845 if not name.startswith('cmd_'):
846 continue
847 attr = self.__getattribute__(name)
848 if not callable(attr):
849 continue
850 try:
851 short_msg = attr.short_help
852 except AttributeError:
853 continue
855 name = name.replace('cmd_', '')
856 name = name.replace('_', '-')
857 print >> sys.stderr, "%-16s%s" % (name, short_msg)
858 print >> sys.stderr
859 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
861 def cmd_usage(self):
862 print >> sys.stderr, "usage: %s <command>" % os.path.basename(sys.argv[0])
863 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 history version"
865 def main(self, args):
866 if len(args) < 1:
867 self.cmd_usage()
868 sys.exit(2)
870 command = args[0]
871 args = args[1:]
873 debug = os.getenv('YAP_DEBUG')
875 try:
876 command = command.replace('-', '_')
878 meth = None
879 for p in self.plugins:
880 try:
881 meth = p.__getattribute__("cmd_"+command)
882 except AttributeError:
883 continue
885 try:
886 default_meth = self.__getattribute__("cmd_"+command)
887 except AttributeError:
888 default_meth = None
890 if meth is None:
891 meth = default_meth
892 if meth is None:
893 raise AttributeError
895 try:
896 if "options" in meth.__dict__:
897 options = meth.options
898 if default_meth and "options" in default_meth.__dict__:
899 options += default_meth.options
900 flags, args = getopt.getopt(args, options)
901 flags = dict(flags)
902 else:
903 flags = dict()
905 # invoke pre-hooks
906 for p in self.plugins:
907 try:
908 meth = p.__getattribute__("pre_"+command)
909 except AttributeError:
910 continue
911 meth(*args, **flags)
913 meth(*args, **flags)
915 # invoke post-hooks
916 for p in self.plugins:
917 try:
918 meth = p.__getattribute__("post_"+command)
919 except AttributeError:
920 continue
921 meth()
923 except (TypeError, getopt.GetoptError):
924 if debug:
925 raise
926 print "%s %s %s" % (sys.argv[0], command, meth.__doc__)
927 except YapError, e:
928 print >> sys.stderr, e
929 sys.exit(1)
930 except AttributeError:
931 if debug:
932 raise
933 self.cmd_usage()
934 sys.exit(2)