| author | |
| committer | |
| log | f145a453b56828af4ad61263ca267ebaeec5a141 |
| tree | c055f6ceef8fab3edae3f4ba9aa1c01b595db2c3 |
| parent | 1f4f693dd6525814996741c32a97dda0ed574939 |
- fetch tracked remote after checkout to update ahead/behind counts
- add integration test for behind count when remote branch advances
- invalidate git React Query data on `turn/completed` events in the web app3 files changed, 75 insertions(+), 2 deletions(-)
apps/server/src/git.test.ts+40| ... | @@ -239,6 +239,46 @@ describe("git integration", () => { | ... | @@ -239,6 +239,46 @@ describe("git integration", () => { |
| 239 | expect(current!.name).toBe("feature"); | 239 | expect(current!.name).toBe("feature"); |
| 240 | }); | 240 | }); |
| 241 | 241 | ||
| 242 | it("refreshes upstream behind count after checkout when remote branch advanced", async () => { | ||
| 243 | await using remote = await makeTmpDir(); | ||
| 244 | await using source = await makeTmpDir(); | ||
| 245 | await using clone = await makeTmpDir(); | ||
| 246 | await git(remote.path, "init --bare"); | ||
| 247 | |||
| 248 | await initRepoWithCommit(source.path); | ||
| 249 | const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( | ||
| 250 | (branch) => branch.current, | ||
| 251 | )!.name; | ||
| 252 | await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); | ||
| 253 | await git(source.path, `push -u origin ${defaultBranch}`); | ||
| 254 | |||
| 255 | const featureBranch = "feature-behind"; | ||
| 256 | await createGitBranch({ cwd: source.path, branch: featureBranch }); | ||
| 257 | await checkoutGitBranch({ cwd: source.path, branch: featureBranch }); | ||
| 258 | await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); | ||
| 259 | await git(source.path, "add feature.txt"); | ||
| 260 | await git(source.path, "commit -m 'feature base'"); | ||
| 261 | await git(source.path, `push -u origin ${featureBranch}`); | ||
| 262 | await checkoutGitBranch({ cwd: source.path, branch: defaultBranch }); | ||
| 263 | |||
| 264 | await git(clone.path, `clone ${JSON.stringify(remote.path)} .`); | ||
| 265 | await git(clone.path, "config user.email 'test@test.com'"); | ||
| 266 | await git(clone.path, "config user.name 'Test'"); | ||
| 267 | await git(clone.path, `checkout -b ${featureBranch} --track origin/${featureBranch}`); | ||
| 268 | await writeFile(path.join(clone.path, "feature.txt"), "feature from remote\n"); | ||
| 269 | await git(clone.path, "add feature.txt"); | ||
| 270 | await git(clone.path, "commit -m 'remote feature update'"); | ||
| 271 | await git(clone.path, `push origin ${featureBranch}`); | ||
| 272 | |||
| 273 | await checkoutGitBranch({ cwd: source.path, branch: featureBranch }); | ||
| 274 | const core = new GitCoreService(); | ||
| 275 | const details = await core.statusDetails(source.path); | ||
| 276 | |||
| 277 | expect(details.branch).toBe(featureBranch); | ||
| 278 | expect(details.aheadCount).toBe(0); | ||
| 279 | expect(details.behindCount).toBe(1); | ||
| 280 | }); | ||
| 281 | |||
| 242 | it("throws when branch does not exist", async () => { | 282 | it("throws when branch does not exist", async () => { |
| 243 | await using tmp = await makeTmpDir(); | 283 | await using tmp = await makeTmpDir(); |
| 244 | await initRepoWithCommit(tmp.path); | 284 | await initRepoWithCommit(tmp.path); |
apps/server/src/git.ts+28| ... | @@ -553,11 +553,39 @@ export class GitCoreService { | ... | @@ -553,11 +553,39 @@ export class GitCoreService { |
| 553 | }); | 553 | }); |
| 554 | } | 554 | } |
| 555 | 555 | ||
| 556 | private async refreshCheckedOutBranchUpstream(cwd: string): Promise<void> { | ||
| 557 | const upstreamRef = trimStdout( | ||
| 558 | await this.gitStdout( | ||
| 559 | cwd, | ||
| 560 | ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], | ||
| 561 | true, | ||
| 562 | ), | ||
| 563 | ); | ||
| 564 | if (upstreamRef.length === 0 || upstreamRef === "@{upstream}") { | ||
| 565 | return; | ||
| 566 | } | ||
| 567 | |||
| 568 | const separatorIndex = upstreamRef.indexOf("/"); | ||
| 569 | if (separatorIndex <= 0) { | ||
| 570 | return; | ||
| 571 | } | ||
| 572 | const remoteName = upstreamRef.slice(0, separatorIndex); | ||
| 573 | if (remoteName.length === 0) { | ||
| 574 | return; | ||
| 575 | } | ||
| 576 | |||
| 577 | await executeGit(cwd, ["fetch", "--quiet", "--no-tags", remoteName], { | ||
| 578 | timeoutMs: 20_000, | ||
| 579 | allowNonZeroExit: true, | ||
| 580 | }); | ||
| 581 | } | ||
| 582 | |||
| 556 | async checkoutBranch(input: GitCheckoutInput): Promise<void> { | 583 | async checkoutBranch(input: GitCheckoutInput): Promise<void> { |
| 557 | await executeGit(input.cwd, ["checkout", input.branch], { | 584 | await executeGit(input.cwd, ["checkout", input.branch], { |
| 558 | timeoutMs: 10_000, | 585 | timeoutMs: 10_000, |
| 559 | fallbackErrorMessage: "git checkout failed", | 586 | fallbackErrorMessage: "git checkout failed", |
| 560 | }); | 587 | }); |
| 588 | await this.refreshCheckedOutBranchUpstream(input.cwd); | ||
| 561 | } | 589 | } |
| 562 | 590 | ||
| 563 | async initRepo(input: GitInitInput): Promise<void> { | 591 | async initRepo(input: GitInitInput): Promise<void> { |
apps/web/src/App.tsx+7-2| ... | @@ -1,4 +1,4 @@ | ... | @@ -1,4 +1,4 @@ |
| 1 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; | 1 | import { QueryClient, QueryClientProvider, useQueryClient } from "@tanstack/react-query"; |
| 2 | import { useEffect, useRef } from "react"; | 2 | import { useEffect, useRef } from "react"; |
| 3 | 3 | ||
| 4 | import ChatView from "./components/ChatView"; | 4 | import ChatView from "./components/ChatView"; |
| ... | @@ -11,22 +11,27 @@ import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./ty | ... | @@ -11,22 +11,27 @@ import { DEFAULT_THREAD_TERMINAL_HEIGHT, DEFAULT_THREAD_TERMINAL_ID } from "./ty |
| 11 | import { onServerWelcome } from "./wsNativeApi"; | 11 | import { onServerWelcome } from "./wsNativeApi"; |
| 12 | import { useNativeApi } from "./hooks/useNativeApi"; | 12 | import { useNativeApi } from "./hooks/useNativeApi"; |
| 13 | import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast"; | 13 | import { AnchoredToastProvider, ToastProvider } from "./components/ui/toast"; |
| 14 | import { invalidateGitQueries } from "./lib/gitReactQuery"; | ||
| 14 | 15 | ||
| 15 | function EventRouter() { | 16 | function EventRouter() { |
| 16 | const api = useNativeApi(); | 17 | const api = useNativeApi(); |
| 17 | const { dispatch } = useStore(); | 18 | const { dispatch } = useStore(); |
| 19 | const queryClient = useQueryClient(); | ||
| 18 | const activeAssistantItemRef = useRef<string | null>(null); | 20 | const activeAssistantItemRef = useRef<string | null>(null); |
| 19 | 21 | ||
| 20 | useEffect(() => { | 22 | useEffect(() => { |
| 21 | if (!api) return; | 23 | if (!api) return; |
| 22 | return api.providers.onEvent((event) => { | 24 | return api.providers.onEvent((event) => { |
| 25 | if (event.method === "turn/completed") { | ||
| 26 | void invalidateGitQueries(queryClient); | ||
| 27 | } | ||
| 23 | dispatch({ | 28 | dispatch({ |
| 24 | type: "APPLY_EVENT", | 29 | type: "APPLY_EVENT", |
| 25 | event, | 30 | event, |
| 26 | activeAssistantItemRef, | 31 | activeAssistantItemRef, |
| 27 | }); | 32 | }); |
| 28 | }); | 33 | }); |
| 29 | }, [api, dispatch]); | 34 | }, [api, dispatch, queryClient]); |
| 30 | 35 | ||
| 31 | useEffect(() => { | 36 | useEffect(() => { |
| 32 | if (!api) return; | 37 | if (!api) return; |