authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 00:33:36-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 00:53:20-08:00
log72d7fccc525e0d19dfe975221d086a1b816d2045
treee70a5503a8b33e2d052a30009c24b49f929f40aa
parentf145a453b56828af4ad61263ca267ebaeec5a141

Make checkout tolerate upstream refresh failures

- treat post-checkout upstream fetch as best effort so checkout still succeeds - route refresh fetch through `this.git(...)` for consistent behavior - add regression test for fetch-timeout during branch checkout

2 files changed, 51 insertions(+), 5 deletions(-)

apps/server/src/git.test.ts+45
...@@ -279,6 +279,51 @@ describe("git integration", () => {...@@ -279,6 +279,51 @@ describe("git integration", () => {
279 expect(details.behindCount).toBe(1);279 expect(details.behindCount).toBe(1);
280 });280 });
281281
282 it("keeps checkout successful when upstream refresh fails", async () => {
283 await using remote = await makeTmpDir();
284 await using source = await makeTmpDir();
285 await git(remote.path, "init --bare");
286
287 await initRepoWithCommit(source.path);
288 const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find(
289 (branch) => branch.current,
290 )!.name;
291 await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`);
292 await git(source.path, `push -u origin ${defaultBranch}`);
293
294 const featureBranch = "feature-refresh-failure";
295 await git(source.path, `branch ${featureBranch}`);
296 await git(source.path, `checkout ${featureBranch}`);
297 await writeFile(path.join(source.path, "feature.txt"), "feature base\n");
298 await git(source.path, "add feature.txt");
299 await git(source.path, "commit -m 'feature base'");
300 await git(source.path, `push -u origin ${featureBranch}`);
301 await git(source.path, `checkout ${defaultBranch}`);
302
303 class RefreshFailingGitCoreService extends GitCoreService {
304 refreshFetchAttempts = 0;
305
306 override async git(
307 cwd: string,
308 args: readonly string[],
309 allowNonZeroExit = false,
310 ): Promise<void> {
311 if (args[0] === "fetch") {
312 this.refreshFetchAttempts += 1;
313 throw new Error("simulated fetch timeout");
314 }
315 await super.git(cwd, args, allowNonZeroExit);
316 }
317 }
318
319 const core = new RefreshFailingGitCoreService();
320 await expect(core.checkoutBranch({ cwd: source.path, branch: featureBranch })).resolves.toBe(
321 undefined,
322 );
323 expect(core.refreshFetchAttempts).toBe(1);
324 expect(await git(source.path, "branch --show-current")).toBe(featureBranch);
325 });
326
282 it("throws when branch does not exist", async () => {327 it("throws when branch does not exist", async () => {
283 await using tmp = await makeTmpDir();328 await using tmp = await makeTmpDir();
284 await initRepoWithCommit(tmp.path);329 await initRepoWithCommit(tmp.path);
apps/server/src/git.ts+6-5
...@@ -574,10 +574,7 @@ export class GitCoreService {...@@ -574,10 +574,7 @@ export class GitCoreService {
574 return;574 return;
575 }575 }
576576
577 await executeGit(cwd, ["fetch", "--quiet", "--no-tags", remoteName], {577 await this.git(cwd, ["fetch", "--quiet", "--no-tags", remoteName], true);
578 timeoutMs: 20_000,
579 allowNonZeroExit: true,
580 });
581 }578 }
582579
583 async checkoutBranch(input: GitCheckoutInput): Promise<void> {580 async checkoutBranch(input: GitCheckoutInput): Promise<void> {
...@@ -585,7 +582,11 @@ export class GitCoreService {...@@ -585,7 +582,11 @@ export class GitCoreService {
585 timeoutMs: 10_000,582 timeoutMs: 10_000,
586 fallbackErrorMessage: "git checkout failed",583 fallbackErrorMessage: "git checkout failed",
587 });584 });
588 await this.refreshCheckedOutBranchUpstream(input.cwd);585 try {
586 await this.refreshCheckedOutBranchUpstream(input.cwd);
587 } catch {
588 // Best effort: checkout already succeeded, so avoid surfacing refresh failures.
589 }
589 }590 }
590591
591 async initRepo(input: GitInitInput): Promise<void> {592 async initRepo(input: GitInitInput): Promise<void> {