The upstream cart.md change was planted to test whether the tool would notice, and the honest sequence is that check did not catch it. Every document was unlocked, and unlocked means I cannot tell you. add revealed the change by overwriting the file, and the only reason the diff was visible is that git happened to be watching the working tree. So the mechanism is proven and the workflow is not: a document nobody has locked is a document nobody is checking, silently. Fixes the defect that exposed. add reported only "adopted" while overwriting local content that differed from what the publisher serves — and a differing copy is the only evidence that something moved while the document was unlocked. It now says so. Recorded with its own limit: add still overwrites rather than stashing, so outside a git working tree the note says something was lost without letting you see it. Also records the unowned half. Nothing decides when check runs, and every answer — a hook, CI, an agent's session start, a schedule — is outside the tool. Filed as a need with no owner rather than built, because choosing one would put a scheduler inside a fetcher and a comparator, with the falsifier that if nobody ever runs check unaided then a tool that reports only when asked reports nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris
299 lines
8.0 KiB
Go
299 lines
8.0 KiB
Go
// Package external implements the operations on documents somebody else
|
|
// published that we depend on.
|
|
//
|
|
// Every act is a fetch or a comparison. Nothing here repairs anything: a changed
|
|
// external is a candidate, not a replacement, and somebody decides.
|
|
package external
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.hypertheory-labs.dev/loom/loom-cli/internal/config"
|
|
"git.hypertheory-labs.dev/loom/loom-cli/internal/lock"
|
|
)
|
|
|
|
var client = &http.Client{Timeout: 30 * time.Second}
|
|
|
|
// FindRoot walks up from dir looking for the .loom directory that marks a
|
|
// repository using loom.
|
|
func FindRoot(dir string) (string, error) {
|
|
d, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
for {
|
|
if fi, err := os.Stat(filepath.Join(d, ".loom")); err == nil && fi.IsDir() {
|
|
return d, nil
|
|
}
|
|
parent := filepath.Dir(d)
|
|
if parent == d {
|
|
return "", errors.New("no .loom directory found in this directory or any parent")
|
|
}
|
|
d = parent
|
|
}
|
|
}
|
|
|
|
// localPath derives where an adopted document is kept from the URL it came from.
|
|
//
|
|
// The path is for a person: <host>/<owner>/<repo>/<basename>. It deliberately
|
|
// does not encode the route, the branch, or .loom/published/ — which is why it
|
|
// cannot be turned back into a URL, and why the lock records the origin.
|
|
func localPath(raw string) (string, error) {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if u.Host == "" || u.Scheme == "" {
|
|
return "", fmt.Errorf("not an absolute URL: %s", raw)
|
|
}
|
|
segs := strings.Split(strings.Trim(u.Path, "/"), "/")
|
|
if len(segs) < 3 {
|
|
return "", fmt.Errorf("cannot tell owner and repository from %s — pass --path", raw)
|
|
}
|
|
base := segs[len(segs)-1]
|
|
if base == "" {
|
|
return "", fmt.Errorf("no file name in %s", raw)
|
|
}
|
|
return path.Join(u.Host, segs[0], segs[1], base), nil
|
|
}
|
|
|
|
// request builds a GET carrying the host's token, if we have one.
|
|
func request(cfg *config.Config, method, raw string, ifNoneMatch string) (*http.Request, error) {
|
|
req, err := http.NewRequest(method, raw, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if t := cfg.TokenFor(req.URL.Host); t != "" {
|
|
req.Header.Set("Authorization", "token "+t)
|
|
}
|
|
if ifNoneMatch != "" {
|
|
req.Header.Set("If-None-Match", ifNoneMatch)
|
|
}
|
|
return req, nil
|
|
}
|
|
|
|
// Add fetches a document, writes it into .loom/externals/, and records its lock.
|
|
//
|
|
// It does not create a .usages.md: an empty facet asserts that we have something
|
|
// to say and we do not.
|
|
func Add(root, raw, override string, out io.Writer) error {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel := override
|
|
if rel == "" {
|
|
if rel, err = localPath(raw); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
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})
|
|
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)
|
|
}
|
|
if etag == "" {
|
|
fmt.Fprintf(out, " etag (none served — check cannot ask conditionally)\n")
|
|
} else {
|
|
fmt.Fprintf(out, " etag %s\n", etag)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Status is what check found for one document.
|
|
type Status struct {
|
|
Path string
|
|
Result string
|
|
Detail string
|
|
}
|
|
|
|
// Check asks every publisher whether their copy has moved.
|
|
//
|
|
// It reports and does nothing else. A changed document is a candidate, not a
|
|
// replacement.
|
|
func Check(root string, out io.Writer) error {
|
|
cfg, err := config.Load()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
locks, err := lock.Load(root)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
seen := map[string]bool{}
|
|
var results []Status
|
|
|
|
for _, rec := range locks.All() {
|
|
seen[rec.Path] = true
|
|
results = append(results, checkOne(cfg, root, rec))
|
|
}
|
|
|
|
// 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"})
|
|
}
|
|
|
|
if len(results) == 0 {
|
|
fmt.Fprintln(out, "no adopted documents")
|
|
return nil
|
|
}
|
|
w := 0
|
|
for _, r := range results {
|
|
if len(r.Path) > w {
|
|
w = len(r.Path)
|
|
}
|
|
}
|
|
for _, r := range results {
|
|
fmt.Fprintf(out, "%-*s %-10s %s\n", w, r.Path, r.Result, r.Detail)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkOne(cfg *config.Config, root string, rec lock.Record) Status {
|
|
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"}
|
|
}
|
|
if rec.ETag == "" {
|
|
return Status{rec.Path, "no-etag", "publisher served none; freshness cannot be asked"}
|
|
}
|
|
req, err := request(cfg, http.MethodGet, rec.URL, rec.ETag)
|
|
if err != nil {
|
|
return Status{rec.Path, "error", err.Error()}
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return Status{rec.Path, "error", err.Error()}
|
|
}
|
|
defer resp.Body.Close()
|
|
io.Copy(io.Discard, resp.Body)
|
|
|
|
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, ""}
|
|
}
|
|
}
|
|
|
|
// unlockedDocs finds adopted documents that no lock covers. Facets we wrote
|
|
// ourselves are not adopted documents and are skipped.
|
|
func unlockedDocs(root string, locked map[string]bool) ([]string, error) {
|
|
base := filepath.Join(root, lock.Dir)
|
|
var out []string
|
|
err := filepath.WalkDir(base, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
if errors.Is(err, fs.ErrNotExist) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
if d.IsDir() || !strings.HasSuffix(d.Name(), ".md") {
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(base, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rel = filepath.ToSlash(rel)
|
|
if locked[rel] || isFacet(rel) {
|
|
return nil
|
|
}
|
|
out = append(out, rel)
|
|
return nil
|
|
})
|
|
return out, err
|
|
}
|
|
|
|
// isFacet reports whether a path is something we wrote beside an adopted
|
|
// document rather than the document itself: x.usages.md, x.gaps.md, x.notes.md.
|
|
func isFacet(rel string) bool {
|
|
base := strings.TrimSuffix(path.Base(rel), ".md")
|
|
i := strings.LastIndex(base, ".")
|
|
if i < 0 {
|
|
return false
|
|
}
|
|
switch base[i+1:] {
|
|
case "usages", "gaps", "notes":
|
|
return true
|
|
}
|
|
return false
|
|
}
|