Skip to content

🐛 Map panel says "gh not installed" when the followed checkout was deleted #347

Closed

Description

@ToRvaLDz

Parent: 🗺 trailhead mod: Claude Code function hooks for on-screen state, context and in-process guards

Question

The map panel reports "gh not installed" on refresh when gh is installed (/usr/bin/gh) but the checkout the panel follows has been deleted.

Repro (observed in ziptv2):

  1. Work a ticket in its workspace /home/torvalds/Git/ziptv2-t734 (sibling clone / worktree); the panel pins that checkout.
  2. Close #734 in the target repo, which removes the workspace directory.
  3. The session cwd is still the deleted workspace. Refresh the panel.
  4. The panel shows "gh not installed".

Root cause (two defects):

  • Misleading message. failureReason in plugins/trailhead/hooks/mod/map-pane-view.ts (~line 433) maps any ENOENT / "not found" error to gh not installed. Spawning a process with a cwd that no longer exists also throws ENOENT (spawn fails on the cwd, not on the binary), so a missing directory is reported as a missing gh.
  • No fallback when the followed checkout disappears. The repo pin (repo-pin.ts) re-derives the checkout from $.session.cwd() (git rev-parse --show-toplevel). When the session cwd is the deleted workspace that read fails and the panel keeps the stale pinned path, so every gh run keeps failing. It should fall back to a still-existing checkout of the same repo (the main working tree, or another entry of git worktree list / a sibling clone).

Expected: when the pinned checkout is gone, the panel re-pins to an existing checkout of the same repo and keeps working; if none is found, the message says the folder is missing (e.g. "checkout not found: "), never "gh not installed". Check the cwd exists before blaming gh.

Workaround: move the session back into the main checkout, then close and reopen the panel with /trailhead:panel.

Blocked by

nothing (on the frontier)

Activity

  1. self-assigned this
    on Nov 2, 2026
  2. ToRvaLDz commented on Nov 2, 2026

    @ToRvaLDz
    OwnerAuthor

    REPRO

    1. Pin the map panel to a ticket workspace checkout (e.g. <repo>-t<n>, a sibling worktree or clone), with the session cwd inside it.
    2. Remove that workspace directory (closing the ticket integrates the branch and deletes the workspace).
    3. Refresh the panel, session cwd still the deleted directory.

    Expected: the panel re-pins to a still-existing checkout of the same repo and keeps listing tickets; with none found, it says the checkout folder is missing (checkout not found: <path>).

    Observed: the panel shows gh not installed, although gh is on the PATH.

    Code path: map-pane.tsx runs gh with cwd: pin.root; spawning with a cwd that does not exist throws ENOENT, which failureReason (map-pane-view.ts) maps to gh not installed. refreshPin (repo-pin.ts) re-reads the repo from the session cwd; in a deleted directory that read fails and nextPin keeps the stale pin, so every refresh fails the same way.

  3. ToRvaLDz commented on Nov 2, 2026

    @ToRvaLDz
    OwnerAuthor

    DIAGNOSIS

    Root cause (two co-occurring defects, both confirmed):

    1. Misattribution. gh() in map-pane.tsx spawns gh with cwd: pin.root. Node reports a missing cwd as ENOENT with the message spawn gh ENOENT, byte-identical to a missing binary. failureReason (map-pane-view.ts) maps ENOENT to gh not installed.
      Falsifiable: if this is the cause, spawn(echo, {cwd: /nonexistent}) and spawn(nonexistent-bin, {cwd: /tmp}) yield the same code and message. Test run: both printed ENOENT spawn <bin> ENOENT. Prediction held.
    2. Stale pin. refreshPin (repo-pin.ts) reads the repo from the session cwd. In a deleted cwd the git reads throw and are swallowed; nextPin then keeps the current pin (or the host returns the memoized repo with the same dead root). The pin never leaves the deleted root, so every refresh fails.
      Falsifiable: if this is the cause, a fake engine whose cwd git reads deny ENOENT keeps repoPin.root at the deleted path after a turn. Test run (fake-engine probe): the pin stayed /work/repo-t7. Prediction held.

    AND-gate: fixing (1) alone gives an accurate message but no recovery; fixing (2) alone hides the message only in this path.

    Eliminated: a non-zero gh exit (goes through the stderr branch, never says "not installed"); isServed (returns without spawning for github.com).

    Fix direction: in gh(), on an ENOENT throw, check whether pin.root still exists before blaming gh (new reason checkout not found: <path>); in the pin refresh, when the pinned root is gone, re-pin to the first existing checkout of the same repo (main working tree, another worktree, a same-remote sibling), using only the engine process API (the mod has no fs).

  4. ToRvaLDz commented on Nov 2, 2026

    @ToRvaLDz
    OwnerAuthor

    VERIFY (round 1)

    status: clean
    critical: 0 · warning: 2 · info: 1

    I recomputed the ticket diff with the protocol command. It returned two commits, a69fadc and 70fd28b, both read. I did not run the suite. I traced the two warnings and the info finding by reading the code, and did not execute them.

    Findings ledger

    • W1: open
    • W2: open
    • I1: open

    Warnings

    W1. After a relocation, the full search re-runs on every refresh.
    /home/torvalds/Git/trailhead/plugins/trailhead/hooks/mod/repo-pin.ts:242-244

    const next = nextPin(current, repo, toplevel)
    const pin = next === null ? null : await livePin($, next)
    • Trigger: the host's memoized session.repo() keeps naming the deleted root. The new test rig models exactly this, and the code comment on line 243 says it happens.
    • First refresh: livePin moves the stored pin to the live root.
    • Every later refresh:
      • nextPin(stored = live, repo = DEAD) sees a different pin and returns DEAD again.
      • livePin(DEAD) finds the root missing.
      • It then runs the whole search again, because missed only caches misses and a hit calls missed.delete.
      • sameValue(stored, pin) stays true, so no move fires, which hides the repeat work.
    • Cost per refresh: fs.list of the parent, then for the first matching sibling rev-parse, remote get-url and worktree list, then more exists and rev-parse/remote calls per worktree.
    • Scale: that is about 5 or more git spawns on every pane refresh, turn complete and tracker write, for as long as the session stays in the deleted cwd. This is the cost the brief asked me to check.
    • Fix: cache hits too, as a Map<deadRoot, liveRoot> that is re-validated with one exists call. Alternatively, in refreshPin, keep current when found.root is a known-dead root that already relocated.
    • Missing coverage: no test counts spawns across a second refresh.

    W2. The tests leave the new contracts unpinned.
    /home/torvalds/Git/trailhead/plugins/trailhead/tests/repo-pin-relocate.test.ts

    • No test asserts that a live pin makes no extra spawns, only the single exists call. That was the cost concern for livePin running on every refresh.
    • No test covers the 60s negative cache, either that a second refresh inside the window spawns nothing or that one after 60s retries.
    • No test covers a same-remote sibling clone as the fallback. The goal lists this case, but both tests that find a live checkout go through worktree list.
    • Test 3 ("probes only from a live directory") only passes because the rig's process.run handler denies spawns whose cwd is dead. It never asserts that every probe ran with cwd: '/'.
    • A test with a second refresh after the relocation would have exposed W1.

    Info

    I1. The missed cache is module-level and keyed only by the dead root.

    • It is never reset on session start, unlike what isStart does for the pin.
    • Test 3 only passes in isolation because DEAD is shared between tests and the clock is presumably fake.
    • Reordering the tests, or reusing DEAD in a new test, could return the cached miss.
    • Consider exporting a reset for tests, or keying the cache by pinKey plus root.

    Checked and found fine

    • Other git probes: all other probes use git -C <path> with cwd: '/', so none spawn in the dead directory.
    • Remote matching: checkoutOf compares host/slug, so SSH and HTTPS remotes of the same repo match. remoteId also case-folds the host.
    • Unreadable fs: exists defaults to true when the filesystem read throws, so a read error does not move the pin.
    • Missing gh: in gh() (map-pane.tsx), a missing gh with an existing cwd still reports "gh not installed". missingRoot is set only when exists is false.
    • Runtime constraints: nothing in the diff uses Node fs or Buffer. Only $.fs.exists, $.fs.list and $.process.run are used.
    • Pin atom and onMove: onMove still fires only when the stored pin changes, and before the atom update, so that ordering is preserved.
    • Security: no injection surface. The paths go as argv, never through a shell.

    Goal-backward verification

    status: met

    I traced each claim to the two Refs: #347 commits (a69fadc, 70fd28b) and ran the full suite: node scripts/test.js gives 84 ok/skipped, 0 failed. The commit set from the exact Ticket-diff command is those two commits.

    • Claim 1: re-pin to a live checkout and keep working. class: met

      • 70fd28b adds livePin in /home/torvalds/Git/trailhead/plugins/trailhead/hooks/mod/repo-pin.ts, called from refreshPin on the pin it is about to store. The pin stays unchanged if its root exists. If the root is gone, it moves to the main working tree, else another worktree, else a same-remote sibling clone.
      • Every probe runs as git -C <path> from /, so nothing spawns in the dead directory. This covers the stale-pin defect from the DIAGNOSIS, including the host returning the memoized dead root.
      • map-pane.tsx reads the pin atom on each refresh, so the moved pin is what gh runs in afterwards.
      • I did not find a test that re-pins and then runs a full panel gh refresh against the new root. The relocation tests check the pin atom. Together with the atom read, I count the claim as satisfied.
    • Claim 2: a missing checkout says checkout not found: <path>, never "gh not installed". class: met

      • In a69fadc, gh() checks $.fs.exists(pin.root) after a spawn throws. If the root is gone it passes missingRoot, and failureReason returns checkout not found: <path> before the ENOENT branch.
      • When no live checkout exists, the pin stays on the dead root (the "no checkout left" relocate test), so this message is what shows.
      • The fallback is safe: the retry of the search for the same dead root is capped at once every 60 seconds.
    • Claim 3: a real missing gh still says "gh not installed". class: met

      • When the root exists, or fs.exists itself throws, missingRoot is undefined. That falls through to the existing ENOENT → gh not installed mapping.
      • The view test asserts failureReason({ error }) without missingRoot is still gh not installed. The existing failureReason test for that message is also unchanged.
    • Claim 4: regression tests exist for each. class: met

      • Claim 1: /home/torvalds/Git/trailhead/plugins/trailhead/tests/repo-pin-relocate.test.ts. It has three tests: re-pin to the main tree, re-pin to another worktree when the main tree is gone, and keep the pin when none is left.
      • Claim 2: the panel test "a pinned checkout that is gone is named, not blamed on gh", which exists for each surface, in map-pane-ui.test.ts. The view test is in map-pane-view.test.ts.
      • Claim 3: the unchanged-behaviour assertions in map-pane-view.test.ts.

    The only weakness is the one noted under claim 1. The new README text matches the behaviour.

    Tests

    Base current with origin/main (0 behind). Full suite node scripts/test.js: 84 ok/skipped, 0 failed (mod tests 670 pass, 0 fail).

    Next: W1, W2 and I1 are taken in a second fix round before resolving.

  5. ToRvaLDz commented on Nov 2, 2026

    @ToRvaLDz
    OwnerAuthor

    VERIFY (round 2)

    status: clean
    critical: 0 · warning: 2 · info: 1

    I recomputed the ticket diff with the protocol command. It now returns three commits: a69fadc, 70fd28b and dfb9320. I read dfb9320 with --remerge-diff. I did not run the suite. I checked the paths below by reading the code and the new tests.

    Findings ledger

    No new findings.

    Verification of each prior finding

    W1: fixed. /home/torvalds/Git/trailhead/plugins/trailhead/hooks/mod/repo-pin.ts (livePin)

    • livePin now keeps the hit in a relocated map keyed by relocationKey(pin), the repo plus the dead root.
    • On the next refresh the host still names the dead root, so nextPin returns it again.
    • exists(DEAD) is false, so livePin takes the relocated hit and runs one exists(known) check.
    • That is two fs.exists calls and no git spawn, and the stored pin is unchanged, so onMove does not fire.
    • If the cached target dies too, the entry is deleted and the search runs again.
    • A live pin still returns right after the first exists check, with no probe.

    W2: fixed. /home/torvalds/Git/trailhead/plugins/trailhead/tests/repo-pin-relocate.test.ts

    • The new tests cover the gaps I listed:
      • Spawn counts after a relocation: later refreshes make no git -C probe and the pin does not move again.
      • A live pin makes no probe.
      • The 60s miss window: no probe at 59s, a probe again after 61s.
      • A plain same-remote sibling clone with no worktree link.
      • Every probe runs with cwd === '/'.
    • The rig now has a controllable clock.now, so the window test does not depend on real time.

    I1: fixed.

    • Both caches are keyed per repo and dead root.
    • refreshPin clears both when isStart is true.
    • A test confirms a session start probes again.

    Residual notes (not findings)

    • A cached hit stays on a sibling checkout even if the main working tree comes back later, until that sibling dies or a new session starts. This is harmless and cheap.
    • When clock.now fails, the miss cache is skipped and the search runs on every refresh. That is the same as the previous behaviour and unlikely.

    Goal-backward verification

    status: met

    Round 2. The Ticket diff recomputed with the exact Refs: #347 command is three commits: a69fadc, 70fd28b and dfb9320. I read dfb9320 with --remerge-diff and re-ran the suite: node scripts/test.js gives 84 ok/skipped, 0 failed.

    • Claim 1: re-pin to a live checkout and keep working. class: met

      • dfb9320 changes livePin in /home/torvalds/Git/trailhead/plugins/trailhead/hooks/mod/repo-pin.ts. A live pin returns unchanged after one exists check.
      • A found checkout is cached per repo and dead root in relocated. Each later refresh revalidates it with one exists check and drops it if it has also gone. This is needed because the host's memoized repo keeps naming the dead root.
      • The relocation tests check the pin atom, not a full panel gh refresh against the new root. map-pane.tsx reads that atom on each refresh, so I count the claim as satisfied.
      • The caches are cleared when a session starts, so an earlier session's cache cannot leak. missed is now keyed by repo and dead root instead of by dead root alone.
      • The earlier search is unchanged. It looks for the main working tree, then another worktree, then a same-remote sibling, with every probe run as git -C from /.
    • Claim 2: a missing checkout says checkout not found: <path>, never "gh not installed". class: met

      • dfb9320 does not touch gh() or failureReason, so the a69fadc behaviour is intact.
      • When no live checkout exists, the pin stays on the dead root and the miss is cached for 60 seconds. The message therefore still shows.
    • Claim 3: a real missing gh still says "gh not installed". class: met

      • Unchanged by dfb9320. With the root present, missingRoot is undefined and the ENOENT mapping applies. The view tests still assert this.
    • Claim 4: regression tests exist for each. class: met

      • Claim 1 now has these tests in /home/torvalds/Git/trailhead/plugins/trailhead/tests/repo-pin-relocate.test.ts:
        • re-pin to the main working tree, and to another worktree when the main tree is gone;
        • a same-remote sibling clone as the fallback;
        • after a relocation, later refreshes run no git probe;
        • a live pin costs no probe;
        • a search that found nothing is not repeated within 60 seconds and is retried after;
        • every probe runs from /;
        • a session start clears the caches.
      • Claim 2 keeps the panel test in map-pane-ui.test.ts and the view test in map-pane-view.test.ts.
      • Claim 3 keeps the unchanged failureReason assertions.

    I found no new gaps in round 2.

    Tests and acceptance

    Base current with origin/main (0 behind). Full suite node scripts/test.js: 84 ok/skipped, 0 failed.

    Acceptance (guided UAT, user-driven): passed.

    1. Started a session inside a throwaway worktree of this repo with the fixed mod installed, opened the map panel: the map showed.
    2. Deleted that worktree while the session cwd stayed in it, then refreshed the panel: the map kept showing (re-pinned to the main checkout), no gh not installed.
  6. ToRvaLDz commented on Nov 2, 2026

    @ToRvaLDz
    OwnerAuthor

    Resolution

    Cause: two co-occurring defects (see DIAGNOSIS). Spawning gh in a deleted checkout fails with the same ENOENT as a missing binary, and failureReason reported it as gh not installed; the repo pin kept the deleted root because the session cwd reads failed and the host kept naming the dead root.

    Fix:

    • a69fadc: gh() in map-pane.tsx checks whether the pinned root still exists after a spawn failure; failureReason (map-pane-view.ts) then says checkout not found: <path>. A real missing gh still says gh not installed.
    • 70fd28b: repo-pin.ts re-pins a dead root to a live checkout of the same repo (main working tree, another worktree, a same-remote sibling clone), probing with git -C from /, never from the dead directory; documented in the mod README.
    • dfb9320: the found checkout is cached (one existence check per later refresh, no git spawns), a fruitless search is retried at most every 60 seconds, and both caches reset at session start.

    Verification: the latest VERIFY comment holds the review ledger (all findings fixed, none open) and the goal check (met). Full suite green; guided acceptance passed (deleted a followed worktree, the panel re-pinned and kept showing the map).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions