]> arthur.barton.de Git - bup.git/blob - cmd/save-cmd.py
bup-save bup.drecurse bup.hlinkdb bup.path: accommodate python 3
[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 def eatslash(dir):
122     if dir.endswith(b'/'):
123         return dir[:-1]
124     else:
125         return dir
126
127 # Metadata is stored in a file named .bupm in each directory.  The
128 # first metadata entry will be the metadata for the current directory.
129 # The remaining entries will be for each of the other directory
130 # elements, in the order they're listed in the index.
131 #
132 # Since the git tree elements are sorted according to
133 # git.shalist_item_sort_key, the metalist items are accumulated as
134 # (sort_key, metadata) tuples, and then sorted when the .bupm file is
135 # created.  The sort_key must be computed using the element's real
136 # name and mode rather than the git mode and (possibly mangled) name.
137
138 # Maintain a stack of information representing the current location in
139 # the archive being constructed.  The current path is recorded in
140 # parts, which will be something like ['', 'home', 'someuser'], and
141 # the accumulated content and metadata for of the dirs in parts is
142 # stored in parallel stacks in shalists and metalists.
143
144 parts = [] # Current archive position (stack of dir names).
145 shalists = [] # Hashes for each dir in paths.
146 metalists = [] # Metadata for each dir in paths.
147
148
149 def _push(part, metadata):
150     # Enter a new archive directory -- make it the current directory.
151     parts.append(part)
152     shalists.append([])
153     metalists.append([(b'', metadata)]) # This dir's metadata (no name).
154
155
156 def _pop(force_tree, dir_metadata=None):
157     # Leave the current archive directory and add its tree to its parent.
158     assert(len(parts) >= 1)
159     part = parts.pop()
160     shalist = shalists.pop()
161     metalist = metalists.pop()
162     if metalist and not force_tree:
163         if dir_metadata: # Override the original metadata pushed for this dir.
164             metalist = [(b'', dir_metadata)] + metalist[1:]
165         sorted_metalist = sorted(metalist, key = lambda x : x[0])
166         metadata = b''.join([m[1].encode() for m in sorted_metalist])
167         metadata_f = BytesIO(metadata)
168         mode, id = hashsplit.split_to_blob_or_tree(w.new_blob, w.new_tree,
169                                                    [metadata_f],
170                                                    keep_boundaries=False)
171         shalist.append((mode, b'.bupm', id))
172     # FIXME: only test if collision is possible (i.e. given --strip, etc.)?
173     if force_tree:
174         tree = force_tree
175     else:
176         names_seen = set()
177         clean_list = []
178         for x in shalist:
179             name = x[1]
180             if name in names_seen:
181                 parent_path = b'/'.join(parts) + b'/'
182                 add_error('error: ignoring duplicate path %s in %s'
183                           % (path_msg(name), path_msg(parent_path)))
184             else:
185                 names_seen.add(name)
186                 clean_list.append(x)
187         tree = w.new_tree(clean_list)
188     if shalists:
189         shalists[-1].append((GIT_MODE_TREE,
190                              git.mangle_name(part,
191                                              GIT_MODE_TREE, GIT_MODE_TREE),
192                              tree))
193     return tree
194
195
196 lastremain = None
197 def progress_report(n):
198     global count, subcount, lastremain
199     subcount += n
200     cc = count + subcount
201     pct = total and (cc*100.0/total) or 0
202     now = time.time()
203     elapsed = now - tstart
204     kps = elapsed and int(cc/1024./elapsed)
205     kps_frac = 10 ** int(math.log(kps+1, 10) - 1)
206     kps = int(kps/kps_frac)*kps_frac
207     if cc:
208         remain = elapsed*1.0/cc * (total-cc)
209     else:
210         remain = 0.0
211     if (lastremain and (remain > lastremain)
212           and ((remain - lastremain)/lastremain < 0.05)):
213         remain = lastremain
214     else:
215         lastremain = remain
216     hours = int(remain/60/60)
217     mins = int(remain/60 - hours*60)
218     secs = int(remain - hours*60*60 - mins*60)
219     if elapsed < 30:
220         remainstr = ''
221         kpsstr = ''
222     else:
223         kpsstr = '%dk/s' % kps
224         if hours:
225             remainstr = '%dh%dm' % (hours, mins)
226         elif mins:
227             remainstr = '%dm%d' % (mins, secs)
228         else:
229             remainstr = '%ds' % secs
230     qprogress('Saving: %.2f%% (%d/%dk, %d/%d files) %s %s\r'
231               % (pct, cc/1024, total/1024, fcount, ftotal,
232                  remainstr, kpsstr))
233
234
235 indexfile = opt.indexfile or git.repo(b'bupindex')
236 r = index.Reader(indexfile)
237 try:
238     msr = index.MetaStoreReader(indexfile + b'.meta')
239 except IOError as ex:
240     if ex.errno != EACCES:
241         raise
242     log('error: cannot access %r; have you run bup index?'
243         % path_msg(indexfile))
244     sys.exit(1)
245 hlink_db = hlinkdb.HLinkDB(indexfile + b'.hlink')
246
247 def already_saved(ent):
248     return ent.is_valid() and w.exists(ent.sha) and ent.sha
249
250 def wantrecurse_pre(ent):
251     return not already_saved(ent)
252
253 def wantrecurse_during(ent):
254     return not already_saved(ent) or ent.sha_missing()
255
256 def find_hardlink_target(hlink_db, ent):
257     if hlink_db and not stat.S_ISDIR(ent.mode) and ent.nlink > 1:
258         link_paths = hlink_db.node_paths(ent.dev, ent.ino)
259         if link_paths:
260             return link_paths[0]
261
262 total = ftotal = 0
263 if opt.progress:
264     for (transname,ent) in r.filter(extra, wantrecurse=wantrecurse_pre):
265         if not (ftotal % 10024):
266             qprogress('Reading index: %d\r' % ftotal)
267         exists = ent.exists()
268         hashvalid = already_saved(ent)
269         ent.set_sha_missing(not hashvalid)
270         if not opt.smaller or ent.size < opt.smaller:
271             if exists and not hashvalid:
272                 total += ent.size
273         ftotal += 1
274     progress('Reading index: %d, done.\n' % ftotal)
275     hashsplit.progress_callback = progress_report
276
277 # Root collisions occur when strip or graft options map more than one
278 # path to the same directory (paths which originally had separate
279 # parents).  When that situation is detected, use empty metadata for
280 # the parent.  Otherwise, use the metadata for the common parent.
281 # Collision example: "bup save ... --strip /foo /foo/bar /bar".
282
283 # FIXME: Add collision tests, or handle collisions some other way.
284
285 # FIXME: Detect/handle strip/graft name collisions (other than root),
286 # i.e. if '/foo/bar' and '/bar' both map to '/'.
287
288 first_root = None
289 root_collision = None
290 tstart = time.time()
291 count = subcount = fcount = 0
292 lastskip_name = None
293 lastdir = b''
294 for (transname,ent) in r.filter(extra, wantrecurse=wantrecurse_during):
295     (dir, file) = os.path.split(ent.name)
296     exists = (ent.flags & index.IX_EXISTS)
297     hashvalid = already_saved(ent)
298     wasmissing = ent.sha_missing()
299     oldsize = ent.size
300     if opt.verbose:
301         if not exists:
302             status = 'D'
303         elif not hashvalid:
304             if ent.sha == index.EMPTY_SHA:
305                 status = 'A'
306             else:
307                 status = 'M'
308         else:
309             status = ' '
310         if opt.verbose >= 2:
311             log('%s %-70s\n' % (status, path_msg(ent.name)))
312         elif not stat.S_ISDIR(ent.mode) and lastdir != dir:
313             if not lastdir.startswith(dir):
314                 log('%s %-70s\n' % (status, path_msg(os.path.join(dir, b''))))
315             lastdir = dir
316
317     if opt.progress:
318         progress_report(0)
319     fcount += 1
320     
321     if not exists:
322         continue
323     if opt.smaller and ent.size >= opt.smaller:
324         if exists and not hashvalid:
325             if opt.verbose:
326                 log('skipping large file "%s"\n' % path_msg(ent.name))
327             lastskip_name = ent.name
328         continue
329
330     assert(dir.startswith(b'/'))
331     if opt.strip:
332         dirp = stripped_path_components(dir, extra)
333     elif opt.strip_path:
334         dirp = stripped_path_components(dir, [opt.strip_path])
335     elif graft_points:
336         dirp = grafted_path_components(graft_points, dir)
337     else:
338         dirp = path_components(dir)
339
340     # At this point, dirp contains a representation of the archive
341     # path that looks like [(archive_dir_name, real_fs_path), ...].
342     # So given "bup save ... --strip /foo/bar /foo/bar/baz", dirp
343     # might look like this at some point:
344     #   [('', '/foo/bar'), ('baz', '/foo/bar/baz'), ...].
345
346     # This dual representation supports stripping/grafting, where the
347     # archive path may not have a direct correspondence with the
348     # filesystem.  The root directory is represented by an initial
349     # component named '', and any component that doesn't have a
350     # corresponding filesystem directory (due to grafting, for
351     # example) will have a real_fs_path of None, i.e. [('', None),
352     # ...].
353
354     if first_root == None:
355         first_root = dirp[0]
356     elif first_root != dirp[0]:
357         root_collision = True
358
359     # If switching to a new sub-tree, finish the current sub-tree.
360     while parts > [x[0] for x in dirp]:
361         _pop(force_tree = None)
362
363     # If switching to a new sub-tree, start a new sub-tree.
364     for path_component in dirp[len(parts):]:
365         dir_name, fs_path = path_component
366         # Not indexed, so just grab the FS metadata or use empty metadata.
367         try:
368             meta = metadata.from_path(fs_path, normalized=True) \
369                 if fs_path else metadata.Metadata()
370         except (OSError, IOError) as e:
371             add_error(e)
372             lastskip_name = dir_name
373             meta = metadata.Metadata()
374         _push(dir_name, meta)
375
376     if not file:
377         if len(parts) == 1:
378             continue # We're at the top level -- keep the current root dir
379         # Since there's no filename, this is a subdir -- finish it.
380         oldtree = already_saved(ent) # may be None
381         newtree = _pop(force_tree = oldtree)
382         if not oldtree:
383             if lastskip_name and lastskip_name.startswith(ent.name):
384                 ent.invalidate()
385             else:
386                 ent.validate(GIT_MODE_TREE, newtree)
387             ent.repack()
388         if exists and wasmissing:
389             count += oldsize
390         continue
391
392     # it's not a directory
393     id = None
394     if hashvalid:
395         id = ent.sha
396         git_name = git.mangle_name(file, ent.mode, ent.gitmode)
397         git_info = (ent.gitmode, git_name, id)
398         shalists[-1].append(git_info)
399         sort_key = git.shalist_item_sort_key((ent.mode, file, id))
400         meta = msr.metadata_at(ent.meta_ofs)
401         meta.hardlink_target = find_hardlink_target(hlink_db, ent)
402         # Restore the times that were cleared to 0 in the metastore.
403         (meta.atime, meta.mtime, meta.ctime) = (ent.atime, ent.mtime, ent.ctime)
404         metalists[-1].append((sort_key, meta))
405     else:
406         if stat.S_ISREG(ent.mode):
407             try:
408                 f = hashsplit.open_noatime(ent.name)
409             except (IOError, OSError) as e:
410                 add_error(e)
411                 lastskip_name = ent.name
412             else:
413                 try:
414                     (mode, id) = hashsplit.split_to_blob_or_tree(
415                                             w.new_blob, w.new_tree, [f],
416                                             keep_boundaries=False)
417                 except (IOError, OSError) as e:
418                     add_error('%s: %s' % (ent.name, e))
419                     lastskip_name = ent.name
420         else:
421             if stat.S_ISDIR(ent.mode):
422                 assert(0)  # handled above
423             elif stat.S_ISLNK(ent.mode):
424                 try:
425                     rl = os.readlink(ent.name)
426                 except (OSError, IOError) as e:
427                     add_error(e)
428                     lastskip_name = ent.name
429                 else:
430                     (mode, id) = (GIT_MODE_SYMLINK, w.new_blob(rl))
431             else:
432                 # Everything else should be fully described by its
433                 # metadata, so just record an empty blob, so the paths
434                 # in the tree and .bupm will match up.
435                 (mode, id) = (GIT_MODE_FILE, w.new_blob(b''))
436
437         if id:
438             ent.validate(mode, id)
439             ent.repack()
440             git_name = git.mangle_name(file, ent.mode, ent.gitmode)
441             git_info = (mode, git_name, id)
442             shalists[-1].append(git_info)
443             sort_key = git.shalist_item_sort_key((ent.mode, file, id))
444             hlink = find_hardlink_target(hlink_db, ent)
445             try:
446                 meta = metadata.from_path(ent.name, hardlink_target=hlink,
447                                           normalized=True)
448             except (OSError, IOError) as e:
449                 add_error(e)
450                 lastskip_name = ent.name
451             else:
452                 metalists[-1].append((sort_key, meta))
453
454     if exists and wasmissing:
455         count += oldsize
456         subcount = 0
457
458
459 if opt.progress:
460     pct = total and count*100.0/total or 100
461     progress('Saving: %.2f%% (%d/%dk, %d/%d files), done.    \n'
462              % (pct, count/1024, total/1024, fcount, ftotal))
463
464 while len(parts) > 1: # _pop() all the parts above the root
465     _pop(force_tree = None)
466 assert(len(shalists) == 1)
467 assert(len(metalists) == 1)
468
469 # Finish the root directory.
470 tree = _pop(force_tree = None,
471             # When there's a collision, use empty metadata for the root.
472             dir_metadata = metadata.Metadata() if root_collision else None)
473
474 sys.stdout.flush()
475 out = byte_stream(sys.stdout)
476
477 if opt.tree:
478     out.write(hexlify(tree))
479     out.write(b'\n')
480 if opt.commit or name:
481     msg = (b'bup save\n\nGenerated by command:\n%r\n'
482            % [argv_bytes(x) for x in sys.argv])
483     userline = (b'%s <%s@%s>' % (userfullname(), username(), hostname()))
484     commit = w.new_commit(tree, oldref, userline, date, None,
485                           userline, date, None, msg)
486     if opt.commit:
487         out.write(hexlify(commit))
488         out.write(b'\n')
489
490 msr.close()
491 w.close()  # must close before we can update the ref
492         
493 if opt.name:
494     if cli:
495         cli.update_ref(refname, commit, oldref)
496     else:
497         git.update_ref(refname, commit, oldref)
498
499 if cli:
500     cli.close()
501
502 if saved_errors:
503     log('WARNING: %d errors encountered while saving.\n' % len(saved_errors))
504     sys.exit(1)