]> arthur.barton.de Git - bup.git/blob - cmd/save-cmd.py
save-cmd.py: don't test for meta index via access()
[bup.git] / cmd / save-cmd.py
1 #!/usr/bin/env python
2 import sys, stat, time, math
3 from cStringIO import StringIO
4 from errno import EACCES
5
6 from bup import hashsplit, git, options, index, client, metadata, hlinkdb
7 from bup.helpers import *
8 from bup.hashsplit import GIT_MODE_TREE, GIT_MODE_FILE, GIT_MODE_SYMLINK
9
10
11 optspec = """
12 bup save [-tc] [-n name] <filenames...>
13 --
14 r,remote=  hostname:/path/to/repo of remote repository
15 t,tree     output a tree id
16 c,commit   output a commit id
17 n,name=    name of backup set to update (if any)
18 d,date=    date for the commit (seconds since the epoch)
19 v,verbose  increase log output (can be used more than once)
20 q,quiet    don't show progress meter
21 smaller=   only back up files smaller than n bytes
22 bwlimit=   maximum bytes/sec to transmit to server
23 f,indexfile=  the name of the index file (normally BUP_DIR/bupindex)
24 strip      strips the path to every filename given
25 strip-path= path-prefix to be stripped when saving
26 graft=     a graft point *old_path*=*new_path* (can be used more than once)
27 #,compress=  set compression level to # (0-9, 9 is highest) [1]
28 """
29 o = options.Options(optspec)
30 (opt, flags, extra) = o.parse(sys.argv[1:])
31
32 git.check_repo_or_die()
33 if not (opt.tree or opt.commit or opt.name):
34     o.fatal("use one or more of -t, -c, -n")
35 if not extra:
36     o.fatal("no filenames given")
37
38 opt.progress = (istty2 and not opt.quiet)
39 opt.smaller = parse_num(opt.smaller or 0)
40 if opt.bwlimit:
41     client.bwlimit = parse_num(opt.bwlimit)
42
43 if opt.date:
44     date = parse_date_or_fatal(opt.date, o.fatal)
45 else:
46     date = time.time()
47
48 if opt.strip and opt.strip_path:
49     o.fatal("--strip is incompatible with --strip-path")
50
51 graft_points = []
52 if opt.graft:
53     if opt.strip:
54         o.fatal("--strip is incompatible with --graft")
55
56     if opt.strip_path:
57         o.fatal("--strip-path is incompatible with --graft")
58
59     for (option, parameter) in flags:
60         if option == "--graft":
61             splitted_parameter = parameter.split('=')
62             if len(splitted_parameter) != 2:
63                 o.fatal("a graft point must be of the form old_path=new_path")
64             old_path, new_path = splitted_parameter
65             if not (old_path and new_path):
66                 o.fatal("a graft point cannot be empty")
67             graft_points.append((realpath(old_path), realpath(new_path)))
68
69 is_reverse = os.environ.get('BUP_SERVER_REVERSE')
70 if is_reverse and opt.remote:
71     o.fatal("don't use -r in reverse mode; it's automatic")
72
73 if opt.name and opt.name.startswith('.'):
74     o.fatal("'%s' is not a valid branch name" % opt.name)
75 refname = opt.name and 'refs/heads/%s' % opt.name or None
76 if opt.remote or is_reverse:
77     cli = client.Client(opt.remote)
78     oldref = refname and cli.read_ref(refname) or None
79     w = cli.new_packwriter(compression_level=opt.compress)
80 else:
81     cli = None
82     oldref = refname and git.read_ref(refname) or None
83     w = git.PackWriter(compression_level=opt.compress)
84
85 handle_ctrl_c()
86
87
88 def eatslash(dir):
89     if dir.endswith('/'):
90         return dir[:-1]
91     else:
92         return dir
93
94
95 # Metadata is stored in a file named .bupm in each directory.  The
96 # first metadata entry will be the metadata for the current directory.
97 # The remaining entries will be for each of the other directory
98 # elements, in the order they're listed in the index.
99 #
100 # Since the git tree elements are sorted according to
101 # git.shalist_item_sort_key, the metalist items are accumulated as
102 # (sort_key, metadata) tuples, and then sorted when the .bupm file is
103 # created.  The sort_key must be computed using the element's real
104 # name and mode rather than the git mode and (possibly mangled) name.
105
106 # Maintain a stack of information representing the current location in
107 # the archive being constructed.  The current path is recorded in
108 # parts, which will be something like ['', 'home', 'someuser'], and
109 # the accumulated content and metadata for of the dirs in parts is
110 # stored in parallel stacks in shalists and metalists.
111
112 parts = [] # Current archive position (stack of dir names).
113 shalists = [] # Hashes for each dir in paths.
114 metalists = [] # Metadata for each dir in paths.
115
116
117 def _push(part, metadata):
118     # Enter a new archive directory -- make it the current directory.
119     parts.append(part)
120     shalists.append([])
121     metalists.append([('', metadata)]) # This dir's metadata (no name).
122
123
124 def _pop(force_tree, dir_metadata=None):
125     # Leave the current archive directory and add its tree to its parent.
126     assert(len(parts) >= 1)
127     part = parts.pop()
128     shalist = shalists.pop()
129     metalist = metalists.pop()
130     if metalist and not force_tree:
131         if dir_metadata: # Override the original metadata pushed for this dir.
132             metalist = [('', dir_metadata)] + metalist[1:]
133         sorted_metalist = sorted(metalist, key = lambda x : x[0])
134         metadata = ''.join([m[1].encode() for m in sorted_metalist])
135         metadata_f = StringIO(metadata)
136         mode, id = hashsplit.split_to_blob_or_tree(w.new_blob, w.new_tree,
137                                                    [metadata_f],
138                                                    keep_boundaries=False)
139         shalist.append((mode, '.bupm', id))
140     tree = force_tree or w.new_tree(shalist)
141     if shalists:
142         shalists[-1].append((GIT_MODE_TREE,
143                              git.mangle_name(part,
144                                              GIT_MODE_TREE, GIT_MODE_TREE),
145                              tree))
146     return tree
147
148
149 lastremain = None
150 def progress_report(n):
151     global count, subcount, lastremain
152     subcount += n
153     cc = count + subcount
154     pct = total and (cc*100.0/total) or 0
155     now = time.time()
156     elapsed = now - tstart
157     kps = elapsed and int(cc/1024./elapsed)
158     kps_frac = 10 ** int(math.log(kps+1, 10) - 1)
159     kps = int(kps/kps_frac)*kps_frac
160     if cc:
161         remain = elapsed*1.0/cc * (total-cc)
162     else:
163         remain = 0.0
164     if (lastremain and (remain > lastremain)
165           and ((remain - lastremain)/lastremain < 0.05)):
166         remain = lastremain
167     else:
168         lastremain = remain
169     hours = int(remain/60/60)
170     mins = int(remain/60 - hours*60)
171     secs = int(remain - hours*60*60 - mins*60)
172     if elapsed < 30:
173         remainstr = ''
174         kpsstr = ''
175     else:
176         kpsstr = '%dk/s' % kps
177         if hours:
178             remainstr = '%dh%dm' % (hours, mins)
179         elif mins:
180             remainstr = '%dm%d' % (mins, secs)
181         else:
182             remainstr = '%ds' % secs
183     qprogress('Saving: %.2f%% (%d/%dk, %d/%d files) %s %s\r'
184               % (pct, cc/1024, total/1024, fcount, ftotal,
185                  remainstr, kpsstr))
186
187
188 indexfile = opt.indexfile or git.repo('bupindex')
189 r = index.Reader(indexfile)
190 try:
191     msr = index.MetaStoreReader(indexfile + '.meta')
192 except IOError, ex:
193     if ex.errno != EACCES:
194         raise
195     log('error: cannot access %r; have you run bup index?' % indexfile)
196     sys.exit(1)
197 hlink_db = hlinkdb.HLinkDB(indexfile + '.hlink')
198
199 def already_saved(ent):
200     return ent.is_valid() and w.exists(ent.sha) and ent.sha
201
202 def wantrecurse_pre(ent):
203     return not already_saved(ent)
204
205 def wantrecurse_during(ent):
206     return not already_saved(ent) or ent.sha_missing()
207
208 def find_hardlink_target(hlink_db, ent):
209     if hlink_db and not stat.S_ISDIR(ent.mode) and ent.nlink > 1:
210         link_paths = hlink_db.node_paths(ent.dev, ent.ino)
211         if link_paths:
212             return link_paths[0]
213
214 total = ftotal = 0
215 if opt.progress:
216     for (transname,ent) in r.filter(extra, wantrecurse=wantrecurse_pre):
217         if not (ftotal % 10024):
218             qprogress('Reading index: %d\r' % ftotal)
219         exists = ent.exists()
220         hashvalid = already_saved(ent)
221         ent.set_sha_missing(not hashvalid)
222         if not opt.smaller or ent.size < opt.smaller:
223             if exists and not hashvalid:
224                 total += ent.size
225         ftotal += 1
226     progress('Reading index: %d, done.\n' % ftotal)
227     hashsplit.progress_callback = progress_report
228
229 # Root collisions occur when strip or graft options map more than one
230 # path to the same directory (paths which originally had separate
231 # parents).  When that situation is detected, use empty metadata for
232 # the parent.  Otherwise, use the metadata for the common parent.
233 # Collision example: "bup save ... --strip /foo /foo/bar /bar".
234
235 # FIXME: Add collision tests, or handle collisions some other way.
236
237 # FIXME: Detect/handle strip/graft name collisions (other than root),
238 # i.e. if '/foo/bar' and '/bar' both map to '/'.
239
240 first_root = None
241 root_collision = None
242 tstart = time.time()
243 count = subcount = fcount = 0
244 lastskip_name = None
245 lastdir = ''
246 for (transname,ent) in r.filter(extra, wantrecurse=wantrecurse_during):
247     (dir, file) = os.path.split(ent.name)
248     exists = (ent.flags & index.IX_EXISTS)
249     hashvalid = already_saved(ent)
250     wasmissing = ent.sha_missing()
251     oldsize = ent.size
252     if opt.verbose:
253         if not exists:
254             status = 'D'
255         elif not hashvalid:
256             if ent.sha == index.EMPTY_SHA:
257                 status = 'A'
258             else:
259                 status = 'M'
260         else:
261             status = ' '
262         if opt.verbose >= 2:
263             log('%s %-70s\n' % (status, ent.name))
264         elif not stat.S_ISDIR(ent.mode) and lastdir != dir:
265             if not lastdir.startswith(dir):
266                 log('%s %-70s\n' % (status, os.path.join(dir, '')))
267             lastdir = dir
268
269     if opt.progress:
270         progress_report(0)
271     fcount += 1
272     
273     if not exists:
274         continue
275     if opt.smaller and ent.size >= opt.smaller:
276         if exists and not hashvalid:
277             add_error('skipping large file "%s"' % ent.name)
278             lastskip_name = ent.name
279         continue
280
281     assert(dir.startswith('/'))
282     if opt.strip:
283         dirp = stripped_path_components(dir, extra)
284     elif opt.strip_path:
285         dirp = stripped_path_components(dir, [opt.strip_path])
286     elif graft_points:
287         dirp = grafted_path_components(graft_points, dir)
288     else:
289         dirp = path_components(dir)
290
291     # At this point, dirp contains a representation of the archive
292     # path that looks like [(archive_dir_name, real_fs_path), ...].
293     # So given "bup save ... --strip /foo/bar /foo/bar/baz", dirp
294     # might look like this at some point:
295     #   [('', '/foo/bar'), ('baz', '/foo/bar/baz'), ...].
296
297     # This dual representation supports stripping/grafting, where the
298     # archive path may not have a direct correspondence with the
299     # filesystem.  The root directory is represented by an initial
300     # component named '', and any component that doesn't have a
301     # corresponding filesystem directory (due to grafting, for
302     # example) will have a real_fs_path of None, i.e. [('', None),
303     # ...].
304
305     if first_root == None:
306         dir_name, fs_path = dirp[0]
307         first_root = dirp[0]
308         # Not indexed, so just grab the FS metadata or use empty metadata.
309         try:
310            meta = metadata.from_path(fs_path) if fs_path else metadata.Metadata()
311         except (OSError, IOError), e:
312             add_error(e)
313             lastskip_name = dir_name
314         else:
315            _push(dir_name, meta)
316     elif first_root != dirp[0]:
317         root_collision = True
318
319     # If switching to a new sub-tree, finish the current sub-tree.
320     while parts > [x[0] for x in dirp]:
321         _pop(force_tree = None)
322
323     # If switching to a new sub-tree, start a new sub-tree.
324     for path_component in dirp[len(parts):]:
325         dir_name, fs_path = path_component
326         # Not indexed, so just grab the FS metadata or use empty metadata.
327         try:
328            meta = metadata.from_path(fs_path) if fs_path else metadata.Metadata()
329         except (OSError, IOError), e:
330             add_error(e)
331             lastskip_name = dir_name
332         else:
333            _push(dir_name, meta)
334
335     if not file:
336         if len(parts) == 1:
337             continue # We're at the top level -- keep the current root dir
338         # Since there's no filename, this is a subdir -- finish it.
339         oldtree = already_saved(ent) # may be None
340         newtree = _pop(force_tree = oldtree)
341         if not oldtree:
342             if lastskip_name and lastskip_name.startswith(ent.name):
343                 ent.invalidate()
344             else:
345                 ent.validate(GIT_MODE_TREE, newtree)
346             ent.repack()
347         if exists and wasmissing:
348             count += oldsize
349         continue
350
351     # it's not a directory
352     id = None
353     if hashvalid:
354         id = ent.sha
355         git_name = git.mangle_name(file, ent.mode, ent.gitmode)
356         git_info = (ent.gitmode, git_name, id)
357         shalists[-1].append(git_info)
358         sort_key = git.shalist_item_sort_key((ent.mode, file, id))
359         meta = msr.metadata_at(ent.meta_ofs)
360         meta.hardlink_target = find_hardlink_target(hlink_db, ent)
361         # Restore the times that were cleared to 0 in the metastore.
362         (meta.atime, meta.mtime, meta.ctime) = (ent.atime, ent.mtime, ent.ctime)
363         metalists[-1].append((sort_key, meta))
364     else:
365         if stat.S_ISREG(ent.mode):
366             try:
367                 f = hashsplit.open_noatime(ent.name)
368             except (IOError, OSError), e:
369                 add_error(e)
370                 lastskip_name = ent.name
371             else:
372                 try:
373                     (mode, id) = hashsplit.split_to_blob_or_tree(
374                                             w.new_blob, w.new_tree, [f],
375                                             keep_boundaries=False)
376                 except (IOError, OSError), e:
377                     add_error('%s: %s' % (ent.name, e))
378                     lastskip_name = ent.name
379         else:
380             if stat.S_ISDIR(ent.mode):
381                 assert(0)  # handled above
382             elif stat.S_ISLNK(ent.mode):
383                 try:
384                     rl = os.readlink(ent.name)
385                 except (OSError, IOError), e:
386                     add_error(e)
387                     lastskip_name = ent.name
388                 else:
389                     (mode, id) = (GIT_MODE_SYMLINK, w.new_blob(rl))
390             else:
391                 # Everything else should be fully described by its
392                 # metadata, so just record an empty blob, so the paths
393                 # in the tree and .bupm will match up.
394                 (mode, id) = (GIT_MODE_FILE, w.new_blob(""))
395
396         if id:
397             ent.validate(mode, id)
398             ent.repack()
399             git_name = git.mangle_name(file, ent.mode, ent.gitmode)
400             git_info = (mode, git_name, id)
401             shalists[-1].append(git_info)
402             sort_key = git.shalist_item_sort_key((ent.mode, file, id))
403             hlink = find_hardlink_target(hlink_db, ent)
404             try:
405                 meta = metadata.from_path(ent.name, hardlink_target=hlink)
406             except (OSError, IOError), e:
407                 add_error(e)
408                 lastskip_name = ent.name
409             else:
410                 metalists[-1].append((sort_key, meta))
411
412     if exists and wasmissing:
413         count += oldsize
414         subcount = 0
415
416
417 if opt.progress:
418     pct = total and count*100.0/total or 100
419     progress('Saving: %.2f%% (%d/%dk, %d/%d files), done.    \n'
420              % (pct, count/1024, total/1024, fcount, ftotal))
421
422 while len(parts) > 1: # _pop() all the parts above the root
423     _pop(force_tree = None)
424 assert(len(shalists) == 1)
425 assert(len(metalists) == 1)
426
427 # Finish the root directory.
428 tree = _pop(force_tree = None,
429             # When there's a collision, use empty metadata for the root.
430             dir_metadata = metadata.Metadata() if root_collision else None)
431
432 if opt.tree:
433     print tree.encode('hex')
434 if opt.commit or opt.name:
435     msg = 'bup save\n\nGenerated by command:\n%r\n' % sys.argv
436     commit = w.new_commit(oldref, tree, date, msg)
437     if opt.commit:
438         print commit.encode('hex')
439
440 msr.close()
441 w.close()  # must close before we can update the ref
442         
443 if opt.name:
444     if cli:
445         cli.update_ref(refname, commit, oldref)
446     else:
447         git.update_ref(refname, commit, oldref)
448
449 if cli:
450     cli.close()
451
452 if saved_errors:
453     log('WARNING: %d errors encountered while saving.\n' % len(saved_errors))
454     sys.exit(1)