]> arthur.barton.de Git - bup.git/commitdiff
cmd-ftp: a new command-line client you can use for browsing your repo.
authorAvery Pennarun <apenwarr@gmail.com>
Sun, 14 Feb 2010 01:21:57 +0000 (20:21 -0500)
committerAvery Pennarun <apenwarr@gmail.com>
Sun, 14 Feb 2010 08:37:24 +0000 (03:37 -0500)
It acts kind of like the 'ftp' command; hence the name.  It even has
readline and filename autocompletion!

The new vfs layer stuff should be useful for cmd-ls and cmd-fuse too.

Makefile
cmd-ftp.py [new file with mode: 0755]
git.py
shquote.py [new file with mode: 0644]
t/tshquote.py [new file with mode: 0644]
vfs.py [new file with mode: 0644]

index ee3ff3737fc5fb9beaa883349bc3f3fb55a1a03a..b9ed07b00c6bdab26c2a6af6c57395e522a8928e 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -21,7 +21,7 @@ default: all
 
 all: bup-split bup-join bup-save bup-init bup-server bup-index bup-tick \
        bup-midx bup-fuse bup-ls bup-damage bup-fsck bup-margin bup-drecurse \
-       bup-random \
+       bup-random bup-ftp \
        bup memtest _hashsplit$(SOEXT) \
        Documentation/all
        
diff --git a/cmd-ftp.py b/cmd-ftp.py
new file mode 100755 (executable)
index 0000000..9594b39
--- /dev/null
@@ -0,0 +1,139 @@
+#!/usr/bin/env python
+import sys, os, re, stat, readline, fnmatch
+import options, git, shquote, vfs
+from helpers import *
+
+def print_node(text, n):
+    if stat.S_ISDIR(n.mode):
+        print '%s/' % text
+    elif stat.S_ISLNK(n.mode):
+        print '%s@' % text
+    else:
+        print '%s' % text
+
+
+def do_ls(path, n):
+    if stat.S_ISDIR(n.mode):
+        for sub in n:
+            print_node(sub.name, sub)
+    else:
+        print_node(path, n)
+
+
+def write_to_file(inf, outf):
+    for blob in chunkyreader(inf):
+        outf.write(blob)
+    
+
+def inputiter():
+    if os.isatty(sys.stdin.fileno()):
+        while 1:
+            try:
+                yield raw_input('bup> ')
+            except EOFError:
+                break
+    else:
+        for line in sys.stdin:
+            yield line
+
+
+def _completer_get_subs(line):
+    (qtype, lastword) = shquote.unfinished_word(line)
+    (dir,name) = os.path.split(lastword)
+    #log('\ncompleter: %r %r %r\n' % (qtype, lastword, text))
+    n = pwd.resolve(dir)
+    subs = list(filter(lambda x: x.name.startswith(name),
+                       n.subs()))
+    return (dir, name, qtype, lastword, subs)
+
+
+_last_line = None
+_last_res = None
+def completer(text, state):
+    global _last_line
+    global _last_res
+    try:
+        line = readline.get_line_buffer()[:readline.get_endidx()]
+        if _last_line != line:
+            _last_res = _completer_get_subs(line)
+            _last_line = line
+        (dir, name, qtype, lastword, subs) = _last_res
+        if state < len(subs):
+            sn = subs[state]
+            sn1 = sn.resolve('')  # deref symlinks
+            fullname = os.path.join(dir, sn.name)
+            if stat.S_ISDIR(sn1.mode):
+                ret = shquote.what_to_add(qtype, lastword, fullname+'/',
+                                          terminate=False)
+            else:
+                ret = shquote.what_to_add(qtype, lastword, fullname,
+                                          terminate=True) + ' '
+            return text + ret
+    except Exception, e:
+        log('\nerror in completion: %s\n' % e)
+
+            
+optspec = """
+bup ftp
+"""
+o = options.Options('bup ftp', optspec)
+(opt, flags, extra) = o.parse(sys.argv[1:])
+
+git.check_repo_or_die()
+
+top = vfs.RefList(None)
+pwd = top
+
+if extra:
+    lines = extra
+else:
+    readline.set_completer_delims(' \t\n\r/')
+    readline.set_completer(completer)
+    readline.parse_and_bind("tab: complete")
+    lines = inputiter()
+
+for line in lines:
+    if not line.strip():
+        continue
+    words = [word for (wordstart,word) in shquote.quotesplit(line)]
+    cmd = words[0].lower()
+    #log('execute: %r %r\n' % (cmd, parm))
+    try:
+        if cmd == 'ls':
+            for parm in (words[1:] or ['.']):
+                do_ls(parm, pwd.resolve(parm))
+        elif cmd == 'cd':
+            for parm in words[1:]:
+                pwd = pwd.resolve(parm)
+        elif cmd == 'pwd':
+            print pwd.fullname()
+        elif cmd == 'cat':
+            for parm in words[1:]:
+                write_to_file(pwd.resolve(parm).open(), sys.stdout)
+        elif cmd == 'get':
+            if len(words) not in [2,3]:
+                raise Exception('Usage: get <filename> [localname]')
+            rname = words[1]
+            (dir,base) = os.path.split(rname)
+            lname = len(words)>2 and words[2] or base
+            log('Saving %r\n' % lname)
+            inf = pwd.resolve(rname).open()
+            write_to_file(inf, open(lname, 'wb'))
+        elif cmd == 'mget':
+            for parm in words[1:]:
+                (dir,base) = os.path.split(parm)
+                for n in pwd.resolve(dir).subs():
+                    if fnmatch.fnmatch(n.name, base):
+                        try:
+                            log('Saving %r\n' % n.name)
+                            inf = n.open()
+                            outf = open(n.name, 'wb')
+                            write_to_file(inf, outf)
+                            outf.close()
+                        except Exception, e:
+                            log('  error: %s\n' % e)
+        else:
+            raise Exception('no such command %r' % cmd)
+    except Exception, e:
+        log('error: %s\n' % e)
+        #raise
diff --git a/git.py b/git.py
index 87ccf8f55c6ad755faca2d75de691551273e69b2..cc425c17827bc3dbc741ca4b4a0fe32b874fb16e 100644 (file)
--- a/git.py
+++ b/git.py
@@ -617,14 +617,17 @@ class CatPipe:
                                       stdout=subprocess.PIPE,
                                       preexec_fn = _gitenv)
             self.get = self._fast_get
-            self.inprogress = 0
+            self.inprogress = None
 
     def _fast_get(self, id):
+        if self.inprogress:
+            log('_fast_get: opening %r while %r is open' 
+                % (id, self.inprogress))
         assert(not self.inprogress)
         assert(id.find('\n') < 0)
         assert(id.find('\r') < 0)
         assert(id[0] != '-')
-        self.inprogress += 1
+        self.inprogress = id
         self.p.stdin.write('%s\n' % id)
         hdr = self.p.stdout.readline()
         if hdr.endswith(' missing\n'):
@@ -636,7 +639,7 @@ class CatPipe:
 
         def ondone():
             assert(self.p.stdout.readline() == '\n')
-            self.inprogress -= 1
+            self.inprogress = None
 
         it = AutoFlushIter(chunkyreader(self.p.stdout, int(spl[2])),
                            ondone = ondone)
diff --git a/shquote.py b/shquote.py
new file mode 100644 (file)
index 0000000..dc339ec
--- /dev/null
@@ -0,0 +1,87 @@
+import re
+
+q = "'"
+qq = '"'
+
+
+class QuoteError(Exception):
+    pass
+
+
+def _quotesplit(line):
+    inquote = None
+    inescape = None
+    wordstart = 0
+    word = ''
+    for i in range(len(line)):
+        c = line[i]
+        if inescape:
+            if inquote == q and c != q:
+                word += '\\'  # single-q backslashes can only quote single-q
+            word += c
+            inescape = False
+        elif c == '\\':
+            inescape = True
+        elif c == inquote:
+            inquote = None
+            # this is un-sh-like, but do it for sanity when autocompleting
+            yield (wordstart, word)
+            word = ''
+            wordstart = i+1
+        elif not inquote and not word and (c == q or c == qq):
+            # the 'not word' constraint on this is un-sh-like, but do it
+            # for sanity when autocompleting
+            inquote = c
+            wordstart = i
+        elif not inquote and c in [' ', '\n', '\r', '\t']:
+            if word:
+                yield (wordstart, word)
+            word = ''
+            wordstart = i+1
+        else:
+            word += c
+    if word:
+        yield (wordstart, word)
+    if inquote or inescape or word:
+        raise QuoteError()
+
+
+def quotesplit(line):
+    l = []
+    try:
+        for i in _quotesplit(line):
+            l.append(i)
+    except QuoteError:
+        pass
+    return l
+
+
+def unfinished_word(line):
+    try:
+        for (wordstart,word) in _quotesplit(line):
+            pass
+    except QuoteError:
+        firstchar = line[wordstart]
+        if firstchar in [q, qq]:
+            return (firstchar, word)
+        else:
+            return (None, word)
+    else:
+        return (None, '')
+
+
+def quotify(qtype, word, terminate):
+    if qtype == qq:
+        return qq + word.replace(qq, '\\"') + (terminate and qq or '')
+    elif qtype == q:
+        return q + word.replace(q, "\\'") + (terminate and q or '')
+    else:
+        return re.sub(r'([\"\' \t\n\r])', r'\\\1', word)
+
+
+def what_to_add(qtype, origword, newword, terminate):
+    if not newword.startswith(origword):
+        return ''
+    else:
+        qold = quotify(qtype, origword, terminate=False)
+        return quotify(qtype, newword, terminate=terminate)[len(qold):]
diff --git a/t/tshquote.py b/t/tshquote.py
new file mode 100644 (file)
index 0000000..9f9c8cc
--- /dev/null
@@ -0,0 +1,44 @@
+from wvtest import *
+import shquote
+
+def qst(line):
+    return [s[1] for s in shquote.quotesplit(line)]
+
+@wvtest
+def test_shquote():
+    WVPASSEQ(qst("""  this is    basic \t\n\r text  """),
+             ['this', 'is', 'basic', 'text'])
+    WVPASSEQ(qst(r""" \"x\" "help" 'yelp' """), ['"x"', 'help', 'yelp'])
+    WVPASSEQ(qst(r""" "'\"\"'" '\"\'' """), ["'\"\"'", '\\"\''])
+
+    WVPASSEQ(shquote.quotesplit('  this is "unfinished'),
+             [(2,'this'), (7,'is'), (10,'unfinished')])
+
+    WVPASSEQ(shquote.quotesplit('"silly"\'will'),
+             [(0,'silly'), (7,'will')])
+
+    WVPASSEQ(shquote.unfinished_word('this is a "billy" "goat'),
+             ('"', 'goat'))
+    WVPASSEQ(shquote.unfinished_word("'x"),
+             ("'", 'x'))
+    WVPASSEQ(shquote.unfinished_word("abra cadabra "),
+             (None, ''))
+    WVPASSEQ(shquote.unfinished_word("abra cadabra"),
+             (None, 'cadabra'))
+
+    (qtype, word) = shquote.unfinished_word("this is /usr/loc")
+    WVPASSEQ(shquote.what_to_add(qtype, word, "/usr/local", True),
+             "al")
+    (qtype, word) = shquote.unfinished_word("this is '/usr/loc")
+    WVPASSEQ(shquote.what_to_add(qtype, word, "/usr/local", True),
+             "al'")
+    (qtype, word) = shquote.unfinished_word("this is \"/usr/loc")
+    WVPASSEQ(shquote.what_to_add(qtype, word, "/usr/local", True),
+             "al\"")
+    (qtype, word) = shquote.unfinished_word("this is \"/usr/loc")
+    WVPASSEQ(shquote.what_to_add(qtype, word, "/usr/local", False),
+             "al")
+    (qtype, word) = shquote.unfinished_word("this is \\ hammer\\ \"")
+    WVPASSEQ(word, ' hammer "')
+    WVPASSEQ(shquote.what_to_add(qtype, word, " hammer \"time\"", True),
+             "time\\\"")
diff --git a/vfs.py b/vfs.py
new file mode 100644 (file)
index 0000000..a97d4f5
--- /dev/null
+++ b/vfs.py
@@ -0,0 +1,243 @@
+import os, re, stat, time
+import git
+from helpers import *
+
+EMPTY_SHA='\0'*20
+
+_cp = None
+def cp():
+    global _cp
+    if not _cp:
+        _cp = git.CatPipe()
+    return _cp
+
+class NodeError(Exception):
+    pass
+class NoSuchFile(NodeError):
+    pass
+class NotDir(NodeError):
+    pass
+class NotFile(NodeError):
+    pass
+class TooManySymlinks(NodeError):
+    pass
+
+
+class FileReader:
+    def __init__(self, node):
+        self.n = node
+        self.ofs = 0
+        self.size = self.n.size()
+
+    def seek(self, ofs):
+        if ofs > self.size:
+            self.ofs = self.size
+        elif ofs < 0:
+            self.ofs = 0
+        else:
+            self.ofs = ofs
+
+    def tell(self):
+        return self.ofs
+
+    def read(self, count = -1):
+        if count < 0:
+            count = self.size - self.ofs
+        buf = self.n.readbytes(self.ofs, count)
+        self.ofs += len(buf)
+        return buf
+
+
+class Node:
+    def __init__(self, parent, name, mode, hash):
+        self.parent = parent
+        self.name = name
+        self.mode = mode
+        self.hash = hash
+        self._subs = None
+        
+    def __cmp__(a, b):
+        return cmp(a.name or None, b.name or None)
+    
+    def __iter__(self):
+        return iter(self.subs())
+    
+    def fullname(self):
+        if self.parent:
+            return os.path.join(self.parent.fullname(), self.name)
+        else:
+            return self.name
+    
+    def _mksubs(self):
+        self._subs = {}
+        
+    def subs(self):
+        if self._subs == None:
+            self._mksubs()
+        return sorted(self._subs.values())
+        
+    def sub(self, name):
+        if self._subs == None:
+            self._mksubs()
+        ret = self._subs.get(name)
+        if not ret:
+            raise NoSuchFile("no file %r in %r" % (name, self.name))
+        return ret
+
+    def top(self):
+        if self.parent:
+            return self.parent.top()
+        else:
+            return self
+
+    def _lresolve(self, parts):
+        #log('_lresolve %r in %r\n' % (parts, self.name))
+        if not parts:
+            return self
+        (first, rest) = (parts[0], parts[1:])
+        if first == '.':
+            return self._lresolve(rest)
+        elif first == '..':
+            if not self.parent:
+                raise NoSuchFile("no parent dir for %r" % self.name)
+            return self.parent._lresolve(rest)
+        elif rest:
+            return self.sub(first)._lresolve(rest)
+        else:
+            return self.sub(first)
+
+    def lresolve(self, path):
+        start = self
+        if path.startswith('/'):
+            start = self.top()
+            path = path[1:]
+        parts = re.split(r'/+', path or '.')
+        if not parts[-1]:
+            parts[-1] = '.'
+        #log('parts: %r %r\n' % (path, parts))
+        return start._lresolve(parts)
+
+    def resolve(self, path):
+        return self.lresolve(path).lresolve('')
+    
+    def nlinks(self):
+        if self._subs == None:
+            self._mksubs()
+        return 1
+
+    def size(self):
+        return 0
+
+    def open(self):
+        raise NotFile('%s is not a regular file' % self.name)
+    
+    def readbytes(self, ofs, count):
+        raise NotFile('%s is not a regular file' % self.name)
+    
+    def read(self, num = -1):
+        if num < 0:
+            num = self.size()
+        return self.readbytes(0, num)
+    
+    
+class File(Node):
+    def _content(self):
+        return cp().join(self.hash.encode('hex'))
+
+    def open(self):
+        return FileReader(self)
+    
+    def size(self):
+        # FIXME inefficient
+        return sum(len(blob) for blob in self._content())
+    
+    def readbytes(self, ofs, count):
+        # FIXME inefficient
+        buf = ''.join(self._content())
+        return buf[ofs:ofs+count]
+    
+
+_symrefs = 0
+class Symlink(File):
+    def __init__(self, parent, name, hash):
+        File.__init__(self, parent, name, 0120000, hash)
+
+    def readlink(self):
+        return self.read(1024)
+
+    def dereference(self):
+        global _symrefs
+        if _symrefs > 100:
+            raise TooManySymlinks('too many levels of symlinks: %r'
+                                  % self.fullname())
+        _symrefs += 1
+        try:
+            return self.parent.lresolve(self.readlink())
+        finally:
+            _symrefs -= 1
+
+    def _lresolve(self, parts):
+        return self.dereference()._lresolve(parts)
+    
+
+class FakeSymlink(Symlink):
+    def __init__(self, parent, name, toname):
+        Symlink.__init__(self, parent, name, EMPTY_SHA)
+        self.toname = toname
+        
+    def _content(self):
+        return self.toname
+    
+
+class Dir(Node):
+    def _mksubs(self):
+        self._subs = {}
+        it = cp().get(self.hash.encode('hex'))
+        type = it.next()
+        if type == 'commit':
+            del it
+            it = cp().get(self.hash.encode('hex') + ':')
+            type = it.next()
+        assert(type == 'tree')
+        for (mode,name,sha) in git._treeparse(''.join(it)):
+            mode = int(mode, 8)
+            if stat.S_ISDIR(mode):
+                self._subs[name] = Dir(self, name, mode, sha)
+            elif stat.S_ISLNK(mode):
+                self._subs[name] = Symlink(self, name, sha)
+            else:
+                self._subs[name] = File(self, name, mode, sha)
+                
+
+class CommitList(Node):
+    def __init__(self, parent, name, hash):
+        Node.__init__(self, parent, name, 040000, hash)
+        
+    def _mksubs(self):
+        self._subs = {}
+        revs = list(git.rev_list(self.hash.encode('hex')))
+        for (date, commit) in revs:
+            l = time.localtime(date)
+            ls = time.strftime('%Y-%m-%d-%H%M%S', l)
+            commithex = commit.encode('hex')
+            self._subs[commithex] = Dir(self, commithex, 040000, commit)
+            self._subs[ls] = FakeSymlink(self, ls, commit.encode('hex'))
+            latest = max(revs)
+        if latest:
+            (date, commit) = latest
+            self._subs['latest'] = FakeSymlink(self, 'latest',
+                                               commit.encode('hex'))
+
+    
+class RefList(Node):
+    def __init__(self, parent):
+        Node.__init__(self, parent, '/', 040000, EMPTY_SHA)
+        
+    def _mksubs(self):
+        self._subs = {}
+        for (name,sha) in git.list_refs():
+            if name.startswith('refs/heads/'):
+                name = name[11:]
+                self._subs[name] = CommitList(self, name, sha)
+        
+