authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-15 01:14:17-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-15 01:23:10-08:00
log98c4b7c724da7f6aeca67a8c34f53af666fa8a01
tree0097a17f12ea1d25cb65c99bf56a97adb4e78348
parent60111b2b912e8ebc9c21a6bac836b60cf8b75122

fetch in background

Co-authored-by: codex <codex@users.noreply.github.com>

2 files changed, 75 insertions(+), 11 deletions(-)

apps/server/src/git.test.ts+73-6
...@@ -272,11 +272,12 @@ describe("git integration", () => {...@@ -272,11 +272,12 @@ describe("git integration", () => {
272272
273 await checkoutGitBranch({ cwd: source.path, branch: featureBranch });273 await checkoutGitBranch({ cwd: source.path, branch: featureBranch });
274 const core = new GitCoreService();274 const core = new GitCoreService();
275 const details = await core.statusDetails(source.path);275 await vi.waitFor(async () => {
276276 const details = await core.statusDetails(source.path);
277 expect(details.branch).toBe(featureBranch);277 expect(details.branch).toBe(featureBranch);
278 expect(details.aheadCount).toBe(0);278 expect(details.aheadCount).toBe(0);
279 expect(details.behindCount).toBe(1);279 expect(details.behindCount).toBe(1);
280 });
280 });281 });
281282
282 it("keeps checkout successful when upstream refresh fails", async () => {283 it("keeps checkout successful when upstream refresh fails", async () => {
...@@ -320,7 +321,9 @@ describe("git integration", () => {...@@ -320,7 +321,9 @@ describe("git integration", () => {
320 await expect(core.checkoutBranch({ cwd: source.path, branch: featureBranch })).resolves.toBe(321 await expect(core.checkoutBranch({ cwd: source.path, branch: featureBranch })).resolves.toBe(
321 undefined,322 undefined,
322 );323 );
323 expect(core.refreshFetchAttempts).toBe(1);324 await vi.waitFor(() => {
325 expect(core.refreshFetchAttempts).toBe(1);
326 });
324 expect(await git(source.path, "branch --show-current")).toBe(featureBranch);327 expect(await git(source.path, "branch --show-current")).toBe(featureBranch);
325 });328 });
326329
...@@ -362,6 +365,9 @@ describe("git integration", () => {...@@ -362,6 +365,9 @@ describe("git integration", () => {
362365
363 const core = new FetchRecordingGitCoreService();366 const core = new FetchRecordingGitCoreService();
364 await core.checkoutBranch({ cwd: source.path, branch: featureBranch });367 await core.checkoutBranch({ cwd: source.path, branch: featureBranch });
368 await vi.waitFor(() => {
369 expect(core.fetchArgs).not.toBeNull();
370 });
365371
366 expect(await git(source.path, "branch --show-current")).toBe(featureBranch);372 expect(await git(source.path, "branch --show-current")).toBe(featureBranch);
367 expect(core.fetchArgs).toEqual([373 expect(core.fetchArgs).toEqual([
...@@ -373,6 +379,67 @@ describe("git integration", () => {...@@ -373,6 +379,67 @@ describe("git integration", () => {
373 ]);379 ]);
374 });380 });
375381
382 it("returns checkout result before background upstream refresh completes", async () => {
383 await using remote = await makeTmpDir();
384 await using source = await makeTmpDir();
385 await git(remote.path, "init --bare");
386
387 await initRepoWithCommit(source.path);
388 const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find(
389 (branch) => branch.current,
390 )!.name;
391 await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`);
392 await git(source.path, `push -u origin ${defaultBranch}`);
393
394 const featureBranch = "feature/background-refresh";
395 await git(source.path, `checkout -b ${featureBranch}`);
396 await writeFile(path.join(source.path, "feature.txt"), "feature base\n");
397 await git(source.path, "add feature.txt");
398 await git(source.path, "commit -m 'feature base'");
399 await git(source.path, `push -u origin ${featureBranch}`);
400 await git(source.path, `checkout ${defaultBranch}`);
401
402 class SlowRefreshGitCoreService extends GitCoreService {
403 fetchStarted = false;
404 private readonly waitForReleasePromise: Promise<void>;
405 private releaseWait: () => void = () => undefined;
406
407 constructor() {
408 super();
409 this.waitForReleasePromise = new Promise<void>((resolve) => {
410 this.releaseWait = resolve;
411 });
412 }
413
414 releaseFetch(): void {
415 this.releaseWait();
416 }
417
418 override async git(
419 cwd: string,
420 args: readonly string[],
421 allowNonZeroExit = false,
422 ): Promise<void> {
423 if (args[0] === "fetch") {
424 this.fetchStarted = true;
425 await this.waitForReleasePromise;
426 return;
427 }
428 await super.git(cwd, args, allowNonZeroExit);
429 }
430 }
431
432 const core = new SlowRefreshGitCoreService();
433 await expect(core.checkoutBranch({ cwd: source.path, branch: featureBranch })).resolves.toBe(
434 undefined,
435 );
436 await vi.waitFor(() => {
437 expect(core.fetchStarted).toBe(true);
438 });
439 expect(await git(source.path, "branch --show-current")).toBe(featureBranch);
440 core.releaseFetch();
441 });
442
376 it("throws when branch does not exist", async () => {443 it("throws when branch does not exist", async () => {
377 await using tmp = await makeTmpDir();444 await using tmp = await makeTmpDir();
378 await initRepoWithCommit(tmp.path);445 await initRepoWithCommit(tmp.path);
apps/server/src/git.ts+2-5
...@@ -587,11 +587,8 @@ export class GitCoreService {...@@ -587,11 +587,8 @@ export class GitCoreService {
587 timeoutMs: 10_000,587 timeoutMs: 10_000,
588 fallbackErrorMessage: "git checkout failed",588 fallbackErrorMessage: "git checkout failed",
589 });589 });
590 try {590 // Refresh upstream refs in the background so checkout remains responsive.
591 await this.refreshCheckedOutBranchUpstream(input.cwd);591 void this.refreshCheckedOutBranchUpstream(input.cwd).catch(() => undefined);
592 } catch {
593 // Best effort: checkout already succeeded, so avoid surfacing refresh failures.
594 }
595 }592 }
596593
597 async initRepo(input: GitInitInput): Promise<void> {594 async initRepo(input: GitInitInput): Promise<void> {