diff --git a/README.md b/README.md index da22cf2..429828e 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ are in [docs/packages.md](docs/packages.md). ``` syspi init [-C dir] [-publisher P] [-name N] [-version V] write .project.json and .meta.json syspi add [@] [] add a usage, resolve, install, lock +syspi remove ... drop a usage, re-lock, remove what nothing else needs from lib/ syspi install | sync resolve .project.json + lock, install what is missing or changed syspi lock write .project.lock.json without installing syspi bundle [-o out.kpar] [-tag T] deterministic .kpar; checks tag == version in a tagged checkout diff --git a/changes/unreleased/remove.added.md b/changes/unreleased/remove.added.md new file mode 100644 index 0000000..c1e89e0 --- /dev/null +++ b/changes/unreleased/remove.added.md @@ -0,0 +1 @@ +- `syspi remove …` drops a usage from `.project.json` (matched by its canonical identity, as `add` matches), re-resolves and re-locks what remains, and removes from `lib/` only the installed copies no remaining package of the closure needs — never a directory the lock did not place there, never one whose contents no longer match the lock. A package another locked package still uses stays, and the message names who uses it. diff --git a/cmd/syspi/main.go b/cmd/syspi/main.go index 2ee2eb2..33ee88f 100644 --- a/cmd/syspi/main.go +++ b/cmd/syspi/main.go @@ -33,6 +33,7 @@ Usage: Commands: init write .project.json and .meta.json for a new package add add a usage to .project.json, then resolve, install and lock + remove drop a usage from .project.json, re-lock, remove what nothing else needs from lib/ install resolve .project.json and the lock, install what is missing or changed sync same as install lock resolve and write .project.lock.json without installing @@ -65,6 +66,8 @@ func run(args []string, stdout, stderr io.Writer) int { err = cmdInit(args[1:], stdout, stderr) case "add": err = cmdAdd(ctx, args[1:], stdout, stderr) + case "remove": + err = cmdRemove(ctx, args[1:], stdout, stderr) case "install", "sync": err = cmdInstall(ctx, args[0], args[1:], stdout, stderr, false) case "lock": @@ -240,6 +243,31 @@ func cmdAdd(ctx context.Context, args []string, stdout, stderr io.Writer) error return refreshMeta(c.dir) } +func cmdRemove(ctx context.Context, args []string, stdout, stderr io.Writer) error { + var c commonFlags + fs := newFlagSet("remove", stderr, "[flags] ...") + c.bind(fs) + if err := parse(fs, args); err != nil { + return err + } + if fs.NArg() < 1 { + fs.Usage() + return errUsage + } + res, err := install.Remove(ctx, c.options(stderr, false), fs.Args()) + if err != nil { + return err + } + for _, u := range res.RemovedUsages { + fmt.Fprintf(stdout, "removed usage %s\n", u) + } + for _, p := range res.Pruned { + fmt.Fprintf(stdout, "removed %s\n", p) + } + report(stdout, res) + return refreshMeta(c.dir) +} + func cmdInstall(ctx context.Context, name string, args []string, stdout, stderr io.Writer, lockOnly bool) error { var c commonFlags fs := newFlagSet(name, stderr, "[flags]") diff --git a/docs/packages.md b/docs/packages.md index 09daf1a..39c146b 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -144,6 +144,13 @@ lib/ The directory name is `-_` (`_` without a publisher), the convention OpenSysML reads. +`syspi remove …` drops the usage with that canonical identity from `.project.json`, +re-resolves and re-locks the remaining usages, and then removes from `lib/` each copy the previous +lock had placed there that the new lock no longer claims — only when its contents still match the +lock entry that installed it (the tree hash or the `.kpar` digest). A directory or archive the +lock never placed under `lib/`, or one whose contents were edited, is left alone and said so. A +package another locked package still uses stays locked and installed. + ## Bundle reproducibility `syspi bundle` writes members sorted with fixed 1980-01-01 ZIP timestamps and regenerates diff --git a/internal/digest/digest.go b/internal/digest/digest.go index 03f88ee..3ac782b 100644 --- a/internal/digest/digest.go +++ b/internal/digest/digest.go @@ -109,3 +109,25 @@ func File(path string) (string, error) { } return hex.EncodeToString(h.Sum(nil)), nil } + +// MetadataDirs lists, relative to root and sorted, the repository metadata +// directories (.git, .hg, .svn) under root that Tree leaves out of a digest. +func MetadataDirs(root string) ([]string, error) { + var out []string + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() && p != root && metadataDirs[d.Name()] { + rel, err := filepath.Rel(root, p) + if err != nil { + return err + } + out = append(out, filepath.ToSlash(rel)) + return filepath.SkipDir + } + return nil + }) + sort.Strings(out) + return out, err +} diff --git a/internal/install/install.go b/internal/install/install.go index 90d05ff..9b901e0 100644 --- a/internal/install/install.go +++ b/internal/install/install.go @@ -38,6 +38,9 @@ type Options struct { HTTP *http.Client // LockOnly resolves and writes the lock without installing anything. LockOnly bool + // Prune removes from lib/ the installed copies the previous lock placed + // there that the new lock no longer claims (see Remove). + Prune bool } // Result reports what a run did. @@ -47,6 +50,10 @@ type Result struct { Installed, Reused, Skipped []string // LockWritten reports whether .project.lock.json changed on disk. LockWritten bool + // RemovedUsages are the usages Remove dropped from .project.json, as written there. + RemovedUsages []string + // Pruned lists the lib/ paths (relative to Root) a pruning run removed. + Pruned []string } type request struct { @@ -152,6 +159,9 @@ func Run(ctx context.Context, opts Options) (*Result, error) { for _, b := range backups { os.RemoveAll(b.src) } + if opts.Prune { + r.prune() + } return r.res, nil } diff --git a/internal/install/libpath.go b/internal/install/libpath.go new file mode 100644 index 0000000..aac9cf4 --- /dev/null +++ b/internal/install/libpath.go @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: Apache-2.0 + +package install + +import ( + "path/filepath" + "strings" + + "github.com/Open-MBEE/SysPI/internal/digest" + "github.com/Open-MBEE/SysPI/internal/lock" + "github.com/Open-MBEE/SysPI/internal/project" +) + +// libPath is where under libDir the lock entry's installed copy lives: the +// exported tree of a vcs entry, the .kpar of an archive entry; "" for a +// package that is never installed (a local directory, a standard library) +// or whose name would not be a direct child of libDir. +func libPath(libDir string, e *lock.Entry) string { + stem := (&project.Project{Name: e.Name, Publisher: e.Publisher, Version: e.Version}).InstallName() + if stem == "." || stem == ".." || strings.ContainsAny(stem, `/\`) { + return "" + } + var p string + switch e.Kind { + case lock.KindVCS: + p = filepath.Join(libDir, stem) + case lock.KindArchive: + p = filepath.Join(libDir, stem+project.ArchiveExt) + default: + return "" + } + if filepath.Dir(p) != filepath.Clean(libDir) { + return "" + } + return p +} + +// installedMatches reports whether the copy at p is the one the lock entry +// describes: the locked tree digest, or the locked .kpar digest. +func installedMatches(p string, e *lock.Entry) bool { + if e.Kind == lock.KindArchive { + cur, err := digest.File(p) + return err == nil && cur == e.SHA256 + } + return installedTreeMatches(p, e) +} diff --git a/internal/install/remove.go b/internal/install/remove.go new file mode 100644 index 0000000..6b01665 --- /dev/null +++ b/internal/install/remove.go @@ -0,0 +1,182 @@ +// SPDX-License-Identifier: Apache-2.0 + +package install + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/Open-MBEE/SysPI/internal/digest" + "github.com/Open-MBEE/SysPI/internal/lock" + "github.com/Open-MBEE/SysPI/internal/origin" + "github.com/Open-MBEE/SysPI/internal/project" +) + +// Remove drops the usages naming resources from .project.json, re-resolves +// and re-locks what remains, and removes from lib/ only the installed copies +// no remaining package of the closure needs. A failing run leaves +// .project.json, the lock and lib/ as they were. +func Remove(ctx context.Context, opts Options, resources []string) (*Result, error) { + root, err := filepath.Abs(opts.Root) + if err != nil { + return nil, err + } + opts.Root = root + if opts.Log == nil { + opts.Log = io.Discard + } + proj, err := project.ReadProject(root) + if err != nil { + return nil, err + } + data, err := os.ReadFile(filepath.Join(root, project.ProjectFile)) + if err != nil { + return nil, err + } + var removed []string + for _, res := range resources { + o, err := origin.Parse(res) + if err != nil { + return nil, err + } + i := usageIndex(proj, o.Identity()) + if i < 0 { + return nil, fmt.Errorf("%s: no usage of %s%s", project.ProjectFile, o.Identity(), lockedBy(root, o)) + } + removed = append(removed, proj.Usage[i].Resource) + proj.Usage = append(proj.Usage[:i], proj.Usage[i+1:]...) + } + if err := proj.Write(root); err != nil { + return nil, err + } + opts.Prune = true + res, err := Run(ctx, opts) + if err != nil { + os.WriteFile(filepath.Join(root, project.ProjectFile), data, 0o644) + return nil, err + } + res.RemovedUsages = removed + for _, u := range removed { + if o, err := origin.Parse(u); err == nil { + if by := requesters(res.Lock, o); len(by) > 0 { + fmt.Fprintf(opts.Log, "%s stays locked: used by %s\n", o.Identity(), joinSorted(by)) + } + } + } + return res, nil +} + +// usageIndex finds the usage with the canonical identity, as add matches it. +func usageIndex(p *project.Project, id string) int { + for i, u := range p.Usage { + if uo, err := origin.Parse(u.Resource); err == nil && uo.Identity() == id { + return i + } + } + return -1 +} + +// lockedBy explains a resource that is not a usage but is in the lock +// because other packages use it. +func lockedBy(root string, o *origin.Origin) string { + l, ok, err := lock.Read(root, project.LockFile) + if err != nil || !ok { + return "" + } + if by := requesters(l, o); len(by) > 0 { + return "; it is locked because " + joinSorted(by) + " uses it" + } + return "" +} + +// requesters names the locked packages whose declared usages name o. +func requesters(l *lock.Lock, o *origin.Origin) []string { + key := o.Key() + var by []string + for _, e := range l.Packages { + for _, u := range e.Usage { + if uo, err := origin.Parse(u.Resource); err == nil && uo.Key() == key { + by = append(by, e.Resource) + break + } + } + } + return by +} + +func joinSorted(s []string) string { + sort.Strings(s) + out := "" + for i, x := range s { + if i > 0 { + out += ", " + } + out += x + } + return out +} + +// prune removes the installed copies the previous lock placed under lib/ +// that the fresh lock no longer claims — only when their contents still +// match the entry that put them there; anything else under lib/ is kept. +// It runs after the new lock is written, so a copy it cannot remove is +// only a leftover the next pruning run removes, never an inconsistency. +func (r *resolver) prune() { + claimed := map[string]bool{} + var local []string + for i := range r.fresh.Packages { + e := &r.fresh.Packages[i] + if p := libPath(r.libDir, e); p != "" { + claimed[p] = true + } + if e.Kind == lock.KindLocal { + if abs, err := filepath.Abs(e.Source); err == nil { + local = append(local, abs) + } + } + } + for i := range r.old.Packages { + e := &r.old.Packages[i] + p := libPath(r.libDir, e) + if p == "" || claimed[p] { + continue + } + if _, err := os.Lstat(p); err != nil { + continue + } + if !installedMatches(p, e) { + r.logf("kept %s: its contents differ from the lock entry that installed it", rel(r.opts.Root, p)) + continue + } + if by := localWithin(p, local); by != "" { + r.logf("kept %s: the local directory %s, still locked, is inside it", rel(r.opts.Root, p), by) + continue + } + if dirs, err := digest.MetadataDirs(p); err != nil || len(dirs) > 0 { + r.logf("kept %s: it holds repository metadata (%s) the lock does not record", rel(r.opts.Root, p), strings.Join(dirs, ", ")) + continue + } + if err := os.RemoveAll(p); err != nil { + r.logf("kept %s (%s %s is no longer used): %v", rel(r.opts.Root, p), e.Name, e.Version, err) + continue + } + r.logf("%s %s is no longer used; %s removed", e.Name, e.Version, rel(r.opts.Root, p)) + r.res.Pruned = append(r.res.Pruned, rel(r.opts.Root, p)) + } + sort.Strings(r.res.Pruned) +} + +// localWithin names the first of the local directories that is p or lies under it. +func localWithin(p string, local []string) string { + for _, l := range local { + if r, err := filepath.Rel(p, l); err == nil && r != ".." && !strings.HasPrefix(r, ".."+string(filepath.Separator)) { + return l + } + } + return "" +} diff --git a/internal/install/remove_test.go b/internal/install/remove_test.go new file mode 100644 index 0000000..a72eb9d --- /dev/null +++ b/internal/install/remove_test.go @@ -0,0 +1,193 @@ +// SPDX-License-Identifier: Apache-2.0 + +package install + +import ( + "bytes" + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Open-MBEE/SysPI/internal/lock" + "github.com/Open-MBEE/SysPI/internal/project" +) + +func remove(t *testing.T, dir string, opts Options, resources ...string) (*Result, string) { + t.Helper() + var log bytes.Buffer + opts.Root = dir + opts.Log = &log + res, err := Remove(context.Background(), opts, resources) + if err != nil { + t.Fatalf("Remove: %v\n%s", err, log.String()) + } + return res, log.String() +} + +func libNames(t *testing.T, dir string) string { + t.Helper() + entries, _ := os.ReadDir(filepath.Join(dir, project.LibDir)) + var names []string + for _, e := range entries { + names = append(names, e.Name()) + } + return strings.Join(names, ",") +} + +func TestRemove(t *testing.T) { + widgets, base := fixture(t) + wres := "git+file://" + filepath.ToSlash(widgets) + bres := "git+file://" + filepath.ToSlash(base) + dir := consumer(t, project.Usage{Resource: wres, VersionConstraint: "~1.1.0"}, project.Usage{Resource: bres, VersionConstraint: "^2.1.0"}) + run(t, dir, Options{}) + // A directory the user put under lib/ is never touched. + write(t, dir, "lib/mine_1.0.0/.project.json", `{"name":"mine","version":"1.0.0"}`+"\n") + if got := libNames(t, dir); got != "acme-widgets_1.1.0,base_2.1.0,mine_1.0.0" { + t.Fatalf("lib/ before: %s", got) + } + + // base is still used by widgets: the usage goes, the package stays locked and installed. + res, log := remove(t, dir, Options{Offline: true}, bres) + if len(res.RemovedUsages) != 1 || res.RemovedUsages[0] != bres || len(res.Pruned) != 0 { + t.Fatalf("remove base: %+v\n%s", res, log) + } + if !strings.Contains(log, "stays locked: used by "+ident(t, wres)) { + t.Fatalf("log lacks the requester:\n%s", log) + } + p, _ := project.ReadProject(dir) + if len(p.Usage) != 1 || p.Usage[0].Resource != wres { + t.Fatalf("usages after: %+v", p.Usage) + } + lk, _, _ := lock.Read(dir, project.LockFile) + if lk.Find(ident(t, bres)) == nil || len(lk.Packages) != 2 { + t.Fatalf("lock after removing base:\n%s", lk.Marshal()) + } + // Removing it again: it is no usage, but the message says who locks it. + _, err := Remove(context.Background(), Options{Root: dir, Offline: true}, []string{bres}) + if err == nil || !strings.Contains(err.Error(), "no usage of "+ident(t, bres)) || !strings.Contains(err.Error(), "locked because "+ident(t, wres)+" uses it") { + t.Fatalf("remove of a transitive package: %v", err) + } + before, _ := os.ReadFile(filepath.Join(dir, project.ProjectFile)) + // A failing remove (the usage is unknown) leaves .project.json alone. + if _, err := Remove(context.Background(), Options{Root: dir, Offline: true}, []string{wres, "git+file:///no/such/repo"}); err == nil || !strings.Contains(err.Error(), "no usage of") { + t.Fatalf("unknown usage: %v", err) + } + if after, _ := os.ReadFile(filepath.Join(dir, project.ProjectFile)); !bytes.Equal(before, after) { + t.Fatal(".project.json changed by a failing remove") + } + if got := libNames(t, dir); got != "acme-widgets_1.1.0,base_2.1.0,mine_1.0.0" { + t.Fatalf("lib/ changed by a failing remove: %s", got) + } + + // Removing widgets removes its closure (base too), but not the user's directory, + // and not an installed copy whose contents no longer match the lock. + write(t, dir, "lib/base_2.1.0/Base.kerml", "package Edited;\n") + res, log = remove(t, dir, Options{Offline: true}, wres) + if strings.Join(res.Pruned, ",") != "lib/acme-widgets_1.1.0" || !strings.Contains(log, "kept lib/base_2.1.0: its contents differ") { + t.Fatalf("remove widgets: %+v\n%s", res, log) + } + if got := libNames(t, dir); got != "base_2.1.0,mine_1.0.0" { + t.Fatalf("lib/ after: %s", got) + } + lk, _, _ = lock.Read(dir, project.LockFile) + if len(lk.Packages) != 0 { + t.Fatalf("lock after removing everything:\n%s", lk.Marshal()) + } + p, _ = project.ReadProject(dir) + if len(p.Usage) != 0 { + t.Fatalf("usages after: %+v", p.Usage) + } +} + +func TestRemoveArchiveAndSeveralAtOnce(t *testing.T) { + widgets, _ := fixture(t) + wres := "git+file://" + filepath.ToSlash(widgets) + pkg := t.TempDir() + write(t, pkg, ".project.json", `{"name":"tools","publisher":"acme","version":"3.1.0"}`) + write(t, pkg, "Tools.sysml", "package Tools;\n") + kpar := filepath.Join(t.TempDir(), "acme-tools_3.1.0.kpar") + if _, err := project.Bundle(pkg, kpar, project.CreatedTime()); err != nil { + t.Fatal(err) + } + std := "https://www.omg.org/spec/SysML/20250201/Systems-Library.kpar" + dir := consumer(t, project.Usage{Resource: wres + "@1.0.0"}, project.Usage{Resource: kpar}, project.Usage{Resource: std}) + run(t, dir, Options{}) + if got := libNames(t, dir); got != "acme-tools_3.1.0.kpar,acme-widgets_1.0.0" { + t.Fatalf("lib/ before: %s", got) + } + res, _ := remove(t, dir, Options{}, kpar, std) + if strings.Join(res.Pruned, ",") != "lib/acme-tools_3.1.0.kpar" || len(res.RemovedUsages) != 2 { + t.Fatalf("%+v", res) + } + lk, _, _ := lock.Read(dir, project.LockFile) + if len(lk.Packages) != 1 || lk.Find(ident(t, wres)) == nil { + t.Fatalf("lock:\n%s", lk.Marshal()) + } + if got := libNames(t, dir); got != "acme-widgets_1.0.0" { + t.Fatalf("lib/ after: %s", got) + } +} + +// A lock whose names would place a copy anywhere but directly under lib/ +// names nothing prune may touch. +func TestLibPathStaysUnderLib(t *testing.T) { + lib := filepath.Join(t.TempDir(), "lib") + for _, e := range []lock.Entry{ + {Kind: lock.KindVCS, Name: "x", Version: "../../etc"}, + {Kind: lock.KindVCS, Name: "x", Version: "1.0.0/../.."}, + {Kind: lock.KindVCS, Name: "x", Version: `1.0.0\..\..`}, + {Kind: lock.KindArchive, Name: "x", Version: "a/b"}, + {Kind: lock.KindLocal, Name: "dev", Version: "0.0.1"}, + {Kind: lock.KindStandard, Name: "Systems-Library.kpar"}, + } { + if p := libPath(lib, &e); p != "" { + t.Errorf("%+v: %q", e, p) + } + } + if p := libPath(lib, &lock.Entry{Kind: lock.KindVCS, Publisher: "acme", Name: "widgets", Version: "1.0.0"}); p != filepath.Join(lib, "acme-widgets_1.0.0") { + t.Errorf("plain entry: %q", p) + } +} + +func TestRemoveKeepsWhatPruneMustNotTouch(t *testing.T) { + widgets, base := fixture(t) + wres := "git+file://" + filepath.ToSlash(widgets) + bres := "git+file://" + filepath.ToSlash(base) + + // A repository the user started inside an installed copy is not deleted with it. + dir := consumer(t, project.Usage{Resource: wres, VersionConstraint: "~1.1.0"}, project.Usage{Resource: bres, VersionConstraint: "^2.1.0"}) + run(t, dir, Options{}) + write(t, dir, "lib/acme-widgets_1.1.0/.git/HEAD", "ref: refs/heads/main\n") + res, log := remove(t, dir, Options{Offline: true}, wres) + if len(res.Pruned) != 0 || !strings.Contains(log, "kept lib/acme-widgets_1.1.0: it holds repository metadata (.git)") { + t.Fatalf("pruned %v\n%s", res.Pruned, log) + } + if _, err := os.Stat(filepath.Join(dir, "lib", "acme-widgets_1.1.0", ".git", "HEAD")); err != nil { + t.Fatal(err) + } + + // A local usage pointing inside an installed copy keeps that copy. + dir = consumer(t, project.Usage{Resource: wres, VersionConstraint: "~1.1.0"}, project.Usage{Resource: bres, VersionConstraint: "^2.1.0"}) + run(t, dir, Options{}) + inner := filepath.Join(dir, "lib", "acme-widgets_1.1.0", "packages", "gears") + p, _ := project.ReadProject(dir) + p.Usage = append(p.Usage, project.Usage{Resource: inner}) + p.Write(dir) + run(t, dir, Options{}) + res, log = remove(t, dir, Options{Offline: true}, wres) + if len(res.Pruned) != 0 || !strings.Contains(log, "kept lib/acme-widgets_1.1.0: the local directory "+inner+", still locked, is inside it") { + t.Fatalf("pruned %v\n%s", res.Pruned, log) + } + if _, err := os.Stat(filepath.Join(inner, "Gears.sysml")); err != nil { + t.Fatal(err) + } + + // No logger: a removal whose usage another package still needs does not panic. + dir = consumer(t, project.Usage{Resource: wres, VersionConstraint: "~1.1.0"}, project.Usage{Resource: bres, VersionConstraint: "^2.1.0"}) + run(t, dir, Options{}) + if _, err := Remove(context.Background(), Options{Root: dir, Offline: true}, []string{bres}); err != nil { + t.Fatal(err) + } +} diff --git a/tests/e2e/remove_test.go b/tests/e2e/remove_test.go new file mode 100644 index 0000000..a92081d --- /dev/null +++ b/tests/e2e/remove_test.go @@ -0,0 +1,109 @@ +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRemove(t *testing.T) { + bin := syspiBinary(t) + root := t.TempDir() + gitRes := "git+file://" + filepath.ToSlash(gitFixture(t, root)) + std := "https://www.omg.org/spec/SysML/20250201/Systems-Library.kpar" + + app := filepath.Join(root, "app") + os.MkdirAll(app, 0o755) + sh(t, app, bin, "init", "-name", "app") + sh(t, app, bin, "add", gitRes+"@1.0.0") + sh(t, app, bin, "add", gitRes+"#packages/gears", "~1.1.0") + sh(t, app, bin, "add", std) + write(t, app, "lib/mine_1.0.0/Mine.sysml", "package Mine;\n") + metaBefore, _ := os.ReadFile(filepath.Join(app, ".meta.json")) + + // An unknown usage is refused and nothing changes. + pjBefore, _ := os.ReadFile(filepath.Join(app, ".project.json")) + lockBefore, _ := os.ReadFile(filepath.Join(app, ".project.lock.json")) + out, err := shErr(app, nil, bin, "remove", "-offline", "git+file:///no/such/repo") + if err == nil || !strings.Contains(out, "no usage of") { + t.Fatalf("unknown usage: %v\n%s", err, out) + } + if pj, _ := os.ReadFile(filepath.Join(app, ".project.json")); string(pj) != string(pjBefore) { + t.Fatal(".project.json changed by a failing remove") + } + + // The sub-package goes, offline; widgets and the user's directory stay. + out = sh(t, app, bin, "remove", "-offline", gitRes+"#packages/gears") + if !strings.Contains(out, "removed usage ") || !strings.Contains(out, "removed lib/acme-gears_1.1.0\n") || !strings.Contains(out, "2 package(s) locked") { + t.Fatalf("remove gears:\n%s", out) + } + if _, err := os.Stat(filepath.Join(app, "lib", "acme-gears_1.1.0")); err == nil { + t.Error("lib/acme-gears_1.1.0 still there") + } + for _, want := range []string{"lib/acme-widgets_1.0.0/Widgets.sysml", "lib/mine_1.0.0/Mine.sysml"} { + if _, err := os.Stat(filepath.Join(app, filepath.FromSlash(want))); err != nil { + t.Errorf("%s: %v", want, err) + } + } + lockAfter, _ := os.ReadFile(filepath.Join(app, ".project.lock.json")) + if string(lockAfter) == string(lockBefore) || strings.Contains(string(lockAfter), "gears") { + t.Fatalf("lock after remove:\n%s", lockAfter) + } + if metaAfter, _ := os.ReadFile(filepath.Join(app, ".meta.json")); string(metaAfter) == string(metaBefore) { + t.Error(".meta.json not refreshed for the changed .project.json") + } + // Removing the same usage twice is an error. + if out, err := shErr(app, nil, bin, "remove", gitRes+"#packages/gears"); err == nil || !strings.Contains(out, "no usage of") { + t.Fatalf("second remove: %v\n%s", err, out) + } + // The standard library and widgets, in one call, by any accepted spelling. + out = sh(t, app, bin, "remove", std, "pkg:generic/widgets?vcs_url="+url(gitRes)) + if !strings.Contains(out, "removed lib/acme-widgets_1.0.0\n") || !strings.Contains(out, "0 package(s) locked") { + t.Fatalf("remove the rest:\n%s", out) + } + entries, _ := os.ReadDir(filepath.Join(app, "lib")) + if len(entries) != 1 || entries[0].Name() != "mine_1.0.0" { + t.Fatalf("lib/ after: %v", entries) + } + pj, _ := os.ReadFile(filepath.Join(app, ".project.json")) + if strings.Contains(string(pj), "usage") { + t.Fatalf(".project.json still has usages:\n%s", pj) + } +} + +// url percent-encodes a vcs_url the way the canonical identity does. +func url(vcsURL string) string { + r := strings.NewReplacer("+", "%2B", "/", "%2F", "@", "%40", "#", "%23") + return r.Replace(vcsURL) +} + +func TestRemoveFromGitHgSvn(t *testing.T) { + requireOrSkip(t, "git", "hg", "svn", "svnadmin") + bin := syspiBinary(t) + root := t.TempDir() + sources := map[string]string{ + "git": "git+file://" + filepath.ToSlash(gitFixture(t, root)), + "hg": "hg+file://" + filepath.ToSlash(hgFixture(t, root)), + "svn": "svn+file://" + strings.TrimPrefix(svnFixture(t, root), "file://"), + } + for name, res := range sources { + app := filepath.Join(root, "app-"+name) + os.MkdirAll(app, 0o755) + sh(t, app, bin, "init", "-name", "app") + sh(t, app, bin, "add", res, "~1.1.0") + out := sh(t, app, bin, "remove", "-offline", res) + if !strings.Contains(out, "removed lib/acme-widgets_1.1.0\n") { + t.Fatalf("%s:\n%s", name, out) + } + if entries, _ := os.ReadDir(filepath.Join(app, "lib")); len(entries) != 0 { + t.Errorf("%s: lib/ not empty: %v", name, entries) + } + lk, _ := os.ReadFile(filepath.Join(app, ".project.lock.json")) + if !strings.Contains(string(lk), `"packages": []`) { + t.Errorf("%s: lock:\n%s", name, lk) + } + } +}