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