During uncommit, save away the old log message
[yap.git] / yap / yap.py
blob852131c0a8f61662f01c107639a8a5f50f7c643c
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 cmd_clone(self, url, directory=""):
165 "<url> [directory]"
166 # XXX: implement in terms of init + remote add + fetch
167 os.system("git clone '%s' %s" % (url, directory))
169 def cmd_init(self):
170 os.system("git init")
172 def cmd_add(self, *files):
173 "<file>..."
174 if not files:
175 raise TypeError
177 for f in files:
178 self._add_one(f)
179 self.cmd_status()
181 def cmd_rm(self, *files):
182 "<file>..."
183 if not files:
184 raise TypeError
186 for f in files:
187 self._rm_one(f)
188 self.cmd_status()
190 def cmd_stage(self, *files):
191 "<file>..."
192 if not files:
193 raise TypeError
195 for f in files:
196 self._stage_one(f)
197 self.cmd_status()
199 def cmd_unstage(self, *files):
200 "<file>..."
201 if not files:
202 raise TypeError
204 for f in files:
205 self._unstage_one(f)
206 self.cmd_status()
208 def cmd_status(self):
209 branch = get_output("git symbolic-ref HEAD")[0]
210 branch = branch.replace('refs/heads/', '')
211 print "Current branch: %s" % branch
213 print "Files with staged changes:"
214 files = self._get_staged_files()
215 for f in files:
216 print "\t%s" % f
217 if not files:
218 print "\t(none)"
220 print "Files with unstaged changes:"
221 prefix = get_output("git rev-parse --show-prefix")
222 files = self._get_unstaged_files()
223 for f in files:
224 if prefix:
225 f = os.path.join(prefix[0], f)
226 print "\t%s" % f
227 if not files:
228 print "\t(none)"
230 @takes_options("a")
231 def cmd_revert(self, *files, **flags):
232 "(-a | <file>)"
233 if '-a' in flags:
234 os.system("git checkout-index -f -a")
235 return
237 if not files:
238 raise TypeError
240 for f in files:
241 self._revert_one(f)
242 self.cmd_status()
244 @takes_options("ad")
245 def cmd_commit(self, **flags):
246 if '-a' in flags and '-d' in flags:
247 raise YapError("Conflicting flags: -a and -d")
249 if '-d' not in flags and self._get_unstaged_files():
250 if '-a' not in flags and self._get_staged_files():
251 raise YapError("Staged and unstaged changes present. Specify what to commit")
252 os.system("git diff-files -p | git apply --cached 2>/dev/null")
253 for f in self._get_new_files():
254 self._stage_one(f)
256 if not self._get_staged_files():
257 raise YapError("No changes to commit")
259 tree = get_output("git write-tree")[0]
261 parent = get_output("git rev-parse HEAD 2> /dev/null")[0]
263 if os.environ.has_key('YAP_EDITOR'):
264 editor = os.environ['YAP_EDITOR']
265 elif os.environ.has_key('GIT_EDITOR'):
266 editor = os.environ['GIT_EDITOR']
267 elif os.environ.has_key('EDITOR'):
268 editor = os.environ['EDITOR']
269 else:
270 editor = "vi"
272 fd, tmpfile = tempfile.mkstemp("yap")
273 os.close(fd)
274 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
275 raise YapError("Editing commit message failed")
276 if parent != 'HEAD':
277 commit = get_output("git commit-tree '%s' -p '%s' < '%s'" % (tree, parent, tmpfile))
278 else:
279 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
280 if not commit:
281 raise YapError("Commit failed; no log message?")
282 os.unlink(tmpfile)
283 os.system("git update-ref HEAD '%s'" % commit[0])
284 self.cmd_status()
286 def cmd_uncommit(self):
287 commit = self._parse_commit("HEAD")
288 repo = get_output('git rev-parse --git-dir')[0]
289 dir = os.path.join(repo, 'yap')
290 try:
291 os.mkdir(dir)
292 except OSError:
293 pass
294 msg_file = os.path.join(dir, 'msg')
295 fd = file(msg_file, 'w')
296 print >>fd, commit['log']
297 fd.close()
299 tree = get_output("git rev-parse HEAD^")
300 os.system("git update-ref -m uncommit HEAD '%s'" % tree[0])
301 self.cmd_status()
303 def cmd_version(self):
304 print "Yap version 0.1"
306 @takes_options("r:")
307 def cmd_log(self, *paths, **flags):
308 "[-r <rev>] <path>..."
309 rev = flags.get('-r', 'HEAD')
310 paths = ' '.join(paths)
311 os.system("git log --name-status '%s' -- %s" % (rev, paths))
313 @takes_options("ud")
314 def cmd_diff(self, **flags):
315 "[ -u | -d ]"
316 if '-u' in flags and '-d' in flags:
317 raise YapError("Conflicting flags: -u and -d")
319 pager = self._get_pager_cmd()
321 os.system("git update-index -q --refresh")
322 if '-u' in flags:
323 os.system("git diff-files -p | %s" % pager)
324 elif '-d' in flags:
325 os.system("git diff-index --cached -p HEAD | %s" % pager)
326 else:
327 os.system("git diff-index -p HEAD | %s" % pager)
329 @takes_options("fd:")
330 def cmd_branch(self, branch=None, **flags):
331 "[ [-f] -d <branch> | <branch> ]"
332 force = '-f' in flags
333 if '-d' in flags:
334 self._delete_branch(flags['-d'], force)
335 self.cmd_branch()
336 return
338 if branch is not None:
339 ref = get_output("git rev-parse HEAD")
340 if not ref:
341 raise YapError("No branch point yet. Make a commit")
342 os.system("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
344 current = get_output("git symbolic-ref HEAD")[0]
345 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
346 for b in branches:
347 if b == current:
348 print "* ",
349 else:
350 print " ",
351 b = b.replace('refs/heads/', '')
352 print b
354 def cmd_switch(self, branch):
355 "<branch>"
356 ref = get_output("git rev-parse 'refs/heads/%s'" % branch)
357 if not ref:
358 raise YapError("No such branch: %s" % branch)
360 # XXX: support merging like git-checkout
361 if self._get_unstaged_files() or self._get_staged_files():
362 raise YapError("You have uncommitted changes. Commit them first")
364 os.system("git symbolic-ref HEAD refs/heads/'%s'" % branch)
365 os.system("git read-tree HEAD")
366 os.system("git checkout-index -f -a")
367 self.cmd_branch()
369 @takes_options("f")
370 def cmd_point(self, where, **flags):
371 "<where>"
372 head = get_output("git rev-parse HEAD")
373 if not head:
374 raise YapError("No commit yet; nowhere to point")
376 ref = get_output("git rev-parse '%s'" % where)
377 if not ref:
378 raise YapError("Not a valid ref: %s" % where)
380 if self._get_unstaged_files() or self._get_staged_files():
381 raise YapError("You have uncommitted changes. Commit them first")
383 type = get_output("git cat-file -t '%s'" % ref[0])
384 if type and type[0] == "tag":
385 tag = get_output("git cat-file tag '%s'" % ref[0])
386 ref[0] = tag[0].split(' ')[1]
388 os.system("git update-ref HEAD '%s'" % ref[0])
390 if '-f' not in flags:
391 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
392 if name == "undefined":
393 os.system("git update-ref HEAD '%s'" % head[0])
394 raise YapError("Pointing there will lose commits. Use -f to force")
396 os.system("git read-tree HEAD")
397 os.system("git checkout-index -f -a")
398 os.system("git update-index --refresh")
400 def cmd_history(self, subcmd, commit):
401 "amend | drop <commit>"
403 if subcmd not in ("amend", "drop"):
404 raise TypeError
406 # XXX: ensure no rebase in progress
408 if subcmd == "amend":
409 # XXX: Use cmd_commit rules
410 stash = get_output("git stash create")
411 os.system("git reset --hard")
412 if not stash:
413 raise YapError("Failed to stash; no changes?")
415 fd, tmpfile = tempfile.mkstemp("yap")
416 os.close(fd)
417 try:
418 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
419 if subcmd == "amend":
420 self.cmd_point(commit, **{'-f': True})
421 run_command("git stash apply --index %s" % stash[0])
422 # XXX: use cmd_commit instead
423 os.system("git commit --amend")
424 stash = get_output("git stash create")
425 os.system("git reset --hard")
426 else:
427 self.cmd_point("%s^" % commit, **{'-f': True})
429 stat = os.stat(tmpfile)
430 size = stat[6]
431 if size > 0:
432 rc = os.system("git am -3 '%s' > /dev/null" % tmpfile)
433 if (rc):
434 raise YapError("Failed to apply changes")
436 if subcmd == "amend" and stash:
437 run_command("git stash apply %s" % stash[0])
438 finally:
439 os.unlink(tmpfile)
440 self.cmd_status()
442 def cmd_usage(self):
443 print >> sys.stderr, "usage: %s <command>" % sys.argv[0]
444 print >> sys.stderr, " valid commands: init add rm stage unstage status revert commit uncommit log diff branch switch point history version"
446 def main(self, args):
447 if len(args) < 1:
448 self.cmd_usage()
449 sys.exit(2)
451 command = args[0]
452 args = args[1:]
454 debug = os.getenv('YAP_DEBUG')
456 try:
457 meth = self.__getattribute__("cmd_"+command)
458 try:
459 if "options" in meth.__dict__:
460 flags, args = getopt.getopt(args, meth.options)
461 flags = dict(flags)
462 else:
463 flags = dict()
465 meth(*args, **flags)
466 except (TypeError, getopt.GetoptError):
467 if debug:
468 raise
469 print "%s %s %s" % (sys.argv[0], command, meth.__doc__)
470 except YapError, e:
471 print >> sys.stderr, e
472 sys.exit(1)
473 except AttributeError:
474 if debug:
475 raise
476 self.cmd_usage()
477 sys.exit(2)