From e62814b4b000084018fb7448a43cf5437db7d4db Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 13 Aug 2026 07:05:32 +0000 Subject: [PATCH] fix(coding-agent): watch git dir so branch display tracks switches The status-line branch watcher bound fs.watch to the .git/HEAD file. git updates HEAD via a lock file plus an atomic rename (HEAD.lock -> HEAD), which unlinks the watched inode, so the watch fired for the first branch switch and then died on the stale inode. The displayed branch froze on the previous branch while later switches went unseen. Watch the git directory instead of the HEAD file (and, for reftable repos, the reftable dir as before) and filter directory events for the HEAD entry. The directory inode is stable across renames, so the watch survives every switch. Fixes #8412 --- packages/coding-agent/CHANGELOG.md | 4 ++ .../modes/components/status-line/component.ts | 20 +++++-- .../test/status-line-vcs-refresh.test.ts | 58 +++++++++++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 14334fddd..1b685373b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the status-line git branch display freezing on the previous branch after the first branch switch, caused by the HEAD watcher binding to a file inode that git unlinks on its atomic HEAD rename ([#8412](https://github.com/can1357/oh-my-pi/issues/8412)). + ## [17.3.0] - 2026-08-13 ### Breaking Changes diff --git a/packages/coding-agent/src/modes/components/status-line/component.ts b/packages/coding-agent/src/modes/components/status-line/component.ts index eff930f3e..0da73f308 100644 --- a/packages/coding-agent/src/modes/components/status-line/component.ts +++ b/packages/coding-agent/src/modes/components/status-line/component.ts @@ -665,13 +665,25 @@ export class StatusLineComponent implements Component { return; } - const watchPath = git.repo.isReftableSync(repository) - ? path.join(repository.gitDir, "reftable") - : repository.headPath; + // Watch the *directory* holding the ref state, not the HEAD file itself. + // git rewrites HEAD via a lock file + atomic rename (`HEAD.lock` → `HEAD`), + // which unlinks the original inode. An `fs.watch` bound to that file inode + // dies after the first branch switch and never fires again, freezing the + // displayed branch (issue #8412). A directory watch tracks the stable + // gitDir inode and survives the rename. + const isReftable = git.repo.isReftableSync(repository); + const watchPath = isReftable ? path.join(repository.gitDir, "reftable") : repository.gitDir; try { - const watcher = fs.watch(watchPath, () => { + const watcher = fs.watch(watchPath, (_event, filename) => { if (this.#disposed || this.#gitWatcher !== watcher) return; + // The gitDir sees churn from many entries (index, ORIG_HEAD, config, + // …); only HEAD moves change the branch. The rename surfaces as a + // `HEAD.lock` event, so match the `HEAD` prefix. The reftable dir + // holds only ref storage, so every change there is ref-relevant. + // `filename` may be null/undefined on some platforms or coalesced + // events — react rather than risk missing a HEAD move. + if (!isReftable && filename != null && !String(filename).startsWith("HEAD")) return; this.invalidateGitCaches(); this.#onBranchChange?.(); }); diff --git a/packages/coding-agent/test/status-line-vcs-refresh.test.ts b/packages/coding-agent/test/status-line-vcs-refresh.test.ts index bb597e280..0f65588b7 100644 --- a/packages/coding-agent/test/status-line-vcs-refresh.test.ts +++ b/packages/coding-agent/test/status-line-vcs-refresh.test.ts @@ -740,3 +740,61 @@ describe("StatusLineComponent applyCwdChange re-points watcher ownership", () => component.dispose(); }); }); + +describe("StatusLineComponent git watcher survives atomic HEAD renames", () => { + let repoDir: string; + + beforeAll(async () => { + repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "status-line-headwatch-")); + const run = (...args: string[]) => Bun.spawnSync(["git", ...args], { cwd: repoDir }); + run("init", "-q"); + run("config", "user.email", "t@example.com"); + run("config", "user.name", "Test"); + run("commit", "--allow-empty", "-q", "-m", "init"); + }); + + afterAll(async () => { + setProjectDir(originalProjectDir); + await fs.rm(repoDir, { recursive: true, force: true }); + }); + + // git rewrites HEAD via a lock file + atomic rename (HEAD.lock → HEAD), which + // unlinks the inode. The pre-fix watcher bound `fs.watch` to the HEAD file, so + // it fired for the first switch and then died on the stale inode — freezing the + // displayed branch on every subsequent switch (issue #8412). Watching the git + // directory instead keeps the watch alive across renames. + it("keeps firing #onBranchChange across consecutive branch switches", async () => { + vi.spyOn(git.branch, "default").mockReturnValue(Promise.withResolvers().promise); + vi.spyOn(git.status, "summary").mockReturnValue(Promise.withResolvers().promise); + vi.spyOn(jj.repo, "rootSync").mockReturnValue(null); + + setProjectDir(repoDir); + const component = new StatusLineComponent(makeSession()); + component.updateSettings(gitSegment); + + // Await the watcher's own #onBranchChange signal rather than a wall-clock + // delay: a fresh resolver is armed before each switch and awaited after it, + // so a frozen watcher surfaces as the test-runner timeout, not a flake. + let branchChanged = Promise.withResolvers(); + component.watchBranch(() => branchChanged.resolve()); + // Prime the branch cache off the initial HEAD. The status/default mocks + // never resolve, so this cold paint cannot fire #onBranchChange itself. + component.getTopBorder(80); + + const switchTo = async (branchName: string) => { + const fired = branchChanged.promise; + await git.branch.checkoutNew(repoDir, branchName); + await fired; + branchChanged = Promise.withResolvers(); + }; + + await switchTo("first"); + expect(component.getTopBorder(80).content).toContain("first"); + + // Regression: the second switch must still reach the display. + await switchTo("second"); + expect(component.getTopBorder(80).content).toContain("second"); + + component.dispose(); + }); +});