authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-12 23:28:10-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-12 23:29:44-08:00
log6b916d3a6fbe2145ac53776c73ba7b3d0aa969de
treea4ff8856f92390614a05277ffdd0cfa50879d6e0
parent21580dc1b0860e5cc711ab2cc494b52e64103d92

Handle branch recency lookup failures in branch listing

- move branch recency parsing into `readBranchRecency` - fall back to an empty recency map if lookup fails - add test to ensure branches still list when recency read times out Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 52 insertions(+), 25 deletions(-)

apps/server/src/git.test.ts+17-1
...@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";...@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
2import { mkdtemp, rm, writeFile } from "node:fs/promises";2import { mkdtemp, rm, writeFile } from "node:fs/promises";
3import { tmpdir } from "node:os";3import { tmpdir } from "node:os";
4import path from "node:path";4import path from "node:path";
5import { describe, expect, it } from "vitest";5import { describe, expect, it, vi } from "vitest";
66
7import {7import {
8 GitCoreService,8 GitCoreService,
...@@ -603,5 +603,21 @@ describe("git integration", () => {...@@ -603,5 +603,21 @@ describe("git integration", () => {
603 const skipped = await core.pushCurrentBranch(tmp.path, null);603 const skipped = await core.pushCurrentBranch(tmp.path, null);
604 expect(skipped.status).toBe("skipped_up_to_date");604 expect(skipped.status).toBe("skipped_up_to_date");
605 });605 });
606
607 it("lists branches when recency lookup fails", async () => {
608 await using tmp = await makeTmpDir();
609 await initRepoWithCommit(tmp.path);
610 const core = new GitCoreService();
611 const recencySpy = vi
612 .spyOn(core as any, "readBranchRecency")
613 .mockRejectedValueOnce(new Error("timeout"));
614
615 const result = await core.listBranches({ cwd: tmp.path });
616
617 expect(result.isRepo).toBe(true);
618 expect(result.branches.length).toBeGreaterThan(0);
619 expect(result.branches[0]?.current).toBe(true);
620 recencySpy.mockRestore();
621 });
606 });622 });
607});623});
apps/server/src/git.ts+35-24
...@@ -312,7 +312,40 @@ export class GitCoreService {...@@ -312,7 +312,40 @@ export class GitCoreService {
312 return value.length > 0 ? value : null;312 return value.length > 0 ? value : null;
313 }313 }
314314
315 private async readBranchRecency(cwd: string): Promise<Map<string, number>> {
316 const branchRecency = await executeGit(
317 cwd,
318 ["for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", "refs/heads"],
319 {
320 timeoutMs: 15_000,
321 allowNonZeroExit: true,
322 },
323 );
324
325 const branchLastCommit = new Map<string, number>();
326 if (branchRecency.code !== 0) {
327 return branchLastCommit;
328 }
329
330 for (const line of branchRecency.stdout.split("\n")) {
331 if (line.length === 0) {
332 continue;
333 }
334 const [name, lastCommitRaw] = line.split("\t");
335 if (!name) {
336 continue;
337 }
338 const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10);
339 branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0);
340 }
341
342 return branchLastCommit;
343 }
344
315 async listBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {345 async listBranches(input: GitListBranchesInput): Promise<GitListBranchesResult> {
346 const branchRecencyPromise = this.readBranchRecency(input.cwd).catch(
347 () => new Map<string, number>(),
348 );
316 const result = await executeGit(input.cwd, ["branch", "--no-color"], {349 const result = await executeGit(input.cwd, ["branch", "--no-color"], {
317 timeoutMs: 10_000,350 timeoutMs: 10_000,
318 allowNonZeroExit: true,351 allowNonZeroExit: true,
...@@ -326,7 +359,7 @@ export class GitCoreService {...@@ -326,7 +359,7 @@ export class GitCoreService {
326 throw new Error(stderr || "git branch failed");359 throw new Error(stderr || "git branch failed");
327 }360 }
328361
329 const [defaultRef, worktreeList, branchRecency] = await Promise.all([362 const [defaultRef, worktreeList, branchLastCommit] = await Promise.all([
330 executeGit(input.cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], {363 executeGit(input.cwd, ["symbolic-ref", "refs/remotes/origin/HEAD"], {
331 timeoutMs: 5_000,364 timeoutMs: 5_000,
332 allowNonZeroExit: true,365 allowNonZeroExit: true,
...@@ -335,14 +368,7 @@ export class GitCoreService {...@@ -335,14 +368,7 @@ export class GitCoreService {
335 timeoutMs: 5_000,368 timeoutMs: 5_000,
336 allowNonZeroExit: true,369 allowNonZeroExit: true,
337 }),370 }),
338 executeGit(371 branchRecencyPromise,
339 input.cwd,
340 ["for-each-ref", "--format=%(refname:short)%09%(committerdate:unix)", "refs/heads"],
341 {
342 timeoutMs: 5_000,
343 allowNonZeroExit: true,
344 },
345 ),
346 ]);372 ]);
347 const defaultBranch =373 const defaultBranch =
348 defaultRef.code === 0374 defaultRef.code === 0
...@@ -364,21 +390,6 @@ export class GitCoreService {...@@ -364,21 +390,6 @@ export class GitCoreService {
364 }390 }
365 }391 }
366392
367 const branchLastCommit = new Map<string, number>();
368 if (branchRecency.code === 0) {
369 for (const line of branchRecency.stdout.split("\n")) {
370 if (line.length === 0) {
371 continue;
372 }
373 const [name, lastCommitRaw] = line.split("\t");
374 if (!name) {
375 continue;
376 }
377 const lastCommit = Number.parseInt(lastCommitRaw ?? "0", 10);
378 branchLastCommit.set(name, Number.isFinite(lastCommit) ? lastCommit : 0);
379 }
380 }
381
382 const branches = result.stdout393 const branches = result.stdout
383 .split("\n")394 .split("\n")
384 .map((line) => line.trim())395 .map((line) => line.trim())