Jelajahi Sumber

feat: rebuildDependants flag with reverse-dependency rebuilds, build cache restore + clean retry, manifest bump command, compile.sh git tags, installer URL to packages.astrologue.nz

clanker 1 Minggu lalu
induk
melakukan
c619fe766c

+ 37 - 5
installer/compile.sh

@@ -9,6 +9,10 @@ OUTPUT_FILE="${1:-install-usm.sh}"
 VERSION="${2:-0.1.0}"
 USM_SOURCE_DIR="${3:-$SCRIPT_DIR/../src}"
 
+INVERCARGILL_TAG=""      # e.g. "v1.0.0"; empty = default branch
+INVERCARGILL_JSON_TAG="" # e.g. "v0.1.1"; empty = default branch
+USM_TAG=""               # empty = use local source directory
+
 # Temporary directory for building
 WORK_DIR=$(mktemp -d)
 trap "rm -rf $WORK_DIR" EXIT
@@ -16,7 +20,11 @@ trap "rm -rf $WORK_DIR" EXIT
 echo "Compiling USM installer..."
 echo "  Version: $VERSION"
 echo "  Output: $OUTPUT_FILE"
-echo "  USM Source: $USM_SOURCE_DIR"
+if [[ -n "$USM_TAG" ]]; then
+    echo "  USM Source: usm.git at tag \"$USM_TAG\""
+else
+    echo "  USM Source: $USM_SOURCE_DIR"
+fi
 
 # Step 1: Clone external repositories
 echo "Cloning external repositories..."
@@ -24,11 +32,15 @@ SOURCES_DIR="$WORK_DIR/sources"
 mkdir -p "$SOURCES_DIR"
 
 # Clone Invercargill
+INVERCARGILL_BRANCH_ARGS=()
+if [[ -n "$INVERCARGILL_TAG" ]]; then
+    INVERCARGILL_BRANCH_ARGS+=(--branch "$INVERCARGILL_TAG")
+fi
 if [[ ! -d "$SOURCES_DIR/Invercargill" ]]; then
     echo "  Cloning Invercargill..."
-    git clone --depth 1 https://fabrica.unitatem.net/Tilo15/Invercargill.git "$SOURCES_DIR/Invercargill" 2>/dev/null || {
+    git clone --depth 1 "${INVERCARGILL_BRANCH_ARGS[@]}" https://fabrica.unitatem.net/Tilo15/Invercargill.git "$SOURCES_DIR/Invercargill" 2>/dev/null || {
         echo "Warning: Could not clone Invercargill, will try without --depth 1"
-        git clone https://fabrica.unitatem.net/Tilo15/Invercargill.git "$SOURCES_DIR/Invercargill" 2>/dev/null || {
+        git clone "${INVERCARGILL_BRANCH_ARGS[@]}" https://fabrica.unitatem.net/Tilo15/Invercargill.git "$SOURCES_DIR/Invercargill" 2>/dev/null || {
             echo "Error: Could not clone Invercargill repository"
             exit 1
         }
@@ -36,17 +48,37 @@ if [[ ! -d "$SOURCES_DIR/Invercargill" ]]; then
 fi
 
 # Clone Invercargill-Json
+INVERCARGILL_JSON_BRANCH_ARGS=()
+if [[ -n "$INVERCARGILL_JSON_TAG" ]]; then
+    INVERCARGILL_JSON_BRANCH_ARGS+=(--branch "$INVERCARGILL_JSON_TAG")
+fi
 if [[ ! -d "$SOURCES_DIR/Invercargill-Json" ]]; then
     echo "  Cloning Invercargill-Json..."
-    git clone --depth 1 https://git.sr.ht/~tilo15/Invercargill-Json "$SOURCES_DIR/Invercargill-Json" 2>/dev/null || {
+    git clone --depth 1 "${INVERCARGILL_JSON_BRANCH_ARGS[@]}" https://git.sr.ht/~tilo15/Invercargill-Json "$SOURCES_DIR/Invercargill-Json" 2>/dev/null || {
         echo "Warning: Could not clone Invercargill-Json, will try without --depth 1"
-        git clone https://git.sr.ht/~tilo15/Invercargill-Json "$SOURCES_DIR/Invercargill-Json" 2>/dev/null || {
+        git clone "${INVERCARGILL_JSON_BRANCH_ARGS[@]}" https://git.sr.ht/~tilo15/Invercargill-Json "$SOURCES_DIR/Invercargill-Json" 2>/dev/null || {
             echo "Error: Could not clone Invercargill-Json repository"
             exit 1
         }
     }
 fi
 
+# Clone USM at a tag instead of using the local source tree; the copy steps
+# below read everything through USM_SOURCE_DIR, so pointing it at the clone's
+# src/ picks up the tagged MANIFEST.usm, usm.config and spm/ helpers as well
+if [[ -n "$USM_TAG" ]]; then
+    echo "  Cloning USM at tag \"$USM_TAG\"..."
+    USM_CLONE_DIR="$WORK_DIR/usm-clone"
+    git clone --depth 1 --branch "$USM_TAG" https://fabrica.unitatem.net/Tilo15/usm.git "$USM_CLONE_DIR" 2>/dev/null || {
+        echo "Warning: Could not clone USM at tag \"$USM_TAG\", will try without --depth 1"
+        git clone --branch "$USM_TAG" https://fabrica.unitatem.net/Tilo15/usm.git "$USM_CLONE_DIR" 2>/dev/null || {
+            echo "Error: Could not clone USM repository at tag \"$USM_TAG\""
+            exit 1
+        }
+    }
+    USM_SOURCE_DIR="$USM_CLONE_DIR/src"
+fi
+
 # Copy USM source (USM's meson.build is in src/, not project root)
 echo "Copying USM source..."
 mkdir -p "$SOURCES_DIR/usm"

+ 3 - 7
src/cli/Deploy.vala

@@ -6,15 +6,11 @@ namespace Usm.Installer {
     /**
      * Canonical source of the self-contained USM installer script baked into
      * deploy images by default (the compiled form of `installer/` in the usm
-     * source tree).
-     *
-     * TODO: this is a placeholder pointing at a reserved documentation domain
-     * that will never resolve. Replace it with the real hosted installer URL
-     * once one exists; until then override it per deploy with
-     * `--installer-url` (whose `file://` form is the sanctioned
+     * source tree), hosted at packages.astrologue.nz; override it per deploy
+     * with `--installer-url` (whose `file://` form is the sanctioned
      * local-testing path).
      */
-    public const string CANONICAL_URL = "https://usm.example.org/installer/install-usm.sh";
+    public const string CANONICAL_URL = "https://packages.astrologue.nz/install-usm.sh";
 }
 
 /** Default container base image, overridable with `--base`. */

+ 8 - 2
src/cli/Manifest.vala

@@ -19,7 +19,13 @@ public static int manifest_main(string[] args) {
     }
 
     var verb = args[1];
-    
+
+    // bump rewrites MANIFEST.usm's own version/flags as raw JSON and drives
+    // git tagging, so it dispatches before the mapped-manifest machinery
+    if(verb == "bump") {
+        return manifest_bump(args);
+    }
+
     // Check for --replace and --debug flags for autoprovides
     if(verb == "autoprovides") {
         for(int i = 2; i < args.length; i++) {
@@ -137,7 +143,7 @@ public static int manifest_main(string[] args) {
 }
 
 private void manifest_usage() {
-    printerr("USAGE:\n\tusm manifest build <build path>\n\tusm manifest install <build path>\n\tusm manifest remove\nusm manifest acquire\nusm manifest package\nusm manifest deploy [--exec CMD] [--base IMAGE] [--repository FILE]... [--no-build] [--installer-url URL]\nusm manifest autoprovides [--replace] [--debug] [build path]\nusm manifest test [build path]\nusm manifest validate [build path]\n");
+    printerr("USAGE:\n\tusm manifest build <build path>\n\tusm manifest install <build path>\n\tusm manifest remove\nusm manifest acquire\nusm manifest package\nusm manifest deploy [--exec CMD] [--base IMAGE] [--repository FILE]... [--no-build] [--installer-url URL]\nusm manifest autoprovides [--replace] [--debug] [build path]\nusm manifest test [build path]\nusm manifest validate [build path]\nusm manifest bump [major|minor|patch|<version>] [--git] [--push] [--rebuild-dependants] [--no-rebuild-dependants]\n");
 }
 
 /**

+ 307 - 0
src/cli/ManifestBump.vala

@@ -0,0 +1,307 @@
+using Invercargill;
+using Invercargill.DataStructures;
+
+
+/** The manifest flags-array entry toggled by bump's rebuild flags. */
+private const string BUMP_REBUILD_DEPENDANTS_FLAG = "rebuildDependants";
+
+
+/**
+ * `usm manifest bump [major|minor|patch|<version>] [--git] [--push]
+ * [--rebuild-dependants] [--no-rebuild-dependants]` — version-bump and
+ * release-tag the MANIFEST.usm in the current directory.
+ *
+ * A bump kind or explicit `X.Y.Z` version rewrites the manifest's `version`
+ * (and nothing else); `--rebuild-dependants` / `--no-rebuild-dependants` add
+ * or remove the `rebuildDependants` flag from the `flags` array in the same
+ * pass. `--git` additionally commits the change (skipping the commit when
+ * nothing is staged) and tags `v<version>` annotated; with no version
+ * argument it releases the current version, committing whatever else is
+ * pending so the tag captures the release state. `--push` pushes branch and
+ * tag, implying `--git`.
+ *
+ * The manifest is parsed and rewritten as raw JSON rather than through
+ * {@link Usm.Manifest}'s mapper, so a bump neither depends on manifest
+ * validation nor rewrites flags the lib's flag enum has to know about.
+ */
+public int manifest_bump(string[] args) {
+    string? spec = null;
+    bool git = false;
+    bool push = false;
+    bool rebuild_dependants = false;
+    bool no_rebuild_dependants = false;
+
+    for(int i = 2; i < args.length; i++) {
+        switch(args[i]) {
+            case "--git":
+                git = true;
+                break;
+            case "--push":
+                push = true;
+                break;
+            case "--rebuild-dependants":
+                rebuild_dependants = true;
+                break;
+            case "--no-rebuild-dependants":
+                no_rebuild_dependants = true;
+                break;
+            default:
+                if(args[i].has_prefix("--") || spec != null) {
+                    printerr(@"Unknown or unexpected bump argument \"$(args[i])\"\n");
+                    return bump_usage();
+                }
+                spec = args[i];
+                break;
+        }
+    }
+
+    if(push) {
+        git = true;
+    }
+    if(rebuild_dependants && no_rebuild_dependants) {
+        printerr("--rebuild-dependants and --no-rebuild-dependants are mutually exclusive\n");
+        return bump_usage();
+    }
+    if(spec == null && !git) {
+        printerr("Expected a bump kind (major, minor, patch), a version, or --git\n");
+        return bump_usage();
+    }
+
+    if(!File.new_for_path("MANIFEST.usm").query_exists()) {
+        printerr("No MANIFEST.usm file found in current directory.\n");
+        return 254;
+    }
+
+    InvercargillJson.JsonElement element;
+    InvercargillJson.JsonObject root;
+    try {
+        element = new InvercargillJson.JsonElement.from_file("MANIFEST.usm");
+        root = element.as<InvercargillJson.JsonObject>();
+    }
+    catch(Error e) {
+        printerr(@"Could not read MANIFEST.usm: $(e.message)\n");
+        return 253;
+    }
+
+    var current_version = root.get_string("version");
+    if(current_version == null) {
+        printerr("MANIFEST.usm declares no version string.\n");
+        return 253;
+    }
+
+    string? new_version = null;
+    if(spec == "major" || spec == "minor" || spec == "patch") {
+        int major;
+        int minor;
+        int patch;
+        if(!bump_parse_version(current_version, out major, out minor, out patch)) {
+            printerr(@"Cannot bump: manifest version \"$current_version\" is not X.Y.Z\n");
+            return 252;
+        }
+        switch(spec) {
+            case "major":
+                new_version = @"$(major + 1).0.0";
+                break;
+            case "minor":
+                new_version = @"$major.$(minor + 1).0";
+                break;
+            default:
+                new_version = @"$major.$minor.$(patch + 1)";
+                break;
+        }
+    }
+    else if(spec != null) {
+        int major;
+        int minor;
+        int patch;
+        if(!bump_parse_version(spec, out major, out minor, out patch)) {
+            printerr(@"\"$spec\" is neither a bump kind (major, minor, patch) nor an X.Y.Z version\n");
+            return 252;
+        }
+        new_version = @"$major.$minor.$patch";
+    }
+
+    bool changed = false;
+    if(new_version != null && new_version != current_version) {
+        try {
+            root.set_native("version", new_version);
+        }
+        catch(Error e) {
+            printerr(@"Could not set the manifest version: $(e.message)\n");
+            return 249;
+        }
+        printerr(@"Version \"$current_version\" → \"$new_version\"\n");
+        changed = true;
+    }
+
+    if(rebuild_dependants || no_rebuild_dependants) {
+        var current_flags = new Vector<string>();
+        if(root.has("flags")) {
+            foreach(var flag in root.get_array("flags")) {
+                current_flags.add(flag.as<string>());
+            }
+        }
+        bool flags_changed = false;
+        if(rebuild_dependants) {
+            if(!current_flags.any(f => f == BUMP_REBUILD_DEPENDANTS_FLAG)) {
+                current_flags.add(BUMP_REBUILD_DEPENDANTS_FLAG);
+                flags_changed = true;
+                printerr(@"Added flag \"$BUMP_REBUILD_DEPENDANTS_FLAG\"\n");
+            }
+            else {
+                printerr(@"Flag \"$BUMP_REBUILD_DEPENDANTS_FLAG\" already present, left as-is\n");
+            }
+        }
+        else if(current_flags.any(f => f == BUMP_REBUILD_DEPENDANTS_FLAG)) {
+            var filtered_flags = new Vector<string>();
+            foreach(var flag in current_flags) {
+                if(flag != BUMP_REBUILD_DEPENDANTS_FLAG) {
+                    filtered_flags.add(flag);
+                }
+            }
+            current_flags = filtered_flags;
+            flags_changed = true;
+            printerr(@"Removed flag \"$BUMP_REBUILD_DEPENDANTS_FLAG\"\n");
+        }
+        else {
+            printerr(@"Flag \"$BUMP_REBUILD_DEPENDANTS_FLAG\" not present, left as-is\n");
+        }
+        if(flags_changed) {
+            try {
+                root.set_native("flags", current_flags.select<Element>(f => new NativeElement<string>(f)).to_elements());
+            }
+            catch(Error e) {
+                printerr(@"Could not rewrite the manifest flags: $(e.message)\n");
+                return 249;
+            }
+        }
+        changed = changed || flags_changed;
+    }
+
+    if(changed) {
+        try {
+            var stream = new DataOutputStream(File.new_for_path("MANIFEST.usm").replace(null, false, FileCreateFlags.REPLACE_DESTINATION));
+            stream.put_string(element.stringify_pretty());
+            stream.close();
+        }
+        catch(Error e) {
+            printerr(@"Could not write MANIFEST.usm: $(e.message)\n");
+            return 249;
+        }
+    }
+
+    if(!git) {
+        return 0;
+    }
+
+    var release_version = new_version ?? current_version;
+    var tag = @"v$release_version";
+
+    var existing_tag = bump_git_output(new string[] { "git", "tag", "-l", tag });
+    if(existing_tag == null) {
+        printerr("Could not list git tags (is the manifest directory a git repository?)\n");
+        return 250;
+    }
+    if(existing_tag.length > 0) {
+        printerr(@"Tag \"$tag\" already exists\n");
+        return 250;
+    }
+
+    // A version bump commits the manifest alone; a bare --git releases the
+    // current version, so every pending change belongs in the release commit
+    if(!bump_git_run(new string[] { "git", "add", spec != null ? "MANIFEST.usm" : "-A" })) {
+        printerr("Could not stage the release changes\n");
+        return 250;
+    }
+    var staged = bump_git_output(new string[] { "git", "diff", "--cached", "--name-only" });
+    if(staged == null) {
+        printerr("Could not inspect the staged changes\n");
+        return 250;
+    }
+    if(staged.length > 0) {
+        if(!bump_git_run(new string[] { "git", "commit", "-m", @"Version $release_version" })) {
+            printerr("Could not commit the release changes\n");
+            return 250;
+        }
+        printerr(@"Committed the release changes as \"Version $release_version\"\n");
+    }
+    else {
+        printerr("Nothing to commit, tagging the current state\n");
+    }
+
+    if(!bump_git_run(new string[] { "git", "tag", "-a", tag, "-m", @"Version $release_version" })) {
+        printerr(@"Could not create tag \"$tag\"\n");
+        return 250;
+    }
+    printerr(@"Tagged \"$tag\"\n");
+
+    if(push) {
+        if(!bump_git_run(new string[] { "git", "push" }) || !bump_git_run(new string[] { "git", "push", "origin", tag })) {
+            printerr(@"Could not push the branch and tag \"$tag\"\n");
+            return 250;
+        }
+        printerr(@"Pushed the branch and tag \"$tag\"\n");
+    }
+
+    return 0;
+}
+
+
+private int bump_usage() {
+    printerr("USAGE:\n\tusm manifest bump [major|minor|patch|<version>] [--git] [--push] [--rebuild-dependants] [--no-rebuild-dependants]\n");
+    return 255;
+}
+
+
+/**
+ * Splits {@link version} into strictly numeric major/minor/patch parts,
+ * returning false when it is not an `X.Y.Z` version — bumping demands
+ * exactly three numeric components.
+ */
+private bool bump_parse_version(string version, out int major, out int minor, out int patch) {
+    major = 0;
+    minor = 0;
+    patch = 0;
+    var parts = version.split(".");
+    return parts.length == 3
+        && int.try_parse(parts[0], out major)
+        && int.try_parse(parts[1], out minor)
+        && int.try_parse(parts[2], out patch);
+}
+
+
+/**
+ * Runs {@link command} in the current directory and returns its trimmed
+ * stdout, or null when it fails to run or exits non-zero (diagnostics reach
+ * the terminal through the inherited stderr).
+ */
+private string? bump_git_output(string[] command) {
+    try {
+        var proc = new Subprocess.newv(command, SubprocessFlags.STDOUT_PIPE);
+        string stdout;
+        proc.communicate_utf8(null, null, out stdout, null);
+        if(proc.get_exit_status() != 0) {
+            return null;
+        }
+        return stdout.chomp();
+    }
+    catch(Error e) {
+        printerr(@"Could not run \"$(command[0])\": $(e.message)\n");
+        return null;
+    }
+}
+
+
+/**
+ * Runs {@link command} in the current directory to success: output flows to
+ * the terminal unchanged and the call reports only whether it succeeded.
+ */
+private bool bump_git_run(string[] command) {
+    try {
+        return new Subprocess.newv(command, SubprocessFlags.INHERIT_FDS).wait_check();
+    }
+    catch(Error e) {
+        printerr(@"Could not run \"$(command[0])\": $(e.message)\n");
+        return false;
+    }
+}

+ 1 - 0
src/cli/meson.build

@@ -1,6 +1,7 @@
 
 sources = files('Cli.vala')
 sources += files('Manifest.vala')
+sources += files('ManifestBump.vala')
 sources += files('Repository.vala')
 sources += files('Install.vala')
 sources += files('Deploy.vala')

+ 12 - 1
src/lib/Manifest.vala

@@ -692,7 +692,14 @@ namespace Usm {
         SET_MANIFEST_PROPERTY_ENVS,
         NINJA_STYLE_PROGRESS,
         SIMPLE_BUILD_ENVIRONMENT,
-        DATA_PACKAGE;
+        DATA_PACKAGE,
+        /**
+         * Installing or updating this package also rebuilds every installed
+         * package whose "depends" refs match any of its provides keys (see
+         * {@link SystemState.find_dependant_names}); the flag lives on the
+         * dependency, not on its dependants.
+         */
+        REBUILD_DEPENDANTS;
 
         public string to_string() {
             switch (this) {
@@ -706,6 +713,8 @@ namespace Usm {
                     return "simpleBuildEnvironment";
                 case ManifestFlag.DATA_PACKAGE:
                     return "dataPackage";
+                case ManifestFlag.REBUILD_DEPENDANTS:
+                    return "rebuildDependants";
                 default:
                     assert_not_reached();
             }
@@ -723,6 +732,8 @@ namespace Usm {
                     return ManifestFlag.SIMPLE_BUILD_ENVIRONMENT;
                 case "dataPackage":
                     return ManifestFlag.DATA_PACKAGE;
+                case "rebuildDependants":
+                    return ManifestFlag.REBUILD_DEPENDANTS;
                 default:
                     throw new ManifestError.INVALID_FLAG(@"Unknown flag \"$str\".");
             }

+ 1 - 1
src/lib/State/CachedPackage.vala

@@ -50,7 +50,7 @@ namespace Usm {
             }
 
             var archive_path = build_archive_path();
-            if(!File.new_for_path(path).query_exists()) {
+            if(!File.new_for_path(archive_path).query_exists()) {
                 throw new StateError.NO_BUILD_ARTIFACT(@"The package \"$package_name\" has no build artifact");
             }
 

+ 50 - 0
src/lib/State/State.vala

@@ -1,4 +1,5 @@
 using Invercargill;
+using Invercargill.DataStructures;
 
 namespace Usm {
 
@@ -83,6 +84,48 @@ namespace Usm {
             client.download_repository_listing(Path.build_filename(list_path, filename), callback);
         }
 
+        /**
+         * Cache-directory names (e.g. "statum-0.1", the package's
+         * {@link CachedPackage.package_name}) of every INSTALLED package
+         * that depends on any resource {@link updated} provides.
+         *
+         * Every phase of each installed manifest is scanned — flat phases
+         * plus the union of all candidate groups of grouped phases — and a
+         * ref counts as a dependency when
+         * {@link ResourceRef.satisfied_by} matches it against any of the
+         * updated package's provides keys, so a `lib:` dependency is still
+         * found when the update provides `canonlib:`. Older installed
+         * versions of {@link updated} itself are never dependants of
+         * themselves. Throws when an installed package's manifest cannot
+         * be read — a corrupt install should surface, not silently skip
+         * rebuilds.
+         */
+        public Vector<string> find_dependant_names(CachedPackage updated) throws Error {
+            var dependants = new Vector<string>();
+            var updated_manifest = updated.get_manifest();
+            var provides_keys = updated_manifest.provides.select<ResourceRef>(p => p.key);
+
+            foreach(var installed in get_installed_packages()) {
+                var manifest = installed.get_manifest();
+                if(manifest.name == updated_manifest.name) {
+                    continue;
+                }
+
+                var refs = new HashSet<ResourceRef>();
+                refs.union_with(manifest.dependencies.runtime.all_refs());
+                refs.union_with(manifest.dependencies.build.all_refs());
+                refs.union_with(manifest.dependencies.manage.all_refs());
+                if(manifest.dependencies.acquire != null) {
+                    refs.union_with(manifest.dependencies.acquire.all_refs());
+                }
+
+                if(refs.any(d => provides_keys.any(p => d.satisfied_by(p)))) {
+                    dependants.add(installed.package_name);
+                }
+            }
+            return dependants;
+        }
+
         public string generate_cache_path(Manifest manifest) {
             var filename = @"$(manifest.name)-$(manifest.version)";
             return Path.build_filename(state_path, "packages", filename);
@@ -91,6 +134,13 @@ namespace Usm {
         public void mark_installed(CachedPackage package) throws Error {
             var filename = Path.get_basename(package.state_path);
             var symlink = File.new_build_filename(state_path, "installed", filename);
+            // A same-version rebuild re-marks an entry that is already
+            // linked: replace the link so a stale target cannot linger
+            try {
+                symlink.delete();
+            }
+            catch(IOError.NOT_FOUND e) {
+            }
             symlink.make_symbolic_link(package.state_path);
         }
 

+ 156 - 13
src/lib/Transaction.vala

@@ -30,6 +30,7 @@ namespace Usm {
         private uint task_count = 0;
         private uint current_task = 0;
         private string current_subject = "transaction";
+        private TransactionTask current_task_type = TransactionTask.STRATEGISING;
 
         /**
          * Lots computed by {@link strategise}: each lot builds, tests and
@@ -43,15 +44,28 @@ namespace Usm {
         /** Removal order computed by {@link strategise}: dependents before their providers. */
         public Vector<CachedPackage> removal_order { get; private set; }
 
+        /**
+         * Rebuild entries planned by {@link strategise} for packages in
+         * {@link to_install} flagged
+         * {@link ManifestFlag.REBUILD_DEPENDANTS}: each entry rebuilds its
+         * installed {@link RebuildEntry.package} — same CachedPackage,
+         * same version, same state path — through the standard pipeline,
+         * tagged {@link TransactionTask.REBUILDING} in progress reports.
+         * Empty when nothing is flagged or {@link state} is unset.
+         */
+        public Vector<RebuildEntry> rebuilds { get; private set; default = new Vector<RebuildEntry>(); }
+
         public void run() throws TransactionError {
 
             // 1. Verify the transaction is valid
             strategise();
 
             var all_packages = to_remove.concat(to_install);
+            var rebuild_packages = rebuilds.select<CachedPackage>(r => r.package).to_vector();
 
             // 2. Unpack packages
             do_for(all_packages, unpack_package, TransactionTask.UNPACKING);
+            do_for(rebuild_packages, unpack_package, TransactionTask.REBUILDING);
 
             // 3. Remove packages
             do_for(removal_order, remove_package, TransactionTask.REMOVING);
@@ -67,8 +81,15 @@ namespace Usm {
                 do_for(lot, install_package, TransactionTask.INSTALLING);
             }
 
+            // Rebuilds run the same pipeline once every flagged package is
+            // installed, so dependants build against the fresh artifacts
+            do_for(rebuild_packages, build_package, TransactionTask.REBUILDING);
+            do_for(rebuild_packages, test_package, TransactionTask.REBUILDING);
+            do_for(rebuild_packages, install_package, TransactionTask.REBUILDING);
+
             // 6. Clean up
             do_for(all_packages, cleanup_package, TransactionTask.CLEANING_UP);
+            do_for(rebuild_packages, cleanup_package, TransactionTask.CLEANING_UP);
 
         }
 
@@ -202,11 +223,76 @@ namespace Usm {
                 }
             }
 
+            plan_rebuilds();
+            task_count += rebuilds.length * 5;
 
             report_progress(TransactionTask.STRATEGISING, 1.0f);
             current_task++;
         }
 
+        /**
+         * Plans rebuildDependants rebuilds: every package in
+         * {@link to_install} flagged
+         * {@link ManifestFlag.REBUILD_DEPENDANTS} names its installed
+         * dependants via {@link SystemState.find_dependant_names}, and each
+         * one neither installed nor removed by this transaction rebuilds
+         * exactly once — dependants the transaction already handles are
+         * skipped, and two flagged packages sharing a dependant rebuild it
+         * once only. Without a {@link state} there is nothing to scan.
+         */
+        private void plan_rebuilds() throws TransactionError {
+            rebuilds = new Vector<RebuildEntry>();
+            if(state == null) {
+                return;
+            }
+
+            try {
+                var installed_by_name = new Dictionary<string, CachedPackage>();
+                foreach(var installed in state.get_installed_packages()) {
+                    // Resolve through the installed symlink so a rebuild
+                    // carries the cache path: reinstalling through the
+                    // installed path itself would make mark_installed
+                    // relink the symlink onto itself
+                    var info = File.new_for_path(installed.state_path).query_info(FileAttribute.STANDARD_SYMLINK_TARGET, FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+                    var target = info.get_symlink_target();
+                    installed_by_name.set(installed.package_name, target != null ? new CachedPackage(target) : installed);
+                }
+
+                var planned = new HashSet<string>();
+                foreach(var package in to_install) {
+                    planned.add(package.get_manifest().name);
+                }
+                foreach(var package in to_remove) {
+                    planned.add(package.get_manifest().name);
+                }
+
+                foreach(var package in to_install) {
+                    var manifest = package.get_manifest();
+                    if(manifest.flags == null || !manifest.flags.has(ManifestFlag.REBUILD_DEPENDANTS)) {
+                        continue;
+                    }
+                    foreach(var name in state.find_dependant_names(package)) {
+                        CachedPackage dependant;
+                        if(!installed_by_name.try_get(name, out dependant)) {
+                            continue;
+                        }
+                        var dependant_name = dependant.get_manifest().name;
+                        if(planned.has(dependant_name)) {
+                            continue;
+                        }
+                        planned.add(dependant_name);
+                        rebuilds.add(new RebuildEntry() {
+                            package = dependant,
+                            trigger = package
+                        });
+                    }
+                }
+            }
+            catch(Error e) {
+                throw new TransactionError.UNKNOWN_ERROR(@"Failed to plan dependant rebuilds: $(e.message)");
+            }
+        }
+
         /**
          * Orders a transaction package set by the given manifest-name order
          * (from a {@link ResolutionResult}); names with no package in the set
@@ -252,6 +338,7 @@ namespace Usm {
             foreach (var package in packages) {
                 try {
                     current_subject = package.package_name;
+                    current_task_type = task_type;
                     report_progress(task_type, 0.0f);
                     func(package);
                     current_task++;
@@ -274,31 +361,71 @@ namespace Usm {
 
             // Get a clean copy of the sources
             package.clean_source();
-            report_progress(TransactionTask.UNPACKING, will_remove ? 0.25f : 0.5f);
+            report_progress(current_task_type, will_remove ? 0.25f : 0.5f);
             package.get_source_directory();
-            report_progress(TransactionTask.UNPACKING, will_remove ? 0.5f : 1.0f);
+            report_progress(current_task_type, will_remove ? 0.5f : 1.0f);
 
             if(will_remove) {
                 // Get a clean copy of the build artifact
                 package.clean_build_directory();
-                report_progress(TransactionTask.UNPACKING, 0.75f);
+                report_progress(current_task_type, 0.75f);
                 package.get_build_directory();
-                report_progress(TransactionTask.UNPACKING, 1.0f);
+                report_progress(current_task_type, 1.0f);
             }
         }
 
+        /**
+         * Builds a package through the unified build-cache path: an
+         * existing build directory or a build archive (restored by
+         * {@link CachedPackage.get_build_directory}) makes the build
+         * incremental, and a failure atop either is retried once from
+         * extracted sources — a stale cache is the likeliest culprit, and
+         * the clean-retry error propagates when that also fails. A failure
+         * on an already-clean build propagates directly.
+         */
         private void build_package(CachedPackage package) throws Error {
-            // Get source directory, and create build directory
+            var reused = package.has_build_directory() || package.has_build_archive();
+            try {
+                attempt_build(package);
+            }
+            catch(Error e) {
+                if(!reused) {
+                    throw e;
+                }
+                package.clean_build_directory();
+                package.clean_source();
+                package.get_source_directory();
+                package.create_build_directory();
+                attempt_build(package);
+            }
+        }
+
+        /**
+         * Runs the package's build script against the unified
+         * build-directory path: an existing build tree is reused and a
+         * build archive is restored in place (both incremental), and only
+         * with neither does a fresh build directory appear — a missing
+         * cache is fine, the user may have cleaned it up. The caller owns
+         * the clean-retry decision (see {@link build_package}).
+         */
+        private void attempt_build(CachedPackage package) throws Error {
+            // Get source directory, and reuse, restore or create the build directory
+            string build_dir;
+            if(package.has_build_directory() || package.has_build_archive()) {
+                build_dir = package.get_build_directory();
+            }
+            else {
+                build_dir = package.create_build_directory();
+            }
             var source_dir = package.get_source_directory();
-            var build_dir = package.create_build_directory();
-                    
+
             // Change directory to sources
             Environment.set_current_dir(source_dir);
             var manifest = new Usm.Manifest.from_file("MANIFEST.usm");
 
             // Build package
             var build_proc = manifest.run_build(build_dir, paths, SubprocessFlags.STDOUT_SILENCE, (progress) => {
-                report_progress(TransactionTask.BUILDING, progress);
+                report_progress(current_task_type, progress);
             });
             build_proc.wait_check();
         }
@@ -331,7 +458,7 @@ namespace Usm {
             if(build_proc != null)
                 build_proc.wait_check();
 
-                manifest.remove_resources( paths, (r, cr, tr, f) => report_progress(TransactionTask.INSTALLING, ((float)cr + (float)f) / (float)tr));
+                manifest.remove_resources( paths, (r, cr, tr, f) => report_progress(current_task_type, ((float)cr + (float)f) / (float)tr));
 
             state.unmark_installed(package);
         }
@@ -343,7 +470,7 @@ namespace Usm {
             // "cd" into the source directory and read the manifest
             Environment.set_current_dir(source_dir);
             var manifest = new Usm.Manifest.from_file("MANIFEST.usm");
-            report_progress(TransactionTask.INSTALLING, 0.0f);
+            report_progress(current_task_type, 0.0f);
 
             string? install_dir = null;
             // Run install process if present
@@ -354,10 +481,10 @@ namespace Usm {
             }
 
             // Install the package's resources
-            manifest.install_resources(source_dir, build_dir, install_dir, paths, (r, cr, tr, f) => report_progress(TransactionTask.INSTALLING, ((float)cr + (float)f) / (float)tr));
+            manifest.install_resources(source_dir, build_dir, install_dir, paths, (r, cr, tr, f) => report_progress(current_task_type, ((float)cr + (float)f) / (float)tr));
 
             // Run post install process if present
-            report_progress(TransactionTask.INSTALLING, 1.0f);
+            report_progress(current_task_type, 1.0f);
             var post_install_proc = manifest.run_post_install(build_dir, InstallType.FRESH, SubprocessFlags.STDOUT_SILENCE);
             if(post_install_proc != null)
             post_install_proc.wait_check();
@@ -376,6 +503,18 @@ namespace Usm {
         }
     }
 
+    /**
+     * One planned rebuildDependants rebuild: {@link package} is the
+     * installed {@link CachedPackage} — same version, same state path —
+     * that rebuilds through the standard pipeline tagged
+     * {@link TransactionTask.REBUILDING}, and {@link trigger} is the
+     * flagged package whose install/update pulled it in.
+     */
+    public class RebuildEntry {
+        public CachedPackage package { get; set; }
+        public CachedPackage trigger { get; set; }
+    }
+
     public enum TransactionTask {
         STRATEGISING,
         UNPACKING,
@@ -383,7 +522,9 @@ namespace Usm {
         TESTING,
         REMOVING,
         INSTALLING,
-        CLEANING_UP;
+        CLEANING_UP,
+        /** The rebuildDependants pipeline for an already-installed package (see {@link RebuildEntry}). */
+        REBUILDING;
 
     public string get_verb() {
         switch (this) {
@@ -401,6 +542,8 @@ namespace Usm {
                 return "installing";
             case CLEANING_UP:
                 return "cleaning up";
+            case REBUILDING:
+                return "rebuilding";
             default:
                 assert_not_reached();
         }

+ 25 - 27
src/lib/Util.vala

@@ -1,35 +1,33 @@
+using Invercargill.DataStructures;
 
 namespace Usm.Util {
 
     public static void delete_tree(string path) throws Error {
-        // Creating a new Directory object for the given folder path
-        var folder = File.new_for_path(path);
-
-        // Getting a list of all files and folders inside the directory
-        var enumerator = folder.enumerate_children("*", FileQueryInfoFlags.NONE);
-
-        // Looping through each file/folder and removing them
-        while (true) {
-            FileInfo? info = enumerator.next_file();
-            if (info == null) {
-                break;
-            }
-
-            // Checking if the current item is a file or a folder
-            if (info.get_file_type() == FileType.REGULAR) {
-                // Removing the file
-                var file = folder.get_child(info.get_name());
-                    file.delete();
-
-            }
-            else if (info.get_file_type() == FileType.DIRECTORY) {
-                // Removing the folder recursively
-                var subfolder = folder.get_child(info.get_name());
-                delete_tree(subfolder.get_path());
-            }
+        var file = File.new_for_path(path);
+
+        // Never descend through a symbolic link: unlink it instead and
+        // leave the target tree alone (the buildInSourceTree build
+        // directory is one)
+        var info = file.query_info(FileAttribute.STANDARD_TYPE, FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+        if(info.get_file_type() != FileType.DIRECTORY) {
+            file.delete();
+            return;
         }
-        
-        folder.delete();
+
+        // Collect the whole listing before deleting anything: mutating the
+        // directory mid-enumeration can skip entries
+        var names = new Vector<string>();
+        var enumerator = file.enumerate_children(FileAttribute.STANDARD_NAME, FileQueryInfoFlags.NOFOLLOW_SYMLINKS);
+        FileInfo child_info = null;
+        while((child_info = enumerator.next_file()) != null) {
+            names.add(child_info.get_name());
+        }
+
+        foreach(var name in names) {
+            delete_tree(file.get_child(name).get_path());
+        }
+
+        file.delete();
     }
     
 

+ 325 - 0
src/tests/TestMain.vala

@@ -122,6 +122,19 @@ namespace Usm.Tests {
         check(Usm.ManifestFlag.from_string("dataPackage") == Usm.ManifestFlag.DATA_PACKAGE, "dataPackage flag parses");
     }
 
+    void test_rebuild_dependants_flag() throws Error {
+        check(Usm.ManifestFlag.REBUILD_DEPENDANTS.to_string() == "rebuildDependants", "rebuildDependants flag serialises");
+        check(Usm.ManifestFlag.from_string("rebuildDependants") == Usm.ManifestFlag.REBUILD_DEPENDANTS, "rebuildDependants flag parses");
+
+        var flagged = manifest_from_json(APP_MANIFEST.printf("invercargill", "invercargill", depends_with("runtime", "[]"))
+            .replace("\"flags\": []", "\"flags\": [\"rebuildDependants\"]"));
+        check(flagged.flags.contains(Usm.ManifestFlag.REBUILD_DEPENDANTS), "rebuildDependants parses within a manifest flags array");
+
+        var serialised = ((!)new JsonElement.from_properties(Usm.Manifest.get_mapper().map_from(flagged))).stringify_pretty();
+        check(manifest_from_json(serialised).flags.contains(Usm.ManifestFlag.REBUILD_DEPENDANTS),
+            "rebuildDependants round-trips through serialisation");
+    }
+
     void test_data_package_validation() throws Error {
         var clean = manifest_from_json(DATA_MANIFEST.printf("\"execs\": { \"acquire\": \"fetch.sh\" }"));
         clean.validate();
@@ -841,6 +854,298 @@ esac
         Usm.Util.delete_tree(scratch);
     }
 
+    // ---- rebuildDependants: lookup, planning, dedup ------------------------------
+
+    /** Manifest for a package named <name> at <version> providing one key with the given flags and depends. */
+    const string REBUILD_MANIFEST = """
+    {
+      "name": "%s",
+      "version": "%s",
+      "summary": "rebuild fixtures",
+      "licences": [],
+      "flags": [%s],
+      "provides": { "%s": "as-expected" },
+      "depends": %s,
+      "execs": {}
+    }
+    """;
+
+    /**
+     * Caches a package under <state_dir>/packages/<name>-<version> built
+     * from {@link REBUILD_MANIFEST}, optionally marks it installed, and
+     * returns its {@link Usm.CachedPackage}.
+     */
+    Usm.CachedPackage scratch_package(string state_dir, string name, string version, string provides, string flags, string depends, bool installed) throws Error {
+        var source = Path.build_filename(state_dir, @"src-$name-$version");
+        DirUtils.create(source, 0755);
+        FileUtils.set_contents(Path.build_filename(source, "MANIFEST.usm"),
+            REBUILD_MANIFEST.printf(name, version, flags, provides, depends));
+
+        var cache_path = Path.build_filename(state_dir, "packages", @"$name-$version");
+        DirUtils.create_with_parents(cache_path, 0755);
+        Usm.Util.archive(source, Path.build_filename(cache_path, "package.usmc"));
+        Usm.Util.delete_tree(source);
+
+        if(installed) {
+            File.new_build_filename(state_dir, "installed", @"$name-$version").make_symbolic_link(cache_path);
+        }
+        return new Usm.CachedPackage(cache_path);
+    }
+
+    /** A managed {@link Usm.SystemState} over a scratch tree: usm.config plus empty state directories. */
+    Usm.SystemState make_state(string scratch) throws Error {
+        var config_dir = Path.build_filename(scratch, "config");
+        var state_dir = Path.build_filename(scratch, "state");
+        DirUtils.create_with_parents(config_dir, 0700);
+        DirUtils.create_with_parents(state_dir, 0700);
+        foreach(var part in new string[] { "packages", "installed", "lists" }) {
+            DirUtils.create(Path.build_filename(state_dir, part), 0700);
+        }
+        FileUtils.set_contents(Path.build_filename(config_dir, "usm.config"),
+            "{\"is_managed\": true, \"managed\": {\"state_path\": \"%s\"}}".printf(state_dir));
+
+        var paths = new Usm.Paths();
+        paths.usm_config_dir = config_dir;
+        return new Usm.SystemState(paths);
+    }
+
+    void test_find_dependant_names() throws Error {
+        var scratch = make_scratch();
+        var state = make_state(scratch);
+        var state_dir = Path.build_filename(scratch, "state");
+
+        var updated = scratch_package(state_dir, "dep-a", "1.0.0", "pc:dep-a.pc", "", depends_with("runtime", "[]"), false);
+        // an older installed dep-a depending on its own provides is not its own dependant
+        scratch_package(state_dir, "dep-a", "0.9.0", "pc:dep-a.pc", "", depends_with("runtime", "[\"pc:dep-a.pc\"]"), true);
+        scratch_package(state_dir, "dep-b", "0.1", "bin:dep-b", "", depends_with("build", "[\"pc:dep-a.pc\"]"), true);
+        scratch_package(state_dir, "dep-c", "2.0", "bin:dep-c", "",
+            "{ \"runtime\": [[\"pc:dep-a.pc\", \"opt:other\"], [\"pc:elsewhere.pc\"]], \"build\": [], \"manage\": [] }", true);
+        scratch_package(state_dir, "unrelated", "1.0", "bin:unrelated", "", depends_with("runtime", "[\"bin:bash\"]"), true);
+
+        var dependants = state.find_dependant_names(updated);
+        check(dependants.length == 2, "exactly the two dependants are found");
+        check(dependants.any(n => n == "dep-b-0.1"), "a build-phase dependant is found");
+        check(dependants.any(n => n == "dep-c-2.0"), "a grouped-phase dependant is found via any candidate group");
+        check(dependants.no(n => n == "unrelated-1.0"), "a package with no dependency on the update is not found");
+        check(dependants.no(n => n == "dep-a-0.9.0"), "an older installed version of the update is not its own dependant");
+
+        // resource-level matching: a canonlib: provide satisfies a lib: dependency
+        var canonical = scratch_package(state_dir, "dep-libc", "1.0", "canonlib:dep-lib.so", "", depends_with("runtime", "[]"), false);
+        scratch_package(state_dir, "dep-lib", "3.0", "lib:dep-lib.so", "", depends_with("runtime", "[\"lib:dep-lib.so\"]"), true);
+        check(state.find_dependant_names(canonical).any(n => n == "dep-lib-3.0"), "a canonlib provide satisfies a lib dependency");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_rebuild_planning() throws Error {
+        var scratch = make_scratch();
+        var state = make_state(scratch);
+        var state_dir = Path.build_filename(scratch, "state");
+
+        // two flagged providers, each with an incoming update
+        scratch_package(state_dir, "liba", "1.0.0", "pc:liba.pc", "\"rebuildDependants\"", depends_with("runtime", "[]"), true);
+        var liba_update = scratch_package(state_dir, "liba", "2.0.0", "pc:liba.pc", "\"rebuildDependants\"", depends_with("runtime", "[]"), false);
+        scratch_package(state_dir, "libx", "1.0.0", "pc:libx.pc", "\"rebuildDependants\"", depends_with("runtime", "[]"), true);
+        var libx_update = scratch_package(state_dir, "libx", "2.0.0", "pc:libx.pc", "\"rebuildDependants\"", depends_with("runtime", "[]"), false);
+        // statum depends on liba in its build phase and libx at runtime
+        scratch_package(state_dir, "statum", "0.1", "bin:statum", "",
+            "{ \"runtime\": [\"pc:libx.pc\"], \"build\": [\"pc:liba.pc\"], \"manage\": [] }", true);
+        // client 1.0.0 depends on liba, but client 2.0.0 joins the transaction explicitly
+        scratch_package(state_dir, "client", "1.0.0", "bin:client", "", depends_with("build", "[\"pc:liba.pc\"]"), true);
+        var client_update = scratch_package(state_dir, "client", "2.0.0", "bin:client", "", depends_with("build", "[\"pc:liba.pc\"]"), false);
+        // libz updates without the flag, so its dependant must NOT rebuild
+        var libz_update = scratch_package(state_dir, "libz", "2.0.0", "pc:libz.pc", "", depends_with("runtime", "[]"), false);
+        scratch_package(state_dir, "user", "1.0.0", "bin:user", "", depends_with("build", "[\"pc:libz.pc\"]"), true);
+
+        var dependants = state.find_dependant_names(liba_update);
+        check(dependants.length == 2 && dependants.any(n => n == "statum-0.1") && dependants.any(n => n == "client-1.0.0"),
+            "the lookup sees both of liba's installed dependants before filtering");
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        to_install.add(liba_update);
+        to_install.add(libx_update);
+        to_install.add(client_update);
+        to_install.add(libz_update);
+        var transaction = new Usm.Transaction() {
+            paths = new Usm.Paths(),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>(),
+            state = state
+        };
+        transaction.strategise();
+
+        check(transaction.rebuilds.length == 1, "one rebuild is planned across two flagged providers");
+        check(transaction.rebuilds[0].package.package_name == "statum-0.1", "the shared dependant statum rebuilds exactly once");
+        check(transaction.rebuilds[0].trigger.package_name == "liba-2.0.0" || transaction.rebuilds[0].trigger.package_name == "libx-2.0.0",
+            "the rebuild records the flagged trigger");
+        check(transaction.rebuilds[0].package.state_path == Path.build_filename(state_dir, "packages", "statum-0.1"),
+            "the rebuild reuses the installed package's own state path");
+
+        var rebuilt = new HashSet<string>();
+        foreach(var entry in transaction.rebuilds) {
+            rebuilt.add(entry.package.package_name);
+        }
+        check(!rebuilt.has("client-1.0.0"), "a dependant already in to_install is not added as a rebuild");
+        check(!rebuilt.has("user-1.0.0"), "dependants of unflagged providers are not rebuilt");
+
+        var lot_names = new StringBuilder();
+        foreach(var lot in transaction.install_lots) {
+            lot_names.append(lot.to_string(p => p.package_name, ","));
+        }
+        check(!lot_names.str.contains("statum"), "a rebuild target never also builds through the install lots");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    // ---- build archive restoration + clean retry ---------------------------------
+
+    /** Manifest for a build-only package: an executable build script, no provides. */
+    const string BUILD_MANIFEST = """
+    {
+      "name": "%s",
+      "version": "%s",
+      "summary": "build cache fixture",
+      "licences": [],
+      "flags": [],
+      "provides": {},
+      "depends": { "runtime": [], "build": [], "manage": [] },
+      "execs": { "build": "build.sh" }
+    }
+    """;
+
+    /** Always fails; logs whether the build directory carried the archived marker. */
+    const string FAILING_BUILD_SCRIPT = """#!/bin/bash
+if [ -f "$1/from-archive" ]; then
+    echo restored >> "$USM_TEST_BUILD_LOG"
+else
+    echo fresh >> "$USM_TEST_BUILD_LOG"
+fi
+exit 1
+""";
+
+    /** Succeeds only against the restored archive, so a retry (fresh) fails. */
+    const string INCREMENTAL_BUILD_SCRIPT = """#!/bin/bash
+if [ -f "$1/from-archive" ]; then
+    echo restored >> "$USM_TEST_BUILD_LOG"
+    exit 0
+fi
+echo fresh >> "$USM_TEST_BUILD_LOG"
+exit 1
+""";
+
+    /** Paths with the destination rooted in <scratch> so nothing touches the real filesystem. */
+    Usm.Paths scratch_paths(string scratch) {
+        var paths = new Usm.Paths();
+        paths.destination = Path.build_filename(scratch, "dest");
+        return paths;
+    }
+
+    /**
+     * Caches a build-only package named <name> running <script>, optionally
+     * seeding its cache with a build.tar.xz holding a "from-archive" marker,
+     * and returns a transaction over it.
+     */
+    Usm.Transaction build_transaction(string scratch, string name, string script, bool with_archive) throws Error {
+        var state = make_state(scratch);
+        var cache_path = Path.build_filename(scratch, "state", "packages", @"$name-1.0.0");
+        DirUtils.create_with_parents(cache_path, 0755);
+
+        var source = Path.build_filename(scratch, @"src-$name");
+        DirUtils.create(source, 0755);
+        FileUtils.set_contents(Path.build_filename(source, "MANIFEST.usm"), BUILD_MANIFEST.printf(name, "1.0.0"));
+        FileUtils.set_contents(Path.build_filename(source, "build.sh"), script);
+        FileUtils.chmod(Path.build_filename(source, "build.sh"), 0755);
+        Usm.Util.archive(source, Path.build_filename(cache_path, "package.usmc"));
+        Usm.Util.delete_tree(source);
+
+        if(with_archive) {
+            var stale_build = Path.build_filename(scratch, "stale-build");
+            DirUtils.create(stale_build, 0755);
+            FileUtils.set_contents(Path.build_filename(stale_build, "from-archive"), "stale");
+            Usm.Util.archive(stale_build, Path.build_filename(cache_path, "build.tar.xz"));
+        }
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        to_install.add(new Usm.CachedPackage(cache_path));
+        return new Usm.Transaction() {
+            paths = scratch_paths(scratch),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>(),
+            state = state
+        };
+    }
+
+    void test_build_archive_restore_and_clean_retry() throws Error {
+        var original_dir = Environment.get_current_dir();
+        var scratch = make_scratch();
+        var log_path = Path.build_filename(scratch, "build.log");
+        Environment.set_variable("USM_TEST_BUILD_LOG", log_path, true);
+
+        var transaction = build_transaction(scratch, "fail-pkg", FAILING_BUILD_SCRIPT, true);
+        var threw = false;
+        try {
+            transaction.run();
+        }
+        catch(Usm.TransactionError e) {
+            threw = true;
+            check(e.message.contains("fail-pkg"), "the clean-retry failure names the package");
+        }
+        check(threw, "a failing restored build followed by a failing clean retry fails the transaction");
+
+        string log = "";
+        FileUtils.get_contents(log_path, out log);
+        check(log == "restored\nfresh\n", "the clean retry fires once after the restored build fails");
+
+        Environment.set_current_dir(original_dir);
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_build_archive_restored_incrementally() throws Error {
+        var original_dir = Environment.get_current_dir();
+        var scratch = make_scratch();
+        var log_path = Path.build_filename(scratch, "build.log");
+        Environment.set_variable("USM_TEST_BUILD_LOG", log_path, true);
+
+        var cache_path = Path.build_filename(scratch, "state", "packages", "inc-pkg-1.0.0");
+        var transaction = build_transaction(scratch, "inc-pkg", INCREMENTAL_BUILD_SCRIPT, true);
+        transaction.run();
+
+        string log = "";
+        FileUtils.get_contents(log_path, out log);
+        check(log == "restored\n", "a successful build runs against the restored archive without a retry");
+        check(File.new_for_path(Path.build_filename(cache_path, "build.tar.xz")).query_exists(),
+            "cleanup re-archives the build directory for the next transaction");
+
+        Environment.set_current_dir(original_dir);
+        Usm.Util.delete_tree(scratch);
+    }
+
+    void test_build_failure_without_cache_propagates() throws Error {
+        var original_dir = Environment.get_current_dir();
+        var scratch = make_scratch();
+        var log_path = Path.build_filename(scratch, "build.log");
+        Environment.set_variable("USM_TEST_BUILD_LOG", log_path, true);
+
+        var transaction = build_transaction(scratch, "clean-pkg", FAILING_BUILD_SCRIPT, false);
+        var threw = false;
+        try {
+            transaction.run();
+        }
+        catch(Usm.TransactionError e) {
+            threw = true;
+        }
+        check(threw, "a failing already-clean build propagates without a retry");
+
+        string log = "";
+        FileUtils.get_contents(log_path, out log);
+        check(log == "fresh\n", "a build without any cache is attempted exactly once");
+
+        Environment.set_current_dir(original_dir);
+        Usm.Util.delete_tree(scratch);
+    }
+
     int main() {
         test_default_ignore();
         try {
@@ -855,6 +1160,7 @@ esac
 
         try {
             test_data_package_flag();
+            test_rebuild_dependants_flag();
             test_data_package_validation();
             test_data_package_from_file();
             test_configuration_system_package_manager();
@@ -904,6 +1210,25 @@ esac
             print("FAIL resolver test threw: %s\n", e.message);
         }
 
+        try {
+            test_find_dependant_names();
+            test_rebuild_planning();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL rebuild planning test threw: %s\n", e.message);
+        }
+
+        try {
+            test_build_archive_restore_and_clean_retry();
+            test_build_archive_restored_incrementally();
+            test_build_failure_without_cache_propagates();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL build cache retry test threw: %s\n", e.message);
+        }
+
         print("%d passed, %d failed\n", passes, failures);
         return failures == 0 ? 0 : 1;
     }