diff --git a/.loom/event-log.md b/.loom/event-log.md index 337eff4..0db0c76 100644 --- a/.loom/event-log.md +++ b/.loom/event-log.md @@ -1110,3 +1110,34 @@ opaque strings cannot support.* **Belief that could be shown wrong:** *that fifty is readable.* **It is today.** *The next remedy is not another compaction — it is the guide `bedrock` needed, and it is not needed yet.* + +## 2026-09-08 — a `404` at adoption has a third reading, and it is resolvable + +**`externals` names two readings of a `404` — withdrawn, or access lost — and +they are the two a **locked** document can have.** *Adoption by name has a third: +**a document that was never there under that name**.* + +*Found in use: `loomctl external add jeffry/homelab-cluster database` reported the +ambiguity for what was a missing `s`.* + +**The tool was reporting an ambiguity it had the means to resolve.** *For the +by-name form it knows the repository, so on a `404` it now lists the published +surface:* + +``` +loomctl: jeffry/homelab-cluster publishes no "database.md". It publishes: + databases.md gitea.md private-access.md ... +``` + +**And it only claims that when the listing succeeds.** *If listing fails too, the +repository itself is unreachable and the original ambiguity is the honest +answer* — **the same discipline as reporting `public` and `not-public` rather than +`private`: say the thing you verified.** + +**Not a gap against `externals`.** *The third reading cannot occur where that +document is speaking, which is `check` against a lock.* **It appears only at +adoption, which is ours.** + +**Belief that could be shown wrong:** *that an extra round trip on a failure is +free.* **It is one partial clone on a path nobody takes twice**, *and if adoption +against large repositories becomes common the listing should be bounded.* diff --git a/internal/external/byname.go b/internal/external/byname.go index 502a5a3..07841e8 100644 --- a/internal/external/byname.go +++ b/internal/external/byname.go @@ -2,6 +2,7 @@ package external import ( "bytes" + "errors" "fmt" "io" "os/exec" @@ -50,7 +51,30 @@ func AddByName(root, ownerRepo, name string, out io.Writer) error { } raw = fmt.Sprintf(raw, branch) } - return Add(root, raw, "", out) + err = Add(root, raw, "", out) + + // A 404 here has a reading the convention does not list, because it can only + // happen at adoption: the name is wrong. We know the repository, so rather + // than reporting an ambiguity we can resolve, look. + var nf *NotFoundError + if errors.As(err, &nf) { + var names strings.Builder + if lerr := List(ctx.CloneURL(ownerRepo), &names); lerr == nil { + return fmt.Errorf("%s publishes no %q. It publishes:\n%s", + ownerRepo, name, indent(names.String())) + } + // Listing failed too, so the repository itself is unreachable and the + // original ambiguity stands. + } + return err +} + +func indent(s string) string { + var b strings.Builder + for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") { + fmt.Fprintf(&b, " %s\n", line) + } + return strings.TrimRight(b.String(), "\n") } // ListByName enumerates a repository named against the current context. diff --git a/internal/external/external.go b/internal/external/external.go index 71b78a5..b4a8449 100644 --- a/internal/external/external.go +++ b/internal/external/external.go @@ -253,6 +253,19 @@ func warnIfNotPublic(root, raw, vis string, out io.Writer) { fmt.Fprintf(out, " record only the dependency, which loomctl cannot do yet.\n") } +// NotFoundError is a 404, which over HTTP carries more than one reading. +// +// The convention names two — withdrawn, or access lost — because those are the +// two a locked document can have. Adoption by name has a third: a document that +// was never there under that name. Callers that know the repository can tell +// them apart; this type is how they get the chance. +type NotFoundError struct{ URL string } + +func (e *NotFoundError) Error() string { + return fmt.Sprintf("404 unresolved: %s was withdrawn, or this credential cannot see it — "+ + "over HTTP these are the same response", e.URL) +} + type fetched struct { data []byte url string @@ -281,8 +294,7 @@ func fetch(cfg *config.Config, raw, ifNoneMatch string) (*fetched, string, error } 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) + return nil, "", &NotFoundError{URL: raw} default: return nil, "", fmt.Errorf("%s: %s", resp.Status, raw) }