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
This commit is contained in:
roboomp
2026-08-13 07:05:32 +00:00
parent 326d24bd40
commit e62814b4b0
3 changed files with 78 additions and 4 deletions
+4
View File
@@ -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
@@ -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?.();
});
@@ -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<string | null>().promise);
vi.spyOn(git.status, "summary").mockReturnValue(Promise.withResolvers<GitStatus | null>().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<void>();
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<void>();
};
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();
});
});