Implement history
[yap.git] / yap / yap.py
blobb178681327dd306a049ea3b0cd45f0506eb251a9
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 cmd_clone(self, url, directory=""):
144 "<url> [directory]"
145 # XXX: implement in terms of init + remote add + fetch
146 os.system("git clone '%s' %s" % (url, directory))
148 def cmd_init(self):
149 os.system("git init")
151 def cmd_add(self, *files):
152 "<file>..."
153 if not files:
154 raise TypeError
156 for f in files:
157 self._add_one(f)
158 self.cmd_status()
160 def cmd_rm(self, *files):
161 "<file>..."
162 if not files:
163 raise TypeError
165 for f in files:
166 self._rm_one(f)
167 self.cmd_status()
169 def cmd_stage(self, *files):
170 "<file>..."
171 if not files:
172 raise TypeError
174 for f in files:
175 self._stage_one(f)
176 self.cmd_status()
178 def cmd_unstage(self, *files):
179 "<file>..."
180 if not files:
181 raise TypeError
183 for f in files:
184 self._unstage_one(f)
185 self.cmd_status()
187 def cmd_status(self):
188 branch = get_output("git symbolic-ref HEAD")[0]
189 branch = branch.replace('refs/heads/', '')
190 print "Current branch: %s" % branch
192 print "Files with staged changes:"
193 files = self._get_staged_files()
194 for f in files:
195 print "\t%s" % f
196 if not files:
197 print "\t(none)"
199 print "Files with unstaged changes:"
200 files = self._get_unstaged_files()
201 for f in files:
202 print "\t%s" % f
203 if not files:
204 print "\t(none)"
206 @takes_options("a")
207 def cmd_revert(self, *files, **flags):
208 "(-a | <file>)"
209 if '-a' in flags:
210 os.system("git checkout-index -f -a")
211 return
213 if not files:
214 raise TypeError
216 for f in files:
217 self._revert_one(f)
218 self.cmd_status()
220 @takes_options("ad")
221 def cmd_commit(self, **flags):
222 if '-a' in flags and '-d' in flags:
223 raise YapError("Conflicting flags: -a and -d")
225 if '-d' not in flags and self._get_unstaged_files():
226 if '-a' not in flags and self._get_staged_files():
227 raise YapError("Staged and unstaged changes present. Specify what to commit")
228 os.system("git diff-files -p | git apply --cached 2>/dev/null")
229 for f in self._get_new_files():
230 self._stage_one(f)
232 if not self._get_staged_files():
233 raise YapError("No changes to commit")
235 tree = get_output("git write-tree")[0]
237 parent = get_output("git rev-parse HEAD 2> /dev/null")[0]
239 if os.environ.has_key('YAP_EDITOR'):
240 editor = os.environ['YAP_EDITOR']
241 elif os.environ.has_key('GIT_EDITOR'):
242 editor = os.environ['GIT_EDITOR']
243 elif os.environ.has_key('EDITOR'):
244 editor = os.environ['EDITOR']
245 else:
246 editor = "vi"
248 fd, tmpfile = tempfile.mkstemp("yap")
249 os.close(fd)
250 if os.system("%s '%s'" % (editor, tmpfile)) != 0:
251 raise YapError("Editing commit message failed")
252 if parent != 'HEAD':
253 commit = get_output("git commit-tree '%s' -p '%s' < '%s'" % (tree, parent, tmpfile))
254 else:
255 commit = get_output("git commit-tree '%s' < '%s'" % (tree, tmpfile))
256 if not commit:
257 raise YapError("Commit failed; no log message?")
258 os.unlink(tmpfile)
259 os.system("git update-ref HEAD '%s'" % commit[0])
260 self.cmd_status()
262 def cmd_uncommit(self):
263 tree = get_output("git rev-parse HEAD^")
264 os.system("git update-ref -m uncommit HEAD '%s'" % tree[0])
265 self.cmd_status()
267 def cmd_version(self):
268 print "Yap version 0.1"
270 @takes_options("r:")
271 def cmd_log(self, *paths, **flags):
272 "[-r <rev>] <path>..."
273 rev = flags.get('-r', 'HEAD')
274 paths = ' '.join(paths)
275 os.system("git log --name-status '%s' -- %s" % (rev, paths))
277 @takes_options("ud")
278 def cmd_diff(self, **flags):
279 "[ -u | -d ]"
280 if '-u' in flags and '-d' in flags:
281 raise YapError("Conflicting flags: -u and -d")
283 pager = self._get_pager_cmd()
285 os.system("git update-index -q --refresh")
286 if '-u' in flags:
287 os.system("git diff-files -p | %s" % pager)
288 elif '-d' in flags:
289 os.system("git diff-index --cached -p HEAD | %s" % pager)
290 else:
291 os.system("git diff-index -p HEAD | %s" % pager)
293 @takes_options("fd:")
294 def cmd_branch(self, branch=None, **flags):
295 "[ [-f] -d <branch> | <branch> ]"
296 force = '-f' in flags
297 if '-d' in flags:
298 self._delete_branch(flags['-d'], force)
299 self.cmd_branch()
300 return
302 if branch is not None:
303 ref = get_output("git rev-parse HEAD")
304 if not ref:
305 raise YapError("No branch point yet. Make a commit")
306 os.system("git update-ref 'refs/heads/%s' '%s'" % (branch, ref[0]))
308 current = get_output("git symbolic-ref HEAD")[0]
309 branches = get_output("git for-each-ref --format='%(refname)' 'refs/heads/*'")
310 for b in branches:
311 if b == current:
312 print "* ",
313 else:
314 print " ",
315 b = b.replace('refs/heads/', '')
316 print b
318 def cmd_switch(self, branch):
319 "<branch>"
320 ref = get_output("git rev-parse 'refs/heads/%s'" % branch)
321 if not ref:
322 raise YapError("No such branch: %s" % branch)
324 # XXX: support merging like git-checkout
325 if self._get_unstaged_files() or self._get_staged_files():
326 raise YapError("You have uncommitted changes. Commit them first")
328 os.system("git symbolic-ref HEAD refs/heads/'%s'" % branch)
329 os.system("git read-tree HEAD")
330 os.system("git checkout-index -f -a")
331 self.cmd_branch()
333 @takes_options("f")
334 def cmd_point(self, where, **flags):
335 "<where>"
336 head = get_output("git rev-parse HEAD")
337 if not head:
338 raise YapError("No commit yet; nowhere to point")
340 ref = get_output("git rev-parse '%s'" % where)
341 if not ref:
342 raise YapError("Not a valid ref: %s" % where)
344 if self._get_unstaged_files() or self._get_staged_files():
345 raise YapError("You have uncommitted changes. Commit them first")
347 os.system("git update-ref HEAD '%s'" % ref[0])
349 if '-f' not in flags:
350 name = get_output("git name-rev --name-only '%s'" % head[0])[0]
351 if name == "undefined":
352 os.system("git update-ref HEAD '%s'" % head[0])
353 raise YapError("Pointing there will lose commits. Use -f to force")
355 os.system("git read-tree HEAD")
356 os.system("git checkout-index -f -a")
357 os.system("git update-index --refresh")
359 def cmd_history(self, subcmd, commit):
360 "amend | drop <commit>"
362 if subcmd not in ("amend", "drop"):
363 raise TypeError
365 # XXX: ensure no rebase in progress
367 if subcmd == "amend":
368 # XXX: Use cmd_commit rules
369 stash = get_output("git stash create")
370 os.system("git reset --hard")
371 if not stash:
372 raise YapError("Failed to stash; no changes?")
374 fd, tmpfile = tempfile.mkstemp("yap")
375 os.close(fd)
376 try:
377 os.system("git format-patch -k --stdout '%s' > %s" % (commit, tmpfile))
378 if subcmd == "amend":
379 self.cmd_point(commit, **{'-f': True})
380 run_command("git stash apply --index %s" % stash[0])
381 # XXX: use cmd_commit instead
382 os.system("git commit --amend")
383 stash = get_output("git stash create")
384 os.system("git reset --hard")
385 else:
386 self.cmd_point("%s^" % commit, **{'-f': True})
388 stat = os.stat(tmpfile)
389 size = stat[6]
390 if size > 0:
391 rc = os.system("git am -3 '%s' > /dev/null" % tmpfile)
392 if (rc):
393 raise YapError("Failed to apply changes")
395 if subcmd == "amend" and stash:
396 run_command("git stash apply %s" % stash[0])
397 finally:
398 os.unlink(tmpfile)
399 self.cmd_status()
401 def cmd_usage(self):
402 print >> sys.stderr, "usage: %s <command>" % sys.argv[0]
403 print >> sys.stderr, " valid commands: init add rm stage unstage status revert commit uncommit log diff branch switch point history version"
405 def main(self, args):
406 if len(args) < 1:
407 self.cmd_usage()
408 sys.exit(2)
410 command = args[0]
411 args = args[1:]
413 debug = os.getenv('YAP_DEBUG')
415 try:
416 meth = self.__getattribute__("cmd_"+command)
417 try:
418 if "options" in meth.__dict__:
419 flags, args = getopt.getopt(args, meth.options)
420 flags = dict(flags)
421 else:
422 flags = dict()
424 meth(*args, **flags)
425 except (TypeError, getopt.GetoptError):
426 if debug:
427 raise
428 print "%s %s %s" % (sys.argv[0], command, meth.__doc__)
429 except YapError, e:
430 print >> sys.stderr, e
431 sys.exit(1)
432 except AttributeError:
433 if debug:
434 raise
435 self.cmd_usage()
436 sys.exit(2)