Parcourir la source

feat: explicitly_installed tracking - roots marked explicit, resolved deps implicit; remove auto-sweeps orphaned non-explicit dependencies

clanker il y a 1 semaine
Parent
commit
bbb5beffdc

+ 1 - 0
src/cli/Install.vala

@@ -106,6 +106,7 @@ private int install_main(string[] args) {
             to_remove = new HashSet<Usm.CachedPackage>(),
             to_install = cached_packages,
             install_order = install_order,
+            explicit_packages = package_names,
             state = state
         };
 

+ 106 - 4
src/cli/Remove.vala

@@ -7,6 +7,13 @@ using Invercargill.DataStructures;
  * (discovered through {@link Usm.SystemState.find_dependant_names}, so
  * the summary shows the full closure before anything happens).
  *
+ * The plan then gains every orphaned dependency: a remaining package
+ * installed implicitly ({@link Usm.OriginInformation.explicitly_installed}
+ * false — a resolved dependency, or a record from before the field
+ * existed) that no package surviving the removal depends on. Orphans
+ * appear under their own "remove (orphaned)" label in the summary, and
+ * nothing is removed without confirmation.
+ *
  * After the transaction each removed package's state directory —
  * sources, build cache, `installed/` entry — is deleted; OTHER versions'
  * directories (the siblings updates keep for downgrade) remain.
@@ -59,8 +66,11 @@ private int remove_main(string[] args) {
         var closure = new Vector<Usm.CachedPackage>();
         remove_collect_cascade(state, by_package_name, new HashSet<string>(), closure, target);
 
+        var orphans = remove_collect_orphans(by_package_name, closure);
+
         var to_remove = new HashSet<Usm.CachedPackage>();
         to_remove.union_with(closure);
+        to_remove.union_with(orphans);
 
         var transaction = new Usm.Transaction() {
             paths = paths,
@@ -70,7 +80,7 @@ private int remove_main(string[] args) {
         };
 
         // Planning before the prompt lets the removal order (dependents
-        // before providers) be computed and shown
+        // before providers, orphans last) be computed and shown
         try {
             transaction.strategise();
         }
@@ -80,7 +90,8 @@ private int remove_main(string[] args) {
         }
 
         var summary = new Usm.TransactionSummary() {
-            to_remove = closure
+            to_remove = closure,
+            orphaned_removals = orphans
         };
         if(!Usm.Cli.confirm_transaction(summary, assume_yes)) {
             print("Aborted.\n");
@@ -93,10 +104,11 @@ private int remove_main(string[] args) {
 
         // The state directories go only after a successful transaction;
         // other versions' directories are never touched
-        foreach(var package in closure) {
+        var removed_packages = closure.concat(orphans);
+        foreach(var package in removed_packages) {
             Usm.Util.delete_tree(package.state_path);
         }
-        print(@"Removed $(closure.length) package(s) ($(closure.to_string(p => p.package_name, ", ")))\n");
+        print(@"Removed $(closure.length + orphans.length) package(s) ($(removed_packages.to_string(p => p.package_name, ", ")))\n");
     }
     catch(Error e) {
         printerr(@"Error: $(e.message)\n");
@@ -128,6 +140,96 @@ private void remove_collect_cascade(Usm.SystemState state, Dictionary<string, Us
     }
 }
 
+/**
+ * Orphan scan over the packages {@link closure} leaves behind: every
+ * implicitly installed survivor that no other survivor depends on,
+ * where dependency means any runtime/build/manage/acquire ref matched
+ * against the candidate's provides (the same resource-level matching
+ * {@link Usm.SystemState.find_dependant_names} applies, restricted to
+ * the survivors because that helper scans the whole installed set).
+ * Rescans until no new orphan appears so a chain of orphans — libB
+ * orphaned, then the libC only libB ever needed — is collected whole.
+ */
+private Vector<Usm.CachedPackage> remove_collect_orphans(Dictionary<string, Usm.CachedPackage> by_package_name,
+        Vector<Usm.CachedPackage> closure) throws Error {
+    // Manifests, dependency refs and explicit marks are read once up
+    // front: the fixpoint loop below only consults these tables
+    var manifests = new Dictionary<string, Usm.Manifest>();
+    var refs_by_name = new Dictionary<string, HashSet<Usm.ResourceRef>>();
+    var implicit_names = new HashSet<string>();
+    foreach(var pair in by_package_name) {
+        var manifest = pair.value.get_manifest();
+        manifests.set(pair.key, manifest);
+        refs_by_name.set(pair.key, remove_dependency_refs(manifest));
+        if(!remove_explicitly_installed(pair.value)) {
+            implicit_names.add(pair.key);
+        }
+    }
+
+    var gone = new HashSet<string>();
+    foreach(var package in closure) {
+        gone.add(package.package_name);
+    }
+
+    var orphans = new Vector<Usm.CachedPackage>();
+    var collected = true;
+    while(collected) {
+        collected = false;
+        foreach(var name in implicit_names) {
+            if(gone.contains(name)) {
+                continue;
+            }
+            var provides = manifests[name].provides.select<Usm.ResourceRef>(p => p.key);
+            var needed = false;
+            foreach(var pair in refs_by_name) {
+                if(pair.key == name || gone.contains(pair.key)) {
+                    continue;
+                }
+                if(pair.value.any(d => provides.any(p => d.satisfied_by(p)))) {
+                    needed = true;
+                    break;
+                }
+            }
+            if(needed) {
+                continue;
+            }
+            gone.add(name);
+            orphans.add(by_package_name[name]);
+            collected = true;
+        }
+    }
+    return orphans;
+}
+
+/**
+ * Every dependency ref of {@link manifest} across all phases, flat plus
+ * the union of all candidate groups of grouped phases.
+ */
+private HashSet<Usm.ResourceRef> remove_dependency_refs(Usm.Manifest manifest) {
+    var refs = new HashSet<Usm.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());
+    }
+    return refs;
+}
+
+/**
+ * The package's explicit-install mark; a missing or unreadable origin
+ * record means the package predates the field, whose default is false —
+ * the same value the record itself would carry.
+ */
+private bool remove_explicitly_installed(Usm.CachedPackage package) {
+    try {
+        return package.get_origin_information().explicitly_installed;
+    }
+    catch(Error e) {
+        return false;
+    }
+}
+
 private int remove_usage() {
     printerr("USAGE:\n\tusm remove [-y|--yes] <package>\n");
     return 255;

+ 21 - 3
src/lib/State/OriginInformation.vala

@@ -10,12 +10,30 @@ namespace Usm {
         public string original_path { get; set; }
         public bool signature_verified { get; set; }
 
+        /**
+         * Whether the user asked for this package by name (`usm install
+         * foo`) rather than receiving it as a resolved dependency; the
+         * `usm remove` orphan scan spares packages marked here. Records
+         * written before the field existed (and records the transaction
+         * writes without repository provenance) carry false/null and
+         * parse back as before.
+         */
+        public bool explicitly_installed { get; set; default = false; }
+
         public static PropertyMapper<OriginInformation> get_mapper() {
             return PropertyMapper.build_for<OriginInformation>(cfg => {
-                cfg.map<string>("repository", o => o.repository, (o, v) => o.repository = v);
-                cfg.map<string>("listfile", o => o.listfile, (o, v) => o.listfile = v);
-                cfg.map<string>("original_path", o => o.original_path, (o, v) => o.original_path = v);
+                cfg.map<string>("repository", o => o.repository, (o, v) => o.repository = v)
+                    .null_when(o => o.repository == null)
+                    .when_null(o => o.repository = null);
+                cfg.map<string>("listfile", o => o.listfile, (o, v) => o.listfile = v)
+                    .null_when(o => o.listfile == null)
+                    .when_null(o => o.listfile = null);
+                cfg.map<string>("original_path", o => o.original_path, (o, v) => o.original_path = v)
+                    .null_when(o => o.original_path == null)
+                    .when_null(o => o.original_path = null);
                 cfg.map<bool>("signature_verified", o => o.signature_verified, (o, v) => o.signature_verified = v);
+                cfg.map<bool>("explicitly_installed", o => o.explicitly_installed, (o, v) => o.explicitly_installed = v)
+                    .when_undefined(o => o.explicitly_installed = false);
             });
         }
 

+ 33 - 0
src/lib/Transaction.vala

@@ -25,6 +25,16 @@ namespace Usm {
          */
         public Vector<string>? remove_order { get; set; }
 
+        /**
+         * Manifest names of the packages the user asked for by name (the
+         * `usm install` arguments): each install records
+         * {@link OriginInformation.explicitly_installed} true for these and
+         * false for everything the resolver pulled in as a dependency.
+         * Null — every command but `usm install` — marks the whole install
+         * set implicit.
+         */
+        public Vector<string>? explicit_packages { get; set; }
+
         /**
          * Version downgrades executed by this transaction: each entry
          * removes {@link DowngradeEntry.current}'s installed resources —
@@ -571,6 +581,29 @@ namespace Usm {
 
             // Update the system state, and cleanup
             state.mark_installed(package);
+            record_origin(package, manifest);
+        }
+
+        /**
+         * Writes the package's origin record after install: a record
+         * already in this cache directory (a rebuild reinstalls the same
+         * one) keeps its provenance and its explicit mark — a rebuild
+         * must not demote an explicitly installed package to
+         * orphan-removable — while a fresh directory starts a record
+         * whose only known field is the mark, set from whether the user
+         * named the package in this transaction.
+         */
+        private void record_origin(CachedPackage package, Manifest manifest) throws Error {
+            OriginInformation origin;
+            try {
+                origin = package.get_origin_information();
+            }
+            catch(Error e) {
+                origin = new OriginInformation();
+            }
+            var named = explicit_packages != null && explicit_packages.contains(manifest.name);
+            origin.explicitly_installed = named || origin.explicitly_installed;
+            package.update_origin_information(origin);
         }
 
         private void cleanup_package(CachedPackage package) throws Error {

+ 15 - 3
src/lib/TransactionSummary.vala

@@ -18,6 +18,13 @@ namespace Usm {
         public Vector<CachedPackage> to_install { get; set; default = new Vector<CachedPackage>(); }
         /** Installed packages the transaction removes; entries whose name is reinstalled render as updates. */
         public Vector<CachedPackage> to_remove { get; set; default = new Vector<CachedPackage>(); }
+        /**
+         * Orphaned dependencies joining the removal plan: implicitly
+         * installed packages no package surviving the removal needs (the
+         * `usm remove` auto-orphan scan). Rendered with their own label
+         * so the plan explains why they go.
+         */
+        public Vector<CachedPackage> orphaned_removals { get; set; default = new Vector<CachedPackage>(); }
         /** Planned rebuildDependants rebuilds, taken from {@link Transaction.rebuilds} after {@link Transaction.strategise}. */
         public Vector<RebuildEntry> to_rebuild { get; set; default = new Vector<RebuildEntry>(); }
         /** Version downgrades; each entry replaces {@link DowngradeEntry.current} with {@link DowngradeEntry.target}. */
@@ -25,7 +32,7 @@ namespace Usm {
 
         /** Whether the summary describes no action at all. */
         public bool is_empty() {
-            return to_install.length == 0 && to_remove.length == 0
+            return to_install.length == 0 && to_remove.length == 0 && orphaned_removals.length == 0
                 && to_rebuild.length == 0 && to_downgrade.length == 0;
         }
 
@@ -33,8 +40,9 @@ namespace Usm {
          * Human-readable multi-line rendering of the plan (without a
          * trailing newline): one line per package with its version
          * transition, categories ordered install, update, remove,
-         * downgrade, rebuild. Unreadable manifests degrade to the cache
-         * directory name rather than failing the display.
+         * remove (orphaned), downgrade, rebuild. Unreadable manifests
+         * degrade to the cache directory name rather than failing the
+         * display.
          */
         public string describe() {
             var builder = new StringBuilder();
@@ -65,6 +73,10 @@ namespace Usm {
                 builder.append_printf("  %-9s  %s %s\n", "remove", summary_name(package), summary_version(package));
             }
 
+            foreach(var package in orphaned_removals.sort((a, b) => summary_name(a).collate(summary_name(b)))) {
+                builder.append_printf("  remove (orphaned)  %s %s\n", summary_name(package), summary_version(package));
+            }
+
             foreach(var entry in to_downgrade.sort((a, b) => summary_name(a.target).collate(summary_name(b.target)))) {
                 builder.append_printf("  %-9s  %s %s → %s\n", "downgrade",
                     summary_name(entry.target), summary_version(entry.current), summary_version(entry.target));

+ 112 - 0
src/tests/TestMain.vala

@@ -1012,6 +1012,109 @@ esac
         Usm.Util.delete_tree(scratch);
     }
 
+    // ---- explicit-install tracking ------------------------------------------------
+
+    void test_origin_information_explicit_install() throws Error {
+        var scratch = make_scratch();
+        var cache_path = Path.build_filename(scratch, "cache-explicit-pkg-1.0.0");
+        DirUtils.create(cache_path, 0755);
+        var cached = new Usm.CachedPackage(cache_path);
+
+        var explicit_record = new Usm.OriginInformation() {
+            repository = "repo",
+            listfile = "2020-03-20T14:34:42.382748.usml",
+            original_path = "explicit-pkg-1.0.0.usmc",
+            signature_verified = true,
+            explicitly_installed = true
+        };
+        cached.update_origin_information(explicit_record);
+        var read_explicit = cached.get_origin_information();
+        check(read_explicit.explicitly_installed, "explicitly_installed true survives an origin-info round trip");
+        check(read_explicit.repository == "repo" && read_explicit.signature_verified, "provenance fields survive the round trip");
+
+        var implicit_record = new Usm.OriginInformation();
+        implicit_record.explicitly_installed = false;
+        cached.update_origin_information(implicit_record);
+        var read_implicit = cached.get_origin_information();
+        check(!read_implicit.explicitly_installed, "explicitly_installed false survives an origin-info round trip");
+        check(read_implicit.repository == null, "a transaction-written record without provenance round-trips null fields");
+
+        FileUtils.set_contents(Path.build_filename(cache_path, "origin-info"),
+            "{\"repository\": \"repo\", \"listfile\": \"2020-03-20T14:34:42.382748.usml\", \"original_path\": \"explicit-pkg-1.0.0.usmc\", \"signature_verified\": true}");
+        check(!cached.get_origin_information().explicitly_installed, "an origin-info file without the field defaults to false");
+
+        Usm.Util.delete_tree(scratch);
+    }
+
+    /** No-op build script: the install pipeline only needs it to succeed. */
+    const string NOOP_BUILD_SCRIPT = """#!/bin/bash
+exit 0
+""";
+
+    /**
+     * Caches a build-only package named <name> at 1.0.0 with a
+     * succeeding build script under <scratch>'s managed state and
+     * returns its cache path.
+     */
+    string explicit_flag_package(string scratch, string name) throws Error {
+        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"), NOOP_BUILD_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);
+        return cache_path;
+    }
+
+    void test_transaction_records_explicit_flags() throws Error {
+        var original_dir = Environment.get_current_dir();
+        var scratch = make_scratch();
+        var state = make_state(scratch);
+
+        var named_path = explicit_flag_package(scratch, "flag-named");
+        var resolved_path = explicit_flag_package(scratch, "flag-resolved");
+
+        var to_install = new HashSet<Usm.CachedPackage>();
+        to_install.add(new Usm.CachedPackage(named_path));
+        to_install.add(new Usm.CachedPackage(resolved_path));
+
+        var explicit_packages = new Vector<string>();
+        explicit_packages.add("flag-named");
+
+        var transaction = new Usm.Transaction() {
+            paths = scratch_paths(scratch),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = to_install,
+            to_remove = new HashSet<Usm.CachedPackage>(),
+            state = state,
+            explicit_packages = explicit_packages
+        };
+        transaction.run();
+
+        check(new Usm.CachedPackage(named_path).get_origin_information().explicitly_installed,
+            "a package named in explicit_packages installs with the explicit mark");
+        check(!new Usm.CachedPackage(resolved_path).get_origin_information().explicitly_installed,
+            "a package only the resolution pulled in installs without the explicit mark");
+
+        var rebuild = new Usm.Transaction() {
+            paths = scratch_paths(scratch),
+            resource_finder = new Usm.ResourceFinder(),
+            to_install = new HashSet<Usm.CachedPackage>(),
+            to_remove = new HashSet<Usm.CachedPackage>(),
+            state = state
+        };
+        rebuild.rebuild_package(new Usm.CachedPackage(named_path));
+        check(new Usm.CachedPackage(named_path).get_origin_information().explicitly_installed,
+            "a rebuild of the same cache directory keeps the explicit mark");
+
+        Environment.set_current_dir(original_dir);
+        Usm.Util.delete_tree(scratch);
+    }
+
     // ---- build archive restoration + clean retry ---------------------------------
 
     /** Manifest for a build-only package: an executable build script, no provides. */
@@ -1234,6 +1337,15 @@ exit 1
             print("FAIL rebuild planning test threw: %s\n", e.message);
         }
 
+        try {
+            test_origin_information_explicit_install();
+            test_transaction_records_explicit_flags();
+        }
+        catch(Error e) {
+            failures++;
+            print("FAIL explicit install tracking test threw: %s\n", e.message);
+        }
+
         try {
             test_build_archive_restore_and_clean_retry();
             test_build_archive_restored_incrementally();