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(); + }); +});