cmd_commit: teach how to check for rebase/am in progress
[yap.git] / yap / yap.py
blob37443ea982a1e282f0d38a7ddc772092dbca659a
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 class Yap(object):
32 def _add_new_file(self, file):
33 repo = get_output('git rev-parse --git-dir')[0]
34 dir = os.path.join(repo, 'yap')
35 try:
36 os.mkdir(dir)
37 except OSError:
38 pass
39 files = self._get_new_files()
40 files.append(file)
41 path = os.path.join(dir, 'new-files')
42 pickle.dump(files, open(path, 'w'))
44 def _get_new_files(self):
45 repo = get_output('git rev-parse --git-dir')[0]
46 path = os.path.join(repo, 'yap', 'new-files')
47 try:
48 files = pickle.load(file(path))
49 except IOError:
50 files = []
52 x = []
53 for f in files:
54 # if f in the index
55 if get_output("git ls-files --cached '%s'" % f) != []:
56 continue
57 x.append(f)
58 return x
60 def _remove_new_file(self, file):
61 files = self._get_new_files()
62 files = filter(lambda x: x != file, files)
64 repo = get_output('git rev-parse --git-dir')[0]
65 path = os.path.join(repo, 'yap', 'new-files')
66 pickle.dump(files, open(path, 'w'))
68 def _clear_new_files(self):
69 repo = get_output('git rev-parse --git-dir')[0]
70 path = os.path.join(repo, 'yap', 'new-files')
71 os.unlink(path)
73 def _assert_file_exists(self, file):
74 if not os.access(file, os.R_OK):
75 raise YapError("No such file: %s" % file)
77 def _get_staged_files(self):
78 if run_command("git rev-parse HEAD"):
79 files = get_output("git ls-files --cached")
80 else:
81 files = get_output("git diff-index --cached --name-only HEAD")
82 return files
84 def _get_unstaged_files(self):
85 files = self._get_new_files()
86 files += get_output("git ls-files -m")
87 return files
89 def _delete_branch(self, branch, force):
90 current = get_output("git symbolic-ref HEAD")[0]
91 current = current.replace('refs/heads/', '')
92 if branch == current:
93 raise YapError("Can't delete current branch")
95 ref = get_output("git rev-parse 'refs/heads/%s'" % branch)
96 if not ref:
97 raise YapError("No such branch: %s" % branch)
98 os.system("git update-ref -d 'refs/heads/%s' '%s'" % (branch, ref[0]))
100 if not force:
101 name = get_output("git name-rev --name-only '%s'" % ref[0])[0]
102 if name == 'undefined':
103 os.system("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
104 raise YapError("Refusing to delete leaf branch (use -f to force)")
105 def _get_pager_cmd(self):
106 if 'YAP_PAGER' in os.environ:
107 return os.environ['YAP_PAGER']
108 elif 'GIT_PAGER' in os.environ:
109 return os.environ['GIT_PAGER']
110 elif 'PAGER' in os.environ:
111 return os.environ['PAGER']
112 else:
113 return "more"
115 def _add_one(self, file):
116 self._assert_file_exists(file)
117 x = get_output("git ls-files '%s'" % file)
118 if x != []:
119 raise YapError("File '%s' already in repository" % file)
120 self._add_new_file(file)
122 def _rm_one(self, file):
123 self._assert_file_exists(file)
124 if get_output("git ls-files '%s'" % file) != []:
125 os.system("git rm --cached '%s'" % file)
126 self._remove_new_file(file)
128 def _stage_one(self, file):
129 self._assert_file_exists(file)
130 os.system("git update-index --add '%s'" % file)
132 def _unstage_one(self, file):
133 self._assert_file_exists(file)
134 if run_command("git rev-parse HEAD"):
135 os.system("git update-index --force-remove '%s'" % file)
136 else:
137 os.system("git diff-index -p HEAD '%s' | git apply -R --cached" % file)
139 def _revert_one(self, file):
140 self._assert_file_exists(file)
141 os.system("git checkout-index -f '%s'" % file)
143 def _parse_commit(self, commit):
144 lines = get_output("git cat-file commit '%s'" % commit)
145 commit = {}
147 mode = None
148 for l in lines:
149 if mode != 'commit' and l.strip() == "":
150 mode = 'commit'
151 commit['log'] = []
152 continue
153 if mode == 'commit':
154 commit['log'].append(l)
155 continue
157 x = l.split(' ')
158 k = x[0]
159 v = ' '.join(x[1:])
160 commit[k] = v
161 commit['log'] = '\n'.join(commit['log'])
162 return commit
164 def _check_commit(self, **flags):
165 if '-a' in flags and '-d' in flags:
166 raise YapError("Conflicting flags: -a and -d")
168 if '-d' not in flags and self._get_unstaged_files():
169 if '-a' not in flags and self._get_staged_files():
170 raise YapError("Staged and unstaged changes present. Specify what to commit")
171 os.system("git diff-files -p | git apply --cached 2>/dev/null")
172 for f in self._get_new_files():
173 self._stage_one(f)
175 if not self._get_staged_files():
176 raise YapError("No changes to commit")
178 def _do_uncommit(self):
179 commit = self._parse_commit("HEAD")
180 repo = get_output('git rev-parse --git-dir')[0]
181 dir = os.path.join(repo, 'yap')
182 try:
183 os.mkdir(dir)
184 except OSError:
185 pass
186 msg_file = os.path.join(dir, 'msg')
187 fd = file(msg_file, 'w')
188 print >>fd, commit['log']
189 fd.close()
191 tree = get_output("git rev-parse HEAD^")
192 os.system("git update-ref -m uncommit HEAD '%s'" % tree[0])
194 def _do_commit(self):
195 tree = get_output("git write-tree")[0]
196 parent = get_output("git rev-parse HEAD 2> /dev/null")[0]
198 if os.environ.has_key('YAP_EDITOR'):
199 editor = os.environ['YAP_EDITOR']
200 elif os.environ.has_key('GIT_EDITOR'):
201 editor = os.environ['GIT_EDITOR']
202 elif os.environ.has_key('EDITOR'):
203 editor = os.environ['EDITOR']
204 else:
205 editor = "vi"
207 fd, tmpfile = tempfile.mkstemp("yap")
208 os.close(fd)
210 repo = get_output('git rev-parse --git-dir')[0]
211 msg_file = os.path.join(repo, 'yap', 'msg')
212 if os.access(msg_file, os.R_OK):
213 fd1 = file(msg_file)
214 fd2 = file(tmpfile, 'w')
215 for l in fd1.xreadlines():
216 print >>fd2, l.strip()
217 fd2.close()
218 os.unlink(msg_file)
220 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
221 raise YapError("Editing commit message failed")
222 if parent != 'HEAD':
223 commit = get_output("git commit-tree '%s' -p '%s' < '%s'" % (tree, parent, tmpfile))
224 else:
225 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
226 if not commit:
227 raise YapError("Commit failed; no log message?")
228 os.unlink(tmpfile)
229 os.system("git update-ref HEAD '%s'" % commit[0])
231 def _check_rebasing(self):
232 repo = get_output('git rev-parse --git-dir')[0]
233 dotest = os.path.join(repo, '.dotest')
234 if os.access(dotest, os.R_OK):
235 raise YapError("A git operation is in progress. Complete it first")
236 dotest = os.path.join(repo, '..', '.dotest')
237 if os.access(dotest, os.R_OK):
238 raise YapError("A git operation is in progress. Complete it first")
240 def cmd_clone(self, url, directory=""):
241 "<url> [directory]"
242 # XXX: implement in terms of init + remote add + fetch
243 os.system("git clone '%s' %s" % (url, directory))
245 def cmd_init(self):
246 os.system("git init")
248 def cmd_add(self, *files):
249 "<file>..."
250 if not files:
251 raise TypeError
253 for f in files:
254 self._add_one(f)
255 self.cmd_status()
257 def cmd_rm(self, *files):
258 "<file>..."
259 if not files:
260 raise TypeError
262 for f in files:
263 self._rm_one(f)
264 self.cmd_status()
266 def cmd_stage(self, *files):
267 "<file>..."
268 if not files:
269 raise TypeError
271 for f in files:
272 self._stage_one(f)
273 self.cmd_status()
275 def cmd_unstage(self, *files):
276 "<file>..."
277 if not files:
278 raise TypeError
280 for f in files:
281 self._unstage_one(f)
282 self.cmd_status()
284 def cmd_status(self):
285 branch = get_output("git symbolic-ref HEAD")[0]
286 branch = branch.replace('refs/heads/', '')
287 print "Current branch: %s" % branch
289 print "Files with staged changes:"
290 files = self._get_staged_files()
291 for f in files:
292 print "\t%s" % f
293 if not files:
294 print "\t(none)"
296 print "Files with unstaged changes:"
297 prefix = get_output("git rev-parse --show-prefix")
298 files = self._get_unstaged_files()
299 for f in files:
300 if prefix:
301 f = os.path.join(prefix[0], f)
302 print "\t%s" % f
303 if not files:
304 print "\t(none)"
306 @takes_options("a")
307 def cmd_revert(self, *files, **flags):
308 "(-a | <file>)"
309 if '-a' in flags:
310 os.system("git checkout-index -f -a")
311 return
313 if not files:
314 raise TypeError
316 for f in files:
317 self._revert_one(f)
318 self.cmd_status()
320 @takes_options("ad")
321 def cmd_commit(self, **flags):
322 self._check_rebasing()
323 self._check_commit(**flags)
324 self._do_commit()
325 self.cmd_status()
327 def cmd_uncommit(self):
328 self._do_uncommit()
329 self.cmd_status()
331 def cmd_version(self):
332 print "Yap version 0.1"
334 @takes_options("r:")
335 def cmd_log(self, *paths, **flags):
336 "[-r <rev>] <path>..."
337 rev = flags.get('-r', 'HEAD')
338 paths = ' '.join(paths)
339 os.system("git log --name-status '%s' -- %s" % (rev, paths))
341 @takes_options("ud")
342 def cmd_diff(self, **flags):
343 "[ -u | -d ]"
344 if '-u' in flags and '-d' in flags:
345 raise YapError("Conflicting flags: -u and -d")
347 pager = self._get_pager_cmd()
349 os.system("git update-index -q --refresh")
350 if '-u' in flags:
351 os.system("git diff-files -p | %s" % pager)
352 elif '-d' in flags:
353 os.system("git diff-index --cached -p HEAD | %s" % pager)
354 else:
355 os.system("git diff-index -p HEAD | %s" % pager)
357 @takes_options("fd:")
358 def cmd_branch(self, branch=None, **flags):
359 "[ [-f] -d <branch> | <branch> ]"
360 force = '-f' in flags
361 if '-d' in flags:
362 self._delete_branch(flags['-d'], force)
363 self.cmd_branch()
364 return
366 if branch is not None:
367 ref = get_output("git rev-parse HEAD")
368 if not ref:
369 raise YapError("No branch point yet. Make a commit")
370 os.system("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
372 current = get_output("git symbolic-ref HEAD")[0]
373 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
374 for b in branches:
375 if b == current:
376 print "* ",
377 else:
378 print " ",
379 b = b.replace('refs/heads/', '')
380 print b
382 def cmd_switch(self, branch):
383 "<branch>"
384 ref = get_output("git rev-parse 'refs/heads/%s'" % branch)
385 if not ref:
386 raise YapError("No such branch: %s" % branch)
388 # XXX: support merging like git-checkout
389 if self._get_unstaged_files() or self._get_staged_files():
390 raise YapError("You have uncommitted changes. Commit them first")
392 os.system("git symbolic-ref HEAD refs/heads/'%s'" % branch)
393 os.system("git read-tree HEAD")
394 os.system("git checkout-index -f -a")
395 self.cmd_branch()
397 @takes_options("f")
398 def cmd_point(self, where, **flags):
399 "<where>"
400 head = get_output("git rev-parse HEAD")
401 if not head:
402 raise YapError("No commit yet; nowhere to point")
404 ref = get_output("git rev-parse '%s'" % where)
405 if not ref:
406 raise YapError("Not a valid ref: %s" % where)
408 if self._get_unstaged_files() or self._get_staged_files():
409 raise YapError("You have uncommitted changes. Commit them first")
411 type = get_output("git cat-file -t '%s'" % ref[0])
412 if type and type[0] == "tag":
413 tag = get_output("git cat-file tag '%s'" % ref[0])
414 ref[0] = tag[0].split(' ')[1]
416 os.system("git update-ref HEAD '%s'" % ref[0])
418 if '-f' not in flags:
419 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
420 if name == "undefined":
421 os.system("git update-ref HEAD '%s'" % head[0])
422 raise YapError("Pointing there will lose commits. Use -f to force")
424 os.system("git read-tree HEAD")
425 os.system("git checkout-index -f -a")
426 os.system("git update-index --refresh")
428 def cmd_history(self, subcmd, *args):
429 "amend | drop <commit>"
431 if subcmd not in ("amend", "drop"):
432 raise TypeError
434 if subcmd == "amend":
435 flags, args = getopt.getopt(args, "ad")
436 flags = dict(flags)
438 if len(args) > 1:
439 raise TypeError
440 if args:
441 commit = args[0]
442 else:
443 commit = "HEAD"
445 if run_command("git rev-parse --verify '%s'" % commit):
446 raise YapError("Not a valid commit: %s" % commit)
448 self._check_rebasing()
450 if subcmd == "amend":
451 self._check_commit(**flags)
453 stash = get_output("git stash create")
454 run_command("git reset --hard")
456 if subcmd == "amend" and not stash:
457 raise YapError("Failed to stash; no changes?")
459 fd, tmpfile = tempfile.mkstemp("yap")
460 os.close(fd)
461 try:
462 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
463 if subcmd == "amend":
464 self.cmd_point(commit, **{'-f': True})
465 run_command("git stash apply --index %s" % stash[0])
466 self._do_uncommit()
467 self._do_commit()
468 stash = get_output("git stash create")
469 run_command("git reset --hard")
470 else:
471 self.cmd_point("%s^" % commit, **{'-f': True})
473 stat = os.stat(tmpfile)
474 size = stat[6]
475 if size > 0:
476 rc = os.system("git am -3 '%s' > /dev/null" % tmpfile)
477 if (rc):
478 raise YapError("Failed to apply changes")
480 if stash:
481 run_command("git stash apply %s" % stash[0])
482 finally:
483 os.unlink(tmpfile)
484 self.cmd_status()
486 def cmd_usage(self):
487 print >> sys.stderr, "usage: %s <command>" % sys.argv[0]
488 print >> sys.stderr, " valid commands: init add rm stage unstage status revert commit uncommit log diff branch switch point history version"
490 def main(self, args):
491 if len(args) < 1:
492 self.cmd_usage()
493 sys.exit(2)
495 command = args[0]
496 args = args[1:]
498 debug = os.getenv('YAP_DEBUG')
500 try:
501 meth = self.__getattribute__("cmd_"+command)
502 try:
503 if "options" in meth.__dict__:
504 flags, args = getopt.getopt(args, meth.options)
505 flags = dict(flags)
506 else:
507 flags = dict()
509 meth(*args, **flags)
510 except (TypeError, getopt.GetoptError):
511 if debug:
512 raise
513 print "%s %s %s" % (sys.argv[0], command, meth.__doc__)
514 except YapError, e:
515 print >> sys.stderr, e
516 sys.exit(1)
517 except AttributeError:
518 if debug:
519 raise
520 self.cmd_usage()
521 sys.exit(2)