Преглед на файлове

feat: multi-package update/remove; pre-download confirmation with download-size summary; cache-hit skip

clanker преди 1 седмица
родител
ревизия
c8fb7e6c27
променени са 6 файла, в които са добавени 229 реда и са изтрити 75 реда
  1. 1 1
      src/cli/Cli.vala
  2. 92 13
      src/cli/Install.vala
  3. 20 18
      src/cli/Remove.vala
  4. 66 43
      src/cli/Update.vala
  5. 13 0
      src/lib/State/CachedPackage.vala
  6. 37 0
      src/lib/TransactionSummary.vala

+ 1 - 1
src/cli/Cli.vala

@@ -121,7 +121,7 @@ public static int main(string[] args) {
 }
 
 private void usage() {
-    printerr("USAGE:\n\tusm manifest\n\tusm info\n\tusm repository\n\tusm install [-y|--yes] <packages>\n\tusm update [-y|--yes] [<package>]\n\tusm remove [-y|--yes] <package>\n\tusm downgrade [-y|--yes] <package> [<version>]\n\tusm rebuild <package>\n\tusm clean [lists|builds|sources|all]\n\tusm clean [build|source] <package>\n\tusm search <query>\n\tusm provides <resource>\n\tusm add-repo <url-or-path>\n\tusm deploy\n\tusm scaffold\n\tusm genconfig\n\tusm enroll [-y|--yes] [--state-path <path>]\n");
+    printerr("USAGE:\n\tusm manifest\n\tusm info\n\tusm repository\n\tusm install [-y|--yes] <packages>\n\tusm update [-y|--yes] [<packages>]\n\tusm remove [-y|--yes] <packages>\n\tusm downgrade [-y|--yes] <package> [<version>]\n\tusm rebuild <package>\n\tusm clean [lists|builds|sources|all]\n\tusm clean [build|source] <package>\n\tusm search <query>\n\tusm provides <resource>\n\tusm add-repo <url-or-path>\n\tusm deploy\n\tusm scaffold\n\tusm genconfig\n\tusm enroll [-y|--yes] [--state-path <path>]\n");
 }
 
 

+ 92 - 13
src/cli/Install.vala

@@ -71,10 +71,15 @@ private int install_main(string[] args) {
             return 240;
         }
 
-        // Download every repository-backed package in install order; supplied
-        // cache packages are used in place and need no download, so they only
-        // need collecting into the transaction set from their cache directories
+        // Build the transaction set WITHOUT downloading: repository
+        // packages join as their (possibly not-yet-existing) cache
+        // directories carrying the listing manifest, so the plan can be
+        // confirmed before a single byte moves. Cache-supplied packages
+        // are used in place from their cache directories.
         var cached_packages = new HashSet<Usm.CachedPackage>();
+        uint download_count = 0;
+        int64 download_bytes = 0;
+        var download_sizes_known = true;
         foreach(var package in resolution.install_order) {
             if(package.repository == null) {
                 if(package.package_path != null) {
@@ -82,17 +87,21 @@ private int install_main(string[] args) {
                 }
                 continue;
             }
-            var client = package.repository.get_client(state.config);
             var path = state.generate_cache_path(package.manifest);
-            var cache_dir = File.new_for_path(path);
-            if(!cache_dir.query_exists()) {
-                cache_dir.make_directory();
+            cached_packages.add(new Usm.CachedPackage(path) {
+                known_manifest = package.manifest
+            });
+            if(File.new_for_path(Path.build_filename(path, "package.usmc")).query_exists()) {
+                continue;
+            }
+            download_count++;
+            var size = install_package_download_size(package.repository, package.repository_entry);
+            if(size == null) {
+                download_sizes_known = false;
+            }
+            else {
+                download_bytes += size;
             }
-            var package_path = Path.build_filename(path, "package.usmc");
-            client.download_package(package_path, package.repository_entry, (f, c, t) => printerr(@"Downloading $f $c/$t bytes\r"));
-            client.verify_package(package_path, package.repository_entry, (f, c, t) => printerr(@"Verifying $f $c/$t bytes\r"));
-            cached_packages.add(new Usm.CachedPackage(path));
-            printerr("\n");
         }
 
         var install_order = new Vector<string>();
@@ -122,13 +131,18 @@ private int install_main(string[] args) {
 
         var summary = new Usm.TransactionSummary() {
             to_install = cached_packages.to_vector(),
-            to_rebuild = transaction.rebuilds
+            to_rebuild = transaction.rebuilds,
+            download_count = download_count,
+            download_bytes = download_sizes_known ? download_bytes : -1
         };
         if(!Usm.Cli.confirm_transaction(summary, assume_yes)) {
             print("Aborted.\n");
             return 1;
         }
 
+        // Only now that the plan is confirmed do the packages transfer
+        install_download_packages(state, resolution, new HashSet<string>());
+
         // Chosen system packages install first, as one transaction
         if(resolution.system_packages.any()) {
             if(!install_system_packages(spm, resolution.system_packages)) {
@@ -152,6 +166,71 @@ private int install_main(string[] args) {
     return 0;
 }
 
+/**
+ * Downloads every repository-backed package of {@link resolution}'s
+ * install order that is not already cached — creating its cache
+ * directory, downloading and checksum-verifying the .usmc. Names in
+ * {@link skip_names} are left alone entirely (update uses this for
+ * dependencies already installed at the resolved version); a cached
+ * package.usmc is never re-transferred. Called only after the plan is
+ * confirmed, so a decline downloads nothing.
+ */
+private void install_download_packages(Usm.SystemState state, Usm.ResolutionResult resolution, HashSet<string> skip_names) throws Error {
+    foreach(var package in resolution.install_order) {
+        if(package.repository == null || skip_names.contains(package.manifest.name)) {
+            continue;
+        }
+        var path = state.generate_cache_path(package.manifest);
+        if(File.new_for_path(Path.build_filename(path, "package.usmc")).query_exists()) {
+            continue;
+        }
+        var client = package.repository.get_client(state.config);
+        var cache_dir = File.new_for_path(path);
+        if(!cache_dir.query_exists()) {
+            cache_dir.make_directory();
+        }
+        var package_path = Path.build_filename(path, "package.usmc");
+        client.download_package(package_path, package.repository_entry, (f, c, t) => printerr(@"Downloading $f $c/$t bytes\r"));
+        client.verify_package(package_path, package.repository_entry, (f, c, t) => printerr(@"Verifying $f $c/$t bytes\r"));
+        printerr("\n");
+    }
+}
+
+/**
+ * The download size in bytes of {@link entry} from {@link repository},
+ * or null when it cannot be determined: `file` repositories stat the
+ * package, remote ones read Content-Length off a curl HEAD request (the
+ * last header wins, so redirects resolve to the final transfer). Used
+ * to show a plan's total download size before any transfer starts.
+ */
+private int64? install_package_download_size(Usm.Repository repository, Usm.RepositoryListingEntry entry) {
+    var uri = repository.url.has_suffix("/") ? repository.url + entry.path : repository.url + "/" + entry.path;
+    try {
+        var scheme = Uri.parse(uri, UriFlags.NONE).get_scheme();
+        if(scheme == "file") {
+            return File.new_for_uri(uri).query_info(FileAttribute.STANDARD_SIZE, FileQueryInfoFlags.NONE).get_size();
+        }
+
+        var proc = new Subprocess.newv(new string[] { "curl", "-sIL", "-m", "10", uri }, SubprocessFlags.STDOUT_PIPE);
+        var output = new DataInputStream(proc.get_stdout_pipe());
+        string? size_text = null;
+        string line;
+        while((line = output.read_line()) != null) {
+            if(line.down().has_prefix("content-length:")) {
+                size_text = line.split(":", 2)[1].strip();
+            }
+        }
+        proc.wait();
+        if(proc.get_successful() && size_text != null) {
+            return int64.parse(size_text);
+        }
+    }
+    catch(Error e) {
+        return null;
+    }
+    return null;
+}
+
 /**
  * Installs the resolution's chosen system packages as one transaction,
  * surfacing progress through the manager's {@link Usm.InstallProgressDelegate}

+ 20 - 18
src/cli/Remove.vala

@@ -2,10 +2,11 @@ using Invercargill;
 using Invercargill.DataStructures;
 
 /**
- * `usm remove [-y|--yes] <package>` — remove an installed package and,
- * by cascade, every installed package that transitively depends on it
- * (discovered through {@link Usm.SystemState.find_dependant_names}, so
- * the summary shows the full closure before anything happens).
+ * `usm remove [-y|--yes] <packages>` — remove installed packages and,
+ * by cascade, every installed package that transitively depends on any
+ * of them (discovered through {@link Usm.SystemState.find_dependant_names},
+ * so the summary shows the full closure before anything happens). All
+ * requested cascades merge into one plan and one confirm.
  *
  * The plan then gains every orphaned dependency: a remaining package
  * installed implicitly ({@link Usm.OriginInformation.explicitly_installed}
@@ -21,7 +22,7 @@ using Invercargill.DataStructures;
 private int remove_main(string[] args) {
 
     var assume_yes = false;
-    string? package_name = null;
+    var package_names = new Vector<string>();
     for(int i = 2; i < args.length; i++) {
         var argument = args[i];
         if(argument == "-y" || argument == "--yes") {
@@ -31,14 +32,11 @@ private int remove_main(string[] args) {
             printerr(@"Unknown option \"$argument\"\n");
             return remove_usage();
         }
-        else if(package_name != null) {
-            return remove_usage();
-        }
         else {
-            package_name = argument;
+            package_names.add(argument);
         }
     }
-    if(package_name == null) {
+    if(package_names.length == 0) {
         return remove_usage();
     }
 
@@ -52,19 +50,23 @@ private int remove_main(string[] args) {
     }
 
     try {
-        var target = state.find_installed(package_name);
-        if(target == null) {
-            printerr(@"No installed package named \"$package_name\"\n");
-            return 254;
-        }
-
         var by_package_name = new Dictionary<string, Usm.CachedPackage>();
         foreach(var installed in state.get_installed_packages()) {
             by_package_name.set(installed.package_name, state.resolve_installed(installed));
         }
 
+        // One shared visited set across every requested target, so a
+        // package reached from two cascades is removed once
         var closure = new Vector<Usm.CachedPackage>();
-        remove_collect_cascade(state, by_package_name, new HashSet<string>(), closure, target);
+        var visited = new HashSet<string>();
+        foreach(var package_name in package_names) {
+            var target = state.find_installed(package_name);
+            if(target == null) {
+                printerr(@"No installed package named \"$package_name\"\n");
+                return 254;
+            }
+            remove_collect_cascade(state, by_package_name, visited, closure, target);
+        }
 
         var orphans = remove_collect_orphans(by_package_name, closure);
 
@@ -231,6 +233,6 @@ private bool remove_explicitly_installed(Usm.CachedPackage package) {
 }
 
 private int remove_usage() {
-    printerr("USAGE:\n\tusm remove [-y|--yes] <package>\n");
+    printerr("USAGE:\n\tusm remove [-y|--yes] <packages>\n");
     return 255;
 }

+ 66 - 43
src/cli/Update.vala

@@ -2,13 +2,14 @@ using Invercargill;
 using Invercargill.DataStructures;
 
 /**
- * `usm update [-y|--yes] [<package>]` — bring installed packages up to
+ * `usm update [-y|--yes] [<packages>]` — bring installed packages up to
  * the newest version their repositories offer.
  *
- * Without a package name every installed package with a newer repository
- * version becomes a resolution root — one resolution, one
- * {@link Usm.TransactionSummary}, one confirm. With a package name only
- * that package updates (an already-current package reports "up to date").
+ * Without package names every installed package with a newer repository
+ * version becomes a resolution root; with names exactly those packages
+ * do (already-current names are skipped with a note). Either way there
+ * is one resolution, one {@link Usm.TransactionSummary}, one confirm —
+ * and the downloads happen only once the plan is accepted.
  *
  * Dependencies already installed at the resolved version are left alone;
  * an installed package moving to a different version is removed
@@ -19,7 +20,7 @@ using Invercargill.DataStructures;
 private int update_main(string[] args) {
 
     var assume_yes = false;
-    string? package_name = null;
+    var package_names = new Vector<string>();
     for(int i = 2; i < args.length; i++) {
         var argument = args[i];
         if(argument == "-y" || argument == "--yes") {
@@ -29,11 +30,8 @@ private int update_main(string[] args) {
             printerr(@"Unknown option \"$argument\"\n");
             return update_usage();
         }
-        else if(package_name != null) {
-            return update_usage();
-        }
         else {
-            package_name = argument;
+            package_names.add(argument);
         }
     }
 
@@ -75,24 +73,29 @@ private int update_main(string[] args) {
         }
 
         var roots = new Vector<Usm.AbstractPackage>();
-        if(package_name != null) {
-            Usm.CachedPackage installed;
-            if(!installed_by_name.try_get(package_name, out installed)) {
-                printerr(@"No installed package named \"$package_name\"\n");
-                return 254;
-            }
-            var target = resolver.find_package(package_name);
-            if(target == null) {
-                printerr(@"No package named \"$package_name\" found in any repository or the cache\n");
-                return 254;
+        if(package_names.length > 0) {
+            foreach(var package_name in package_names) {
+                Usm.CachedPackage installed;
+                if(!installed_by_name.try_get(package_name, out installed)) {
+                    printerr(@"No installed package named \"$package_name\"\n");
+                    return 254;
+                }
+                var target = resolver.find_package(package_name);
+                if(target == null) {
+                    printerr(@"No package named \"$package_name\" found in any repository or the cache\n");
+                    return 254;
+                }
+                Usm.Version installed_version;
+                installed_versions.try_get(package_name, out installed_version);
+                if(!target.manifest.version.greater_than(installed_version)) {
+                    print(@"\"$package_name\" is already up to date.\n");
+                    continue;
+                }
+                roots.add(target);
             }
-            Usm.Version installed_version;
-            installed_versions.try_get(package_name, out installed_version);
-            if(!target.manifest.version.greater_than(installed_version)) {
-                print(@"\"$package_name\" is already up to date.\n");
+            if(roots.length == 0) {
                 return 0;
             }
-            roots.add(target);
         }
         else {
             foreach(var pair in installed_by_name) {
@@ -117,15 +120,26 @@ private int update_main(string[] args) {
             return 240;
         }
 
-        // Download and collect every changed package; a dependency already
-        // installed at the resolved version stays as-is, so an update plan
-        // names only what actually moves
-        var cached_packages = new HashSet<Usm.CachedPackage>();
+        // Build the transaction set WITHOUT downloading: dependencies
+        // already installed at the resolved version stay as-is (so an
+        // update plan names only what actually moves), and repository
+        // packages join as their cache directories carrying the listing
+        // manifest — the plan is confirmed before a single byte moves
+        var current_names = new HashSet<string>();
         foreach(var package in resolution.install_order) {
             Usm.Version installed_version;
-            var already_current = installed_versions.try_get(package.manifest.name, out installed_version)
-                && installed_version.compare(package.manifest.version) == 0;
-            if(already_current) {
+            if(installed_versions.try_get(package.manifest.name, out installed_version)
+                    && installed_version.compare(package.manifest.version) == 0) {
+                current_names.add(package.manifest.name);
+            }
+        }
+
+        var cached_packages = new HashSet<Usm.CachedPackage>();
+        uint download_count = 0;
+        int64 download_bytes = 0;
+        var download_sizes_known = true;
+        foreach(var package in resolution.install_order) {
+            if(current_names.contains(package.manifest.name)) {
                 continue;
             }
             if(package.repository == null) {
@@ -134,17 +148,21 @@ private int update_main(string[] args) {
                 }
                 continue;
             }
-            var client = package.repository.get_client(state.config);
             var path = state.generate_cache_path(package.manifest);
-            var cache_dir = File.new_for_path(path);
-            if(!cache_dir.query_exists()) {
-                cache_dir.make_directory();
+            cached_packages.add(new Usm.CachedPackage(path) {
+                known_manifest = package.manifest
+            });
+            if(File.new_for_path(Path.build_filename(path, "package.usmc")).query_exists()) {
+                continue;
+            }
+            download_count++;
+            var size = install_package_download_size(package.repository, package.repository_entry);
+            if(size == null) {
+                download_sizes_known = false;
+            }
+            else {
+                download_bytes += size;
             }
-            var package_path = Path.build_filename(path, "package.usmc");
-            client.download_package(package_path, package.repository_entry, (f, c, t) => printerr(@"Downloading $f $c/$t bytes\r"));
-            client.verify_package(package_path, package.repository_entry, (f, c, t) => printerr(@"Verifying $f $c/$t bytes\r"));
-            cached_packages.add(new Usm.CachedPackage(path));
-            printerr("\n");
         }
 
         // The installed versions being replaced by a different version
@@ -195,13 +213,18 @@ private int update_main(string[] args) {
         var summary = new Usm.TransactionSummary() {
             to_install = cached_packages.to_vector(),
             to_remove = to_remove.to_vector(),
-            to_rebuild = transaction.rebuilds
+            to_rebuild = transaction.rebuilds,
+            download_count = download_count,
+            download_bytes = download_sizes_known ? download_bytes : -1
         };
         if(!Usm.Cli.confirm_transaction(summary, assume_yes)) {
             print("Aborted.\n");
             return 1;
         }
 
+        // Only now that the plan is confirmed do the packages transfer
+        install_download_packages(state, resolution, current_names);
+
         if(resolution.system_packages.any()) {
             if(!install_system_packages(spm, resolution.system_packages)) {
                 return 239;
@@ -221,6 +244,6 @@ private int update_main(string[] args) {
 }
 
 private int update_usage() {
-    printerr("USAGE:\n\tusm update [-y|--yes] [<package>]\n");
+    printerr("USAGE:\n\tusm update [-y|--yes] [<packages>]\n");
     return 255;
 }

+ 13 - 0
src/lib/State/CachedPackage.vala

@@ -8,6 +8,16 @@ namespace Usm {
         public string package_path { get; private set; }
         public string package_name { get; private set; }
 
+        /**
+         * The package's manifest when already known without reading the
+         * cached .usmc — set from a repository listing entry so a plan
+         * can be described, confirmed and strategised before the package
+         * is downloaded. Null (the default) reads {@link package_path}
+         * as usual; execution itself always works from the extracted
+         * sources' own MANIFEST.usm.
+         */
+        public Manifest? known_manifest { get; set; }
+
         public CachedPackage(string path) {
             this.state_path = path;
             this.package_path = Path.build_filename(path, "package.usmc");
@@ -15,6 +25,9 @@ namespace Usm {
         }
 
         public Manifest get_manifest() throws Error {
+            if(known_manifest != null) {
+                return known_manifest;
+            }
             return new Manifest.from_package(package_path);
         }
 

+ 37 - 0
src/lib/TransactionSummary.vala

@@ -29,6 +29,22 @@ namespace Usm {
         public Vector<RebuildEntry> to_rebuild { get; set; default = new Vector<RebuildEntry>(); }
         /** Version downgrades; each entry replaces {@link DowngradeEntry.current} with {@link DowngradeEntry.target}. */
         public Vector<DowngradeEntry> to_downgrade { get; set; default = new Vector<DowngradeEntry>(); }
+        /**
+         * How many packages must be downloaded from a repository before
+         * the transaction can run (packages already in the local cache
+         * do not count); zero when everything is cached or only local
+         * packages are involved.
+         */
+        public uint download_count { get; set; default = 0; }
+        /**
+         * Total size in bytes of those downloads, or -1 when any size
+         * is unknown (a server that answers no Content-Length, for
+         * example); meaningless when {@link download_count} is zero.
+         */
+        public int64 download_bytes { get; set; default = -1; }
+
+        /** Byte units for {@link format_byte_size}, ascending. */
+        private const string[] BYTE_UNITS = { "B", "KiB", "MiB", "GiB", "TiB" };
 
         /** Whether the summary describes no action at all. */
         public bool is_empty() {
@@ -88,12 +104,33 @@ namespace Usm {
                     summary_name(entry.trigger), summary_version(entry.trigger));
             }
 
+            if(download_count > 0) {
+                if(download_bytes >= 0) {
+                    builder.append_printf("  %u package(s) to download, %s total\n", download_count, format_byte_size(download_bytes));
+                }
+                else {
+                    builder.append_printf("  %u package(s) to download, size unknown\n", download_count);
+                }
+            }
+
             if(builder.str.has_suffix("\n")) {
                 builder.truncate(builder.len - 1);
             }
             return builder.str;
         }
 
+        /** Human-readable byte size, for example `24.5 MiB` or `512 B`. */
+        private static string format_byte_size(int64 bytes) {
+            double size = bytes;
+            foreach(var unit in BYTE_UNITS) {
+                if(size < 1024.0 || unit == BYTE_UNITS[BYTE_UNITS.length - 1]) {
+                    return unit == "B" ? @"$bytes B" : "%.1f %s".printf(size, unit);
+                }
+                size /= 1024.0;
+            }
+            assert_not_reached();
+        }
+
         /** The manifest name of {@link package}, falling back to its cache directory name when unreadable. */
         private static string summary_name(CachedPackage package) {
             try {