From bc028144c2e262bae225700d400f81f30cd4b15d Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sun, 23 Aug 2026 15:37:04 -0500 Subject: [PATCH 01/15] setup: don't enqueue home.init headless with no repo to clone A non-interactive run with no RT_HOME_URL had only one place left to go: `rt home init`'s built-in default, which names a repo the operator does not own. The clean-room step of the release pipeline hit exactly that -- it tried to clone rt's author's private home repo from a CI runner and dead-ended the install at step 2 of 20. Failing was correct; attempting it was not. Interactively the case is answerable, so the gate is on nonInteractive AND no RT_HOME_URL. The gate is here rather than in home.init: the command erroring when it genuinely cannot clone is what a real user with a wrong RT_HOME_URL needs, and softening it there to quiet a headless run trades a good error for a silent one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01QMy7FiR4bcTt8GTNdmWALS --- lib/setup/__tests__/steps-a.test.ts | 17 +++++++++++++++++ lib/setup/steps/home.ts | 14 +++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/lib/setup/__tests__/steps-a.test.ts b/lib/setup/__tests__/steps-a.test.ts index 7afe5c55..fb81d85c 100644 --- a/lib/setup/__tests__/steps-a.test.ts +++ b/lib/setup/__tests__/steps-a.test.ts @@ -169,6 +169,23 @@ describe("home.init", () => { expect(homeInitStep.applies(restoring)).toBe(false); }); + test("does not apply non-interactively with no RT_HOME_URL — would target a repo this operator does not own", () => { + const { ctx } = makeCtx(fakeProbes({ env: {} }), { nonInteractive: true }); + expect(homeInitStep.applies(ctx)).toBe(false); + }); + + test("applies non-interactively when RT_HOME_URL names the repo to clone", () => { + const { ctx } = makeCtx(fakeProbes({ env: { RT_HOME_URL: "https://example.com/o/home.git" } }), { nonInteractive: true }); + expect(homeInitStep.applies(ctx)).toBe(true); + }); + + // The gate must not quiet a headless run by disabling the step for real + // users: interactively, a missing RT_HOME_URL is answerable. + test("still applies interactively with no RT_HOME_URL — a human can supply one or authenticate", () => { + const { ctx } = makeCtx(fakeProbes({ env: {} }), { nonInteractive: false }); + expect(homeInitStep.applies(ctx)).toBe(true); + }); + test("already cloned + key present -> done, never re-runs `rt home init`", async () => { const p = fakeProbes({ home: "/fake-home", dirs: { "/fake-home/.mattstack/user": [".git"] }, files: { "/fake-home/.mattstack/user/.git": "gitdir" } }); const { ctx } = makeCtx(p, { secrets: fakeSecrets(fakeAgeKeySeamWithKey()) }); diff --git a/lib/setup/steps/home.ts b/lib/setup/steps/home.ts index 69a0b90d..b111f6a0 100644 --- a/lib/setup/steps/home.ts +++ b/lib/setup/steps/home.ts @@ -125,7 +125,19 @@ export const homeInitStep: StepDef = { id: "home.init", title: "Create your settings home repo", kind: "rt", - applies: (ctx) => ctx.intent?.mode !== "restore", + // Not enqueued when nothing names a repo AND nobody can be asked for one. + // Interactively that case is fine — a human supplies a URL or authenticates + // — but headless it can only reach for `rt home init`'s built-in default, + // which is a repo this operator does not own. A clean-room run cloning the + // author's private home repo is wrong even on the runs where it succeeds. + // + // The gate lives here rather than inside home.init: the command failing when + // it genuinely cannot clone is the right answer for a real user with a wrong + // RT_HOME_URL, and softening it there to quiet a headless run would trade a + // good error for a silent one. + applies: (ctx) => + ctx.intent?.mode !== "restore" + && (!ctx.nonInteractive || Boolean(ctx.p.env.RT_HOME_URL)), run: homeInitRunSafe, }; From 1fb0ec435e50120c682e899dc2bbb4306c3d1694 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sun, 23 Aug 2026 15:45:19 -0500 Subject: [PATCH 02/15] docs: home repo local-first design Co-Authored-By: Claude Fable 5 --- ...2026-08-23-home-repo-local-first-design.md | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md diff --git a/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md b/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md new file mode 100644 index 00000000..10820cd1 --- /dev/null +++ b/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md @@ -0,0 +1,168 @@ +# Home repo: local first, remote optional — design + +`rt home init` always ends with a working home repo on this machine. Whether +that repo has a remote is the operator's choice, offered by the Mac app's setup +and changeable later. Local-only is a permanent, fully supported state — and it +is reported honestly, because a repo that has never left the machine is not +backed up. + +## Why + +`commands/home.ts:84` hardcodes `DEFAULT_USER_REPO_URL = +"https://github.com/m4ttheweric/mattstack-home"` — rt's author's personal repo — +as the fallback when no URL is supplied. Anyone else installing mattstack.app +without setting `RT_HOME_URL` reaches for it: today they dead-end on auth, +because it is private; if it were ever public they would receive someone else's +settings. + +CI surfaced this on 2026-08-23 (release run 32664342028) when the clean-room +step reached `home.init` and failed with `Run gh auth login, then Retry`. The +warning line that names the problem — *"no RT_HOME_URL set — targeting rt's +built-in default repo, not one owned by this operator"* — had been printing +correctly all along. Nothing acted on it. + +Matt's rulings, 2026-08-23: + +> "it should be a first class part of the mac app setup." + +> "always set up a repo on the user's machine so we don't break the program. but +> give the user the option on where to build it." + +> "local-only should be fully supported but a mattstack settings state that gives +> a warning … so the user understands their settings are not backed up anywhere." + +The decomposition that falls out: **the home repo existing** is load-bearing — +settings stores, the snapshot daemon, and `rt verify` all assume +`~/.mattstack/user` is a repo. **The remote is a convenience.** Welding them +together is what made a missing URL fatal. + +## 1. `rt home init` always ends with a repo + +Resolution order for the clone URL: + +1. `--url ` +2. the setup intent's `homeRepo` (see §4) +3. `RT_HOME_URL` in the environment +4. **none of the above → `git init` a local-only repo** + +`DEFAULT_USER_REPO_URL` is **deleted**, not replaced. rt never invents a home +repo for someone. + +The local-only path produces the same tree the clone path does — `user/` as a +git repo, `.gitignore`, `snapshot-owners.jsonc`, the machine-key file, the +profile directory, the `skills.jsonc` symlink — with an initial commit and no +remote. Every downstream consumer sees exactly what it sees today. + +**A repo that exists is never re-initialised.** The existing short-circuit on an +already-present clone stays; local-only is only the behaviour for a first run +with no URL. + +## 2. The snapshot daemon: "no remote" is a state, not a failure + +Today `lib/daemon/home-snapshot.ts` pushes unconditionally and broadcasts +`home:push-failed` when the push fails (`:485`). There is no remote detection +anywhere in the module. A local-only repo would therefore fire a failure event +on every cycle — the difference between a supported state and a tolerated one. + +The daemon checks for a configured remote before arming the push. With none: + +- it commits on the same debounce, unchanged +- it **skips the push** — no push timer, no retry timer, no `home:push-failed` +- it logs the skip once at `debug`, not on every cycle + +Everything commit-side is untouched: the janitor, claimed zones +(`snapshot-owners.jsonc`, `rt home claim|release`), and the live kill switch all +behave identically. This is deliberately not a new mode — it is one branch +before the push. + +**A remote appearing later needs no restart.** The check reads the repo's +current remote at push time, so attaching one begins pushing on the next cycle. + +## 3. Honesty: a local-only repo must never read as backed up + +This is the real risk of always-create, and the part most likely to be got +wrong: **the user gets a working repo and reasonably assumes it is safe.** + +`rt verify` renders over the health probes in `lib/setup/rt-health.ts`; a +non-required probe that reports anything other than `ready` renders as a +warning (`commands/verify.ts:70-75`). So the home-repo probe reports a distinct +non-`ready` state when the repo has no remote, and it is **not** marked +required — a warning, not a failed check. That satisfies the installer lane's +invariant that a probe which cannot verify must never report `ready`: nothing +has left this machine, so "backed up" cannot be verified. + +Wording matters as much as the state. The row says what is and is not true: + +> **home repo — local only.** Your settings are versioned on this machine but +> are not backed up anywhere. Add a remote to sync them. + +Not "not configured" (it is configured, deliberately), and not an error (nothing +is wrong). The tray's health row carries the same state and the same sentence. + +**Shipping surfaces are `rt verify` and the tray.** The settings page from +`2026-08-23-settings-console-page-design.md` is specced but not built, and is +queued behind the console's binary-compile work — so it cannot be where this +warning lives first. When it exists, a home-repo panel is the natural home for +the indicator. Note it will not be key-shaped: "this repo has no remote" is +derived git state, not a registry key, so it needs its own panel rather than a +row in the key table. + +## 4. How the URL reaches rt + +`SetupIntent` (`lib/setup/intent.ts`) gains a top-level `homeRepo?: string`. +It is orthogonal to `mode` — a `create` and a `join` both need one, and +`restore` already carries its own under `restore.homeRepo`. + +The Mac app's setup collects it and writes the intent, the same way +`TeamChoiceModel` already calls `rt setup intent restore` for the restore flow. +**Deferring is a first-class answer**: choosing nothing yields a local-only repo +and a warning, never a blocked install. Whether the app also offers to create a +remote repo on the operator's behalf is the installer lane's design question, +not this spec's — it needs `gh` auth and a `repo` scope, which is a heavy ask at +first run. + +The installer lane owns the intent field, the app screen, and the step list. +This spec owns everything under §1-§3. + +## 5. The headless gate rides along + +The installer lane holds an unpushed commit (`8e50d23`, branch +`fix/home-init-gate`) that stops `home.init` being enqueued headless with no +URL. Its justification is that the only reachable outcome is failing on auth — +which stops being true the moment no-URL succeeds. Left in place afterwards it +would leave a clean-room install with **no home repo at all**, while everything +downstream assumes there is one. + +So it is cherry-picked into this change and removed in the same commit, so the +tree is never in a state where the gate exists but is pointless. **Its third +test is kept** — the one asserting `home.init` still applies interactively with +no URL — because it is what catches this change quietly disabling the step for +real users. Its expected reason changes; its value does not. + +## Testing + +- **Local-only init produces a working repo**: `user/` is a git repo with an + initial commit, no remote, and every artifact the clone path produces. +- **Resolution order**, each rung in turn, including that a present `--url` + beats an intent `homeRepo` beats `RT_HOME_URL`. +- **An existing repo is never re-initialised**, with and without a remote. +- **The daemon skips the push with no remote** and broadcasts no + `home:push-failed` — asserted over several cycles, since the bug this prevents + is per-cycle spam. +- **The daemon pushes once a remote is attached**, with no restart. +- **`rt verify` reports local-only as a warning, not a failure**, and the run + still passes its critical checks. +- **`home.init` still applies interactively with no URL** (the kept gate test). + +**Test against a HOME with no clone.** This machine cannot detect a regression +here: `home.init` short-circuits on the existing clone, so the path never runs +locally. That is the same shape as the bug this spec fixes — the code looked +fine because the failing path was never exercised. + +## Out of scope + +- Creating a remote repo on the operator's behalf (installer lane; needs `gh` + auth and a `repo` scope). +- The settings-page home-repo panel — the page does not exist yet. +- Migrating anyone off the old default. Nobody is on it: it is private to its + owner, so every other operator's `home.init` failed rather than cloning it. From c90852a6bfa7240743f054f8749bf53e7618518e Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sun, 23 Aug 2026 15:47:41 -0500 Subject: [PATCH 03/15] docs: green requires a completed push, not a configured remote Co-Authored-By: Claude Fable 5 --- ...2026-08-23-home-repo-local-first-design.md | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md b/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md index 10820cd1..5db19477 100644 --- a/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md +++ b/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md @@ -99,6 +99,36 @@ Wording matters as much as the state. The row says what is and is not true: Not "not configured" (it is configured, deliberately), and not an error (nothing is wrong). The tray's health row carries the same state and the same sentence. +### Green means a push succeeded, never that a remote exists + +A remote that is set but has never been pushed to — wrong URL, missing auth, +failing every cycle — is still *"your settings are not backed up anywhere"*. A +probe that reads git config and reports `ready` on `remote exists` would call +that safe. It is the same mistake as asserting a bundled binary's signature and +size instead of running it: the shape is right and the path is dead. + +So the probe reports four states, and `ready` requires evidence of a **completed +push**: + +| condition | state | +|---|---| +| no remote | warning — the local-only wording above | +| remote set, never pushed | warning — "remote configured, nothing pushed yet" | +| remote set, last push failed | warning — names the failure; this is the state a user is least likely to notice and most likely to be hurt by | +| remote set, push succeeded | `ready` — naming when it last succeeded | + +The snapshot daemon is the thing pushing, so it records each push outcome rather +than the probe inferring one. It already persists to `state.db` under the +`home-snapshot` namespace (`lib/daemon/home-snapshot.ts:158`), which is where the +last-push result belongs: outcome and timestamp, written on success and on +failure. + +**The fallback, if recording proves harder than it looks:** never report `ready` +on remote-configured alone — collapse "configured but unproven" into the same +warning tier as local-only. Weaker, because a working setup then reads as +warning forever, but it cannot lie. A probe that overstates safety is worse than +one that understates it, because the failure is silent and the loss is total. + **Shipping surfaces are `rt verify` and the tray.** The settings page from `2026-08-23-settings-console-page-design.md` is specced but not built, and is queued behind the console's binary-compile work — so it cannot be where this @@ -152,6 +182,10 @@ real users. Its expected reason changes; its value does not. - **The daemon pushes once a remote is attached**, with no restart. - **`rt verify` reports local-only as a warning, not a failure**, and the run still passes its critical checks. +- **Green requires a completed push.** Each of the four probe states renders + correctly, and specifically: a repo with a remote that has never pushed, and + one whose last push failed, both report a warning rather than `ready`. This is + the assertion that stops the probe reporting shape instead of outcome. - **`home.init` still applies interactively with no URL** (the kept gate test). **Test against a HOME with no clone.** This machine cannot detect a regression From 2810f15a7e1bc7ac3b1af5a6c2b5159f381f257f Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sun, 23 Aug 2026 16:04:21 -0500 Subject: [PATCH 04/15] docs: compare against the remote ref, never @{u} A repo that was git init-ed and later given a remote has the remote ref but no upstream config, so @{u} exits 128. It resolves on a clone, so the wrong form passes everywhere except the path this spec creates. Co-Authored-By: Claude Fable 5 --- ...2026-08-23-home-repo-local-first-design.md | 322 ++++++++++++------ 1 file changed, 226 insertions(+), 96 deletions(-) diff --git a/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md b/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md index 5db19477..acba5814 100644 --- a/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md +++ b/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md @@ -38,20 +38,55 @@ together is what made a missing URL fatal. ## 1. `rt home init` always ends with a repo -Resolution order for the clone URL: +### Resolution lives in one place + +Today the order the spec wants is not implementable: `rt home init` reads only +`--url` (`parseUrlArg`, `commands/home.ts:193-201`), and `RT_HOME_URL` is read +solely by `lib/setup/steps/home.ts:61`, which converts it into `--url`. So env +arrives *as* rung 1 and an intent value could never outrank it. + +**`rt home init` owns resolution.** It resolves, in order: 1. `--url ` -2. the setup intent's `homeRepo` (see §4) +2. the setup intent's `homeRepo` (§4) 3. `RT_HOME_URL` in the environment 4. **none of the above → `git init` a local-only repo** +`lib/setup/steps/home.ts` **stops synthesizing `--url` from `RT_HOME_URL`** and +lets `home init` read the intent and env itself. In practice the step then passes +`--url` never. Splitting resolution across both is what inverts the order. + +Two things go with that deletion: the step's warning line at `:64` ("no +RT_HOME_URL set — targeting rt's built-in default repo") becomes false once +`DEFAULT_USER_REPO_URL` is gone, and the only other `ctx.p.env.RT_HOME_URL` +reference disappears with the §5 gate — after which `p.env` may be unused in that +file. + +**Seam the intent read.** `readIntent` takes `Pick`, +which `commands/home.ts` does not currently have. `HomeInitSeams` already has the +pattern; the plan must say how the intent read is injected, so the resolver stays +unit-testable without writing a real `~/.mattstack/rt/setup-intent.json`. + `DEFAULT_USER_REPO_URL` is **deleted**, not replaced. rt never invents a home repo for someone. -The local-only path produces the same tree the clone path does — `user/` as a -git repo, `.gitignore`, `snapshot-owners.jsonc`, the machine-key file, the -profile directory, the `skills.jsonc` symlink — with an initial commit and no -remote. Every downstream consumer sees exactly what it sees today. +### What local-only produces + +The same tree the clone path does: `user/` as a git repo with `main` checked +out, `.gitignore`, `snapshot-owners.jsonc`, the machine-key file, the profile +directory, and the `skills.jsonc` symlink — plus an initial commit and no +remote. `lib/team/create.ts:168-190` is a working `git init -b main` → scaffold → +commit reference to follow. + +`ensureStateDirs` and `ensureHomeAgeKey` run outside the clone-gated steps, so +local-only inherits `.sops.yaml` and the age key unchanged. + +**Ordering to settle during implementation:** `ensureHomeAgeKey` writes +`user/.sops.yaml` *after* `executeInitPlan`, with a "commit it yourself" +message. The clone path has no initial commit to mirror, so the plan must say +whether local-only's initial commit lands before that write (leaving `.sops.yaml` +uncommitted, consistent with today) or after it. Pick one and state it; do not +leave it to the implementer. **A repo that exists is never re-initialised.** The existing short-circuit on an already-present clone stays; local-only is only the behaviour for a first run @@ -59,10 +94,11 @@ with no URL. ## 2. The snapshot daemon: "no remote" is a state, not a failure -Today `lib/daemon/home-snapshot.ts` pushes unconditionally and broadcasts -`home:push-failed` when the push fails (`:485`). There is no remote detection -anywhere in the module. A local-only repo would therefore fire a failure event -on every cycle — the difference between a supported state and a tolerated one. +Today `lib/daemon/home-snapshot.ts` runs `git push -q origin HEAD` (`:458`) +unconditionally and broadcasts `home:push-failed` (`:485`) when it fails. There +is no remote detection anywhere in the module, so a local-only repo would fire a +failure event every cycle — the difference between a supported state and a +tolerated one. The daemon checks for a configured remote before arming the push. With none: @@ -70,131 +106,225 @@ The daemon checks for a configured remote before arming the push. With none: - it **skips the push** — no push timer, no retry timer, no `home:push-failed` - it logs the skip once at `debug`, not on every cycle -Everything commit-side is untouched: the janitor, claimed zones -(`snapshot-owners.jsonc`, `rt home claim|release`), and the live kill switch all -behave identically. This is deliberately not a new mode — it is one branch -before the push. +Everything commit-side is untouched: the janitor, claimed zones (`:639-671`), and +the kill switch (`:452-457`, `:510-516`) are all pre-push and behave identically. +This is deliberately not a new mode — it is one branch before the push. + +### Attaching a remote later must actually push + +`doRun` arms a push only via `if (committed || pushPending) schedulePush()` +(`:672`). So after a user attaches a remote, the commits accumulated while +local-only would sit unpushed until the next file change happens to produce a +commit — a janitor tick alone would not push them. -**A remote appearing later needs no restart.** The check reads the repo's -current remote at push time, so attaching one begins pushing on the next cycle. +**The rule: arm a push when a remote exists and HEAD is ahead of +`refs/remotes/origin/`, not only when this cycle committed.** That covers the attach-a-remote case +without the alternative's cost — setting `pushPending = true` while remote-less +would make `status()` and the tray show a permanently pending push that nothing +is going to perform. + +### There is no verb for attaching a remote, and this spec does not add one + +`rt home init --url ` is a **no-op against an existing repo**: `buildInitPlan` +skips `cloneUserRepo` when `userRepoPresent`, and `config.url` is then unused. So +today the only way to attach a remote is by hand: + +``` +git -C ~/.mattstack/user remote add origin +``` + +That is what the warning row's remedy names (§3). A `rt home remote set ` +verb is the obvious follow-up and is **explicitly out of scope here** — this spec +must not claim an affordance that does not exist. The Mac app's setup screen is +the other route: re-running setup lets an operator who deferred choose a remote. ## 3. Honesty: a local-only repo must never read as backed up This is the real risk of always-create, and the part most likely to be got wrong: **the user gets a working repo and reasonably assumes it is safe.** -`rt verify` renders over the health probes in `lib/setup/rt-health.ts`; a -non-required probe that reports anything other than `ready` renders as a -warning (`commands/verify.ts:70-75`). So the home-repo probe reports a distinct -non-`ready` state when the repo has no remote, and it is **not** marked -required — a warning, not a failed check. That satisfies the installer lane's -invariant that a probe which cannot verify must never report `ready`: nothing -has left this machine, so "backed up" cannot be verified. +### A new probe row -Wording matters as much as the state. The row says what is and is not true: +There is no home-repo row today — `lib/setup/validators/rt-health.ts` (note the +path; not `lib/setup/rt-health.ts`) has only `access.team-repo`, which is a +different repo. This spec **creates** a row: id `home.backup`, `required: false`, +with the usual title/why. -> **home repo — local only.** Your settings are versioned on this machine but -> are not backed up anywhere. Add a remote to sync them. +`required: false` matters twice over: a required row would make deferring a +blocker by the back door, and `commands/verify.ts:70-75` renders a non-required +non-`ready` row as `warn`/`severity: "warning"`, excluded from the failure tally +and from `process.exit(1)` (`:151`, `:161`). -Not "not configured" (it is configured, deliberately), and not an error (nothing -is wrong). The tray's health row carries the same state and the same sentence. +### The status must be `needs-you`, not merely "non-ready" + +`RowStatus` is `ready|missing|invalid|needs-you|checking|skipped|error` +(`lib/setup/contract.ts:3`). Only the first four render as `warn`. **`skipped` +and `checking` render as `skip` with `severity: "info"`** — a dim dash and no +warning at all. "Local-only" is a plausible-sounding reading of `skipped`, and +choosing it would silently defeat this entire section. The row reports +**`needs-you`** for every non-green state below. ### Green means a push succeeded, never that a remote exists A remote that is set but has never been pushed to — wrong URL, missing auth, failing every cycle — is still *"your settings are not backed up anywhere"*. A -probe that reads git config and reports `ready` on `remote exists` would call -that safe. It is the same mistake as asserting a bundled binary's signature and -size instead of running it: the shape is right and the path is dead. - -So the probe reports four states, and `ready` requires evidence of a **completed -push**: - -| condition | state | -|---|---| -| no remote | warning — the local-only wording above | -| remote set, never pushed | warning — "remote configured, nothing pushed yet" | -| remote set, last push failed | warning — names the failure; this is the state a user is least likely to notice and most likely to be hurt by | -| remote set, push succeeded | `ready` — naming when it last succeeded | - -The snapshot daemon is the thing pushing, so it records each push outcome rather -than the probe inferring one. It already persists to `state.db` under the -`home-snapshot` namespace (`lib/daemon/home-snapshot.ts:158`), which is where the -last-push result belongs: outcome and timestamp, written on success and on -failure. - -**The fallback, if recording proves harder than it looks:** never report `ready` -on remote-configured alone — collapse "configured but unproven" into the same -warning tier as local-only. Weaker, because a working setup then reads as -warning forever, but it cannot lie. A probe that overstates safety is worse than -one that understates it, because the failure is silent and the loss is total. - -**Shipping surfaces are `rt verify` and the tray.** The settings page from -`2026-08-23-settings-console-page-design.md` is specced but not built, and is -queued behind the console's binary-compile work — so it cannot be where this -warning lives first. When it exists, a home-repo panel is the natural home for -the indicator. Note it will not be key-shaped: "this repo has no remote" is -derived git state, not a registry key, so it needs its own panel rather than a -row in the key table. +probe reporting `ready` on `remote exists` would call that safe. It is the same +mistake as asserting a bundled binary's signature and size instead of running it: +the shape is right and the path is dead. + +**Evidence of a completed push is git's own remote-tracking state**, not the +daemon's word: + +- `refs/remotes/origin/` exists, and +- `git rev-list refs/remotes/origin/..HEAD` is empty (nothing local is + unpushed), where `` comes from `git symbolic-ref --short HEAD` + +**Never use `@{u}`.** The daemon pushes `git push -q origin HEAD` with no `-u` +(`home-snapshot.ts:458`), so a repo that was `git init`-ed and later had a remote +added has the remote ref but no `branch..remote`/`.merge` — `@{u}` exits +128 with *"no upstream configured"*. Verified on a scratch repo: the ref +resolves, `rev-list refs/remotes/origin/main..HEAD` returns 0, and the `@{u}` +form fails outright. + +The trap is that `@{u}` **works on a cloned repo**, because `git clone` +configures upstream. It would pass on the author's machine and on every existing +install, and fail only on the local-only-then-attached path — the population this +spec creates. That is the same "the code looked fine because the failing path was +never exercised" shape this document's own closing note warns about. + +Configuring upstream instead (making the daemon's first push `git push -u origin +HEAD`) is a separate, independent change. It is **not** assumed here: a probe +depending on it would still be wrong for every repo pushed before it landed. + +That ref is updated *by* a successful push, so it is outcome evidence. Reading it +rather than a daemon record also covers three cases a record would miss: a +hand-run `git push`, a machine where the snapshot daemon is disabled or was never +installed, and existing users — who would otherwise read "nothing pushed yet" +until their next push. + +| condition | status | row says | +|---|---|---| +| no remote | `needs-you` | **local only.** Your settings are versioned on this machine but are not backed up anywhere. Remedy names the `git remote add` command from §2. | +| remote set, no upstream ref | `needs-you` | remote configured, nothing pushed yet | +| remote set, commits unpushed | `needs-you` | names how many commits are unpushed, and the last push failure if one is recorded | +| remote set, upstream current | `ready` | naming when the last push succeeded, if known | + +Not "not configured" (it is configured, deliberately), and not an error (nothing +is wrong). + +**The daemon's recorded outcome supplements the ref check; it never gates +green.** It supplies the *why* for a failing push — the state a user is least +likely to notice and most likely to be hurt by. Record it under its **own kv +key**, not the existing state row: `persistState` (`:201-209`) writes +`{ firstSeenDirty }` wholesale on **every** commit cycle, so a `lastPush` field +added to `HOME_SNAPSHOT_KEY` would be clobbered within seconds and the probe +would report "never pushed" forever. A separate key in the same +`HOME_SNAPSHOT_NS` namespace avoids that without touching the janitor's +first-seen-dirty clock. + +**The probe reads `state.db` directly, never the daemon over IPC.** `rt verify` +runs in CI and mid-install when the daemon is not up; an IPC failure would render +as `error` rather than the intended warning. A missing key reads as "no recorded +push", which is correct on a fresh install and is why the ref check is primary. + +### The `snapshot.push` step tells the same truth + +`lib/setup/steps/tools.ts:165-211`, titled *"Push your first snapshot"*, returns +`done: "committed "` off the daemon's commit — it never observes a push. On +a local-only install the operator finishes setup looking at a green *"Push your +first snapshot"*, which is exactly the false safety this section exists to +prevent. It lives in this repo, so it is in scope: with no remote its detail says +the snapshot was committed locally and not pushed. Its `done` state is +unchanged — the step did what it could — but its wording stops implying the +snapshot left the machine. + +### Shipping surfaces + +`rt verify` and the tray. The tray needs **no Swift work**: its status window +renders `rt setup status --json` rows generically +(`SetupCoordinator.swift:49`), so a new row appears on its own. + +The settings page from `2026-08-23-settings-console-page-design.md` is specced +but not built and is queued behind the console's binary-compile work, so it +cannot be where this warning lives first. When it exists, a home-repo panel is +the natural home — and it will not be key-shaped, since "this repo has no remote" +is derived git state rather than a registry key. ## 4. How the URL reaches rt -`SetupIntent` (`lib/setup/intent.ts`) gains a top-level `homeRepo?: string`. -It is orthogonal to `mode` — a `create` and a `join` both need one, and -`restore` already carries its own under `restore.homeRepo`. +`SetupIntent` (`lib/setup/intent.ts`) gains a top-level `homeRepo?: string`. It +is orthogonal to `mode` — a `create` and a `join` both need one, and `restore` +keeps its own under `restore.homeRepo`. Parsing is an unvalidated `JSON.parse` +cast, so no `v` bump is required. The Mac app's setup collects it and writes the intent, the same way -`TeamChoiceModel` already calls `rt setup intent restore` for the restore flow. -**Deferring is a first-class answer**: choosing nothing yields a local-only repo -and a warning, never a blocked install. Whether the app also offers to create a -remote repo on the operator's behalf is the installer lane's design question, -not this spec's — it needs `gh` auth and a `repo` scope, which is a heavy ask at -first run. +`TeamChoiceModel` already calls `rt setup intent restore`. **Deferring is a +first-class answer**: choosing nothing yields a local-only repo and a warning, +never a blocked install. Whether the app also offers to create a remote repo on +the operator's behalf is the installer lane's design question, not this spec's — +it needs `gh` auth and a `repo` scope, a heavy ask at first run. The installer lane owns the intent field, the app screen, and the step list. -This spec owns everything under §1-§3. +This spec owns §1-§3. + +## 5. The headless gate is already here, and goes with this change -## 5. The headless gate rides along +The installer lane's gate — which stops `home.init` being enqueued headless with +no URL — is **already on this branch as `bc02814`** (a cherry-pick of their +`8e50d23`). Do not cherry-pick it again. -The installer lane holds an unpushed commit (`8e50d23`, branch -`fix/home-init-gate`) that stops `home.init` being enqueued headless with no -URL. Its justification is that the only reachable outcome is failing on auth — -which stops being true the moment no-URL succeeds. Left in place afterwards it -would leave a clean-room install with **no home repo at all**, while everything -downstream assumes there is one. +Its justification is that the only reachable outcome is failing on auth, which +stops being true the moment no-URL succeeds. Left in place it would leave a +clean-room install with **no home repo at all** while everything downstream +assumes there is one. So **delete the `applies` clause and its comment block in +the same commit that adds local-only creation**, so the tree is never in a state +where the gate exists but is pointless. -So it is cherry-picked into this change and removed in the same commit, so the -tree is never in a state where the gate exists but is pointless. **Its third -test is kept** — the one asserting `home.init` still applies interactively with -no URL — because it is what catches this change quietly disabling the step for -real users. Its expected reason changes; its value does not. +**Keep its third test** — "still applies interactively with no RT_HOME_URL" — +with its expectation rewritten for this design. It is what catches this change +quietly disabling the step for real users. ## Testing -- **Local-only init produces a working repo**: `user/` is a git repo with an - initial commit, no remote, and every artifact the clone path produces. -- **Resolution order**, each rung in turn, including that a present `--url` - beats an intent `homeRepo` beats `RT_HOME_URL`. +- **Local-only init produces a working repo**: `user/` is a git repo on `main` + with an initial commit, no remote, and every artifact the clone path produces. +- **Resolution order**, each rung in turn — and specifically that an intent + `homeRepo` beats `RT_HOME_URL`, which is the case current plumbing gets wrong. - **An existing repo is never re-initialised**, with and without a remote. - **The daemon skips the push with no remote** and broadcasts no `home:push-failed` — asserted over several cycles, since the bug this prevents is per-cycle spam. -- **The daemon pushes once a remote is attached**, with no restart. -- **`rt verify` reports local-only as a warning, not a failure**, and the run - still passes its critical checks. -- **Green requires a completed push.** Each of the four probe states renders - correctly, and specifically: a repo with a remote that has never pushed, and - one whose last push failed, both report a warning rather than `ready`. This is - the assertion that stops the probe reporting shape instead of outcome. +- **Attaching a remote pushes the backlog** without a new commit and without a + restart. Drive it with a janitor tick (`janitorIntervalMin`, default 30) — the + only cycle that fires with no file change. The existing `setTimeout`/`now` deps + support this; "no restart" does not mean "immediately". +- **Green requires a completed push.** Each of the four states renders correctly; + specifically, a remote with no upstream ref and a remote with unpushed commits + both report `needs-you`, not `ready`. This is the assertion that stops the probe + reporting shape instead of outcome. +- **The green assertion runs against a `git init` + `git remote add` repo, not a + clone.** A clone configures upstream and would pass even if the probe used + `@{u}`; only the init-then-attach path catches that. +- **The probe's status is `needs-you`**, asserted explicitly — `skipped` would + render as info and show no warning. +- **A `lastPush` record survives a commit cycle** (the clobber case). +- **`rt verify` shows the home row as a warning, not a failure.** Assert *that + row's* status and severity — **not** that the whole run passes, because + `access.team-repo` is `required: true` and reports `missing` on a machine with + no team remote (`lib/setup/validators/access.ts:50-56`), which is a critical + failure unrelated to this change. - **`home.init` still applies interactively with no URL** (the kept gate test). **Test against a HOME with no clone.** This machine cannot detect a regression here: `home.init` short-circuits on the existing clone, so the path never runs -locally. That is the same shape as the bug this spec fixes — the code looked -fine because the failing path was never exercised. +locally. That is the same shape as the bug this spec fixes — the code looked fine +because the failing path was never exercised. ## Out of scope +- **`rt home remote set `** — the obvious follow-up verb for attaching a + remote to an existing repo. Today's remedy is the `git remote add` command in + §2, and the row says so rather than implying an affordance that does not exist. - Creating a remote repo on the operator's behalf (installer lane; needs `gh` auth and a `repo` scope). - The settings-page home-repo panel — the page does not exist yet. From 75233b2409499f33268179fd9dc06cc3aed40112 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sun, 23 Aug 2026 16:05:51 -0500 Subject: [PATCH 05/15] docs: a missing remote ref arms the push, it is not 'nothing to push' rev-list against an absent ref is fatal, and that is the state of the freshly attached remote this rule exists to serve. Co-Authored-By: Claude Fable 5 --- .../2026-08-23-home-repo-local-first-design.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md b/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md index acba5814..f89aec5d 100644 --- a/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md +++ b/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md @@ -118,7 +118,17 @@ local-only would sit unpushed until the next file change happens to produce a commit — a janitor tick alone would not push them. **The rule: arm a push when a remote exists and HEAD is ahead of -`refs/remotes/origin/`, not only when this cycle committed.** That covers the attach-a-remote case +`refs/remotes/origin/`, not only when this cycle committed.** + +**A missing `refs/remotes/origin/` counts as everything-unpushed and +arms the push.** That is the state of a freshly attached remote — the exact user +who just followed the remedy above — and `rev-list refs/remotes/origin/..HEAD` +is *fatal* when the ref does not exist, not empty. Only once the ref exists is +the comparison meaningful. Treating the fatal as "nothing to push" would leave +the local-only backlog sitting unpushed until some later file change happened to +commit, reinstating the gap this rule closes. + +That covers the attach-a-remote case without the alternative's cost — setting `pushPending = true` while remote-less would make `status()` and the tray show a permanently pending push that nothing is going to perform. @@ -205,9 +215,9 @@ until their next push. | condition | status | row says | |---|---|---| | no remote | `needs-you` | **local only.** Your settings are versioned on this machine but are not backed up anywhere. Remedy names the `git remote add` command from §2. | -| remote set, no upstream ref | `needs-you` | remote configured, nothing pushed yet | +| remote set, no `refs/remotes/origin/` | `needs-you` | remote configured, nothing pushed yet | | remote set, commits unpushed | `needs-you` | names how many commits are unpushed, and the last push failure if one is recorded | -| remote set, upstream current | `ready` | naming when the last push succeeded, if known | +| remote set, ref exists, nothing ahead of it | `ready` | naming when the last push succeeded, if known | Not "not configured" (it is configured, deliberately), and not an error (nothing is wrong). From 4892b7165300df2c23249d9608e03b90120b7c9b Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sun, 23 Aug 2026 16:06:52 -0500 Subject: [PATCH 06/15] docs: test the whole init-to-second-push sequence, not one fixture Co-Authored-By: Claude Fable 5 --- .../2026-08-23-home-repo-local-first-design.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md b/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md index f89aec5d..6194b00f 100644 --- a/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md +++ b/docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md @@ -309,12 +309,15 @@ quietly disabling the step for real users. only cycle that fires with no file change. The existing `setTimeout`/`now` deps support this; "no restart" does not mean "immediately". - **Green requires a completed push.** Each of the four states renders correctly; - specifically, a remote with no upstream ref and a remote with unpushed commits - both report `needs-you`, not `ready`. This is the assertion that stops the probe + specifically, a remote with no `refs/remotes/origin/` and a remote with + unpushed commits both report `needs-you`, not `ready`. This is the assertion that stops the probe reporting shape instead of outcome. -- **The green assertion runs against a `git init` + `git remote add` repo, not a - clone.** A clone configures upstream and would pass even if the probe used - `@{u}`; only the init-then-attach path catches that. +- **Tests build the whole sequence, not just the fixture: `git init` → commit → + attach a remote → first push → second push.** A clone arrives with upstream + configured, an origin, and history, which is why every defect found reviewing + this spec was invisible on the author's machine — and each one lived in a + different step of that sequence, so a fixture alone would not have caught them + all. - **The probe's status is `needs-you`**, asserted explicitly — `skipped` would render as info and show no warning. - **A `lastPush` record survives a commit cycle** (the clobber case). From a88f76f20205d97378490fcee90c08f6c714fec1 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sun, 23 Aug 2026 16:17:45 -0500 Subject: [PATCH 07/15] docs: home repo local-first implementation plan Co-Authored-By: Claude Fable 5 --- .../plans/2026-08-23-home-repo-local-first.md | 391 ++++++++++++++++++ 1 file changed, 391 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-23-home-repo-local-first.md diff --git a/docs/superpowers/plans/2026-08-23-home-repo-local-first.md b/docs/superpowers/plans/2026-08-23-home-repo-local-first.md new file mode 100644 index 00000000..cc35b68c --- /dev/null +++ b/docs/superpowers/plans/2026-08-23-home-repo-local-first.md @@ -0,0 +1,391 @@ +# Home Repo Local-First Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** `rt home init` always ends with a working home repo — cloning when given a URL, `git init`-ing locally when not — and every surface reports honestly whether that repo is actually backed up. + +**Architecture:** Four independent slices. Task 1 moves URL resolution into `rt home init` and deletes the hardcoded default. Task 2 adds the local-only init path. Task 3 teaches the snapshot daemon that "no remote" is a state rather than a failure. Task 4 adds the health probe and fixes the one setup step that currently implies a push happened when it did not. + +**Tech Stack:** Bun (TypeScript), `bun:test`, git plumbing via the existing exec seams. + +**Spec:** `docs/superpowers/specs/2026-08-23-home-repo-local-first-design.md` — read it first; it carries the reasoning these tasks implement. + +## Global Constraints + +- Worktree `/Users/matt/Documents/GitHub/repo-tools-homerepo-wt`, branch `home-repo-local-first`. b3's headless gate is **already cherry-picked as `bc02814`** — do not cherry-pick it again. +- **Never use `@{u}`.** The daemon pushes `git push -q origin HEAD` with no `-u`, so a repo that was `git init`-ed and later given a remote has the remote ref but no upstream config: `@{u}` exits 128. Always compare against `refs/remotes/origin/`, with `` from `git symbolic-ref --short HEAD`. +- **A missing `refs/remotes/origin/` means everything-unpushed**, not "nothing to push". `git rev-list` against an absent ref is fatal, not empty. +- **Tests build the whole sequence — `git init` → commit → attach remote → first push → second push — never a clone.** A clone arrives with upstream configured, an origin, and history; every defect found reviewing this spec was invisible on a clone, and each lived in a different step of that sequence. +- Never touch the real `~/.mattstack`, the keychain, or a live daemon. Tests repoint `process.env.HOME` via the existing bunfig preload. +- Comments constraint-only — no narration, no ticket numbers, no reviewer-facing justification. +- Gates per task, FOREGROUND: `bun test lib commands packages` + `bun x tsc --noEmit`. Baseline is 3470 pass / 0 fail; any delta is yours to explain. +- One commit per task, trailer `Co-Authored-By: Claude Fable 5 `. + +--- + +### Task 1: `rt home init` owns URL resolution + +Today `parseUrlArg` (`commands/home.ts:193-201`) returns `DEFAULT_USER_REPO_URL` when no `--url` is given, and `lib/setup/steps/home.ts:61` converts `RT_HOME_URL` into `--url`. So env arrives *as* rung 1 and an intent value could never outrank it. + +**Files:** +- Modify: `commands/home.ts:84` (delete `DEFAULT_USER_REPO_URL`), `:193-201` (`parseUrlArg`), `HomeInitSeams` at `:495` +- Modify: `lib/setup/steps/home.ts:55-65` (stop synthesizing `--url`, delete the now-false warning) +- Test: `commands/__tests__/home.test.ts` + +**Interfaces:** +- Produces: `resolveHomeUrl(args: string[], seams: { readIntent: () => SetupIntent | null; env: Record }): string | null` — `null` means local-only. Task 2 consumes the `null`. +- Consumes: `readIntent(p: Pick)` from `lib/setup/intent.ts:34`, seamed so tests never write a real `~/.mattstack/rt/setup-intent.json`. + +- [ ] **Step 1: Write the failing tests** + +```ts +test("--url wins over intent and env", () => { + const url = resolveHomeUrl(["--url", "https://x/a.git"], { + readIntent: () => ({ v: 1, at: "", mode: "create", homeRepo: "https://x/b.git" }) as SetupIntent, + env: { RT_HOME_URL: "https://x/c.git" }, + }); + expect(url).toBe("https://x/a.git"); +}); + +test("intent homeRepo beats RT_HOME_URL", () => { + const url = resolveHomeUrl([], { + readIntent: () => ({ v: 1, at: "", mode: "create", homeRepo: "https://x/b.git" }) as SetupIntent, + env: { RT_HOME_URL: "https://x/c.git" }, + }); + expect(url).toBe("https://x/b.git"); +}); + +test("RT_HOME_URL is used when nothing else supplies one", () => { + expect(resolveHomeUrl([], { readIntent: () => null, env: { RT_HOME_URL: "https://x/c.git" } })).toBe("https://x/c.git"); +}); + +test("no url anywhere resolves to null — local-only, never a built-in default", () => { + expect(resolveHomeUrl([], { readIntent: () => null, env: {} })).toBeNull(); +}); + +test("--url with no value still throws rather than falling through to local-only", () => { + expect(() => resolveHomeUrl(["--url"], { readIntent: () => null, env: {} })).toThrow(InvalidUrlArgError); +}); +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `bun test commands/__tests__/home.test.ts -t resolveHomeUrl` +Expected: FAIL — `resolveHomeUrl` is not defined. + +- [ ] **Step 3: Implement** + +Delete `DEFAULT_USER_REPO_URL` entirely. Rewrite `parseUrlArg` to return `string | null` (keeping its `InvalidUrlArgError` throw for a valueless `--url`) and add: + +```ts +export function resolveHomeUrl( + args: string[], + seams: { readIntent: () => SetupIntent | null; env: Record }, +): string | null { + const fromFlag = parseUrlArg(args); + if (fromFlag !== null) return fromFlag; + const fromIntent = seams.readIntent()?.homeRepo; + if (fromIntent) return fromIntent; + return seams.env.RT_HOME_URL ?? null; +} +``` + +Add `readIntent?: () => SetupIntent | null` to `HomeInitSeams`, defaulted at call time like the other seams. + +- [ ] **Step 4: Strip the setup step's env handling** + +In `lib/setup/steps/home.ts`, delete the `RT_HOME_URL` read, the `--url` synthesis, and the warning at `:64` ("no RT_HOME_URL set — targeting rt's built-in default repo") — that line is false once the default is gone. The step invokes `["home", "init"]` with no `--url`. If `p.env` becomes unused in the file after Task 3's gate deletion, remove the import then, not now. + +- [ ] **Step 5: Run tests and the gates** + +Run: `bun test lib commands packages` and `bun x tsc --noEmit` +Expected: PASS. Existing tests asserting the old default will fail — update them to the new behaviour rather than restoring the constant. + +- [ ] **Step 6: Commit** + +```bash +git add commands/home.ts commands/__tests__/home.test.ts lib/setup/steps/home.ts +git commit -m "feat(home): rt home init owns url resolution; delete the hardcoded default" +``` + +--- + +### Task 2: The local-only init path + +**Files:** +- Modify: `lib/home/init-plan.ts:32` (step union), `:194-198` (clone branch) +- Modify: `lib/home/init-exec.ts:72-76` (executor) +- Modify: `commands/home.ts` (pass the resolved `string | null` into the plan config) +- Modify: `lib/setup/steps/home.ts` — **delete the `applies` gate and its comment block from `bc02814`**, keeping its third test with the expectation rewritten +- Test: `lib/home/__tests__/init-plan.test.ts`, `lib/setup/__tests__/steps-a.test.ts` + +**Interfaces:** +- Consumes: Task 1's `resolveHomeUrl` returning `string | null`. +- Produces: plan step `{ kind: "initUserRepo" }`, emitted in place of `cloneUserRepo` when the URL is `null`. + +**Decision this plan settles (the spec asked for it):** the local-only initial commit happens **inside `initUserRepo`, before** `ensureHomeAgeKey` writes `user/.sops.yaml`. That leaves `.sops.yaml` uncommitted, exactly as the clone path leaves it today, so both paths reach the same end state and the existing "commit it yourself" message stays true. + +- [ ] **Step 1: Write the failing tests** + +```ts +test("no url plans initUserRepo instead of cloneUserRepo", () => { + const plan = buildInitPlan(freshState(), { url: null, machineKey: "m" }); + expect(plan.steps.some((s) => s.kind === "initUserRepo")).toBe(true); + expect(plan.steps.some((s) => s.kind === "cloneUserRepo")).toBe(false); +}); + +test("a url still plans cloneUserRepo", () => { + const plan = buildInitPlan(freshState(), { url: "https://x/a.git", machineKey: "m" }); + expect(plan.steps.some((s) => s.kind === "cloneUserRepo")).toBe(true); +}); + +test("gitignore and owners ride along with initUserRepo, same as clone", () => { + const plan = buildInitPlan(freshState(), { url: null, machineKey: "m" }); + expect(plan.steps.some((s) => s.kind === "writeGitignore")).toBe(true); + expect(plan.steps.some((s) => s.kind === "writeOwners")).toBe(true); +}); + +test("an existing repo is never re-initialised, with or without a url", () => { + const present = { ...freshState(), userRepoPresent: true }; + for (const url of [null, "https://x/a.git"]) { + const plan = buildInitPlan(present, { url, machineKey: "m" }); + expect(plan.steps.some((s) => s.kind === "initUserRepo" || s.kind === "cloneUserRepo")).toBe(false); + } +}); +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `bun test lib/home/__tests__/init-plan.test.ts` +Expected: FAIL — `initUserRepo` is not a step kind. + +- [ ] **Step 3: Implement the plan step** + +Widen the config's `url` to `string | null`, add `| { kind: "initUserRepo" }` to the step union, and branch: + +```ts +if (!state.userRepoPresent) { + steps.push(config.url === null ? { kind: "initUserRepo" } : { kind: "cloneUserRepo", url: config.url }); + steps.push({ kind: "writeGitignore", content: renderHomeGitignore() }); + steps.push({ kind: "writeOwners", content: renderOwnersFile() }); +} +``` + +- [ ] **Step 4: Implement the executor** + +In `lib/home/init-exec.ts`, alongside `cloneUserRepo`. `lib/team/create.ts:168-190` is the working reference for this sequence: + +```ts +case "initUserRepo": { + log("initialising user/ as a local repo (no remote)"); + await run(exec, ["git", "init", "-b", "main", "user"]); + return; +} +``` + +The initial commit lands after `writeGitignore`/`writeOwners` have populated the tree — add a `commitInitialUserRepo` step emitted only on the local-only path, running `git -C user add -A` then `git -C user commit -m "initial home repo"`. A clone has history already, which is why this step is local-only. + +- [ ] **Step 5: Delete the headless gate** + +In `lib/setup/steps/home.ts`, remove the `applies` clause added by `bc02814` and its comment block — the comment argues for a gate that no longer exists. Restore `applies: (ctx) => ctx.intent?.mode !== "restore"`. Rewrite the kept third test: + +```ts +test("still applies interactively with no RT_HOME_URL — home init now creates a local-only repo", () => { + const { ctx } = makeCtx(fakeProbes({ env: {} }), { nonInteractive: false }); + expect(homeInitStep.applies(ctx)).toBe(true); +}); +``` + +Delete the other two gate tests: they assert a gate that is gone. + +- [ ] **Step 6: Run tests and the gates** + +Run: `bun test lib commands packages` and `bun x tsc --noEmit` +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add lib/home/ commands/home.ts lib/setup/steps/home.ts lib/setup/__tests__/steps-a.test.ts +git commit -m "feat(home): git init a local-only repo when no url is supplied" +``` + +--- + +### Task 3: The daemon treats "no remote" as a state + +**Files:** +- Modify: `lib/daemon/home-snapshot.ts:458` (push), `:672` (arming) +- Test: `lib/daemon/__tests__/home-snapshot.test.ts` + +**Interfaces:** +- Produces: `hasRemote(exec, repoDir): Promise` and `unpushedAgainstOrigin(exec, repoDir): Promise` — Task 4's probe uses the same git commands but reads them independently, not by importing these. + +- [ ] **Step 1: Write the failing tests** + +```ts +test("no remote: commits, never pushes, never broadcasts a failure", async () => { + const h = await harnessWithLocalOnlyRepo(); // git init + commit, no remote + await h.writeFile("user/settings.user.jsonc", "{}"); + await h.runCycles(3); + expect(h.commits().length).toBeGreaterThan(0); + expect(h.execCalls().filter((c) => c[1] === "push")).toEqual([]); + expect(h.broadcasts("home:push-failed")).toEqual([]); +}); + +test("a freshly attached remote arms a push with no new commit", async () => { + const h = await harnessWithLocalOnlyRepo(); + await h.writeFile("user/a", "1"); + await h.runCycles(1); // commits locally, no push + await h.attachRemote(); // git remote add origin + await h.janitorTick(); // no file change + expect(h.execCalls().filter((c) => c[1] === "push").length).toBe(1); +}); + +test("second push only fires when there is something ahead of the ref", async () => { + const h = await harnessWithLocalOnlyRepo(); + await h.attachRemote(); + await h.writeFile("user/a", "1"); + await h.runCycles(1); // first push + await h.janitorTick(); // nothing new + expect(h.execCalls().filter((c) => c[1] === "push").length).toBe(1); +}); +``` + +`harnessWithLocalOnlyRepo` builds the full sequence — `git init -b main`, a commit, and a bare repo available for `attachRemote()` — never a clone. + +- [ ] **Step 2: Run to verify they fail** + +Run: `bun test lib/daemon/__tests__/home-snapshot.test.ts` +Expected: FAIL — the push runs unconditionally, so the first test sees a push call. + +- [ ] **Step 3: Implement remote detection** + +```ts +async function hasRemote(exec: ExecSeam, cwd: string): Promise { + const r = await exec(["git", "remote"], { cwd, stderr: "pipe" }); + return r.code === 0 && r.stdout.trim().length > 0; +} +``` + +Guard the push body: with no remote, log once at `debug` and return without pushing, without arming a retry, and without broadcasting `home:push-failed`. + +- [ ] **Step 4: Implement the arming rule** + +```ts +async function unpushedAgainstOrigin(exec: ExecSeam, cwd: string): Promise { + const b = await exec(["git", "symbolic-ref", "--short", "HEAD"], { cwd, stderr: "pipe" }); + if (b.code !== 0) return false; // detached HEAD: never green, never arm + const branch = b.stdout.trim(); + const ref = `refs/remotes/origin/${branch}`; + const has = await exec(["git", "rev-parse", "--verify", "-q", ref], { cwd, stderr: "pipe" }); + if (has.code !== 0) return true; // no ref yet — everything is unpushed + const ahead = await exec(["git", "rev-list", `${ref}..HEAD`], { cwd, stderr: "pipe" }); + return ahead.code === 0 && ahead.stdout.trim().length > 0; +} +``` + +Replace `if (committed || pushPending) schedulePush()` at `:672` with a check that also arms when `await hasRemote(...)` and `await unpushedAgainstOrigin(...)`. **Never `@{u}`** — see Global Constraints. + +- [ ] **Step 5: Run tests and the gates** + +Run: `bun test lib commands packages` and `bun x tsc --noEmit` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add lib/daemon/home-snapshot.ts lib/daemon/__tests__/home-snapshot.test.ts +git commit -m "feat(daemon): no remote is a state, not a push failure" +``` + +--- + +### Task 4: The `home.backup` probe and the honest setup step + +**Files:** +- Modify: `lib/setup/validators/rt-health.ts` (add the row — note the path; `lib/setup/rt-health.ts` does not exist) +- Modify: `lib/setup/steps/tools.ts:165-211` (`snapshot.push` detail) +- Test: `lib/setup/__tests__/rt-health.test.ts`, `lib/setup/__tests__/steps-*.test.ts` + +**Interfaces:** +- Consumes: nothing from Tasks 1-3 at the code level — the probe reads git state itself, so it works when the daemon has never run. + +- [ ] **Step 1: Write the failing tests** + +```ts +test("no remote: needs-you, not skipped — skipped renders as info and shows no warning", async () => { + const row = await homeBackupRow(await localOnlyRepo()); + expect(row.status).toBe("needs-you"); + expect(row.required).toBe(false); +}); + +test("remote attached but never pushed: needs-you, not ready", async () => { + const repo = await localOnlyRepo(); + await attachRemote(repo); + expect((await homeBackupRow(repo)).status).toBe("needs-you"); +}); + +test("commits ahead of the ref: needs-you", async () => { + const repo = await pushedRepo(); + await commit(repo, "later"); + expect((await homeBackupRow(repo)).status).toBe("needs-you"); +}); + +test("pushed and nothing ahead: ready", async () => { + expect((await homeBackupRow(await pushedRepo())).status).toBe("ready"); +}); +``` + +`pushedRepo()` is `git init` → commit → `git remote add` → `git push origin HEAD` — **not a clone**. A clone configures upstream and would pass even against a broken `@{u}` implementation. + +- [ ] **Step 2: Run to verify they fail** + +Run: `bun test lib/setup/__tests__/rt-health.test.ts` +Expected: FAIL — no `home.backup` row exists. + +- [ ] **Step 3: Implement the row** + +Add to `lib/setup/validators/rt-health.ts`, following the shape of the existing `access.team-repo` row: id `home.backup`, `required: false`, resolving status with the same git sequence as Task 3's `unpushedAgainstOrigin` (branch → ref exists → rev-list). Details per the spec's table: + +| condition | status | detail | +|---|---|---| +| no remote | `needs-you` | `local only — your settings are versioned on this machine but are not backed up anywhere` | +| no `refs/remotes/origin/` | `needs-you` | `remote configured, nothing pushed yet` | +| commits ahead of the ref | `needs-you` | ` commit(s) not pushed` | +| ref exists, nothing ahead | `ready` | `last pushed ` | + +Remedy for the no-remote case names the command, since no verb exists: +`git -C ~/.mattstack/user remote add origin ` + +`needs-you` is required, not merely "non-ready": `skipped` and `checking` render as `skip`/`severity: "info"` (`commands/verify.ts:70-75`) — a dim dash with no warning, which would silently defeat the row's purpose. + +- [ ] **Step 4: Fix `snapshot.push`'s wording** + +`lib/setup/steps/tools.ts:165-211` is titled "Push your first snapshot" and returns `done: "committed "` off the daemon's commit — it never observes a push. With no remote its detail must say the snapshot was committed locally and not pushed. The `done` state is unchanged; the step did what it could. + +```ts +const detail = await hasRemote(...) ? `pushed ${sha}` : `committed ${sha} locally — no remote, nothing pushed`; +``` + +- [ ] **Step 5: Run tests and the gates** + +Run: `bun test lib commands packages` and `bun x tsc --noEmit` +Expected: PASS. + +- [ ] **Step 6: Verify against a real fresh HOME (orchestrator-only)** + +This machine cannot detect a regression here — `home.init` short-circuits on the existing clone, so the path never runs locally. Against a temp HOME with no clone: `rt home init` produces a local-only repo; `rt verify` shows `home.backup` as a **warning**, and the run's critical-check tally is unchanged by it. Assert that row's status and severity specifically — **not** that the whole run passes, since `access.team-repo` is `required: true` and reports `missing` with no team remote (`lib/setup/validators/access.ts:50-56`). + +- [ ] **Step 7: Commit** + +```bash +git add lib/setup/validators/rt-health.ts lib/setup/steps/tools.ts lib/setup/__tests__/ +git commit -m "feat(setup): home.backup probe, and snapshot.push stops implying a push" +``` + +--- + +## Not in this plan + +Per the spec's out-of-scope section: `rt home remote set ` (the remedy names the git command instead), creating a remote on the operator's behalf, the settings-page panel, and the `SetupIntent.homeRepo` field itself plus the app screen — those are the installer lane's, and Task 1 reads the field defensively whether or not it is populated yet. From 51e48f8c8f441b6fc3221454bf41122a1c494d9b Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sun, 23 Aug 2026 17:08:22 -0500 Subject: [PATCH 08/15] feat(home): rt home init owns url resolution; delete the hardcoded default Deletes DEFAULT_USER_REPO_URL (a personal private repo) and adds resolveHomeUrl, a real precedence chain: --url > setup intent's homeRepo > RT_HOME_URL > null. lib/setup/steps/home.ts stops synthesizing --url from env now that rt home init resolves it directly. Co-Authored-By: Claude Fable 5 --- commands/__tests__/home.test.ts | 59 +++++++++++++++++++++++++++------ commands/home.ts | 51 ++++++++++++++++++++++++---- lib/command-tree-def.ts | 2 +- lib/setup/intent.ts | 2 ++ lib/setup/steps/home.ts | 12 +------ 5 files changed, 97 insertions(+), 29 deletions(-) diff --git a/commands/__tests__/home.test.ts b/commands/__tests__/home.test.ts index 9a87b5ec..b5f2d0d4 100644 --- a/commands/__tests__/home.test.ts +++ b/commands/__tests__/home.test.ts @@ -1,6 +1,5 @@ import { describe, test, expect, spyOn, beforeEach, afterEach } from "bun:test"; import { - DEFAULT_USER_REPO_URL, claudePluginsPointerMessage, defaultAgeKeyInputSeam, defaultMaterializeEnv, @@ -13,6 +12,7 @@ import { InvalidUrlArgError, probeDeckHealthy, readStdinTrimmed, + resolveHomeUrl, type AgeKeyInputSeam, type DeckHealthProbe, type HomeDaemonSeam, @@ -20,6 +20,7 @@ import { type MachineProfilePickerSeam, type SopsYamlSeam, } from "../home.ts"; +import type { SetupIntent } from "../../lib/setup/intent.ts"; import { setSetting } from "../../lib/settings/write.ts"; import { Readable } from "stream"; import { EventEmitter } from "events"; @@ -40,6 +41,7 @@ const FAKE_PUBLIC_KEY = "age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq const FAKE_PRIVATE_KEY = "AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ"; const SOPS_YAML_PATH = join(mattstackHome(), "user", ".sops.yaml"); const KEY = "mbp-14"; +const TEST_URL = "https://github.com/example/mattstack-home.git"; /** In-memory .sops.yaml — never touches the real filesystem. */ class FakeSopsYamlSeam implements SopsYamlSeam { @@ -376,15 +378,15 @@ describe("homeInit", () => { expect(sopsYamlSeam.writes).toEqual([]); }); - test("a fresh, fully successful init clones with the default URL, mints the age key, and writes .sops.yaml after adoption", async () => { + test("a fresh, fully successful init clones the given URL, mints the age key, and writes .sops.yaml after adoption", async () => { const seam = new FakeSeam(); const ageKeySeam = new FakeAgeKeySeam(); const sopsYamlSeam = new FakeSopsYamlSeam(); - const { exitCode, logs } = await runHomeInit(fakeProbes({}), seam, ageKeySeam, [], sopsYamlSeam); + const { exitCode, logs } = await runHomeInit(fakeProbes({}), seam, ageKeySeam, ["--url", TEST_URL], sopsYamlSeam); expect(exitCode).toBeUndefined(); const cloneCall = seam.calls.find((c) => c.kind === "run") as { kind: string; arg: string[] } | undefined; - expect(cloneCall?.arg).toEqual(["git", "clone", DEFAULT_USER_REPO_URL, "user"]); + expect(cloneCall?.arg).toEqual(["git", "clone", TEST_URL, "user"]); expect(ageKeySeam.calls.some((c) => c[1] === "find-generic-password")).toBe(true); expect(ageKeySeam.calls.some((c) => c[0] === "age-keygen")).toBe(true); expect(sopsYamlSeam.files.get(SOPS_YAML_PATH)).toBe(renderSopsYaml(FAKE_PUBLIC_KEY)); @@ -397,13 +399,20 @@ describe("homeInit", () => { expect(successIdx).toBeGreaterThan(readyIdx); }); - test("--url overrides the default clone URL", async () => { + test("--url is used to clone", async () => { const seam = new FakeSeam(); - const customUrl = "https://github.com/example/mattstack-home.git"; - await runHomeInit(fakeProbes({}), seam, new FakeAgeKeySeam(), ["--url", customUrl]); + await runHomeInit(fakeProbes({}), seam, new FakeAgeKeySeam(), ["--url", TEST_URL]); const cloneCall = seam.calls.find((c) => c.kind === "run") as { kind: string; arg: string[] } | undefined; - expect(cloneCall?.arg).toEqual(["git", "clone", customUrl, "user"]); + expect(cloneCall?.arg).toEqual(["git", "clone", TEST_URL, "user"]); + }); + + test("no url anywhere: the clone step still runs (a real clone attempt fails loudly on its own), never silently substituting a built-in default repo", async () => { + const seam = new FakeSeam(); + await runHomeInit(fakeProbes({}), seam, new FakeAgeKeySeam()); + + const cloneCall = seam.calls.find((c) => c.kind === "run") as { kind: string; arg: string[] } | undefined; + expect(cloneCall?.arg).toEqual(["git", "clone", "", "user"]); }); test("--dry-run never touches the age key or runs any step, even on a fresh (not-yet-provisioned) home", async () => { @@ -774,14 +783,14 @@ describe("homeInit", () => { probes, cloneAwareSeam, new FakeAgeKeySeam(), - ["--profile", "desktop"], + ["--url", TEST_URL, "--profile", "desktop"], new FakeSopsYamlSeam(), KEY, ); expect(exitCode).toBeUndefined(); const cloneCall = cloneAwareSeam.calls.find((c) => c.kind === "run") as { kind: string; arg: string[] } | undefined; - expect(cloneCall?.arg).toEqual(["git", "clone", DEFAULT_USER_REPO_URL, "user"]); + expect(cloneCall?.arg).toEqual(["git", "clone", TEST_URL, "user"]); expect(cloneAwareSeam.calls).toContainEqual({ kind: "writeFile", arg: { path: "machine-key", content: "desktop" } }); }); @@ -1363,6 +1372,36 @@ describe("homeInit", () => { }); }); +describe("resolveHomeUrl", () => { + test("--url wins over intent and env", () => { + const url = resolveHomeUrl(["--url", "https://x/a.git"], { + readIntent: () => ({ v: 1, at: "", mode: "create", homeRepo: "https://x/b.git" }) as SetupIntent, + env: { RT_HOME_URL: "https://x/c.git" }, + }); + expect(url).toBe("https://x/a.git"); + }); + + test("intent homeRepo beats RT_HOME_URL", () => { + const url = resolveHomeUrl([], { + readIntent: () => ({ v: 1, at: "", mode: "create", homeRepo: "https://x/b.git" }) as SetupIntent, + env: { RT_HOME_URL: "https://x/c.git" }, + }); + expect(url).toBe("https://x/b.git"); + }); + + test("RT_HOME_URL is used when nothing else supplies one", () => { + expect(resolveHomeUrl([], { readIntent: () => null, env: { RT_HOME_URL: "https://x/c.git" } })).toBe("https://x/c.git"); + }); + + test("no url anywhere resolves to null — local-only, never a built-in default", () => { + expect(resolveHomeUrl([], { readIntent: () => null, env: {} })).toBeNull(); + }); + + test("--url with no value still throws rather than falling through to local-only", () => { + expect(() => resolveHomeUrl(["--url"], { readIntent: () => null, env: {} })).toThrow(InvalidUrlArgError); + }); +}); + describe("claudePluginsPointerMessage", () => { test("neither key resolved: no message", () => { expect(claudePluginsPointerMessage(undefined, undefined)).toBeNull(); diff --git a/commands/home.ts b/commands/home.ts index 3bde1422..6b67505b 100644 --- a/commands/home.ts +++ b/commands/home.ts @@ -80,8 +80,7 @@ import { loadRepoIndex } from "../lib/daemon/repo-index.ts"; import { loadMachineRepoTracking } from "../lib/repo-tracking.ts"; import { isDaemonInstalled } from "../lib/daemon-config.ts"; import { getSetting } from "../lib/settings/resolve.ts"; - -export const DEFAULT_USER_REPO_URL = "https://github.com/m4ttheweric/mattstack-home"; +import { readIntent as readIntentFromDisk, type SetupIntent } from "../lib/setup/intent.ts"; export interface HomeProbes { isGitRepo(dir: string): boolean; @@ -187,12 +186,12 @@ function describeStep(step: InitStep): string { } } -/** Thrown by parseUrlArg for a `--url` with no usable value — never silently absorbed into the default or into the next flag. */ +/** Thrown by parseUrlArg for a `--url` with no usable value — never silently absorbed into a default or into the next flag. */ export class InvalidUrlArgError extends Error {} -function parseUrlArg(args: string[]): string { +function parseUrlArg(args: string[]): string | null { const idx = args.indexOf("--url"); - if (idx === -1) return DEFAULT_USER_REPO_URL; + if (idx === -1) return null; const value = args[idx + 1]; if (value === undefined || value.startsWith("--")) { @@ -201,6 +200,24 @@ function parseUrlArg(args: string[]): string { return value; } +/** + * The precedence chain for which repo `rt home init` provisions: an explicit + * `--url` beats the setup intent's `homeRepo` (set once, ahead of time, by + * `create`/`join`), which beats `RT_HOME_URL` (a per-invocation override). + * `null` means no rung supplied one — a deliberate, first-class outcome, not + * a fallback to any repo this operator never chose. + */ +export function resolveHomeUrl( + args: string[], + seams: { readIntent: () => SetupIntent | null; env: Record }, +): string | null { + const fromFlag = parseUrlArg(args); + if (fromFlag !== null) return fromFlag; + const fromIntent = seams.readIntent()?.homeRepo; + if (fromIntent) return fromIntent; + return seams.env.RT_HOME_URL ?? null; +} + /** Thrown by parseProfileArg for a `--profile` with no usable value. */ export class InvalidProfileArgError extends Error {} @@ -511,6 +528,8 @@ export interface HomeInitSeams { materializeEnv?: () => Promise; /** Runs each materialize step's subprocess. Defaults to a real `runCapture` wrap; tests inject a fake that never touches a real binary. */ materializeExec?: MaterializeExecSeam; + /** Defaults to a real read of ~/.mattstack/rt/setup-intent.json; tests inject a fixed value instead of writing that file for real. */ + readIntent?: () => SetupIntent | null; } export async function homeInit(args: string[], _ctx: CommandContext = {}, seams: HomeInitSeams = {}): Promise { @@ -523,15 +542,28 @@ export async function homeInit(args: string[], _ctx: CommandContext = {}, seams: const isInteractive = seams.isInteractive ?? (() => Boolean(process.stdin.isTTY)); const materializeExec = seams.materializeExec ?? defaultMaterializeExec(); const materializeEnv = seams.materializeEnv ?? (() => defaultMaterializeEnv(materializeExec)); + const readIntent = + seams.readIntent ?? + (() => + readIntentFromDisk({ + readFile: (path) => { + try { + return readFileSync(path, "utf8"); + } catch { + return null; + } + }, + home: mattstackHome(), + })); const dryRun = args.includes("--dry-run"); const noMaterialize = args.includes("--no-materialize"); const home = mattstackHome(); - let url: string; + let resolvedUrl: string | null; let profileFlag: string | undefined; try { - url = parseUrlArg(args); + resolvedUrl = resolveHomeUrl(args, { readIntent, env: process.env }); profileFlag = parseProfileArg(args); } catch (err) { if (err instanceof InvalidUrlArgError || err instanceof InvalidProfileArgError) { @@ -540,6 +572,11 @@ export async function homeInit(args: string[], _ctx: CommandContext = {}, seams: } throw err; } + // buildInitPlan only reads .url on the branch that pushes cloneUserRepo + // (state.userRepoPresent === false); an unresolved url that DOES reach a + // real clone attempt fails loudly via the existing step-failure path + // instead of silently picking a repo nobody chose. + const url = resolvedUrl ?? ""; const newProfileFlag = args.includes("--new-profile"); let state = gatherHomeState(home, probes, key); diff --git a/lib/command-tree-def.ts b/lib/command-tree-def.ts index 7bb7b159..c448b983 100644 --- a/lib/command-tree-def.ts +++ b/lib/command-tree-def.ts @@ -834,7 +834,7 @@ export const TREE: Record = { name: "Clone URL", flag: "--url", type: "text", - hint: "The user repo to clone (default: https://github.com/m4ttheweric/mattstack-home)", + hint: "The user repo to clone — falls back to the setup intent's homeRepo, then RT_HOME_URL, then no clone", }, { name: "Profile", diff --git a/lib/setup/intent.ts b/lib/setup/intent.ts index 1543a58c..0899b2d9 100644 --- a/lib/setup/intent.ts +++ b/lib/setup/intent.ts @@ -25,6 +25,8 @@ export interface SetupIntent { team?: { slug: string; name: string; remote: string; others: boolean }; join?: { id: string; keyB64: string; pointer: InvitePointer }; restore?: { homeRepo: string }; + /** Orthogonal to `mode` — create and join both need one; restore keeps its own under `restore.homeRepo`. */ + homeRepo?: string; } export function intentPath(home: string): string { diff --git a/lib/setup/steps/home.ts b/lib/setup/steps/home.ts index b111f6a0..c59ad074 100644 --- a/lib/setup/steps/home.ts +++ b/lib/setup/steps/home.ts @@ -54,17 +54,7 @@ async function homeInitRun(ctx: ApplyContext): Promise { return { state: "failed", detail: `local age key check failed: ${keyStatus.message}`, remedy: "Unlock your keychain, then Retry" }; } - // No --url is passed unless the operator supplied one: `rt home init`'s - // own default (commands/home.ts's DEFAULT_USER_REPO_URL) targets the repo - // rt's own author uses, not this operator's — passing nothing here is - // honest only because we say so, not because it's silently the right repo. - const url = p.env.RT_HOME_URL; - const args = url ? ["home", "init", "--url", url] : ["home", "init"]; - if (!url) { - ctx.log("home.init", "no RT_HOME_URL set — targeting rt's built-in default repo, not one owned by this operator; set RT_HOME_URL to target your own"); - } - - const result = await p.runRt(args, { timeoutMs: HOME_INIT_TIMEOUT_MS }); + const result = await p.runRt(["home", "init"], { timeoutMs: HOME_INIT_TIMEOUT_MS }); if (result.code === 0) { const lastLine = result.stdout.trim().split("\n").pop() ?? ""; return { state: "done", detail: lastLine }; From 4f697472c3ca1db9f0598b808ab347892c921af2 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sun, 23 Aug 2026 17:17:23 -0500 Subject: [PATCH 09/15] fix(home): seam homeInit's env instead of reading process.env directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveHomeUrl's env rung was the one unseamed input in homeInit — every other seam (readIntent, materializeEnv, ageKeySeam, pickerSeam, isInteractive) already follows this call-time-defaulting pattern. Also fixes the new "no url anywhere" test, which previously depended on the ambient shell's RT_HOME_URL being unset. Co-Authored-By: Claude Fable 5 --- commands/__tests__/home.test.ts | 17 +++++++++++++++-- commands/home.ts | 5 ++++- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/commands/__tests__/home.test.ts b/commands/__tests__/home.test.ts index b5f2d0d4..aff59a80 100644 --- a/commands/__tests__/home.test.ts +++ b/commands/__tests__/home.test.ts @@ -238,6 +238,7 @@ async function runHomeInit( isInteractive: () => boolean = () => false, materializeEnv: () => Promise = async () => NOOP_MATERIALIZE_ENV, materializeExec: MaterializeExecSeam = new FakeMaterializeExecSeam(), + env: Record = {}, ): Promise<{ exitCode: number | undefined; logs: string[]; errors: string[] }> { const exitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); @@ -251,7 +252,7 @@ async function runHomeInit( errors.push(parts.map(String).join(" ")); }); try { - await homeInit(args, {}, { probes, exec, ageKeySeam, sopsYamlSeam, key, pickerSeam, isInteractive, materializeEnv, materializeExec }); + await homeInit(args, {}, { probes, exec, ageKeySeam, sopsYamlSeam, key, pickerSeam, isInteractive, materializeEnv, materializeExec, env }); return { exitCode: undefined, logs, errors }; } catch { const code = exitSpy.mock.calls.at(-1)?.[0] as number | undefined; @@ -409,7 +410,19 @@ describe("homeInit", () => { test("no url anywhere: the clone step still runs (a real clone attempt fails loudly on its own), never silently substituting a built-in default repo", async () => { const seam = new FakeSeam(); - await runHomeInit(fakeProbes({}), seam, new FakeAgeKeySeam()); + await runHomeInit( + fakeProbes({}), + seam, + new FakeAgeKeySeam(), + [], + new FakeSopsYamlSeam(), + KEY, + new UnreachablePickerSeam(), + () => false, + async () => NOOP_MATERIALIZE_ENV, + new FakeMaterializeExecSeam(), + {}, // no RT_HOME_URL — proves the "no url resolved" path, independent of the ambient shell's actual env + ); const cloneCall = seam.calls.find((c) => c.kind === "run") as { kind: string; arg: string[] } | undefined; expect(cloneCall?.arg).toEqual(["git", "clone", "", "user"]); diff --git a/commands/home.ts b/commands/home.ts index 6b67505b..5567a393 100644 --- a/commands/home.ts +++ b/commands/home.ts @@ -530,6 +530,8 @@ export interface HomeInitSeams { materializeExec?: MaterializeExecSeam; /** Defaults to a real read of ~/.mattstack/rt/setup-intent.json; tests inject a fixed value instead of writing that file for real. */ readIntent?: () => SetupIntent | null; + /** Defaults to `process.env` — resolveHomeUrl's RT_HOME_URL rung; tests inject a fixed value instead of depending on the ambient shell's environment. */ + env?: Record; } export async function homeInit(args: string[], _ctx: CommandContext = {}, seams: HomeInitSeams = {}): Promise { @@ -555,6 +557,7 @@ export async function homeInit(args: string[], _ctx: CommandContext = {}, seams: }, home: mattstackHome(), })); + const env = seams.env ?? process.env; const dryRun = args.includes("--dry-run"); const noMaterialize = args.includes("--no-materialize"); @@ -563,7 +566,7 @@ export async function homeInit(args: string[], _ctx: CommandContext = {}, seams: let resolvedUrl: string | null; let profileFlag: string | undefined; try { - resolvedUrl = resolveHomeUrl(args, { readIntent, env: process.env }); + resolvedUrl = resolveHomeUrl(args, { readIntent, env }); profileFlag = parseProfileArg(args); } catch (err) { if (err instanceof InvalidUrlArgError || err instanceof InvalidProfileArgError) { From 72dc1134dc032a4c1ba8c6a09f6b12f5fd89a649 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sun, 23 Aug 2026 17:32:02 -0500 Subject: [PATCH 10/15] feat(home): git init a local-only repo when no url is supplied Co-Authored-By: Claude Fable 5 --- commands/__tests__/home.test.ts | 9 +++-- commands/home.ts | 17 +++++----- lib/command-tree-def.ts | 4 +-- lib/home/__tests__/init-plan.test.ts | 49 ++++++++++++++++++++++++++-- lib/home/init-exec.ts | 11 +++++++ lib/home/init-plan.ts | 18 +++++++--- lib/setup/__tests__/steps-a.test.ts | 14 +------- lib/setup/steps/home.ts | 14 +------- 8 files changed, 89 insertions(+), 47 deletions(-) diff --git a/commands/__tests__/home.test.ts b/commands/__tests__/home.test.ts index aff59a80..e538408c 100644 --- a/commands/__tests__/home.test.ts +++ b/commands/__tests__/home.test.ts @@ -408,7 +408,7 @@ describe("homeInit", () => { expect(cloneCall?.arg).toEqual(["git", "clone", TEST_URL, "user"]); }); - test("no url anywhere: the clone step still runs (a real clone attempt fails loudly on its own), never silently substituting a built-in default repo", async () => { + test("no url anywhere: git-inits a local-only repo and commits its initial tree, never silently substituting a built-in default repo", async () => { const seam = new FakeSeam(); await runHomeInit( fakeProbes({}), @@ -424,8 +424,11 @@ describe("homeInit", () => { {}, // no RT_HOME_URL — proves the "no url resolved" path, independent of the ambient shell's actual env ); - const cloneCall = seam.calls.find((c) => c.kind === "run") as { kind: string; arg: string[] } | undefined; - expect(cloneCall?.arg).toEqual(["git", "clone", "", "user"]); + const runCalls = seam.calls.filter((c) => c.kind === "run").map((c) => c.arg as string[]); + expect(runCalls).toContainEqual(["git", "init", "-b", "main", "user"]); + expect(runCalls).toContainEqual(["git", "-C", "user", "add", "-A"]); + expect(runCalls).toContainEqual(["git", "-C", "user", "commit", "-m", "initial home repo"]); + expect(runCalls.some((arg) => arg[1] === "clone")).toBe(false); }); test("--dry-run never touches the age key or runs any step, even on a fresh (not-yet-provisioned) home", async () => { diff --git a/commands/home.ts b/commands/home.ts index 5567a393..4f73bf97 100644 --- a/commands/home.ts +++ b/commands/home.ts @@ -173,6 +173,10 @@ function describeStep(step: InitStep): string { return `create missing state dirs: ${step.dirs.join(", ")}`; case "cloneUserRepo": return `clone ${step.url} into user/`; + case "initUserRepo": + return "git init a local-only user/ repo (no remote)"; + case "commitInitialUserRepo": + return "commit the initial user/ tree"; case "writeGitignore": return "write the user repo's .gitignore"; case "writeOwners": @@ -341,7 +345,7 @@ async function ensureHomeAgeKey( } /** buildInitPlan's only checked failure (InvalidMachineKeyError) turned into the CLI's print-and-exit(1) — shared by every one of homeInit's three plan builds so the three don't drift. */ -function planOrExit(state: HomeState, config: { url: string; machineKey: string }): InitPlan { +function planOrExit(state: HomeState, config: { url: string | null; machineKey: string }): InitPlan { try { return buildInitPlan(state, config); } catch (err) { @@ -575,11 +579,6 @@ export async function homeInit(args: string[], _ctx: CommandContext = {}, seams: } throw err; } - // buildInitPlan only reads .url on the branch that pushes cloneUserRepo - // (state.userRepoPresent === false); an unresolved url that DOES reach a - // real clone attempt fails loudly via the existing step-failure path - // instead of silently picking a repo nobody chose. - const url = resolvedUrl ?? ""; const newProfileFlag = args.includes("--new-profile"); let state = gatherHomeState(home, probes, key); @@ -593,7 +592,7 @@ export async function homeInit(args: string[], _ctx: CommandContext = {}, seams: if (!state.machineKeyFilePresent) { if (!state.userRepoPresent) { if (dryRun) { - const previewPlan = planOrExit(state, { url, machineKey: key }); + const previewPlan = planOrExit(state, { url: resolvedUrl, machineKey: key }); printPlan(home, previewPlan.steps); if (previewPlan.blocked === "skills-symlink-real-file") printSkillsSymlinkBlocked(home); console.log( @@ -624,7 +623,7 @@ export async function homeInit(args: string[], _ctx: CommandContext = {}, seams: skillsSymlinkPresent: true, skillsSymlinkBlocked: false, }; - const clonePlan = planOrExit(cloneOnlyState, { url, machineKey: key }); + const clonePlan = planOrExit(cloneOnlyState, { url: resolvedUrl, machineKey: key }); printPlan(home, clonePlan.steps); const cloneResult = await executeInitPlan(clonePlan.steps, exec, (message) => console.log(` ${message}`)); @@ -698,7 +697,7 @@ export async function homeInit(args: string[], _ctx: CommandContext = {}, seams: process.exit(1); } - const plan = planOrExit(state, { url, machineKey: chosenKey }); + const plan = planOrExit(state, { url: resolvedUrl, machineKey: chosenKey }); // Env gathering is read-only (which deck, the repo index, rt.repoTracking, // the daemon-install marker) — safe to run under --dry-run, so the preview diff --git a/lib/command-tree-def.ts b/lib/command-tree-def.ts index c448b983..186577cb 100644 --- a/lib/command-tree-def.ts +++ b/lib/command-tree-def.ts @@ -825,7 +825,7 @@ export const TREE: Record = { description: "The git-backed ~/.mattstack/user personal repo", subcommands: { init: { - description: "Provision this machine: print, then run, the plan (which clones the user repo as one of its steps)", + description: "Provision this machine: print, then run, the plan (which clones or git-inits the user repo as one of its steps)", module: "./commands/home.ts", fn: "homeInit", args: [ @@ -834,7 +834,7 @@ export const TREE: Record = { name: "Clone URL", flag: "--url", type: "text", - hint: "The user repo to clone — falls back to the setup intent's homeRepo, then RT_HOME_URL, then no clone", + hint: "The user repo to clone — falls back to the setup intent's homeRepo, then RT_HOME_URL, then a local-only git init with no remote", }, { name: "Profile", diff --git a/lib/home/__tests__/init-plan.test.ts b/lib/home/__tests__/init-plan.test.ts index 0eae1ffd..4f164737 100644 --- a/lib/home/__tests__/init-plan.test.ts +++ b/lib/home/__tests__/init-plan.test.ts @@ -13,7 +13,8 @@ import { type InitPlanConfig, } from "../init-plan.ts"; -const CONFIG: InitPlanConfig = { url: "https://github.com/m4ttheweric/mattstack-home", machineKey: "mbp-14" }; +const TEST_URL = "https://github.com/m4ttheweric/mattstack-home"; +const CONFIG: InitPlanConfig = { url: TEST_URL, machineKey: "mbp-14" }; const FRESH_STATE: HomeState = { userRepoPresent: false, @@ -33,6 +34,10 @@ const FULLY_PROVISIONED_STATE: HomeState = { stateDirsMissing: [], }; +function freshState(): HomeState { + return { ...FRESH_STATE }; +} + describe("buildInitPlan", () => { test("fresh HOME: emits every step in order", () => { const plan = buildInitPlan(FRESH_STATE, CONFIG); @@ -48,7 +53,7 @@ describe("buildInitPlan", () => { "writeSkillsSymlink", ]); expect(plan.steps[0]).toEqual({ kind: "ensureStateDirs", dirs: STATE_DIR_NAMES }); - expect(plan.steps[1]).toEqual({ kind: "cloneUserRepo", url: CONFIG.url }); + expect(plan.steps[1]).toEqual({ kind: "cloneUserRepo", url: TEST_URL }); expect(plan.steps.find((s) => s.kind === "writeMachineKey")).toEqual({ kind: "writeMachineKey", key: "mbp-14", @@ -151,6 +156,46 @@ describe("buildInitPlan", () => { expect(STATE_DIR_NAMES).toContain("ci-attendants"); }); + describe("local-only init (config.url === null)", () => { + test("no url plans initUserRepo instead of cloneUserRepo", () => { + const plan = buildInitPlan(freshState(), { url: null, machineKey: "m" }); + expect(plan.steps.some((s) => s.kind === "initUserRepo")).toBe(true); + expect(plan.steps.some((s) => s.kind === "cloneUserRepo")).toBe(false); + }); + + test("a url still plans cloneUserRepo", () => { + const plan = buildInitPlan(freshState(), { url: "https://x/a.git", machineKey: "m" }); + expect(plan.steps.some((s) => s.kind === "cloneUserRepo")).toBe(true); + }); + + test("gitignore and owners ride along with initUserRepo, same as clone", () => { + const plan = buildInitPlan(freshState(), { url: null, machineKey: "m" }); + expect(plan.steps.some((s) => s.kind === "writeGitignore")).toBe(true); + expect(plan.steps.some((s) => s.kind === "writeOwners")).toBe(true); + }); + + test("an existing repo is never re-initialised, with or without a url", () => { + const present = { ...freshState(), userRepoPresent: true }; + for (const url of [null, "https://x/a.git"]) { + const plan = buildInitPlan(present, { url, machineKey: "m" }); + expect(plan.steps.some((s) => s.kind === "initUserRepo" || s.kind === "cloneUserRepo")).toBe(false); + } + }); + + test("commitInitialUserRepo is planned only on the local-only path, after writeGitignore/writeOwners", () => { + const plan = buildInitPlan(freshState(), { url: null, machineKey: "m" }); + const kinds = plan.steps.map((s) => s.kind); + expect(kinds).toContain("commitInitialUserRepo"); + expect(kinds.indexOf("commitInitialUserRepo")).toBeGreaterThan(kinds.indexOf("writeGitignore")); + expect(kinds.indexOf("commitInitialUserRepo")).toBeGreaterThan(kinds.indexOf("writeOwners")); + }); + + test("a url plans no commitInitialUserRepo step — a clone already has history", () => { + const plan = buildInitPlan(freshState(), { url: "https://x/a.git", machineKey: "m" }); + expect(plan.steps.some((s) => s.kind === "commitInitialUserRepo")).toBe(false); + }); + }); + describe("machine-key guard — refuses before ever emitting writeMachineKey/ensureProfileDir", () => { test.each([ ["empty", ""], diff --git a/lib/home/init-exec.ts b/lib/home/init-exec.ts index 72afe095..643c2ccb 100644 --- a/lib/home/init-exec.ts +++ b/lib/home/init-exec.ts @@ -74,6 +74,17 @@ async function runStep(step: InitStep, exec: ExecSeam, log: StepLog): Promise { expect(homeInitStep.applies(restoring)).toBe(false); }); - test("does not apply non-interactively with no RT_HOME_URL — would target a repo this operator does not own", () => { - const { ctx } = makeCtx(fakeProbes({ env: {} }), { nonInteractive: true }); - expect(homeInitStep.applies(ctx)).toBe(false); - }); - - test("applies non-interactively when RT_HOME_URL names the repo to clone", () => { - const { ctx } = makeCtx(fakeProbes({ env: { RT_HOME_URL: "https://example.com/o/home.git" } }), { nonInteractive: true }); - expect(homeInitStep.applies(ctx)).toBe(true); - }); - - // The gate must not quiet a headless run by disabling the step for real - // users: interactively, a missing RT_HOME_URL is answerable. - test("still applies interactively with no RT_HOME_URL — a human can supply one or authenticate", () => { + test("still applies interactively with no RT_HOME_URL — home init now creates a local-only repo", () => { const { ctx } = makeCtx(fakeProbes({ env: {} }), { nonInteractive: false }); expect(homeInitStep.applies(ctx)).toBe(true); }); diff --git a/lib/setup/steps/home.ts b/lib/setup/steps/home.ts index c59ad074..d958a6f7 100644 --- a/lib/setup/steps/home.ts +++ b/lib/setup/steps/home.ts @@ -115,19 +115,7 @@ export const homeInitStep: StepDef = { id: "home.init", title: "Create your settings home repo", kind: "rt", - // Not enqueued when nothing names a repo AND nobody can be asked for one. - // Interactively that case is fine — a human supplies a URL or authenticates - // — but headless it can only reach for `rt home init`'s built-in default, - // which is a repo this operator does not own. A clean-room run cloning the - // author's private home repo is wrong even on the runs where it succeeds. - // - // The gate lives here rather than inside home.init: the command failing when - // it genuinely cannot clone is the right answer for a real user with a wrong - // RT_HOME_URL, and softening it there to quiet a headless run would trade a - // good error for a silent one. - applies: (ctx) => - ctx.intent?.mode !== "restore" - && (!ctx.nonInteractive || Boolean(ctx.p.env.RT_HOME_URL)), + applies: (ctx) => ctx.intent?.mode !== "restore", run: homeInitRunSafe, }; From d926ed59fde86ffabb589ae0daed7cdcad32bbf6 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sun, 23 Aug 2026 17:49:54 -0500 Subject: [PATCH 11/15] feat(daemon): no remote is a state, not a push failure Co-Authored-By: Claude Fable 5 --- lib/daemon/__tests__/home-snapshot.test.ts | 114 ++++++++++++++++++++- lib/daemon/home-snapshot.ts | 40 +++++++- 2 files changed, 151 insertions(+), 3 deletions(-) diff --git a/lib/daemon/__tests__/home-snapshot.test.ts b/lib/daemon/__tests__/home-snapshot.test.ts index b9b68225..c3e77694 100644 --- a/lib/daemon/__tests__/home-snapshot.test.ts +++ b/lib/daemon/__tests__/home-snapshot.test.ts @@ -1,11 +1,11 @@ import { afterAll, describe, expect, test } from "bun:test"; import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; -import { join } from "path"; +import { dirname, join } from "path"; import { execFileSync } from "child_process"; import type { Database } from "bun:sqlite"; import type { Logger } from "pino"; -import type { RunResult } from "../../subprocess.ts"; +import { runCapture, type RunResult } from "../../subprocess.ts"; import type { Owners } from "../../home/snapshot-owners.ts"; import { openStateDb } from "../../state/db.ts"; import { closeStateDb, getKvValue } from "../../state/index.ts"; @@ -34,9 +34,11 @@ function defaultResponders(opts: { pushExit?: number; pushStderr?: string; sha?: string; + hasRemote?: boolean; } = {}): Responder[] { const { isRepo = true, branch = "main", branchExit = 0, statusZ = "", commitExit = 0, addExit = 0, pushExit = 0, pushStderr = "", sha = "abc123", + hasRemote = true, } = opts; return [ (argv) => (argv[1] === "rev-parse" && argv[2] === "--is-inside-work-tree") @@ -51,6 +53,8 @@ function defaultResponders(opts: { (argv) => (argv[1] === "status") ? { stdout: statusZ, stderr: "", exitCode: 0 } : undefined, (argv) => (argv[1] === "add") ? { stdout: "", stderr: "", exitCode: addExit } : undefined, (argv) => (argv[1] === "commit") ? { stdout: "", stderr: "", exitCode: commitExit } : undefined, + // `hasRemote()`'s own probe — most fixtures simulate a repo that already has origin configured, matching every pre-existing push test's assumption. + (argv) => (argv[1] === "remote" && argv.length === 2) ? { stdout: hasRemote ? "origin\n" : "", stderr: "", exitCode: 0 } : undefined, (argv) => (argv[1] === "push") ? { stdout: "", stderr: pushStderr, exitCode: pushExit } : undefined, ]; } @@ -1489,3 +1493,109 @@ describe("startHomeSnapshot — settings-read resilience", () => { expect(log.calls.filter((c) => c.level === "warn" && c.args[1] === warnLine).length).toBe(1); }); }); + +// ─── local-only remote state: real git, no clone — no remote is a state ──── + +describe("startHomeSnapshot — local-only remote state", () => { + const createdRoots: string[] = []; + afterAll(() => { + for (const root of createdRoots) { + try { rmSync(root, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } + } + }); + + const LOCAL_ONLY_SETTINGS: HomeSnapshotSettings = { ...DEFAULT_SETTINGS, pushDelaySec: 1 }; + const PUSH_SETTLE_MS = 1500; + + /** Builds the full sequence against real git — `git init` -> commit -> (later) attach remote -> push -> push again — never a clone, since a clone arrives with upstream already configured and every defect this suite guards against is invisible there. */ + async function harnessWithLocalOnlyRepo() { + const root = realpathSync(mkdtempSync(join(tmpdir(), "rt-home-snapshot-localonly-"))); + createdRoots.push(root); + const repoDir = join(root, "user"); + const originDir = join(root, "origin.git"); + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-q", "-b", "main", repoDir]); + execFileSync("git", ["config", "user.email", "rt@example.test"], { cwd: repoDir }); + execFileSync("git", ["config", "user.name", "rt test"], { cwd: repoDir }); + writeFileSync(join(repoDir, "README.md"), "seed\n"); + execFileSync("git", ["add", "-A"], { cwd: repoDir }); + execFileSync("git", ["commit", "-q", "-m", "seed"], { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-q", originDir]); + + const db = openStateDb(join(root, "state.db"), "cli"); + const broadcastLog: { type: string; data: unknown }[] = []; + const calls: string[][] = []; + const exec: NonNullable = async (argv, opts) => { + calls.push([...argv]); + return runCapture(argv, opts); + }; + + const handle = startHomeSnapshot({ + log: fakeLog(), + broadcast: (type, data) => broadcastLog.push({ type, data }), + repoDir, + db, + exec, + readSettings: () => LOCAL_ONLY_SETTINGS, + readOwners: () => NO_OWNERS, + }); + await handle.ready; + + const settle = () => new Promise((resolve) => setTimeout(resolve, PUSH_SETTLE_MS)); + + return { + async writeFile(relPath: string, content: string): Promise { + const target = join(root, relPath); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, content); + }, + async runCycles(n: number): Promise { + for (let i = 0; i < n; i++) { + await handle.runNow("watch"); + await settle(); + } + }, + async janitorTick(): Promise { + await handle.runNow("janitor"); + await settle(); + }, + async attachRemote(): Promise { + execFileSync("git", ["remote", "add", "origin", originDir], { cwd: repoDir }); + }, + execCalls: () => calls, + commits: () => broadcastLog.filter((b) => b.type === "home:snapshot"), + broadcasts: (type: string) => broadcastLog.filter((b) => b.type === type).map((b) => b.data), + stop: () => handle.stop(), + }; + } + + test("no remote: commits, never pushes, never broadcasts a failure", async () => { + const h = await harnessWithLocalOnlyRepo(); + await h.writeFile("user/settings.user.jsonc", "{}"); + await h.runCycles(3); + expect(h.commits().length).toBeGreaterThan(0); + expect(h.execCalls().filter((c) => c[1] === "push")).toEqual([]); + expect(h.broadcasts("home:push-failed")).toEqual([]); + h.stop(); + }, 15_000); + + test("a freshly attached remote arms a push with no new commit", async () => { + const h = await harnessWithLocalOnlyRepo(); + await h.writeFile("user/a", "1"); + await h.runCycles(1); // commits locally, no push + await h.attachRemote(); // git remote add origin + await h.janitorTick(); // no file change + expect(h.execCalls().filter((c) => c[1] === "push").length).toBe(1); + h.stop(); + }, 15_000); + + test("second push only fires when there is something ahead of the ref", async () => { + const h = await harnessWithLocalOnlyRepo(); + await h.attachRemote(); + await h.writeFile("user/a", "1"); + await h.runCycles(1); // first push + await h.janitorTick(); // nothing new + expect(h.execCalls().filter((c) => c[1] === "push").length).toBe(1); + h.stop(); + }, 15_000); +}); diff --git a/lib/daemon/home-snapshot.ts b/lib/daemon/home-snapshot.ts index a603d7b4..59f12312 100644 --- a/lib/daemon/home-snapshot.ts +++ b/lib/daemon/home-snapshot.ts @@ -155,6 +155,31 @@ function redactCredentials(text: string): string { return text.replace(/:\/\/[^/@\s]+@/g, "://@"); } +/** `git remote` prints nothing (exit 0) once no remote is configured — the same signal a spawn/exec failure produces, so both read as "no remote" here rather than a push ever being attempted against a broken git. */ +async function hasRemote(exec: ExecFn, cwd: string): Promise { + const result = await exec(["git", "remote"], { cwd, timeoutMs: GIT_TIMEOUT_MS, stderr: "pipe" }); + return result.exitCode === 0 && result.stdout.trim().length > 0; +} + +/** + * Compares against `refs/remotes/origin/` directly — never `@{u}`. + * A repo `git init`-ed locally and given a remote later has no + * `branch..remote` configured, so `@{u}` exits 128 even though the + * remote-tracking ref itself exists. A missing ref means everything is + * unpushed (an absent ref is FATAL to `rev-list`, not empty), so its + * absence is checked explicitly before ever calling `rev-list` against it. + */ +async function unpushedAgainstOrigin(exec: ExecFn, cwd: string): Promise { + const branchResult = await exec(["git", "symbolic-ref", "--short", "HEAD"], { cwd, timeoutMs: GIT_TIMEOUT_MS, stderr: "pipe" }); + if (branchResult.exitCode !== 0) return false; // detached HEAD: never green, never arm + const branch = branchResult.stdout.trim(); + const ref = `refs/remotes/origin/${branch}`; + const hasRef = await exec(["git", "rev-parse", "--verify", "-q", ref], { cwd, timeoutMs: GIT_TIMEOUT_MS, stderr: "pipe" }); + if (hasRef.exitCode !== 0) return true; // no remote-tracking ref yet: everything is unpushed + const ahead = await exec(["git", "rev-list", `${ref}..HEAD`], { cwd, timeoutMs: GIT_TIMEOUT_MS, stderr: "pipe" }); + return ahead.exitCode === 0 && ahead.stdout.trim().length > 0; +} + const HOME_SNAPSHOT_NS = "home-snapshot"; const HOME_SNAPSHOT_KEY = "state"; @@ -455,6 +480,12 @@ export function startHomeSnapshot(rawDeps: HomeSnapshotDeps): HomeSnapshotHandle deps.log.debug("home-snapshot: disabled via rt.homeSnapshot.enabled=false; skipping a due push"); return; } + // Local-only (rt home init with no remote attached) is a permanent, + // supported state — not a push failure: no exec, no retry, no broadcast. + if (!(await hasRemote(deps.exec, deps.repoDir))) { + deps.log.debug("home-snapshot: no remote configured; nothing to push"); + return; + } const result = await deps.exec(["git", "push", "-q", "origin", "HEAD"], { cwd: deps.repoDir, timeoutMs: PUSH_TIMEOUT_MS, @@ -669,7 +700,14 @@ export function startHomeSnapshot(rawDeps: HomeSnapshotDeps): HomeSnapshotHandle } } - if (committed || pushPending) schedulePush(); + if (committed || pushPending) { + schedulePush(); + } else if (await hasRemote(deps.exec, deps.repoDir) && await unpushedAgainstOrigin(deps.exec, deps.repoDir)) { + // The only path that notices a remote attached by hand after commits + // already existed — nothing else this cycle sets `committed` or + // `pushPending` for a run that made no local changes. + schedulePush(); + } if (!committed && plan.autoPaths.length === 0 && plan.janitorZones.length === 0) { return { committed: false, sha: null, paths: [], reason, skipped: "no-changes" }; From fdad477f09bb2e655be95e05f20770eba171fd19 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sun, 23 Aug 2026 17:58:52 -0500 Subject: [PATCH 12/15] fix(daemon): unborn-branch push hazard, latched push state on no-remote, rev-list --count Co-Authored-By: Claude Fable 5 --- lib/daemon/__tests__/home-snapshot.test.ts | 85 +++++++++++++++++----- lib/daemon/home-snapshot.ts | 29 +++++++- 2 files changed, 93 insertions(+), 21 deletions(-) diff --git a/lib/daemon/__tests__/home-snapshot.test.ts b/lib/daemon/__tests__/home-snapshot.test.ts index c3e77694..033539c2 100644 --- a/lib/daemon/__tests__/home-snapshot.test.ts +++ b/lib/daemon/__tests__/home-snapshot.test.ts @@ -1571,31 +1571,80 @@ describe("startHomeSnapshot — local-only remote state", () => { test("no remote: commits, never pushes, never broadcasts a failure", async () => { const h = await harnessWithLocalOnlyRepo(); - await h.writeFile("user/settings.user.jsonc", "{}"); - await h.runCycles(3); - expect(h.commits().length).toBeGreaterThan(0); - expect(h.execCalls().filter((c) => c[1] === "push")).toEqual([]); - expect(h.broadcasts("home:push-failed")).toEqual([]); - h.stop(); + try { + await h.writeFile("user/settings.user.jsonc", "{}"); + await h.runCycles(3); + expect(h.commits().length).toBeGreaterThan(0); + expect(h.execCalls().filter((c) => c[1] === "push")).toEqual([]); + expect(h.broadcasts("home:push-failed")).toEqual([]); + } finally { + h.stop(); + } }, 15_000); test("a freshly attached remote arms a push with no new commit", async () => { const h = await harnessWithLocalOnlyRepo(); - await h.writeFile("user/a", "1"); - await h.runCycles(1); // commits locally, no push - await h.attachRemote(); // git remote add origin - await h.janitorTick(); // no file change - expect(h.execCalls().filter((c) => c[1] === "push").length).toBe(1); - h.stop(); + try { + await h.writeFile("user/a", "1"); + await h.runCycles(1); // commits locally, no push + await h.attachRemote(); // git remote add origin + await h.janitorTick(); // no file change + expect(h.execCalls().filter((c) => c[1] === "push").length).toBe(1); + } finally { + h.stop(); + } }, 15_000); test("second push only fires when there is something ahead of the ref", async () => { const h = await harnessWithLocalOnlyRepo(); - await h.attachRemote(); - await h.writeFile("user/a", "1"); - await h.runCycles(1); // first push - await h.janitorTick(); // nothing new - expect(h.execCalls().filter((c) => c[1] === "push").length).toBe(1); - h.stop(); + try { + await h.attachRemote(); + await h.writeFile("user/a", "1"); + await h.runCycles(1); // first push + await h.janitorTick(); // nothing new + expect(h.execCalls().filter((c) => c[1] === "push").length).toBe(1); + } finally { + h.stop(); + } + }, 15_000); + + test("remote attached, zero commits (unborn branch): no push armed, no push-failed", async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "rt-home-snapshot-unborn-"))); + createdRoots.push(root); + const repoDir = join(root, "user"); + const originDir = join(root, "origin.git"); + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-q", "-b", "main", repoDir]); + execFileSync("git", ["config", "user.email", "rt@example.test"], { cwd: repoDir }); + execFileSync("git", ["config", "user.name", "rt test"], { cwd: repoDir }); + execFileSync("git", ["init", "--bare", "-q", originDir]); + execFileSync("git", ["remote", "add", "origin", originDir], { cwd: repoDir }); + + const db = openStateDb(join(root, "state.db"), "cli"); + const broadcastLog: { type: string; data: unknown }[] = []; + const calls: string[][] = []; + const exec: NonNullable = async (argv, opts) => { + calls.push([...argv]); + return runCapture(argv, opts); + }; + const handle = startHomeSnapshot({ + log: fakeLog(), + broadcast: (type, data) => broadcastLog.push({ type, data }), + repoDir, + db, + exec, + readSettings: () => LOCAL_ONLY_SETTINGS, + readOwners: () => NO_OWNERS, + }); + try { + await handle.ready; + await handle.runNow("janitor"); // no files, nothing to auto-commit — the branch this is actually testing + await new Promise((resolve) => setTimeout(resolve, PUSH_SETTLE_MS)); + + expect(calls.filter((c) => c[1] === "push")).toEqual([]); + expect(broadcastLog.filter((b) => b.type === "home:push-failed")).toEqual([]); + } finally { + handle.stop(); + } }, 15_000); }); diff --git a/lib/daemon/home-snapshot.ts b/lib/daemon/home-snapshot.ts index 59f12312..3f80f30f 100644 --- a/lib/daemon/home-snapshot.ts +++ b/lib/daemon/home-snapshot.ts @@ -155,7 +155,15 @@ function redactCredentials(text: string): string { return text.replace(/:\/\/[^/@\s]+@/g, "://@"); } -/** `git remote` prints nothing (exit 0) once no remote is configured — the same signal a spawn/exec failure produces, so both read as "no remote" here rather than a push ever being attempted against a broken git. */ +/** + * True only when `git remote` succeeds and lists at least one name. An + * exec failure (spawn error, `GIT_TIMEOUT_MS` kill — `exitCode: -1`) also + * reads as "no remote" here, same end result as a healthy repo with none + * configured but via a different exit code, not the same signal. The two + * are deliberately NOT distinguished for now: a broken/timed-out git + * currently goes quiet (debug log, no push attempt) instead of raising + * `home:push-failed` the way an actual push attempt would. + */ async function hasRemote(exec: ExecFn, cwd: string): Promise { const result = await exec(["git", "remote"], { cwd, timeoutMs: GIT_TIMEOUT_MS, stderr: "pipe" }); return result.exitCode === 0 && result.stdout.trim().length > 0; @@ -168,16 +176,25 @@ async function hasRemote(exec: ExecFn, cwd: string): Promise { * remote-tracking ref itself exists. A missing ref means everything is * unpushed (an absent ref is FATAL to `rev-list`, not empty), so its * absence is checked explicitly before ever calling `rev-list` against it. + * + * An unborn branch (a remote attached before the first commit ever landed + * — e.g. `git commit` failing outright with no `user.name`/`user.email` + * configured) prints its branch name via `symbolic-ref` just fine, exit 0, + * same as a normal branch — HEAD itself must be verified separately, or + * this arms a `git push` with nothing to push ("src refspec HEAD does not + * match any"), which fails every time and drives a retry storm. */ async function unpushedAgainstOrigin(exec: ExecFn, cwd: string): Promise { const branchResult = await exec(["git", "symbolic-ref", "--short", "HEAD"], { cwd, timeoutMs: GIT_TIMEOUT_MS, stderr: "pipe" }); if (branchResult.exitCode !== 0) return false; // detached HEAD: never green, never arm + const headResult = await exec(["git", "rev-parse", "--verify", "-q", "HEAD"], { cwd, timeoutMs: GIT_TIMEOUT_MS, stderr: "pipe" }); + if (headResult.exitCode !== 0) return false; // unborn branch: no commits yet, nothing to push const branch = branchResult.stdout.trim(); const ref = `refs/remotes/origin/${branch}`; const hasRef = await exec(["git", "rev-parse", "--verify", "-q", ref], { cwd, timeoutMs: GIT_TIMEOUT_MS, stderr: "pipe" }); if (hasRef.exitCode !== 0) return true; // no remote-tracking ref yet: everything is unpushed - const ahead = await exec(["git", "rev-list", `${ref}..HEAD`], { cwd, timeoutMs: GIT_TIMEOUT_MS, stderr: "pipe" }); - return ahead.exitCode === 0 && ahead.stdout.trim().length > 0; + const ahead = await exec(["git", "rev-list", "--count", `${ref}..HEAD`], { cwd, timeoutMs: GIT_TIMEOUT_MS, stderr: "pipe" }); + return ahead.exitCode === 0 && Number(ahead.stdout.trim()) > 0; } const HOME_SNAPSHOT_NS = "home-snapshot"; @@ -482,8 +499,14 @@ export function startHomeSnapshot(rawDeps: HomeSnapshotDeps): HomeSnapshotHandle } // Local-only (rt home init with no remote attached) is a permanent, // supported state — not a push failure: no exec, no retry, no broadcast. + // Clearing pushPending/lastPushError here matters for a remote that + // existed, failed to push, and was then removed by hand — without this, + // a stale failure latches into status() forever and `committed || + // pushPending` re-arms a push every cycle that only ever no-ops here. if (!(await hasRemote(deps.exec, deps.repoDir))) { deps.log.debug("home-snapshot: no remote configured; nothing to push"); + pushPending = false; + lastPushError = null; return; } const result = await deps.exec(["git", "push", "-q", "origin", "HEAD"], { From d1c01291caeddca1186b9bb22eea9e32f07d2576 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sun, 23 Aug 2026 18:16:11 -0500 Subject: [PATCH 13/15] feat(setup): home.backup probe, and snapshot.push stops implying a push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Green means a push actually happened, read from git's own remote-tracking ref (never @{u}) — never merely that a remote is configured. Co-Authored-By: Claude Fable 5 --- lib/setup/__tests__/steps-c.test.ts | 14 +- .../__tests__/validators-rt-health.test.ts | 143 +++++++++++++++++- lib/setup/home-git.ts | 66 ++++++++ lib/setup/steps/tools.ts | 10 +- lib/setup/validators/rt-health.ts | 52 ++++++- 5 files changed, 278 insertions(+), 7 deletions(-) create mode 100644 lib/setup/home-git.ts diff --git a/lib/setup/__tests__/steps-c.test.ts b/lib/setup/__tests__/steps-c.test.ts index 6e229012..8b752ea1 100644 --- a/lib/setup/__tests__/steps-c.test.ts +++ b/lib/setup/__tests__/steps-c.test.ts @@ -499,10 +499,20 @@ describe("apply steps C: plugins, fast-browser, herdr, extension, services.start // ─── snapshot.push ──────────────────────────────────────────────────────── describe("snapshot.push", () => { - test("daemon reachable, commits -> done with the short sha", async () => { + test("daemon reachable, commits, no remote -> done, honest that nothing was pushed", async () => { const p = fakeProbes({ home, daemon: async () => ({ ok: true, data: { committed: true, sha: "abcdef1234567890", paths: ["a"], reason: "manual" } }) }); const outcome = await snapshotPushStep.run(makeCtx(p).ctx); - expect(outcome).toEqual({ state: "done", detail: "committed abcdef12" }); + expect(outcome).toEqual({ state: "done", detail: "committed abcdef12 locally — no remote, nothing pushed" }); + }); + + test("daemon reachable, commits, remote attached -> done, defers the push claim to the daemon's next cycle (never asserts a push happened)", async () => { + const p = fakeProbes({ + home, + daemon: async () => ({ ok: true, data: { committed: true, sha: "abcdef1234567890", paths: ["a"], reason: "manual" } }), + exec: async (argv) => (argv[0] === "git" && argv[1] === "remote" ? ok("origin\n") : ok("")), + }); + const outcome = await snapshotPushStep.run(makeCtx(p).ctx); + expect(outcome).toEqual({ state: "done", detail: "committed abcdef12 — push follows on the daemon's next cycle" }); }); test("idempotent re-run: two independent triggers each call the daemon again — nothing memoized between runs", async () => { diff --git a/lib/setup/__tests__/validators-rt-health.test.ts b/lib/setup/__tests__/validators-rt-health.test.ts index 1492706d..051375b7 100644 --- a/lib/setup/__tests__/validators-rt-health.test.ts +++ b/lib/setup/__tests__/validators-rt-health.test.ts @@ -1,13 +1,15 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs"; +import { afterEach, afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"; +import { execFileSync } from "child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; import { dirname, join } from "path"; import { DAEMON_CONFIG_PATH } from "../../daemon-config.ts"; import { LOGIN_ITEMS_SETTINGS_ACTION } from "../permissions.ts"; import { setSetting } from "../../settings/write.ts"; -import { rtHealthRows } from "../validators/rt-health.ts"; +import { homeBackupRow, rtHealthRows } from "../validators/rt-health.ts"; import { fakeProbes, ok, missing } from "./fakes.ts"; import type { ExecScript } from "./fakes.ts"; +import { createRealProbes } from "../probes.ts"; import type { Probes } from "../probes.ts"; // Every test that isn't specifically exercising tool.fzf uses this: a real @@ -34,6 +36,7 @@ const ROW_ORDER = [ "tool.extension", "tool.shell", "tool.daemon", + "home.backup", ]; async function pickRow(rowsP: ReturnType, id: string) { @@ -505,3 +508,137 @@ describe("rtHealthRows — tool.daemon", () => { expect(r.detail).not.toContain("not registered with launchd"); }); }); + +/** + * Real git, never a fake exec script — this is exactly the `@{u}` trap + * home-snapshot.test.ts guards against: a clone configures upstream and + * would pass even against a broken `@{u}` implementation, so every repo + * here is built by hand (`git init` -> commit -> attach remote -> push). + */ +describe("rtHealthRows — home.backup (real git)", () => { + const createdRoots: string[] = []; + afterAll(() => { + for (const root of createdRoots) { + try { rmSync(root, { recursive: true, force: true }); } catch { /* best-effort cleanup */ } + } + }); + + function initRepo(repoDir: string): void { + mkdirSync(repoDir, { recursive: true }); + execFileSync("git", ["init", "-q", "-b", "main", repoDir]); + execFileSync("git", ["config", "user.email", "rt@example.test"], { cwd: repoDir }); + execFileSync("git", ["config", "user.name", "rt test"], { cwd: repoDir }); + } + + function freshRepoDir(prefix: string): string { + const root = realpathSync(mkdtempSync(join(tmpdir(), prefix))); + createdRoots.push(root); + const repoDir = join(root, "user"); + initRepo(repoDir); + return repoDir; + } + + async function commit(repoDir: string, message: string): Promise { + writeFileSync(join(repoDir, `${message}.txt`), message); + execFileSync("git", ["add", "-A"], { cwd: repoDir }); + execFileSync("git", ["commit", "-q", "-m", message], { cwd: repoDir }); + } + + async function localOnlyRepo(): Promise { + const repoDir = freshRepoDir("rt-health-backup-localonly-"); + await commit(repoDir, "seed"); + return repoDir; + } + + async function attachRemote(repoDir: string): Promise { + const originDir = join(dirname(repoDir), "origin.git"); + execFileSync("git", ["init", "--bare", "-q", originDir]); + execFileSync("git", ["remote", "add", "origin", originDir], { cwd: repoDir }); + } + + /** `git init` -> commit -> `git remote add` -> `git push origin HEAD` — never a clone (a clone arrives with upstream configured and would mask a broken `@{u}` implementation). */ + async function pushedRepo(): Promise { + const repoDir = await localOnlyRepo(); + await attachRemote(repoDir); + execFileSync("git", ["push", "-q", "origin", "HEAD"], { cwd: repoDir }); + return repoDir; + } + + test("no remote: needs-you, not skipped — skipped renders as info and shows no warning", async () => { + const row = await homeBackupRow(await localOnlyRepo()); + expect(row.status).toBe("needs-you"); + expect(row.required).toBe(false); + expect(row.detail).toBe("local only — your settings are versioned on this machine but are not backed up anywhere"); + expect(row.action).not.toBeNull(); + }); + + test("remote attached but never pushed: needs-you, not ready", async () => { + const repo = await localOnlyRepo(); + await attachRemote(repo); + const row = await homeBackupRow(repo); + expect(row.status).toBe("needs-you"); + expect(row.detail).toBe("remote configured, nothing pushed yet"); + }); + + test("commits ahead of the ref: needs-you", async () => { + const repo = await pushedRepo(); + await commit(repo, "later"); + const row = await homeBackupRow(repo); + expect(row.status).toBe("needs-you"); + expect(row.detail).toBe("1 commit(s) not pushed"); + }); + + test("pushed and nothing ahead: ready", async () => { + const row = await homeBackupRow(await pushedRepo()); + expect(row.status).toBe("ready"); + expect(row.detail).toStartWith("last pushed "); + expect(row.action).toBeNull(); + }); + + test("unborn branch (remote attached before any commit ever landed): needs-you, never crashes on a missing ref", async () => { + const repoDir = freshRepoDir("rt-health-backup-unborn-"); + await attachRemote(repoDir); + const row = await homeBackupRow(repoDir); + expect(row.status).toBe("needs-you"); + expect(row.detail).toBe("remote configured, nothing pushed yet"); + }); + + test("no home repo at this path yet: needs-you, never claims settings are versioned when there's nothing there", async () => { + const dir = realpathSync(mkdtempSync(join(tmpdir(), "rt-health-backup-norepo-"))); + createdRoots.push(dir); + const row = await homeBackupRow(dir); + expect(row.status).toBe("needs-you"); + expect(row.detail).toBe("no home repo found yet — nothing to back up"); + }); + + test("rev-list check fails (timeout/corrupt store): needs-you, could-not-determine — never falls through to ready on evidence that never arrived", async () => { + const repo = await pushedRepo(); + const realExec = createRealProbes().exec; + const flakyExec: Probes["exec"] = async (argv, opts) => { + if (argv[0] === "git" && argv[1] === "rev-list") return { code: 128, stdout: "", stderr: "fatal: bad object" }; + return realExec(argv, opts); + }; + const row = await homeBackupRow(repo, flakyExec); + expect(row.status).toBe("needs-you"); + expect(row.detail).toBe("could not determine push status — the rev-list check failed"); + }); + + test("rtHealthRows wires home.backup off p.home/.mattstack/user, not p.home/user — catches a dropped .mattstack segment", async () => { + const root = realpathSync(mkdtempSync(join(tmpdir(), "rt-health-backup-wiring-"))); + createdRoots.push(root); + const repoDir = join(root, ".mattstack", "user"); + initRepo(repoDir); + await commit(repoDir, "seed"); + await attachRemote(repoDir); + execFileSync("git", ["push", "-q", "origin", "HEAD"], { cwd: repoDir }); + + const rows = await rtHealthRows(fakeProbes({ home: root, exec: createRealProbes().exec }), { ci: false }, NOOP_FZF); + const r = rows.find((x) => x.id === "home.backup"); + expect(r).toBeDefined(); + // A path join that drops ".mattstack" (or joins nothing at all) points + // at a directory that never exists, which reads as "needs-you" — only + // the correct join lands on the real, pushed repo built above. + expect(r?.status).toBe("ready"); + expect(r?.required).toBe(false); + }); +}); diff --git a/lib/setup/home-git.ts b/lib/setup/home-git.ts new file mode 100644 index 00000000..cc5260ef --- /dev/null +++ b/lib/setup/home-git.ts @@ -0,0 +1,66 @@ +/** + * Setup-layer git helpers for the home repo's push state. + * + * A near-duplicate of lib/daemon/home-snapshot.ts's `hasRemote` / + * `unpushedAgainstOrigin` — typed against Probes["exec"] (result field + * `code`, not runCapture's `exitCode`) instead of the daemon's own exec + * convention. A setup validator/step must not import from lib/daemon/, so + * this is a deliberate second copy, not a shared cross-layer module. + */ + +import type { Probes } from "./probes.ts"; + +const GIT_TIMEOUT_MS = 15_000; + +/** True only when `git rev-parse --is-inside-work-tree` succeeds and says so — distinct from "no remote configured", which presupposes a repo exists at all. */ +export async function isGitRepo(exec: Probes["exec"], cwd: string): Promise { + const result = await exec(["git", "rev-parse", "--is-inside-work-tree"], { cwd, timeoutMs: GIT_TIMEOUT_MS }); + return result.code === 0 && result.stdout.trim() === "true"; +} + +/** True only when `git remote` succeeds and lists at least one name. An exec failure (missing git, timeout) also reads as "no remote" — same end result as a healthy repo with none configured. */ +export async function hasRemote(exec: Probes["exec"], cwd: string): Promise { + const result = await exec(["git", "remote"], { cwd, timeoutMs: GIT_TIMEOUT_MS }); + return result.code === 0 && result.stdout.trim().length > 0; +} + +export type OriginPushState = { kind: "no-ref" } | { kind: "ahead"; count: number } | { kind: "up-to-date"; committedAt: Date | null } | { kind: "unknown" }; + +/** + * Compares against `refs/remotes/origin/` directly — never `@{u}`. + * A repo `git init`-ed locally and given a remote later has no + * `branch..remote` configured, so `@{u}` exits 128 even though the + * remote-tracking ref itself exists. A missing ref means everything is + * unpushed (an absent ref is FATAL to `rev-list`, not empty), so its + * existence is checked before it is ever compared against. + * + * An unborn branch (no commit has ever landed — e.g. `git commit` failing + * outright with no `user.name`/`user.email` configured) still prints its + * branch name via `symbolic-ref`, exit 0 — folded into "no-ref" here rather + * than a distinct case, since either way nothing has been confirmed pushed. + */ +export async function originPushState(exec: Probes["exec"], cwd: string): Promise { + const branchResult = await exec(["git", "symbolic-ref", "--short", "HEAD"], { cwd, timeoutMs: GIT_TIMEOUT_MS }); + if (branchResult.code !== 0) return { kind: "no-ref" }; // detached HEAD + + const headResult = await exec(["git", "rev-parse", "--verify", "-q", "HEAD"], { cwd, timeoutMs: GIT_TIMEOUT_MS }); + if (headResult.code !== 0) return { kind: "no-ref" }; // unborn branch: no commits yet + + const branch = branchResult.stdout.trim(); + const ref = `refs/remotes/origin/${branch}`; + const hasRef = await exec(["git", "rev-parse", "--verify", "-q", ref], { cwd, timeoutMs: GIT_TIMEOUT_MS }); + if (hasRef.code !== 0) return { kind: "no-ref" }; // no remote-tracking ref yet: everything is unpushed + + // A non-zero exit (timeout, corrupt object store, permissions) must never + // fall through to "up-to-date": that would render `ready` on evidence + // that never arrived, the exact outcome this row exists to prevent. + const ahead = await exec(["git", "rev-list", "--count", `${ref}..HEAD`], { cwd, timeoutMs: GIT_TIMEOUT_MS }); + if (ahead.code !== 0) return { kind: "unknown" }; + const count = Number(ahead.stdout.trim()); + if (!Number.isFinite(count)) return { kind: "unknown" }; + if (count > 0) return { kind: "ahead", count }; + + const log = await exec(["git", "log", "-1", "--format=%cI", ref], { cwd, timeoutMs: GIT_TIMEOUT_MS }); + const committedAt = log.code === 0 && log.stdout.trim() ? new Date(log.stdout.trim()) : null; + return { kind: "up-to-date", committedAt }; +} diff --git a/lib/setup/steps/tools.ts b/lib/setup/steps/tools.ts index 5ff6ae03..3a877028 100644 --- a/lib/setup/steps/tools.ts +++ b/lib/setup/steps/tools.ts @@ -7,11 +7,13 @@ * fresh machine reads as `skipped`, not `failed`. */ +import { join } from "path"; import { resolveTool } from "../../deps/resolve.ts"; import type { SnapshotResult } from "../../daemon/home-snapshot.ts"; import { withoutUrls } from "../../team/redact.ts"; import type { ApplyContext } from "../apply.ts"; import type { StepDef, StepOutcome } from "../apply.ts"; +import { hasRemote } from "../home-git.ts"; import type { Probes } from "../probes.ts"; import { claudeConfigDirs, NO_EDITORS_DETAIL, setupTool, VSIX_NOT_FOUND_DETAIL, type ToolsInstallSeams } from "../tools-install.ts"; import { toFailedOutcome } from "./step-utils.ts"; @@ -192,7 +194,13 @@ async function snapshotPushRun(ctx: ApplyContext): Promise { const result = reply.data as SnapshotResult | undefined; if (result?.skipped) return { state: "skipped", detail: `snapshot skipped: ${result.skipped}` }; if (!result?.committed) return { state: "done", detail: "no changes to snapshot" }; - return { state: "done", detail: `committed ${result.sha ? result.sha.slice(0, 8) : "(no sha)"}` }; + + const sha = result.sha ? result.sha.slice(0, 8) : "(no sha)"; + // This step only ever observes the daemon's commit, never a push — the + // daemon pushes async on its own delay, and home.backup (not this step) + // is the row that confirms whether a push actually landed. + const remote = await hasRemote(ctx.p.exec, join(ctx.p.home, ".mattstack", "user")); + return { state: "done", detail: remote ? `committed ${sha} — push follows on the daemon's next cycle` : `committed ${sha} locally — no remote, nothing pushed` }; } async function snapshotPushRunSafe(ctx: ApplyContext): Promise { diff --git a/lib/setup/validators/rt-health.ts b/lib/setup/validators/rt-health.ts index 20476acb..bf1e2c33 100644 --- a/lib/setup/validators/rt-health.ts +++ b/lib/setup/validators/rt-health.ts @@ -18,8 +18,9 @@ import { resolveFzf } from "../../fzf.ts"; import { legacyDirsPresent, legacyTrayAppPaths, RT_DIR_LABEL } from "../../rt-paths.ts"; import { detectShellFrom, shellRcPathFor } from "../../shell-integration.ts"; import { row, type Action, type Row } from "../contract.ts"; +import { hasRemote, isGitRepo, originPushState } from "../home-git.ts"; import { LOGIN_ITEMS_SETTINGS_ACTION } from "../permissions.ts"; -import type { Probes } from "../probes.ts"; +import { createRealProbes, type Probes } from "../probes.ts"; // ─── rt-context extension check (moved from commands/verify.ts) ────────────── @@ -71,6 +72,7 @@ export interface RtHealthSeams { } const REAL_SEAMS: RtHealthSeams = { resolveFzf }; +const REAL_EXEC: Probes["exec"] = createRealProbes().exec; // ─── row builders ────────────────────────────────────────────────────────── @@ -78,6 +80,8 @@ const LINK_BUNDLED_RT: Action = { type: "link-bundled", label: "Use mattstack's" const LINK_BUNDLED_FZF: Action = { type: "link-bundled", label: "Use mattstack's", tool: "fzf" }; const REINSTALL_SHIMS_ACTION: Action = { type: "run", label: "Re-install shims", verb: ["intercept", "install"] }; const INSTALL_EXTENSION_ACTION: Action = { type: "run", label: "Install extension", verb: ["tools", "setup", "extension"] }; +/** No `rt home remote set` verb exists yet (installer-lane scope), so the remedy names the raw git command instead of a `run` action. */ +const HOME_BACKUP_ADD_REMOTE_ACTION: Action = { type: "steps", label: "Show steps…", steps: ["git -C ~/.mattstack/user remote add origin "] }; const MERGE_LEGACY_STATE_ACTION: Action = { type: "steps", label: "Merge legacy state", @@ -336,6 +340,51 @@ async function daemonRow(p: Probes, opts: { ci: boolean }): Promise { return row({ ...base, status: "ready", detail: parts.join(", ") }); } +/** Wall-clock, not an injected `now()` — this row takes a bare `exec`, not a full Probes, so there is no seam to inject. */ +function relativeWhen(committedAt: Date | null): string { + if (!committedAt) return "recently"; + const mins = Math.floor((Date.now() - committedAt.getTime()) / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +/** + * Green means a push actually happened, never merely that a remote is + * configured — read from git's own remote-tracking ref, so this is right on + * a machine where the daemon has never run and right when the user pushed + * by hand. Takes a bare `repoDir` + `exec` (not the full `Probes`) so it can + * be pointed at a real git repo directly, independent of the OS `$HOME` a + * full `Probes` carries. + */ +export async function homeBackupRow(repoDir: string, exec: Probes["exec"] = REAL_EXEC): Promise { + const base = { + id: "home.backup", + kind: "tool" as const, + title: "Home repo backup", + why: "Local-only is fully supported — this only confirms whether your settings are actually backed up anywhere, not just committed on this machine.", + required: false, + optionalNote: "Works without this; local-only just means this machine is the only copy of your settings.", + recheck: "on-activate" as const, + }; + + if (!(await isGitRepo(exec, repoDir))) { + return row({ ...base, status: "needs-you", detail: "no home repo found yet — nothing to back up" }); + } + + if (!(await hasRemote(exec, repoDir))) { + return row({ ...base, status: "needs-you", detail: "local only — your settings are versioned on this machine but are not backed up anywhere", action: HOME_BACKUP_ADD_REMOTE_ACTION }); + } + + const state = await originPushState(exec, repoDir); + if (state.kind === "no-ref") return row({ ...base, status: "needs-you", detail: "remote configured, nothing pushed yet" }); + if (state.kind === "ahead") return row({ ...base, status: "needs-you", detail: `${state.count} commit(s) not pushed` }); + if (state.kind === "unknown") return row({ ...base, status: "needs-you", detail: "could not determine push status — the rev-list check failed" }); + return row({ ...base, status: "ready", detail: `last pushed ${relativeWhen(state.committedAt)}` }); +} + // ─── entry point ──────────────────────────────────────────────────────────── export async function rtHealthRows(p: Probes, opts: { ci: boolean }, seams: RtHealthSeams = REAL_SEAMS): Promise { @@ -350,5 +399,6 @@ export async function rtHealthRows(p: Probes, opts: { ci: boolean }, seams: RtHe extensionRow(p), shellRow(p), await daemonRow(p, opts), + await homeBackupRow(join(p.home, ".mattstack", "user"), p.exec), ]; } From 33ae1bcd37efb2cbb01a83798b483c954a8cc622 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sun, 23 Aug 2026 19:01:35 -0500 Subject: [PATCH 14/15] RT: home-repo-local-first final review fixes Intent rung: `rt home init` read setup-intent.json from mattstackHome(), but intentPath() appends `.mattstack` itself, so the read landed one level too deep and RT_HOME_URL always won. Pass the OS home, matching every other caller, and cover it with a non-seamed test. Setup step: the `rt home init` failure remedy told users to run `gh auth login` even when the failure was a local `git init`/`commit` that contacted no host. Reserve that remedy for auth-shaped stderr. home.backup probe: report unborn repos honestly whether or not a remote is attached; treat only `origin` as a remote, since every push and ref comparison downstream is origin-only; carry the push step in the remedy; drop the module-load `createRealProbes()` that captured $HOME at construction. Daemon: same origin-only remote check, and the hand-attached-remote detection moves to the janitor tick so a no-op watch debounce stops paying five git spawns. Also: `RT_HOME_URL=""` is unset rather than a clone of "", `restore.homeRepo` is honoured, the initial commit runs with signing off and tolerates an empty tree, and the generated `home` docs pages no longer name the deleted default. Co-Authored-By: Claude Fable 5 --- commands/__tests__/home.test.ts | 55 ++++++++++++++++++- commands/home.ts | 16 ++++-- lib/daemon/__tests__/home-snapshot.test.ts | 31 +++++++++++ lib/daemon/home-snapshot.ts | 20 +++---- lib/home/__tests__/init-exec.test.ts | 26 +++++++++ lib/home/init-exec.ts | 9 ++- lib/setup/__tests__/steps-a.test.ts | 11 ++++ .../__tests__/validators-rt-health.test.ts | 27 ++++++++- lib/setup/home-git.ts | 16 ++++-- lib/setup/probes.ts | 2 +- lib/setup/steps/home.ts | 13 ++++- lib/setup/validators/rt-health.ts | 26 +++++++-- website/docs/reference/home/index.mdx | 2 +- website/docs/reference/home/init.mdx | 4 +- 14 files changed, 221 insertions(+), 37 deletions(-) diff --git a/commands/__tests__/home.test.ts b/commands/__tests__/home.test.ts index e538408c..b7a16a93 100644 --- a/commands/__tests__/home.test.ts +++ b/commands/__tests__/home.test.ts @@ -33,9 +33,9 @@ import { readOwners } from "../../lib/home/snapshot-owners.ts"; import type { DaemonResponse } from "../../lib/daemon-client.ts"; import type { SnapshotResult, SnapshotStatus } from "../../lib/daemon/home-snapshot.ts"; import type { MaterializeEnv, MaterializeExecResult, MaterializeExecSeam } from "../../lib/home/materialize.ts"; -import { mkdtempSync, realpathSync, rmSync } from "fs"; +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "fs"; import { tmpdir } from "os"; -import { join } from "path"; +import { dirname, join } from "path"; const FAKE_PUBLIC_KEY = "age1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq"; const FAKE_PRIVATE_KEY = "AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ"; @@ -427,7 +427,7 @@ describe("homeInit", () => { const runCalls = seam.calls.filter((c) => c.kind === "run").map((c) => c.arg as string[]); expect(runCalls).toContainEqual(["git", "init", "-b", "main", "user"]); expect(runCalls).toContainEqual(["git", "-C", "user", "add", "-A"]); - expect(runCalls).toContainEqual(["git", "-C", "user", "commit", "-m", "initial home repo"]); + expect(runCalls).toContainEqual(["git", "-c", "commit.gpgsign=false", "-C", "user", "commit", "-m", "initial home repo"]); expect(runCalls.some((arg) => arg[1] === "clone")).toBe(false); }); @@ -1416,6 +1416,55 @@ describe("resolveHomeUrl", () => { test("--url with no value still throws rather than falling through to local-only", () => { expect(() => resolveHomeUrl(["--url"], { readIntent: () => null, env: {} })).toThrow(InvalidUrlArgError); }); + + test("an exported-but-empty RT_HOME_URL is unset, never a clone of \"\"", () => { + expect(resolveHomeUrl([], { readIntent: () => null, env: { RT_HOME_URL: "" } })).toBeNull(); + }); + + test("restore.homeRepo is honoured — otherwise a local-only repo squats the path home.restore needs", () => { + const url = resolveHomeUrl([], { + readIntent: () => ({ v: 1, at: "", mode: "restore", restore: { homeRepo: "https://x/restore.git" } }) as SetupIntent, + env: { RT_HOME_URL: "https://x/c.git" }, + }); + expect(url).toBe("https://x/restore.git"); + }); +}); + +/** + * Deliberately NOT seamed: the seamed tests above pass `readIntent` in + * directly and so cannot catch a default wired at the wrong path. + */ +describe("homeInit — the real intent read", () => { + test("reads the setup-intent.json that setup actually writes, so intent still outranks RT_HOME_URL", async () => { + const origHome = process.env.HOME; + const isolatedHome = realpathSync(mkdtempSync(join(tmpdir(), "rt-home-intent-"))); + process.env.HOME = isolatedHome; + try { + const intentFile = join(isolatedHome, ".mattstack", "rt", "setup-intent.json"); + mkdirSync(dirname(intentFile), { recursive: true }); + writeFileSync(intentFile, JSON.stringify({ v: 1, at: "", mode: "create", homeRepo: "https://x/from-intent.git" })); + + const probes = fakeProbes({ exists: (path) => path.endsWith("/machine-key") }); + const { logs } = await runHomeInit( + probes, + new FakeSeam(), + new FakeAgeKeySeam(), + ["--dry-run", "--no-materialize"], + new FakeSopsYamlSeam(), + KEY, + new UnreachablePickerSeam(), + () => false, + async () => NOOP_MATERIALIZE_ENV, + new FakeMaterializeExecSeam(), + { RT_HOME_URL: "https://x/from-env.git" }, + ); + + expect(logs.join("\n")).toContain("clone https://x/from-intent.git into user/"); + } finally { + process.env.HOME = origHome; + rmSync(isolatedHome, { recursive: true, force: true }); + } + }); }); describe("claudePluginsPointerMessage", () => { diff --git a/commands/home.ts b/commands/home.ts index 4f73bf97..287cc03d 100644 --- a/commands/home.ts +++ b/commands/home.ts @@ -33,6 +33,7 @@ */ import { existsSync, readdirSync, readFileSync, readlinkSync, statSync, writeFileSync } from "fs"; +import { homedir } from "os"; import { join } from "path"; import type { CommandContext } from "../lib/command-tree.ts"; import { bold, dim, green, red, reset, yellow } from "../lib/ansi.ts"; @@ -207,7 +208,10 @@ function parseUrlArg(args: string[]): string | null { /** * The precedence chain for which repo `rt home init` provisions: an explicit * `--url` beats the setup intent's `homeRepo` (set once, ahead of time, by - * `create`/`join`), which beats `RT_HOME_URL` (a per-invocation override). + * `create`/`join`, or under `restore.homeRepo` in restore mode — ignoring the + * restore rung would provision a local-only repo that then squats the path + * `home.restore` needs, unrecoverably), which beats `RT_HOME_URL` (a + * per-invocation override). * `null` means no rung supplied one — a deliberate, first-class outcome, not * a fallback to any repo this operator never chose. */ @@ -217,9 +221,11 @@ export function resolveHomeUrl( ): string | null { const fromFlag = parseUrlArg(args); if (fromFlag !== null) return fromFlag; - const fromIntent = seams.readIntent()?.homeRepo; + const intent = seams.readIntent(); + const fromIntent = intent?.homeRepo ?? intent?.restore?.homeRepo; if (fromIntent) return fromIntent; - return seams.env.RT_HOME_URL ?? null; + // An exported-but-empty RT_HOME_URL is "unset", never a clone of "". + return seams.env.RT_HOME_URL || null; } /** Thrown by parseProfileArg for a `--profile` with no usable value. */ @@ -559,7 +565,9 @@ export async function homeInit(args: string[], _ctx: CommandContext = {}, seams: return null; } }, - home: mattstackHome(), + // The OS home, not mattstackHome(): intentPath() appends `.mattstack` + // itself, and every writer (setup, team create/join) passes Probes.home. + home: process.env.HOME ?? homedir(), })); const env = seams.env ?? process.env; diff --git a/lib/daemon/__tests__/home-snapshot.test.ts b/lib/daemon/__tests__/home-snapshot.test.ts index 033539c2..bd762961 100644 --- a/lib/daemon/__tests__/home-snapshot.test.ts +++ b/lib/daemon/__tests__/home-snapshot.test.ts @@ -1562,6 +1562,9 @@ describe("startHomeSnapshot — local-only remote state", () => { async attachRemote(): Promise { execFileSync("git", ["remote", "add", "origin", originDir], { cwd: repoDir }); }, + async attachNonOriginRemote(): Promise { + execFileSync("git", ["remote", "add", "upstream", originDir], { cwd: repoDir }); + }, execCalls: () => calls, commits: () => broadcastLog.filter((b) => b.type === "home:snapshot"), broadcasts: (type: string) => broadcastLog.filter((b) => b.type === type).map((b) => b.data), @@ -1582,6 +1585,34 @@ describe("startHomeSnapshot — local-only remote state", () => { } }, 15_000); + test("a non-origin remote never arms a push: the push itself is origin-only", async () => { + const h = await harnessWithLocalOnlyRepo(); + try { + await h.writeFile("user/a", "1"); + await h.runCycles(1); + await h.attachNonOriginRemote(); + await h.janitorTick(); + expect(h.execCalls().filter((c) => c[1] === "push")).toEqual([]); + expect(h.broadcasts("home:push-failed")).toEqual([]); + } finally { + h.stop(); + } + }, 15_000); + + test("a watch cycle that commits nothing spawns no hand-attached-remote probes", async () => { + const h = await harnessWithLocalOnlyRepo(); + try { + await h.attachRemote(); + await h.janitorTick(); // pushes the seed commit, clearing the unpushed state + const before = h.execCalls().length; + await h.runCycles(1); // nothing changed on disk + const during = h.execCalls().slice(before); + expect(during.filter((c) => c[1] === "remote" || c[1] === "symbolic-ref" || c[1] === "rev-list")).toEqual([]); + } finally { + h.stop(); + } + }, 15_000); + test("a freshly attached remote arms a push with no new commit", async () => { const h = await harnessWithLocalOnlyRepo(); try { diff --git a/lib/daemon/home-snapshot.ts b/lib/daemon/home-snapshot.ts index 3f80f30f..fff34097 100644 --- a/lib/daemon/home-snapshot.ts +++ b/lib/daemon/home-snapshot.ts @@ -156,17 +156,14 @@ function redactCredentials(text: string): string { } /** - * True only when `git remote` succeeds and lists at least one name. An - * exec failure (spawn error, `GIT_TIMEOUT_MS` kill — `exitCode: -1`) also - * reads as "no remote" here, same end result as a healthy repo with none - * configured but via a different exit code, not the same signal. The two - * are deliberately NOT distinguished for now: a broken/timed-out git - * currently goes quiet (debug log, no push attempt) instead of raising - * `home:push-failed` the way an actual push attempt would. + * `origin` specifically, not any remote: the push below is `origin`-only, so + * an `upstream`-only repo would push to a remote that does not exist. An exec + * failure (spawn error, `GIT_TIMEOUT_MS` kill — `exitCode: -1`) also reads as + * "no remote", so a broken git goes quiet rather than attempting a push. */ async function hasRemote(exec: ExecFn, cwd: string): Promise { const result = await exec(["git", "remote"], { cwd, timeoutMs: GIT_TIMEOUT_MS, stderr: "pipe" }); - return result.exitCode === 0 && result.stdout.trim().length > 0; + return result.exitCode === 0 && result.stdout.split("\n").some((name) => name.trim() === "origin"); } /** @@ -725,10 +722,11 @@ export function startHomeSnapshot(rawDeps: HomeSnapshotDeps): HomeSnapshotHandle if (committed || pushPending) { schedulePush(); - } else if (await hasRemote(deps.exec, deps.repoDir) && await unpushedAgainstOrigin(deps.exec, deps.repoDir)) { + } else if (reason === "janitor" && (await hasRemote(deps.exec, deps.repoDir)) && (await unpushedAgainstOrigin(deps.exec, deps.repoDir))) { // The only path that notices a remote attached by hand after commits - // already existed — nothing else this cycle sets `committed` or - // `pushPending` for a run that made no local changes. + // already existed. Confined to the janitor tick: on the watch debounce + // these five git spawns would run on every no-op cycle, to detect a + // state that only ever changes by hand. schedulePush(); } diff --git a/lib/home/__tests__/init-exec.test.ts b/lib/home/__tests__/init-exec.test.ts index 996b5341..49b0c53c 100644 --- a/lib/home/__tests__/init-exec.test.ts +++ b/lib/home/__tests__/init-exec.test.ts @@ -96,6 +96,32 @@ describe("executeInitPlan", () => { } }); + describe("commitInitialUserRepo", () => { + test("commits with signing off — a global commit.gpgsign with an unusable key must not fail an init that needs no signature", async () => { + const seam = new FakeExecSeam(); + + const result = await executeInitPlan([{ kind: "commitInitialUserRepo" }], seam, noopLog); + + expect(result).toEqual({ ok: true }); + expect(seam.calls).toContainEqual({ kind: "run", cmd: ["git", "-c", "commit.gpgsign=false", "-C", "user", "commit", "-m", "initial home repo"], cwd: undefined }); + }); + + test("nothing to commit is tolerated — a resumed init can reach here with the tree already committed", async () => { + const seam = new FakeExecSeam({ failRun: (cmd) => (cmd.includes("commit") ? "nothing to commit, working tree clean" : undefined) }); + + expect(await executeInitPlan([{ kind: "commitInitialUserRepo" }], seam, noopLog)).toEqual({ ok: true }); + }); + + test("a real commit failure still aborts", async () => { + const seam = new FakeExecSeam({ failRun: (cmd) => (cmd.includes("commit") ? "fatal: empty ident name not allowed" : undefined) }); + + const result = await executeInitPlan([{ kind: "commitInitialUserRepo" }], seam, noopLog); + + expect(result.ok).toBe(false); + if (!result.ok) expect(result.failedStep).toBe("commitInitialUserRepo"); + }); + }); + describe("writeGitignore / writeOwners — write-if-absent, decided at exec time", () => { test("an empty (freshly created) clone: neither file exists yet, so the ruled content is written", async () => { const seam = new FakeExecSeam({ exists: () => false }); diff --git a/lib/home/init-exec.ts b/lib/home/init-exec.ts index 643c2ccb..71a59a72 100644 --- a/lib/home/init-exec.ts +++ b/lib/home/init-exec.ts @@ -82,7 +82,14 @@ async function runStep(step: InitStep, exec: ExecSeam, log: StepLog): Promise { expect(outcome).toEqual({ state: "failed", detail: "gh: not authenticated", remedy: "Run `gh auth login`, then Retry" }); }); + test("a local-only `git init` failure contacts no host — `gh auth login` is reserved for auth-shaped stderr", async () => { + const p = fakeProbes({ + home: "/fake-home", + runRt: async () => ({ code: 1, stdout: "", stderr: 'rt home init: failed at step "commitInitialUserRepo":\nfatal: empty ident name not allowed' }), + }); + const { ctx } = makeCtx(p, { secrets: fakeSecrets(fakeAgeKeySeamAbsent()) }); + + const outcome = await homeInitStep.run(ctx); + expect(outcome).toMatchObject({ state: "failed", remedy: "Check the error above, then Retry" }); + }); + test("idempotent re-run: a repo already cloned by a prior partial run reports done again without re-running init", async () => { const p = fakeProbes({ home: "/fake-home", dirs: { "/fake-home/.mattstack/user": [".git"] }, files: { "/fake-home/.mattstack/user/.git": "gitdir" } }); const { ctx } = makeCtx(p, { secrets: fakeSecrets(fakeAgeKeySeamWithKey()) }); diff --git a/lib/setup/__tests__/validators-rt-health.test.ts b/lib/setup/__tests__/validators-rt-health.test.ts index 051375b7..6a69a823 100644 --- a/lib/setup/__tests__/validators-rt-health.test.ts +++ b/lib/setup/__tests__/validators-rt-health.test.ts @@ -600,7 +600,32 @@ describe("rtHealthRows — home.backup (real git)", () => { await attachRemote(repoDir); const row = await homeBackupRow(repoDir); expect(row.status).toBe("needs-you"); - expect(row.detail).toBe("remote configured, nothing pushed yet"); + expect(row.detail).toBe("no commits yet — nothing is versioned or backed up"); + }); + + test("unborn branch, no remote: never claims settings are versioned on this machine when nothing is committed", async () => { + const repoDir = freshRepoDir("rt-health-backup-unborn-local-"); + const row = await homeBackupRow(repoDir); + expect(row.status).toBe("needs-you"); + expect(row.detail).toBe("no commits yet — nothing is versioned or backed up"); + }); + + test("a non-origin remote reads as local-only: every push and ref comparison downstream is origin-only", async () => { + const repoDir = freshRepoDir("rt-health-backup-upstream-only-"); + await commit(repoDir, "seed"); + const otherDir = join(dirname(repoDir), "upstream.git"); + execFileSync("git", ["init", "--bare", "-q", otherDir]); + execFileSync("git", ["remote", "add", "upstream", otherDir], { cwd: repoDir }); + + const row = await homeBackupRow(repoDir); + expect(row.detail).toBe("local only — your settings are versioned on this machine but are not backed up anywhere"); + }); + + test("remote configured, nothing pushed: the remedy names the push, not just the remote add", async () => { + const repo = await localOnlyRepo(); + await attachRemote(repo); + const row = await homeBackupRow(repo); + expect((row.action as { steps: string[] } | null)?.steps.join("\n")).toContain("push origin HEAD"); }); test("no home repo at this path yet: needs-you, never claims settings are versioned when there's nothing there", async () => { diff --git a/lib/setup/home-git.ts b/lib/setup/home-git.ts index cc5260ef..0f065ffa 100644 --- a/lib/setup/home-git.ts +++ b/lib/setup/home-git.ts @@ -18,10 +18,16 @@ export async function isGitRepo(exec: Probes["exec"], cwd: string): Promise { const result = await exec(["git", "remote"], { cwd, timeoutMs: GIT_TIMEOUT_MS }); - return result.code === 0 && result.stdout.trim().length > 0; + return result.code === 0 && result.stdout.split("\n").some((name) => name.trim() === "origin"); +} + +/** True only when HEAD resolves — false on an unborn branch, where nothing is versioned at all. */ +export async function hasCommits(exec: Probes["exec"], cwd: string): Promise { + const result = await exec(["git", "rev-parse", "--verify", "-q", "HEAD"], { cwd, timeoutMs: GIT_TIMEOUT_MS }); + return result.code === 0; } export type OriginPushState = { kind: "no-ref" } | { kind: "ahead"; count: number } | { kind: "up-to-date"; committedAt: Date | null } | { kind: "unknown" }; @@ -34,10 +40,8 @@ export type OriginPushState = { kind: "no-ref" } | { kind: "ahead"; count: numbe * unpushed (an absent ref is FATAL to `rev-list`, not empty), so its * existence is checked before it is ever compared against. * - * An unborn branch (no commit has ever landed — e.g. `git commit` failing - * outright with no `user.name`/`user.email` configured) still prints its - * branch name via `symbolic-ref`, exit 0 — folded into "no-ref" here rather - * than a distinct case, since either way nothing has been confirmed pushed. + * An unborn branch still prints a branch name via `symbolic-ref`, exit 0, so + * HEAD is verified separately; it folds into "no-ref". */ export async function originPushState(exec: Probes["exec"], cwd: string): Promise { const branchResult = await exec(["git", "symbolic-ref", "--short", "HEAD"], { cwd, timeoutMs: GIT_TIMEOUT_MS }); diff --git a/lib/setup/probes.ts b/lib/setup/probes.ts index 58ec4b37..1c14d3b1 100644 --- a/lib/setup/probes.ts +++ b/lib/setup/probes.ts @@ -53,7 +53,7 @@ const DEFAULT_FETCH_TIMEOUT_MS = 5000; /** Grace between SIGTERM and SIGKILL once timeoutMs elapses — long enough for a well-behaved child to exit on TERM, short enough to keep the 124 path bounded. */ const KILL_GRACE_MS = 200; -async function execWithTimeout(argv: string[], opts?: { cwd?: string; timeoutMs?: number; env?: Record; input?: string; inherit?: boolean }): Promise { +export async function execWithTimeout(argv: string[], opts?: { cwd?: string; timeoutMs?: number; env?: Record; input?: string; inherit?: boolean }): Promise { const hasInput = opts?.input !== undefined && !opts?.inherit; // A local no-arg closure (rather than `Bun.spawn(argv, {...})` inline diff --git a/lib/setup/steps/home.ts b/lib/setup/steps/home.ts index d958a6f7..18e0ac17 100644 --- a/lib/setup/steps/home.ts +++ b/lib/setup/steps/home.ts @@ -61,7 +61,18 @@ async function homeInitRun(ctx: ApplyContext): Promise { } const stderrHead = result.stderr.trim().split("\n")[0] ?? ""; - return { state: "failed", detail: stderrHead, remedy: "Run `gh auth login`, then Retry" }; + return { state: "failed", detail: stderrHead, remedy: homeInitRemedy(result.stderr) }; +} + +/** + * `rt home init` reaches a remote only when a url was resolved; the local-only + * path (`git init`/`add`/`commit`) contacts no host at all, so `gh auth login` + * is reserved for stderr that actually names an auth/clone failure. + */ +function homeInitRemedy(stderr: string): string { + return /authenticat|could not read username|permission denied|access denied|repository not found|403 forbidden|invalid username or (?:password|token)|gh auth login/i.test(stderr) + ? "Run `gh auth login`, then Retry" + : "Check the error above, then Retry"; } async function homeRestoreRun(ctx: ApplyContext): Promise { diff --git a/lib/setup/validators/rt-health.ts b/lib/setup/validators/rt-health.ts index bf1e2c33..6c1c979c 100644 --- a/lib/setup/validators/rt-health.ts +++ b/lib/setup/validators/rt-health.ts @@ -18,9 +18,9 @@ import { resolveFzf } from "../../fzf.ts"; import { legacyDirsPresent, legacyTrayAppPaths, RT_DIR_LABEL } from "../../rt-paths.ts"; import { detectShellFrom, shellRcPathFor } from "../../shell-integration.ts"; import { row, type Action, type Row } from "../contract.ts"; -import { hasRemote, isGitRepo, originPushState } from "../home-git.ts"; +import { hasCommits, hasRemote, isGitRepo, originPushState } from "../home-git.ts"; import { LOGIN_ITEMS_SETTINGS_ACTION } from "../permissions.ts"; -import { createRealProbes, type Probes } from "../probes.ts"; +import { execWithTimeout, type Probes } from "../probes.ts"; // ─── rt-context extension check (moved from commands/verify.ts) ────────────── @@ -72,7 +72,9 @@ export interface RtHealthSeams { } const REAL_SEAMS: RtHealthSeams = { resolveFzf }; -const REAL_EXEC: Probes["exec"] = createRealProbes().exec; +// The bare exec, not `createRealProbes().exec`: a full Probes captures $HOME at +// construction, and this is module-load time. +const REAL_EXEC: Probes["exec"] = execWithTimeout; // ─── row builders ────────────────────────────────────────────────────────── @@ -80,8 +82,14 @@ const LINK_BUNDLED_RT: Action = { type: "link-bundled", label: "Use mattstack's" const LINK_BUNDLED_FZF: Action = { type: "link-bundled", label: "Use mattstack's", tool: "fzf" }; const REINSTALL_SHIMS_ACTION: Action = { type: "run", label: "Re-install shims", verb: ["intercept", "install"] }; const INSTALL_EXTENSION_ACTION: Action = { type: "run", label: "Install extension", verb: ["tools", "setup", "extension"] }; -/** No `rt home remote set` verb exists yet (installer-lane scope), so the remedy names the raw git command instead of a `run` action. */ -const HOME_BACKUP_ADD_REMOTE_ACTION: Action = { type: "steps", label: "Show steps…", steps: ["git -C ~/.mattstack/user remote add origin "] }; +/** No `rt home remote set` verb exists yet (installer-lane scope), so the remedy names the raw git commands instead of a `run` action. */ +const HOME_BACKUP_PUSH_STEP = "git -C ~/.mattstack/user push origin HEAD (or wait — the daemon pushes on its next cycle, up to 30 minutes)"; +const HOME_BACKUP_ADD_REMOTE_ACTION: Action = { + type: "steps", + label: "Show steps…", + steps: ["git -C ~/.mattstack/user remote add origin ", HOME_BACKUP_PUSH_STEP], +}; +const HOME_BACKUP_PUSH_ACTION: Action = { type: "steps", label: "Show steps…", steps: [HOME_BACKUP_PUSH_STEP] }; const MERGE_LEGACY_STATE_ACTION: Action = { type: "steps", label: "Merge legacy state", @@ -374,12 +382,18 @@ export async function homeBackupRow(repoDir: string, exec: Probes["exec"] = REAL return row({ ...base, status: "needs-you", detail: "no home repo found yet — nothing to back up" }); } + // Ahead of the remote check: an unborn repo is not "versioned on this + // machine" either way, so a local-only one must not claim it is. + if (!(await hasCommits(exec, repoDir))) { + return row({ ...base, status: "needs-you", detail: "no commits yet — nothing is versioned or backed up" }); + } + if (!(await hasRemote(exec, repoDir))) { return row({ ...base, status: "needs-you", detail: "local only — your settings are versioned on this machine but are not backed up anywhere", action: HOME_BACKUP_ADD_REMOTE_ACTION }); } const state = await originPushState(exec, repoDir); - if (state.kind === "no-ref") return row({ ...base, status: "needs-you", detail: "remote configured, nothing pushed yet" }); + if (state.kind === "no-ref") return row({ ...base, status: "needs-you", detail: "remote configured, nothing pushed yet", action: HOME_BACKUP_PUSH_ACTION }); if (state.kind === "ahead") return row({ ...base, status: "needs-you", detail: `${state.count} commit(s) not pushed` }); if (state.kind === "unknown") return row({ ...base, status: "needs-you", detail: "could not determine push status — the rev-list check failed" }); return row({ ...base, status: "ready", detail: `last pushed ${relativeWhen(state.committedAt)}` }); diff --git a/website/docs/reference/home/index.mdx b/website/docs/reference/home/index.mdx index 1b184fa8..53a5a70e 100644 --- a/website/docs/reference/home/index.mdx +++ b/website/docs/reference/home/index.mdx @@ -19,7 +19,7 @@ rt home | Command | Description | | --- | --- | -| [`init`](init) | Provision this machine: print, then run, the plan (which clones the user repo as one of its steps) | +| [`init`](init) | Provision this machine: print, then run, the plan (which clones or git-inits the user repo as one of its steps) | | [`key`](key) | The mattstack age key (keychain-custodied) | | [`snapshot`](snapshot) | Run the snapshot daemon now (or show its status with --status) | | [`claim`](claim) | Claim a zone so the daemon leaves it for you to commit by hand | diff --git a/website/docs/reference/home/init.mdx b/website/docs/reference/home/init.mdx index 30fa8a80..c386ba42 100644 --- a/website/docs/reference/home/init.mdx +++ b/website/docs/reference/home/init.mdx @@ -7,7 +7,7 @@ sidebar_label: init `rt › home › init` -Provision this machine: print, then run, the plan (which clones the user repo as one of its steps) +Provision this machine: print, then run, the plan (which clones or git-inits the user repo as one of its steps) ## Usage @@ -20,7 +20,7 @@ rt home init [flags] | Flag / Arg | Type | Default | Description | | --- | --- | --- | --- | | [`--dry-run`](/guides/common-flags) | boolean | `false` | Print the plan without running it | -| `--url` | text | | The user repo to clone (default: https://github.com/m4ttheweric/mattstack-home) | +| `--url` | text | | The user repo to clone — falls back to the setup intent's homeRepo, then RT_HOME_URL, then a local-only git init with no remote | | `--profile` | text | | Adopt this machine profile (user/local/<key>/); combine with --new-profile to create a new one under this name — skips the interactive picker on a fresh machine | | `--new-profile` | boolean | `false` | Start a new machine profile (named by --profile, or this machine's hostname slug) instead of adopting an existing one | | `--no-materialize` | boolean | `false` | Skip the last phase — regenerating rt's PATH shims/daemon registration and each installed tool's setup verb | From ed0cb7aac1e05e1dd0049e2d8d75372508931a40 Mon Sep 17 00:00:00 2001 From: Matthew Goodwin Date: Sun, 23 Aug 2026 19:33:19 -0500 Subject: [PATCH 15/15] =?UTF-8?q?RT:=20home-repo=20follow-up=20=E2=80=94?= =?UTF-8?q?=20lastPush=20record,=20honest=20sync=20wording,=20unsigned=20d?= =?UTF-8?q?aemon=20commits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the spec's §3 lastPush record and five smaller residuals. - The daemon records each push outcome under its own kv key in HOME_SNAPSHOT_NS (never the row persistState rewrites wholesale every cycle), and the home.backup row reads it to name WHY a push is failing. Green gating is untouched: refs/remotes/origin/ stays the sole evidence for ready, the record is diagnostic detail on a non-ready row. - home.backup stops calling a committer date a push time: "in sync — last commit " off the ref tip, "in sync — last pushed " only when the daemon's record supplies a real push timestamp. - Both daemon snapshot commits run with -c commit.gpgsign=false, matching the init path — a global signing config with an unusable key was failing them outright. - The home.init auth remedy stops sending local mkdir permission errors to `gh auth login`; a bare "permission denied" now needs the clone step or a remote-shaped token. - The hand-attached-remote probe is gated to reason !== "watch" rather than janitor-only, so `rt home snapshot` pushes a backlog immediately instead of the user waiting up to 30 minutes. - runHomeInit defaults readIntent to () => null, taking ~120 tests off a live setup-intent.json read; the one test that exercises the real disk read opts in explicitly and still fails on a double-.mattstack regression. Co-Authored-By: Claude Fable 5 --- commands/__tests__/home.test.ts | 9 +- lib/daemon/__tests__/home-snapshot.test.ts | 127 +++++++++++++++--- lib/daemon/home-snapshot.ts | 33 +++-- lib/home/push-record.ts | 57 ++++++++ lib/setup/__tests__/steps-a.test.ts | 33 +++++ .../__tests__/validators-rt-health.test.ts | 45 ++++++- lib/setup/steps/home.ts | 19 ++- lib/setup/validators/rt-health.ts | 42 +++++- lib/state/db.ts | 7 +- lib/state/index.ts | 1 + 10 files changed, 334 insertions(+), 39 deletions(-) create mode 100644 lib/home/push-record.ts diff --git a/commands/__tests__/home.test.ts b/commands/__tests__/home.test.ts index b7a16a93..c38419f6 100644 --- a/commands/__tests__/home.test.ts +++ b/commands/__tests__/home.test.ts @@ -239,6 +239,7 @@ async function runHomeInit( materializeEnv: () => Promise = async () => NOOP_MATERIALIZE_ENV, materializeExec: MaterializeExecSeam = new FakeMaterializeExecSeam(), env: Record = {}, + readIntent: (() => SetupIntent | null) | "real-disk-read" = () => null, ): Promise<{ exitCode: number | undefined; logs: string[]; errors: string[] }> { const exitSpy = spyOn(process, "exit").mockImplementation(() => { throw new Error("process.exit"); @@ -252,7 +253,12 @@ async function runHomeInit( errors.push(parts.map(String).join(" ")); }); try { - await homeInit(args, {}, { probes, exec, ageKeySeam, sopsYamlSeam, key, pickerSeam, isInteractive, materializeEnv, materializeExec, env }); + // Omitting `readIntent` entirely is what makes homeInit fall through to + // its real ~/.mattstack/rt/setup-intent.json read; the default above + // keeps every other case off disk, so nothing a preload or a later test + // writes there can move an assertion. + const intentSeam = readIntent === "real-disk-read" ? {} : { readIntent }; + await homeInit(args, {}, { probes, exec, ageKeySeam, sopsYamlSeam, key, pickerSeam, isInteractive, materializeEnv, materializeExec, env, ...intentSeam }); return { exitCode: undefined, logs, errors }; } catch { const code = exitSpy.mock.calls.at(-1)?.[0] as number | undefined; @@ -1457,6 +1463,7 @@ describe("homeInit — the real intent read", () => { async () => NOOP_MATERIALIZE_ENV, new FakeMaterializeExecSeam(), { RT_HOME_URL: "https://x/from-env.git" }, + "real-disk-read", ); expect(logs.join("\n")).toContain("clone https://x/from-intent.git into user/"); diff --git a/lib/daemon/__tests__/home-snapshot.test.ts b/lib/daemon/__tests__/home-snapshot.test.ts index bd762961..569aa4c6 100644 --- a/lib/daemon/__tests__/home-snapshot.test.ts +++ b/lib/daemon/__tests__/home-snapshot.test.ts @@ -9,6 +9,7 @@ import { runCapture, type RunResult } from "../../subprocess.ts"; import type { Owners } from "../../home/snapshot-owners.ts"; import { openStateDb } from "../../state/db.ts"; import { closeStateDb, getKvValue } from "../../state/index.ts"; +import { readHomePushRecord } from "../../home/push-record.ts"; import { startHomeSnapshot, type HomeSnapshotDeps, type HomeSnapshotSettings } from "../home-snapshot.ts"; // ─── test doubles ──────────────────────────────────────────────────────────── @@ -24,6 +25,13 @@ function fakeLog(): Logger & { calls: { level: string; args: unknown[] }[] } { type ExecOpts = { cwd?: string; timeoutMs?: number; stderr?: "ignore" | "pipe" }; type Responder = (argv: string[]) => RunResult | undefined; +/** The git subcommand, past any leading `-c ` pairs — snapshot commits carry `-c commit.gpgsign=false`, so argv[1] is not always the verb. */ +function gitVerb(argv: string[]): string | undefined { + let i = 1; + while (argv[i] === "-c") i += 2; + return argv[i]; +} + function defaultResponders(opts: { isRepo?: boolean; branch?: string; @@ -52,7 +60,7 @@ function defaultResponders(opts: { : undefined, (argv) => (argv[1] === "status") ? { stdout: statusZ, stderr: "", exitCode: 0 } : undefined, (argv) => (argv[1] === "add") ? { stdout: "", stderr: "", exitCode: addExit } : undefined, - (argv) => (argv[1] === "commit") ? { stdout: "", stderr: "", exitCode: commitExit } : undefined, + (argv) => (gitVerb(argv) === "commit") ? { stdout: "", stderr: "", exitCode: commitExit } : undefined, // `hasRemote()`'s own probe — most fixtures simulate a repo that already has origin configured, matching every pre-existing push test's assumption. (argv) => (argv[1] === "remote" && argv.length === 2) ? { stdout: hasRemote ? "origin\n" : "", stderr: "", exitCode: 0 } : undefined, (argv) => (argv[1] === "push") ? { stdout: "", stderr: pushStderr, exitCode: pushExit } : undefined, @@ -280,7 +288,7 @@ describe("startHomeSnapshot — live enabled toggle", () => { const result = await handle.runNow("manual"); expect(result.skipped).toBe("disabled"); - expect(execCalls.some((c) => c[1] === "add" || c[1] === "commit")).toBe(false); + expect(execCalls.some((c) => c[1] === "add" || gitVerb(c) === "commit")).toBe(false); }); test("status().enabled reflects the live setting on every call, not a value captured at startup", async () => { @@ -419,7 +427,7 @@ describe("startHomeSnapshot — watcher", () => { timers.fire((t) => t.ms === DEFAULT_SETTINGS.debounceSec * 1000); await flushAsync(); - const commitCall = execCalls.find((c) => c[1] === "commit"); + const commitCall = execCalls.find((c) => gitVerb(c) === "commit"); expect(commitCall).toBeDefined(); expect(commitCall).toContain("snapshot: a.txt"); expect(broadcasts.some((b) => b.type === "home:snapshot")).toBe(true); @@ -438,7 +446,7 @@ describe("startHomeSnapshot — preflight", () => { const result = await handle.runNow("manual"); expect(result.skipped).toBe("detached"); - expect(execCalls.some((c) => c[1] === "add" || c[1] === "commit")).toBe(false); + expect(execCalls.some((c) => c[1] === "add" || gitVerb(c) === "commit")).toBe(false); expect(log.calls.some((c) => c.level === "warn")).toBe(true); }); @@ -452,7 +460,7 @@ describe("startHomeSnapshot — preflight", () => { expect(result.skipped).toBeUndefined(); expect(result.committed).toBe(true); - expect(execCalls.some((c) => c[1] === "commit")).toBe(true); + expect(execCalls.some((c) => gitVerb(c) === "commit")).toBe(true); }); test("a MERGE_HEAD present skips the cycle", async () => { @@ -564,14 +572,14 @@ describe("startHomeSnapshot — commit shapes", () => { await handle.runNow("manual"); const addIdx = execCalls.findIndex((c) => c[1] === "add"); - const commitIdx = execCalls.findIndex((c) => c[1] === "commit"); + const commitIdx = execCalls.findIndex((c) => gitVerb(c) === "commit"); expect(execCalls[addIdx]).toEqual(["git", "add", "-A", "--", ".", ":(exclude)prefs/", ":(exclude)secrets/"]); // The commit is pathspec-restricted too, not just the add — a plain // `git commit` would otherwise sweep in anything staged outside this // add (e.g. by the user, or inside a claimed zone), regardless of what // THIS add excluded. expect(execCalls[commitIdx]).toEqual([ - "git", "commit", "-q", "-m", "snapshot (manual): notes", + "git", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "snapshot (manual): notes", "--", ".", ":(exclude)prefs/", ":(exclude)secrets/", ]); expect(optsLog[addIdx]?.cwd).toBe("/fake/repo"); @@ -586,14 +594,14 @@ describe("startHomeSnapshot — commit shapes", () => { const manualHandle = startHomeSnapshot(manualDeps); await manualHandle.ready; await manualHandle.runNow("manual"); - expect(manualCalls.find((c) => c[1] === "commit")).toContain("snapshot (manual): a.txt"); + expect(manualCalls.find((c) => gitVerb(c) === "commit")).toContain("snapshot (manual): a.txt"); const { fn: watchExec, calls: watchCalls } = makeFakeExec(defaultResponders({ statusZ: "?? a.txt\0" })); const { deps: watchDeps } = baseDeps({ exec: watchExec }); const watchHandle = startHomeSnapshot(watchDeps); await watchHandle.ready; await watchHandle.runNow("watch"); - expect(watchCalls.find((c) => c[1] === "commit")).toContain("snapshot: a.txt"); + expect(watchCalls.find((c) => gitVerb(c) === "commit")).toContain("snapshot: a.txt"); }); test("nothing to auto-commit — no-op, no add/commit, skipped:'no-changes'", async () => { @@ -606,7 +614,7 @@ describe("startHomeSnapshot — commit shapes", () => { expect(result.committed).toBe(false); expect(result.skipped).toBe("no-changes"); - expect(execCalls.some((c) => c[1] === "add" || c[1] === "commit")).toBe(false); + expect(execCalls.some((c) => c[1] === "add" || gitVerb(c) === "commit")).toBe(false); expect(broadcasts.length).toBe(0); }); @@ -637,10 +645,28 @@ describe("startHomeSnapshot — commit shapes", () => { expect(manualResult.committed).toBe(true); expect(execCalls).toContainEqual(["git", "add", "-A", "--", "prefs/"]); expect(execCalls).toContainEqual([ - "git", "commit", "-q", "-m", "snapshot (janitor): prefs/ dirty >2h, owner matt", "--", "prefs/", + "git", "-c", "commit.gpgsign=false", "commit", "-q", "-m", "snapshot (janitor): prefs/ dirty >2h, owner matt", "--", "prefs/", ]); expect(broadcasts.some((b) => b.type === "home:snapshot" && (b.data as any).paths.includes("prefs/"))).toBe(true); }); + + test("every commit site runs with commit.gpgsign=false — a global signing config with an unusable key must not fail an unattended snapshot", async () => { + const owners: Owners = { zones: { "prefs/": { owner: "matt", claimedAt: "2026-01-01T00:00:00.000Z" } } }; + const db = freshDb(); + db.query("INSERT INTO kv (ns, k, v, updated_at) VALUES ('home-snapshot', 'state', ?, 0);") + .run(JSON.stringify({ firstSeenDirty: { "prefs/": 0 } })); + + const { fn: execFn, calls: execCalls } = makeFakeExec(defaultResponders({ statusZ: "?? notes/a.md\0?? prefs/x.md\0" })); + const { deps } = baseDeps({ exec: execFn, readOwners: () => owners, db, now: () => 10_000_000 }); + + const handle = startHomeSnapshot(deps); + await handle.ready; + await handle.runNow("manual"); + + const commits = execCalls.filter((c) => gitVerb(c) === "commit"); + expect(commits.length).toBe(2); // the auto commit and the janitor zone commit + for (const argv of commits) expect(argv.slice(0, 3)).toEqual(["git", "-c", "commit.gpgsign=false"]); + }); }); // ─── concurrency guard ─────────────────────────────────────────────────────── @@ -803,7 +829,7 @@ describe("startHomeSnapshot — push", () => { if (argv[1] === "rev-parse" && argv[2] === "HEAD") return { stdout: "sha1\n", stderr: "", exitCode: 0 }; if (argv[1] === "status") return { stdout: "?? a.txt\0", stderr: "", exitCode: 0 }; if (argv[1] === "add") return { stdout: "", stderr: "", exitCode: 0 }; - if (argv[1] === "commit") { await gate; return { stdout: "", stderr: "", exitCode: 0 }; } + if (gitVerb(argv) === "commit") { await gate; return { stdout: "", stderr: "", exitCode: 0 }; } return { stdout: "", stderr: "", exitCode: 0 }; }; const { deps, timers } = baseDeps({ exec }); @@ -845,6 +871,60 @@ describe("startHomeSnapshot — state persistence", () => { expect(handle2.status().firstSeenDirty["prefs/"]).toBe(42); }); + test("the last-push record survives later commit cycles — its own kv row, never a sibling field of the one persistState rewrites wholesale", async () => { + const { fn: execFn } = makeFakeExec(defaultResponders({ statusZ: "?? a.txt\0" })); + const { deps, timers } = baseDeps({ exec: execFn }); + const handle = startHomeSnapshot(deps); + await handle.ready; + + await handle.runNow("watch"); + timers.fire((t) => t.ms === DEFAULT_SETTINGS.pushDelaySec * 1000); + await flushAsync(); + expect(readHomePushRecord(deps.db)).toMatchObject({ ok: true, at: 1_000_000 }); + + // persistState writes `{ firstSeenDirty }` over its whole row on EVERY + // cycle, committing or not — a lastPush stored there would be gone here. + await handle.runNow("watch"); + await handle.runNow("watch"); + await flushAsync(); + expect(readHomePushRecord(deps.db)).toMatchObject({ ok: true, at: 1_000_000 }); + expect(Object.keys(getKvValue>("home-snapshot", "state", {}, deps.db))).toEqual(["firstSeenDirty"]); + + handle.stop(); + }); + + test("a failed push records why, so the home.backup row can name it", async () => { + const { fn: execFn } = makeFakeExec(defaultResponders({ statusZ: "?? a.txt\0", pushExit: 1, pushStderr: "remote: Permission to o/r.git denied\n" })); + const { deps, timers } = baseDeps({ exec: execFn }); + const handle = startHomeSnapshot(deps); + await handle.ready; + + await handle.runNow("watch"); + timers.fire((t) => t.ms === DEFAULT_SETTINGS.pushDelaySec * 1000); + await flushAsync(); + + expect(readHomePushRecord(deps.db)).toMatchObject({ ok: false, error: "remote: Permission to o/r.git denied\n" }); + handle.stop(); + }); + + test("a credentialed remote URL never reaches the persisted record", async () => { + const { fn: execFn } = makeFakeExec(defaultResponders({ + statusZ: "?? a.txt\0", + pushExit: 128, + pushStderr: "fatal: unable to access 'https://matt:ghp_secret@github.com/o/r.git/'", + })); + const { deps, timers } = baseDeps({ exec: execFn }); + const handle = startHomeSnapshot(deps); + await handle.ready; + + await handle.runNow("watch"); + timers.fire((t) => t.ms === DEFAULT_SETTINGS.pushDelaySec * 1000); + await flushAsync(); + + expect(readHomePushRecord(deps.db)?.error).not.toContain("ghp_secret"); + handle.stop(); + }); + test("a malformed stored row starts from empty state instead of crashing, and warns loudly", async () => { const db = freshDb(); db.query("INSERT INTO kv (ns, k, v, updated_at) VALUES ('home-snapshot', 'state', '{not json', 0);").run(); @@ -1138,7 +1218,7 @@ describe("startHomeSnapshot — commit observability", () => { test("a repeated identical commit failure warns once; a different failure message warns again", async () => { let stderr = "fatal: unable to write new index file"; const execWithStderr: NonNullable = async (argv) => { - if (argv[1] === "commit") return { stdout: "", stderr, exitCode: 1 }; + if (gitVerb(argv) === "commit") return { stdout: "", stderr, exitCode: 1 }; for (const r of defaultResponders({ statusZ: "?? a.txt\0" })) { const res = r(argv); if (res) return res; @@ -1174,7 +1254,7 @@ describe("startHomeSnapshot — git add failure", () => { const result = await handle.runNow("manual"); expect(result.skipped).toBe("index-locked"); - expect(execCalls.some((c) => c[1] === "commit")).toBe(false); + expect(execCalls.some((c) => gitVerb(c) === "commit")).toBe(false); }); test("a git add failure NOT mentioning index.lock skips with 'add-failed'", async () => { @@ -1189,7 +1269,7 @@ describe("startHomeSnapshot — git add failure", () => { const result = await handle.runNow("manual"); expect(result.skipped).toBe("add-failed"); - expect(execCalls.some((c) => c[1] === "commit")).toBe(false); + expect(execCalls.some((c) => gitVerb(c) === "commit")).toBe(false); }); test("a repeated identical git add failure warns once", async () => { @@ -1559,6 +1639,10 @@ describe("startHomeSnapshot — local-only remote state", () => { await handle.runNow("janitor"); await settle(); }, + async manualTick(): Promise { + await handle.runNow("manual"); + await settle(); + }, async attachRemote(): Promise { execFileSync("git", ["remote", "add", "origin", originDir], { cwd: repoDir }); }, @@ -1626,6 +1710,19 @@ describe("startHomeSnapshot — local-only remote state", () => { } }, 15_000); + test("`rt home snapshot` (reason manual) notices a hand-attached remote too — the affordance a user reaches for right after attaching one", async () => { + const h = await harnessWithLocalOnlyRepo(); + try { + await h.writeFile("user/a", "1"); + await h.runCycles(1); // commits locally, no remote yet + await h.attachRemote(); + await h.manualTick(); // nothing new to commit + expect(h.execCalls().filter((c) => c[1] === "push").length).toBe(1); + } finally { + h.stop(); + } + }, 15_000); + test("second push only fires when there is something ahead of the ref", async () => { const h = await harnessWithLocalOnlyRepo(); try { diff --git a/lib/daemon/home-snapshot.ts b/lib/daemon/home-snapshot.ts index fff34097..0d0fb56c 100644 --- a/lib/daemon/home-snapshot.ts +++ b/lib/daemon/home-snapshot.ts @@ -38,6 +38,7 @@ import { setKvValue, } from "../state/index.ts"; import { readOwners as readOwnersReal, type Owners } from "../home/snapshot-owners.ts"; +import { HOME_SNAPSHOT_NS, recordHomePush, type HomePushRecord } from "../home/push-record.ts"; import { parsePorcelainZ, planSnapshot } from "./home-snapshot-plan.ts"; export type SnapshotReason = "manual" | "watch" | "janitor"; @@ -194,7 +195,6 @@ async function unpushedAgainstOrigin(exec: ExecFn, cwd: string): Promise 0; } -const HOME_SNAPSHOT_NS = "home-snapshot"; const HOME_SNAPSHOT_KEY = "state"; interface PersistedHomeSnapshotState { @@ -246,6 +246,15 @@ function persistState(db: Database, firstSeenDirty: Record, log: } } +/** The `home.backup` row's only source for WHY a push is failing — the one thing about a broken backup that git's own refs cannot show. Its own kv key, never HOME_SNAPSHOT_KEY, which persistState overwrites wholesale every cycle. */ +function persistPushRecord(db: Database, record: HomePushRecord, log: Logger): void { + try { + recordHomePush(db, record); + } catch (err) { + log.warn({ err }, "home-snapshot: failed to persist the last-push record"); + } +} + export function startHomeSnapshot(rawDeps: HomeSnapshotDeps): HomeSnapshotHandle { const repoDir = rawDeps.repoDir ?? join(mattstackHome(), "user"); const rawReadSettings = rawDeps.readSettings ?? (() => getSetting("rt.homeSnapshot").value); @@ -516,6 +525,7 @@ export function startHomeSnapshot(rawDeps: HomeSnapshotDeps): HomeSnapshotHandle pushFailureBroadcast = false; lastPushAt = deps.now(); lastPushError = null; + persistPushRecord(deps.db, { at: lastPushAt, ok: true }, deps.log); if (pushRetryTimer) { deps.clearTimeout(pushRetryTimer); pushRetryTimer = null; @@ -528,6 +538,7 @@ export function startHomeSnapshot(rawDeps: HomeSnapshotDeps): HomeSnapshotHandle const redactedStderr = redactCredentials(result.stderr); pushPending = true; lastPushError = redactedStderr; + persistPushRecord(deps.db, { at: deps.now(), ok: false, error: redactedStderr }, deps.log); deps.log.warn({ stderr: redactedStderr }, "home-snapshot: push failed"); // Only the FIRST failure of an unbroken streak broadcasts — a retry // storm (schedulePushRetry firing every pushDelaySec*5) would @@ -664,8 +675,12 @@ export function startHomeSnapshot(rawDeps: HomeSnapshotDeps): HomeSnapshotHandle // commit to the same pathspec makes it self-contained: only matched // paths are committed, whatever sits staged for the zone is left // exactly as it was. + // + // `-c commit.gpgsign=false`: a global signing config with an unusable + // key fails every snapshot commit outright (exit 128), and nothing + // about an unattended backup commit needs a signature. const message = reason === "manual" ? plan.message.replace(/^snapshot:/, "snapshot (manual):") : plan.message; - const commitResult = await deps.exec(["git", "commit", "-q", "-m", message, "--", ".", ...excludeArgs], { + const commitResult = await deps.exec(["git", "-c", "commit.gpgsign=false", "commit", "-q", "-m", message, "--", ".", ...excludeArgs], { cwd: deps.repoDir, timeoutMs: GIT_TIMEOUT_MS, stderr: "pipe", @@ -696,8 +711,8 @@ export function startHomeSnapshot(rawDeps: HomeSnapshotDeps): HomeSnapshotHandle deps.log.warn({ stderr: addResult.stderr, zone: jz.zone }, "home-snapshot: janitor add failed; skipping this zone this cycle"); continue; } - // Same self-contained-commit reasoning as the auto commit above. - const commitResult = await deps.exec(["git", "commit", "-q", "-m", message, "--", jz.zone], { + // Same self-contained-commit and unsigned-commit reasoning as the auto commit above. + const commitResult = await deps.exec(["git", "-c", "commit.gpgsign=false", "commit", "-q", "-m", message, "--", jz.zone], { cwd: deps.repoDir, timeoutMs: GIT_TIMEOUT_MS, stderr: "pipe", @@ -722,11 +737,13 @@ export function startHomeSnapshot(rawDeps: HomeSnapshotDeps): HomeSnapshotHandle if (committed || pushPending) { schedulePush(); - } else if (reason === "janitor" && (await hasRemote(deps.exec, deps.repoDir)) && (await unpushedAgainstOrigin(deps.exec, deps.repoDir))) { + } else if (reason !== "watch" && (await hasRemote(deps.exec, deps.repoDir)) && (await unpushedAgainstOrigin(deps.exec, deps.repoDir))) { // The only path that notices a remote attached by hand after commits - // already existed. Confined to the janitor tick: on the watch debounce - // these five git spawns would run on every no-op cycle, to detect a - // state that only ever changes by hand. + // already existed. Excluded from the watch debounce (and only there): + // these five git spawns would otherwise run on every no-op fs cycle, to + // detect a state that only ever changes by hand. `rt home snapshot` is + // the affordance a user reaches for right after attaching a remote, so + // "manual" must reach this even with nothing to commit. schedulePush(); } diff --git a/lib/home/push-record.ts b/lib/home/push-record.ts new file mode 100644 index 00000000..a7a7adaf --- /dev/null +++ b/lib/home/push-record.ts @@ -0,0 +1,57 @@ +/** + * The home-snapshot daemon's most recent push outcome, written by the daemon + * and read by the `home.backup` setup row. + * + * It lives under its OWN kv key, never `home-snapshot`/`state`: that row is + * rewritten wholesale (`{ firstSeenDirty }`) on every commit cycle, so a + * sibling field there would be clobbered within seconds and the probe would + * report "never pushed" forever. + * + * It is diagnostic only. Git's own `refs/remotes/origin/` stays the + * sole evidence a push completed — this record supplies the *why* behind a + * push that is failing, which nothing else on the machine surfaces. + */ + +import { existsSync } from "fs"; +import type { Database } from "bun:sqlite"; +import { getKvValue, getStateDb, setKvValue, stateDbPath } from "../state/index.ts"; + +/** Shared with lib/daemon/home-snapshot.ts's own `state` row — same namespace, different key, which is the whole point. */ +export const HOME_SNAPSHOT_NS = "home-snapshot"; +export const HOME_PUSH_KEY = "last-push"; + +export interface HomePushRecord { + /** Epoch ms of the attempt. */ + at: number; + ok: boolean; + /** Credential-redacted stderr of a failed attempt; absent on success. */ + error?: string; +} + +function isHomePushRecord(value: unknown): value is HomePushRecord { + if (typeof value !== "object" || value === null) return false; + const record = value as Partial; + return typeof record.at === "number" && Number.isFinite(record.at) && typeof record.ok === "boolean"; +} + +export function recordHomePush(db: Database, record: HomePushRecord): void { + setKvValue(HOME_SNAPSHOT_NS, HOME_PUSH_KEY, record, db); +} + +/** + * Never opens state.db when the file is absent, and never throws: `rt verify` + * runs in CI and mid-install, where a probe read must neither materialize the + * database nor turn a missing record into an `error` row. No record means "no + * recorded push", which is the correct reading on a machine whose daemon has + * never run. + */ +export function readHomePushRecord(db?: Database): HomePushRecord | null { + try { + const target = db ?? (existsSync(stateDbPath()) ? getStateDb() : null); + if (!target) return null; + const raw = getKvValue(HOME_SNAPSHOT_NS, HOME_PUSH_KEY, null, target); + return isHomePushRecord(raw) ? raw : null; + } catch { + return null; + } +} diff --git a/lib/setup/__tests__/steps-a.test.ts b/lib/setup/__tests__/steps-a.test.ts index 69b54785..dee16bea 100644 --- a/lib/setup/__tests__/steps-a.test.ts +++ b/lib/setup/__tests__/steps-a.test.ts @@ -219,6 +219,39 @@ describe("home.init", () => { expect(outcome).toMatchObject({ state: "failed", remedy: "Check the error above, then Retry" }); }); + test("a LOCAL permission failure is not an auth failure — `gh auth login` fixes nothing about a directory rt cannot write", async () => { + const p = fakeProbes({ + home: "/fake-home", + runRt: async () => ({ code: 1, stdout: "", stderr: 'rt home init: failed at step "initUserRepo":\nfatal: cannot mkdir user: Permission denied' }), + }); + const { ctx } = makeCtx(p, { secrets: fakeSecrets(fakeAgeKeySeamAbsent()) }); + + const outcome = await homeInitStep.run(ctx); + expect(outcome).toMatchObject({ state: "failed", remedy: "Check the error above, then Retry" }); + }); + + test("ssh's `Permission denied (publickey)` is unambiguous on its own, clone step named or not", async () => { + const p = fakeProbes({ + home: "/fake-home", + runRt: async () => ({ code: 1, stdout: "", stderr: "git@github.com: Permission denied (publickey).\nfatal: Could not read from remote repository." }), + }); + const { ctx } = makeCtx(p, { secrets: fakeSecrets(fakeAgeKeySeamAbsent()) }); + + const outcome = await homeInitStep.run(ctx); + expect(outcome).toMatchObject({ state: "failed", remedy: "Run `gh auth login`, then Retry" }); + }); + + test("a bare permission denial from the clone step IS auth-shaped — only that step ever contacts a host", async () => { + const p = fakeProbes({ + home: "/fake-home", + runRt: async () => ({ code: 1, stdout: "", stderr: 'rt home init: failed at step "cloneUserRepo":\nremote: Permission denied' }), + }); + const { ctx } = makeCtx(p, { secrets: fakeSecrets(fakeAgeKeySeamAbsent()) }); + + const outcome = await homeInitStep.run(ctx); + expect(outcome).toMatchObject({ state: "failed", remedy: "Run `gh auth login`, then Retry" }); + }); + test("idempotent re-run: a repo already cloned by a prior partial run reports done again without re-running init", async () => { const p = fakeProbes({ home: "/fake-home", dirs: { "/fake-home/.mattstack/user": [".git"] }, files: { "/fake-home/.mattstack/user/.git": "gitdir" } }); const { ctx } = makeCtx(p, { secrets: fakeSecrets(fakeAgeKeySeamWithKey()) }); diff --git a/lib/setup/__tests__/validators-rt-health.test.ts b/lib/setup/__tests__/validators-rt-health.test.ts index 6a69a823..2e5a348b 100644 --- a/lib/setup/__tests__/validators-rt-health.test.ts +++ b/lib/setup/__tests__/validators-rt-health.test.ts @@ -516,6 +516,9 @@ describe("rtHealthRows — tool.daemon", () => { * here is built by hand (`git init` -> commit -> attach remote -> push). */ describe("rtHealthRows — home.backup (real git)", () => { + /** The daemon's push record is diagnostic only — every state below is asserted against no record first, since that is what a machine whose daemon has never run reports. */ + const NO_RECORD = () => null; + const REAL_EXEC: Probes["exec"] = createRealProbes().exec; const createdRoots: string[] = []; afterAll(() => { for (const root of createdRoots) { @@ -583,18 +586,52 @@ describe("rtHealthRows — home.backup (real git)", () => { test("commits ahead of the ref: needs-you", async () => { const repo = await pushedRepo(); await commit(repo, "later"); - const row = await homeBackupRow(repo); + const row = await homeBackupRow(repo, REAL_EXEC, NO_RECORD); expect(row.status).toBe("needs-you"); expect(row.detail).toBe("1 commit(s) not pushed"); }); - test("pushed and nothing ahead: ready", async () => { - const row = await homeBackupRow(await pushedRepo()); + test("pushed and nothing ahead, no daemon record: ready, and names the COMMIT — the ref tip's committer date is not a push time", async () => { + const row = await homeBackupRow(await pushedRepo(), REAL_EXEC, NO_RECORD); expect(row.status).toBe("ready"); - expect(row.detail).toStartWith("last pushed "); + expect(row.detail).toStartWith("in sync — last commit "); + expect(row.detail).not.toContain("pushed"); expect(row.action).toBeNull(); }); + test("pushed and nothing ahead, with a recorded successful push: says pushed, off the record's real timestamp", async () => { + const row = await homeBackupRow(await pushedRepo(), REAL_EXEC, () => ({ at: Date.now() - 5 * 60_000, ok: true })); + expect(row.status).toBe("ready"); + expect(row.detail).toBe("in sync — last pushed 5m ago"); + }); + + test("a record claiming a successful push never turns a needs-you row green — the tracking ref stays the only evidence", async () => { + const repo = await pushedRepo(); + await commit(repo, "later"); + const row = await homeBackupRow(repo, REAL_EXEC, () => ({ at: Date.now(), ok: true })); + expect(row.status).toBe("needs-you"); + expect(row.detail).toBe("1 commit(s) not pushed"); + }); + + test("commits ahead with a recorded push failure: names why, which nothing else on the machine surfaces", async () => { + const repo = await pushedRepo(); + await commit(repo, "later"); + const row = await homeBackupRow(repo, REAL_EXEC, () => ({ + at: Date.now(), + ok: false, + error: "remote: Permission to acme/home.git denied to matt.\nfatal: unable to access\n", + })); + expect(row.status).toBe("needs-you"); + expect(row.detail).toBe("1 commit(s) not pushed — the last push failed: remote: Permission to acme/home.git denied to matt."); + }); + + test("a repo with no remote never consults the record — local-only is a state, not a push failure", async () => { + const row = await homeBackupRow(await localOnlyRepo(), REAL_EXEC, () => { + throw new Error("readLastPush must not be reached on the local-only path"); + }); + expect(row.detail).toBe("local only — your settings are versioned on this machine but are not backed up anywhere"); + }); + test("unborn branch (remote attached before any commit ever landed): needs-you, never crashes on a missing ref", async () => { const repoDir = freshRepoDir("rt-health-backup-unborn-"); await attachRemote(repoDir); diff --git a/lib/setup/steps/home.ts b/lib/setup/steps/home.ts index 18e0ac17..469da204 100644 --- a/lib/setup/steps/home.ts +++ b/lib/setup/steps/home.ts @@ -64,15 +64,26 @@ async function homeInitRun(ctx: ApplyContext): Promise { return { state: "failed", detail: stderrHead, remedy: homeInitRemedy(result.stderr) }; } +/** Stderr that names a remote/auth failure on its own terms, with no ambiguity about which end of the wire failed. */ +const REMOTE_AUTH_STDERR = /authenticat|could not read username|access denied|repository not found|403 forbidden|invalid username or (?:password|token)|gh auth login|permission denied \(publickey/i; +/** ssh writes "Permission denied (publickey)", but so does a plain local `fatal: cannot mkdir user: Permission denied` — on its own this says nothing about a remote. */ +const AMBIGUOUS_PERMISSION_STDERR = /permission denied/i; +/** `commands/home.ts` prints `failed at step ""`; only the clone step ever contacts a host. */ +const CLONE_STEP_STDERR = /failed at step "cloneUserRepo"/; + /** * `rt home init` reaches a remote only when a url was resolved; the local-only * path (`git init`/`add`/`commit`) contacts no host at all, so `gh auth login` - * is reserved for stderr that actually names an auth/clone failure. + * is reserved for stderr that actually names an auth/clone failure. A bare + * "permission denied" qualifies only alongside the clone step — otherwise a + * local filesystem permission error, the very class the local-only path + * introduced, would be sent to `gh auth login`. */ function homeInitRemedy(stderr: string): string { - return /authenticat|could not read username|permission denied|access denied|repository not found|403 forbidden|invalid username or (?:password|token)|gh auth login/i.test(stderr) - ? "Run `gh auth login`, then Retry" - : "Check the error above, then Retry"; + const remoteShaped = + REMOTE_AUTH_STDERR.test(stderr) || + (AMBIGUOUS_PERMISSION_STDERR.test(stderr) && CLONE_STEP_STDERR.test(stderr)); + return remoteShaped ? "Run `gh auth login`, then Retry" : "Check the error above, then Retry"; } async function homeRestoreRun(ctx: ApplyContext): Promise { diff --git a/lib/setup/validators/rt-health.ts b/lib/setup/validators/rt-health.ts index 6c1c979c..e174533d 100644 --- a/lib/setup/validators/rt-health.ts +++ b/lib/setup/validators/rt-health.ts @@ -17,6 +17,7 @@ import { localBinDir, shimReport, staleIntercepts } from "../../endpoint/shim.ts import { resolveFzf } from "../../fzf.ts"; import { legacyDirsPresent, legacyTrayAppPaths, RT_DIR_LABEL } from "../../rt-paths.ts"; import { detectShellFrom, shellRcPathFor } from "../../shell-integration.ts"; +import { readHomePushRecord, type HomePushRecord } from "../../home/push-record.ts"; import { row, type Action, type Row } from "../contract.ts"; import { hasCommits, hasRemote, isGitRepo, originPushState } from "../home-git.ts"; import { LOGIN_ITEMS_SETTINGS_ACTION } from "../permissions.ts"; @@ -349,9 +350,9 @@ async function daemonRow(p: Probes, opts: { ci: boolean }): Promise { } /** Wall-clock, not an injected `now()` — this row takes a bare `exec`, not a full Probes, so there is no seam to inject. */ -function relativeWhen(committedAt: Date | null): string { - if (!committedAt) return "recently"; - const mins = Math.floor((Date.now() - committedAt.getTime()) / 60_000); +function relativeWhen(at: Date | null): string { + if (!at) return "recently"; + const mins = Math.floor((Date.now() - at.getTime()) / 60_000); if (mins < 1) return "just now"; if (mins < 60) return `${mins}m ago`; const hrs = Math.floor(mins / 60); @@ -359,6 +360,14 @@ function relativeWhen(committedAt: Date | null): string { return `${Math.floor(hrs / 24)}d ago`; } +/** One line, bounded: a push failure's stderr can run to a paragraph, and this shares a row's `detail` with the count it explains. */ +function pushFailureSummary(record: HomePushRecord): string | null { + if (record.ok) return null; + const firstLine = (record.error ?? "").split("\n").map((l) => l.trim()).find((l) => l !== ""); + if (!firstLine) return null; + return firstLine.length > 160 ? `${firstLine.slice(0, 157)}…` : firstLine; +} + /** * Green means a push actually happened, never merely that a remote is * configured — read from git's own remote-tracking ref, so this is right on @@ -366,8 +375,17 @@ function relativeWhen(committedAt: Date | null): string { * by hand. Takes a bare `repoDir` + `exec` (not the full `Probes`) so it can * be pointed at a real git repo directly, independent of the OS `$HOME` a * full `Probes` carries. + * + * `readLastPush` supplies only diagnostic detail — the daemon's own record of + * its last push attempt. It never gates `ready`: a record saying "pushed fine" + * on a repo whose tracking ref disagrees is exactly the shape-not-outcome + * reading this row exists to refuse. */ -export async function homeBackupRow(repoDir: string, exec: Probes["exec"] = REAL_EXEC): Promise { +export async function homeBackupRow( + repoDir: string, + exec: Probes["exec"] = REAL_EXEC, + readLastPush: () => HomePushRecord | null = () => readHomePushRecord(), +): Promise { const base = { id: "home.backup", kind: "tool" as const, @@ -394,9 +412,21 @@ export async function homeBackupRow(repoDir: string, exec: Probes["exec"] = REAL const state = await originPushState(exec, repoDir); if (state.kind === "no-ref") return row({ ...base, status: "needs-you", detail: "remote configured, nothing pushed yet", action: HOME_BACKUP_PUSH_ACTION }); - if (state.kind === "ahead") return row({ ...base, status: "needs-you", detail: `${state.count} commit(s) not pushed` }); if (state.kind === "unknown") return row({ ...base, status: "needs-you", detail: "could not determine push status — the rev-list check failed" }); - return row({ ...base, status: "ready", detail: `last pushed ${relativeWhen(state.committedAt)}` }); + + const lastPush = readLastPush(); + if (state.kind === "ahead") { + const why = lastPush ? pushFailureSummary(lastPush) : null; + const detail = `${state.count} commit(s) not pushed${why ? ` — the last push failed: ${why}` : ""}`; + return row({ ...base, status: "needs-you", detail }); + } + + // `state.committedAt` is the tracking ref tip's COMMITTER date, not a push + // time — a week-old commit pushed five minutes ago would read "last pushed + // 7d ago". Only the daemon's record carries a real push timestamp, so the + // wording changes with the evidence rather than overstating it. + if (lastPush?.ok) return row({ ...base, status: "ready", detail: `in sync — last pushed ${relativeWhen(new Date(lastPush.at))}` }); + return row({ ...base, status: "ready", detail: `in sync — last commit ${relativeWhen(state.committedAt)}` }); } // ─── entry point ──────────────────────────────────────────────────────────── diff --git a/lib/state/db.ts b/lib/state/db.ts index 66c169b2..a39dfd68 100644 --- a/lib/state/db.ts +++ b/lib/state/db.ts @@ -363,6 +363,11 @@ export function openStateDb(path: string, flavor: DbFlavor = "cli"): Database { let singleton: Database | null = null; let singletonPath: string | null = null; +/** Resolved at call time, never cached: the suite swaps `process.env.HOME` between cases, and a memoized path would outlive the HOME it was derived from. */ +export function stateDbPath(): string { + return join(rtDir(), "state.db"); +} + /** * The lazy production singleton: one connection per process, held for the * process lifetime (spec "The database") — true in production, where @@ -374,7 +379,7 @@ let singletonPath: string | null = null; * since deleted (SQLITE_IOERR_VNODE). Never call this at module scope. */ export function getStateDb(flavor: DbFlavor = "cli"): Database { - const path = join(rtDir(), "state.db"); + const path = stateDbPath(); if (!singleton || singletonPath !== path) { singleton?.close(); singleton = openStateDb(path, flavor); diff --git a/lib/state/index.ts b/lib/state/index.ts index 3951ea31..a956dce9 100644 --- a/lib/state/index.ts +++ b/lib/state/index.ts @@ -52,6 +52,7 @@ export { LEGACY_IMPORTS, openStateDb, getStateDb, + stateDbPath, closeStateDb, type DbFlavor, type LegacyImport,