diff --git a/README.md b/README.md index 429828e..a806001 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,8 @@ what package managers install. - **Lock** — `.project.lock.json` beside `.project.json`: one entry per package of the transitive closure with `resource`, `kind`, `vcs`, `source`, `tag`, `revision`, `sha256` and the usages it declares; deterministic bytes, so a fresh clone reproduces it exactly. A lock - entry wins over a constraint. + entry wins over a constraint everywhere but in `update`, the one command that moves a lock + entry (`outdated` shows what it would move). - **Constraints** — `versionConstraint` in `.project.json`: exact (`1.2.0`), caret (`^1.2.0`), tilde (`~1.2.0`) and comparison ranges (`>=1.2.0 <2.0.0`). `@` in the resource with no `versionConstraint` is an exact constraint. @@ -55,6 +56,8 @@ 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 update [...] move the named (or all) usages to the highest version allowed, install, re-lock +syspi outdated [-json] [...] report what update would move; write nothing 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/update.added.md b/changes/unreleased/update.added.md new file mode 100644 index 0000000..c9187e4 --- /dev/null +++ b/changes/unreleased/update.added.md @@ -0,0 +1 @@ +- `syspi update […]` re-resolves the named (or all) usages to the highest version their constraints allow, ignoring the lock — the one command that moves a lock entry — installs, rewrites the lock and removes the copies the moved entries left under `lib/`; `syspi outdated [-json] […]` reports what `update` would move (`name old -> new`) and writes nothing. diff --git a/cmd/syspi/main.go b/cmd/syspi/main.go index 33ee88f..fe72e16 100644 --- a/cmd/syspi/main.go +++ b/cmd/syspi/main.go @@ -6,6 +6,7 @@ package main import ( "context" + "encoding/json" "errors" "flag" "fmt" @@ -34,6 +35,8 @@ 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/ + update move the named (or all) usages to the highest version their constraints allow, install, re-lock + outdated report what update would move; write nothing 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 @@ -68,6 +71,10 @@ func run(args []string, stdout, stderr io.Writer) int { err = cmdAdd(ctx, args[1:], stdout, stderr) case "remove": err = cmdRemove(ctx, args[1:], stdout, stderr) + case "update": + err = cmdUpdate(ctx, args[1:], stdout, stderr) + case "outdated": + err = cmdOutdated(ctx, args[1:], stdout, stderr) case "install", "sync": err = cmdInstall(ctx, args[0], args[1:], stdout, stderr, false) case "lock": @@ -268,6 +275,60 @@ func cmdRemove(ctx context.Context, args []string, stdout, stderr io.Writer) err return refreshMeta(c.dir) } +func cmdUpdate(ctx context.Context, args []string, stdout, stderr io.Writer) error { + var c commonFlags + fs := newFlagSet("update", stderr, "[flags] [...]") + c.bind(fs) + if err := parse(fs, args); err != nil { + return err + } + res, err := install.Update(ctx, c.options(stderr, false), fs.Args()) + if err != nil { + return err + } + for i := range res.Changes { + fmt.Fprintln(stdout, res.Changes[i].Describe()) + } + if len(res.Changes) == 0 { + fmt.Fprintln(stdout, "every package is at the highest version its constraint allows; nothing moved") + } + for _, p := range res.Pruned { + fmt.Fprintf(stdout, "removed %s\n", p) + } + report(stdout, res) + return nil +} + +func cmdOutdated(ctx context.Context, args []string, stdout, stderr io.Writer) error { + var c commonFlags + fs := newFlagSet("outdated", stderr, "[flags] [...]") + c.bind(fs) + asJSON := fs.Bool("json", false, "print the changes as a JSON array") + if err := parse(fs, args); err != nil { + return err + } + res, err := install.Outdated(ctx, c.options(stderr, false), fs.Args()) + if err != nil { + return err + } + if *asJSON { + enc := json.NewEncoder(stdout) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if res.Changes == nil { + res.Changes = []install.Change{} + } + return enc.Encode(res.Changes) + } + for i := range res.Changes { + fmt.Fprintln(stdout, res.Changes[i].Describe()) + } + if len(res.Changes) == 0 && !c.quiet { + fmt.Fprintln(stdout, "every package is at the highest version its constraint allows") + } + return nil +} + 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 39c146b..a9320af 100644 --- a/docs/packages.md +++ b/docs/packages.md @@ -131,6 +131,16 @@ published into `lib/` only after every usage resolved and verified; a failing ru and the lock as they were. A lock entry wins over the constraint in `.project.json` as long as it satisfies it; `install` then verifies the installed copy's digest and fetches only what is missing or changed. +`update` is the one command that moves a lock entry. `syspi update […]` re-resolves the +named usages of `.project.json` — every repository of the closure when none is named — to the +highest tagged version that satisfies their constraints, ignoring the lock; whatever moved is +installed, the lock rewritten, and the copies the moved entries left under `lib/` removed as +`remove` removes them. Every other command (`add`, `install`, `sync`, `lock`, `remove`) keeps a +lock entry that satisfies its constraint. `syspi outdated [-json] […]` resolves the same +way and reports each entry that would move (`name old -> new`, plus entries the move would add or +drop), writing nothing. Archives, local directories and standard libraries have no versions to +choose from: their usage names what they are, and `update` leaves them. + ## Install layout ``` diff --git a/internal/install/install.go b/internal/install/install.go index 9b901e0..89c5a2e 100644 --- a/internal/install/install.go +++ b/internal/install/install.go @@ -14,6 +14,7 @@ import ( "os" "path" "path/filepath" + "sort" "strings" "github.com/Open-MBEE/SysPI/internal/digest" @@ -41,6 +42,14 @@ type Options struct { // Prune removes from lib/ the installed copies the previous lock placed // there that the new lock no longer claims (see Remove). Prune bool + // Update names, by repository key, the usages re-resolved to the highest + // version their constraint allows, ignoring the lock; UpdateAll does so + // for every repository of the closure (see Update and Outdated). + Update []string + UpdateAll bool + // DryRun resolves as LockOnly does but writes nothing: the Result + // carries the lock the run would have written. + DryRun bool } // Result reports what a run did. @@ -54,6 +63,19 @@ type Result struct { RemovedUsages []string // Pruned lists the lib/ paths (relative to Root) a pruning run removed. Pruned []string + // Changes lists, in resource order, every lock entry the run moved, + // added or dropped against the previous lock. + Changes []Change +} + +// Change is one lock entry that differs between the previous and the new lock. +type Change struct { + Resource string `json:"resource"` + Name string `json:"name,omitempty"` + // From and To are the versions before and after; one is empty for an + // entry only one of the locks has. + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` } type request struct { @@ -112,6 +134,9 @@ func Run(ctx context.Context, opts Options) (*Result, error) { return nil, err } opts.Root = root + if opts.DryRun { + opts.LockOnly = true + } proj, err := project.ReadProject(root) if err != nil { return nil, err @@ -144,6 +169,11 @@ func Run(ctx context.Context, opts Options) (*Result, error) { return nil, err } } + r.res.Changes = changes(r.old, r.fresh) + if opts.DryRun { + r.res.Lock = r.fresh + return r.res, nil + } // Everything resolved and verified: publish the staged packages, then // the lock, restoring every replaced package if any step fails. backups, err := r.publish() @@ -349,13 +379,13 @@ func (r *resolver) archivePath(p *project.Project) string { // installArchive stages a verified archive for lib/ unless an identical // copy is already there, and queues its usages. func (r *resolver) installArchive(e *lock.Entry, a *project.Archive, src string) error { - if r.opts.LockOnly { - return r.enqueueUsages(e, a.Project) - } dst := r.archivePath(a.Project) if err := r.claim(dst, e.Resource, a.SHA256); err != nil { return err } + if r.opts.LockOnly { + return r.enqueueUsages(e, a.Project) + } if cur, err := digest.File(dst); (err == nil && cur == a.SHA256) || r.alreadyStaged(dst) { r.res.Reused = append(r.res.Reused, e.Resource) return r.enqueueUsages(e, a.Project) @@ -454,10 +484,10 @@ func (r *resolver) reuseLocked(old *lock.Entry) (*lock.Entry, bool, error) { dst := filepath.Join(r.libDir, stem+project.ArchiveExt) cur, err := digest.File(dst) if r.opts.LockOnly || (err == nil && cur == old.SHA256) { + if err := r.claim(dst, old.Resource, old.SHA256); err != nil { + return nil, false, err + } if !r.opts.LockOnly { - if err := r.claim(dst, old.Resource, old.SHA256); err != nil { - return nil, false, err - } r.res.Reused = append(r.res.Reused, old.Resource) } e := *old @@ -572,6 +602,66 @@ func treePath(libDir string, p *project.Project) string { // installedTreeMatches reports whether dir holds a tree with the locked // digest, ignoring a .meta.json this tool generated. +// updating reports whether the lock is ignored for this repository: the +// run updates everything, or named this usage's repository. +func (r *resolver) updating(o *origin.Origin) bool { + if r.opts.UpdateAll { + return true + } + for _, k := range r.opts.Update { + if k == o.Key() { + return true + } + } + return false +} + +// latest opens the repository and selects the highest tagged version that +// satisfies the request's constraint. +func (r *resolver) latest(o *origin.Origin, req request) (vcs.Source, vcs.Version, error) { + src, err := vcs.Open(o.VCS, o.Source) + if err != nil { + return nil, vcs.Version{}, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err) + } + vs, err := src.ListVersions(r.ctx) + if err != nil { + return nil, vcs.Version{}, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err) + } + sel, err := vcs.Select(vs, req.constraint) + if err != nil { + return nil, vcs.Version{}, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err) + } + return src, sel, nil +} + +// changes diffs two locks by repository key (the old lock's resources are +// already keyed): every entry moved, added or dropped. +func changes(old, fresh *lock.Lock) []Change { + var out []Change + seen := map[string]bool{} + for i := range fresh.Packages { + e := &fresh.Packages[i] + k := repositoryKey(e.Resource) + seen[k] = true + c := Change{Resource: e.Resource, Name: e.Name, To: e.Version} + if o := old.Find(k); o != nil { + if o.Version == e.Version { + continue + } + c.From = o.Version + } + out = append(out, c) + } + for i := range old.Packages { + e := &old.Packages[i] + if !seen[repositoryKey(e.Resource)] { + out = append(out, Change{Resource: e.Resource, Name: e.Name, From: e.Version}) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Resource < out[j].Resource }) + return out +} + func installedTreeMatches(dir string, old *lock.Entry) bool { if _, err := os.Stat(filepath.Join(dir, project.ProjectFile)); err != nil { return false @@ -587,6 +677,23 @@ func installedTreeMatches(dir string, old *lock.Entry) bool { func (r *resolver) resolveVCS(o *origin.Origin, req request) (*lock.Entry, error) { id := o.Identity() old := r.lockedRepository(o, req.constraint) + var src vcs.Source + var sel vcs.Version + if old != nil && r.updating(o) { + if r.opts.Offline { + return nil, fmt.Errorf("%s: usage %q: offline; looking for a newer version of %s needs the repository", req.requester, req.resource, id) + } + var err error + if src, sel, err = r.latest(o, req); err != nil { + return nil, err + } + if sel.Version.String() != old.Version { + old = nil + } else { + // Already at the top: the locked tag, not another spelling of the same version. + sel = vcs.Version{Tag: old.Tag, Version: sel.Version} + } + } if old != nil && !r.opts.LockOnly { stem := (&project.Project{Name: old.Name, Publisher: old.Publisher, Version: old.Version}).InstallName() dst := filepath.Join(r.libDir, stem) @@ -601,6 +708,9 @@ func (r *resolver) resolveVCS(o *origin.Origin, req request) (*lock.Entry, error } } if old != nil && r.opts.LockOnly { + if err := r.claim(treePath(r.libDir, &project.Project{Name: old.Name, Publisher: old.Publisher, Version: old.Version}), id, old.SHA256); err != nil { + return nil, err + } e := *old e.Resource, e.Source, e.Usage = id, o.Source, nil return &e, r.requeueLocked(&e, old) @@ -608,21 +718,19 @@ func (r *resolver) resolveVCS(o *origin.Origin, req request) (*lock.Entry, error if r.opts.Offline { return nil, fmt.Errorf("%s: usage %q: offline and %s is not installed under %s", req.requester, req.resource, id, project.LibDir) } - src, err := vcs.Open(o.VCS, o.Source) - if err != nil { - return nil, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err) - } - var sel vcs.Version - if old != nil { - v, _ := version.Parse(old.Version) - sel = vcs.Version{Tag: old.Tag, Version: v} - } else { - vs, err := src.ListVersions(r.ctx) - if err != nil { + var err error + switch { + case src != nil: + // Opened and selected above: the lock is being moved. + case old != nil: + if src, err = vcs.Open(o.VCS, o.Source); err != nil { return nil, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err) } - if sel, err = vcs.Select(vs, req.constraint); err != nil { - return nil, fmt.Errorf("%s: usage %q: %v", req.requester, req.resource, err) + v, _ := version.Parse(old.Version) + sel = vcs.Version{Tag: old.Tag, Version: v} + default: + if src, sel, err = r.latest(o, req); err != nil { + return nil, err } } revision, err := src.Revision(r.ctx, sel) @@ -688,6 +796,10 @@ func (r *resolver) resolveVCS(o *origin.Origin, req request) (*lock.Entry, error return nil, fmt.Errorf("%s: usage %q: tag %s of %s exports tree SHA-256 %s, the lock recorded %s", req.requester, req.resource, sel.Tag, o.Source, sum, old.SHA256) } e := &lock.Entry{Resource: id, Kind: lock.KindVCS, Publisher: p.Publisher, Name: p.Name, Version: p.Version, VCS: o.VCS, Source: o.Source, Tag: sel.Tag, Revision: revision, SHA256: sum, MetaGenerated: !hasMeta} + dst := treePath(r.libDir, p) + if err := r.claim(dst, id, sum); err != nil { + return nil, err + } if r.opts.LockOnly { return e, r.enqueueUsages(e, p) } @@ -701,10 +813,6 @@ func (r *resolver) resolveVCS(o *origin.Origin, req request) (*lock.Entry, error } r.logf("%s %s: tag %s has no %s; generated one for the installed copy", p.Name, p.Version, sel.Tag, project.MetaFile) } - dst := treePath(r.libDir, p) - if err := r.claim(dst, id, sum); err != nil { - return nil, err - } if r.alreadyStaged(dst) { r.res.Reused = append(r.res.Reused, id) return e, r.enqueueUsages(e, p) diff --git a/internal/install/update.go b/internal/install/update.go new file mode 100644 index 0000000..b61c2fc --- /dev/null +++ b/internal/install/update.go @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: Apache-2.0 + +package install + +import ( + "context" + "fmt" + + "github.com/Open-MBEE/SysPI/internal/origin" + "github.com/Open-MBEE/SysPI/internal/project" +) + +// Update re-resolves the named usages of .project.json — every one when +// resources is empty — to the highest version satisfying their constraints, +// ignoring the lock, then installs and re-locks as Run does and prunes the +// copies the moved entries leave behind. It is the one way a lock entry +// moves. Result.Changes lists each entry moved, added or dropped. +func Update(ctx context.Context, opts Options, resources []string) (*Result, error) { + if err := selectUpdates(&opts, resources); err != nil { + return nil, err + } + opts.Prune = true + return Run(ctx, opts) +} + +// Outdated resolves as Update would and reports what it would change, +// writing nothing: Result.Changes lists the entries that would move. +func Outdated(ctx context.Context, opts Options, resources []string) (*Result, error) { + if err := selectUpdates(&opts, resources); err != nil { + return nil, err + } + opts.DryRun = true + return Run(ctx, opts) +} + +// selectUpdates turns the named resources into the repository keys the +// resolver ignores the lock for; each must be a usage of .project.json. +func selectUpdates(opts *Options, resources []string) error { + if len(resources) == 0 { + opts.UpdateAll = true + return nil + } + proj, err := project.ReadProject(opts.Root) + if err != nil { + return err + } + for _, res := range resources { + o, err := origin.Parse(res) + if err != nil { + return err + } + if usageIndex(proj, o.Identity()) < 0 { + return fmt.Errorf("%s: no usage of %s", project.ProjectFile, o.Identity()) + } + if o.Kind != origin.VCS { + return fmt.Errorf("%s: %s is not a repository; it has no versions to choose from, and its usage names what it is", project.ProjectFile, o.Identity()) + } + opts.Update = append(opts.Update, o.Key()) + } + return nil +} + +// repositoryKey is what two lock entries for one repository share however +// the .git suffix was spelled: origin.Key for a repository resource, the +// resource itself otherwise. +func repositoryKey(resource string) string { + if o, err := origin.Parse(resource); err == nil && o.Kind == origin.VCS { + return o.Key() + } + return resource +} + +// Describe is the one-line report of a change: "name old -> new", "name new (new)" +// or "name old (dropped)". +func (c *Change) Describe() string { + name := c.Name + if name == "" { + name = c.Resource + } + switch { + case c.From == "": + return fmt.Sprintf("%s %s (new)", name, c.To) + case c.To == "": + return fmt.Sprintf("%s %s (dropped)", name, c.From) + } + return fmt.Sprintf("%s %s -> %s", name, c.From, c.To) +} diff --git a/internal/install/update_test.go b/internal/install/update_test.go new file mode 100644 index 0000000..c027b4f --- /dev/null +++ b/internal/install/update_test.go @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: Apache-2.0 + +package install + +import ( + "context" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/Open-MBEE/SysPI/internal/lock" + "github.com/Open-MBEE/SysPI/internal/project" +) + +func describeAll(cs []Change) string { + var s []string + for i := range cs { + s = append(s, cs[i].Describe()) + } + return strings.Join(s, "; ") +} + +func lockedVersions(t *testing.T, dir string) string { + t.Helper() + l, _, err := lock.Read(dir, project.LockFile) + if err != nil { + t.Fatal(err) + } + var s []string + for i := range l.Packages { + s = append(s, l.Packages[i].Name+" "+l.Packages[i].Version) + } + sort.Strings(s) + return strings.Join(s, ",") +} + +func TestUpdateAndOutdated(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 + "@1.0.0"}, project.Usage{Resource: bres + "@2.0.0"}) + run(t, dir, Options{}) + if got := lockedVersions(t, dir); got != "base 2.0.0,widgets 1.0.0" { + t.Fatalf("locked: %s", got) + } + // Loosen the constraints: the lock still wins for every command but update. + p := &project.Project{Name: "app", Publisher: "test", Version: "0.1.0", Usage: []project.Usage{ + {Resource: wres, VersionConstraint: ">=1.0.0 <1.2.0"}, + {Resource: bres, VersionConstraint: "^2.0.0"}, + }} + if err := p.Write(dir); err != nil { + t.Fatal(err) + } + res, _ := run(t, dir, Options{}) + if got := lockedVersions(t, dir); got != "base 2.0.0,widgets 1.0.0" || len(res.Changes) != 0 { + t.Fatalf("install moved the lock: %s %v", got, res.Changes) + } + lockBefore, _ := os.ReadFile(filepath.Join(dir, project.LockFile)) + + // Naming widgets alone cannot move it: widgets 1.1.0 needs base ^2.1.0 + // and the lock entry for base, not named, wins — the conflict is reported. + if _, err := Outdated(context.Background(), Options{Root: dir}, []string{wres}); err == nil || !strings.Contains(err.Error(), "already resolved to 2.0.0") { + t.Fatalf("outdated widgets alone: %v", err) + } + // outdated: what would move, nothing written. + for _, named := range [][]string{nil, {wres, bres}} { + res, err := Outdated(context.Background(), Options{Root: dir}, named) + if err != nil { + t.Fatal(err) + } + if got := describeAll(res.Changes); got != "base 2.0.0 -> 2.1.0; widgets 1.0.0 -> 1.1.0" { + t.Fatalf("outdated %v: %s", named, got) + } + if lockNow, _ := os.ReadFile(filepath.Join(dir, project.LockFile)); string(lockNow) != string(lockBefore) { + t.Fatal("outdated wrote the lock") + } + if got := libNames(t, dir); got != "acme-widgets_1.0.0,base_2.0.0" { + t.Fatalf("outdated touched lib/: %s", got) + } + } + res, err := Outdated(context.Background(), Options{Root: dir}, []string{bres}) + if err != nil || describeAll(res.Changes) != "base 2.0.0 -> 2.1.0" { + t.Fatalf("outdated base: %v %s", err, describeAll(res.Changes)) + } + + // update of one usage moves that entry, installs it and prunes the old copy. + res, err = Update(context.Background(), Options{Root: dir}, []string{bres}) + if err != nil { + t.Fatal(err) + } + if got := describeAll(res.Changes); got != "base 2.0.0 -> 2.1.0" || !res.LockWritten || strings.Join(res.Pruned, ",") != "lib/base_2.0.0" { + t.Fatalf("update base: %s written=%v pruned=%v", got, res.LockWritten, res.Pruned) + } + if got := lockedVersions(t, dir); got != "base 2.1.0,widgets 1.0.0" { + t.Fatalf("locked: %s", got) + } + if got := libNames(t, dir); got != "acme-widgets_1.0.0,base_2.1.0" { + t.Fatalf("lib/: %s", got) + } + // update of everything. + res, err = Update(context.Background(), Options{Root: dir}, nil) + if err != nil { + t.Fatal(err) + } + if got := describeAll(res.Changes); got != "widgets 1.0.0 -> 1.1.0" || strings.Join(res.Pruned, ",") != "lib/acme-widgets_1.0.0" { + t.Fatalf("update all: %s pruned=%v", got, res.Pruned) + } + if got := libNames(t, dir); got != "acme-widgets_1.1.0,base_2.1.0" { + t.Fatalf("lib/: %s", got) + } + res, err = Outdated(context.Background(), Options{Root: dir}, nil) + if err != nil || len(res.Changes) != 0 { + t.Fatalf("still outdated: %v %s", err, describeAll(res.Changes)) + } + // Nothing to move: update changes nothing and reinstalls nothing. + res, err = Update(context.Background(), Options{Root: dir}, nil) + if err != nil || len(res.Changes) != 0 || len(res.Installed) != 0 || res.LockWritten { + t.Fatalf("update at the top: %v %+v", err, res) + } + + // Refusals. + if _, err := Update(context.Background(), Options{Root: dir, Offline: true}, nil); err == nil || !strings.Contains(err.Error(), "offline") { + t.Fatalf("offline update: %v", err) + } + if _, err := Outdated(context.Background(), Options{Root: dir}, []string{"git+file:///no/such/repo"}); err == nil || !strings.Contains(err.Error(), "no usage of") { + t.Fatalf("unknown usage: %v", err) + } + std := "https://www.omg.org/spec/SysML/20250201/Systems-Library.kpar" + p.Usage = append(p.Usage, project.Usage{Resource: std}) + p.Write(dir) + if _, err := Outdated(context.Background(), Options{Root: dir}, []string{std}); err == nil || !strings.Contains(err.Error(), "is not a repository") { + t.Fatalf("standard library named: %v", err) + } + // A new usage shows as new, a dropped one as dropped, in resource order. + res, err = Outdated(context.Background(), Options{Root: dir}, nil) + if err != nil || describeAll(res.Changes) != "Systems-Library.kpar (new)" { + t.Fatalf("new usage: %v %q", err, describeAll(res.Changes)) + } +} + +// A dry run sees the destination collision install would refuse, and an +// entry already at the top keeps its locked tag when another tag of the +// same version appears. +func TestOutdatedSeesCollisionsAndKeepsTheLockedTag(t *testing.T) { + widgets, base := fixture(t) + bres := "git+file://" + filepath.ToSlash(base) + twin := t.TempDir() + git(t, twin, "init", "-q", "-b", "main") + write(t, twin, ".project.json", `{"name":"base","version":"2.1.0"}`+"\n") + write(t, twin, "Other.kerml", "package Other;\n") + git(t, twin, "add", "-A") + git(t, twin, "commit", "-q", "-m", "2.1.0") + git(t, twin, "tag", "2.1.0") + dir := consumer(t, project.Usage{Resource: bres + "@2.1.0"}, project.Usage{Resource: "git+file://" + filepath.ToSlash(twin) + "@2.1.0"}) + for _, f := range []func() error{ + func() error { _, err := Outdated(context.Background(), Options{Root: dir}, nil); return err }, + func() error { _, err := Run(context.Background(), Options{Root: dir, LockOnly: true}); return err }, + } { + if err := f(); err == nil || !strings.Contains(err.Error(), "both install as lib/base_2.1.0 with different contents") { + t.Fatalf("collision not seen: %v", err) + } + } + + wres := "git+file://" + filepath.ToSlash(widgets) + dir = consumer(t, project.Usage{Resource: wres, VersionConstraint: "~1.1.0"}) + run(t, dir, Options{}) + // Another tag naming the same version, on the commit without .project.json. + git(t, base, "tag", "v2.1.0", "3.0.0") + res, err := Update(context.Background(), Options{Root: dir}, nil) + if err != nil || len(res.Changes) != 0 || len(res.Installed) != 0 { + t.Fatalf("update with a twin tag: %v %+v", err, res) + } + res, err = Outdated(context.Background(), Options{Root: dir}, nil) + if err != nil || len(res.Changes) != 0 { + t.Fatalf("outdated with a twin tag: %v %s", err, describeAll(res.Changes)) + } +} diff --git a/tests/e2e/update_test.go b/tests/e2e/update_test.go new file mode 100644 index 0000000..b65e5d9 --- /dev/null +++ b/tests/e2e/update_test.go @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 + +package e2e + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +// looseProject rewrites the consuming .project.json so its one usage allows +// 1.0.0 up to (not including) 1.2.0, where the lock holds 1.0.0. +func looseProject(t *testing.T, app, res string) { + t.Helper() + write(t, app, ".project.json", "{\n \"name\": \"app\",\n \"version\": \"0.1.0\",\n \"usage\": [\n {\n \"resource\": \""+res+"\",\n \"versionConstraint\": \">=1.0.0 <1.2.0\"\n }\n ]\n}\n") +} + +func TestUpdateAndOutdated(t *testing.T) { + bin := syspiBinary(t) + root := t.TempDir() + gitRes := "git+file://" + filepath.ToSlash(gitFixture(t, root)) + + 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") + looseProject(t, app, gitRes) + // Every other command keeps the lock entry. + sh(t, app, bin, "install") + sh(t, app, bin, "lock") + lockBefore, _ := os.ReadFile(filepath.Join(app, ".project.lock.json")) + if !strings.Contains(string(lockBefore), `"version": "1.0.0"`) { + t.Fatalf("install moved the lock:\n%s", lockBefore) + } + + out := sh(t, app, bin, "outdated") + if out != "widgets 1.0.0 -> 1.1.0\n" { + t.Fatalf("outdated:\n%s", out) + } + var cs []map[string]string + if err := json.Unmarshal([]byte(sh(t, app, bin, "outdated", "-json")), &cs); err != nil { + t.Fatal(err) + } + if len(cs) != 1 || cs[0]["name"] != "widgets" || cs[0]["from"] != "1.0.0" || cs[0]["to"] != "1.1.0" || !strings.HasPrefix(cs[0]["resource"], "pkg:generic/widgets?vcs_url=") { + t.Fatalf("outdated -json: %v", cs) + } + if lockNow, _ := os.ReadFile(filepath.Join(app, ".project.lock.json")); string(lockNow) != string(lockBefore) { + t.Fatal("outdated wrote the lock") + } + if _, err := os.Stat(filepath.Join(app, "lib", "acme-widgets_1.1.0")); err == nil { + t.Fatal("outdated installed something") + } + if out, err := shErr(app, nil, bin, "outdated", "-offline"); err == nil || !strings.Contains(out, "offline") { + t.Fatalf("outdated -offline: %v\n%s", err, out) + } + + out = sh(t, app, bin, "update") + for _, want := range []string{"widgets 1.0.0 -> 1.1.0\n", "removed lib/acme-widgets_1.0.0\n", "1 package(s) locked: 1 installed", ".project.lock.json updated"} { + if !strings.Contains(out, want) { + t.Errorf("update lacks %q:\n%s", want, out) + } + } + entries, _ := os.ReadDir(filepath.Join(app, "lib")) + if len(entries) != 1 || entries[0].Name() != "acme-widgets_1.1.0" { + t.Fatalf("lib/ after update: %v", entries) + } + if lockNow, _ := os.ReadFile(filepath.Join(app, ".project.lock.json")); !strings.Contains(string(lockNow), `"version": "1.1.0"`) || !strings.Contains(string(lockNow), `"tag": "v1.1.0"`) { + t.Fatalf("lock after update:\n%s", lockNow) + } + if out := sh(t, app, bin, "outdated"); out != "every package is at the highest version its constraint allows\n" { + t.Fatalf("outdated after update:\n%s", out) + } + if out := sh(t, app, bin, "update", gitRes); !strings.HasPrefix(out, "every package is at the highest version its constraint allows; nothing moved\n") { + t.Fatalf("update at the top:\n%s", out) + } + if out, err := shErr(app, nil, bin, "update", "git+file:///no/such/repo"); err == nil || !strings.Contains(out, "no usage of") { + t.Fatalf("update of an unknown usage: %v\n%s", err, out) + } +} + +func TestUpdateAcrossVCS(t *testing.T) { + requireOrSkip(t, "hg", "svn", "svnadmin") + bin := syspiBinary(t) + root := t.TempDir() + for name, res := range 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://"), + } { + app := filepath.Join(root, "app-"+name) + os.MkdirAll(app, 0o755) + sh(t, app, bin, "init", "-name", "app") + sh(t, app, bin, "add", res+"@1.0.0") + looseProject(t, app, res) + if out := sh(t, app, bin, "outdated"); out != "widgets 1.0.0 -> 1.1.0\n" { + t.Errorf("%s outdated:\n%s", name, out) + } + out := sh(t, app, bin, "update", res) + if !strings.Contains(out, "widgets 1.0.0 -> 1.1.0\n") || !strings.Contains(out, "removed lib/acme-widgets_1.0.0\n") { + t.Errorf("%s update:\n%s", name, out) + } + if _, err := os.Stat(filepath.Join(app, "lib", "acme-widgets_1.1.0", "Widgets.sysml")); err != nil { + t.Errorf("%s: %v", name, err) + } + } +}