Implement short help system
[yap.git] / yap / yap.py
blob844474e2f5953e778853954a556eda280605ebdf
1 import sys
2 import os
3 import getopt
4 import pickle
5 import tempfile
7 def get_output(cmd):
8 fd = os.popen(cmd)
9 output = fd.readlines()
10 rc = fd.close()
11 return [x.strip() for x in output]
13 def run_command(cmd):
14 rc = os.system("%s > /dev/null 2>&1" % cmd)
15 rc >>= 8
16 return rc
18 class YapError(Exception):
19 def __init__(self, msg):
20 self.msg = msg
22 def __str__(self):
23 return self.msg
25 def takes_options(options):
26 def decorator(func):
27 func.options = options
28 return func
29 return decorator
31 def short_help(help_msg):
32 def decorator(func):
33 func.short_help = help_msg
34 return func
35 return decorator
37 def long_help(help_msg):
38 def decorator(func):
39 func.long_help = help_msg
40 return func
41 return decorator
43 class Yap(object):
44 def _add_new_file(self, file):
45 repo = get_output('git rev-parse --git-dir')[0]
46 dir = os.path.join(repo, 'yap')
47 try:
48 os.mkdir(dir)
49 except OSError:
50 pass
51 files = self._get_new_files()
52 files.append(file)
53 path = os.path.join(dir, 'new-files')
54 pickle.dump(files, open(path, 'w'))
56 def _get_new_files(self):
57 repo = get_output('git rev-parse --git-dir')[0]
58 path = os.path.join(repo, 'yap', 'new-files')
59 try:
60 files = pickle.load(file(path))
61 except IOError:
62 files = []
64 x = []
65 for f in files:
66 # if f in the index
67 if get_output("git ls-files --cached '%s'" % f) != []:
68 continue
69 x.append(f)
70 return x
72 def _remove_new_file(self, file):
73 files = self._get_new_files()
74 files = filter(lambda x: x != file, files)
76 repo = get_output('git rev-parse --git-dir')[0]
77 path = os.path.join(repo, 'yap', 'new-files')
78 pickle.dump(files, open(path, 'w'))
80 def _clear_new_files(self):
81 repo = get_output('git rev-parse --git-dir')[0]
82 path = os.path.join(repo, 'yap', 'new-files')
83 os.unlink(path)
85 def _assert_file_exists(self, file):
86 if not os.access(file, os.R_OK):
87 raise YapError("No such file: %s" % file)
89 def _get_staged_files(self):
90 if run_command("git rev-parse HEAD"):
91 files = get_output("git ls-files --cached")
92 else:
93 files = get_output("git diff-index --cached --name-only HEAD")
94 return files
96 def _get_unstaged_files(self):
97 files = self._get_new_files()
98 files += get_output("git ls-files -m")
99 return files
101 def _delete_branch(self, branch, force):
102 current = get_output("git symbolic-ref HEAD")[0]
103 current = current.replace('refs/heads/', '')
104 if branch == current:
105 raise YapError("Can't delete current branch")
107 ref = get_output("git rev-parse 'refs/heads/%s'" % branch)
108 if not ref:
109 raise YapError("No such branch: %s" % branch)
110 os.system("git update-ref -d 'refs/heads/%s' '%s'" % (branch, ref[0]))
112 if not force:
113 name = get_output("git name-rev --name-only '%s'" % ref[0])[0]
114 if name == 'undefined':
115 os.system("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
116 raise YapError("Refusing to delete leaf branch (use -f to force)")
117 def _get_pager_cmd(self):
118 if 'YAP_PAGER' in os.environ:
119 return os.environ['YAP_PAGER']
120 elif 'GIT_PAGER' in os.environ:
121 return os.environ['GIT_PAGER']
122 elif 'PAGER' in os.environ:
123 return os.environ['PAGER']
124 else:
125 return "more"
127 def _add_one(self, file):
128 self._assert_file_exists(file)
129 x = get_output("git ls-files '%s'" % file)
130 if x != []:
131 raise YapError("File '%s' already in repository" % file)
132 self._add_new_file(file)
134 def _rm_one(self, file):
135 self._assert_file_exists(file)
136 if get_output("git ls-files '%s'" % file) != []:
137 os.system("git rm --cached '%s'" % file)
138 self._remove_new_file(file)
140 def _stage_one(self, file):
141 self._assert_file_exists(file)
142 os.system("git update-index --add '%s'" % file)
144 def _unstage_one(self, file):
145 self._assert_file_exists(file)
146 if run_command("git rev-parse HEAD"):
147 os.system("git update-index --force-remove '%s'" % file)
148 else:
149 os.system("git diff-index -p HEAD '%s' | git apply -R --cached" % file)
151 def _revert_one(self, file):
152 self._assert_file_exists(file)
153 os.system("git checkout-index -f '%s'" % file)
155 def _parse_commit(self, commit):
156 lines = get_output("git cat-file commit '%s'" % commit)
157 commit = {}
159 mode = None
160 for l in lines:
161 if mode != 'commit' and l.strip() == "":
162 mode = 'commit'
163 commit['log'] = []
164 continue
165 if mode == 'commit':
166 commit['log'].append(l)
167 continue
169 x = l.split(' ')
170 k = x[0]
171 v = ' '.join(x[1:])
172 commit[k] = v
173 commit['log'] = '\n'.join(commit['log'])
174 return commit
176 def _check_commit(self, **flags):
177 if '-a' in flags and '-d' in flags:
178 raise YapError("Conflicting flags: -a and -d")
180 if '-d' not in flags and self._get_unstaged_files():
181 if '-a' not in flags and self._get_staged_files():
182 raise YapError("Staged and unstaged changes present. Specify what to commit")
183 os.system("git diff-files -p | git apply --cached 2>/dev/null")
184 for f in self._get_new_files():
185 self._stage_one(f)
187 if not self._get_staged_files():
188 raise YapError("No changes to commit")
190 def _do_uncommit(self):
191 commit = self._parse_commit("HEAD")
192 repo = get_output('git rev-parse --git-dir')[0]
193 dir = os.path.join(repo, 'yap')
194 try:
195 os.mkdir(dir)
196 except OSError:
197 pass
198 msg_file = os.path.join(dir, 'msg')
199 fd = file(msg_file, 'w')
200 print >>fd, commit['log']
201 fd.close()
203 tree = get_output("git rev-parse HEAD^")
204 os.system("git update-ref -m uncommit HEAD '%s'" % tree[0])
206 def _do_commit(self):
207 tree = get_output("git write-tree")[0]
208 parent = get_output("git rev-parse HEAD 2> /dev/null")[0]
210 if os.environ.has_key('YAP_EDITOR'):
211 editor = os.environ['YAP_EDITOR']
212 elif os.environ.has_key('GIT_EDITOR'):
213 editor = os.environ['GIT_EDITOR']
214 elif os.environ.has_key('EDITOR'):
215 editor = os.environ['EDITOR']
216 else:
217 editor = "vi"
219 fd, tmpfile = tempfile.mkstemp("yap")
220 os.close(fd)
222 repo = get_output('git rev-parse --git-dir')[0]
223 msg_file = os.path.join(repo, 'yap', 'msg')
224 if os.access(msg_file, os.R_OK):
225 fd1 = file(msg_file)
226 fd2 = file(tmpfile, 'w')
227 for l in fd1.xreadlines():
228 print >>fd2, l.strip()
229 fd2.close()
230 os.unlink(msg_file)
232 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
233 raise YapError("Editing commit message failed")
234 if parent != 'HEAD':
235 commit = get_output("git commit-tree '%s' -p '%s' < '%s'" % (tree, parent, tmpfile))
236 else:
237 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
238 if not commit:
239 raise YapError("Commit failed; no log message?")
240 os.unlink(tmpfile)
241 os.system("git update-ref HEAD '%s'" % commit[0])
243 def _check_rebasing(self):
244 repo = get_output('git rev-parse --git-dir')[0]
245 dotest = os.path.join(repo, '.dotest')
246 if os.access(dotest, os.R_OK):
247 raise YapError("A git operation is in progress. Complete it first")
248 dotest = os.path.join(repo, '..', '.dotest')
249 if os.access(dotest, os.R_OK):
250 raise YapError("A git operation is in progress. Complete it first")
252 def _list_remotes(self):
253 remotes = get_output("git config --get-regexp 'remote.*.url'")
254 for x in remotes:
255 remote, url = x.split(' ')
256 remote = remote.replace('remote.', '')
257 remote = remote.replace('.url', '')
258 yield remote, url
260 @short_help("make a local copy of an existing repository")
261 def cmd_clone(self, url, directory=""):
262 "<url> [directory]"
263 # XXX: implement in terms of init + remote add + fetch
264 os.system("git clone '%s' %s" % (url, directory))
266 @short_help("turn a directory into a repository")
267 def cmd_init(self):
268 os.system("git init")
270 @short_help("add a new file to the repository")
271 def cmd_add(self, *files):
272 "<file>..."
273 if not files:
274 raise TypeError
276 for f in files:
277 self._add_one(f)
278 self.cmd_status()
280 @short_help("delete a file from the repository")
281 def cmd_rm(self, *files):
282 "<file>..."
283 if not files:
284 raise TypeError
286 for f in files:
287 self._rm_one(f)
288 self.cmd_status()
290 @short_help("stage changes in a file for commit")
291 def cmd_stage(self, *files):
292 "<file>..."
293 if not files:
294 raise TypeError
296 for f in files:
297 self._stage_one(f)
298 self.cmd_status()
300 @short_help("unstage changes in a file")
301 def cmd_unstage(self, *files):
302 "<file>..."
303 if not files:
304 raise TypeError
306 for f in files:
307 self._unstage_one(f)
308 self.cmd_status()
310 @short_help("show files with staged and unstaged changes")
311 def cmd_status(self):
312 branch = get_output("git symbolic-ref HEAD")[0]
313 branch = branch.replace('refs/heads/', '')
314 print "Current branch: %s" % branch
316 print "Files with staged changes:"
317 files = self._get_staged_files()
318 for f in files:
319 print "\t%s" % f
320 if not files:
321 print "\t(none)"
323 print "Files with unstaged changes:"
324 prefix = get_output("git rev-parse --show-prefix")
325 files = self._get_unstaged_files()
326 for f in files:
327 if prefix:
328 f = os.path.join(prefix[0], f)
329 print "\t%s" % f
330 if not files:
331 print "\t(none)"
333 @short_help("remove uncommitted changes from a file (*)")
334 @takes_options("a")
335 def cmd_revert(self, *files, **flags):
336 "(-a | <file>)"
337 if '-a' in flags:
338 os.system("git checkout-index -f -a")
339 return
341 if not files:
342 raise TypeError
344 for f in files:
345 self._revert_one(f)
346 self.cmd_status()
348 @short_help("record changes to files as a new commit")
349 @takes_options("ad")
350 def cmd_commit(self, **flags):
351 self._check_rebasing()
352 self._check_commit(**flags)
353 self._do_commit()
354 self.cmd_status()
356 @short_help("reverse the actions of the last commit")
357 def cmd_uncommit(self):
358 self._do_uncommit()
359 self.cmd_status()
361 def cmd_version(self):
362 print "Yap version 0.1"
364 @short_help("show the changelog for particular versions or files")
365 @takes_options("r:")
366 def cmd_log(self, *paths, **flags):
367 "[-r <rev>] <path>..."
368 rev = flags.get('-r', 'HEAD')
369 paths = ' '.join(paths)
370 os.system("git log --name-status '%s' -- %s" % (rev, paths))
372 @short_help("show staged, unstaged, or all uncommitted changes")
373 @takes_options("ud")
374 def cmd_diff(self, **flags):
375 "[ -u | -d ]"
376 if '-u' in flags and '-d' in flags:
377 raise YapError("Conflicting flags: -u and -d")
379 pager = self._get_pager_cmd()
381 os.system("git update-index -q --refresh")
382 if '-u' in flags:
383 os.system("git diff-files -p | %s" % pager)
384 elif '-d' in flags:
385 os.system("git diff-index --cached -p HEAD | %s" % pager)
386 else:
387 os.system("git diff-index -p HEAD | %s" % pager)
389 @short_help("list, create, or delete branches")
390 @takes_options("fd:")
391 def cmd_branch(self, branch=None, **flags):
392 "[ [-f] -d <branch> | <branch> ]"
393 force = '-f' in flags
394 if '-d' in flags:
395 self._delete_branch(flags['-d'], force)
396 self.cmd_branch()
397 return
399 if branch is not None:
400 ref = get_output("git rev-parse HEAD")
401 if not ref:
402 raise YapError("No branch point yet. Make a commit")
403 os.system("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
405 current = get_output("git symbolic-ref HEAD")[0]
406 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
407 for b in branches:
408 if b == current:
409 print "* ",
410 else:
411 print " ",
412 b = b.replace('refs/heads/', '')
413 print b
415 @short_help("change the current working branch")
416 def cmd_switch(self, branch):
417 "<branch>"
418 ref = get_output("git rev-parse 'refs/heads/%s'" % branch)
419 if not ref:
420 raise YapError("No such branch: %s" % branch)
422 # XXX: support merging like git-checkout
423 if self._get_unstaged_files() or self._get_staged_files():
424 raise YapError("You have uncommitted changes. Commit them first")
426 os.system("git symbolic-ref HEAD refs/heads/'%s'" % branch)
427 os.system("git read-tree HEAD")
428 os.system("git checkout-index -f -a")
429 self.cmd_branch()
431 @short_help("move the current branch to a different revision")
432 @takes_options("f")
433 def cmd_point(self, where, **flags):
434 "<where>"
435 head = get_output("git rev-parse HEAD")
436 if not head:
437 raise YapError("No commit yet; nowhere to point")
439 ref = get_output("git rev-parse '%s'" % where)
440 if not ref:
441 raise YapError("Not a valid ref: %s" % where)
443 if self._get_unstaged_files() or self._get_staged_files():
444 raise YapError("You have uncommitted changes. Commit them first")
446 type = get_output("git cat-file -t '%s'" % ref[0])
447 if type and type[0] == "tag":
448 tag = get_output("git cat-file tag '%s'" % ref[0])
449 ref[0] = tag[0].split(' ')[1]
451 os.system("git update-ref HEAD '%s'" % ref[0])
453 if '-f' not in flags:
454 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
455 if name == "undefined":
456 os.system("git update-ref HEAD '%s'" % head[0])
457 raise YapError("Pointing there will lose commits. Use -f to force")
459 os.system("git read-tree HEAD")
460 os.system("git checkout-index -f -a")
461 os.system("git update-index --refresh")
463 @short_help("alter history by dropping or amending commits")
464 def cmd_history(self, subcmd, *args):
465 "amend | drop <commit>"
467 if subcmd not in ("amend", "drop", "continue", "skip"):
468 raise TypeError
470 resolvemsg = """
471 When you have resolved the conflicts run \"yap history continue\".
472 To skip the problematic patch, run \"yap history skip\"."""
474 if subcmd == "continue":
475 os.system("git am -r --resolvemsg='%s'" % resolvemsg)
476 return
477 if subcmd == "skip":
478 os.system("git reset --hard")
479 os.system("git am --skip --resolvemsg='%s'" % resolvemsg)
480 return
482 if subcmd == "amend":
483 flags, args = getopt.getopt(args, "ad")
484 flags = dict(flags)
486 if len(args) > 1:
487 raise TypeError
488 if args:
489 commit = args[0]
490 else:
491 commit = "HEAD"
493 if run_command("git rev-parse --verify '%s'" % commit):
494 raise YapError("Not a valid commit: %s" % commit)
496 self._check_rebasing()
498 if subcmd == "amend":
499 self._check_commit(**flags)
501 stash = get_output("git stash create")
502 run_command("git reset --hard")
504 if subcmd == "amend" and not stash:
505 raise YapError("Failed to stash; no changes?")
507 fd, tmpfile = tempfile.mkstemp("yap")
508 os.close(fd)
509 try:
510 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
511 if subcmd == "amend":
512 self.cmd_point(commit, **{'-f': True})
513 run_command("git stash apply --index %s" % stash[0])
514 self._do_uncommit()
515 self._do_commit()
516 stash = get_output("git stash create")
517 run_command("git reset --hard")
518 else:
519 self.cmd_point("%s^" % commit, **{'-f': True})
521 stat = os.stat(tmpfile)
522 size = stat[6]
523 if size > 0:
524 rc = os.system("git am -3 --resolvemsg=\'%s\' %s" % (resolvemsg, tmpfile))
525 if (rc):
526 raise YapError("Failed to apply changes")
528 if stash:
529 run_command("git stash apply %s" % stash[0])
530 finally:
531 os.unlink(tmpfile)
532 self.cmd_status()
534 @short_help("show the changes introduced by a given commit")
535 def cmd_show(self, commit="HEAD"):
536 "[commit]"
537 os.system("git show '%s'" % commit)
539 @short_help("apply the changes in a given commit to the current branch")
540 @takes_options("r")
541 def cmd_cherry_pick(self, commit, **flags):
542 "[-r] <commit>"
543 if '-r' in flags:
544 os.system("git revert '%s'" % commit)
545 else:
546 os.system("git cherry-pick '%s'" % commit)
548 @short_help("list, add, or delete configured remote repositories")
549 @takes_options("d:")
550 def cmd_repo(self, name=None, url=None, **flags):
551 "[<name> <url> | -d <name>]"
552 if name is not None and url is None:
553 raise TypeError
555 if '-d' in flags:
556 if flags['-d'] not in self._list_remotes():
557 raise YapError("No such repository: %s" % flags['-d'])
558 os.system("git config --unset remote.%s.url" % flags['-d'])
559 os.system("git config --unset remote.%s.fetch" % flags['-d'])
561 if name:
562 if flags['-d'] in self._list_remotes():
563 raise YapError("Repository '%s' already exists" % flags['-d'])
564 os.system("git config remote.%s.url %s" % (name, url))
565 os.system("git config remote.%s.fetch +refs/heads/*:refs/remotes/%s/*" % (name, url))
567 for remote, url in self._list_remotes():
568 print "%s:\t\t%s" % (remote, url)
570 def cmd_help(self):
571 print >> sys.stderr, "Yet Another (Git) Porcelein"
572 print >> sys.stderr
574 for name in dir(self):
575 if not name.startswith('cmd_'):
576 continue
577 attr = self.__getattribute__(name)
578 if not callable(attr):
579 continue
580 try:
581 short_msg = attr.short_help
582 except AttributeError:
583 continue
585 name = name.replace('cmd_', '')
586 name = name.replace('_', '-')
587 print >> sys.stderr, "%-16s%s" % (name, short_msg)
588 print >> sys.stderr
589 print >> sys.stderr, "(*) Indicates that the command is not readily reversible"
591 def cmd_usage(self):
592 print >> sys.stderr, "usage: %s <command>" % sys.argv[0]
593 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"
595 def main(self, args):
596 if len(args) < 1:
597 self.cmd_usage()
598 sys.exit(2)
600 command = args[0]
601 args = args[1:]
603 debug = os.getenv('YAP_DEBUG')
605 try:
606 command = command.replace('-', '_')
607 meth = self.__getattribute__("cmd_"+command)
608 try:
609 if "options" in meth.__dict__:
610 flags, args = getopt.getopt(args, meth.options)
611 flags = dict(flags)
612 else:
613 flags = dict()
615 meth(*args, **flags)
616 except (TypeError, getopt.GetoptError):
617 if debug:
618 raise
619 print "%s %s %s" % (sys.argv[0], command, meth.__doc__)
620 except YapError, e:
621 print >> sys.stderr, e
622 sys.exit(1)
623 except AttributeError:
624 if debug:
625 raise
626 self.cmd_usage()
627 sys.exit(2)