Skip to content

Commit bc7fe11

Browse files
fix(web): a new worktree thread opened during a clone bases on the real default branch (#36379)
1 parent b35819c commit bc7fe11

9 files changed

Lines changed: 138 additions & 4 deletions

File tree

‎apps/server/src/mcp/toolkits/project/handlers.test.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -788,6 +788,7 @@ it.effect("an agent's clone is not cut off while git is still transferring", ()
788788
),
789789
Layer.provide(
790790
Layer.mock(GitVcsDriver.GitVcsDriver)({
791+
invalidateRefs: () => Effect.void,
791792
// Like the real driver, the clone fails once it outlives the deadline it was given.
792793
execute: (input) =>
793794
Deferred.succeed(started, undefined).pipe(

‎apps/server/src/sourceControl/SourceControlRepositoryService.test.ts‎

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ function layer(input: {
7878
Layer.provide(
7979
Layer.mock(GitVcsDriver.GitVcsDriver)({
8080
execute: () => Effect.succeed(processOutput()),
81+
invalidateRefs: () => Effect.void,
8182
ensureRemote: () => Effect.succeed("origin"),
8283
pushCurrentBranch: () =>
8384
Effect.succeed({
@@ -220,7 +221,7 @@ it.effect.each(["--bare", "source.git"])(
220221
});
221222
assert.strictEqual(result.cwd, destinationPath);
222223
assert.isTrue(yield* fs.exists(path.join(destinationPath, ".git")));
223-
}).pipe(Effect.provide(layer({ git: { execute: git.execute } })));
224+
}).pipe(Effect.provide(layer({ git })));
224225
}).pipe(
225226
Effect.provide(
226227
GitVcsDriver.layer.pipe(
@@ -235,6 +236,72 @@ it.effect.each(["--bare", "source.git"])(
235236
),
236237
);
237238

239+
it.effect.each([
240+
{ readsWhen: "before git creates the repository", readBeforeInit: true },
241+
{ readsWhen: "while HEAD is unborn", readBeforeInit: false },
242+
])("lists the cloned branches after a ref list was read $readsWhen", ({ readBeforeInit }) =>
243+
Effect.gen(function* () {
244+
const fs = yield* FileSystem.FileSystem;
245+
const path = yield* Path.Path;
246+
const git = yield* GitVcsDriver.GitVcsDriver;
247+
const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-clone-refs-" });
248+
const source = path.join(parent, "source");
249+
const destinationPath = path.join(parent, "checkout");
250+
const run = (cwd: string, args: ReadonlyArray<string>) =>
251+
git.execute({ operation: "test.git", cwd, args });
252+
yield* run(parent, ["init", "--initial-branch=staging", source]);
253+
yield* run(source, [
254+
"-c",
255+
"user.name=Test",
256+
"-c",
257+
258+
"commit",
259+
"--allow-empty",
260+
"-m",
261+
"init",
262+
]);
263+
const refNames = git
264+
.listRefs({ cwd: destinationPath })
265+
.pipe(Effect.map((result) => result.refs.map((ref) => ref.name)));
266+
let duringClone: ReadonlyArray<string> | null = null;
267+
// Stands in for git clone so the read lands at a chosen point inside it.
268+
const cloneInTwoSteps = Effect.gen(function* () {
269+
yield* fs.makeDirectory(destinationPath).pipe(Effect.orDie);
270+
if (readBeforeInit) duringClone = yield* refNames;
271+
yield* run(parent, ["init", "--initial-branch=staging", destinationPath]);
272+
if (!readBeforeInit) duringClone = yield* refNames;
273+
return yield* run(destinationPath, ["pull", source, "staging"]);
274+
});
275+
yield* Effect.gen(function* () {
276+
const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
277+
yield* service.cloneRepository({ remoteUrl: source, destinationPath });
278+
}).pipe(
279+
Effect.provide(
280+
layer({
281+
git: {
282+
...git,
283+
execute: (input) => (input.args[0] === "clone" ? cloneInTwoSteps : git.execute(input)),
284+
},
285+
}),
286+
),
287+
);
288+
289+
assert.deepStrictEqual(duringClone, []);
290+
assert.deepStrictEqual(yield* refNames, ["staging"]);
291+
}).pipe(
292+
Effect.provide(
293+
GitVcsDriver.layer.pipe(
294+
Layer.provide(
295+
ServerConfig.layerTest(process.cwd(), {
296+
prefix: "t3-clone-refs-config-",
297+
}),
298+
),
299+
Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
300+
),
301+
),
302+
),
303+
);
304+
238305
it.effect("reports clone progress from git's stderr and keeps its error text on failure", () =>
239306
Effect.gen(function* () {
240307
const fs = yield* FileSystem.FileSystem;

‎apps/server/src/sourceControl/SourceControlRepositoryService.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -429,6 +429,8 @@ export const make = Effect.gen(function* () {
429429
),
430430
);
431431

432+
// A ref list read while the clone ran saw an unborn HEAD and no refs.
433+
yield* git.invalidateRefs(prepared.destinationPath);
432434
return {
433435
cwd: prepared.destinationPath,
434436
remoteUrl,

‎apps/server/src/vcs/GitVcsDriver.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,8 @@ export class GitVcsDriver extends Context.Service<
458458
input: VcsSwitchRefInput,
459459
) => Effect.Effect<VcsSwitchRefResult, GitCommandError>;
460460
readonly initRepo: (input: VcsInitInput) => Effect.Effect<void, GitCommandError>;
461+
/** Drops cached refs after something outside this driver changed them, such as a clone into `cwd`. */
462+
readonly invalidateRefs: (cwd: string) => Effect.Effect<void>;
461463
readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>;
462464
}
463465
>()("t3/vcs/GitVcsDriver") {}

‎apps/server/src/vcs/GitVcsDriverCore.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5610,6 +5610,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
56105610
return yield* withListRefsInvalidation(input.cwd, switchRef(input));
56115611
}),
56125612
initRepo: initRepoWithListRefsInvalidation,
5613+
invalidateRefs: Effect.fn("GitVcsDriver.invalidateRefs")(function* (cwd) {
5614+
// First, so a cached "not a repository" cannot end the ref invalidation early.
5615+
const cacheKey = normalizeRepositoryPathsCacheKey(cwd);
5616+
yield* Cache.invalidate(repositoryPathsRefreshCache, cacheKey);
5617+
yield* Cache.invalidate(repositoryPathsCache, cacheKey);
5618+
return yield* withListRefsInvalidation(cwd, Effect.void);
5619+
}),
56135620
listLocalBranchNames,
56145621
listWorktreePaths,
56155622
});

‎apps/web/src/components/BranchToolbar.logic.test.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
resolveDraftEnvModeAfterBranchChange,
99
resolveEffectiveEnvMode,
1010
resolveBranchToolbarPrBranch,
11+
resolveDefaultWorktreeBaseBranch,
1112
resolveLocalCheckoutBranchMismatch,
1213
resolveLockedWorkspaceLabel,
1314
sanitizeNewRefName,
@@ -47,6 +48,24 @@ describe("resolveBranchToolbarPrBranch", () => {
4748
});
4849
});
4950

51+
describe("resolveDefaultWorktreeBaseBranch", () => {
52+
it("offers nothing while a clone has no refs, then the default branch once they are listed", () => {
53+
expect(resolveDefaultWorktreeBaseBranch([])).toBeNull();
54+
expect(
55+
resolveDefaultWorktreeBaseBranch([
56+
{ name: "feature", isDefault: false, current: true },
57+
{ name: "staging", isDefault: true, current: false },
58+
]),
59+
).toBe("staging");
60+
expect(
61+
resolveDefaultWorktreeBaseBranch([
62+
{ name: "other", isDefault: false, current: false },
63+
{ name: "feature", isDefault: false, current: true },
64+
]),
65+
).toBe("feature");
66+
});
67+
});
68+
5069
describe("resolveLocalCheckoutBranchMismatch", () => {
5170
it("detects when a local thread is associated with a different branch than the checkout", () => {
5271
expect(

‎apps/web/src/components/BranchToolbar.logic.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,16 @@ export function resolveBranchToolbarValue(input: {
199199
return currentGitBranch ?? activeThreadBranch;
200200
}
201201

202+
/**
203+
* The base a New worktree thread gets without the user picking one. Only a listed ref qualifies:
204+
* during a clone the status names an unborn HEAD that no ref backs.
205+
*/
206+
export function resolveDefaultWorktreeBaseBranch(
207+
refs: ReadonlyArray<Pick<VcsRef, "name" | "isDefault" | "current">>,
208+
): string | null {
209+
return (refs.find((ref) => ref.isDefault) ?? refs.find((ref) => ref.current))?.name ?? null;
210+
}
211+
202212
export function resolveBranchTriggerLabel(input: {
203213
activeWorktreePath: string | null;
204214
effectiveEnvMode: EnvMode;

‎apps/web/src/components/BranchToolbarBranchSelector.tsx‎

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
import { GitBranchIcon } from "lucide-react";
1818
import {
1919
useEffect,
20+
useEffectEvent,
2021
useImperativeHandle,
2122
useOptimistic,
2223
useState,
@@ -30,6 +31,7 @@ import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
3031
import { readLocalApi } from "../localApi";
3132
import { useOpenPrLink } from "../lib/openPullRequestLink";
3233
import { usePaginatedBranches } from "../state/queries";
34+
import { useProjectClone } from "../state/projectClones";
3335
import { readThreadShell, useProject, useQuietThreadShell } from "../state/entities";
3436
import { useEnvironmentQuery } from "../state/query";
3537
import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
@@ -49,6 +51,7 @@ import {
4951
resolveBranchToolbarPrBranch,
5052
resolveBranchSelectionTarget,
5153
resolveBranchToolbarValue,
54+
resolveDefaultWorktreeBaseBranch,
5255
resolveDraftEnvModeAfterBranchChange,
5356
resolveEffectiveEnvMode,
5457
sanitizeNewRefName,
@@ -290,7 +293,6 @@ export function BranchToolbarBranchSelector({
290293
const createBranchItemValue = canCreateBranch
291294
? `__create_new_branch__:${trimmedBranchQuery}`
292295
: null;
293-
const defaultBranchName = refs.find((refName) => refName.isDefault)?.name ?? null;
294296
const pickerCreateBranchItemValue = hasExactBranchMatch ? null : createBranchItemValue;
295297
const branchPickerItems = [
296298
...(checkoutPullRequestItemValue ? [checkoutPullRequestItemValue] : []),
@@ -537,7 +539,20 @@ export function BranchToolbarBranchSelector({
537539

538540
const worktreeBaseBranchCandidate = isInitialBranchesLoadPending
539541
? null
540-
: (defaultBranchName ?? currentGitBranch);
542+
: resolveDefaultWorktreeBaseBranch(refs);
543+
544+
const projectCloneDone = useProjectClone(activeProjectRef)?.phase === "done";
545+
const refreshRefsAfterClone = useEffectEvent(() => {
546+
if (branchCwd === null) return;
547+
void refreshLocalStatusForPicker({
548+
environmentId,
549+
input: { cwd: branchCwd, localOnly: true },
550+
});
551+
});
552+
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- refs listed while the project was cloning are empty; reload them past the server's snapshot when the clone finishes
553+
useEffect(() => {
554+
if (projectCloneDone) refreshRefsAfterClone();
555+
}, [projectCloneDone]);
541556

542557
// The server sets started threads' branches; defaulting here would detach the agent from its worktree.
543558
const threadStarted = serverSession !== null;

‎apps/web/src/components/GitActionsControl.tsx‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ import {
9292
useVcsPullAction,
9393
} from "../state/sourceControlActions";
9494
import { useQuietThreadProjection, useQuietThreadShell } from "~/state/entities";
95+
import { useProjectClone } from "~/state/projectClones";
9596
import { readEnvironmentScope, useEnvironmentScope } from "~/state/session";
9697
import { useEnvironmentQuery } from "~/state/query";
9798
import { useFilesystemReadAccess } from "~/state/filesystem";
@@ -124,6 +125,7 @@ import {
124125
type OfferedSourceControlProviders,
125126
} from "@t3tools/client-runtime/operations/projects";
126127
import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients";
128+
import { scopeProjectRef } from "@t3tools/client-runtime/environment";
127129
import { formatDiffCount } from "@t3tools/client-runtime/diff-count";
128130
import { useOpenLink } from "~/browser/useOpenLink";
129131

@@ -1148,9 +1150,17 @@ export default function GitActionsControl({
11481150
activeDraftThread?.envMode === "worktree" &&
11491151
activeDraftThread.worktreePath === null;
11501152

1153+
// Until a clone finishes, the status names an unborn HEAD that the repository may never have.
1154+
const activeProjectClone = useProjectClone(
1155+
activeDraftThread
1156+
? scopeProjectRef(activeDraftThread.environmentId, activeDraftThread.projectId)
1157+
: null,
1158+
);
1159+
const isProjectCloning = activeProjectClone !== null && activeProjectClone.phase !== "done";
1160+
11511161
// oxlint-disable-next-line t3code/no-unreasoned-state-sync -- persists the draft thread branch when the live git status subscription reports a different branch
11521162
useEffect(() => {
1153-
if (isGitActionRunning || isSelectingWorktreeBase || activeServerThread) {
1163+
if (isGitActionRunning || isSelectingWorktreeBase || activeServerThread || isProjectCloning) {
11541164
return;
11551165
}
11561166

@@ -1168,6 +1178,7 @@ export default function GitActionsControl({
11681178
activeDraftThread?.branch,
11691179
gitStatusForActions,
11701180
isGitActionRunning,
1181+
isProjectCloning,
11711182
isSelectingWorktreeBase,
11721183
]);
11731184

0 commit comments

Comments
 (0)