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:
Vendored
+152
-83
@@ -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 <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
|
||||
|
||||
Reference in New Issue
Block a user