]> arthur.barton.de Git - bup.git/commitdiff
Merge initial bup rm command
authorRob Browning <rlb@defaultvalue.org>
Sat, 13 Feb 2016 15:25:45 +0000 (09:25 -0600)
committerRob Browning <rlb@defaultvalue.org>
Sun, 14 Feb 2016 00:10:09 +0000 (18:10 -0600)
Signed-off-by: Rob Browning <rlb@defaultvalue.org>
Tested-by: Rob Browning <rlb@defaultvalue.org>
Documentation/bup-rm.md [new file with mode: 0644]
Makefile
cmd/rm-cmd.py [new file with mode: 0755]
cmd/save-cmd.py
cmd/split-cmd.py
lib/bup/git.py
lib/bup/t/tgit.py
t/test-rm.sh [new file with mode: 0755]

diff --git a/Documentation/bup-rm.md b/Documentation/bup-rm.md
new file mode 100644 (file)
index 0000000..a0382e1
--- /dev/null
@@ -0,0 +1,50 @@
+% bup-rm(1) Bup %BUP_VERSION%
+% Rob Browning <rlb@defaultvalue.org>
+% %BUP_DATE%
+
+# NAME
+
+bup-rm - remove references to archive content (CAUTION: EXPERIMENTAL)
+
+# SYNOPSIS
+
+bup rm [-#|--verbose] <*branch*|*save*...>
+
+# DESCRIPTION
+
+`bup rm` removes the indicated *branch*es (backup sets) and *save*s.
+By itself, this command does not delete any actual data (nor recover
+any storage space), but it may make it very difficult or impossible to
+refer to the deleted items, unless there are other references to them
+(e.g. tags).
+
+A subsequent garbage collection, either by the forthcoming `bup gc`
+command, or by a normal `git gc`, may permanently delete data that is
+no longer reachable from the remaining branches or tags, and reclaim
+the related storage space.
+
+NOTE: This is one of the few bup commands that modifies your archive
+in intentionally destructive ways.
+
+# OPTIONS
+
+-v, \--verbose
+:   increase verbosity (can be used more than once).
+
+-*#*, \--compress=*#*
+:   set the compression level to # (a value from 0-9, where
+    9 is the highest and 0 is no compression).  The default
+    is 6.  Note that `bup rm` may only write new commits.
+
+# EXAMPLES
+
+    # Delete the backup set (branch) foo and a save in bar.
+    $ bup rm /foo /bar/2014-10-21-214720
+
+# SEE ALSO
+
+`bup-save`(1), `bup-fsck`(1), and `bup-tag`(1)
+
+# BUP
+
+Part of the `bup`(1) suite.
index 410613f793983211facb0938fe97f7f4b7496e36..30e9aeb28c73be1301405df267d430e23d3534cb 100644 (file)
--- a/Makefile
+++ b/Makefile
@@ -145,6 +145,7 @@ runtests-python: all t/tmp
            | tee -a t/tmp/test-log/$$$$.log
 
 cmdline_tests := \
+  t/test-rm.sh \
   t/test-main.sh \
   t/test-list-idx.sh \
   t/test-index.sh \
diff --git a/cmd/rm-cmd.py b/cmd/rm-cmd.py
new file mode 100755 (executable)
index 0000000..46b3d7e
--- /dev/null
@@ -0,0 +1,168 @@
+#!/bin/sh
+"""": # -*-python-*-
+bup_python="$(dirname "$0")/bup-python" || exit $?
+exec "$bup_python" "$0" ${1+"$@"}
+"""
+# end of bup preamble
+
+import sys
+
+from bup import client, git, options, vfs
+from bup.git import get_commit_items
+from bup.helpers import add_error, handle_ctrl_c, log, saved_errors
+
+optspec = """
+bup rm <branch|save...>
+--
+#,compress=  set compression level to # (0-9, 9 is highest) [6]
+v,verbose    increase verbosity (can be specified multiple times)
+unsafe       use the command even though it may be DANGEROUS
+"""
+
+def append_commit(hash, parent, cp, writer):
+    ci = get_commit_items(hash, cp)
+    tree = ci.tree.decode('hex')
+    author = '%s <%s>' % (ci.author_name, ci.author_mail)
+    committer = '%s <%s>' % (ci.committer_name, ci.committer_mail)
+    c = writer.new_commit(tree, parent,
+                          author, ci.author_sec, ci.author_offset,
+                          committer, ci.committer_sec, ci.committer_offset,
+                          ci.message)
+    return c, tree
+
+
+def filter_branch(tip_commit_hex, exclude, writer):
+    # May return None if everything is excluded.
+    commits = [c for _, c in git.rev_list(tip_commit_hex)]
+    commits.reverse()
+    last_c, tree = None, None
+    # Rather than assert that we always find an exclusion here, we'll
+    # just let the StopIteration signal the error.
+    first_exclusion = next(i for i, c in enumerate(commits) if exclude(c))
+    if first_exclusion != 0:
+        last_c = commits[first_exclusion - 1]
+        tree = get_commit_items(last_c.encode('hex'),
+                                git.cp()).tree.decode('hex')
+        commits = commits[first_exclusion:]
+    for c in commits:
+        if exclude(c):
+            continue
+        last_c, tree = append_commit(c.encode('hex'), last_c, git.cp(), writer)
+    return last_c
+
+
+def rm_saves(saves, writer):
+    assert(saves)
+    branch_node = saves[0].parent
+    for save in saves: # Be certain they're all on the same branch
+        assert(save.parent == branch_node)
+    rm_commits = frozenset([x.dereference().hash for x in saves])
+    orig_tip = branch_node.hash
+    new_tip = filter_branch(orig_tip.encode('hex'),
+                            lambda x: x in rm_commits,
+                            writer)
+    assert(orig_tip)
+    assert(new_tip != orig_tip)
+    return orig_tip, new_tip
+
+
+def dead_items(vfs_top, paths):
+    """Return an optimized set of removals, reporting errors via
+    add_error, and if there are any errors, return None, None."""
+    dead_branches = {}
+    dead_saves = {}
+    # Scan for bad requests, and opportunities to optimize
+    for path in paths:
+        try:
+            n = vfs_top.lresolve(path)
+        except vfs.NodeError as e:
+            add_error('unable to resolve %s: %s' % (path, e))
+        else:
+            if isinstance(n, vfs.BranchList): # rm /foo
+                branchname = n.name
+                dead_branches[branchname] = n
+                dead_saves.pop(branchname, None) # rm /foo obviates rm /foo/bar
+            elif isinstance(n, vfs.FakeSymlink) and isinstance(n.parent,
+                                                               vfs.BranchList):
+                if n.name == 'latest':
+                    add_error("error: cannot delete 'latest' symlink")
+                else:
+                    branchname = n.parent.name
+                    if branchname not in dead_branches:
+                        dead_saves.setdefault(branchname, []).append(n)
+            else:
+                add_error("don't know how to remove %r yet" % n.fullname())
+    if saved_errors:
+        return None, None
+    return dead_branches, dead_saves
+
+
+handle_ctrl_c()
+
+o = options.Options(optspec)
+opt, flags, extra = o.parse(sys.argv[1:])
+
+if not opt.unsafe:
+    o.fatal('refusing to run dangerous, experimental command without --unsafe')
+
+if len(extra) < 1:
+    o.fatal('no paths specified')
+
+paths = extra
+
+git.check_repo_or_die()
+top = vfs.RefList(None)
+
+dead_branches, dead_saves = dead_items(top, paths)
+if saved_errors:
+    log('not proceeding with any removals\n')
+    sys.exit(1)
+
+updated_refs = {}  # ref_name -> (original_ref, tip_commit(bin))
+writer = None
+
+if dead_saves:
+    writer = git.PackWriter(compression_level=opt.compress)
+
+for branch, saves in dead_saves.iteritems():
+    assert(saves)
+    updated_refs['refs/heads/' + branch] = rm_saves(saves, writer)
+
+for branch, node in dead_branches.iteritems():
+    ref = 'refs/heads/' + branch
+    assert(not ref in updated_refs)
+    updated_refs[ref] = (node.hash, None)
+
+if writer:
+    # Must close before we can update the ref(s) below.
+    writer.close()
+
+# Only update the refs here, at the very end, so that if something
+# goes wrong above, the old refs will be undisturbed.  Make an attempt
+# to update each ref.
+for ref_name, info in updated_refs.iteritems():
+    orig_ref, new_ref = info
+    try:
+        if not new_ref:
+            git.delete_ref(ref_name, orig_ref.encode('hex'))
+        else:
+            git.update_ref(ref_name, new_ref, orig_ref)
+            if opt.verbose:
+                new_hex = new_ref.encode('hex')
+                if orig_ref:
+                    orig_hex = orig_ref.encode('hex')
+                    log('updated %r (%s -> %s)\n'
+                        % (ref_name, orig_hex, new_hex))
+                else:
+                    log('updated %r (%s)\n' % (ref_name, new_hex))
+    except (git.GitError, client.ClientError) as ex:
+        if new_ref:
+            add_error('while trying to update %r (%s -> %s): %s'
+                      % (ref_name, orig_ref, new_ref, ex))
+        else:
+            add_error('while trying to delete %r (%s): %s'
+                      % (ref_name, orig_ref, ex))
+
+if saved_errors:
+    log('warning: %d errors encountered\n' % len(saved_errors))
+    sys.exit(1)
index 4fa6dcae1981532311953c0577386ab5e0a9901a..56351efd66cd84100b41bd969124a9ff6edaaacb 100755 (executable)
@@ -12,9 +12,10 @@ import os, sys, stat, time, math
 from bup import hashsplit, git, options, index, client, metadata, hlinkdb
 from bup.hashsplit import GIT_MODE_TREE, GIT_MODE_FILE, GIT_MODE_SYMLINK
 from bup.helpers import (add_error, grafted_path_components, handle_ctrl_c,
-                         istty2, log, parse_date_or_fatal, parse_num,
+                         hostname, istty2, log, parse_date_or_fatal, parse_num,
                          path_components, progress, qprogress, resolve_parent,
-                         saved_errors, stripped_path_components)
+                         saved_errors, stripped_path_components,
+                         userfullname, username)
 
 
 optspec = """
@@ -454,7 +455,9 @@ if opt.tree:
     print tree.encode('hex')
 if opt.commit or opt.name:
     msg = 'bup save\n\nGenerated by command:\n%r\n' % sys.argv
-    commit = w.new_commit(oldref, tree, date, msg)
+    userline = '%s <%s@%s>' % (userfullname(), username(), hostname())
+    commit = w.new_commit(tree, oldref, userline, date, None,
+                          userline, date, None, msg)
     if opt.commit:
         print commit.encode('hex')
 
index abf37371973744d4f1b7f433153d2e40655e2b18..e813dd7d2658b4420ab21f9b9a7f4f78a7b9fc06 100755 (executable)
@@ -8,8 +8,8 @@ exec "$bup_python" "$0" ${1+"$@"}
 import os, sys, time
 
 from bup import hashsplit, git, options, client
-from bup.helpers import (handle_ctrl_c, log, parse_num, qprogress, reprogress,
-                         saved_errors)
+from bup.helpers import (handle_ctrl_c, hostname, log, parse_num, qprogress,
+                         reprogress, saved_errors, userfullname, username)
 
 
 optspec = """
@@ -179,7 +179,9 @@ if opt.tree:
 if opt.commit or opt.name:
     msg = 'bup split\n\nGenerated by command:\n%r\n' % sys.argv
     ref = opt.name and ('refs/heads/%s' % opt.name) or None
-    commit = pack_writer.new_commit(oldref, tree, date, msg)
+    userline = '%s <%s@%s>' % (userfullname(), username(), hostname())
+    commit = pack_writer.new_commit(tree, oldref, userline, date, None,
+                                    userline, date, None, msg)
     if opt.commit:
         print commit.encode('hex')
 
index 458810bbfece732d0c374c093790e0391b92f6f1..315d8f3fa295aff53193a9315be5bb1aeecbeb94 100644 (file)
@@ -10,7 +10,8 @@ from itertools import islice
 from bup import _helpers, path, midx, bloom, xstat
 from bup.helpers import (Sha1, add_error, chunkyreader, debug1, debug2,
                          fdatasync,
-                         hostname, log, merge_iter, mmap_read, mmap_readwrite,
+                         hostname, localtime, log, merge_iter,
+                         mmap_read, mmap_readwrite,
                          progress, qprogress, unlink, username, userfullname,
                          utc_offset_str)
 
@@ -93,6 +94,19 @@ def get_commit_items(id, cp):
     return parse_commit(commit_content)
 
 
+def _local_git_date_str(epoch_sec):
+    return '%d %s' % (epoch_sec, utc_offset_str(epoch_sec))
+
+
+def _git_date_str(epoch_sec, tz_offset_sec):
+    offs =  tz_offset_sec // 60
+    return '%d %s%02d%02d' \
+        % (epoch_sec,
+           '+' if offs >= 0 else '-',
+           abs(offs) // 60,
+           abs(offs) % 60)
+
+
 def repo(sub = '', repo_dir=None):
     """Get the path to the git repository or one of its subdirectories."""
     global repodir
@@ -675,24 +689,29 @@ class PackWriter:
         content = tree_encode(shalist)
         return self.maybe_write('tree', content)
 
-    def _new_commit(self, tree, parent, author, adate, committer, cdate, msg):
+    def new_commit(self, tree, parent,
+                   author, adate_sec, adate_tz,
+                   committer, cdate_sec, cdate_tz,
+                   msg):
+        """Create a commit object in the pack.  The date_sec values must be
+        epoch-seconds, and if a tz is None, the local timezone is assumed."""
+        if adate_tz:
+            adate_str = _git_date_str(adate_sec, adate_tz)
+        else:
+            adate_str = _local_git_date_str(adate_sec)
+        if cdate_tz:
+            cdate_str = _git_date_str(cdate_sec, cdate_tz)
+        else:
+            cdate_str = _local_git_date_str(cdate_sec)
         l = []
         if tree: l.append('tree %s' % tree.encode('hex'))
         if parent: l.append('parent %s' % parent.encode('hex'))
-        if author: l.append('author %s %s' % (author, _git_date(adate)))
-        if committer: l.append('committer %s %s' % (committer, _git_date(cdate)))
+        if author: l.append('author %s %s' % (author, adate_str))
+        if committer: l.append('committer %s %s' % (committer, cdate_str))
         l.append('')
         l.append(msg)
         return self.maybe_write('commit', '\n'.join(l))
 
-    def new_commit(self, parent, tree, date, msg):
-        """Create a commit object in the pack."""
-        userline = '%s <%s@%s>' % (userfullname(), username(), hostname())
-        commit = self._new_commit(tree, parent,
-                                  userline, date, userline, date,
-                                  msg)
-        return commit
-
     def abort(self):
         """Remove the pack file from disk."""
         f = self.file
@@ -803,10 +822,6 @@ class PackWriter:
             idx_f.close()
 
 
-def _git_date(date):
-    return '%d %s' % (date, utc_offset_str(date))
-
-
 def _gitenv(repo_dir = None):
     if not repo_dir:
         repo_dir = repo()
@@ -937,10 +952,11 @@ def update_ref(refname, newval, oldval, repo_dir=None):
     _git_wait('git update-ref', p)
 
 
-def delete_ref(refname):
-    """Delete a repository reference."""
+def delete_ref(refname, oldvalue=None):
+    """Delete a repository reference (see git update-ref(1))."""
     assert(refname.startswith('refs/'))
-    p = subprocess.Popen(['git', 'update-ref', '-d', refname],
+    oldvalue = [] if not oldvalue else [oldvalue]
+    p = subprocess.Popen(['git', 'update-ref', '-d', refname] + oldvalue,
                          preexec_fn = _gitenv())
     _git_wait('git update-ref', p)
 
index 8665e8011c1a69f9912e8bdea7e70ef35988c739..7487c4245b5b7d258dfc5420271fe2fdb684748d 100644 (file)
@@ -2,7 +2,7 @@ from subprocess import check_call
 import struct, os, subprocess, tempfile, time
 
 from bup import git
-from bup.helpers import log, mkdirp, readpipe
+from bup.helpers import localtime, log, mkdirp, readpipe
 
 from wvtest import *
 
@@ -289,6 +289,72 @@ def test_commit_parsing():
         subprocess.call(['rm', '-rf', tmpdir])
 
 
+@wvtest
+def test_new_commit():
+    initial_failures = wvfailure_count()
+    tmpdir = tempfile.mkdtemp(dir=bup_tmp, prefix='bup-tgit-')
+    os.environ['BUP_MAIN_EXE'] = bup_exe
+    os.environ['BUP_DIR'] = bupdir = tmpdir + "/bup"
+    git.init_repo(bupdir)
+    git.verbose = 1
+
+    w = git.PackWriter()
+    tree = os.urandom(20)
+    parent = os.urandom(20)
+    author_name = 'Author'
+    author_mail = 'author@somewhere'
+    adate_sec = 1439657836
+    cdate_sec = adate_sec + 1
+    committer_name = 'Committer'
+    committer_mail = 'committer@somewhere'
+    adate_tz_sec = cdate_tz_sec = None
+    commit = w.new_commit(tree, parent,
+                          '%s <%s>' % (author_name, author_mail),
+                          adate_sec, adate_tz_sec,
+                          '%s <%s>' % (committer_name, committer_mail),
+                          cdate_sec, cdate_tz_sec,
+                          'There is a small mailbox here')
+    adate_tz_sec = -60 * 60
+    cdate_tz_sec = 120 * 60
+    commit_off = w.new_commit(tree, parent,
+                              '%s <%s>' % (author_name, author_mail),
+                              adate_sec, adate_tz_sec,
+                              '%s <%s>' % (committer_name, committer_mail),
+                              cdate_sec, cdate_tz_sec,
+                              'There is a small mailbox here')
+    w.close()
+
+    commit_items = git.get_commit_items(commit.encode('hex'), git.cp())
+    local_author_offset = localtime(adate_sec).tm_gmtoff
+    local_committer_offset = localtime(cdate_sec).tm_gmtoff
+    WVPASSEQ(tree, commit_items.tree.decode('hex'))
+    WVPASSEQ(1, len(commit_items.parents))
+    WVPASSEQ(parent, commit_items.parents[0].decode('hex'))
+    WVPASSEQ(author_name, commit_items.author_name)
+    WVPASSEQ(author_mail, commit_items.author_mail)
+    WVPASSEQ(adate_sec, commit_items.author_sec)
+    WVPASSEQ(local_author_offset, commit_items.author_offset)
+    WVPASSEQ(committer_name, commit_items.committer_name)
+    WVPASSEQ(committer_mail, commit_items.committer_mail)
+    WVPASSEQ(cdate_sec, commit_items.committer_sec)
+    WVPASSEQ(local_committer_offset, commit_items.committer_offset)
+
+    commit_items = git.get_commit_items(commit_off.encode('hex'), git.cp())
+    WVPASSEQ(tree, commit_items.tree.decode('hex'))
+    WVPASSEQ(1, len(commit_items.parents))
+    WVPASSEQ(parent, commit_items.parents[0].decode('hex'))
+    WVPASSEQ(author_name, commit_items.author_name)
+    WVPASSEQ(author_mail, commit_items.author_mail)
+    WVPASSEQ(adate_sec, commit_items.author_sec)
+    WVPASSEQ(adate_tz_sec, commit_items.author_offset)
+    WVPASSEQ(committer_name, commit_items.committer_name)
+    WVPASSEQ(committer_mail, commit_items.committer_mail)
+    WVPASSEQ(cdate_sec, commit_items.committer_sec)
+    WVPASSEQ(cdate_tz_sec, commit_items.committer_offset)
+    if wvfailure_count() == initial_failures:
+        subprocess.call(['rm', '-rf', tmpdir])
+
+
 @wvtest
 def test_list_refs():
     initial_failures = wvfailure_count()
@@ -340,3 +406,8 @@ def test_list_refs():
     WVPASSEQ(frozenset(git.list_refs(limit_to_tags=True)), expected_tags)
     if wvfailure_count() == initial_failures:
         subprocess.call(['rm', '-rf', tmpdir])
+
+def test__git_date_str():
+    WVPASSEQ('0 +0000', git._git_date_str(0, 0))
+    WVPASSEQ('0 -0130', git._git_date_str(0, -90 * 60))
+    WVPASSEQ('0 +0130', git._git_date_str(0, 90 * 60))
diff --git a/t/test-rm.sh b/t/test-rm.sh
new file mode 100755 (executable)
index 0000000..80824fb
--- /dev/null
@@ -0,0 +1,234 @@
+#!/usr/bin/env bash
+. ./wvtest-bup.sh || exit $?
+. ./t/lib.sh || exit $?
+
+set -o pipefail
+
+# Perhaps this should check the rsync version instead, and not sure if
+# it's just darwin, or all of these.
+case "$(uname)" in
+    CYGWIN*|NetBSD)
+        rsx=''
+        ;;
+    Darwin)
+        rsx=.
+        ;;
+    *)
+        rsx=...
+        ;;
+esac
+
+if test "$(uname)" = Darwin; then
+    deleting=deleting
+else
+    deleting="deleting  "
+    plusx=++
+fi
+
+top="$(WVPASS pwd)" || exit $?
+tmpdir="$(WVPASS wvmktempdir)" || exit $?
+
+export BUP_DIR="$tmpdir/bup"
+export GIT_DIR="$tmpdir/bup"
+
+
+bup() { "$top/bup" "$@"; }
+compare-trees() { "$top/t/compare-trees" "$@"; }
+
+
+WVPASS bup init
+WVPASS cd "$tmpdir"
+
+
+WVSTART "rm /foo (lone branch)"
+WVPASS mkdir src src/foo
+WVPASS echo twisty-maze > src/1
+WVPASS bup index src
+WVPASS bup save -n src src
+WVPASS "$top"/t/sync-tree bup/ bup-baseline/
+# FIXME: test -n
+WVPASS bup tick # Make sure we always get the timestamp changes below
+WVPASS bup rm --unsafe /src
+WVPASSEQ "$(compare-trees bup/ bup-baseline/)" \
+"*$deleting logs/refs/heads/src
+*$deleting refs/heads/src
+.d..t...${rsx} logs/refs/heads/
+.d..t...${rsx} refs/heads/"
+
+
+WVSTART "rm /foo (one of many)"
+WVPASS rm -rf bup
+WVPASS mv bup-baseline bup
+WVPASS echo twisty-maze > src/2
+WVPASS bup index src
+WVPASS bup save -n src-2 src
+WVPASS echo twisty-maze > src/3
+WVPASS bup index src
+WVPASS bup save -n src-3 src
+WVPASS "$top"/t/sync-tree bup/ bup-baseline/
+WVPASS bup tick # Make sure we always get the timestamp changes below
+WVPASS bup rm --unsafe /src
+WVPASSEQ "$(compare-trees bup/ bup-baseline/)" \
+"*$deleting logs/refs/heads/src
+*$deleting refs/heads/src
+.d..t...${rsx} logs/refs/heads/
+.d..t...${rsx} refs/heads/"
+
+
+WVSTART "rm /foo /bar (multiple of many)"
+WVPASS rm -rf bup
+WVPASS mv bup-baseline bup
+WVPASS echo twisty-maze > src/4
+WVPASS bup index src
+WVPASS bup save -n src-4 src
+WVPASS echo twisty-maze > src/5
+WVPASS bup index src
+WVPASS bup save -n src-5 src
+WVPASS "$top"/t/sync-tree bup/ bup-baseline/
+WVPASS bup tick # Make sure we always get the timestamp changes below
+WVPASS bup rm --unsafe /src-2 /src-4
+WVPASSEQ "$(compare-trees bup/ bup-baseline/)" \
+"*$deleting logs/refs/heads/src-4
+*$deleting logs/refs/heads/src-2
+*$deleting refs/heads/src-4
+*$deleting refs/heads/src-2
+.d..t...${rsx} logs/refs/heads/
+.d..t...${rsx} refs/heads/"
+
+
+WVSTART "rm /foo /bar (all)"
+WVPASS rm -rf bup
+WVPASS mv bup-baseline bup
+WVPASS "$top"/t/sync-tree bup/ bup-baseline/
+WVPASS bup tick # Make sure we always get the timestamp changes below
+WVPASS bup rm --unsafe /src /src-2 /src-3 /src-4 /src-5
+WVPASSEQ "$(compare-trees bup/ bup-baseline/)" \
+"*$deleting logs/refs/heads/src-5
+*$deleting logs/refs/heads/src-4
+*$deleting logs/refs/heads/src-3
+*$deleting logs/refs/heads/src-2
+*$deleting logs/refs/heads/src
+*$deleting refs/heads/src-5
+*$deleting refs/heads/src-4
+*$deleting refs/heads/src-3
+*$deleting refs/heads/src-2
+*$deleting refs/heads/src
+.d..t...${rsx} logs/refs/heads/
+.d..t...${rsx} refs/heads/"
+
+
+WVSTART "rm /foo/bar (lone save - equivalent to rm /foo)"
+WVPASS rm -rf bup bup-baseline src
+WVPASS bup init
+WVPASS mkdir src
+WVPASS echo twisty-maze > src/1
+WVPASS bup index src
+WVPASS bup save -n src src
+save1="$(WVPASS bup ls src | head -n 1)" || exit $?
+WVPASS "$top"/t/sync-tree bup/ bup-baseline/
+WVPASS bup tick # Make sure we always get the timestamp changes below
+WVFAIL bup rm --unsafe /src/latest
+WVPASS bup rm --unsafe /src/"$save1"
+WVPASSEQ "$(compare-trees bup/ bup-baseline/)" \
+"*$deleting logs/refs/heads/src
+*$deleting refs/heads/src
+.d..t...${rsx} logs/refs/heads/
+.d..t...${rsx} refs/heads/"
+
+
+verify-changes-caused-by-rewriting-save()
+(
+    local before="$1"
+    local after="$2"
+    local tmpdir="$(WVPASS wvmktempdir)" || exit $?
+    (WVPASS cd "$before" && WVPASS find . | WVPASS sort) > "$tmpdir/before"
+    (WVPASS cd "$after" && WVPASS find . | WVPASS sort) > "$tmpdir/after"
+    new_paths="$(WVPASS comm -13 "$tmpdir/before" "$tmpdir/after")" || exit $?
+    new_idx="$(echo "$new_paths" | WVPASS grep -E '^\./objects/pack/pack-.*\.idx$' | cut -b 3-)"
+    new_pack="$(echo "$new_paths" | WVPASS grep -E '^\./objects/pack/pack-.*\.pack$' | cut -b 3-)"
+    WVPASSEQ "$(compare-trees "$after/" "$before/")" \
+">fcst...${rsx} logs/refs/heads/src
+.d..t...${rsx} objects/
+.d..t...${rsx} objects/pack/
+>fcst...${rsx} objects/pack/bup.bloom
+>f+++++++${plusx} $new_idx
+>f+++++++${plusx} $new_pack
+.d..t...${rsx} refs/heads/
+>fc.t...${rsx} refs/heads/src"
+    WVPASS rm -rf "$tmpdir"
+)
+
+commit-hash-n()
+{
+    local n="$1" repo="$2" branch="$3"
+    GIT_DIR="$repo" WVPASS git rev-list --reverse "$branch" \
+        | WVPASS awk "FNR == $n"
+}
+
+rm-safe-cinfo()
+{
+    local n="$1" repo="$2" branch="$3" hash
+    hash="$(commit-hash-n "$n" "$repo" "$branch")" || exit $?
+    local fmt='Tree: %T%n'
+    fmt="${fmt}Author: %an <%ae> %ai%n"
+    fmt="${fmt}Committer: %cn <%ce> %ci%n"
+    fmt="${fmt}%n%s%n%b"
+    GIT_DIR="$repo" WVPASS git log -n1 --pretty=format:"$fmt" "$hash"
+}
+
+
+WVSTART 'rm /foo/BAR (setup)'
+WVPASS rm -rf bup bup-baseline src
+WVPASS bup init
+WVPASS mkdir src
+WVPASS echo twisty-maze > src/1
+WVPASS bup index src
+WVPASS bup save -n src src
+WVPASS echo twisty-maze > src/2
+WVPASS bup index src
+WVPASS bup tick
+WVPASS bup save -n src src
+WVPASS echo twisty-maze > src/3
+WVPASS bup index src
+WVPASS bup tick
+WVPASS bup save -n src src
+WVPASS mv bup bup-baseline
+WVPASS bup tick # Make sure we always get the timestamp changes below
+
+
+WVSTART "rm /foo/BAR (first of many)"
+WVPASS "$top"/t/sync-tree bup-baseline/ bup/
+victim="$(WVPASS bup ls src | head -n 1)" || exit $?
+WVPASS bup rm --unsafe /src/"$victim"
+verify-changes-caused-by-rewriting-save bup-baseline bup
+WVPASSEQ 2 $(git rev-list src | wc -l)
+WVPASSEQ "$(rm-safe-cinfo 1 bup src)" "$(rm-safe-cinfo 2 bup-baseline src)"
+WVPASSEQ "$(rm-safe-cinfo 2 bup src)" "$(rm-safe-cinfo 3 bup-baseline src)"
+
+
+WVSTART "rm /foo/BAR (one of many)"
+WVPASS "$top"/t/sync-tree bup-baseline/ bup/
+victim="$(WVPASS bup ls src | tail -n +2 | head -n 1)" || exit $?
+WVPASS bup rm --unsafe /src/"$victim"
+verify-changes-caused-by-rewriting-save bup-baseline bup
+WVPASSEQ 2 $(git rev-list src | wc -l)
+WVPASSEQ "$(commit-hash-n 1 bup src)" "$(commit-hash-n 1 bup-baseline src)"
+WVPASSEQ "$(rm-safe-cinfo 2 bup src)" "$(rm-safe-cinfo 3 bup-baseline src)"
+
+
+WVSTART "rm /foo/BAR (last of many)"
+WVPASS "$top"/t/sync-tree bup-baseline/ bup/
+victim="$(WVPASS bup ls src | tail -n 2 | head -n 1)" || exit $?
+WVPASS bup rm --unsafe -vv /src/"$victim"
+WVPASSEQ "$(compare-trees bup/ bup-baseline/)" \
+">fcst...${rsx} logs/refs/heads/src
+.d..t...${rsx} refs/heads/
+>fc.t...${rsx} refs/heads/src"
+WVPASSEQ 2 $(git rev-list src | wc -l)
+WVPASSEQ "$(commit-hash-n 1 bup src)" "$(commit-hash-n 1 bup-baseline src)"
+WVPASSEQ "$(commit-hash-n 2 bup src)" "$(commit-hash-n 2 bup-baseline src)"
+
+
+# FIXME: test that committer changes when rewriting, when appropriate.
+
+WVPASS rm -rf "$tmpdir"