cmd_history: don't trash uncommitted changes
[yap.git] / yap / yap.py
blobea83554eb074eb98d0a44b787da07f3c0a53ea10
1 import sys
2 import os
3 import glob
4 import getopt
5 import pickle
6 import tempfile
8 from plugin import YapPlugin
10 def get_output(cmd):
11 fd = os.popen(cmd)
12 output = fd.readlines()
13 rc = fd.close()
14 return [x.strip() for x in output]
16 def run_command(cmd):
17 rc = os.system("%s > /dev/null 2>&1" % cmd)
18 rc >>= 8
19 return rc
21 class YapError(Exception):
22 def __init__(self, msg):
23 self.msg = msg
25 def __str__(self):
26 return self.msg
28 def takes_options(options):
29 def decorator(func):
30 func.options = options
31 return func
32 return decorator
34 def short_help(help_msg):
35 def decorator(func):
36 func.short_help = help_msg
37 return func
38 return decorator
40 def long_help(help_msg):
41 def decorator(func):
42 func.long_help = help_msg
43 return func
44 return decorator
46 class Yap(object):
47 def __init__(self):
48 self.plugins = set()
49 plugindir = os.path.expanduser("~/.yap/plugins")
50 for p in glob.glob(os.path.join(plugindir, "*.py")):
51 glbls = {}
52 execfile(p, glbls)
53 for cls in glbls.values():
54 if not type(cls) == type:
55 continue
56 if not issubclass(cls, YapPlugin):
57 continue
58 if cls is YapPlugin:
59 continue
60 x = cls(self)
61 # XXX: check for override overlap
62 self.plugins.add(x)
64 def _add_new_file(self, file):
65 repo = get_output('git rev-parse --git-dir')[0]
66 dir = os.path.join(repo, 'yap')
67 try:
68 os.mkdir(dir)
69 except OSError:
70 pass
71 files = self._get_new_files()
72 files.append(file)
73 path = os.path.join(dir, 'new-files')
74 pickle.dump(files, open(path, 'w'))
76 def _get_new_files(self):
77 repo = get_output('git rev-parse --git-dir')[0]
78 path = os.path.join(repo, 'yap', 'new-files')
79 try:
80 files = pickle.load(file(path))
81 except IOError:
82 files = []
84 x = []
85 for f in files:
86 # if f in the index
87 if get_output("git ls-files --cached '%s'" % f) != []:
88 continue
89 x.append(f)
90 return x
92 def _remove_new_file(self, file):
93 files = self._get_new_files()
94 files = filter(lambda x: x != file, files)
96 repo = get_output('git rev-parse --git-dir')[0]
97 path = os.path.join(repo, 'yap', 'new-files')
98 pickle.dump(files, open(path, 'w'))
100 def _clear_new_files(self):
101 repo = get_output('git rev-parse --git-dir')[0]
102 path = os.path.join(repo, 'yap', 'new-files')
103 os.unlink(path)
105 def _assert_file_exists(self, file):
106 if not os.access(file, os.R_OK):
107 raise YapError("No such file: %s" % file)
109 def _get_staged_files(self):
110 if run_command("git rev-parse HEAD"):
111 files = get_output("git ls-files --cached")
112 else:
113 files = get_output("git diff-index --cached --name-only HEAD")
114 return files
116 def _get_unstaged_files(self):
117 files = self._get_new_files()
118 files += get_output("git ls-files -m")
119 return files
121 def _delete_branch(self, branch, force):
122 current = get_output("git symbolic-ref HEAD")[0]
123 current = current.replace('refs/heads/', '')
124 if branch == current:
125 raise YapError("Can't delete current branch")
127 ref = get_output("git rev-parse 'refs/heads/%s'" % branch)
128 if not ref:
129 raise YapError("No such branch: %s" % branch)
130 os.system("git update-ref -d 'refs/heads/%s' '%s'" % (branch, ref[0]))
132 if not force:
133 name = get_output("git name-rev --name-only '%s'" % ref[0])[0]
134 if name == 'undefined':
135 os.system("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
136 raise YapError("Refusing to delete leaf branch (use -f to force)")
137 def _get_pager_cmd(self):
138 if 'YAP_PAGER' in os.environ:
139 return os.environ['YAP_PAGER']
140 elif 'GIT_PAGER' in os.environ:
141 return os.environ['GIT_PAGER']
142 elif 'PAGER' in os.environ:
143 return os.environ['PAGER']
144 else:
145 return "more"
147 def _add_one(self, file):
148 self._assert_file_exists(file)
149 x = get_output("git ls-files '%s'" % file)
150 if x != []:
151 raise YapError("File '%s' already in repository" % file)
152 self._add_new_file(file)
154 def _rm_one(self, file):
155 self._assert_file_exists(file)
156 if get_output("git ls-files '%s'" % file) != []:
157 os.system("git rm --cached '%s'" % file)
158 self._remove_new_file(file)
160 def _stage_one(self, file):
161 self._assert_file_exists(file)
162 os.system("git update-index --add '%s'" % file)
164 def _unstage_one(self, file):
165 self._assert_file_exists(file)
166 if run_command("git rev-parse HEAD"):
167 os.system("git update-index --force-remove '%s'" % file)
168 else:
169 os.system("git diff-index -p HEAD '%s' | git apply -R --cached" % file)
171 def _revert_one(self, file):
172 self._assert_file_exists(file)
173 self._unstage_one(file)
174 os.system("git checkout-index -f '%s'" % file)
176 def _parse_commit(self, commit):
177 lines = get_output("git cat-file commit '%s'" % commit)
178 commit = {}
180 mode = None
181 for l in lines:
182 if mode != 'commit' and l.strip() == "":
183 mode = 'commit'
184 commit['log'] = []
185 continue
186 if mode == 'commit':
187 commit['log'].append(l)
188 continue
190 x = l.split(' ')
191 k = x[0]
192 v = ' '.join(x[1:])
193 commit[k] = v
194 commit['log'] = '\n'.join(commit['log'])
195 return commit
197 def _check_commit(self, **flags):
198 if '-a' in flags and '-d' in flags:
199 raise YapError("Conflicting flags: -a and -d")
201 if '-d' not in flags and self._get_unstaged_files():
202 if '-a' not in flags and self._get_staged_files():
203 raise YapError("Staged and unstaged changes present. Specify what to commit")
204 os.system("git diff-files -p | git apply --cached 2>/dev/null")
205 for f in self._get_new_files():
206 self._stage_one(f)
208 if not self._get_staged_files():
209 raise YapError("No changes to commit")
211 def _do_uncommit(self):
212 commit = self._parse_commit("HEAD")
213 repo = get_output('git rev-parse --git-dir')[0]
214 dir = os.path.join(repo, 'yap')
215 try:
216 os.mkdir(dir)
217 except OSError:
218 pass
219 msg_file = os.path.join(dir, 'msg')
220 fd = file(msg_file, 'w')
221 print >>fd, commit['log']
222 fd.close()
224 tree = get_output("git rev-parse HEAD^")
225 os.system("git update-ref -m uncommit HEAD '%s'" % tree[0])
227 def _do_commit(self):
228 tree = get_output("git write-tree")[0]
229 parent = get_output("git rev-parse HEAD 2> /dev/null")[0]
231 if os.environ.has_key('YAP_EDITOR'):
232 editor = os.environ['YAP_EDITOR']
233 elif os.environ.has_key('GIT_EDITOR'):
234 editor = os.environ['GIT_EDITOR']
235 elif os.environ.has_key('EDITOR'):
236 editor = os.environ['EDITOR']
237 else:
238 editor = "vi"
240 fd, tmpfile = tempfile.mkstemp("yap")
241 os.close(fd)
243 repo = get_output('git rev-parse --git-dir')[0]
244 msg_file = os.path.join(repo, 'yap', 'msg')
245 if os.access(msg_file, os.R_OK):
246 fd1 = file(msg_file)
247 fd2 = file(tmpfile, 'w')
248 for l in fd1.xreadlines():
249 print >>fd2, l.strip()
250 fd2.close()
251 os.unlink(msg_file)
253 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
254 raise YapError("Editing commit message failed")
255 if parent != 'HEAD':
256 commit = get_output("git commit-tree '%s' -p '%s' < '%s'" % (tree, parent, tmpfile))
257 else:
258 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
259 if not commit:
260 raise YapError("Commit failed; no log message?")
261 os.unlink(tmpfile)
262 os.system("git update-ref HEAD '%s'" % commit[0])
264 def _check_rebasing(self):
265 repo = get_output('git rev-parse --git-dir')[0]
266 dotest = os.path.join(repo, '.dotest')
267 if os.access(dotest, os.R_OK):
268 raise YapError("A git operation is in progress. Complete it first")
269 dotest = os.path.join(repo, '..', '.dotest')
270 if os.access(dotest, os.R_OK):
271 raise YapError("A git operation is in progress. Complete it first")
273 def _list_remotes(self):
274 remotes = get_output("git config --get-regexp 'remote.*.url'")
275 for x in remotes:
276 remote, url = x.split(' ')
277 remote = remote.replace('remote.', '')
278 remote = remote.replace('.url', '')
279 yield remote, url
281 @short_help("make a local copy of an existing repository")
282 @long_help("""
283 The first argument is a URL to the existing repository. This can be an
284 absolute path if the repository is local, or a URL with the git://,
285 ssh://, or http:// schemes. By default, the directory used is the last
286 component of the URL, sans '.git'. This can be overridden by providing
287 a second argument.
288 """)
289 def cmd_clone(self, url, directory=""):
290 "<url> [directory]"
291 # XXX: implement in terms of init + remote add + fetch
292 os.system("git clone '%s' %s" % (url, directory))
294 @short_help("turn a directory into a repository")
295 @long_help("""
296 Converts the current working directory into a repository. The primary
297 side-effect of this command is the creation of a '.git' subdirectory.
298 No files are added nor commits made.
299 """)
300 def cmd_init(self):
301 os.system("git init")
303 @short_help("add a new file to the repository")
304 @long_help("""
305 The arguments are the files to be added to the repository. Once added,
306 the files will show as "unstaged changes" in the output of 'status'. To
307 reverse the effects of this command, see 'rm'.
308 """)
309 def cmd_add(self, *files):
310 "<file>..."
311 if not files:
312 raise TypeError
314 for f in files:
315 self._add_one(f)
316 self.cmd_status()
318 @short_help("delete a file from the repository")
319 @long_help("""
320 The arguments are the files to be removed from the current revision of
321 the repository. The files will still exist in any past commits that the
322 file may have been a part of. The file is not actually deleted, it is
323 just no longer tracked as part of the repository.
324 """)
325 def cmd_rm(self, *files):
326 "<file>..."
327 if not files:
328 raise TypeError
330 for f in files:
331 self._rm_one(f)
332 self.cmd_status()
334 @short_help("stage changes in a file for commit")
335 @long_help("""
336 The arguments are the files to be staged. Staging changes is a way to
337 build up a commit when you do not want to commit all changes at once.
338 To commit only staged changes, use the '-d' flag to 'commit.' To
339 reverse the effects of this command, see 'unstage'. Once staged, the
340 files will show as "staged changes" in the output of 'status'.
341 """)
342 def cmd_stage(self, *files):
343 "<file>..."
344 if not files:
345 raise TypeError
347 for f in files:
348 self._stage_one(f)
349 self.cmd_status()
351 @short_help("unstage changes in a file")
352 @long_help("""
353 The arguments are the files to be unstaged. Once unstaged, the files
354 will show as "unstaged changes" in the output of 'status'. The '-a'
355 flag can be used to unstage all staged changes at once.
356 """)
357 @takes_options("a")
358 def cmd_unstage(self, *files, **flags):
359 "[-a] | <file>..."
360 if '-a' in flags:
361 os.system("git read-tree HEAD")
362 self.cmd_status()
363 return
365 if not files:
366 raise TypeError
368 for f in files:
369 self._unstage_one(f)
370 self.cmd_status()
372 @short_help("show files with staged and unstaged changes")
373 @long_help("""
374 Show the files in the repository with changes since the last commit,
375 categorized based on whether the changes are staged or not. A file may
376 appear under each heading if the same file has both staged and unstaged
377 changes.
378 """)
379 def cmd_status(self):
380 branch = get_output("git symbolic-ref HEAD")[0]
381 branch = branch.replace('refs/heads/', '')
382 print "Current branch: %s" % branch
384 print "Files with staged changes:"
385 files = self._get_staged_files()
386 for f in files:
387 print "\t%s" % f
388 if not files:
389 print "\t(none)"
391 print "Files with unstaged changes:"
392 prefix = get_output("git rev-parse --show-prefix")
393 files = self._get_unstaged_files()
394 for f in files:
395 if prefix:
396 f = os.path.join(prefix[0], f)
397 print "\t%s" % f
398 if not files:
399 print "\t(none)"
401 @short_help("remove uncommitted changes from a file (*)")
402 @long_help("""
403 The arguments are the files whose changes will be reverted. If the '-a'
404 flag is given, then all files will have uncommitted changes removed.
405 Note that there is no way to reverse this command short of manually
406 editing each file again.
407 """)
408 @takes_options("a")
409 def cmd_revert(self, *files, **flags):
410 "(-a | <file>)"
411 if '-a' in flags:
412 os.system("git read-tree HEAD")
413 os.system("git checkout-index -f -a")
414 return
416 if not files:
417 raise TypeError
419 for f in files:
420 self._revert_one(f)
421 self.cmd_status()
423 @short_help("record changes to files as a new commit")
424 @long_help("""
425 Create a new commit recording changes since the last commit. If there
426 are only unstaged changes, those will be recorded. If there are only
427 staged changes, those will be recorder. Otherwise, you will have to
428 specify either the '-a' flag or the '-d' flag to commit all changes or
429 only staged changes, respectively. To reverse the effects of this
430 command, see 'uncommit'.
431 """)
432 @takes_options("ad")
433 def cmd_commit(self, **flags):
434 self._check_rebasing()
435 self._check_commit(**flags)
436 self._do_commit()
437 self.cmd_status()
439 @short_help("reverse the actions of the last commit")
440 @long_help("""
441 Reverse the effects of the last 'commit' operation. The changes that
442 were part of the previous commit will show as "staged changes" in the
443 output of 'status'. This means that if no files were changed since the
444 last commit was created, 'uncommit' followed by 'commit' is a lossless
445 operation.
446 """)
447 def cmd_uncommit(self):
448 self._do_uncommit()
449 self.cmd_status()
451 @short_help("report the current version of yap")
452 def cmd_version(self):
453 print "Yap version 0.1"
455 @short_help("show the changelog for particular versions or files")
456 @long_help("""
457 The arguments are the files with which to filter history. If none are
458 given, all changes are listed. Otherwise only commits that affected one
459 or more of the given files are listed. The -r option changes the
460 starting revision for traversing history. By default, history is listed
461 starting at HEAD.
462 """)
463 @takes_options("r:")
464 def cmd_log(self, *paths, **flags):
465 "[-r <rev>] <path>..."
466 rev = flags.get('-r', 'HEAD')
467 paths = ' '.join(paths)
468 os.system("git log --name-status '%s' -- %s" % (rev, paths))
470 @short_help("show staged, unstaged, or all uncommitted changes")
471 @long_help("""
472 Show staged, unstaged, or all uncommitted changes. By default, all
473 changes are shown. The '-u' flag causes only unstaged changes to be
474 shown. The '-d' flag causes only staged changes to be shown.
475 """)
476 @takes_options("ud")
477 def cmd_diff(self, **flags):
478 "[ -u | -d ]"
479 if '-u' in flags and '-d' in flags:
480 raise YapError("Conflicting flags: -u and -d")
482 pager = self._get_pager_cmd()
484 os.system("git update-index -q --refresh")
485 if '-u' in flags:
486 os.system("git diff-files -p | %s" % pager)
487 elif '-d' in flags:
488 os.system("git diff-index --cached -p HEAD | %s" % pager)
489 else:
490 os.system("git diff-index -p HEAD | %s" % pager)
492 @short_help("list, create, or delete branches")
493 @long_help("""
494 If no arguments are given, a list of local branches is given. The
495 current branch is indicated by a "*" next to the name. If an argument
496 is given, it is taken as the name of a new branch to create. The branch
497 will start pointing at the current HEAD. See 'point' for details on
498 changing the revision of the new branch. Note that this command does
499 not switch the current working branch. See 'switch' for details on
500 changing the current working branch.
502 The '-d' flag can be used to delete local branches. If the delete
503 operation would remove the last branch reference to a given line of
504 history (colloquially referred to as "dangling commits"), yap will
505 report an error and abort. The '-f' flag can be used to force the delete
506 in spite of this.
507 """)
508 @takes_options("fd:")
509 def cmd_branch(self, branch=None, **flags):
510 "[ [-f] -d <branch> | <branch> ]"
511 force = '-f' in flags
512 if '-d' in flags:
513 self._delete_branch(flags['-d'], force)
514 self.cmd_branch()
515 return
517 if branch is not None:
518 ref = get_output("git rev-parse HEAD")
519 if not ref:
520 raise YapError("No branch point yet. Make a commit")
521 os.system("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
523 current = get_output("git symbolic-ref HEAD")[0]
524 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
525 for b in branches:
526 if b == current:
527 print "* ",
528 else:
529 print " ",
530 b = b.replace('refs/heads/', '')
531 print b
533 @short_help("change the current working branch")
534 @long_help("""
535 The argument is the name of the branch to make the current working
536 branch. This command will fail if there are uncommitted changes to any
537 files. Otherwise, the contents of the files in the working directory
538 are updated to reflect their state in the new branch. Additionally, any
539 future commits are added to the new branch instead of the previous line
540 of history.
541 """)
542 def cmd_switch(self, branch):
543 "<branch>"
544 ref = get_output("git rev-parse 'refs/heads/%s'" % branch)
545 if not ref:
546 raise YapError("No such branch: %s" % branch)
548 # XXX: support merging like git-checkout
549 if self._get_unstaged_files() or self._get_staged_files():
550 raise YapError("You have uncommitted changes. Commit them first")
552 os.system("git symbolic-ref HEAD refs/heads/'%s'" % branch)
553 os.system("git read-tree HEAD")
554 os.system("git checkout-index -f -a")
555 self.cmd_branch()
557 @short_help("move the current branch to a different revision")
558 @long_help("""
559 The argument is the hash of the commit to which the current branch
560 should point, or alternately a branch or tag (a.k.a, "committish"). If
561 moving the branch would create "dangling commits" (see 'branch'), yap
562 will report an error and abort. The '-f' flag can be used to force the
563 operation in spite of this.
564 """)
565 @takes_options("f")
566 def cmd_point(self, where, **flags):
567 "<where>"
568 head = get_output("git rev-parse HEAD")
569 if not head:
570 raise YapError("No commit yet; nowhere to point")
572 ref = get_output("git rev-parse '%s'" % where)
573 if not ref:
574 raise YapError("Not a valid ref: %s" % where)
576 if self._get_unstaged_files() or self._get_staged_files():
577 raise YapError("You have uncommitted changes. Commit them first")
579 type = get_output("git cat-file -t '%s'" % ref[0])
580 if type and type[0] == "tag":
581 tag = get_output("git cat-file tag '%s'" % ref[0])
582 ref[0] = tag[0].split(' ')[1]
584 os.system("git update-ref HEAD '%s'" % ref[0])
586 if '-f' not in flags:
587 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
588 if name == "undefined":
589 os.system("git update-ref HEAD '%s'" % head[0])
590 raise YapError("Pointing there will lose commits. Use -f to force")
592 os.system("git read-tree HEAD")
593 os.system("git checkout-index -f -a")
594 os.system("git update-index --refresh")
596 @short_help("alter history by dropping or amending commits")
597 @long_help("""
598 This command operates in two distinct modes, "amend" and "drop" mode.
599 In drop mode, the given commit is removed from the history of the
600 current branch, as though that commit never happened. By default the
601 commit used is HEAD.
603 In amend mode, the uncommitted changes present are merged into a
604 previous commit. This is useful for correcting typos or adding missed
605 files into past commits. By default the commit used is HEAD.
607 While rewriting history it is possible that conflicts will arise. If
608 this happens, the rewrite will pause and you will be prompted to resolve
609 the conflicts and staged them. Once that is done, you will run "yap
610 history continue." If instead you want the conflicting commit removed
611 from history (perhaps your changes supercede that commit) you can run
612 "yap history skip". Once the rewrite completes, your branch will be on
613 the same commit as when the rewrite started.
614 """)
615 def cmd_history(self, subcmd, *args):
616 "amend | drop <commit>"
618 if subcmd not in ("amend", "drop", "continue", "skip"):
619 raise TypeError
621 resolvemsg = """
622 When you have resolved the conflicts run \"yap history continue\".
623 To skip the problematic patch, run \"yap history skip\"."""
625 if subcmd == "continue":
626 os.system("git am -3 -r --resolvemsg='%s'" % resolvemsg)
627 return
628 if subcmd == "skip":
629 os.system("git reset --hard")
630 os.system("git am -3 --skip --resolvemsg='%s'" % resolvemsg)
631 return
633 if subcmd == "amend":
634 flags, args = getopt.getopt(args, "ad")
635 flags = dict(flags)
637 if len(args) > 1:
638 raise TypeError
639 if args:
640 commit = args[0]
641 else:
642 commit = "HEAD"
644 if run_command("git rev-parse --verify '%s'" % commit):
645 raise YapError("Not a valid commit: %s" % commit)
647 self._check_rebasing()
649 if subcmd == "amend":
650 self._check_commit(**flags)
651 if self._get_unstaged_files():
652 # XXX: handle unstaged changes better
653 raise YapError("Commit away changes that you aren't amending")
655 try:
656 stash = get_output("git stash create")
657 run_command("git reset --hard")
658 if subcmd == "amend" and not stash:
659 raise YapError("Failed to stash; no changes?")
661 try:
662 fd, tmpfile = tempfile.mkstemp("yap")
663 os.close(fd)
664 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
665 if subcmd == "amend":
666 self.cmd_point(commit, **{'-f': True})
667 finally:
668 if subcmd == "amend":
669 run_command("git stash apply --index %s" % stash[0])
671 try:
672 if subcmd == "amend":
673 self._do_uncommit()
674 self._do_commit()
675 else:
676 self.cmd_point("%s^" % commit, **{'-f': True})
678 stat = os.stat(tmpfile)
679 size = stat[6]
680 if size > 0:
681 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
682 if (rc):
683 raise YapError("Failed to apply changes")
684 finally:
685 if stash:
686 run_command("git stash apply --index %s" % stash[0])
687 finally:
688 os.unlink(tmpfile)
689 self.cmd_status()
691 @short_help("show the changes introduced by a given commit")
692 @long_help("""
693 By default, the changes in the last commit are shown. To override this,
694 specify a hash, branch, or tag (committish). The hash of the commit,
695 the commit's author, log message, and a diff of the changes are shown.
696 """)
697 def cmd_show(self, commit="HEAD"):
698 "[commit]"
699 os.system("git show '%s'" % commit)
701 @short_help("apply the changes in a given commit to the current branch")
702 @long_help("""
703 The argument is the hash, branch, or tag (committish) of the commit to
704 be applied. In general, it only makes sense to apply commits that
705 happened on another branch. The '-r' flag can be used to have the
706 changes in the given commit reversed from the current branch. In
707 general, this only makes sense for commits that happened on the current
708 branch.
709 """)
710 @takes_options("r")
711 def cmd_cherry_pick(self, commit, **flags):
712 "[-r] <commit>"
713 if '-r' in flags:
714 os.system("git revert '%s'" % commit)
715 else:
716 os.system("git cherry-pick '%s'" % commit)
718 @short_help("list, add, or delete configured remote repositories")
719 @long_help("""
720 When invoked with no arguments, this command will show the list of
721 currently configured remote repositories, giving both the name and URL
722 of each. To add a new repository, give the desired name as the first
723 argument and the URL as the second. The '-d' flag can be used to remove
724 a previously added repository.
725 """)
726 @takes_options("d:")
727 def cmd_repo(self, name=None, url=None, **flags):
728 "[<name> <url> | -d <name>]"
729 if name is not None and url is None:
730 raise TypeError
732 if '-d' in flags:
733 if flags['-d'] not in self._list_remotes():
734 raise YapError("No such repository: %s" % flags['-d'])
735 os.system("git config --unset remote.%s.url" % flags['-d'])
736 os.system("git config --unset remote.%s.fetch" % flags['-d'])
738 if name:
739 if flags['-d'] in self._list_remotes():
740 raise YapError("Repository '%s' already exists" % flags['-d'])
741 os.system("git config remote.%s.url %s" % (name, url))
742 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, url))
744 for remote, url in self._list_remotes():
745 print "%s:\t\t%s" % (remote, url)
747 def cmd_help(self, cmd=None):
748 if cmd is not None:
749 try:
750 attr = self.__getattribute__("cmd_"+cmd)
751 except AttributeError:
752 raise YapError("No such command: %s" % cmd)
753 try:
754 help = attr.long_help
755 except AttributeError:
756 raise YapError("Sorry, no help for '%s'. Ask Steven." % cmd)
758 print >>sys.stderr, "The '%s' command" % cmd
759 print >>sys.stderr, "\tyap %s %s" % (cmd, attr.__doc__)
760 print >>sys.stderr, "%s" % help
761 return
763 print >> sys.stderr, "Yet Another (Git) Porcelein"
764 print >> sys.stderr
766 for name in dir(self):
767 if not name.startswith('cmd_'):
768 continue
769 attr = self.__getattribute__(name)
770 if not callable(attr):
771 continue
772 try:
773 short_msg = attr.short_help
774 except AttributeError:
775 continue
777 name = name.replace('cmd_', '')
778 name = name.replace('_', '-')
779 print >> sys.stderr, "%-16s%s" % (name, short_msg)
780 print >> sys.stderr
781 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
783 def cmd_usage(self):
784 print >> sys.stderr, "usage: %s <command>" % sys.argv[0]
785 print >> sys.stderr, " valid commands: help init add rm stage unstage status revert commit uncommit log show diff branch switch point cherry-pick history version"
787 def main(self, args):
788 if len(args) < 1:
789 self.cmd_usage()
790 sys.exit(2)
792 command = args[0]
793 args = args[1:]
795 debug = os.getenv('YAP_DEBUG')
797 try:
798 command = command.replace('-', '_')
800 meth = None
801 for p in self.plugins:
802 try:
803 meth = p.__getattribute__("cmd_"+command)
804 except AttributeError:
805 continue
807 try:
808 default_meth = self.__getattribute__("cmd_"+command)
809 except AttributeError:
810 default_meth = None
812 if meth is None:
813 meth = default_meth
814 if meth is None:
815 raise AttributeError
817 try:
818 if "options" in meth.__dict__:
819 options = meth.options
820 if default_meth and "options" in default_meth.__dict__:
821 options += default_meth.options
822 flags, args = getopt.getopt(args, options)
823 flags = dict(flags)
824 else:
825 flags = dict()
827 # invoke pre-hooks
828 for p in self.plugins:
829 try:
830 meth = p.__getattribute__("pre_"+command)
831 except AttributeError:
832 continue
833 meth(*args, **flags)
835 meth(*args, **flags)
837 # invoke post-hooks
838 for p in self.plugins:
839 try:
840 meth = p.__getattribute__("post_"+command)
841 except AttributeError:
842 continue
843 meth()
845 except (TypeError, getopt.GetoptError):
846 if debug:
847 raise
848 print "%s %s %s" % (sys.argv[0], command, meth.__doc__)
849 except YapError, e:
850 print >> sys.stderr, e
851 sys.exit(1)
852 except AttributeError:
853 if debug:
854 raise
855 self.cmd_usage()
856 sys.exit(2)