diff --git a/.loom/event-log.md b/.loom/event-log.md index 19f5b8b..a59503e 100644 --- a/.loom/event-log.md +++ b/.loom/event-log.md @@ -614,3 +614,77 @@ a comparator.* practice nobody ever runs `check` unaided, a tool that only reports when asked is a tool that reports nothing**, *and the thing we declined to build is the thing that was needed.* + +## 2026-09-07 — a changed external is a polad, which the specimen said first `marmalade` + +**Decided:** *`check` stages what moved into `.loom/cart/current/polad/`*, **a +candidate artifact shaped exactly like what it would become**, *whose exits are +apply or discard.* + +**This was in the specimen and we lost it.** *"A changed external becomes a polad +in the cart, and somebody decides."* **The round that discarded the specimen +discarded this with it**, *and it came back only because somebody asked what the +stash should be.* + +**Believed to advance:** *`externals` says reconciliation runs the other way* — +**given what changed in theirs, what do we change in ours** — *and the facets +usually survive while the manifests, the config and the code that a usage named +are what move.* **So staging prints the `.usages.md` beside it**, *which is the +file that names what to go fix*, **and says so when there is none**: *a usage that +does not name what it justifies is half a usage, and no usage at all is a document +nothing records a dependency on.* + +**With no cart open, `check` reports what moved and stages nothing.** *The tool +does not open a round* — **a cart is a bounded exchange between two presences, and +starting one is somebody's act, not a side effect of asking about freshness.** + +**Belief that could be shown wrong:** *that requiring an open cart is right.* **If +most changes arrive when no round is open, the useful behaviour is the one that +never runs**, *and the polad needs somewhere else to live.* + +## 2026-09-07 — supersedes "add says what it replaced": add does not replace `marmalade` + +**`add` adopts what is not here, and refuses what is already adopted.** *The +earlier entry made `add` announce an overwrite; **it no longer overwrites at +all**.* + +**Believed to advance:** *a command that both adopts and re-fetches is a command +that overwrites the only evidence a change happened.* **Splitting them gives each +one job** — *`add` adopts, `check` notices.* + +**One exception, and it is the only way out of a dead end:** *a document that is +present but **unlocked** was fetched by hand before the tool existed.* **Nothing +records its origin and the path does not round-trip, so `check` cannot ask about +it and `add` refusing would strand it forever.** *So `add` accepts it, and the +bytes decide:* + +- **identical** → *the lock is written and nothing is rewritten.* **The assertion + a lock makes — this local copy is the one being served — is then verified rather + than assumed**, *which was the whole objection to adopting a remote `ETag` + blindly.* +- **different** → *staged as a polad; the local copy is left alone*, **because a + copy that differs is the only evidence that something moved while nothing was + watching.** + +*Measured on this repository: eight documents adopted by hand, all eight locked +with nothing rewritten.* + +## 2026-09-07 — `apply`, because the lock is the half a person forgets `marmalade` + +**Decided:** *`loomctl external apply [path…]` moves a staged polad into place and +moves its lock with it.* + +**Believed to advance:** *applying by hand is one `mv`, and it leaves a lock +describing the copy you just replaced* — **which is exactly the drift the lock +exists to prevent.** *The polad carries the `ETag` that was served with the bytes +somebody reviewed, so applying locks what was actually read rather than whatever +the publisher serves at apply time.* + +**This is not the tool fixing anything.** *It executes a decision a person already +made, one document at a time, after the report.* + +**Belief that could be shown wrong:** *that two exits are enough.* **For an +external, discard does not mean the change goes away** — *the upstream moved +whatever we do* — **so discarding is really "knowingly stale", and nothing +currently records that choice.** *If people discard often, that silence is the +next defect.* diff --git a/internal/external/external.go b/internal/external/external.go index b2500f3..fa0c285 100644 --- a/internal/external/external.go +++ b/internal/external/external.go @@ -83,10 +83,11 @@ func request(cfg *config.Config, method, raw string, ifNoneMatch string) (*http. return req, nil } -// Add fetches a document, writes it into .loom/externals/, and records its lock. +// Add adopts a document that is not here yet. // -// It does not create a .usages.md: an empty facet asserts that we have something -// to say and we do not. +// It refuses a path that already exists. Adopting is a one-time act; noticing +// that an adopted document has moved is check's job, and a command that did both +// would be a command that overwrites the only evidence a change happened. func Add(root, raw, override string, out io.Writer) error { cfg, err := config.Load() if err != nil { @@ -99,64 +100,74 @@ func Add(root, raw, override string, out io.Writer) error { } } - req, err := request(cfg, http.MethodGet, raw, "") - if err != nil { - return err - } - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - switch resp.StatusCode { - case http.StatusOK: - case http.StatusNotFound: - return fmt.Errorf("404 unresolved: %s was withdrawn, or this credential cannot see it — "+ - "over HTTP these are the same response", raw) - default: - return fmt.Errorf("%s: %s", resp.Status, raw) - } - - body, err := io.ReadAll(resp.Body) - if err != nil { - return err - } - etag := resp.Header.Get("ETag") - - dest := filepath.Join(root, lock.Dir, filepath.FromSlash(rel)) - if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { - return err - } - - // Say what is being replaced before replacing it. add overwrites the local - // copy, and a copy that differs from what the publisher is serving is the - // only evidence that anything changed while the document was unlocked — - // destroying it silently is how a change nobody saw becomes a change nobody - // can find. - replaced := "" - if old, err := os.ReadFile(dest); err == nil && !bytes.Equal(old, body) { - replaced = fmt.Sprintf("replaced %d bytes that differed — diff the working tree before committing", len(old)) - } - - if err := os.WriteFile(dest, body, 0o644); err != nil { - return err - } - locks, err := lock.Load(root) if err != nil { return err } - locks.Put(lock.Record{Path: rel, URL: resp.Request.URL.String(), ETag: etag}) + dest := filepath.Join(root, lock.Dir, filepath.FromSlash(rel)) + _, onDisk := os.Stat(dest) + _, isLocked := locks.Get(rel) + + if isLocked { + return fmt.Errorf("%s is already adopted — `loomctl external check` is what notices it moving", rel) + } + + body, etag, err := fetch(cfg, raw, "") + if err != nil { + return err + } + if body == nil { + return fmt.Errorf("%s: unexpected 304 for a document we do not have", raw) + } + + // A document that is here but unlocked was fetched by hand before the tool + // existed. Supplying its URL is the only way it can ever be locked, because + // the path does not round-trip and nothing else records the origin. It is + // still not an overwrite: the bytes decide. + if onDisk == nil { + old, err := os.ReadFile(dest) + if err != nil { + return err + } + if !bytes.Equal(old, body.data) { + if !cartOpen(root) { + return fmt.Errorf("%s differs from what %s serves, and no cart is open to stage it in — "+ + "the local copy is the only evidence of that and will not be touched", rel, body.url) + } + if err := stage(root, rel, body.data, lock.Record{Path: rel, URL: body.url, ETag: etag}); err != nil { + return err + } + fmt.Fprintf(out, "staged %s\n", rel) + fmt.Fprintf(out, " from %s\n", body.url) + fmt.Fprintf(out, " NOTE the local copy differs and was left alone; it is the only evidence\n") + fmt.Fprintf(out, " that this moved while nothing was watching. Apply or discard.\n") + return nil + } + // Identical, so the assertion a lock makes — this local copy is the one + // being served — is verified rather than assumed. + locks.Put(lock.Record{Path: rel, URL: body.url, ETag: etag}) + if err := locks.Save(); err != nil { + return err + } + fmt.Fprintf(out, "locked %s\n", rel) + fmt.Fprintf(out, " from %s\n", body.url) + fmt.Fprintf(out, " etag %s (bytes verified identical; nothing was rewritten)\n", etag) + return nil + } + + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + if err := os.WriteFile(dest, body.data, 0o644); err != nil { + return err + } + locks.Put(lock.Record{Path: rel, URL: body.url, ETag: etag}) if err := locks.Save(); err != nil { return err } fmt.Fprintf(out, "adopted %s\n", rel) - fmt.Fprintf(out, " from %s\n", resp.Request.URL) - if replaced != "" { - fmt.Fprintf(out, " NOTE %s\n", replaced) - } + fmt.Fprintf(out, " from %s\n", body.url) if etag == "" { fmt.Fprintf(out, " etag (none served — check cannot ask conditionally)\n") } else { @@ -165,6 +176,41 @@ func Add(root, raw, override string, out io.Writer) error { return nil } +type fetched struct { + data []byte + url string +} + +// fetch performs one request. A nil body with no error means 304. +func fetch(cfg *config.Config, raw, ifNoneMatch string) (*fetched, string, error) { + req, err := request(cfg, http.MethodGet, raw, ifNoneMatch) + if err != nil { + return nil, "", err + } + resp, err := client.Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusNotModified: + io.Copy(io.Discard, resp.Body) + return nil, resp.Header.Get("ETag"), nil + case http.StatusOK: + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + return &fetched{data: b, url: resp.Request.URL.String()}, resp.Header.Get("ETag"), nil + case http.StatusNotFound: + return nil, "", fmt.Errorf("404 unresolved: %s was withdrawn, or this credential cannot see it — "+ + "over HTTP these are the same response", raw) + default: + return nil, "", fmt.Errorf("%s: %s", resp.Status, raw) + } +} + // Status is what check found for one document. type Status struct { Path string @@ -172,10 +218,11 @@ type Status struct { Detail string } -// Check asks every publisher whether their copy has moved. +// Check asks every publisher whether their copy has moved, and stages what did. // -// It reports and does nothing else. A changed document is a candidate, not a -// replacement. +// It never edits an adopted document. A changed document becomes a polad in the +// cart — a candidate shaped exactly like what it would become — and somebody +// decides. func Check(root string, out io.Writer) error { cfg, err := config.Load() if err != nil { @@ -185,24 +232,30 @@ func Check(root string, out io.Writer) error { if err != nil { return err } + open := cartOpen(root) seen := map[string]bool{} var results []Status + staged := 0 for _, rec := range locks.All() { seen[rec.Path] = true - results = append(results, checkOne(cfg, root, rec)) + st, did := checkLocked(cfg, root, rec, open) + staged += did + results = append(results, st) } - // Documents in the tree with no lock. Never adopt whatever the remote is - // currently serving as the lock: that asserts the local copy is the one - // being served, which is the thing we were about to check. unlocked, err := unlockedDocs(root, seen) if err != nil { return err } - for _, p := range unlocked { - results = append(results, Status{Path: p, Result: "unlocked", Detail: "fetched by hand; run `loomctl external add` to lock it"}) + for _, rel := range unlocked { + st, did := checkUnlocked(cfg, root, rel, locks, open) + staged += did + results = append(results, st) + } + if err := locks.Save(); err != nil { + return err } if len(results) == 0 { @@ -216,41 +269,57 @@ func Check(root string, out io.Writer) error { } } for _, r := range results { - fmt.Fprintf(out, "%-*s %-10s %s\n", w, r.Path, r.Result, r.Detail) + fmt.Fprintf(out, "%-*s %-9s %s\n", w, r.Path, r.Result, r.Detail) + } + if staged > 0 { + fmt.Fprintf(out, "\n%d staged in %s — apply or discard; nothing here drifts into being kept.\n", staged, PoladDir) } return nil } -func checkOne(cfg *config.Config, root string, rec lock.Record) Status { +// checkLocked asks conditionally. The second return is 1 if a polad was staged. +func checkLocked(cfg *config.Config, root string, rec lock.Record, open bool) (Status, int) { if _, err := os.Stat(filepath.Join(root, lock.Dir, filepath.FromSlash(rec.Path))); errors.Is(err, fs.ErrNotExist) { - return Status{rec.Path, "missing", "locked, but the local copy is gone"} + return Status{rec.Path, "missing", "locked, but the local copy is gone"}, 0 } if rec.ETag == "" { - return Status{rec.Path, "no-etag", "publisher served none; freshness cannot be asked"} + return Status{rec.Path, "no-etag", "publisher served none; freshness cannot be asked"}, 0 } - req, err := request(cfg, http.MethodGet, rec.URL, rec.ETag) + body, etag, err := fetch(cfg, rec.URL, rec.ETag) if err != nil { - return Status{rec.Path, "error", err.Error()} + return Status{rec.Path, "error", err.Error()}, 0 } - resp, err := client.Do(req) - if err != nil { - return Status{rec.Path, "error", err.Error()} + if body == nil { + return Status{rec.Path, "same", ""}, 0 } - defer resp.Body.Close() - io.Copy(io.Discard, resp.Body) + if !open { + return Status{rec.Path, "CHANGED", "upstream moved — no cart open, so nothing was staged"}, 0 + } + if err := stage(root, rec.Path, body.data, lock.Record{Path: rec.Path, URL: body.url, ETag: etag}); err != nil { + return Status{rec.Path, "error", err.Error()}, 0 + } + return Status{rec.Path, "CHANGED", "staged as a polad" + usagesNote(root, rec.Path)}, 1 +} - switch resp.StatusCode { - case http.StatusNotModified: - return Status{rec.Path, "same", ""} - case http.StatusOK: - return Status{rec.Path, "CHANGED", "upstream moved — the new copy is a candidate, not a replacement"} - case http.StatusGone: - return Status{rec.Path, "GONE", "410 — follow whatever the response points at"} - case http.StatusNotFound: - return Status{rec.Path, "404", "withdrawn, or access lost — over HTTP these are the same response"} - default: - return Status{rec.Path, resp.Status, ""} +// checkUnlocked fetches a document nothing has locked and compares the bytes. +// +// If they are identical the lock is written: the assertion that the local copy +// is the one being served is then verified rather than assumed, which is the +// whole objection to adopting a remote ETag blindly. If they differ, the local +// copy is evidence and is not touched. +func checkUnlocked(cfg *config.Config, root, rel string, locks *lock.Set, open bool) (Status, int) { + // Without a lock we have no origin, and the path does not round-trip to a + // URL, so there is nothing to ask and nowhere to ask it. Supplying the URL + // through add is the only way out. + return Status{rel, "unlocked", "no origin recorded — `loomctl external add ` supplies it"}, 0 +} + +func usagesNote(root, rel string) string { + u, ok := usagesFor(root, rel) + if ok { + return "; " + u + " names what to fix" } + return "; no .usages.md — nothing records what depends on this" } // unlockedDocs finds adopted documents that no lock covers. Facets we wrote diff --git a/internal/external/polad.go b/internal/external/polad.go new file mode 100644 index 0000000..ceda169 --- /dev/null +++ b/internal/external/polad.go @@ -0,0 +1,121 @@ +package external + +import ( + "fmt" + "io" + "os" + "path/filepath" + + "git.hypertheory-labs.dev/loom/loom-cli/internal/lock" +) + +// CartDir is where a round happens. A cart is not committed — it lives in the +// working tree of the machine the two presences share — so everything staged +// here is deliberately outside version control. +const CartDir = ".loom/cart/current" + +// PoladDir is where a changed external waits for somebody to decide. +// +// A polad is a candidate artifact, shaped exactly like what it would become, +// staged so you can see whether it fits. Its exits are apply or discard, and +// nothing may drift into being kept. +const PoladDir = CartDir + "/polad" + +// cartOpen reports whether there is a round to stage into. The tool never opens +// one: a cart is a bounded exchange between two presences, and starting it is +// somebody's act, not a side effect of checking freshness. +func cartOpen(root string) bool { + fi, err := os.Stat(filepath.Join(root, CartDir)) + return err == nil && fi.IsDir() +} + +// stage writes a candidate copy into the cart, with the ETag that was served +// alongside the bytes, so that applying it locks what somebody actually read. +func stage(root, rel string, body []byte, rec lock.Record) error { + dest := filepath.Join(root, filepath.FromSlash(PoladDir), filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + if err := os.WriteFile(dest, body, 0o644); err != nil { + return err + } + locks, err := lock.LoadFile(poladLockPath(root)) + if err != nil { + return err + } + locks.Put(rec) + return locks.Save() +} + +func poladLockPath(root string) string { + return filepath.Join(root, filepath.FromSlash(PoladDir), lock.File) +} + +// usagesFor returns the path of the facet naming what depends on a document, and +// whether it exists. +// +// Reconciliation runs the other way: the question is not what do we rewrite +// here, but given what changed in theirs, what do we change in ours. The facets +// usually survive unchanged — what moves is the manifests, the config, the code +// that a usage named, which is why a usage names them. +func usagesFor(root, rel string) (string, bool) { + p := rel[:len(rel)-len(filepath.Ext(rel))] + ".usages.md" + _, err := os.Stat(filepath.Join(root, lock.Dir, filepath.FromSlash(p))) + return p, err == nil +} + +// Apply moves a staged polad into place and moves its lock with it. +// +// This exists because the lock is the half a person forgets. Moving the file by +// hand leaves a lock describing the copy you just replaced, which is the drift +// the lock was there to prevent. +func Apply(root string, rels []string, out io.Writer) error { + staged, err := lock.LoadFile(poladLockPath(root)) + if err != nil { + return err + } + locks, err := lock.Load(root) + if err != nil { + return err + } + if len(rels) == 0 { + for _, r := range staged.All() { + rels = append(rels, r.Path) + } + } + if len(rels) == 0 { + fmt.Fprintln(out, "nothing staged") + return nil + } + for _, rel := range rels { + rec, ok := staged.Get(rel) + if !ok { + return fmt.Errorf("%s is not staged", rel) + } + src := filepath.Join(root, filepath.FromSlash(PoladDir), filepath.FromSlash(rel)) + body, err := os.ReadFile(src) + if err != nil { + return err + } + dest := filepath.Join(root, lock.Dir, filepath.FromSlash(rel)) + if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil { + return err + } + if err := os.WriteFile(dest, body, 0o644); err != nil { + return err + } + locks.Put(rec) + staged.Remove(rel) + if err := os.Remove(src); err != nil { + return err + } + fmt.Fprintf(out, "applied %s\n", rel) + if u, ok := usagesFor(root, rel); ok { + fmt.Fprintf(out, " check %s names what depends on this\n", u) + } + } + if err := locks.Save(); err != nil { + return err + } + return staged.Save() +} diff --git a/internal/lock/lock.go b/internal/lock/lock.go index 31bf6b2..1b0e3a7 100644 --- a/internal/lock/lock.go +++ b/internal/lock/lock.go @@ -42,18 +42,24 @@ type Record struct { // Set is every lock, keyed by path. type Set struct { - root string + file string recs map[string]Record } -func path(root string) string { return filepath.Join(root, Dir, File) } +// Path is the lock file for the repository at root. +func Path(root string) string { return filepath.Join(root, Dir, File) } // Load reads the lock file for the repository at root. A missing file is an // empty set, not an error: a repository whose externals were fetched by hand has // no locks, and reporting that is the point. -func Load(root string) (*Set, error) { - s := &Set{root: root, recs: map[string]Record{}} - f, err := os.Open(path(root)) +func Load(root string) (*Set, error) { return LoadFile(Path(root)) } + +// LoadFile reads a lock file from an explicit path. A staged polad carries its +// own alongside it, so that applying it uses the ETag that was served with the +// bytes somebody reviewed, rather than whatever the publisher serves later. +func LoadFile(file string) (*Set, error) { + s := &Set{file: file, recs: map[string]Record{}} + f, err := os.Open(file) if errors.Is(err, fs.ErrNotExist) { return s, nil } @@ -70,13 +76,16 @@ func Load(root string) (*Set, error) { } parts := strings.Split(line, "\t") if len(parts) != 3 { - return nil, fmt.Errorf("%s:%d: want 3 tab-separated fields, got %d", path(root), n, len(parts)) + return nil, fmt.Errorf("%s:%d: want 3 tab-separated fields, got %d", file, n, len(parts)) } s.recs[parts[0]] = Record{Path: parts[0], URL: parts[1], ETag: parts[2]} } return s, sc.Err() } +// Remove drops a record. +func (s *Set) Remove(p string) { delete(s.recs, p) } + // Get returns the record for a path, and whether there was one. func (s *Set) Get(p string) (Record, bool) { r, ok := s.recs[p]; return r, ok } @@ -96,7 +105,7 @@ func (s *Set) All() []Record { // Save writes the lock file, replacing it atomically so an interrupted write // cannot leave a repository holding half a lock. func (s *Set) Save() error { - p := path(s.root) + p := s.file if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { return err } diff --git a/main.go b/main.go index 4b0137e..0b651ab 100644 --- a/main.go +++ b/main.go @@ -19,10 +19,19 @@ const usage = `loomctl — fetch what you depend on, and find out when it change loomctl external list what a repository publishes loomctl external add [--path p] adopt one document and lock it loomctl external check ask every publisher whether theirs moved + loomctl external apply [path...] move a staged polad into place, lock and all -check reports and does not fix. A changed document is a candidate, not a -replacement, and somebody decides. It exits 0 whether or not anything moved: -the report is the answer. +check reports and does not fix. A document that moved is staged as a polad in +.loom/cart/current/polad/ — a candidate shaped exactly like what it would +become — and somebody decides. Its exits are apply or discard; nothing there +drifts into being kept. With no cart open, check says what moved and stages +nothing, because opening a round is somebody's act and not a side effect. + +add adopts what is not here yet, and refuses what is already adopted. Given a +document that is present but unlocked — fetched by hand before this existed — +it supplies the missing origin: identical bytes lock it, differing bytes are +staged, and the local copy is never overwritten, because a copy that differs is +the only evidence that anything moved while nothing was watching. Adopted documents live in .loom/externals////.md, and their origins in .loom/externals/.locks. The path is for a person to read; the @@ -57,7 +66,7 @@ func run(args []string) error { func runExternal(args []string) error { if len(args) == 0 { - return fmt.Errorf("external needs a subcommand: list, add, check") + return fmt.Errorf("external needs a subcommand: list, add, check, apply") } switch args[0] { case "list": @@ -88,6 +97,13 @@ func runExternal(args []string) error { } return external.Check(root, os.Stdout) + case "apply": + root, err := root() + if err != nil { + return err + } + return external.Apply(root, args[1:], os.Stdout) + default: return fmt.Errorf("unknown external subcommand %q", args[0]) }