Compare commits
22
Commits
f0b3269610
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7b169b87e | ||
|
|
a5093c1efc | ||
|
|
045924ec36 | ||
|
|
d3fec24bc6 | ||
|
|
4e7aa1ff1f | ||
|
|
af8f739bfe | ||
|
|
b3617ed195 | ||
|
|
da6e8b9b51 | ||
|
|
1cc528c8aa | ||
|
|
fb8b8d01d3 | ||
|
|
fb96b2f90b | ||
|
|
73b3a97dce | ||
|
|
104cbd050c | ||
|
|
49c7297d08 | ||
|
|
e0d7afecae | ||
|
|
c09a1b7570 | ||
|
|
f89f7c25c6 | ||
|
|
6a5966ba99 | ||
|
|
c0ae0e892f | ||
|
|
021bf63a11 | ||
|
|
57ed133021 | ||
|
|
1543df0a0c |
@@ -0,0 +1,5 @@
|
|||||||
|
# cart v1: a cart is not committed. Ignored, gone means gone.
|
||||||
|
.loom/cart/
|
||||||
|
|
||||||
|
# build output
|
||||||
|
/loomctl
|
||||||
+682
-59
@@ -7,21 +7,6 @@ by saying so.*
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 2026-09-07 — what this tool is `osprey`
|
|
||||||
|
|
||||||
**Decided:** *`loom-cli` is a fetcher and a comparator.* **It fetches a document
|
|
||||||
you depend on and tells you whether the publisher's copy has moved.** *It reports
|
|
||||||
and never repairs.*
|
|
||||||
|
|
||||||
**Believed to advance:** *every act in the loom model is a file in somebody's
|
|
||||||
repository — publishing is writing one, adopting is fetching a URL, homing a gap
|
|
||||||
is an `mv`.* **Almost nothing needs a program**, so the program should be the
|
|
||||||
part that cannot be done by hand: an HTTP request, repeated.
|
|
||||||
|
|
||||||
**Belief that could be shown wrong:** *that reporting is enough.* **If every
|
|
||||||
report is followed by the same manual act, we have moved the work rather than
|
|
||||||
removed it**, and the missing command will be obvious.
|
|
||||||
|
|
||||||
## 2026-09-07 — the design lives here and not in a spec `osprey`
|
## 2026-09-07 — the design lives here and not in a spec `osprey`
|
||||||
|
|
||||||
**Decided:** *this log is the artifact of `osprey`.* **No specification document
|
**Decided:** *this log is the artifact of `osprey`.* **No specification document
|
||||||
@@ -72,40 +57,6 @@ somebody's published surface or from a file they may rename at will.
|
|||||||
location.* **If anybody writes code that parses it back into a URL, the path
|
location.* **If anybody writes code that parses it back into a URL, the path
|
||||||
should stop looking like one.**
|
should stop looking like one.**
|
||||||
|
|
||||||
## 2026-09-07 — the lock, and what it holds `osprey`
|
|
||||||
|
|
||||||
**Decided:** *one record per adopted document in `.loom/externals/.locks`: the
|
|
||||||
**resolved** origin URL, and the `ETag` it came with.*
|
|
||||||
|
|
||||||
**Believed to advance:** *the specimen said there would be no lock file, on the
|
|
||||||
strength of the `ETag`-as-blob-hash claim.* **That claim failed twice**, so the
|
|
||||||
file exists and is named for what it is. *It was briefly called `.etags`, from
|
|
||||||
when we believed there would be one field.*
|
|
||||||
|
|
||||||
**Resolved, and not the short form.** *Measured: gitea `303`s
|
|
||||||
`/loom/externals/raw/externals.md` to `/raw/branch/main/…`* — **so a lock holding a
|
|
||||||
short URL is locked to whatever the default branch is at the time you ask**, and a
|
|
||||||
branch rename reports as a change in the document.
|
|
||||||
|
|
||||||
**Belief that could be shown wrong:** *that one file is cheap.* **Two fetches in
|
|
||||||
one round conflict inside it, and the conflict is in a file no human can resolve
|
|
||||||
by reading.** *If that bites, it becomes one record per document and the tree is
|
|
||||||
mirrored twice.*
|
|
||||||
|
|
||||||
## 2026-09-07 — unlocked is a state `osprey`
|
|
||||||
|
|
||||||
**Decided:** *a document with no lock is reported as unlocked.* **`check` never
|
|
||||||
adopts whatever the remote is currently serving as the lock.**
|
|
||||||
|
|
||||||
**Believed to advance:** *adopting it would assert the local copy is the one being
|
|
||||||
served, which is the thing you were about to check.* **It is the hash assumption
|
|
||||||
in a different coat, and it fails silently in the one case that matters — a copy
|
|
||||||
somebody edited.**
|
|
||||||
|
|
||||||
**Belief that could be shown wrong:** *that anybody will run `pull` again to fix
|
|
||||||
it.* **If unlocked documents simply accumulate, the report is noise and something
|
|
||||||
has to lock them.**
|
|
||||||
|
|
||||||
## 2026-09-07 — `404` is unresolved, and that is no longer provisional `osprey`
|
## 2026-09-07 — `404` is unresolved, and that is no longer provisional `osprey`
|
||||||
|
|
||||||
**Decided:** *`check` reports a `404` as unresolvable — the document was withdrawn
|
**Decided:** *`check` reports a `404` as unresolvable — the document was withdrawn
|
||||||
@@ -450,16 +401,6 @@ unknown.**
|
|||||||
next token*, **and because if a read scope is not enough, that is the only argument
|
next token*, **and because if a read scope is not enough, that is the only argument
|
||||||
for keeping ssh.**
|
for keeping ssh.**
|
||||||
|
|
||||||
## 2026-09-07 — `--help` is the documentation surface `marmalade`
|
|
||||||
|
|
||||||
**Decided:** *`--help` is what a person or an agent actually runs when handed an
|
|
||||||
unfamiliar CLI, so it is the surface that has to be good* — **say what a command
|
|
||||||
does and what it will not do.**
|
|
||||||
|
|
||||||
**A `.loom/published/` for `loomctl` is deferred, not declined.** *The distinction
|
|
||||||
matters because `init` was declined.* **It earns publication when somebody depends
|
|
||||||
on this tool's behaviour rather than reading its help.**
|
|
||||||
|
|
||||||
## 2026-09-07 — decided by fallback: where the credential lives `marmalade`
|
## 2026-09-07 — decided by fallback: where the credential lives `marmalade`
|
||||||
|
|
||||||
**Nobody chose this; the fallback became the decision and that is recorded so it
|
**Nobody chose this; the fallback became the decision and that is recorded so it
|
||||||
@@ -518,3 +459,685 @@ the cart that named it.*
|
|||||||
**Recorded rather than fixed.** *The role still works; its justification was
|
**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
|
written for a case that has not yet occurred.* **If a round ever does produce code
|
||||||
while it is open, nothing here changes.**
|
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.* **`<host>/<owner>/<repo>/<name>.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.*
|
||||||
|
|
||||||
|
## 2026-09-07 — what the planted change actually proved `marmalade`
|
||||||
|
|
||||||
|
**`cart.md` was changed upstream deliberately, without telling us, to see whether
|
||||||
|
the tool would notice.** *It did — and the sequence is worth recording, because
|
||||||
|
the obvious reading is wrong.*
|
||||||
|
|
||||||
|
**`check` did not catch it.** *All eight documents were unlocked, and `unlocked`
|
||||||
|
means **I cannot tell you**.* **It was `add` that revealed the change, by
|
||||||
|
overwriting the file** — *and the only reason anybody saw what had changed is that
|
||||||
|
`git` happened to be watching the working tree.*
|
||||||
|
|
||||||
|
> **So the mechanism is proven and the workflow is not.** *A conditional request
|
||||||
|
> against a lock works. **A document nobody has locked is a document nobody is
|
||||||
|
> checking**, and it stays that way silently.*
|
||||||
|
|
||||||
|
**Decided, as a consequence:** *`add` now reports when it replaces local content
|
||||||
|
that differs from what the publisher is serving.* **It used to say only
|
||||||
|
`adopted`.** *A copy that differs is the only evidence that something moved while
|
||||||
|
the document was unlocked, and destroying it silently is how a change nobody saw
|
||||||
|
becomes a change nobody can find.*
|
||||||
|
|
||||||
|
**Belief that could be shown wrong:** *that a note is enough.* **`add` still
|
||||||
|
overwrites** — *it does not stash the old bytes anywhere* — **and outside a git
|
||||||
|
working tree the note tells you something was lost without letting you see it.**
|
||||||
|
*If that bites, `add` needs `--dry-run` or a refusal.*
|
||||||
|
|
||||||
|
## 2026-09-07 — the unowned half: somebody has to run it `marmalade`
|
||||||
|
|
||||||
|
**Nothing here answers *when* `check` runs.**
|
||||||
|
|
||||||
|
*`bedrock` says it about running systems and it is just as true of this:*
|
||||||
|
**nothing serves the truth, so the only mechanism is somebody looking.** *The tool
|
||||||
|
makes looking cheap; it does not make it happen.*
|
||||||
|
|
||||||
|
**Recorded as a need with no owner rather than a feature**, *because the answers
|
||||||
|
are all outside the tool* — **a git hook, a CI job, an agent's session start, a
|
||||||
|
scheduled run** — *and choosing one here would put a scheduler inside a fetcher and
|
||||||
|
a comparator.*
|
||||||
|
|
||||||
|
**Belief that could be shown wrong:** *that staying out of it is right.* **If in
|
||||||
|
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
|
||||||
|
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.*
|
||||||
|
|
||||||
|
## 2026-09-07 — declined: a separate `adopt` verb `marmalade`
|
||||||
|
|
||||||
|
**Considered:** *`loomctl external adopt <path> <url>`* — **a command whose job is
|
||||||
|
to say "this copy came from there"**, *for a document that is present but
|
||||||
|
unlocked.* **Both parties reached the deadlock independently and this was the
|
||||||
|
other way out.**
|
||||||
|
|
||||||
|
**Not done, because the byte comparison makes the verb unnecessary.** *Whatever
|
||||||
|
the command is called, it must not trust the claim* — **it has to fetch and
|
||||||
|
compare, because the whole point is that nobody knows whether the local copy is
|
||||||
|
still a copy.** *Once it does that, it is `add` with an origin supplied, and the
|
||||||
|
user's intent in both cases is the same sentence: **I depend on this document and
|
||||||
|
here is where it lives.***
|
||||||
|
|
||||||
|
**And a verb earns its place by naming an act, not a state.** *"Present but
|
||||||
|
unlocked" is a condition a repository is temporarily in* — **a permanent command
|
||||||
|
for it advertises a transitional situation as a normal one.**
|
||||||
|
|
||||||
|
**Belief that could be shown wrong:** *that the condition is transitional.* **If
|
||||||
|
people and agents keep dropping documents into `externals/` by hand — and they
|
||||||
|
will — it is a recurring category and not a migration**, *and then it deserves its
|
||||||
|
own word, discoverable in `--help` rather than found in a hint.*
|
||||||
|
|
||||||
|
*The domain word survives regardless: `externals` says adopting is fetching a URL,
|
||||||
|
and the tool says `adopted` throughout.*
|
||||||
|
|
||||||
|
## 2026-09-07 — the credential is not needed here, and is not gone `quince`
|
||||||
|
|
||||||
|
**`jeffry/homelab-cluster` is public and `jeffry/homelab-impl` is private.**
|
||||||
|
*Adoption flows public → private, so nothing this repository depends on requires a
|
||||||
|
credential, and `loomctl` has been exercised end to end without one.*
|
||||||
|
|
||||||
|
**That does not remove the PAT. It moves when you need it.** *`externals` now
|
||||||
|
says: **do not adopt from a source less readable than the repository you are
|
||||||
|
adopting into.*** *Which sorts the cases:*
|
||||||
|
|
||||||
|
- **a public repository adopting** — *may only adopt public documents*, **so it
|
||||||
|
never needs a credential**, *and needing one is the signal that something is
|
||||||
|
wrong.*
|
||||||
|
- **a private repository adopting private documents** — *legitimate, and needs
|
||||||
|
one.*
|
||||||
|
|
||||||
|
> **So needing a credential stopped being a capability and became a signal.** *The
|
||||||
|
> tool cannot tell the two apart — it sees that the source is private and cannot
|
||||||
|
> see who may read the repository the copy lands in* — **which is exactly why
|
||||||
|
> `add` warns rather than deciding.**
|
||||||
|
|
||||||
|
**Belief that could be shown wrong:** *that public-adopting-public covers the
|
||||||
|
common case.* **The moment a private repository wants to depend on another private
|
||||||
|
one, the credential is required and untested.**
|
||||||
|
|
||||||
|
## 2026-09-07 — status change: the git-over-HTTPS assumption is unexercised, not blocking `quince`
|
||||||
|
|
||||||
|
**An earlier entry records that nobody has confirmed a read-scoped PAT
|
||||||
|
authenticates git over HTTPS, and calls it load-bearing. It is no longer
|
||||||
|
blocking.**
|
||||||
|
|
||||||
|
*It bears on `external list` against a private repository, which is the
|
||||||
|
private-to-private case above and does not occur here.* **The path is unexercised
|
||||||
|
rather than untested-and-in-the-way.**
|
||||||
|
|
||||||
|
**Recorded because the code did not change and its status did**, *which is the
|
||||||
|
kind of thing only a log says.* **It stays the first thing to run against the next
|
||||||
|
token.**
|
||||||
|
|
||||||
|
## 2026-09-07 — the completeness case has a mechanism: it is the facet `quince`
|
||||||
|
|
||||||
|
**The open question was: a `200` tells you a document moved and says nothing about
|
||||||
|
whether your casting still covers it.**
|
||||||
|
|
||||||
|
**`check` prints the document's `.usages.md` when it stages a polad**, *and for an
|
||||||
|
agreement that is the file its roles are cast in* — **so the question is answered
|
||||||
|
by reading the facet the tool just pointed at.**
|
||||||
|
|
||||||
|
**It failed on the first real change because `externals.md` had no facet at all**,
|
||||||
|
*and the tool said `no .usages.md — nothing records what depends on this`:* **a
|
||||||
|
correct report and useless as a prompt.**
|
||||||
|
|
||||||
|
**So both were written.** *`externals.usages.md` names which Go file implements
|
||||||
|
which rule — an unusual usage, because this repository implements the convention
|
||||||
|
rather than using it — and `cart.usages.md` gained the `v1` casting it was missing.*
|
||||||
|
|
||||||
|
**Belief that could be shown wrong:** *that pointing is enough.* **Nothing checks
|
||||||
|
that a facet was updated, or that it was even read.**
|
||||||
|
|
||||||
|
## 2026-09-07 — decided: nothing committed announces an open cart `quince`
|
||||||
|
|
||||||
|
**A cart is local and untracked, so a clone cannot see that a round is open.**
|
||||||
|
*That is the boundary and not a defect:* **`v1` makes the cart local to the
|
||||||
|
working tree the two presences share, and somebody who has only cloned is by
|
||||||
|
construction not one of them.**
|
||||||
|
|
||||||
|
**`.gitignore` records that carts happen here. Nothing records that one is open**,
|
||||||
|
*and the asymmetry is deliberate* — **a mechanism for announcing something
|
||||||
|
designed to be ephemeral is the first step in it not being ephemeral.**
|
||||||
|
|
||||||
|
**Belief that could be shown wrong:** *that the shared working tree is the right
|
||||||
|
unit.* **If two presences ever work from different machines, the cart has nowhere
|
||||||
|
to live**, *and everything above stops being true at once.*
|
||||||
|
|
||||||
|
## 2026-09-07 — declined for now: reference-only adoption `quince`
|
||||||
|
|
||||||
|
**`externals` offers two ways out of the confidentiality rule.** *We implement
|
||||||
|
neither, and this records why the first is not built.*
|
||||||
|
|
||||||
|
**Reference-only is mechanically small:** *a lock with no file. `check` never
|
||||||
|
touches the copy — it sends `If-None-Match` and reads the status — so the only
|
||||||
|
code that changes is telling deliberate absence from loss, which is one optional
|
||||||
|
field in the lock.* **The facets stay**, *which is the ownership line drawn
|
||||||
|
exactly: the facet is ours, the document is theirs.*
|
||||||
|
|
||||||
|
**The cost is larger than the document suggests, and it is why this is worth an
|
||||||
|
entry rather than a `TODO`:** *with a copy, `CHANGED` gives you a diff, and today
|
||||||
|
the diff was the whole answer.* **Reference-only keeps no old bytes, so it tells
|
||||||
|
you *that* a document moved and never *what* moved** — *and the question
|
||||||
|
`externals` says reconciliation asks is a question about the delta.*
|
||||||
|
|
||||||
|
> **So it is not adoption minus offline reading. It is adoption minus
|
||||||
|
> reconciliation-by-diff.**
|
||||||
|
|
||||||
|
**Not built because we have no instance.** *Everything this repository adopts is
|
||||||
|
public.* **And the convention's other exit — ask them to publish — is the one that
|
||||||
|
actually occurred**: *`homelab-cluster` went public and the problem dissolved.*
|
||||||
|
**The better exit made the worse one unnecessary in the only case we have had.**
|
||||||
|
|
||||||
|
**Belief that could be shown wrong:** *that the case stays hypothetical.* **A
|
||||||
|
private repository here depending on another private one makes it real** —
|
||||||
|
*`homelab-impl` is the obvious candidate* — **and then the design above is a few
|
||||||
|
hours.**
|
||||||
|
|
||||||
|
*The warning now names both exits, including the one we have not built. **Telling
|
||||||
|
somebody a rule and not the way out of it is how a guardrail becomes something
|
||||||
|
people route around.***
|
||||||
|
|
||||||
|
## 2026-09-07 — measured: only gitea resolves its own short raw URL `rowan`
|
||||||
|
|
||||||
|
**A lock must record a resolved URL** — *a short form follows whatever the default
|
||||||
|
branch is at the time you ask, so a branch rename reports as a change in the
|
||||||
|
document.* **Adopting by name means building that URL, and the three hosts do not
|
||||||
|
behave alike:**
|
||||||
|
|
||||||
|
```
|
||||||
|
gitea /{owner}/{repo}/raw/{path} 303 -> /raw/branch/main/{path} resolved
|
||||||
|
github raw.githubusercontent.com/{o}/{r}/HEAD/{path} 200, no redirect NOT resolved
|
||||||
|
gitlab /{owner}/{repo}/-/raw/HEAD/{path} 200, no redirect NOT resolved
|
||||||
|
```
|
||||||
|
|
||||||
|
**`HEAD` works on all three and resolves on none.** *It would put a moving ref in
|
||||||
|
the lock, which is the hazard we recorded and then nearly built.*
|
||||||
|
|
||||||
|
**So gitea's redirect does the work, and the other two need the branch resolved
|
||||||
|
first** — *`git ls-remote --symref <url> HEAD`, which is one round trip and no
|
||||||
|
clone.*
|
||||||
|
|
||||||
|
**Belief that could be shown wrong:** *that gitea keeps redirecting.* **The short
|
||||||
|
form is a convenience, not a documented interface**, *and if it stops we resolve
|
||||||
|
the branch the same way as everybody else.*
|
||||||
|
|
||||||
|
## 2026-09-07 — supersedes "git is needed for `list` only" `rowan`
|
||||||
|
|
||||||
|
**Adopting by name against github or gitlab needs `git ls-remote` to resolve the
|
||||||
|
default branch.** *Against gitea it does not, because the redirect answers the
|
||||||
|
same question.*
|
||||||
|
|
||||||
|
**So the earlier claim — `list` is the only command that needs `git`, which makes
|
||||||
|
`list` the seam — is now wrong for two of three flavors.** *The seam is unchanged
|
||||||
|
in practice here, where everything is gitea, and the entry would otherwise read as
|
||||||
|
still true.*
|
||||||
|
|
||||||
|
*Recorded because the code changed in a way that quietly moved a boundary somebody
|
||||||
|
might rely on.*
|
||||||
|
|
||||||
|
## 2026-09-08 — measured: the private path works, and nothing is unexercised now `rowan`
|
||||||
|
|
||||||
|
**Supersedes the entry that recorded the git-over-HTTPS assumption as unexercised.
|
||||||
|
It has been run.** *A read-scoped token, issued for one session and destroyed
|
||||||
|
after.*
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone --filter=blob:none --depth=1 over HTTPS + token, private repo OK, 124KB
|
||||||
|
loomctl external list jeffry/homelab-impl OK, authenticated
|
||||||
|
short raw URL + 303, authenticated 404 anon -> 200, resolved branch
|
||||||
|
If-None-Match across the redirect, authenticated 304
|
||||||
|
```
|
||||||
|
|
||||||
|
**So a private repository can adopt from another private one**, *by name and by
|
||||||
|
URL, with `check` working off the lock afterwards.* **Every path in the tool has
|
||||||
|
now been run at least once.**
|
||||||
|
|
||||||
|
*Incidentally confirmed: `homelab-impl` publishes nothing and adopts six documents
|
||||||
|
from `homelab-cluster`, each with a `.usages.md`.* **Consuming inward, which is
|
||||||
|
the arrangement that made the confidentiality hazard impossible in that shape.**
|
||||||
|
|
||||||
|
## 2026-09-08 — the tool is a mast, not a lock `rowan`
|
||||||
|
|
||||||
|
**Recorded because it decides a question that keeps coming back, and nothing in
|
||||||
|
the code says it.**
|
||||||
|
|
||||||
|
> **`loomctl` grants no access.** *It reads what your credentials already let you
|
||||||
|
> read.* **Anything it does, a person could do with copy and paste** — *and the
|
||||||
|
> locks mean nothing outside the tool and the discipline of the agreement.*
|
||||||
|
|
||||||
|
**So it warns and does not refuse.** *The point is not to make the wrong thing
|
||||||
|
impossible; it is to make it deliberate* — **the same move as annotating a file
|
||||||
|
you could simply edit.**
|
||||||
|
|
||||||
|
**Which is also why `add <full-url>` stays**, *even though it can reach outside a
|
||||||
|
publisher's `.loom/published/` and is, in that form, a small general-purpose file
|
||||||
|
fetcher.* **A general-purpose tool is not the aim**, *so the escape hatch is kept
|
||||||
|
and made to say what it is, rather than removed.*
|
||||||
|
|
||||||
|
**What the tool adds over copy and paste is not restriction. It is the record** —
|
||||||
|
*origin, resolved branch, `ETag`.* **A pasted document has no provenance**, *so
|
||||||
|
nobody can later ask where it came from or whether we were allowed to have it* —
|
||||||
|
**not because the question is hard, but because the evidence is gone.**
|
||||||
|
|
||||||
|
## 2026-09-08 — the confidentiality check is coarse, deliberately `rowan`
|
||||||
|
|
||||||
|
**`add` now asks whether *this* repository is readable anonymously**, *by resolving
|
||||||
|
`origin` and making one unauthenticated request.* **So the warning fires when the
|
||||||
|
source is private and the destination is public, rather than on every adoption a
|
||||||
|
private repository performs** — *which was the previous behaviour and would have
|
||||||
|
been noise in exactly the workflow that is legitimate.*
|
||||||
|
|
||||||
|
**Belief that could be shown wrong, and it is a known blind spot rather than a
|
||||||
|
guess:** *the signal distinguishes public from not-public and nothing finer.*
|
||||||
|
**Two repositories private to different people is the case where private-to-private
|
||||||
|
genuinely widens access, and this check cannot see it** — *so the tool says so, in
|
||||||
|
the message, instead of implying a verdict it has not earned.*
|
||||||
|
|
||||||
|
**Failing open is deliberate:** *no origin, or an unparseable one, reports "I
|
||||||
|
cannot tell" and warns.* **"Cannot tell" must never read as "not public."**
|
||||||
|
|
||||||
|
## 2026-09-08 — three gaps closed, and the workaround they justified is now the rule `rowan`
|
||||||
|
|
||||||
|
**`publication`, `recording-decisions` and `cart` were amended in answer to gaps
|
||||||
|
filed from here.** *Closed at reconciliation rather than at repair: each stayed
|
||||||
|
true of the copy we held until `check` reported `200`.* **The gap files for the
|
||||||
|
first two are deleted; `cart.gaps.md` keeps four of five.**
|
||||||
|
|
||||||
|
**What survives is what they justified, and in every case it is the same shape —
|
||||||
|
something we invented locally became specified behaviour, unchanged in practice
|
||||||
|
and entirely changed in standing:**
|
||||||
|
|
||||||
|
- **`wal.md`.** *We built it before `cart/v2` named it, and only because it was
|
||||||
|
suggested to us.* **`v2` cites that:** *a practice that works only when somebody
|
||||||
|
is standing there to suggest it is not a practice.*
|
||||||
|
- **"A fetch you did not perform."** *Ours was a declined command and an insight
|
||||||
|
kept without an implementation; it is now the test in `publication`.*
|
||||||
|
- **The compaction rule**, *including the four kinds that may never be dropped,*
|
||||||
|
**plus one addition we did not think of: a compaction is itself a decision and
|
||||||
|
gets an entry.**
|
||||||
|
|
||||||
|
## 2026-09-08 — declined: fixing `annotating` `rowan`
|
||||||
|
|
||||||
|
**Four real gaps stand unfixed, deliberately.** *It is the most-depended-on
|
||||||
|
document here and a revision costs every adopter an afternoon.*
|
||||||
|
|
||||||
|
**Two of the four are not gaps in the agreement at all.** *The rule exists — the
|
||||||
|
source hash that detects a violated freeze, and **dissolve** as the definition of
|
||||||
|
what responding is — **but it lives in a local editor protocol and not in the
|
||||||
|
agreement**.* **That needs a decision about where a rule lives, which is not an
|
||||||
|
amendment and is not ours.**
|
||||||
|
|
||||||
|
**Local answers keep running:** *verify the source hash and the anchors before
|
||||||
|
responding, and say so if they disagree.*
|
||||||
|
|
||||||
|
## 2026-09-08 — a casting file goes stale on amendments that add no role `rowan`
|
||||||
|
|
||||||
|
**Twice in one day.** *`cart/v1` and `cart/v2` each invalidated nothing we had
|
||||||
|
cast and each left `cart.usages.md` incomplete.*
|
||||||
|
|
||||||
|
> **`check` reports that a document moved and cannot say whether your casting
|
||||||
|
> still covers it.** *The mechanism is that it prints the `.usages.md` beside a
|
||||||
|
> staged polad, which it did, both times.* **It points; it cannot make anybody
|
||||||
|
> read.**
|
||||||
|
|
||||||
|
**Belief that could be shown wrong:** *that pointing is enough.* **Nothing checks
|
||||||
|
that a facet was updated, or that it was opened.**
|
||||||
|
|
||||||
|
*Related, and the reason this entry can be trusted: this round arrived headed
|
||||||
|
`rowan` in a file named for a round that had already converted, with no `name`
|
||||||
|
file.* **The name is what tags durable entries**, *so a mismatch mis-files the
|
||||||
|
record permanently.* **The heading won; it is recorded in `cart.usages.md`.**
|
||||||
|
|
||||||
|
## 2026-09-08 — declined: a round on the third-file question `rowan`
|
||||||
|
|
||||||
|
**Not opened, and not sent upstream as a gap either.** *Left open in
|
||||||
|
`cart.gaps.md` where it was filed.*
|
||||||
|
|
||||||
|
**Because it may not be a category.** *Ours appeared because a round was converted
|
||||||
|
while somebody was still writing, which the agreement already forbids.* **A third
|
||||||
|
file that only ever arrives as a conversion-timing failure has its fix upstream**,
|
||||||
|
*and the choice between moving it and rejecting it never has to be made.*
|
||||||
|
|
||||||
|
> **Before writing the rule, count how many arrive any other way.**
|
||||||
|
|
||||||
|
*One instance is not a sample* — **which is the reason `cart/v2` gave for leaving
|
||||||
|
it alone, applied one step further.**
|
||||||
|
|
||||||
|
## 2026-09-08 — `orient`, and declined: a second orientation file `sorrel`
|
||||||
|
|
||||||
|
**Decided:** *one generated file, `.loom/orientation.md`, for whoever arrives
|
||||||
|
next.* **It restates no rule** — *every rule it might repeat is owned by a
|
||||||
|
document already in the working tree, and a copy of a rule is a copy that goes
|
||||||
|
stale.*
|
||||||
|
|
||||||
|
**Declined: splitting it into an externals half and a publishing half.** *The
|
||||||
|
splitting rule is **generate what varies, adopt what does not*** — **and the
|
||||||
|
publishing half varies not at all and is already adopted**, *so `publication.md`
|
||||||
|
appears in the index like any other adopted document, in exactly the repositories
|
||||||
|
that adopted it.* **A second command would emit a file whose whole content is a
|
||||||
|
pointer to a file already in the tree**, *and would revive the `published` noun
|
||||||
|
that went with `published check`.*
|
||||||
|
|
||||||
|
**Declined: `.loom/externals/README.md` as the path**, *which was measured rather
|
||||||
|
than argued:*
|
||||||
|
|
||||||
|
```
|
||||||
|
$ loomctl external check
|
||||||
|
README.md unlocked no origin recorded — ...
|
||||||
|
```
|
||||||
|
|
||||||
|
**`check` walks that directory for adopted documents, so a generated file there
|
||||||
|
reports as unlocked in every repository using the feature, forever.** *And the
|
||||||
|
deeper reason is the invariant: everything under `.loom/externals/` is somebody
|
||||||
|
else's document, which is what makes "do not edit these" a rule you can state in
|
||||||
|
four words.*
|
||||||
|
|
||||||
|
**The `.usages.md` is pointed at, not summarised.** *What depends on a document is
|
||||||
|
free prose, and anything that extracts a claim from prose will eventually extract
|
||||||
|
it wrong.*
|
||||||
|
|
||||||
|
**Belief that could be shown wrong:** *that a fixed path is worth more than a
|
||||||
|
configured one.* **It only pays if it is the same everywhere**, *which is not ours
|
||||||
|
to decide and is with `bedrock`.*
|
||||||
|
|
||||||
|
## 2026-09-08 — a probe is not a fetch, and confusing them clears alarms `sorrel`
|
||||||
|
|
||||||
|
**Recorded because the bug was one line and the trap is permanent.**
|
||||||
|
|
||||||
|
*Visibility was computed by one function used in two places.* **At `add` time,
|
||||||
|
"no credential is configured" means the fetch that just succeeded was anonymous,
|
||||||
|
so the source is public** — *sound.* **In the audit, nothing is being fetched:
|
||||||
|
"no credential configured" says nothing about whether a stranger could read the
|
||||||
|
URL**, *and returning `public` there would have **silently cleared real
|
||||||
|
alarms**.*
|
||||||
|
|
||||||
|
> **The worst failure available to a check is to report that it found nothing.**
|
||||||
|
|
||||||
|
**Probing is now its own function that always asks with no credential at all**,
|
||||||
|
*because what matters is what a stranger can read and not what we can.* **Found by
|
||||||
|
testing the alarm path rather than after shipping it.**
|
||||||
|
|
||||||
|
## 2026-09-08 — `orient`, not `prompt`, and why that was mine to notice `sorrel`
|
||||||
|
|
||||||
|
**Renamed before it shipped.** *`prompt` reads as "produce something to feed a
|
||||||
|
model", and the file is a table of contents a person can read.*
|
||||||
|
|
||||||
|
**The correction was right and the assumption behind it was mine:** *`prompt` was
|
||||||
|
not being used in the narrow sense, and I took it at the narrowest reading
|
||||||
|
available.* **Second time in two days** — *the first was reading `others` as
|
||||||
|
`everyone` in `publication.md`, which cost a command that was built, validated
|
||||||
|
three times, and then declined.*
|
||||||
|
|
||||||
|
*Recorded as a working note rather than a decision: **when a word could be read
|
||||||
|
narrowly or broadly, this repository has evidence that I pick narrow.***
|
||||||
|
|
||||||
|
## 2026-09-08 — published: what the tool guarantees `tansy`
|
||||||
|
|
||||||
|
**`.loom/published/guarantees.md`**, *and step 4 of `publication` was run: `200`
|
||||||
|
anonymously, off no credential of ours.*
|
||||||
|
|
||||||
|
**The earlier decision did not reverse; its condition fired.** *`.loom/published/`
|
||||||
|
was recorded as deferred rather than declined, on the condition that somebody
|
||||||
|
depends on the tool's behaviour rather than reading its help.* **A docs site whose
|
||||||
|
build calls `add`, `check` and `apply` is that consumer.**
|
||||||
|
|
||||||
|
*The delta is not the command list — that is a copy of what the code owns.*
|
||||||
|
**`--help` says what the tool does; a published page says what will keep being
|
||||||
|
true**, *and a reader cannot learn from a binary which of its behaviours survives
|
||||||
|
the next version.*
|
||||||
|
|
||||||
|
**The page includes what is not promised**, *which is the half a reimplementation
|
||||||
|
needs most:* **the command surface, the orientation file's format across
|
||||||
|
versions, the precision of the visibility signal, and that anything is checked
|
||||||
|
when nobody is looking.**
|
||||||
|
|
||||||
|
## 2026-09-08 — we do not promise the surface; we promise the change lands `tansy`
|
||||||
|
|
||||||
|
**A consumer's README tells a person to type `loomctl external add loom/<repo>
|
||||||
|
<doc>.md`.** *Nothing parses it — which is exactly why no check would catch a
|
||||||
|
rename.* **The thing that moved would not be a document anybody adopted.**
|
||||||
|
|
||||||
|
**Promising the surface would freeze the CLI. So the promise is narrower and
|
||||||
|
runs:**
|
||||||
|
|
||||||
|
> **We do not promise the surface holds. We promise a change to it lands on the
|
||||||
|
> page you already lock.**
|
||||||
|
|
||||||
|
*`guarantees.md` carries an append-only `Surface changes` section*, **so a rename
|
||||||
|
reports `200` to every consumer the day it happens.**
|
||||||
|
|
||||||
|
**Belief that could be shown wrong:** *that anybody remembers to write in it.*
|
||||||
|
**Nothing enforces the section** — *the same weakness as a facet nobody updates.*
|
||||||
|
|
||||||
|
## 2026-09-08 — two ETags can be compared and never ordered `tansy`
|
||||||
|
|
||||||
|
**Found by accident, and it is a consequence of a rule we published this
|
||||||
|
morning.**
|
||||||
|
|
||||||
|
*The docs site stamped a hand-written guide with the `ETag` it was read against,
|
||||||
|
and its banner said `written against an older guarantees`.* **It was backwards:
|
||||||
|
the guide was ahead of the site's copy, not behind.**
|
||||||
|
|
||||||
|
**And it cannot be fixed by trying harder.** *An `ETag` is opaque — that is the
|
||||||
|
point of it, and we guarantee we never interpret one.* **So a consumer can know
|
||||||
|
that two versions disagree and can never know which is behind.**
|
||||||
|
|
||||||
|
*The banner now claims no direction.* **Anybody building freshness on this
|
||||||
|
inherits the same limit**, *and it belongs in the log because it follows from the
|
||||||
|
guarantee rather than from anybody's code.*
|
||||||
|
|
||||||
|
*Consequence nobody predicted: reconciling cleared the banner with no
|
||||||
|
acknowledgement, which is correct — an acknowledgement is for a guide that is
|
||||||
|
behind, and this one was ahead.*
|
||||||
|
|
||||||
|
## 2026-09-08 — compacted, and it barely compressed `tansy`
|
||||||
|
|
||||||
|
**A compaction is itself a decision, so this is the entry `recording-decisions`
|
||||||
|
asks for.** *Six entries dropped, fifty kept. The uncompacted log is in git
|
||||||
|
history, which is where a reader who needs it goes.*
|
||||||
|
|
||||||
|
**Dropped, each because a competent reader can now recover it from the artifact,
|
||||||
|
and here is which artifact:**
|
||||||
|
|
||||||
|
- *`what this tool is`* — **`.loom/published/guarantees.md` and `README.md`**
|
||||||
|
- *`the lock, and what it holds`* — **`guarantees.md` documents the format**
|
||||||
|
- *`unlocked is a state`* — **`guarantees.md`, and `add` refuses in code**
|
||||||
|
- *`--help is the documentation surface`* — **`README.md` says so and points there**
|
||||||
|
- *`the lock records visibility, and the word it will not use`* — **`guarantees.md`
|
||||||
|
carries the field and the refusal of the word `private`**
|
||||||
|
- *`conversion now destroys, so the log is the only record`* — **`cart/v2`, adopted
|
||||||
|
here, and `cart.usages.md` names `wal.md` as the mechanism**
|
||||||
|
|
||||||
|
> **Every one of them became droppable by being published.** *Nothing was
|
||||||
|
> summarised away; the content moved into a document somebody else can lock, which
|
||||||
|
> is a stronger place for it than a log only we read.*
|
||||||
|
|
||||||
|
## What did not compress, and why that is the finding
|
||||||
|
|
||||||
|
**Fifty entries survived, and the reason is the rule rather than sentiment.**
|
||||||
|
*Almost all of them are one of the four kinds that may never be dropped:*
|
||||||
|
|
||||||
|
**Declines** — *`init`, `published check`, a separate `adopt` verb, a plugin
|
||||||
|
boundary, an audience declaration, reference-only adoption, resolving `404` over
|
||||||
|
ssh, a second orientation file, worktrees, a spec, fixing `annotating`.* **Not one
|
||||||
|
is recoverable from the code, because the code is what they are absent from.**
|
||||||
|
|
||||||
|
**Measurements** — *the `ETag` is a blob hash on gitea and not on GitHub; three
|
||||||
|
hosts and one partial clone; only gitea resolves its own short raw URL; a refused
|
||||||
|
write names the token's scopes; a private repository over HTTPS with a read
|
||||||
|
token.* **Facts about other people's systems, which no artifact here holds.**
|
||||||
|
|
||||||
|
**Supersessions** — *publication is not publicness; a cart is not committed; `add`
|
||||||
|
does not replace; `git` is not needed for `list` only; the private path is
|
||||||
|
exercised.* **Each is a change of mind, which is most of what a reader wants.**
|
||||||
|
|
||||||
|
**Beliefs shown wrong** — *the specimen's hash-as-lock; `others` read as
|
||||||
|
`everyone`; a probe treated as a fetch; a banner that claimed a direction two
|
||||||
|
opaque strings cannot support.*
|
||||||
|
|
||||||
|
> **The log is long because the work was exploratory, not because it is padded.**
|
||||||
|
> *A project that mostly built things would compact to a page. This one mostly
|
||||||
|
> found out it was wrong about things, and that is the part the code cannot
|
||||||
|
> hold.*
|
||||||
|
|
||||||
|
**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.*
|
||||||
|
|||||||
Vendored
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# loomctl locks — one record per adopted document.
|
||||||
|
# path<TAB>url<TAB>etag[<TAB>visibility]
|
||||||
|
# The url is resolved: a short form would follow whatever the default branch
|
||||||
|
# is at the time you ask. visibility is what the source could be read as when
|
||||||
|
# it was fetched, because that is checked once and the copy is durable.
|
||||||
|
git.hypertheory-labs.dev/jeffry/homelab-cluster/gitea.md https://git.hypertheory-labs.dev/jeffry/homelab-cluster/raw/branch/main/.loom/published/gitea.md "530c5bef62bbd325956ed170bb2decf37975e4b9" public
|
||||||
|
git.hypertheory-labs.dev/loom/annotating/annotating.md https://git.hypertheory-labs.dev/loom/annotating/raw/branch/main/.loom/published/annotating.md "9b1f7e6ca92f2339b2d433686c27845362944bdb" public
|
||||||
|
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" public
|
||||||
|
git.hypertheory-labs.dev/loom/bedrock/publication.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md "c0d9231aa8eed3041c60b72da5735585457fbc90" public
|
||||||
|
git.hypertheory-labs.dev/loom/bedrock/recording-decisions.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/recording-decisions.md "d190eeae7929b9a99ecf09211d94e7e5b32635bd" public
|
||||||
|
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" public
|
||||||
|
git.hypertheory-labs.dev/loom/bedrock/starting.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/starting.md "b9eefba0f4668a496ccfc6a1377277f0721456d2" public
|
||||||
|
git.hypertheory-labs.dev/loom/cart/cart.md https://git.hypertheory-labs.dev/loom/cart/raw/branch/main/.loom/published/cart.md "15331f1a9cc81bf61a44830cfbb7c274f4c2b119" public
|
||||||
|
git.hypertheory-labs.dev/loom/externals/externals.md https://git.hypertheory-labs.dev/loom/externals/raw/branch/main/.loom/published/externals.md "50673ccffc14d57150ac0a9027b0712d9dcf940d"
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# The git host
|
||||||
|
|
||||||
|
**`git.hypertheory-labs.dev`**, on the public internet, with a real certificate.
|
||||||
|
**This is where the `loom/*` repositories live.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SSH is on 2222, and it is not optional to know
|
||||||
|
|
||||||
|
**Git-over-SSH does not pass through Traefik** — it is raw TCP, on a
|
||||||
|
`LoadBalancer` that binds a host port on every node. **Port 22 is held by each
|
||||||
|
node's own `sshd`**, so the service is on **2222**.
|
||||||
|
|
||||||
|
```
|
||||||
|
ssh://git@git.hypertheory-labs.dev:2222/<org>/<repo>.git
|
||||||
|
```
|
||||||
|
|
||||||
|
**A clone URL without the port will not work**, and the failure looks like an
|
||||||
|
authentication problem rather than a wrong port.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh -T -p 2222 git@git.hypertheory-labs.dev # "Hi there, <name>!" once a key is registered
|
||||||
|
```
|
||||||
|
|
||||||
|
*`Permission denied (publickey)` from a node IP is the **success** case for an
|
||||||
|
unregistered key — the server answered and offered its host key.*
|
||||||
|
|
||||||
|
## Registration is closed
|
||||||
|
|
||||||
|
**One account.** *The anonymous landing page serves no sign-up link.* **If you
|
||||||
|
need access, somebody creates it for you.**
|
||||||
|
|
||||||
|
## Two things that will surprise you
|
||||||
|
|
||||||
|
**Sessions do not survive a restart.** *There is no Redis or valkey here — cache
|
||||||
|
and session are in memory, deliberately.* **The queue is on disk and does
|
||||||
|
survive.** *With one user this is nearly free; it stops being free if this ever
|
||||||
|
grows real users.*
|
||||||
|
|
||||||
|
**If the control-plane node is down, this is down.** *The repository volume is
|
||||||
|
pinned to it and cannot move.* **Postgres is unaffected** — it replicates — *but
|
||||||
|
the git objects live on a volume that cannot be rescheduled.* See
|
||||||
|
[storage](storage.md).
|
||||||
|
|
||||||
|
## Never pin the chart below what is deployed
|
||||||
|
|
||||||
|
**Gitea does not migrate its schema backward.** *An older chart fails in the
|
||||||
|
`configure-gitea` init container with "database is for a newer Gitea", the
|
||||||
|
rollout hangs, and the old pod keeps serving.*
|
||||||
|
|
||||||
|
**Check `helm history` before setting a version.** *This has already happened
|
||||||
|
once.*
|
||||||
|
|
||||||
|
## The container registry
|
||||||
|
|
||||||
|
**Gitea has one. Access to it is not worked out**, and that is an open problem
|
||||||
|
rather than an omission — see [`gaps/`](../gaps/publishing-container-images.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checking this is still true
|
||||||
|
|
||||||
|
**Verified 2026-09-03**, after a rebuild from scratch.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kubectl get svc gitea-ssh -n gitea # EXTERNAL-IP = node IPs, 2222/TCP
|
||||||
|
kubectl get ingress -n gitea # CLASS=traefik, git.hypertheory-labs.dev
|
||||||
|
curl -sS -o /dev/null -w "%{http_code}\n" https://git.hypertheory-labs.dev/
|
||||||
|
```
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# Usages — `gitea.md`
|
||||||
|
|
||||||
|
**Adopted 2026-09-07, and it is the reason this tool exists.**
|
||||||
|
|
||||||
|
*This page was published, accurate, and unreachable when it would have helped.
|
||||||
|
The `:2222` fact cost three tool calls and a guess, and the page says:* **"A clone
|
||||||
|
URL without the port will not work, and the failure looks like an authentication
|
||||||
|
problem rather than a wrong port."** *It named the failure before it happened.*
|
||||||
|
|
||||||
|
## What we use
|
||||||
|
|
||||||
|
**SSH is on `2222`, and a clone URL without the port fails as an auth error.**
|
||||||
|
*Used by anybody working in this repository by hand.* **Not used by `loomctl`** —
|
||||||
|
*every transport it has is HTTPS on 443* — **which is worth saying, because it is
|
||||||
|
the difference between a fact for people and a fact for the tool.**
|
||||||
|
|
||||||
|
**Registration is closed; one account.** *Which is why
|
||||||
|
`internal/external/polad.go` and `internal/external/external.go` are built around
|
||||||
|
a single reader identity per host, and why nothing here tries to check a
|
||||||
|
publication as somebody else.*
|
||||||
|
|
||||||
|
## What we expected and did not find
|
||||||
|
|
||||||
|
**Nothing.** *The gap this document would have filled was ours, not its: it was
|
||||||
|
private, and now it is not.*
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Gaps — `annotating`
|
||||||
|
|
||||||
|
**From one round of real use.** *The agreement is the most-depended-on thing here
|
||||||
|
and deliberately minimal, so these are things a minimal document leaves to its
|
||||||
|
adopters — the question is only whether the adopters know that.*
|
||||||
|
|
||||||
|
## How a reader detects that the freeze was violated
|
||||||
|
|
||||||
|
**The agreement fixes a source file once notes exist, because the quotes would
|
||||||
|
come loose. Nothing lets a reader tell whether that happened.**
|
||||||
|
|
||||||
|
*The protocol we work under adds one — **a truncated `sha256` of the source in the
|
||||||
|
notes header**, checked before responding — and that is a local invention, not
|
||||||
|
this document.* **An adopter following only this page has quotes that may have
|
||||||
|
drifted and no way to know.**
|
||||||
|
|
||||||
|
*Local answer: we verify the hash and the anchors before responding, and say so if
|
||||||
|
they disagree.*
|
||||||
|
|
||||||
|
## What responding actually is
|
||||||
|
|
||||||
|
**The agreement says deleting the notes releases the source. It does not say that
|
||||||
|
responding means rewriting the source to incorporate them.**
|
||||||
|
|
||||||
|
*Read literally, a reader could answer in chat and delete the notes, or edit the
|
||||||
|
source and leave the notes in place.* **Both are consistent with the text and both
|
||||||
|
break the pair.** *The all-or-nothing rewrite — **dissolve** — is named in our
|
||||||
|
local protocol and not here.*
|
||||||
|
|
||||||
|
## What to do when a frozen file must change
|
||||||
|
|
||||||
|
**There is no escape hatch, and there probably should not be a general one** —
|
||||||
|
*but there is also no sentence saying what to do when the source is wrong in a way
|
||||||
|
that cannot wait.*
|
||||||
|
|
||||||
|
*Local answer: has not come up. **We would delete the notes and say so**, which is
|
||||||
|
a guess.*
|
||||||
|
|
||||||
|
## Whether a prompt's answer belongs anywhere durable
|
||||||
|
|
||||||
|
**`Question`, `Select` and `Affirm` answers land in a notes file, and a notes file
|
||||||
|
is deleted when it dissolves.** *Under `cart/v1` the cart is untracked as well, so
|
||||||
|
in a cart the answer to a prompt has no archive at all unless somebody copies it
|
||||||
|
out.*
|
||||||
|
|
||||||
|
**This is the collision an adopter sees and neither document does:** *the protocol
|
||||||
|
we work under says **commit before dissolving, git is the only archive of the
|
||||||
|
conversation** — and an ignored cart has no git.*
|
||||||
@@ -15,6 +15,43 @@ should be unmissable.*
|
|||||||
**`ls .loom/published/` is *what have we committed to?*** — and it is the question
|
**`ls .loom/published/` is *what have we committed to?*** — and it is the question
|
||||||
to ask before changing anything.
|
to ask before changing anything.
|
||||||
|
|
||||||
|
## Publishing is not an act you can complete alone
|
||||||
|
|
||||||
|
**A file in `.loom/published/` that its intended readers cannot fetch is not
|
||||||
|
published.** *The move is the act, but the act is not finished until somebody who
|
||||||
|
is not you can perform the read.*
|
||||||
|
|
||||||
|
> **The failure is invisible from the inside.** *Every check available to the
|
||||||
|
> publisher passes — the file is there, the commit landed, the path is right.*
|
||||||
|
> **Only a reader can detect it, and a reader who cannot fetch it does not know
|
||||||
|
> the document exists to ask about.**
|
||||||
|
|
||||||
|
*The commonest form is a document that answers "why can you not see this?" and is
|
||||||
|
published **inside** the thing that cannot be seen.*
|
||||||
|
|
||||||
|
**So publication has a visibility, and it is a property of the act rather than of
|
||||||
|
the file.** *"Others" is not "everyone".* **Naming who the readers are is part of
|
||||||
|
publishing**, because a repository that depends on this one has to know whether it
|
||||||
|
is inside that set — *and because a reader who copies a document out inherits the
|
||||||
|
copy without inheriting the visibility it was published under.*
|
||||||
|
|
||||||
|
**The test is a fetch you did not perform.**
|
||||||
|
|
||||||
|
## Doing it
|
||||||
|
|
||||||
|
1. **Decide it is a contract and not a detail.** *This is the whole decision and
|
||||||
|
the rest is mechanics.*
|
||||||
|
2. **Move the file into `.loom/published/`.** *One commit, so the diff carries it.*
|
||||||
|
3. **Say who the readers are** — *if it is not obvious from where it landed.*
|
||||||
|
4. **Fetch it.** *From somewhere that is not your machine and not your account.*
|
||||||
|
5. **Record the decision**, if you keep a log.
|
||||||
|
|
||||||
|
**Step 4 is the one that gets skipped and the only one that can fail.** *Steps 1
|
||||||
|
to 3 are things you do; step 4 is a fact about the world that you find out.*
|
||||||
|
|
||||||
|
*Then say what it depends on. A published document that quietly requires a private
|
||||||
|
one has moved the problem rather than solved it.*
|
||||||
|
|
||||||
## What is published is a document
|
## What is published is a document
|
||||||
|
|
||||||
**Prose, in whatever form its readers need.** *If a repository organises its
|
**Prose, in whatever form its readers need.** *If a repository organises its
|
||||||
|
|||||||
@@ -24,6 +24,29 @@ reads.*
|
|||||||
you already rejected — because the rejection lived somewhere that did not
|
you already rejected — because the rejection lived somewhere that did not
|
||||||
survive.*
|
survive.*
|
||||||
|
|
||||||
|
## Compaction, and the entries it may never touch
|
||||||
|
|
||||||
|
**"Never revised" and "somebody must be able to read it" both hold, and far
|
||||||
|
enough out they conflict** — *a log nobody can get through has stopped being a
|
||||||
|
record.*
|
||||||
|
|
||||||
|
**Compaction is allowed. Dropping an entry is not the same as revising one**, and
|
||||||
|
the uncompacted log is in git history, which is where a reader who needs it goes.
|
||||||
|
|
||||||
|
> **An entry may be dropped when a competent reader could recover it by reading
|
||||||
|
> the artifact.**
|
||||||
|
>
|
||||||
|
> **An entry may never be dropped when it records a decline, a measurement, a
|
||||||
|
> belief that was shown wrong, or one entry superseding another.**
|
||||||
|
|
||||||
|
*Which means **compaction is safe in exactly the cases where the entry was
|
||||||
|
redundant with the artifact, and unsafe in exactly the cases the log exists
|
||||||
|
for.*** **The four protected kinds are the ones with no other home:** *the code
|
||||||
|
shows what was built and can never show what was refused, what was measured, what
|
||||||
|
turned out false, or that somebody changed their mind.*
|
||||||
|
|
||||||
|
**A compaction is itself a decision, and gets an entry.**
|
||||||
|
|
||||||
## Where it lives
|
## Where it lives
|
||||||
|
|
||||||
`.loom/event-log.md`. **A stream, so a file.**
|
`.loom/event-log.md`. **A stream, so a file.**
|
||||||
|
|||||||
@@ -60,9 +60,16 @@ what they came for.*
|
|||||||
|
|
||||||
## Look at one instead of reading this
|
## Look at one instead of reading this
|
||||||
|
|
||||||
**[`jeffry/homelab-cluster`](https://git.hypertheory-labs.dev/jeffry/homelab-cluster)**
|
**[`jeffry/homelab-cluster`](https://git.hypertheory-labs.dev/jeffry/homelab-cluster)** —
|
||||||
— *six documents, one gap, no decomposition, and a `README` that says what the
|
*six documents, one gap, no decomposition.* **It describes what a home cluster
|
||||||
root documents are for and what these are for.*
|
offers to somebody deciding whether to put something on it**, and a reader who
|
||||||
|
has never heard of any of this still gets what they came for.
|
||||||
|
|
||||||
**It is a better answer than this page**, and if the two ever disagree, it is
|
**It was private, and being unable to link to it is what made the split obvious.**
|
||||||
right.
|
*The operational tree is a version inventory and a target list; the pages telling
|
||||||
|
somebody what to decide are neither.* **So the manifests moved to a private
|
||||||
|
sibling and the documents stayed** — *and the documents got better for being
|
||||||
|
written to a stranger, which was not the reason for doing 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.
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Gaps — `cart`
|
||||||
|
|
||||||
|
**Written from three rounds as the first adopter, `v0` into `v1`.** *Each of these
|
||||||
|
is something we got wrong or could not tell from the document, not something we
|
||||||
|
disagreed with.*
|
||||||
|
|
||||||
|
## Whether a response is an annotation or a reply in your own file
|
||||||
|
|
||||||
|
**We read *"only dailies get annotated"* as a prescription and derived a
|
||||||
|
contradiction from it** — *a daily must keep growing, `annotating` freezes an
|
||||||
|
annotated file, so the one annotatable thing is the one thing that cannot be
|
||||||
|
frozen.*
|
||||||
|
|
||||||
|
**The document means it as a restriction on what may be annotated**, *and the
|
||||||
|
discriminator that resolves it is not written anywhere:* **an annotation creates
|
||||||
|
an obligation, so it is the blocking form and correspondence is the non-blocking
|
||||||
|
one.** *Annotate to ask or to challenge; write in your own file to assert.*
|
||||||
|
|
||||||
|
*Local answer: we corresponded, and annotated once, deliberately, to block.*
|
||||||
|
|
||||||
|
## What to do when an open item has no fallback
|
||||||
|
|
||||||
|
**The document requires every open item to state its own fallback and does not say
|
||||||
|
what a reader does when one does not.** *We hit this on the first prompt of the
|
||||||
|
first round.*
|
||||||
|
|
||||||
|
**The failure is quiet:** *without a fallback, an unanswered question is an
|
||||||
|
unresolved obligation and the round stalls* — **which is the exact thing the rule
|
||||||
|
exists to prevent**, *so a missing fallback breaks the mechanism rather than
|
||||||
|
merely omitting a nicety.*
|
||||||
|
|
||||||
|
*Local answer: we supplied our own fallback and said so, rather than treating it
|
||||||
|
as blocking. **And `cart.usages.md` is where an adopter is most likely to write a
|
||||||
|
first open item, and is the one place the fallback rule is not in front of
|
||||||
|
them.***
|
||||||
|
|
||||||
|
## What a third file means when one appears
|
||||||
|
|
||||||
|
**The document is emphatic that there is never a third file and gives the test for
|
||||||
|
refusing one.** *It does not say what to do with a third file that has already
|
||||||
|
been written.*
|
||||||
|
|
||||||
|
**Ours arrived as a person's name.** *We moved the text, unedited, into the
|
||||||
|
presence's daily* — **but "move it" and "reject it" are different acts with
|
||||||
|
different costs**, *and picking one was ours to invent.*
|
||||||
|
|
||||||
|
**Added after the fact, and it may dissolve the gap rather than answer it:** *ours
|
||||||
|
appeared because the round was converted while somebody was still writing.* **The
|
||||||
|
agreement already has a rule against that** — *do not convert while somebody is
|
||||||
|
mid-thought* — **so this third file was a symptom and not a category.**
|
||||||
|
|
||||||
|
> **Before writing a rule for third files, count how many arrive any other way.**
|
||||||
|
> *If they are all conversion-timing failures, the fix is upstream of here and the
|
||||||
|
> two acts never have to be chosen between.*
|
||||||
|
|
||||||
|
*One instance is not a sample. **We would rather leave this open than freeze a
|
||||||
|
rule out of a single case**, which is the same reason `v2` did not answer it.*
|
||||||
|
|
||||||
|
## Whether an unanswered `Affirm` and a fallback are the same silence
|
||||||
|
|
||||||
|
**Silence means proceed, and a fallback is what proceeding looks like.** *But
|
||||||
|
`cart` also wants a fallback that quietly became the decision to be findable
|
||||||
|
later, and gives no mechanism for finding one.*
|
||||||
|
|
||||||
|
*Local answer: the entry says it was decided by fallback, in those words. **It
|
||||||
|
works because we remembered, which is not a mechanism.***
|
||||||
+75
-2
@@ -1,6 +1,6 @@
|
|||||||
# Agreement — the cart
|
# Agreement — the cart
|
||||||
|
|
||||||
**v0.** Depends on `annotating/v0`.
|
**v2.** Depends on `annotating/v0`.
|
||||||
|
|
||||||
**How two parties work out what something means before one of them changes it.**
|
**How two parties work out what something means before one of them changes it.**
|
||||||
|
|
||||||
@@ -92,6 +92,34 @@ words.** That costs precision and buys something better:
|
|||||||
> **Pinning a defect in place lets you be exact without having understood.
|
> **Pinning a defect in place lets you be exact without having understood.
|
||||||
> Restating surfaces the misunderstanding that pinning hides.**
|
> Restating surfaces the misunderstanding that pinning hides.**
|
||||||
|
|
||||||
|
## Converting destroys, so extract first
|
||||||
|
|
||||||
|
**The cart is not committed. So conversion does not archive it — it deletes it.**
|
||||||
|
*Every earlier version of this agreement was wrong about that by accident: a
|
||||||
|
committed cart left its dailies in history, and "gone" was only ever true of the
|
||||||
|
tree.*
|
||||||
|
|
||||||
|
> **At conversion the cart is the only copy of everything in it.** *The dailies,
|
||||||
|
> the annotations, the answers to every prompt, and the write-ahead log.*
|
||||||
|
|
||||||
|
**So before converting, extract:**
|
||||||
|
|
||||||
|
- **every answer to a `Question`, `Select` or `Affirm`** — *an answer lands in a
|
||||||
|
notes file, a notes file dies at dissolve, and an untracked cart has no history
|
||||||
|
to fall back on.* **A prompt answered and not extracted was never answered.**
|
||||||
|
- **every write-ahead entry that survived** — *promote or discard, and both are
|
||||||
|
acts.*
|
||||||
|
- **every fallback that quietly became the decision** — *say so in the entry, in
|
||||||
|
those words.*
|
||||||
|
|
||||||
|
**Staging as you go turns conversion into a move rather than a rewrite.** *Keep
|
||||||
|
the write-ahead log current during the round and there is nothing to reconstruct
|
||||||
|
at the end, which is when you have least appetite for it.*
|
||||||
|
|
||||||
|
*Two rules in this agreement now both push toward loss — **act as if the shelf is
|
||||||
|
discarded daily**, and **the cart is not committed.** They are both right and
|
||||||
|
together they mean **nothing in a cart is safe by default.***
|
||||||
|
|
||||||
## Conversion emits two things
|
## Conversion emits two things
|
||||||
|
|
||||||
**The artifact** — the code, the decision, the change — **and the declines.**
|
**The artifact** — the code, the decision, the change — **and the declines.**
|
||||||
@@ -104,9 +132,54 @@ already rejected, and the rejection is gone because it lived in an annotation
|
|||||||
that died with the round.
|
that died with the round.
|
||||||
|
|
||||||
**A decline needs no file of its own.** It is an entry in whatever durable record
|
**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.
|
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
|
## Where a cart lives, and the shelf
|
||||||
|
|
||||||
**A fixed path, and at most two things in it:**
|
**A fixed path, and at most two things in it:**
|
||||||
|
|||||||
@@ -33,3 +33,68 @@ revert to.*
|
|||||||
|
|
||||||
*Which is the argument for stating a belief rather than a preference: **ours was
|
*Which is the argument for stating a belief rather than a preference: **ours was
|
||||||
shown wrong in a way we could see**, and a preference could not have been.*
|
shown wrong in a way we could see**, and a preference could not have been.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cast against `v1`: the cart is not committed
|
||||||
|
|
||||||
|
**`v1` adds no role, so nothing above changed.** *It adds something an adopter
|
||||||
|
gets wrong by default, and this file said nothing about it until now.*
|
||||||
|
|
||||||
|
**`.loom/cart/` is in `.gitignore`.** *Cast in this repository on 2026-09-07,
|
||||||
|
after `v1` landed mid-round.*
|
||||||
|
|
||||||
|
> **We got this wrong before it was written down.** *Two rounds — `osprey` and
|
||||||
|
> `marmalade` — were committed, and they are still in this repository's history.*
|
||||||
|
> **Not rewritten:** *honouring a rule adopted afterwards by rewriting history
|
||||||
|
> would cost more than it buys*, **and the cost is exactly the one `v1` names** —
|
||||||
|
> *the negotiation stays quotable forever.*
|
||||||
|
|
||||||
|
**What depends on this casting:** *`.gitignore`, and
|
||||||
|
`internal/external/polad.go`* — **which stages a candidate into
|
||||||
|
`.loom/cart/current/polad/` and therefore writes only into the untracked tree.**
|
||||||
|
*If the cart were ever committed again, `check` would start proposing changes
|
||||||
|
inside version control, which is the opposite of what a polad is for.*
|
||||||
|
|
||||||
|
## An open conflict this repository cannot settle
|
||||||
|
|
||||||
|
**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.**
|
||||||
|
|
||||||
|
*Both documents are loom's. **Recorded here because the conflict is visible from
|
||||||
|
inside an adopter and not from inside either document.***
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cast against `v2`: extract before converting
|
||||||
|
|
||||||
|
**`v2` adds no role.** *It adds an obligation at conversion, and this file is
|
||||||
|
where an adopter looks for what this repository does about it.*
|
||||||
|
|
||||||
|
**The write-ahead log is `wal.md`, in the cart, staged during the round.** *Cast
|
||||||
|
2026-09-07. It existed here before `v2` named it, as a local answer to the gap
|
||||||
|
`v2` closed.*
|
||||||
|
|
||||||
|
> **What comes out before the `rm`:** *every answer to a `Question`, `Select` or
|
||||||
|
> `Affirm`; every surviving write-ahead entry; and every fallback that quietly
|
||||||
|
> became the decision, said in those words.* **Destination is
|
||||||
|
> `.loom/event-log.md`**, *which is the durable record cast at the top of this
|
||||||
|
> file.*
|
||||||
|
|
||||||
|
**The status of our workaround changed and the code did not.** *Staging as we went
|
||||||
|
was a thing we invented and would not have thought of unaided.* **It is now
|
||||||
|
specified behaviour** — *unchanged in practice, entirely changed in standing.*
|
||||||
|
|
||||||
|
## Still uncast, because the document does not ask
|
||||||
|
|
||||||
|
**Nothing here says what a cart's `name` file must agree with.** *This round
|
||||||
|
arrived as a daily headed `rowan`, in a file called `loom-osprey.md`, with no
|
||||||
|
`name` file at all.* **The name is what tags durable entries** — *"everything
|
||||||
|
tagged with this name was considered in a single sphere of understanding"* — **so a
|
||||||
|
disagreement between the heading and the file mis-files the record, silently and
|
||||||
|
permanently.**
|
||||||
|
|
||||||
|
*Local answer: **the daily's own heading wins**, `name` is written to match, and
|
||||||
|
the file is renamed.* **Recorded here rather than filed as a gap**, *because it may
|
||||||
|
just be somebody typing fast.*
|
||||||
|
|||||||
@@ -24,6 +24,28 @@ in the document.*
|
|||||||
you fetch may refer to others; follow one when you hit something you do not know.
|
you fetch may refer to others; follow one when you hit something you do not know.
|
||||||
**Pre-resolving that is how you get a `node_modules`.***
|
**Pre-resolving that is how you get a `node_modules`.***
|
||||||
|
|
||||||
|
### Confidentiality does not travel with the copy
|
||||||
|
|
||||||
|
**Adopting is copying.** *So a document from a repository somebody may not read
|
||||||
|
ends up in a repository they may* — **and the publisher loses control of it at the
|
||||||
|
moment of adoption**, because the copy's visibility is governed by your repository
|
||||||
|
and not by theirs.
|
||||||
|
|
||||||
|
> **Do not adopt from a source less readable than the repository you are adopting
|
||||||
|
> into.** *If you may read it and your readers may not, copying it publishes it.*
|
||||||
|
|
||||||
|
**Two ways out, and the second is better when it is available.**
|
||||||
|
|
||||||
|
**Reference-only** — *record the lock and fetch on demand, keep no copy.* **You
|
||||||
|
give up reading it offline**, which is most of what a copy is for, *and you keep
|
||||||
|
the dependency recorded and checkable.*
|
||||||
|
|
||||||
|
**Ask them to publish** — *the thing you needed was almost certainly not the
|
||||||
|
confidential part.* **A repository that must stay private can still have a public
|
||||||
|
sibling that publishes**, and the split is usually along a line that already
|
||||||
|
exists: **the operational tree is what is sensitive; the pages telling somebody
|
||||||
|
what to decide are not.**
|
||||||
|
|
||||||
## Two facets beside it
|
## Two facets beside it
|
||||||
|
|
||||||
- **`.usages.md`** — *what we use, and **which of our artifacts depend on it***
|
- **`.usages.md`** — *what we use, and **which of our artifacts depend on it***
|
||||||
@@ -40,6 +62,7 @@ you fetch may refer to others; follow one when you hit something you do not know
|
|||||||
|
|
||||||
- **where it was fetched from** — *resolved, as above*
|
- **where it was fetched from** — *resolved, as above*
|
||||||
- **the publisher's `ETag`** — *verbatim*
|
- **the publisher's `ETag`** — *verbatim*
|
||||||
|
- **the visibility the source could be read at, when it was fetched**
|
||||||
|
|
||||||
**It is committed**, because the thing it locks is committed, and *a lock that
|
**It is committed**, because the thing it locks is committed, and *a lock that
|
||||||
travels separately from what it locks is the drift this is meant to prevent.*
|
travels separately from what it locks is the drift this is meant to prevent.*
|
||||||
@@ -49,6 +72,26 @@ travels separately from what it locks is the drift this is meant to prevent.*
|
|||||||
> asserts your copy is the one being served, which is the thing you were going to
|
> asserts your copy is the one being served, which is the thing you were going to
|
||||||
> check.
|
> check.
|
||||||
|
|
||||||
|
### Why visibility is in the lock and not left to the moment
|
||||||
|
|
||||||
|
**Access is checked once and the copy is durable.** *A token gets you the read;
|
||||||
|
nothing revisits it afterwards.* **So the adoption's legitimacy rests on the
|
||||||
|
relative visibility of two repositories — and that is changeable by a checkbox, a
|
||||||
|
year later, by somebody who never saw the adoption.**
|
||||||
|
|
||||||
|
> **Record `public` or `not-public`. Never `private`.** *An anonymous request can
|
||||||
|
> tell you a repository is not public. It cannot tell two repositories private to
|
||||||
|
> **different people** apart* — **which is exactly the case where private into
|
||||||
|
> private genuinely widens access.** *Record the word you can verify.*
|
||||||
|
|
||||||
|
**Checking it costs one request per run, not one per document**, *because the only
|
||||||
|
thing that must be current is **your own** visibility.* **Re-check a source only
|
||||||
|
when the alarm would fire** — *you are public, it was recorded not-public* — *and
|
||||||
|
if it has since gone public, update the lock and say nothing.*
|
||||||
|
|
||||||
|
*A stored visibility decays in both directions, which is why the alarm re-checks
|
||||||
|
rather than trusting the record.*
|
||||||
|
|
||||||
## Freshness is a conditional request
|
## Freshness is a conditional request
|
||||||
|
|
||||||
**Locked on the publisher's `ETag`, verbatim — never a hash you compute.** *A
|
**Locked on the publisher's `ETag`, verbatim — never a hash you compute.** *A
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Usages — `externals`
|
||||||
|
|
||||||
|
**This repository implements this document.** *That is an unusual usage: most
|
||||||
|
adopters use a convention, and `loomctl` is the convention's mechanism, so nearly
|
||||||
|
every rule here names a line of code.*
|
||||||
|
|
||||||
|
## What we use, and what implements it
|
||||||
|
|
||||||
|
**Freshness is a conditional request, locked on the publisher's `ETag`,
|
||||||
|
verbatim** — *`internal/lock/lock.go`* (**the record: path, resolved URL, `ETag`**)
|
||||||
|
*and* `internal/external/external.go` (*`fetch`, which sends `If-None-Match`*).
|
||||||
|
**We compute no hash anywhere**, *which this document requires and which was
|
||||||
|
measured to matter: on gitea the `ETag` is the git blob hash and on GitHub it is
|
||||||
|
not.*
|
||||||
|
|
||||||
|
**The status table — `304`, `200`, `410`, `404`** — *`checkLocked` in
|
||||||
|
`internal/external/external.go`.* **`404` is reported unresolved, naming both
|
||||||
|
readings**, *which is this document's rule and was a gap we filed against it
|
||||||
|
before it was.*
|
||||||
|
|
||||||
|
**"The new copy is a candidate, not a replacement"** — *`internal/external/polad.go`.*
|
||||||
|
**A changed document is staged in the cart as a polad and nothing is overwritten**;
|
||||||
|
*applying is a separate act.*
|
||||||
|
|
||||||
|
**"Reconciliation runs the other way"** — *`usagesNote` and `usagesFor` in
|
||||||
|
`internal/external/polad.go`*, **which print this kind of file beside a staged
|
||||||
|
change**, *because it names the code to go and fix.* **It says so when there is
|
||||||
|
none.*
|
||||||
|
|
||||||
|
**The published surface is what `list` reads** — *`internal/external/list.go`,*
|
||||||
|
`.loom/published` **only.** *What is not exported is not hidden; it is simply not
|
||||||
|
what you depend on.*
|
||||||
|
|
||||||
|
**The path is for a person** — *`localPath` in `internal/external/external.go`.*
|
||||||
|
*This document retracted "the path says where it came from"; **we record the
|
||||||
|
resolved origin in the lock instead**, and the path is `<host>/<owner>/<repo>/<name>.md`
|
||||||
|
with `--path` for when that guess is wrong.*
|
||||||
|
|
||||||
|
## What we expected to find and did not
|
||||||
|
|
||||||
|
**Nothing outstanding.** *The `404` gap we filed here was closed on 2026-09-07 and
|
||||||
|
the facet was deleted at reconciliation.*
|
||||||
|
|
||||||
|
## Not yet implemented
|
||||||
|
|
||||||
|
**"Confidentiality does not travel with the copy"**, *added 2026-09-07.* **Nothing
|
||||||
|
in `loomctl` detects it.** *The tool knows the half it can see — whether a fetch
|
||||||
|
needed a credential — and does not yet say so.* **Reference-only adoption, which
|
||||||
|
this document offers as the first way out, does not exist either.**
|
||||||
|
|
||||||
|
*Recorded here rather than as a gap, because the gap is ours: **the document says
|
||||||
|
what to do and the tool does not do it yet.***
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<!-- Generated by `loomctl orient`. Safe to overwrite; nobody typed it.
|
||||||
|
Regenerate after adopting a document or reconciling a change. -->
|
||||||
|
|
||||||
|
# What this repository depends on
|
||||||
|
|
||||||
|
Copies of other people's documents are kept under `.loom/externals/`, at a path
|
||||||
|
that says where each came from. **They are copies: do not edit them.** Anything
|
||||||
|
you want to say about one goes in a file *beside* it, never into it.
|
||||||
|
|
||||||
|
Three moves, and each has a document that owns the rule:
|
||||||
|
|
||||||
|
- **A copy is wrong, or you needed something it does not say** — write it in
|
||||||
|
`<name>.gaps.md` beside the copy.
|
||||||
|
- **What of ours depends on a copy** — write it in `<name>.usages.md` beside it.
|
||||||
|
- **A source changed** — `loomctl external check` says so and stages the new copy;
|
||||||
|
`loomctl external apply` takes it. Neither edits anything on its own.
|
||||||
|
|
||||||
|
## `git.hypertheory-labs.dev/jeffry/homelab-cluster/gitea.md`
|
||||||
|
|
||||||
|
- source: https://git.hypertheory-labs.dev/jeffry/homelab-cluster/raw/branch/main/.loom/published/gitea.md
|
||||||
|
- what of ours depends on it: `.loom/externals/git.hypertheory-labs.dev/jeffry/homelab-cluster/gitea.usages.md`
|
||||||
|
|
||||||
|
## `git.hypertheory-labs.dev/loom/annotating/annotating.md`
|
||||||
|
|
||||||
|
- source: https://git.hypertheory-labs.dev/loom/annotating/raw/branch/main/.loom/published/annotating.md
|
||||||
|
- what of ours depends on it: **nothing recorded** — no `.usages.md`
|
||||||
|
- what we expected and did not find: `.loom/externals/git.hypertheory-labs.dev/loom/annotating/annotating.gaps.md`
|
||||||
|
|
||||||
|
## `git.hypertheory-labs.dev/loom/bedrock/loom-directory.md`
|
||||||
|
|
||||||
|
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/loom-directory.md
|
||||||
|
- what of ours depends on it: **nothing recorded** — no `.usages.md`
|
||||||
|
|
||||||
|
## `git.hypertheory-labs.dev/loom/bedrock/publication.md`
|
||||||
|
|
||||||
|
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md
|
||||||
|
- what of ours depends on it: **nothing recorded** — no `.usages.md`
|
||||||
|
|
||||||
|
## `git.hypertheory-labs.dev/loom/bedrock/recording-decisions.md`
|
||||||
|
|
||||||
|
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/recording-decisions.md
|
||||||
|
- what of ours depends on it: **nothing recorded** — no `.usages.md`
|
||||||
|
|
||||||
|
## `git.hypertheory-labs.dev/loom/bedrock/sibling-facets.md`
|
||||||
|
|
||||||
|
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/sibling-facets.md
|
||||||
|
- what of ours depends on it: **nothing recorded** — no `.usages.md`
|
||||||
|
|
||||||
|
## `git.hypertheory-labs.dev/loom/bedrock/starting.md`
|
||||||
|
|
||||||
|
- source: https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/starting.md
|
||||||
|
- what of ours depends on it: **nothing recorded** — no `.usages.md`
|
||||||
|
|
||||||
|
## `git.hypertheory-labs.dev/loom/cart/cart.md`
|
||||||
|
|
||||||
|
- source: https://git.hypertheory-labs.dev/loom/cart/raw/branch/main/.loom/published/cart.md
|
||||||
|
- what of ours depends on it: `.loom/externals/git.hypertheory-labs.dev/loom/cart/cart.usages.md`
|
||||||
|
- what we expected and did not find: `.loom/externals/git.hypertheory-labs.dev/loom/cart/cart.gaps.md`
|
||||||
|
|
||||||
|
## `git.hypertheory-labs.dev/loom/externals/externals.md`
|
||||||
|
|
||||||
|
- source: https://git.hypertheory-labs.dev/loom/externals/raw/branch/main/.loom/published/externals.md
|
||||||
|
- what of ours depends on it: `.loom/externals/git.hypertheory-labs.dev/loom/externals/externals.usages.md`
|
||||||
|
|
||||||
|
## This repository publishes
|
||||||
|
|
||||||
|
See `.loom/published/`. What is handed over for others to depend on is
|
||||||
|
there; the rest of the repository is not hidden, it is simply not what
|
||||||
|
anybody depends on.
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
# What `loomctl` guarantees
|
||||||
|
|
||||||
|
**Not what it does — `--help` says that, and the commands will change.** *This is
|
||||||
|
what will keep being true while they do.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## It grants no access, and records where the copy came from
|
||||||
|
|
||||||
|
**`loomctl` reads what your credentials already let you read.** *Everything it
|
||||||
|
does is possible with copy and paste.* **It is a mast, not a lock** — *the point
|
||||||
|
is to make the wrong thing deliberate, not impossible.*
|
||||||
|
|
||||||
|
> **What it adds over a paste is provenance.** *A pasted document cannot answer
|
||||||
|
> "where did this come from, and were we allowed to have it" — not because the
|
||||||
|
> question is hard, but because the evidence is gone.*
|
||||||
|
|
||||||
|
## It never writes over the network
|
||||||
|
|
||||||
|
**No push, no publish, no `POST`, no token that needs write scope.** *Every byte
|
||||||
|
it writes is a file in your working tree.* **Committing and pushing are yours**,
|
||||||
|
*because the consequences of a push land on people a tool cannot experience.*
|
||||||
|
|
||||||
|
**A credential given to `loomctl` should never carry write scope**, *and if one
|
||||||
|
does, nothing here will use it.*
|
||||||
|
|
||||||
|
## Freshness is a conditional request against the publisher's `ETag`
|
||||||
|
|
||||||
|
**Stored verbatim, opaque, never a hash we compute.** *A fetch that normalises
|
||||||
|
anything breaks a local digest and reports a change that did not happen.*
|
||||||
|
|
||||||
|
*This is `externals`' rule and `loomctl` is a second holder of it. **It is stated
|
||||||
|
here so that the tool holding it is a fact somebody can find**, and so that
|
||||||
|
breaking it is visible rather than silent* — **a specimen was written in this
|
||||||
|
repository that computed a hash instead, and it was wrong within a day.**
|
||||||
|
|
||||||
|
**The URL in a lock is resolved.** *A short form follows whatever the default
|
||||||
|
branch is at the time you ask, so a branch rename would report as a change in the
|
||||||
|
document.*
|
||||||
|
|
||||||
|
## It reports; it does not repair
|
||||||
|
|
||||||
|
**Nothing is overwritten.** *A document that moved upstream is written to a
|
||||||
|
staging area as a candidate, and taking it is a separate act.* **A local copy
|
||||||
|
that differs from what the publisher serves is left alone** — *it is the only
|
||||||
|
evidence that something changed while nothing was watching.*
|
||||||
|
|
||||||
|
**`add` adopts what is absent and refuses what is already adopted.** *A document
|
||||||
|
that is present but unlocked is locked only when the bytes are identical to what
|
||||||
|
the publisher serves*, **so a lock's claim — this copy is the one being served —
|
||||||
|
is verified rather than assumed.**
|
||||||
|
|
||||||
|
## The lock file
|
||||||
|
|
||||||
|
**`.loom/externals/.locks`, one record per adopted document, tab-separated,
|
||||||
|
ordered by path.**
|
||||||
|
|
||||||
|
```
|
||||||
|
path <TAB> url <TAB> etag [ <TAB> visibility ]
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`path`** — *relative to `.loom/externals/`, and **for a person to read**.* **It
|
||||||
|
does not round-trip to a URL**; *it drops the route, the branch, and the
|
||||||
|
publisher's `.loom/published/`.*
|
||||||
|
- **`url`** — *the resolved origin, branch and all.*
|
||||||
|
- **`etag`** — *the publisher's, verbatim, including its quotes.*
|
||||||
|
- **`visibility`** — *optional. What the source could be read as **when it was
|
||||||
|
fetched**: `public` or `not-public`.* **Never `private`** — *an anonymous
|
||||||
|
request tells those two apart and nothing finer.*
|
||||||
|
|
||||||
|
**Records with three fields remain valid.** *A repository does not stop working
|
||||||
|
because the tool learned something new.*
|
||||||
|
|
||||||
|
## What is not promised
|
||||||
|
|
||||||
|
**The command surface.** *Names, flags and output are `--help`'s business and may
|
||||||
|
change. Nothing should parse them.*
|
||||||
|
|
||||||
|
**The generated orientation file's format.** *It is byte-deterministic within a
|
||||||
|
version so that its diff is readable; it is not stable across versions.*
|
||||||
|
|
||||||
|
**That visibility is precise.** *The signal distinguishes public from not-public
|
||||||
|
and nothing finer, so it cannot see two repositories private to different people
|
||||||
|
— which is the case where adopting between private repositories genuinely widens
|
||||||
|
access.* **The tool says so where it reports it, rather than implying a verdict it
|
||||||
|
has not earned.**
|
||||||
|
|
||||||
|
**That anything is checked when you are not looking.** *Nothing here runs on a
|
||||||
|
schedule, and a document nobody checks is a document nobody is checking.*
|
||||||
|
|
||||||
|
## What is promised about the things that are not promised
|
||||||
|
|
||||||
|
**The command surface may change. This page will say when it does.**
|
||||||
|
|
||||||
|
*A consumer whose README tells a person to type `loomctl external add loom/<repo>
|
||||||
|
<doc>.md` depends on that command by name — **not by parsing it, which is why no
|
||||||
|
check would ever catch a rename**. The thing that moved would not be a document
|
||||||
|
they adopted.*
|
||||||
|
|
||||||
|
> **So the exclusion is narrower than "you are on your own".** *We do not promise
|
||||||
|
> the surface holds; **we promise that a change to it lands here**, on a page you
|
||||||
|
> can lock* — **and a lock on this page reports `200` the day a command is
|
||||||
|
> renamed.**
|
||||||
|
|
||||||
|
**That is the whole mechanism, and it costs one section:**
|
||||||
|
|
||||||
|
### Surface changes
|
||||||
|
|
||||||
|
*Append-only, newest last. Nothing yet — the commands have not been renamed since
|
||||||
|
this page existed.*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Verified fetchable by somebody who is not us on 2026-09-08.*
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
# loom-cli
|
# loom-cli
|
||||||
|
|
||||||
**Not built.** *A small tool for the operations a person should not do by hand:
|
**`loomctl` fetches the documents this repository depends on, and tells you when
|
||||||
fetch a document you depend on, and find out when it changed.*
|
they change.** It reports; it never repairs.
|
||||||
|
|
||||||
**The design is in [`.loom/event-log.md`](.loom/event-log.md)**, *not in a spec.*
|
```
|
||||||
**Every entry says what was decided and the belief that could turn out false**, so
|
go build -o loomctl .
|
||||||
you can see which parts are load-bearing and which were guesses. *Everything
|
./loomctl --help
|
||||||
tagged `osprey` was decided in one round.*
|
```
|
||||||
|
|
||||||
**Three commands.** *`pull` adopts a document and writes its lock; `check` asks
|
`check` only reads. Nothing here writes over the network, so a credential it is
|
||||||
every publisher whether their copy has moved; `reachable` asks whether somebody
|
given never needs write scope.
|
||||||
who is not you can fetch what you published.* **None of them repairs anything.**
|
|
||||||
|
|
||||||
**`bedrock` and `externals`, under [`.loom/externals/`](.loom/externals/), are not
|
**Why it is the way it is:** [`.loom/event-log.md`](.loom/event-log.md). Every
|
||||||
discardable.** *Read them as given — accommodating them is what makes this a loom
|
entry says what was decided and the belief that could turn out to be false.
|
||||||
tool rather than some other thing.*
|
|
||||||
|
**What it depends on, and what we could not tell from those documents:**
|
||||||
|
[`.loom/externals/`](.loom/externals/) — the `.gaps.md` files beside each one.
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// 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 a set of named contexts with one current, and why a host's URL
|
||||||
|
// shapes live here rather than in what a person types. Nobody should have to
|
||||||
|
// know that gitea serves raw files from /raw/branch/<branch>/ to adopt a
|
||||||
|
// document.
|
||||||
|
//
|
||||||
|
// It lives in the user's home directory and never in a repository.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Context is one named way of talking to one host.
|
||||||
|
type Context struct {
|
||||||
|
Host string `json:"host"`
|
||||||
|
// Flavor selects the URL shapes: gitea, github or gitlab. Empty means gitea.
|
||||||
|
Flavor string `json:"flavor,omitempty"`
|
||||||
|
// Token is read-only. loomctl never writes over the network, so a token it
|
||||||
|
// is given should not carry write scope.
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
CurrentContext string `json:"current-context"`
|
||||||
|
Contexts map[string]Context `json:"contexts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 and no context.
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
c := &Config{Contexts: map[string]Context{}}
|
||||||
|
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.Contexts == nil {
|
||||||
|
c.Contexts = map[string]Context{}
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current returns the context a bare `owner/repo` is resolved against.
|
||||||
|
func (c *Config) Current() (Context, error) {
|
||||||
|
if c.CurrentContext == "" {
|
||||||
|
return Context{}, fmt.Errorf("no current-context in %s — a bare owner/repo has no host to resolve against", Path())
|
||||||
|
}
|
||||||
|
ctx, ok := c.Contexts[c.CurrentContext]
|
||||||
|
if !ok {
|
||||||
|
return Context{}, fmt.Errorf("current-context %q is not defined in %s", c.CurrentContext, Path())
|
||||||
|
}
|
||||||
|
if ctx.Host == "" {
|
||||||
|
return Context{}, fmt.Errorf("context %q has no host", c.CurrentContext)
|
||||||
|
}
|
||||||
|
return ctx, 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
|
||||||
|
}
|
||||||
|
for _, ctx := range c.Contexts {
|
||||||
|
if ctx.Host == host && ctx.Token != "" {
|
||||||
|
return ctx.Token
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func envKey(host string) string {
|
||||||
|
r := strings.NewReplacer(".", "_", "-", "_", ":", "_")
|
||||||
|
return strings.ToUpper(r.Replace(host))
|
||||||
|
}
|
||||||
|
|
||||||
|
// RawURL builds the address a document is served from, given owner/repo and a
|
||||||
|
// path inside the repository.
|
||||||
|
//
|
||||||
|
// For gitea the short form is deliberate: the host redirects it to the resolved
|
||||||
|
// branch, so the URL recorded in the lock names a branch rather than a moving
|
||||||
|
// ref, without anybody having to know which branch it was.
|
||||||
|
func (c Context) RawURL(ownerRepo, pathInRepo string) (string, bool) {
|
||||||
|
switch c.flavor() {
|
||||||
|
case "gitea":
|
||||||
|
return fmt.Sprintf("https://%s/%s/raw/%s", c.Host, ownerRepo, pathInRepo), true
|
||||||
|
case "github":
|
||||||
|
return fmt.Sprintf("https://raw.githubusercontent.com/%s/%%s/%s", ownerRepo, pathInRepo), false
|
||||||
|
case "gitlab":
|
||||||
|
return fmt.Sprintf("https://%s/%s/-/raw/%%s/%s", c.Host, ownerRepo, pathInRepo), false
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// CloneURL is what git is pointed at.
|
||||||
|
func (c Context) CloneURL(ownerRepo string) string {
|
||||||
|
return fmt.Sprintf("https://%s/%s.git", c.Host, ownerRepo)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Context) flavor() string {
|
||||||
|
if c.Flavor == "" {
|
||||||
|
return "gitea"
|
||||||
|
}
|
||||||
|
return strings.ToLower(c.Flavor)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flavors names what RawURL understands, for error messages.
|
||||||
|
func Flavors() string { return "gitea, github, gitlab" }
|
||||||
Vendored
+110
@@ -0,0 +1,110 @@
|
|||||||
|
package external
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.hypertheory-labs.dev/loom/loom-cli/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AddByName adopts a document named the way `list` prints it, against the
|
||||||
|
// current context.
|
||||||
|
//
|
||||||
|
// loomctl external add loom/cart cart
|
||||||
|
//
|
||||||
|
// Nobody should have to type a host's raw-file route to adopt a document. The
|
||||||
|
// route belongs to the host, so it lives in the context; the published
|
||||||
|
// directory belongs to the convention, so it lives in the code; and what is
|
||||||
|
// left — which repository, which document — is the only part a person knows.
|
||||||
|
func AddByName(root, ownerRepo, name string, out io.Writer) error {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, err := cfg.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if strings.Count(ownerRepo, "/") != 1 {
|
||||||
|
return fmt.Errorf("want owner/repo, got %q", ownerRepo)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(name, ".md") {
|
||||||
|
name += ".md"
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, resolved := ctx.RawURL(ownerRepo, PublishedDir+"/"+name)
|
||||||
|
if raw == "" {
|
||||||
|
return fmt.Errorf("context %q has flavor %q; known flavors are %s",
|
||||||
|
cfg.CurrentContext, ctx.Flavor, config.Flavors())
|
||||||
|
}
|
||||||
|
if !resolved {
|
||||||
|
// The lock must record a resolved URL. A short form that stays short
|
||||||
|
// follows whatever the default branch is at the time you ask, so a
|
||||||
|
// branch rename would report as a change in the document.
|
||||||
|
branch, err := defaultBranch(ctx.CloneURL(ownerRepo))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolving the default branch of %s: %w", ownerRepo, err)
|
||||||
|
}
|
||||||
|
raw = fmt.Sprintf(raw, branch)
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
func ListByName(ownerRepo string, out io.Writer) error {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
ctx, err := cfg.Current()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return List(ctx.CloneURL(ownerRepo), out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultBranch asks the remote which branch HEAD points at, without cloning.
|
||||||
|
//
|
||||||
|
// Only hosts whose raw URLs do not redirect need this. Gitea resolves its own
|
||||||
|
// short form, which is why adopting from gitea needs no git at all.
|
||||||
|
func defaultBranch(cloneURL string) (string, error) {
|
||||||
|
cmd := exec.Command("git", "ls-remote", "--symref", cloneURL, "HEAD")
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
|
||||||
|
}
|
||||||
|
for _, line := range strings.Split(stdout.String(), "\n") {
|
||||||
|
if after, ok := strings.CutPrefix(line, "ref: refs/heads/"); ok {
|
||||||
|
return strings.TrimSpace(strings.SplitN(after, "\t", 2)[0]), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no symref in git ls-remote output")
|
||||||
|
}
|
||||||
Vendored
+68
@@ -0,0 +1,68 @@
|
|||||||
|
package external
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"git.hypertheory-labs.dev/loom/loom-cli/internal/lock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// auditExposure reports documents adopted from a source that could not be read
|
||||||
|
// anonymously, into a repository that now can.
|
||||||
|
//
|
||||||
|
// Access is verified once, at fetch, and the copy is durable. Whether the
|
||||||
|
// adoption is still legitimate rests on the relative visibility of two
|
||||||
|
// repositories — a fact somebody can change with a checkbox a year later,
|
||||||
|
// without ever seeing the adoption. This is what turns that from a silent
|
||||||
|
// permanent hazard into something that runs.
|
||||||
|
func auditExposure(root string, locks *lock.Set, out io.Writer) (changed bool) {
|
||||||
|
var suspect []lock.Record
|
||||||
|
for _, r := range locks.All() {
|
||||||
|
if r.Visibility == lock.NotPublic {
|
||||||
|
suspect = append(suspect, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(suspect) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only our own visibility has to be current, and it is one request for the
|
||||||
|
// whole run rather than one per document.
|
||||||
|
public, known := selfVisibility(root)
|
||||||
|
if known && !public {
|
||||||
|
return false // adopted private into private; nothing has widened
|
||||||
|
}
|
||||||
|
if !known {
|
||||||
|
fmt.Fprintf(out, "\n%d document(s) came from a source that needed a credential, and I cannot\n", len(suspect))
|
||||||
|
fmt.Fprintf(out, "tell who may read this repository — no usable origin.\n")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// The stored value decays in both directions. A source that has since gone
|
||||||
|
// public would otherwise raise this alarm forever, so re-check — but only
|
||||||
|
// the suspects, and only when the alarm would actually fire.
|
||||||
|
var still []lock.Record
|
||||||
|
for _, r := range suspect {
|
||||||
|
// Probe with no credential: what matters is what a stranger can read,
|
||||||
|
// not what we can.
|
||||||
|
if probeAnonymous(r.URL) == lock.Public {
|
||||||
|
r.Visibility = lock.Public
|
||||||
|
locks.Put(r)
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
still = append(still, r)
|
||||||
|
}
|
||||||
|
if len(still) == 0 {
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(out, "\nEXPOSURE this repository is public and holds %d document(s) adopted from\n", len(still))
|
||||||
|
fmt.Fprintf(out, " sources that are not:\n")
|
||||||
|
for _, r := range still {
|
||||||
|
fmt.Fprintf(out, " %s\n", r.Path)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, " Confidentiality does not travel with the copy. This was legitimate\n")
|
||||||
|
fmt.Fprintf(out, " when adopted if this repository was not public then.\n")
|
||||||
|
return changed
|
||||||
|
}
|
||||||
Vendored
+459
@@ -0,0 +1,459 @@
|
|||||||
|
// 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 adopts a document that is not here yet.
|
||||||
|
//
|
||||||
|
// 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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel := override
|
||||||
|
if rel == "" {
|
||||||
|
if rel, err = localPath(raw); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locks, err := lock.Load(root)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
vis := sourceVisibility(cfg, body.url)
|
||||||
|
|
||||||
|
// 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, Visibility: vis})
|
||||||
|
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, Visibility: vis})
|
||||||
|
if err := locks.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Fprintf(out, "adopted %s\n", rel)
|
||||||
|
fmt.Fprintf(out, " from %s\n", body.url)
|
||||||
|
warnIfNotPublic(root, body.url, vis, out)
|
||||||
|
notePublishedSurface(body.url, out)
|
||||||
|
if etag == "" {
|
||||||
|
fmt.Fprintf(out, " etag (none served — check cannot ask conditionally)\n")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(out, " etag %s\n", etag)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// warnIfNotPublic says so when a document could only be fetched with a
|
||||||
|
// credential.
|
||||||
|
//
|
||||||
|
// Adopting is copying, so a document from a repository somebody may not read
|
||||||
|
// ends up in a repository they may, and the publisher loses control of it at the
|
||||||
|
// moment of adoption. The tool can see half of that — whether this fetch needed
|
||||||
|
// a credential — and cannot see the other half, which is who can read the
|
||||||
|
// repository the copy is landing in. It reports the half it knows.
|
||||||
|
// sourceVisibility reports what a document could be read as at the moment it was
|
||||||
|
// fetched, given that the fetch had just succeeded.
|
||||||
|
//
|
||||||
|
// It is free: when no credential was configured the fetch itself was anonymous,
|
||||||
|
// so the answer is already known; when one was, the extra request is the one the
|
||||||
|
// warning needed anyway.
|
||||||
|
//
|
||||||
|
// It must not be used to re-check a document we are not fetching. "No credential
|
||||||
|
// configured" says nothing about whether a probe would succeed, and treating it
|
||||||
|
// as public there would silently clear a real alarm.
|
||||||
|
func sourceVisibility(cfg *config.Config, raw string) string {
|
||||||
|
if u, err := url.Parse(raw); err == nil && cfg.TokenFor(u.Host) == "" {
|
||||||
|
return lock.Public // it came back without a credential
|
||||||
|
}
|
||||||
|
return probeAnonymous(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeAnonymous asks, with no credential at all, whether a URL can be read.
|
||||||
|
func probeAnonymous(raw string) string {
|
||||||
|
req, err := http.NewRequest(http.MethodHead, raw, nil)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
return lock.Public
|
||||||
|
}
|
||||||
|
return lock.NotPublic
|
||||||
|
}
|
||||||
|
|
||||||
|
// warnIfNotPublic says something when adopting would widen who can read a
|
||||||
|
// document.
|
||||||
|
//
|
||||||
|
// Adopting is copying, so a document from a repository somebody may not read
|
||||||
|
// ends up in a repository they may, and the publisher loses control of it at the
|
||||||
|
// moment of adoption.
|
||||||
|
func warnIfNotPublic(root, raw, vis string, out io.Writer) {
|
||||||
|
if vis != lock.NotPublic {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
public, known := selfVisibility(root)
|
||||||
|
if known && !public {
|
||||||
|
fmt.Fprintf(out, " NOTE private source, and this repository is not public either.\n")
|
||||||
|
fmt.Fprintf(out, " Access is not widened by that alone — but this check only\n")
|
||||||
|
fmt.Fprintf(out, " tells public from not-public, so it cannot see two repositories\n")
|
||||||
|
fmt.Fprintf(out, " private to different people. That case does widen it.\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !known {
|
||||||
|
fmt.Fprintf(out, " WARN this needed a credential, and I cannot tell who may read this\n")
|
||||||
|
fmt.Fprintf(out, " repository — no usable origin. Check before you commit.\n")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, " WARN this needed a credential, and THIS repository is public.\n")
|
||||||
|
fmt.Fprintf(out, " Confidentiality does not travel with the copy: adopting this\n")
|
||||||
|
fmt.Fprintf(out, " publishes it to everyone. Do not adopt from a source less\n")
|
||||||
|
fmt.Fprintf(out, " readable than the repository you are adopting into.\n")
|
||||||
|
fmt.Fprintf(out, " Two ways out: ask them to publish it — usually the thing you\n")
|
||||||
|
fmt.Fprintf(out, " needed was not the confidential part — or keep no copy and\n")
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, "", &NotFoundError{URL: raw}
|
||||||
|
default:
|
||||||
|
return nil, "", fmt.Errorf("%s: %s", resp.Status, raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, and stages what did.
|
||||||
|
//
|
||||||
|
// 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 {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
locks, err := lock.Load(root)
|
||||||
|
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
|
||||||
|
st, did := checkLocked(cfg, root, rec, open)
|
||||||
|
staged += did
|
||||||
|
results = append(results, st)
|
||||||
|
}
|
||||||
|
|
||||||
|
unlocked, err := unlockedDocs(root, seen)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, rel := range unlocked {
|
||||||
|
st, did := checkUnlocked(cfg, root, rel, locks, open)
|
||||||
|
staged += did
|
||||||
|
results = append(results, st)
|
||||||
|
}
|
||||||
|
auditPrinted := &strings.Builder{}
|
||||||
|
auditExposure(root, locks, auditPrinted)
|
||||||
|
if err := locks.Save(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
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 %-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)
|
||||||
|
}
|
||||||
|
io.WriteString(out, auditPrinted.String())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"}, 0
|
||||||
|
}
|
||||||
|
if rec.ETag == "" {
|
||||||
|
return Status{rec.Path, "no-etag", "publisher served none; freshness cannot be asked"}, 0
|
||||||
|
}
|
||||||
|
body, etag, err := fetch(cfg, rec.URL, rec.ETag)
|
||||||
|
if err != nil {
|
||||||
|
return Status{rec.Path, "error", err.Error()}, 0
|
||||||
|
}
|
||||||
|
if body == nil {
|
||||||
|
return Status{rec.Path, "same", ""}, 0
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 without rewriting this copy"}, 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
|
||||||
|
// 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
|
||||||
|
}
|
||||||
Vendored
+72
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+107
@@ -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
|
||||||
|
}
|
||||||
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()
|
||||||
|
}
|
||||||
Vendored
+97
@@ -0,0 +1,97 @@
|
|||||||
|
package external
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// selfVisibility reports whether the repository we are adopting into can be read
|
||||||
|
// anonymously.
|
||||||
|
//
|
||||||
|
// This is the half of the confidentiality rule the tool was previously blind to.
|
||||||
|
// Knowing only that a source is private makes the warning fire on every adoption
|
||||||
|
// a private repository performs, which is the legitimate case — and a warning
|
||||||
|
// that always fires is a warning nobody reads.
|
||||||
|
//
|
||||||
|
// The signal is coarse on purpose. It distinguishes public from not-public and
|
||||||
|
// nothing finer, so it cannot see that two repositories are private to different
|
||||||
|
// groups. That case widens access and this check will miss it.
|
||||||
|
func selfVisibility(root string) (public bool, known bool) {
|
||||||
|
out, err := exec.Command("git", "-C", root, "remote", "get-url", "origin").Output()
|
||||||
|
if err != nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
host, ownerRepo, ok := splitRemote(strings.TrimSpace(string(out)))
|
||||||
|
if !ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(http.MethodHead, "https://"+host+"/"+ownerRepo, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
return resp.StatusCode == http.StatusOK, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitRemote pulls a host and owner/repo out of a git remote, whether it is
|
||||||
|
// ssh, ssh:// or https.
|
||||||
|
func splitRemote(remote string) (host, ownerRepo string, ok bool) {
|
||||||
|
s := remote
|
||||||
|
if i := strings.Index(s, "://"); i >= 0 {
|
||||||
|
s = s[i+3:]
|
||||||
|
}
|
||||||
|
if at := strings.Index(s, "@"); at >= 0 {
|
||||||
|
s = s[at+1:]
|
||||||
|
}
|
||||||
|
// scp-style "host:owner/repo.git" and url-style "host:port/owner/repo.git"
|
||||||
|
var rest string
|
||||||
|
if i := strings.IndexAny(s, ":/"); i >= 0 {
|
||||||
|
host, rest = s[:i], s[i+1:]
|
||||||
|
} else {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
if j := strings.Index(rest, "/"); j >= 0 && isPort(rest[:j]) {
|
||||||
|
rest = rest[j+1:]
|
||||||
|
}
|
||||||
|
rest = strings.TrimSuffix(strings.Trim(rest, "/"), ".git")
|
||||||
|
if host == "" || strings.Count(rest, "/") != 1 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return host, rest, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPort(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, r := range s {
|
||||||
|
if r < '0' || r > '9' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// notePublishedSurface says when an adopted document did not come from the
|
||||||
|
// publisher's published surface.
|
||||||
|
//
|
||||||
|
// What is not exported is not hidden — the rest of a repository is there to read.
|
||||||
|
// It is simply not what you depend on, and a lock against it records a dependency
|
||||||
|
// on something that was never a contract.
|
||||||
|
func notePublishedSurface(rawURL string, out interface{ Write([]byte) (int, error) }) {
|
||||||
|
if strings.Contains(rawURL, "/"+PublishedDir+"/") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var b bytes.Buffer
|
||||||
|
fmt.Fprintf(&b, " NOTE not from %s — what is not exported is not hidden, but it is\n", PublishedDir)
|
||||||
|
fmt.Fprintf(&b, " not what you depend on. Nothing promises this path will still\n")
|
||||||
|
fmt.Fprintf(&b, " be there, or still mean this, tomorrow.\n")
|
||||||
|
out.Write(b.Bytes())
|
||||||
|
}
|
||||||
Vendored
+34
@@ -0,0 +1,34 @@
|
|||||||
|
package external
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestSplitRemote(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
remote, host, ownerRepo string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"ssh://git@git.hypertheory-labs.dev:2222/loom/loom-cli.git", "git.hypertheory-labs.dev", "loom/loom-cli", true},
|
||||||
|
{"ssh://git@git.hypertheory-labs.dev/loom/loom-cli.git", "git.hypertheory-labs.dev", "loom/loom-cli", true},
|
||||||
|
{"git@github.com:octocat/Hello-World.git", "github.com", "octocat/Hello-World", true},
|
||||||
|
{"https://gitlab.com/gitlab-org/gitlab-svgs.git", "gitlab.com", "gitlab-org/gitlab-svgs", true},
|
||||||
|
{"https://gitlab.com/gitlab-org/gitlab-svgs", "gitlab.com", "gitlab-org/gitlab-svgs", true},
|
||||||
|
// A nested group is not owner/repo, and guessing would produce a URL
|
||||||
|
// that answers about the wrong repository.
|
||||||
|
{"https://gitlab.com/group/sub/project.git", "", "", false},
|
||||||
|
{"/srv/git/bare.git", "", "", false},
|
||||||
|
{"", "", "", false},
|
||||||
|
} {
|
||||||
|
host, or, ok := splitRemote(tc.remote)
|
||||||
|
if ok != tc.ok || host != tc.host || or != tc.ownerRepo {
|
||||||
|
t.Errorf("splitRemote(%q) = (%q, %q, %v), want (%q, %q, %v)",
|
||||||
|
tc.remote, host, or, ok, tc.host, tc.ownerRepo, tc.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelfVisibilityUnknownOutsideARepo(t *testing.T) {
|
||||||
|
// Failing open matters: "cannot tell" must not read as "not public".
|
||||||
|
if _, known := selfVisibility(t.TempDir()); known {
|
||||||
|
t.Error("selfVisibility in a non-repository reported a known answer")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// 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" +
|
||||||
|
"# path<TAB>url<TAB>etag[<TAB>visibility]\n" +
|
||||||
|
"# The url is resolved: a short form would follow whatever the default branch\n" +
|
||||||
|
"# is at the time you ask. visibility is what the source could be read as when\n" +
|
||||||
|
"# it was fetched, because that is checked once and the copy is durable.\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
|
||||||
|
// Visibility is what the source could be read as when it was fetched:
|
||||||
|
// "public", "not-public", or empty for locks written before this was
|
||||||
|
// recorded.
|
||||||
|
//
|
||||||
|
// It is deliberately coarse. An anonymous request tells public from
|
||||||
|
// not-public and nothing finer, so this cannot distinguish two repositories
|
||||||
|
// private to different people — which is the case where adopting between
|
||||||
|
// private repositories genuinely widens access.
|
||||||
|
//
|
||||||
|
// It is recorded because access is checked once, at fetch, and the copy is
|
||||||
|
// durable. Whether an adoption is still legitimate depends on the relative
|
||||||
|
// visibility of two repositories, which somebody can change with a checkbox
|
||||||
|
// a year later without ever seeing the adoption.
|
||||||
|
Visibility string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Visibility values. Never "private": the signal cannot support the word.
|
||||||
|
const (
|
||||||
|
Public = "public"
|
||||||
|
NotPublic = "not-public"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set is every lock, keyed by path.
|
||||||
|
type Set struct {
|
||||||
|
file string
|
||||||
|
recs map[string]Record
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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) { return LoadFile(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) {
|
||||||
|
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 && len(parts) != 4 {
|
||||||
|
return nil, fmt.Errorf("%s:%d: want 3 or 4 tab-separated fields, got %d", file, n, len(parts))
|
||||||
|
}
|
||||||
|
r := Record{Path: parts[0], URL: parts[1], ETag: parts[2]}
|
||||||
|
if len(parts) == 4 {
|
||||||
|
r.Visibility = parts[3]
|
||||||
|
}
|
||||||
|
s.recs[parts[0]] = r
|
||||||
|
}
|
||||||
|
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.
|
||||||
|
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 := s.file
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(header)
|
||||||
|
for _, r := range s.All() {
|
||||||
|
if r.Visibility == "" {
|
||||||
|
fmt.Fprintf(&b, "%s\t%s\t%s\n", r.Path, r.URL, r.ETag)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "%s\t%s\t%s\t%s\n", r.Path, r.URL, r.ETag, r.Visibility)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package lock
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Locks written before visibility was recorded have three fields, and must keep
|
||||||
|
// loading: a repository does not get to stop working because the tool learned
|
||||||
|
// something new.
|
||||||
|
func TestLoadsThreeAndFourFieldRecords(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
p := Path(root)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
body := "# comment\n\nh/o/r/old.md\thttps://h/old\t\"1\"\n" +
|
||||||
|
"h/o/r/new.md\thttps://h/new\t\"2\"\tnot-public\n"
|
||||||
|
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s, err := Load(root)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
old, _ := s.Get("h/o/r/old.md")
|
||||||
|
if old.Visibility != "" {
|
||||||
|
t.Errorf("a three-field record should have unknown visibility, got %q", old.Visibility)
|
||||||
|
}
|
||||||
|
nw, _ := s.Get("h/o/r/new.md")
|
||||||
|
if nw.Visibility != NotPublic {
|
||||||
|
t.Errorf("visibility = %q, want %q", nw.Visibility, NotPublic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And unknown must survive a round trip rather than being written as a value.
|
||||||
|
if err := s.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
out, _ := os.ReadFile(p)
|
||||||
|
for _, line := range strings.Split(string(out), "\n") {
|
||||||
|
if strings.HasPrefix(line, "h/o/r/old.md") && strings.Count(line, "\t") != 2 {
|
||||||
|
t.Errorf("unknown visibility was written as a field: %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectsAMalformedRecord(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
p := Path(root)
|
||||||
|
os.MkdirAll(filepath.Dir(p), 0o755)
|
||||||
|
os.WriteFile(p, []byte("h/o/r/x.md\thttps://h/x\n"), 0o644)
|
||||||
|
if _, err := Load(root); err == nil {
|
||||||
|
t.Error("a two-field record should be an error, not a silently empty ETag")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
// Package orient generates a table of contents over what a repository depends
|
||||||
|
// on, for whoever arrives next — a person, or an agent of any make.
|
||||||
|
//
|
||||||
|
// It restates no rule. Every rule it might repeat is owned by a document already
|
||||||
|
// in the working tree, and a copy of a rule is a copy that goes stale: this file
|
||||||
|
// says only what is here, where it came from, and where the rules live.
|
||||||
|
//
|
||||||
|
// The output is byte-deterministic. A generated file that churns produces diffs
|
||||||
|
// nobody reads, and the diff is most of the value — it is how somebody sees that
|
||||||
|
// their dependencies moved.
|
||||||
|
package orient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.hypertheory-labs.dev/loom/loom-cli/internal/lock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// File is where the orientation lives, relative to the repository root.
|
||||||
|
//
|
||||||
|
// Beside .loom/event-log.md rather than inside .loom/externals/, because
|
||||||
|
// everything in that directory is somebody else's document — which is what makes
|
||||||
|
// "do not edit these" a rule you can state in four words — and because `check`
|
||||||
|
// walks it and would report a generated file as an unlocked external forever.
|
||||||
|
const File = ".loom/orientation.md"
|
||||||
|
|
||||||
|
const preamble = `<!-- Generated by ` + "`loomctl orient`" + `. Safe to overwrite; nobody typed it.
|
||||||
|
Regenerate after adopting a document or reconciling a change. -->
|
||||||
|
|
||||||
|
# What this repository depends on
|
||||||
|
|
||||||
|
Copies of other people's documents are kept under ` + "`.loom/externals/`" + `, at a path
|
||||||
|
that says where each came from. **They are copies: do not edit them.** Anything
|
||||||
|
you want to say about one goes in a file *beside* it, never into it.
|
||||||
|
|
||||||
|
Three moves, and each has a document that owns the rule:
|
||||||
|
|
||||||
|
- **A copy is wrong, or you needed something it does not say** — write it in
|
||||||
|
` + "`<name>.gaps.md`" + ` beside the copy.
|
||||||
|
- **What of ours depends on a copy** — write it in ` + "`<name>.usages.md`" + ` beside it.
|
||||||
|
- **A source changed** — ` + "`loomctl external check`" + ` says so and stages the new copy;
|
||||||
|
` + "`loomctl external apply`" + ` takes it. Neither edits anything on its own.
|
||||||
|
`
|
||||||
|
|
||||||
|
// Generate writes the orientation for the repository at root.
|
||||||
|
func Generate(root, out string) (string, error) {
|
||||||
|
locks, err := lock.Load(root)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(preamble)
|
||||||
|
|
||||||
|
recs := locks.All() // already ordered by path
|
||||||
|
if len(recs) == 0 {
|
||||||
|
b.WriteString("\nThis repository adopts nothing yet.\n")
|
||||||
|
}
|
||||||
|
for _, r := range recs {
|
||||||
|
fmt.Fprintf(&b, "\n## `%s`\n\n", r.Path)
|
||||||
|
fmt.Fprintf(&b, "- source: %s\n", r.URL)
|
||||||
|
facets := facetsFor(root, r.Path)
|
||||||
|
if u, ok := facets["usages"]; ok {
|
||||||
|
fmt.Fprintf(&b, "- what of ours depends on it: `%s`\n", u)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintf(&b, "- what of ours depends on it: **nothing recorded** — no `.usages.md`\n")
|
||||||
|
}
|
||||||
|
if g, ok := facets["gaps"]; ok {
|
||||||
|
fmt.Fprintf(&b, "- what we expected and did not find: `%s`\n", g)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if dir := filepath.Join(root, ".loom", "published"); isDir(dir) {
|
||||||
|
b.WriteString("\n## This repository publishes\n\n")
|
||||||
|
b.WriteString("See `.loom/published/`. What is handed over for others to depend on is\n")
|
||||||
|
b.WriteString("there; the rest of the repository is not hidden, it is simply not what\n")
|
||||||
|
b.WriteString("anybody depends on.\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
dest := out
|
||||||
|
if dest == "" {
|
||||||
|
dest = filepath.Join(root, filepath.FromSlash(File))
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return dest, os.WriteFile(dest, []byte(b.String()), 0o644)
|
||||||
|
}
|
||||||
|
|
||||||
|
// facetsFor finds the files written beside an adopted document.
|
||||||
|
func facetsFor(root, rel string) map[string]string {
|
||||||
|
found := map[string]string{}
|
||||||
|
stem := strings.TrimSuffix(rel, path.Ext(rel))
|
||||||
|
for _, kind := range []string{"usages", "gaps"} {
|
||||||
|
p := stem + "." + kind + ".md"
|
||||||
|
if _, err := os.Stat(filepath.Join(root, lock.Dir, filepath.FromSlash(p))); err == nil {
|
||||||
|
found[kind] = lock.Dir + "/" + p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDir(p string) bool {
|
||||||
|
fi, err := os.Stat(p)
|
||||||
|
return err == nil && fi.IsDir()
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package orient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.hypertheory-labs.dev/loom/loom-cli/internal/lock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A generated file that churns produces diffs nobody reads, and the diff is most
|
||||||
|
// of the value.
|
||||||
|
func TestGenerateIsDeterministic(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
locks, err := lock.LoadFile(lock.Path(root))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Inserted out of order on purpose: the output must not depend on it.
|
||||||
|
for _, r := range []lock.Record{
|
||||||
|
{Path: "h/o/zeta/z.md", URL: "https://h/z", ETag: `"3"`},
|
||||||
|
{Path: "h/o/alpha/a.md", URL: "https://h/a", ETag: `"1"`},
|
||||||
|
{Path: "h/o/mid/m.md", URL: "https://h/m", ETag: `"2"`},
|
||||||
|
} {
|
||||||
|
locks.Put(r)
|
||||||
|
}
|
||||||
|
if err := locks.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var first string
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
dest, err := Generate(root, filepath.Join(root, "out.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(dest)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if i == 0 {
|
||||||
|
first = string(b)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if string(b) != first {
|
||||||
|
t.Fatal("output changed between runs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if a, z := strings.Index(first, "alpha"), strings.Index(first, "zeta"); a > z {
|
||||||
|
t.Error("entries are not ordered by path")
|
||||||
|
}
|
||||||
|
if strings.Contains(first, "publishes") {
|
||||||
|
t.Error("claimed the repository publishes with no .loom/published")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSaysWhenNothingRecordsADependency(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
locks, _ := lock.LoadFile(lock.Path(root))
|
||||||
|
locks.Put(lock.Record{Path: "h/o/r/doc.md", URL: "https://h/doc", ETag: `"1"`})
|
||||||
|
if err := locks.Save(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
dest, err := Generate(root, filepath.Join(root, "out.md"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b, _ := os.ReadFile(dest)
|
||||||
|
if !strings.Contains(string(b), "nothing recorded") {
|
||||||
|
t.Error("a document with no .usages.md should say so — it is a finding, not an omission")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
// 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"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.hypertheory-labs.dev/loom/loom-cli/internal/config"
|
||||||
|
|
||||||
|
"git.hypertheory-labs.dev/loom/loom-cli/internal/external"
|
||||||
|
"git.hypertheory-labs.dev/loom/loom-cli/internal/orient"
|
||||||
|
)
|
||||||
|
|
||||||
|
const usage = `loomctl — fetch what you depend on, and find out when it changed.
|
||||||
|
|
||||||
|
loomctl external list <owner/repo> what a repository publishes
|
||||||
|
loomctl external add <owner/repo> <doc> adopt one document and lock it
|
||||||
|
loomctl external add <url> [--path p] ... or by its full raw URL
|
||||||
|
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 document that moved is staged as a polad in
|
||||||
|
.loom/cart/current/polad/ — a candidate shaped exactly like what it would
|
||||||
|
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.
|
||||||
|
|
||||||
|
loomctl grants no access. It reads what your credentials already let you read,
|
||||||
|
and records where the copy came from — which is the part copy-and-paste loses.
|
||||||
|
It warns rather than refuses: adopting from a source less readable than the
|
||||||
|
repository you are adopting into is yours to decide, and the check only tells
|
||||||
|
public from not-public, so it cannot see two repositories private to different
|
||||||
|
people.
|
||||||
|
|
||||||
|
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
|
||||||
|
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.
|
||||||
|
|
||||||
|
loomctl orient [--out path] write .loom/orientation.md for whoever arrives next
|
||||||
|
loomctl config which context is current, and from where
|
||||||
|
|
||||||
|
A bare owner/repo is resolved against the current context in
|
||||||
|
~/.config/loomctl/config.json, which holds a host, its flavor and a read-only
|
||||||
|
token. The host's raw-file route lives there so that nobody has to type it, and
|
||||||
|
LOOMCTL_TOKEN_<HOST> overrides the file for one invocation. loomctl never writes
|
||||||
|
over the network, so a token it is given should never carry write scope.
|
||||||
|
|
||||||
|
{
|
||||||
|
"current-context": "hypertheory",
|
||||||
|
"contexts": {
|
||||||
|
"hypertheory": {
|
||||||
|
"host": "git.hypertheory-labs.dev",
|
||||||
|
"flavor": "gitea",
|
||||||
|
"token": "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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:])
|
||||||
|
case "orient":
|
||||||
|
fs := flag.NewFlagSet("orient", flag.ContinueOnError)
|
||||||
|
out := fs.String("out", "", "write here instead of "+orient.File)
|
||||||
|
if err := fs.Parse(args[1:]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
root, err := root()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dest, err := orient.Generate(root, *out)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(os.Stdout, "wrote %s\n", dest)
|
||||||
|
return nil
|
||||||
|
case "config":
|
||||||
|
return showConfig(os.Stdout)
|
||||||
|
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, apply")
|
||||||
|
}
|
||||||
|
switch args[0] {
|
||||||
|
case "list":
|
||||||
|
if len(args) != 2 {
|
||||||
|
return fmt.Errorf("usage: loomctl external list <owner/repo>")
|
||||||
|
}
|
||||||
|
if strings.Contains(args[1], "://") {
|
||||||
|
return external.List(args[1], os.Stdout)
|
||||||
|
}
|
||||||
|
return external.ListByName(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
|
||||||
|
}
|
||||||
|
root, err := root()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
switch fs.NArg() {
|
||||||
|
case 1:
|
||||||
|
if !strings.Contains(fs.Arg(0), "://") {
|
||||||
|
return fmt.Errorf("adopting by name needs the document too: " +
|
||||||
|
"loomctl external add <owner/repo> <doc>")
|
||||||
|
}
|
||||||
|
return external.Add(root, fs.Arg(0), *path, os.Stdout)
|
||||||
|
case 2:
|
||||||
|
return external.AddByName(root, fs.Arg(0), fs.Arg(1), os.Stdout)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("usage: loomctl external add <owner/repo> <doc>, " +
|
||||||
|
"or loomctl external add <url> [--path p]")
|
||||||
|
}
|
||||||
|
|
||||||
|
case "check":
|
||||||
|
root, err := root()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
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:
|
||||||
|
return fmt.Errorf("unknown external subcommand %q", args[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// showConfig says which context is current and where it came from, so that "my
|
||||||
|
// token is not being used" is answerable without printing the token.
|
||||||
|
func showConfig(out io.Writer) error {
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "config %s\n", config.Path())
|
||||||
|
if len(cfg.Contexts) == 0 {
|
||||||
|
fmt.Fprintln(out, " (none — public repositories still work by full URL)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(cfg.Contexts))
|
||||||
|
for n := range cfg.Contexts {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
for _, n := range names {
|
||||||
|
c := cfg.Contexts[n]
|
||||||
|
marker := " "
|
||||||
|
if n == cfg.CurrentContext {
|
||||||
|
marker = "*"
|
||||||
|
}
|
||||||
|
flavor := c.Flavor
|
||||||
|
if flavor == "" {
|
||||||
|
flavor = "gitea (default)"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "%s %-14s %-32s %-16s %s\n", marker, n, c.Host, flavor, credential(cfg, c))
|
||||||
|
}
|
||||||
|
if _, err := cfg.Current(); err != nil {
|
||||||
|
fmt.Fprintf(out, "\n%v\n", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func credential(cfg *config.Config, c config.Context) string {
|
||||||
|
if os.Getenv("LOOMCTL_TOKEN") != "" {
|
||||||
|
return "token from LOOMCTL_TOKEN"
|
||||||
|
}
|
||||||
|
if cfg.TokenFor(c.Host) != "" {
|
||||||
|
if c.Token == "" {
|
||||||
|
return "token from the environment"
|
||||||
|
}
|
||||||
|
return "token set"
|
||||||
|
}
|
||||||
|
return "no token (anonymous)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func root() (string, error) {
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return external.FindRoot(wd)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user