Skip to content
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <resource>[@<version>] [<constraint>] add a usage, resolve, install, lock
syspi remove <resource>... 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] <dir> deterministic .kpar; checks tag == version in a tagged checkout
Expand Down
1 change: 1 addition & 0 deletions changes/unreleased/remove.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- `syspi remove <resource>…` 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.
28 changes: 28 additions & 0 deletions cmd/syspi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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] <resource>...")
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]")
Expand Down
7 changes: 7 additions & 0 deletions docs/packages.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,13 @@ lib/
The directory name is `<publisher>-<name>_<version>` (`<name>_<version>` without a publisher),
the convention OpenSysML reads.

`syspi remove <resource>…` 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
Expand Down
22 changes: 22 additions & 0 deletions internal/digest/digest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
10 changes: 10 additions & 0 deletions internal/install/install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down Expand Up @@ -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
}

Expand Down
46 changes: 46 additions & 0 deletions internal/install/libpath.go
Original file line number Diff line number Diff line change
@@ -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)
}
182 changes: 182 additions & 0 deletions internal/install/remove.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
Loading
Loading