Compare commits
8
Commits
f0b3269610
...
e0d7afecae
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
@@ -518,3 +518,322 @@ the cart that named it.*
|
||||
**Recorded rather than fixed.** *The role still works; its justification was
|
||||
written for a case that has not yet occurred.* **If a round ever does produce code
|
||||
while it is open, nothing here changes.**
|
||||
|
||||
## 2026-09-07 — `loomctl external` exists, and its first run reconciled two documents `marmalade`
|
||||
|
||||
**Built: `list`, `add`, `check`.** *Go, no dependencies outside the standard
|
||||
library, `git` shelled out for `list` only.*
|
||||
|
||||
**The first real run did the thing the tool is for.** *Eight documents were
|
||||
adopted by hand before it existed; `check` reported all eight `unlocked`, and
|
||||
`add` locked them* — **and two came back changed**, `bedrock/starting.md` *and*
|
||||
`cart/cart.md`. **Neither change would have been noticed by anybody.**
|
||||
|
||||
**Believed to advance:** *the lock is what makes `check` possible at all.* **With
|
||||
no lock there is nothing to compare and the only honest report is `unlocked`** —
|
||||
*and the tool refuses to invent one by adopting whatever the remote currently
|
||||
serves.*
|
||||
|
||||
**Belief that could be shown wrong:** *that a per-person path derived from the URL
|
||||
is good enough.* **`<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 — conversion now destroys, so the log is the only record `quince`
|
||||
|
||||
**Under `cart/v1` the cart is untracked, so converting a round deletes its dailies
|
||||
outright.** *Every previous conversion left them in git.*
|
||||
|
||||
**So an entry that was not written before the `rm` is gone**, *and the write-ahead
|
||||
log is the mechanism* — **stage as you go, because at conversion the cart is the
|
||||
only copy and you are about to delete it.**
|
||||
|
||||
**And it sharpens a conflict recorded in `cart.usages.md`:** *the annotation
|
||||
protocol here says commit before dissolving because git is the only archive.*
|
||||
**With an ignored cart there is no archive, so dissolving a notes file destroys
|
||||
the annotations outright.** *Both documents are loom's; this is where an adopter
|
||||
can see the collision.*
|
||||
|
||||
## 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.***
|
||||
|
||||
Vendored
+12
@@ -0,0 +1,12 @@
|
||||
# loomctl locks — one record per adopted document.
|
||||
# path<TAB>url<TAB>etag The url is resolved: a short form would follow
|
||||
# whatever the default branch is at the time you ask.
|
||||
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"
|
||||
git.hypertheory-labs.dev/loom/annotating/annotating.md https://git.hypertheory-labs.dev/loom/annotating/raw/branch/main/.loom/published/annotating.md "9b1f7e6ca92f2339b2d433686c27845362944bdb"
|
||||
git.hypertheory-labs.dev/loom/bedrock/loom-directory.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/loom-directory.md "ee0f49cb900c0812678061971194325d9cba366a"
|
||||
git.hypertheory-labs.dev/loom/bedrock/publication.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/publication.md "eb0cb63629a36b056f215dfbe24567d1918cec38"
|
||||
git.hypertheory-labs.dev/loom/bedrock/recording-decisions.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/recording-decisions.md "970d4b4da76aac99c9b1f1b6580ec20daa42e329"
|
||||
git.hypertheory-labs.dev/loom/bedrock/sibling-facets.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/sibling-facets.md "a46446a34ccb8bfc533d3cce19f4c88548c4fa04"
|
||||
git.hypertheory-labs.dev/loom/bedrock/starting.md https://git.hypertheory-labs.dev/loom/bedrock/raw/branch/main/.loom/published/starting.md "7d997a30248a88c90b23f2f9453d7cfceb03848e"
|
||||
git.hypertheory-labs.dev/loom/cart/cart.md https://git.hypertheory-labs.dev/loom/cart/raw/branch/main/.loom/published/cart.md "49fc852bdd280293f0f5e0050034e3b89ff7255e"
|
||||
git.hypertheory-labs.dev/loom/externals/externals.md https://git.hypertheory-labs.dev/loom/externals/raw/branch/main/.loom/published/externals.md "a7586eb52caf275d9bcedbbd8042c43e5aaad0b9"
|
||||
@@ -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.*
|
||||
@@ -0,0 +1,29 @@
|
||||
# Gaps — `publication`
|
||||
|
||||
## Whether publishing implies that the audience can read it
|
||||
|
||||
**The document says publishing is a change of kind and that `.loom/published/` is
|
||||
what a repository has handed over for others to depend on.** *It does not say that
|
||||
the handing over must succeed.*
|
||||
|
||||
**We built a command on the assumption that it meant *anyone* could fetch it, and
|
||||
that was wrong** — *others is not everyone, and a repository may publish to a
|
||||
private audience deliberately.* **But the opposite is not addressed either:
|
||||
nothing here says that a document in `published/` which nobody in its intended
|
||||
audience can fetch has not been published.**
|
||||
|
||||
*The sentence we needed is in a sibling and not here:* **publishing is not an act
|
||||
you can complete alone.** *It appears as an aside about a tool, not as a property
|
||||
of publication.*
|
||||
|
||||
*Local answer: we declined to build the check, on the belief that a registry with
|
||||
named consumers answers it better than a probe can. **The convention still does not
|
||||
say whether an unfetchable publication is a publication.***
|
||||
|
||||
## Where confidentiality lives
|
||||
|
||||
**`externals` now says confidentiality does not travel with the copy.** *That rule
|
||||
is about adopting, and it exists because of a property of publishing* — **what a
|
||||
publisher may safely put in `published/` depends on who can read the repository it
|
||||
is in**, *and this document, which is where publishing is defined, does not
|
||||
mention visibility at all.*
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
# Gaps — `recording-decisions`
|
||||
|
||||
## Whether a log may ever be compacted, and what compaction may not touch
|
||||
|
||||
**The document says entries are appended, newest last, and never revised** — *"a
|
||||
revised record cannot show that anybody changed their mind, which is most of what
|
||||
a reader wants from it."*
|
||||
|
||||
**It also acknowledges no upper bound.** *Ours reached about thirty entries in a
|
||||
day across three rounds, and `bedrock`'s own README exists because its log needed
|
||||
a guide.* **At some size the log stops being readable, and the only remedies are
|
||||
revision, which this forbids, or a guide, which is a second document that can
|
||||
drift.**
|
||||
|
||||
**The gap is not "may we compact".** *It is that **"never revised" and "somebody
|
||||
must be able to read it" both hold and eventually conflict**, and the document
|
||||
does not say which gives.*
|
||||
|
||||
*Local answer, provisional: **compaction is allowed and git history is where the
|
||||
uncompacted log lives.*** *An entry may be dropped when a competent reader could
|
||||
recover it by reading the code.* **An entry may never be dropped when it records a
|
||||
decline, a measurement, a belief that was shown wrong, or one entry superseding
|
||||
another** — *because those are precisely the record of somebody changing their
|
||||
mind, and dropping them is the failure this document names.*
|
||||
|
||||
> **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.**
|
||||
@@ -60,9 +60,13 @@ what they came for.*
|
||||
|
||||
## Look at one instead of reading this
|
||||
|
||||
**[`jeffry/homelab-cluster`](https://git.hypertheory-labs.dev/jeffry/homelab-cluster)**
|
||||
— *six documents, one gap, no decomposition, and a `README` that says what the
|
||||
root documents are for and what these are for.*
|
||||
**There is a worked example — six documents, one gap, no decomposition — and it
|
||||
is private.**
|
||||
|
||||
**It is a better answer than this page**, and if the two ever disagree, it is
|
||||
right.
|
||||
*It describes a cluster in enough detail to be a target list, so it is not
|
||||
published, and **this page will not link you to something you cannot fetch.***
|
||||
**If you have access, ask for it by name; if you do not, the two questions at the
|
||||
top are the whole of it.**
|
||||
|
||||
> **A public page naming a private thing as its canonical answer is worse than no
|
||||
> example**, and this page did exactly that until somebody measured it.
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# 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.*
|
||||
|
||||
## What must be extracted before converting, now that conversion destroys
|
||||
|
||||
**`v1` makes the cart untracked. So converting deletes the dailies outright**,
|
||||
*where every earlier conversion left them in git.*
|
||||
|
||||
**The write-ahead log is named in `v1` but not required**, *and nothing says that
|
||||
at conversion the cart is the only copy.* **Two rules now both push toward loss** —
|
||||
*act as if the shelf is discarded daily, and the cart is not committed* — **and
|
||||
neither says what has to be written down first.**
|
||||
|
||||
*Local answer: a `wal.md` in the cart, staged as we go, so converting is a move
|
||||
rather than a rewrite. **We would not have thought of it if it had not been
|
||||
suggested.***
|
||||
|
||||
## 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.***
|
||||
+47
-2
@@ -1,6 +1,6 @@
|
||||
# Agreement — the cart
|
||||
|
||||
**v0.** Depends on `annotating/v0`.
|
||||
**v1.** Depends on `annotating/v0`.
|
||||
|
||||
**How two parties work out what something means before one of them changes it.**
|
||||
|
||||
@@ -104,9 +104,54 @@ already rejected, and the rejection is gone because it lived in an annotation
|
||||
that died with the round.
|
||||
|
||||
**A decline needs no file of its own.** It is an entry in whatever durable record
|
||||
you keep, and **it should say what you believed, not just what you chose** — only
|
||||
you keep — **which must outlive the cart**, *and therefore cannot be inside it* — and **it should say what you believed, not just what you chose** — only
|
||||
a belief can later be shown wrong.
|
||||
|
||||
## The cart is local, and that is what keeps it to two files
|
||||
|
||||
**A cart is two developers working side by side.** *Everything else — the wider
|
||||
team, the people who need to know, the thing that has to be tracked — is issues,
|
||||
chat, whatever you already have.* **This is not that channel and it does not scale
|
||||
into one.**
|
||||
|
||||
> **So the cart is not committed.** *It lives in the working tree of the machine
|
||||
> the two presences share, and `.loom/cart/` is ignored by version control.*
|
||||
|
||||
**The reason is not tidiness. A committed cart grows a third file by itself.**
|
||||
*The rule above defends against somebody asking for one; **version control does
|
||||
not require anybody to ask.*** *Anyone who can clone can add `joe-rose.md`, and
|
||||
then `sue-rose.md`, and the agreement's defence — **what happens to this file when
|
||||
the person changes?** — is never invoked, because nobody ever had the
|
||||
conversation.*
|
||||
|
||||
**This is also what makes a round actually end.** *Committed, a cart is gone from
|
||||
the tree and permanent in history — **so "gone" means "no longer live" and
|
||||
negotiation stays quotable forever.*** **Ignored, gone means gone**, which is what
|
||||
the round dying was for.
|
||||
|
||||
*The cost, stated: **two presences who do not share a filesystem cannot use a
|
||||
cart.*** *That is a real limit and it is the right one — if you need a medium
|
||||
between machines, you need the other channel, and reaching for a cart there is
|
||||
how it becomes a chat log.*
|
||||
|
||||
## What is not yet a decision goes in the write-ahead log
|
||||
|
||||
**A round produces things that are neither questions nor decisions:** *something
|
||||
observed, something that may turn out to be noise, something you would kick
|
||||
yourself for losing and cannot yet justify writing down.*
|
||||
|
||||
**`event-log.wal.md`, in the cart.** *Findings, not decisions.* **Nothing in it is
|
||||
durable and nothing in it has been decided.**
|
||||
|
||||
**At conversion, each entry either becomes an entry in the durable record or is
|
||||
discarded.** *Same two exits as a polad, and for the same reason: **conversion is
|
||||
when you know most about it.***
|
||||
|
||||
> **Write the reason it is not yet an entry.** *An observation you cannot justify
|
||||
> promoting is worth keeping; **one you have not said why you are hesitant about
|
||||
> will be promoted by whoever finds it, on the strength of it having been written
|
||||
> down.***
|
||||
|
||||
## Where a cart lives, and the shelf
|
||||
|
||||
**A fixed path, and at most two things in it:**
|
||||
|
||||
@@ -33,3 +33,34 @@ revert to.*
|
||||
|
||||
*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.*
|
||||
|
||||
---
|
||||
|
||||
## 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.***
|
||||
|
||||
@@ -24,6 +24,28 @@ in the document.*
|
||||
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`.***
|
||||
|
||||
### 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
|
||||
|
||||
- **`.usages.md`** — *what we use, and **which of our artifacts depend on it***
|
||||
|
||||
@@ -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,84 @@
|
||||
// Package config reads the per-host settings loomctl needs to talk to a git host.
|
||||
//
|
||||
// The config is not only a secret. It is how you talk to a host at all, which is
|
||||
// why it is keyed by host rather than being a single token. It lives in the
|
||||
// user's home directory and never in a repository — see .loom/event-log.md,
|
||||
// "decided by fallback: where the credential lives".
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Host is what we know about one git host.
|
||||
type Host struct {
|
||||
// Token is a read-only personal access token. It must not carry write
|
||||
// scope: loomctl never writes over the network.
|
||||
Token string `json:"token,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Hosts map[string]Host `json:"hosts"`
|
||||
}
|
||||
|
||||
// Path is where the config lives. Never inside a repository.
|
||||
func Path() string {
|
||||
if p := os.Getenv("LOOMCTL_CONFIG"); p != "" {
|
||||
return p
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".config", "loomctl", "config.json")
|
||||
}
|
||||
|
||||
// Load reads the config. A missing file is not an error: everything loomctl does
|
||||
// against a public repository works with no credential at all.
|
||||
func Load() (*Config, error) {
|
||||
c := &Config{Hosts: map[string]Host{}}
|
||||
p := Path()
|
||||
if p == "" {
|
||||
return c, nil
|
||||
}
|
||||
b, err := os.ReadFile(p)
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return c, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", p, err)
|
||||
}
|
||||
if err := json.Unmarshal(b, c); err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", p, err)
|
||||
}
|
||||
if c.Hosts == nil {
|
||||
c.Hosts = map[string]Host{}
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// TokenFor returns the token for a host, or "" if we have none.
|
||||
//
|
||||
// An environment variable wins over the file, so a token can be supplied for one
|
||||
// invocation without ever being written to disk.
|
||||
func (c *Config) TokenFor(host string) string {
|
||||
if t := os.Getenv("LOOMCTL_TOKEN_" + envKey(host)); t != "" {
|
||||
return t
|
||||
}
|
||||
if t := os.Getenv("LOOMCTL_TOKEN"); t != "" {
|
||||
return t
|
||||
}
|
||||
return c.Hosts[host].Token
|
||||
}
|
||||
|
||||
// envKey turns a hostname into the shape an environment variable can carry.
|
||||
func envKey(host string) string {
|
||||
r := strings.NewReplacer(".", "_", "-", "_", ":", "_")
|
||||
return strings.ToUpper(r.Replace(host))
|
||||
}
|
||||
Vendored
+402
@@ -0,0 +1,402 @@
|
||||
// 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)
|
||||
}
|
||||
|
||||
// A document that is here but unlocked was fetched by hand before the tool
|
||||
// existed. Supplying its URL is the only way it can ever be locked, because
|
||||
// the path does not round-trip and nothing else records the origin. It is
|
||||
// still not an overwrite: the bytes decide.
|
||||
if onDisk == nil {
|
||||
old, err := os.ReadFile(dest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !bytes.Equal(old, body.data) {
|
||||
if !cartOpen(root) {
|
||||
return fmt.Errorf("%s differs from what %s serves, and no cart is open to stage it in — "+
|
||||
"the local copy is the only evidence of that and will not be touched", rel, body.url)
|
||||
}
|
||||
if err := stage(root, rel, body.data, lock.Record{Path: rel, URL: body.url, ETag: etag}); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(out, "staged %s\n", rel)
|
||||
fmt.Fprintf(out, " from %s\n", body.url)
|
||||
fmt.Fprintf(out, " NOTE the local copy differs and was left alone; it is the only evidence\n")
|
||||
fmt.Fprintf(out, " that this moved while nothing was watching. Apply or discard.\n")
|
||||
return nil
|
||||
}
|
||||
// Identical, so the assertion a lock makes — this local copy is the one
|
||||
// being served — is verified rather than assumed.
|
||||
locks.Put(lock.Record{Path: rel, URL: body.url, ETag: etag})
|
||||
if err := locks.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(out, "locked %s\n", rel)
|
||||
fmt.Fprintf(out, " from %s\n", body.url)
|
||||
fmt.Fprintf(out, " etag %s (bytes verified identical; nothing was rewritten)\n", etag)
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(dest, body.data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
locks.Put(lock.Record{Path: rel, URL: body.url, ETag: etag})
|
||||
if err := locks.Save(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(out, "adopted %s\n", rel)
|
||||
fmt.Fprintf(out, " from %s\n", body.url)
|
||||
warnIfNotPublic(cfg, 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.
|
||||
func warnIfNotPublic(cfg *config.Config, raw string, out io.Writer) {
|
||||
req, err := http.NewRequest(http.MethodHead, raw, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if cfg.TokenFor(req.URL.Host) == "" {
|
||||
return // no credential was used, so the fetch was already anonymous
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(out, " WARN this needed a credential — anonymously it is %s.\n", resp.Status)
|
||||
fmt.Fprintf(out, " Confidentiality does not travel with the copy: this now lives in\n")
|
||||
fmt.Fprintf(out, " .loom/externals/ and is readable by anyone who can read THIS\n")
|
||||
fmt.Fprintf(out, " repository, which I cannot see. 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")
|
||||
}
|
||||
|
||||
type fetched struct {
|
||||
data []byte
|
||||
url string
|
||||
}
|
||||
|
||||
// fetch performs one request. A nil body with no error means 304.
|
||||
func fetch(cfg *config.Config, raw, ifNoneMatch string) (*fetched, string, error) {
|
||||
req, err := request(cfg, http.MethodGet, raw, ifNoneMatch)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusNotModified:
|
||||
io.Copy(io.Discard, resp.Body)
|
||||
return nil, resp.Header.Get("ETag"), nil
|
||||
case http.StatusOK:
|
||||
b, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &fetched{data: b, url: resp.Request.URL.String()}, resp.Header.Get("ETag"), nil
|
||||
case http.StatusNotFound:
|
||||
return nil, "", fmt.Errorf("404 unresolved: %s was withdrawn, or this credential cannot see it — "+
|
||||
"over HTTP these are the same response", raw)
|
||||
default:
|
||||
return nil, "", fmt.Errorf("%s: %s", resp.Status, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Status is what check found for one document.
|
||||
type Status struct {
|
||||
Path string
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// 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 The url is resolved: a short form would follow\n" +
|
||||
"# whatever the default branch is at the time you ask.\n"
|
||||
|
||||
// Record is one adopted document.
|
||||
type Record struct {
|
||||
// Path is relative to Dir, and is for a person to read. The origin is the
|
||||
// URL: the path does not round-trip, because it drops the route, the
|
||||
// branch, and .loom/published/.
|
||||
Path string
|
||||
// URL is the resolved origin, branch and all.
|
||||
URL string
|
||||
// ETag is the publisher's, verbatim, including its quotes.
|
||||
ETag string
|
||||
}
|
||||
|
||||
// Set is every lock, keyed by path.
|
||||
type Set struct {
|
||||
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 {
|
||||
return nil, fmt.Errorf("%s:%d: want 3 tab-separated fields, got %d", file, n, len(parts))
|
||||
}
|
||||
s.recs[parts[0]] = Record{Path: parts[0], URL: parts[1], ETag: parts[2]}
|
||||
}
|
||||
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() {
|
||||
fmt.Fprintf(&b, "%s\t%s\t%s\n", r.Path, r.URL, r.ETag)
|
||||
}
|
||||
tmp, err := os.CreateTemp(filepath.Dir(p), ".locks-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tmp.WriteString(b.String()); err != nil {
|
||||
tmp.Close()
|
||||
os.Remove(tmp.Name())
|
||||
return err
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmp.Name())
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp.Name(), p)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
// loomctl fetches documents this repository depends on, and finds out when they
|
||||
// change.
|
||||
//
|
||||
// It reports and never repairs. Everything it writes, it writes to the working
|
||||
// tree — committing and pushing are yours, because the consequences of a push
|
||||
// land on people a tool cannot experience.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"git.hypertheory-labs.dev/loom/loom-cli/internal/external"
|
||||
)
|
||||
|
||||
const usage = `loomctl — fetch what you depend on, and find out when it changed.
|
||||
|
||||
loomctl external list <repo-url> what a repository publishes
|
||||
loomctl external add <url> [--path p] adopt one document and lock it
|
||||
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.
|
||||
|
||||
add warns when a document could only be fetched with a credential: adopting is
|
||||
copying, and confidentiality does not travel with the copy. It cannot see who
|
||||
may read the repository the copy lands in, so it reports the half it knows.
|
||||
|
||||
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.
|
||||
|
||||
Credentials are read-only and per host, in ~/.config/loomctl/config.json or in
|
||||
LOOMCTL_TOKEN_<HOST>. loomctl never writes over the network, so a token it is
|
||||
given should never carry write scope.
|
||||
|
||||
Requires git on PATH, for list only.
|
||||
`
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "loomctl: "+err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
|
||||
fmt.Print(usage)
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "external":
|
||||
return runExternal(args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q\n\n%s", args[0], usage)
|
||||
}
|
||||
}
|
||||
|
||||
func runExternal(args []string) error {
|
||||
if len(args) == 0 {
|
||||
return fmt.Errorf("external needs a subcommand: list, add, check, apply")
|
||||
}
|
||||
switch args[0] {
|
||||
case "list":
|
||||
if len(args) != 2 {
|
||||
return fmt.Errorf("usage: loomctl external list <repo-url>")
|
||||
}
|
||||
return external.List(args[1], os.Stdout)
|
||||
|
||||
case "add":
|
||||
fs := flag.NewFlagSet("add", flag.ContinueOnError)
|
||||
path := fs.String("path", "", "where to keep it, relative to .loom/externals (default: derived from the URL)")
|
||||
if err := fs.Parse(args[1:]); err != nil {
|
||||
return err
|
||||
}
|
||||
if fs.NArg() != 1 {
|
||||
return fmt.Errorf("usage: loomctl external add <url> [--path p]")
|
||||
}
|
||||
root, err := root()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return external.Add(root, fs.Arg(0), *path, os.Stdout)
|
||||
|
||||
case "check":
|
||||
root, err := root()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return external.Check(root, os.Stdout)
|
||||
|
||||
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])
|
||||
}
|
||||
}
|
||||
|
||||
func root() (string, error) {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return external.FindRoot(wd)
|
||||
}
|
||||
Reference in New Issue
Block a user