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