From 1543df0a0c0c8adab4e357f72c69ad7254cb946a Mon Sep 17 00:00:00 2001 From: Jeff Gonzalez Date: Mon, 7 Sep 2026 14:48:29 -0400 Subject: [PATCH] =?UTF-8?q?loomctl=20external:=20list,=20add,=20check=20?= =?UTF-8?q?=E2=80=94=20and=20its=20first=20run=20found=20two=20changed=20d?= =?UTF-8?q?ocuments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go, standard library only, with git shelled out for list alone. list enumerates a publisher's .loom/published by partial clone and ls-tree; add fetches one document, writes it under .loom/externals and records the resolved origin and the publisher's ETag in .loom/externals/.locks; check asks conditionally and reports. The first real run did what the tool exists for. All eight documents adopted by hand before it existed reported unlocked — the tool refuses to invent a lock by adopting whatever the remote currently serves, since that would assert the local copy is the one being served, which is the thing it was about to check. Locking them fetched two that had moved: bedrock/starting.md, which now says the worked example is private and will not link to something you cannot fetch, and cart.md, which went to v1. cart v1 changes a role we cast: a cart is not committed, because a committed cart grows a third file by itself — version control does not require anybody to ask, so the two-file rule is never invoked — and because ignored, gone means gone. Adds .loom/cart/ to .gitignore and supersedes the isolation entry rather than editing it. osprey and marmalade are already in history and are left there: rewriting to honour a rule adopted afterwards costs more than it buys. Records the conflict this creates rather than settling it: the annotation protocol here says commit before dissolving because git is the only archive, and an ignored cart has no archive, so dissolving would destroy the annotations outright. Credentials are read-only, per host, and passed to git through the environment rather than argv, because argv is visible to every process on the machine. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018UTxuSizozEA8yDitPuris --- .gitignore | 5 + .loom/event-log.md | 52 ++++ .loom/externals/.locks | 11 + .../loom/bedrock/starting.md | 14 +- .../loom/cart/cart.md | 49 ++- go.mod | 3 + internal/config/config.go | 84 ++++++ internal/external/external.go | 283 ++++++++++++++++++ internal/external/external_test.go | 72 +++++ internal/external/list.go | 107 +++++++ internal/lock/lock.go | 122 ++++++++ main.go | 102 +++++++ 12 files changed, 897 insertions(+), 7 deletions(-) create mode 100644 .gitignore create mode 100644 .loom/externals/.locks create mode 100644 go.mod create mode 100644 internal/config/config.go create mode 100644 internal/external/external.go create mode 100644 internal/external/external_test.go create mode 100644 internal/external/list.go create mode 100644 internal/lock/lock.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d37546 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# cart v1: a cart is not committed. Ignored, gone means gone. +.loom/cart/ + +# build output +/loomctl diff --git a/.loom/event-log.md b/.loom/event-log.md index 0c0d596..7183ffd 100644 --- a/.loom/event-log.md +++ b/.loom/event-log.md @@ -518,3 +518,55 @@ the cart that named it.* **Recorded rather than fixed.** *The role still works; its justification was written for a case that has not yet occurred.* **If a round ever does produce code while it is open, nothing here changes.** + +## 2026-09-07 — `loomctl external` exists, and its first run reconciled two documents `marmalade` + +**Built: `list`, `add`, `check`.** *Go, no dependencies outside the standard +library, `git` shelled out for `list` only.* + +**The first real run did the thing the tool is for.** *Eight documents were +adopted by hand before it existed; `check` reported all eight `unlocked`, and +`add` locked them* — **and two came back changed**, `bedrock/starting.md` *and* +`cart/cart.md`. **Neither change would have been noticed by anybody.** + +**Believed to advance:** *the lock is what makes `check` possible at all.* **With +no lock there is nothing to compare and the only honest report is `unlocked`** — +*and the tool refuses to invent one by adopting whatever the remote currently +serves.* + +**Belief that could be shown wrong:** *that a per-person path derived from the URL +is good enough.* **`///.md` guesses that the first two +path segments name an owner and a repository**, *which is true of gitea, GitHub and +GitLab and is not a rule.* **`--path` exists for when it is wrong.** + +## 2026-09-07 — supersedes the isolation role: a cart is not committed `marmalade` + +**`cart` is now `v1` and it changed the thing we cast a role on.** + +> **So the cart is not committed.** *It lives in the working tree of the machine +> the two presences share, and `.loom/cart/` is ignored by version control.* + +**The reason is not tidiness:** *a committed cart grows a third file by itself.* +**The two-file rule defends against somebody asking for one; version control does +not require anybody to ask** — *anyone who can clone can add a third, and the +agreement's test is never invoked because nobody had the conversation.* + +**And it is what makes a round end.** *Committed, a cart is gone from the tree and +permanent in history* — **so "gone" means "no longer live" and the negotiation +stays quotable forever.** *Ignored, gone means gone.* + +**So `.loom/cart/` is now in `.gitignore`.** *The isolation role entry above +assumed the cart lived on the default branch; **the cart lives on no branch.*** +*What survives of that entry is the other half: work is isolated on a branch named +for the round that authorised it.* + +**Not undone: `osprey` and `marmalade` are already in this repository's history.** +*Rewriting history to honour a rule adopted afterwards would cost more than it +buys*, **and the two rounds are quotable forever, which is exactly what `v1` says +not to want.** *Recorded rather than repaired.* + +**Belief that could be shown wrong, and it is a real conflict:** *the annotation +protocol this repository works under says **commit before dissolving, git is the +only archive of the conversation.*** **An ignored cart has no archive**, so +dissolving a notes file destroys the annotations outright. *One of the two is +wrong and it is not ours to settle.* diff --git a/.loom/externals/.locks b/.loom/externals/.locks new file mode 100644 index 0000000..8d1faf9 --- /dev/null +++ b/.loom/externals/.locks @@ -0,0 +1,11 @@ +# loomctl locks — one record per adopted document. +# pathurletag The url is resolved: a short form would follow +# whatever the default branch is at the time you ask. +git.hypertheory-labs.dev/loom/annotating/annotating.md https://git.hypertheory-labs.dev/loom/annotating/raw/branch/main/.loom/published/annotating.md "9b1f7e6ca92f2339b2d433686c27845362944bdb" +git.hypertheory-labs.dev/loom/bedrock/loom-directory.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/loom-directory.md "ee0f49cb900c0812678061971194325d9cba366a" +git.hypertheory-labs.dev/loom/bedrock/publication.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md "eb0cb63629a36b056f215dfbe24567d1918cec38" +git.hypertheory-labs.dev/loom/bedrock/recording-decisions.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/recording-decisions.md "970d4b4da76aac99c9b1f1b6580ec20daa42e329" +git.hypertheory-labs.dev/loom/bedrock/sibling-facets.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/sibling-facets.md "a46446a34ccb8bfc533d3cce19f4c88548c4fa04" +git.hypertheory-labs.dev/loom/bedrock/starting.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/starting.md "7d997a30248a88c90b23f2f9453d7cfceb03848e" +git.hypertheory-labs.dev/loom/cart/cart.md https://git.hypertheory-labs.dev/loom/cart/raw/branch/main/.loom/published/cart.md "49fc852bdd280293f0f5e0050034e3b89ff7255e" +git.hypertheory-labs.dev/loom/externals/externals.md https://git.hypertheory-labs.dev/loom/externals/raw/branch/main/.loom/published/externals.md "9641f0e77b6f5c0161fa7593805277ba0c1e6176" diff --git a/.loom/externals/git.hypertheory-labs.dev/loom/bedrock/starting.md b/.loom/externals/git.hypertheory-labs.dev/loom/bedrock/starting.md index e5fba07..7d997a3 100644 --- a/.loom/externals/git.hypertheory-labs.dev/loom/bedrock/starting.md +++ b/.loom/externals/git.hypertheory-labs.dev/loom/bedrock/starting.md @@ -60,9 +60,13 @@ what they came for.* ## Look at one instead of reading this -**[`jeffry/homelab-cluster`](https://git.hypertheory-labs.dev/jeffry/homelab-cluster)** -— *six documents, one gap, no decomposition, and a `README` that says what the -root documents are for and what these are for.* +**There is a worked example — six documents, one gap, no decomposition — and it +is private.** -**It is a better answer than this page**, and if the two ever disagree, it is -right. +*It describes a cluster in enough detail to be a target list, so it is not +published, and **this page will not link you to something you cannot fetch.*** +**If you have access, ask for it by name; if you do not, the two questions at the +top are the whole of it.** + +> **A public page naming a private thing as its canonical answer is worse than no +> example**, and this page did exactly that until somebody measured it. diff --git a/.loom/externals/git.hypertheory-labs.dev/loom/cart/cart.md b/.loom/externals/git.hypertheory-labs.dev/loom/cart/cart.md index 46a2d5d..49fc852 100644 --- a/.loom/externals/git.hypertheory-labs.dev/loom/cart/cart.md +++ b/.loom/externals/git.hypertheory-labs.dev/loom/cart/cart.md @@ -1,6 +1,6 @@ # Agreement — the cart -**v0.** Depends on `annotating/v0`. +**v1.** Depends on `annotating/v0`. **How two parties work out what something means before one of them changes it.** @@ -104,9 +104,54 @@ already rejected, and the rejection is gone because it lived in an annotation that died with the round. **A decline needs no file of its own.** It is an entry in whatever durable record -you keep, and **it should say what you believed, not just what you chose** — only +you keep — **which must outlive the cart**, *and therefore cannot be inside it* — and **it should say what you believed, not just what you chose** — only a belief can later be shown wrong. +## The cart is local, and that is what keeps it to two files + +**A cart is two developers working side by side.** *Everything else — the wider +team, the people who need to know, the thing that has to be tracked — is issues, +chat, whatever you already have.* **This is not that channel and it does not scale +into one.** + +> **So the cart is not committed.** *It lives in the working tree of the machine +> the two presences share, and `.loom/cart/` is ignored by version control.* + +**The reason is not tidiness. A committed cart grows a third file by itself.** +*The rule above defends against somebody asking for one; **version control does +not require anybody to ask.*** *Anyone who can clone can add `joe-rose.md`, and +then `sue-rose.md`, and the agreement's defence — **what happens to this file when +the person changes?** — is never invoked, because nobody ever had the +conversation.* + +**This is also what makes a round actually end.** *Committed, a cart is gone from +the tree and permanent in history — **so "gone" means "no longer live" and +negotiation stays quotable forever.*** **Ignored, gone means gone**, which is what +the round dying was for. + +*The cost, stated: **two presences who do not share a filesystem cannot use a +cart.*** *That is a real limit and it is the right one — if you need a medium +between machines, you need the other channel, and reaching for a cart there is +how it becomes a chat log.* + +## What is not yet a decision goes in the write-ahead log + +**A round produces things that are neither questions nor decisions:** *something +observed, something that may turn out to be noise, something you would kick +yourself for losing and cannot yet justify writing down.* + +**`event-log.wal.md`, in the cart.** *Findings, not decisions.* **Nothing in it is +durable and nothing in it has been decided.** + +**At conversion, each entry either becomes an entry in the durable record or is +discarded.** *Same two exits as a polad, and for the same reason: **conversion is +when you know most about it.*** + +> **Write the reason it is not yet an entry.** *An observation you cannot justify +> promoting is worth keeping; **one you have not said why you are hesitant about +> will be promoted by whoever finds it, on the strength of it having been written +> down.*** + ## Where a cart lives, and the shelf **A fixed path, and at most two things in it:** diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ca4729b --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git.hypertheory-labs.dev/loom/loom-cli + +go 1.25 diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2487c64 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,84 @@ +// Package config reads the per-host settings loomctl needs to talk to a git host. +// +// The config is not only a secret. It is how you talk to a host at all, which is +// why it is keyed by host rather than being a single token. It lives in the +// user's home directory and never in a repository — see .loom/event-log.md, +// "decided by fallback: where the credential lives". +package config + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// Host is what we know about one git host. +type Host struct { + // Token is a read-only personal access token. It must not carry write + // scope: loomctl never writes over the network. + Token string `json:"token,omitempty"` +} + +type Config struct { + Hosts map[string]Host `json:"hosts"` +} + +// Path is where the config lives. Never inside a repository. +func Path() string { + if p := os.Getenv("LOOMCTL_CONFIG"); p != "" { + return p + } + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, ".config", "loomctl", "config.json") +} + +// Load reads the config. A missing file is not an error: everything loomctl does +// against a public repository works with no credential at all. +func Load() (*Config, error) { + c := &Config{Hosts: map[string]Host{}} + p := Path() + if p == "" { + return c, nil + } + b, err := os.ReadFile(p) + if errors.Is(err, fs.ErrNotExist) { + return c, nil + } + if err != nil { + return nil, fmt.Errorf("reading %s: %w", p, err) + } + if err := json.Unmarshal(b, c); err != nil { + return nil, fmt.Errorf("parsing %s: %w", p, err) + } + if c.Hosts == nil { + c.Hosts = map[string]Host{} + } + return c, nil +} + +// TokenFor returns the token for a host, or "" if we have none. +// +// An environment variable wins over the file, so a token can be supplied for one +// invocation without ever being written to disk. +func (c *Config) TokenFor(host string) string { + if t := os.Getenv("LOOMCTL_TOKEN_" + envKey(host)); t != "" { + return t + } + if t := os.Getenv("LOOMCTL_TOKEN"); t != "" { + return t + } + return c.Hosts[host].Token +} + +// envKey turns a hostname into the shape an environment variable can carry. +func envKey(host string) string { + r := strings.NewReplacer(".", "_", "-", "_", ":", "_") + return strings.ToUpper(r.Replace(host)) +} diff --git a/internal/external/external.go b/internal/external/external.go new file mode 100644 index 0000000..211deba --- /dev/null +++ b/internal/external/external.go @@ -0,0 +1,283 @@ +// 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 ( + "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: ///. 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 + } + 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 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 +} diff --git a/internal/external/external_test.go b/internal/external/external_test.go new file mode 100644 index 0000000..79b5a3d --- /dev/null +++ b/internal/external/external_test.go @@ -0,0 +1,72 @@ +package external + +import "testing" + +func TestLocalPath(t *testing.T) { + for _, tc := range []struct { + name, url, want string + wantErr bool + }{ + { + name: "gitea raw, published document", + url: "https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/starting.md", + want: "git.hypertheory-labs.dev/loom/bedrock/starting.md", + }, + { + name: "github raw host", + url: "https://raw.githubusercontent.com/octocat/Hello-World/main/README.md", + want: "raw.githubusercontent.com/octocat/Hello-World/README.md", + }, + { + name: "not enough path to name an owner and repository", + url: "https://example.com/thing.md", + wantErr: true, + }, + { + name: "not absolute", + url: "/loom/bedrock/starting.md", + wantErr: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := localPath(tc.url) + if tc.wantErr { + if err == nil { + t.Fatalf("localPath(%q) = %q, want an error", tc.url, got) + } + return + } + if err != nil { + t.Fatalf("localPath(%q): %v", tc.url, err) + } + if got != tc.want { + t.Errorf("localPath(%q) = %q, want %q", tc.url, got, tc.want) + } + }) + } +} + +func TestIsFacet(t *testing.T) { + // A facet is something we wrote beside an adopted document. check must not + // report our own writing as an unlocked external. + facets := []string{ + "host/loom/cart/cart.usages.md", + "host/loom/externals/externals.gaps.md", + "host/o/r/plan.notes.md", + } + documents := []string{ + "host/loom/cart/cart.md", + "host/loom/bedrock/recording-decisions.md", // a hyphen is not a facet + "host/o/r/starting.md", + } + for _, p := range facets { + if !isFacet(p) { + t.Errorf("isFacet(%q) = false, want true", p) + } + } + for _, p := range documents { + if isFacet(p) { + t.Errorf("isFacet(%q) = true, want false", p) + } + } +} diff --git a/internal/external/list.go b/internal/external/list.go new file mode 100644 index 0000000..8b7db0a --- /dev/null +++ b/internal/external/list.go @@ -0,0 +1,107 @@ +package external + +import ( + "bytes" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "git.hypertheory-labs.dev/loom/loom-cli/internal/config" +) + +// PublishedDir is the only directory in somebody else's repository that list +// looks at. What is not exported is not hidden — it is simply not what you +// depend on. +const PublishedDir = ".loom/published" + +// List enumerates a publisher's published surface. +// +// It shells out to git rather than using a host's REST API, because git is the +// one interface gitea, GitHub and GitLab all speak the same way: their contents +// APIs have three different URL shapes, three JSON shapes and three auth +// schemes, and a private repository refuses the anonymous ones. The cost is that +// git must be on PATH. +func List(repoURL string, out io.Writer) error { + cfg, err := config.Load() + if err != nil { + return err + } + + dir, err := os.MkdirTemp("", "loomctl-list-") + if err != nil { + return err + } + defer os.RemoveAll(dir) + + clone := exec.Command("git", "clone", + "--filter=blob:none", // trees only: we want names, not contents + "--depth=1", // and bound the damage if the server ignores the filter + "--no-checkout", + "--quiet", + repoURL, dir, + ) + clone.Env = gitEnv(cfg, repoURL) + var stderr bytes.Buffer + clone.Stderr = &stderr + if err := clone.Run(); err != nil { + return fmt.Errorf("git clone: %w\n%s", err, strings.TrimSpace(stderr.String())) + } + + // git's fallback when a server refuses the filter is silent apart from this + // warning, and the fallback is to download everything. + if strings.Contains(stderr.String(), "filtering not recognized by server") { + fmt.Fprintf(out, "warning: %s ignored --filter, so this fetched every blob at HEAD\n\n", repoURL) + } + + ls := exec.Command("git", "-C", dir, "ls-tree", "--name-only", "HEAD:"+PublishedDir) + var names, lsErr bytes.Buffer + ls.Stdout, ls.Stderr = &names, &lsErr + if err := ls.Run(); err != nil { + fmt.Fprintf(out, "%s publishes nothing — no %s\n", repoURL, PublishedDir) + return nil + } + + for _, n := range strings.Split(strings.TrimSpace(names.String()), "\n") { + if n != "" { + fmt.Fprintln(out, n) + } + } + return nil +} + +// gitEnv passes credentials to git through the environment rather than through +// -c on the command line, because argv is visible to every process on the +// machine and an environment is not. +func gitEnv(cfg *config.Config, repoURL string) []string { + env := append(os.Environ(), "GIT_TERMINAL_PROMPT=0") + host := hostOf(repoURL) + if host == "" { + return env + } + t := cfg.TokenFor(host) + if t == "" { + return env + } + return append(env, + "GIT_CONFIG_COUNT=1", + "GIT_CONFIG_KEY_0=http.extraHeader", + "GIT_CONFIG_VALUE_0=Authorization: token "+t, + ) +} + +func hostOf(raw string) string { + i := strings.Index(raw, "://") + if i < 0 { + return "" + } + rest := raw[i+3:] + if at := strings.Index(rest, "@"); at >= 0 { + rest = rest[at+1:] + } + if s := strings.IndexAny(rest, "/:"); s >= 0 { + rest = rest[:s] + } + return rest +} diff --git a/internal/lock/lock.go b/internal/lock/lock.go new file mode 100644 index 0000000..31bf6b2 --- /dev/null +++ b/internal/lock/lock.go @@ -0,0 +1,122 @@ +// Package lock reads and writes .loom/externals/.locks. +// +// One record per adopted document: where it was fetched from, resolved, and the +// ETag the publisher served with it. The ETag is opaque and is never a hash we +// compute — on gitea it happens to equal the git blob hash and on GitHub it does +// not, so a design that compares a local hash to a remote ETag works on exactly +// one host by coincidence. See .loom/event-log.md. +package lock + +import ( + "bufio" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" +) + +// Dir is where adopted documents live, relative to the repository root. +const Dir = ".loom/externals" + +// File is the lock file, inside Dir. +const File = ".locks" + +const header = "# loomctl locks — one record per adopted document.\n" + + "# pathurletag The url is resolved: a short form would follow\n" + + "# whatever the default branch is at the time you ask.\n" + +// Record is one adopted document. +type Record struct { + // Path is relative to Dir, and is for a person to read. The origin is the + // URL: the path does not round-trip, because it drops the route, the + // branch, and .loom/published/. + Path string + // URL is the resolved origin, branch and all. + URL string + // ETag is the publisher's, verbatim, including its quotes. + ETag string +} + +// Set is every lock, keyed by path. +type Set struct { + root string + recs map[string]Record +} + +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)) + if errors.Is(err, fs.ErrNotExist) { + return s, nil + } + if err != nil { + return nil, err + } + defer f.Close() + + sc := bufio.NewScanner(f) + for n := 1; sc.Scan(); n++ { + line := sc.Text() + if strings.TrimSpace(line) == "" || strings.HasPrefix(line, "#") { + continue + } + 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)) + } + s.recs[parts[0]] = Record{Path: parts[0], URL: parts[1], ETag: parts[2]} + } + return s, sc.Err() +} + +// 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 } + +// Put adds or replaces a record. +func (s *Set) Put(r Record) { s.recs[r.Path] = r } + +// All returns every record, ordered by path so the file diffs cleanly. +func (s *Set) All() []Record { + out := make([]Record, 0, len(s.recs)) + for _, r := range s.recs { + out = append(out, r) + } + sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path }) + return out +} + +// 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) + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return err + } + var b strings.Builder + b.WriteString(header) + for _, r := range s.All() { + fmt.Fprintf(&b, "%s\t%s\t%s\n", r.Path, r.URL, r.ETag) + } + tmp, err := os.CreateTemp(filepath.Dir(p), ".locks-*") + if err != nil { + return err + } + if _, err := tmp.WriteString(b.String()); err != nil { + tmp.Close() + os.Remove(tmp.Name()) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmp.Name()) + return err + } + return os.Rename(tmp.Name(), p) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4b0137e --- /dev/null +++ b/main.go @@ -0,0 +1,102 @@ +// loomctl fetches documents this repository depends on, and finds out when they +// change. +// +// It reports and never repairs. Everything it writes, it writes to the working +// tree — committing and pushing are yours, because the consequences of a push +// land on people a tool cannot experience. +package main + +import ( + "flag" + "fmt" + "os" + + "git.hypertheory-labs.dev/loom/loom-cli/internal/external" +) + +const usage = `loomctl — fetch what you depend on, and find out when it changed. + + 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 + +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. + +Adopted documents live in .loom/externals////.md, and +their origins in .loom/externals/.locks. The path is for a person to read; the +lock is what a machine uses, because the path does not round-trip to a URL. + +Credentials are read-only and per host, in ~/.config/loomctl/config.json or in +LOOMCTL_TOKEN_. loomctl never writes over the network, so a token it is +given should never carry write scope. + +Requires git on PATH, for list only. +` + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintln(os.Stderr, "loomctl: "+err.Error()) + os.Exit(1) + } +} + +func run(args []string) error { + if len(args) == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help" { + fmt.Print(usage) + return nil + } + switch args[0] { + case "external": + return runExternal(args[1:]) + default: + return fmt.Errorf("unknown command %q\n\n%s", args[0], usage) + } +} + +func runExternal(args []string) error { + if len(args) == 0 { + return fmt.Errorf("external needs a subcommand: list, add, check") + } + switch args[0] { + case "list": + if len(args) != 2 { + return fmt.Errorf("usage: loomctl external list ") + } + return external.List(args[1], os.Stdout) + + case "add": + fs := flag.NewFlagSet("add", flag.ContinueOnError) + path := fs.String("path", "", "where to keep it, relative to .loom/externals (default: derived from the URL)") + if err := fs.Parse(args[1:]); err != nil { + return err + } + if fs.NArg() != 1 { + return fmt.Errorf("usage: loomctl external add [--path p]") + } + root, err := root() + if err != nil { + return err + } + return external.Add(root, fs.Arg(0), *path, os.Stdout) + + case "check": + root, err := root() + if err != nil { + return err + } + return external.Check(root, os.Stdout) + + default: + return fmt.Errorf("unknown external subcommand %q", args[0]) + } +} + +func root() (string, error) { + wd, err := os.Getwd() + if err != nil { + return "", err + } + return external.FindRoot(wd) +}