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