a changed external is a polad, and add no longer overwrites anything
Restores something the specimen said and the round that discarded the specimen lost with it: a changed external becomes a polad in the cart, and somebody decides. check now stages what moved into .loom/cart/current/polad/ with the ETag that was served alongside the bytes, and prints the .usages.md beside it, because reconciliation runs the other way — the facets usually survive and what moves is the code a usage named. It says so when there is no usages file, which is its own finding. With no cart open, check reports 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. add now adopts what is not here and refuses what is already adopted, superseding the entry that had it announce an overwrite — it no longer overwrites at all. The one exception is the only way out of a dead end: a document present but unlocked was fetched by hand, nothing records its origin, and the path does not round-trip, so check cannot ask about it and a refusal would strand it forever. add accepts it and the bytes decide — identical locks it without rewriting anything, which makes the lock's assertion verified rather than assumed, and different stages a polad and leaves the local copy alone because it is the only evidence anything moved. apply exists because the lock is the half a person forgets: moving a polad by hand leaves a lock describing the copy you just replaced. Recorded with its limit — for an external, discard does not mean the change goes away, so discarding is really knowingly stale and nothing yet records that choice. Measured end to end on this repository: eight hand-fetched documents, all eight locked, nothing rewritten. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
This commit is contained in:
@@ -614,3 +614,77 @@ a comparator.*
|
|||||||
practice nobody ever runs `check` unaided, a tool that only reports when asked is
|
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
|
a tool that reports nothing**, *and the thing we declined to build is the thing
|
||||||
that was needed.*
|
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.*
|
||||||
|
|||||||
Vendored
+152
-83
@@ -83,10 +83,11 @@ func request(cfg *config.Config, method, raw string, ifNoneMatch string) (*http.
|
|||||||
return req, nil
|
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
|
// It refuses a path that already exists. Adopting is a one-time act; noticing
|
||||||
// to say and we do not.
|
// 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 {
|
func Add(root, raw, override string, out io.Writer) error {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
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)
|
locks, err := lock.Load(root)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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 {
|
if err := locks.Save(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Fprintf(out, "adopted %s\n", rel)
|
fmt.Fprintf(out, "adopted %s\n", rel)
|
||||||
fmt.Fprintf(out, " from %s\n", resp.Request.URL)
|
fmt.Fprintf(out, " from %s\n", body.url)
|
||||||
if replaced != "" {
|
|
||||||
fmt.Fprintf(out, " NOTE %s\n", replaced)
|
|
||||||
}
|
|
||||||
if etag == "" {
|
if etag == "" {
|
||||||
fmt.Fprintf(out, " etag (none served — check cannot ask conditionally)\n")
|
fmt.Fprintf(out, " etag (none served — check cannot ask conditionally)\n")
|
||||||
} else {
|
} else {
|
||||||
@@ -165,6 +176,41 @@ func Add(root, raw, override string, out io.Writer) error {
|
|||||||
return nil
|
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.
|
// Status is what check found for one document.
|
||||||
type Status struct {
|
type Status struct {
|
||||||
Path string
|
Path string
|
||||||
@@ -172,10 +218,11 @@ type Status struct {
|
|||||||
Detail string
|
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
|
// It never edits an adopted document. A changed document becomes a polad in the
|
||||||
// replacement.
|
// cart — a candidate shaped exactly like what it would become — and somebody
|
||||||
|
// decides.
|
||||||
func Check(root string, out io.Writer) error {
|
func Check(root string, out io.Writer) error {
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -185,24 +232,30 @@ func Check(root string, out io.Writer) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
open := cartOpen(root)
|
||||||
|
|
||||||
seen := map[string]bool{}
|
seen := map[string]bool{}
|
||||||
var results []Status
|
var results []Status
|
||||||
|
staged := 0
|
||||||
|
|
||||||
for _, rec := range locks.All() {
|
for _, rec := range locks.All() {
|
||||||
seen[rec.Path] = true
|
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)
|
unlocked, err := unlockedDocs(root, seen)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, p := range unlocked {
|
for _, rel := range unlocked {
|
||||||
results = append(results, Status{Path: p, Result: "unlocked", Detail: "fetched by hand; run `loomctl external add` to lock it"})
|
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 {
|
if len(results) == 0 {
|
||||||
@@ -216,41 +269,57 @@ func Check(root string, out io.Writer) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, r := range results {
|
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
|
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) {
|
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 == "" {
|
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 {
|
if err != nil {
|
||||||
return Status{rec.Path, "error", err.Error()}
|
return Status{rec.Path, "error", err.Error()}, 0
|
||||||
}
|
}
|
||||||
resp, err := client.Do(req)
|
if body == nil {
|
||||||
if err != nil {
|
return Status{rec.Path, "same", ""}, 0
|
||||||
return Status{rec.Path, "error", err.Error()}
|
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
if !open {
|
||||||
io.Copy(io.Discard, resp.Body)
|
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 {
|
// checkUnlocked fetches a document nothing has locked and compares the bytes.
|
||||||
case http.StatusNotModified:
|
//
|
||||||
return Status{rec.Path, "same", ""}
|
// If they are identical the lock is written: the assertion that the local copy
|
||||||
case http.StatusOK:
|
// is the one being served is then verified rather than assumed, which is the
|
||||||
return Status{rec.Path, "CHANGED", "upstream moved — the new copy is a candidate, not a replacement"}
|
// whole objection to adopting a remote ETag blindly. If they differ, the local
|
||||||
case http.StatusGone:
|
// copy is evidence and is not touched.
|
||||||
return Status{rec.Path, "GONE", "410 — follow whatever the response points at"}
|
func checkUnlocked(cfg *config.Config, root, rel string, locks *lock.Set, open bool) (Status, int) {
|
||||||
case http.StatusNotFound:
|
// Without a lock we have no origin, and the path does not round-trip to a
|
||||||
return Status{rec.Path, "404", "withdrawn, or access lost — over HTTP these are the same response"}
|
// URL, so there is nothing to ask and nowhere to ask it. Supplying the URL
|
||||||
default:
|
// through add is the only way out.
|
||||||
return Status{rec.Path, resp.Status, ""}
|
return Status{rel, "unlocked", "no origin recorded — `loomctl external add <url>` 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
|
// unlockedDocs finds adopted documents that no lock covers. Facets we wrote
|
||||||
|
|||||||
Vendored
+121
@@ -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()
|
||||||
|
}
|
||||||
+16
-7
@@ -42,18 +42,24 @@ type Record struct {
|
|||||||
|
|
||||||
// Set is every lock, keyed by path.
|
// Set is every lock, keyed by path.
|
||||||
type Set struct {
|
type Set struct {
|
||||||
root string
|
file string
|
||||||
recs map[string]Record
|
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
|
// 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
|
// empty set, not an error: a repository whose externals were fetched by hand has
|
||||||
// no locks, and reporting that is the point.
|
// no locks, and reporting that is the point.
|
||||||
func Load(root string) (*Set, error) {
|
func Load(root string) (*Set, error) { return LoadFile(Path(root)) }
|
||||||
s := &Set{root: root, recs: map[string]Record{}}
|
|
||||||
f, err := os.Open(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) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
@@ -70,13 +76,16 @@ func Load(root string) (*Set, error) {
|
|||||||
}
|
}
|
||||||
parts := strings.Split(line, "\t")
|
parts := strings.Split(line, "\t")
|
||||||
if len(parts) != 3 {
|
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]}
|
s.recs[parts[0]] = Record{Path: parts[0], URL: parts[1], ETag: parts[2]}
|
||||||
}
|
}
|
||||||
return s, sc.Err()
|
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.
|
// 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 }
|
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
|
// Save writes the lock file, replacing it atomically so an interrupted write
|
||||||
// cannot leave a repository holding half a lock.
|
// cannot leave a repository holding half a lock.
|
||||||
func (s *Set) Save() error {
|
func (s *Set) Save() error {
|
||||||
p := path(s.root)
|
p := s.file
|
||||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,10 +19,19 @@ const usage = `loomctl — fetch what you depend on, and find out when it change
|
|||||||
loomctl external list <repo-url> what a repository publishes
|
loomctl external list <repo-url> what a repository publishes
|
||||||
loomctl external add <url> [--path p] adopt one document and lock it
|
loomctl external add <url> [--path p] adopt one document and lock it
|
||||||
loomctl external check ask every publisher whether theirs moved
|
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
|
check reports and does not fix. A document that moved is staged as a polad in
|
||||||
replacement, and somebody decides. It exits 0 whether or not anything moved:
|
.loom/cart/current/polad/ — a candidate shaped exactly like what it would
|
||||||
the report is the answer.
|
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/<host>/<owner>/<repo>/<name>.md, and
|
Adopted documents live in .loom/externals/<host>/<owner>/<repo>/<name>.md, and
|
||||||
their origins in .loom/externals/.locks. The path is for a person to read; the
|
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 {
|
func runExternal(args []string) error {
|
||||||
if len(args) == 0 {
|
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] {
|
switch args[0] {
|
||||||
case "list":
|
case "list":
|
||||||
@@ -88,6 +97,13 @@ func runExternal(args []string) error {
|
|||||||
}
|
}
|
||||||
return external.Check(root, os.Stdout)
|
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:
|
default:
|
||||||
return fmt.Errorf("unknown external subcommand %q", args[0])
|
return fmt.Errorf("unknown external subcommand %q", args[0])
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user