Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 134 additions & 0 deletions apps/server/src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,140 @@ describe("git integration", () => {
expect(current!.name).toBe("feature");
});

it("refreshes upstream behind count after checkout when remote branch advanced", async () => {
await using remote = await makeTmpDir();
await using source = await makeTmpDir();
await using clone = await makeTmpDir();
await git(remote.path, "init --bare");

await initRepoWithCommit(source.path);
const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find(
(branch) => branch.current,
)!.name;
await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`);
await git(source.path, `push -u origin ${defaultBranch}`);

const featureBranch = "feature-behind";
await createGitBranch({ cwd: source.path, branch: featureBranch });
await checkoutGitBranch({ cwd: source.path, branch: featureBranch });
await writeFile(path.join(source.path, "feature.txt"), "feature base\n");
await git(source.path, "add feature.txt");
await git(source.path, "commit -m 'feature base'");
await git(source.path, `push -u origin ${featureBranch}`);
await checkoutGitBranch({ cwd: source.path, branch: defaultBranch });

await git(clone.path, `clone ${JSON.stringify(remote.path)} .`);
await git(clone.path, "config user.email 'test@test.com'");
await git(clone.path, "config user.name 'Test'");
await git(clone.path, `checkout -b ${featureBranch} --track origin/${featureBranch}`);
await writeFile(path.join(clone.path, "feature.txt"), "feature from remote\n");
await git(clone.path, "add feature.txt");
await git(clone.path, "commit -m 'remote feature update'");
await git(clone.path, `push origin ${featureBranch}`);

await checkoutGitBranch({ cwd: source.path, branch: featureBranch });
const core = new GitCoreService();
const details = await core.statusDetails(source.path);

expect(details.branch).toBe(featureBranch);
expect(details.aheadCount).toBe(0);
expect(details.behindCount).toBe(1);
});

it("keeps checkout successful when upstream refresh fails", async () => {
await using remote = await makeTmpDir();
await using source = await makeTmpDir();
await git(remote.path, "init --bare");

await initRepoWithCommit(source.path);
const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find(
(branch) => branch.current,
)!.name;
await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`);
await git(source.path, `push -u origin ${defaultBranch}`);

const featureBranch = "feature-refresh-failure";
await git(source.path, `branch ${featureBranch}`);
await git(source.path, `checkout ${featureBranch}`);
await writeFile(path.join(source.path, "feature.txt"), "feature base\n");
await git(source.path, "add feature.txt");
await git(source.path, "commit -m 'feature base'");
await git(source.path, `push -u origin ${featureBranch}`);
await git(source.path, `checkout ${defaultBranch}`);

class RefreshFailingGitCoreService extends GitCoreService {
refreshFetchAttempts = 0;

override async git(
cwd: string,
args: readonly string[],
allowNonZeroExit = false,
): Promise<void> {
if (args[0] === "fetch") {
this.refreshFetchAttempts += 1;
throw new Error("simulated fetch timeout");
}
await super.git(cwd, args, allowNonZeroExit);
}
}

const core = new RefreshFailingGitCoreService();
await expect(core.checkoutBranch({ cwd: source.path, branch: featureBranch })).resolves.toBe(
undefined,
);
expect(core.refreshFetchAttempts).toBe(1);
expect(await git(source.path, "branch --show-current")).toBe(featureBranch);
});

it("refresh fetch is scoped to the checked out branch upstream refspec", async () => {
await using remote = await makeTmpDir();
await using source = await makeTmpDir();
await git(remote.path, "init --bare");

await initRepoWithCommit(source.path);
const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find(
(branch) => branch.current,
)!.name;
await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`);
await git(source.path, `push -u origin ${defaultBranch}`);

const featureBranch = "feature/scoped-fetch";
await git(source.path, `checkout -b ${featureBranch}`);
await writeFile(path.join(source.path, "feature.txt"), "feature base\n");
await git(source.path, "add feature.txt");
await git(source.path, "commit -m 'feature base'");
await git(source.path, `push -u origin ${featureBranch}`);
await git(source.path, `checkout ${defaultBranch}`);

class FetchRecordingGitCoreService extends GitCoreService {
fetchArgs: readonly string[] | null = null;

override async git(
cwd: string,
args: readonly string[],
allowNonZeroExit = false,
): Promise<void> {
if (args[0] === "fetch") {
this.fetchArgs = args;
return;
}
await super.git(cwd, args, allowNonZeroExit);
}
}

const core = new FetchRecordingGitCoreService();
await core.checkoutBranch({ cwd: source.path, branch: featureBranch });

expect(await git(source.path, "branch --show-current")).toBe(featureBranch);
expect(core.fetchArgs).toEqual([
"fetch",
"--quiet",
"--no-tags",
"origin",
`+refs/heads/${featureBranch}:refs/remotes/origin/${featureBranch}`,
]);
});

it("throws when branch does not exist", async () => {
await using tmp = await makeTmpDir();
await initRepoWithCommit(tmp.path);
Expand Down
34 changes: 34 additions & 0 deletions apps/server/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -553,11 +553,45 @@ export class GitCoreService {
});
}

private async refreshCheckedOutBranchUpstream(cwd: string): Promise<void> {
const upstreamRef = trimStdout(
await this.gitStdout(
cwd,
["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"],
true,
),
);
if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") {
return;
}

const separatorIndex = upstreamRef.indexOf("/");
if (separatorIndex <= 0) {
return;
}
const remoteName = upstreamRef.slice(0, separatorIndex);
const upstreamBranch = upstreamRef.slice(separatorIndex + 1);
if (remoteName.length === 0) {
return;
}
if (upstreamBranch.length === 0) {
return;
}

const refspec = `+refs/heads/${upstreamBranch}:refs/remotes/${upstreamRef}`;
await this.git(cwd, ["fetch", "--quiet", "--no-tags", remoteName, refspec], true);
}

async checkoutBranch(input: GitCheckoutInput): Promise<void> {
await executeGit(input.cwd, ["checkout", input.branch], {
timeoutMs: 10_000,
fallbackErrorMessage: "git checkout failed",
});
try {
await this.refreshCheckedOutBranchUpstream(input.cwd);
} catch {
// Best effort: checkout already succeeded, so avoid surfacing refresh failures.
}
}
Comment thread
juliusmarminge marked this conversation as resolved.

async initRepo(input: GitInitInput): Promise<void> {
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";

import ChatView from "./components/ChatView";
Expand All @@ -11,22 +11,27 @@ import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./ty
import { onServerWelcome } from "./wsNativeApi";
import { useNativeApi } from "./hooks/useNativeApi";
import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast";
import { invalidateGitQueries } from "./lib/gitReactQuery";

function EventRouter() {
const api = useNativeApi();
const { dispatch } = useStore();
const queryClient = useQueryClient();
const activeAssistantItemRef = useRef<string | null>(null);

useEffect(() => {
if (!api) return;
return api.providers.onEvent((event) => {
if (event.method === "turn/completed") {
void invalidateGitQueries(queryClient);
}
dispatch({
type: "APPLY_EVENT",
event,
activeAssistantItemRef,
});
});
}, [api, dispatch]);
}, [api, dispatch, queryClient]);

useEffect(() => {
if (!api) return;
Expand Down