Skip to content

Commit 52a24c8

Browse files
Add origin-based worktree bootstrap option (#3157)
1 parent 30034ec commit 52a24c8

22 files changed

Lines changed: 472 additions & 13 deletions

apps/server/src/git/GitWorkflowService.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,18 @@ export interface GitWorkflowServiceShape {
6161
readonly createWorktree: (
6262
input: VcsCreateWorktreeInput,
6363
) => Effect.Effect<VcsCreateWorktreeResult, GitCommandError>;
64+
readonly fetchRemote: (input: {
65+
readonly cwd: string;
66+
readonly remoteName: string;
67+
}) => Effect.Effect<void, GitCommandError>;
68+
readonly resolveRemoteTrackingCommit: (input: {
69+
readonly cwd: string;
70+
readonly refName: string;
71+
readonly fallbackRemoteName: string;
72+
}) => Effect.Effect<
73+
{ readonly commitSha: string; readonly remoteRefName: string },
74+
GitCommandError
75+
>;
6476
readonly removeWorktree: (input: VcsRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>;
6577
readonly createRef: (
6678
input: VcsCreateRefInput,
@@ -295,6 +307,14 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () {
295307
ensureGitCommand("GitWorkflowService.createWorktree", input.cwd).pipe(
296308
Effect.andThen(git.createWorktree(input)),
297309
),
310+
fetchRemote: (input) =>
311+
ensureGitCommand("GitWorkflowService.fetchRemote", input.cwd).pipe(
312+
Effect.andThen(git.fetchRemote(input)),
313+
),
314+
resolveRemoteTrackingCommit: (input) =>
315+
ensureGitCommand("GitWorkflowService.resolveRemoteTrackingCommit", input.cwd).pipe(
316+
Effect.andThen(git.resolveRemoteTrackingCommit(input)),
317+
),
298318
removeWorktree: (input) =>
299319
ensureGitCommand("GitWorkflowService.removeWorktree", input.cwd).pipe(
300320
Effect.andThen(git.removeWorktree(input)),

apps/server/src/server.test.ts

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5918,6 +5918,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
59185918
() =>
59195919
Effect.gen(function* () {
59205920
const dispatchedCommands: Array<OrchestrationCommand> = [];
5921+
const bootstrapGitOperations: string[] = [];
59215922
const refreshStatus = vi.fn((_: string) =>
59225923
Effect.succeed({
59235924
isRepo: true,
@@ -5936,13 +5937,33 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
59365937
pr: null,
59375938
}),
59385939
);
5940+
const fetchRemote = vi.fn(
5941+
(_: Parameters<GitVcsDriver.GitVcsDriverShape["fetchRemote"]>[0]) =>
5942+
Effect.sync(() => {
5943+
bootstrapGitOperations.push("fetch");
5944+
}),
5945+
);
5946+
const fetchedOriginCommit = "0123456789abcdef0123456789abcdef01234567";
5947+
const resolveRemoteTrackingCommit = vi.fn(
5948+
(_: Parameters<GitVcsDriver.GitVcsDriverShape["resolveRemoteTrackingCommit"]>[0]) =>
5949+
Effect.sync(() => {
5950+
bootstrapGitOperations.push("resolve-remote-commit");
5951+
return {
5952+
commitSha: fetchedOriginCommit,
5953+
remoteRefName: "origin/main",
5954+
};
5955+
}),
5956+
);
59395957
const createWorktree = vi.fn(
59405958
(_: Parameters<GitVcsDriver.GitVcsDriverShape["createWorktree"]>[0]) =>
5941-
Effect.succeed({
5942-
worktree: {
5943-
refName: "t3code/bootstrap-refName",
5944-
path: "/tmp/bootstrap-worktree",
5945-
},
5959+
Effect.sync(() => {
5960+
bootstrapGitOperations.push("create-worktree");
5961+
return {
5962+
worktree: {
5963+
refName: "t3code/bootstrap-refName",
5964+
path: "/tmp/bootstrap-worktree",
5965+
},
5966+
};
59465967
}),
59475968
);
59485969
const runForThread = vi.fn(
@@ -5959,6 +5980,8 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
59595980
yield* buildAppUnderTest({
59605981
layers: {
59615982
gitVcsDriver: {
5983+
fetchRemote,
5984+
resolveRemoteTrackingCommit,
59625985
createWorktree,
59635986
},
59645987
vcsStatusBroadcaster: {
@@ -6010,6 +6033,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
60106033
projectCwd: "/tmp/project",
60116034
baseBranch: "main",
60126035
branch: "t3code/bootstrap-refName",
6036+
startFromOrigin: true,
60136037
},
60146038
runSetupScript: true,
60156039
},
@@ -6031,10 +6055,24 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
60316055
);
60326056
assert.deepEqual(createWorktree.mock.calls[0]?.[0], {
60336057
cwd: "/tmp/project",
6034-
refName: "main",
6058+
refName: fetchedOriginCommit,
60356059
newRefName: "t3code/bootstrap-refName",
60366060
path: null,
60376061
});
6062+
assert.deepEqual(fetchRemote.mock.calls[0]?.[0], {
6063+
cwd: "/tmp/project",
6064+
remoteName: "origin",
6065+
});
6066+
assert.deepEqual(resolveRemoteTrackingCommit.mock.calls[0]?.[0], {
6067+
cwd: "/tmp/project",
6068+
refName: "main",
6069+
fallbackRemoteName: "origin",
6070+
});
6071+
assert.deepEqual(bootstrapGitOperations, [
6072+
"fetch",
6073+
"resolve-remote-commit",
6074+
"create-worktree",
6075+
]);
60386076
assert.deepEqual(runForThread.mock.calls[0]?.[0], {
60396077
threadId: ThreadId.make("thread-bootstrap"),
60406078
projectId: defaultProjectId,

apps/server/src/vcs/GitVcsDriver.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,22 @@ export interface GitFetchRemoteTrackingBranchInput {
161161
remoteBranch: string;
162162
}
163163

164+
export interface GitFetchRemoteInput {
165+
cwd: string;
166+
remoteName: string;
167+
}
168+
169+
export interface GitResolveRemoteTrackingCommitInput {
170+
cwd: string;
171+
refName: string;
172+
fallbackRemoteName: string;
173+
}
174+
175+
export interface GitResolveRemoteTrackingCommitResult {
176+
commitSha: string;
177+
remoteRefName: string;
178+
}
179+
164180
export interface GitSetBranchUpstreamInput {
165181
cwd: string;
166182
branch: string;
@@ -217,6 +233,10 @@ export interface GitVcsDriverShape {
217233
) => Effect.Effect<void, GitCommandError>;
218234
readonly ensureRemote: (input: GitEnsureRemoteInput) => Effect.Effect<string, GitCommandError>;
219235
readonly resolvePrimaryRemoteName: (cwd: string) => Effect.Effect<string, GitCommandError>;
236+
readonly fetchRemote: (input: GitFetchRemoteInput) => Effect.Effect<void, GitCommandError>;
237+
readonly resolveRemoteTrackingCommit: (
238+
input: GitResolveRemoteTrackingCommitInput,
239+
) => Effect.Effect<GitResolveRemoteTrackingCommitResult, GitCommandError>;
220240
readonly fetchRemoteBranch: (
221241
input: GitFetchRemoteBranchInput,
222242
) => Effect.Effect<void, GitCommandError>;

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

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -442,6 +442,69 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => {
442442
});
443443

444444
describe("remote operations", () => {
445+
it.effect("creates a worktree from the latest fetched remote commit", () =>
446+
Effect.gen(function* () {
447+
const cwd = yield* makeTmpDir();
448+
const remote = yield* makeTmpDir("git-remote-");
449+
const peer = yield* makeTmpDir("git-peer-");
450+
const { initialBranch } = yield* initRepoWithCommit(cwd);
451+
yield* git(remote, ["init", "--bare"]);
452+
yield* git(cwd, ["remote", "add", "origin", remote]);
453+
yield* git(cwd, ["push", "-u", "origin", initialBranch]);
454+
yield* git(remote, ["symbolic-ref", "HEAD", `refs/heads/${initialBranch}`]);
455+
const beforeFetch = yield* git(cwd, ["rev-parse", `refs/remotes/origin/${initialBranch}`]);
456+
457+
yield* git(peer, ["clone", remote, "."]);
458+
yield* git(peer, ["config", "user.email", "test@test.com"]);
459+
yield* git(peer, ["config", "user.name", "Test"]);
460+
yield* writeTextFile(peer, "remote-change.txt", "remote\n");
461+
yield* git(peer, ["add", "remote-change.txt"]);
462+
yield* git(peer, ["commit", "-m", "remote change"]);
463+
yield* git(peer, ["push", "origin", initialBranch]);
464+
const remoteHead = yield* git(peer, ["rev-parse", "HEAD"]);
465+
assert.notEqual(beforeFetch, remoteHead);
466+
467+
const driver = yield* GitVcsDriver.GitVcsDriver;
468+
yield* driver.fetchRemote({ cwd, remoteName: "origin" });
469+
470+
const resolvedBase = yield* driver.resolveRemoteTrackingCommit({
471+
cwd,
472+
refName: initialBranch,
473+
fallbackRemoteName: "origin",
474+
});
475+
const explicitlyResolvedBase = yield* driver.resolveRemoteTrackingCommit({
476+
cwd,
477+
refName: `origin/${initialBranch}`,
478+
fallbackRemoteName: "origin",
479+
});
480+
481+
assert.deepEqual(resolvedBase, {
482+
commitSha: remoteHead,
483+
remoteRefName: `origin/${initialBranch}`,
484+
});
485+
assert.deepEqual(explicitlyResolvedBase, resolvedBase);
486+
assert.equal(yield* git(cwd, ["rev-parse", initialBranch]), beforeFetch);
487+
488+
const pathService = yield* Path.Path;
489+
const worktreePath = pathService.join(
490+
yield* makeTmpDir("git-fetched-worktrees-"),
491+
"fetched-origin",
492+
);
493+
yield* driver.createWorktree({
494+
cwd,
495+
path: worktreePath,
496+
refName: resolvedBase.commitSha,
497+
newRefName: "t3code/fetched-origin",
498+
});
499+
500+
assert.equal(yield* git(worktreePath, ["rev-parse", "HEAD"]), remoteHead);
501+
assert.equal(
502+
yield* driver.readConfigValue(worktreePath, "branch.t3code/fetched-origin.remote"),
503+
null,
504+
);
505+
}),
506+
);
507+
445508
it.effect("pushes with upstream setup and skips when already up to date", () =>
446509
Effect.gen(function* () {
447510
const cwd = yield* makeTmpDir();

apps/server/src/vcs/GitVcsDriverCore.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2188,6 +2188,38 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
21882188
);
21892189
});
21902190

2191+
const fetchRemote: GitVcsDriver.GitVcsDriverShape["fetchRemote"] = Effect.fn("fetchRemote")(
2192+
function* (input) {
2193+
yield* executeGit(
2194+
"GitVcsDriver.fetchRemote",
2195+
input.cwd,
2196+
["fetch", "--quiet", input.remoteName],
2197+
{
2198+
env: STATUS_UPSTREAM_REFRESH_ENV,
2199+
fallbackErrorMessage: `git fetch ${input.remoteName} failed`,
2200+
},
2201+
);
2202+
},
2203+
);
2204+
2205+
const resolveRemoteTrackingCommit: GitVcsDriver.GitVcsDriverShape["resolveRemoteTrackingCommit"] =
2206+
Effect.fn("resolveRemoteTrackingCommit")(function* (input) {
2207+
const remoteNames = yield* listRemoteNames(input.cwd);
2208+
const parsedRemoteRef = parseRemoteRefWithRemoteNames(
2209+
input.refName,
2210+
remoteNames.toSorted((left, right) => right.length - left.length),
2211+
);
2212+
const remoteRefName =
2213+
parsedRemoteRef?.remoteRef ?? `${input.fallbackRemoteName}/${input.refName}`;
2214+
const commitSha = yield* runGitStdout("GitVcsDriver.resolveRemoteTrackingCommit", input.cwd, [
2215+
"rev-parse",
2216+
"--verify",
2217+
`refs/remotes/${remoteRefName}^{commit}`,
2218+
]).pipe(Effect.map((stdout) => stdout.trim()));
2219+
2220+
return { commitSha, remoteRefName };
2221+
});
2222+
21912223
const fetchRemoteBranch: GitVcsDriver.GitVcsDriverShape["fetchRemoteBranch"] = Effect.fn(
21922224
"fetchRemoteBranch",
21932225
)(function* (input) {
@@ -2413,6 +2445,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
24132445
fetchPullRequestBranch,
24142446
ensureRemote,
24152447
resolvePrimaryRemoteName,
2448+
fetchRemote,
2449+
resolveRemoteTrackingCommit,
24162450
fetchRemoteBranch,
24172451
fetchRemoteTrackingBranch,
24182452
setBranchUpstream,

apps/server/src/ws.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -694,9 +694,22 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) =>
694694
}
695695

696696
if (bootstrap?.prepareWorktree) {
697+
let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch;
698+
if (bootstrap.prepareWorktree.startFromOrigin) {
699+
yield* gitWorkflow.fetchRemote({
700+
cwd: bootstrap.prepareWorktree.projectCwd,
701+
remoteName: "origin",
702+
});
703+
const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({
704+
cwd: bootstrap.prepareWorktree.projectCwd,
705+
refName: bootstrap.prepareWorktree.baseBranch,
706+
fallbackRemoteName: "origin",
707+
});
708+
worktreeBaseRef = resolvedRemoteBase.commitSha;
709+
}
697710
const worktree = yield* gitWorkflow.createWorktree({
698711
cwd: bootstrap.prepareWorktree.projectCwd,
699-
refName: bootstrap.prepareWorktree.baseBranch,
712+
refName: worktreeBaseRef,
700713
newRefName: bootstrap.prepareWorktree.branch,
701714
path: null,
702715
});

apps/web/src/components/BranchToolbar.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ interface BranchToolbarProps {
4545
effectiveEnvModeOverride?: EnvMode;
4646
activeThreadBranchOverride?: string | null;
4747
onActiveThreadBranchOverrideChange?: (branch: string | null) => void;
48+
startFromOrigin: boolean;
49+
onStartFromOriginChange: (startFromOrigin: boolean) => void;
4850
envLocked: boolean;
4951
onCheckoutPullRequestRequest?: (reference: string) => void;
5052
onComposerFocusRequest?: () => void;
@@ -196,6 +198,8 @@ export const BranchToolbar = memo(function BranchToolbar({
196198
effectiveEnvModeOverride,
197199
activeThreadBranchOverride,
198200
onActiveThreadBranchOverrideChange,
201+
startFromOrigin,
202+
onStartFromOriginChange,
199203
envLocked,
200204
onCheckoutPullRequestRequest,
201205
onComposerFocusRequest,
@@ -279,6 +283,8 @@ export const BranchToolbar = memo(function BranchToolbar({
279283
{...(effectiveEnvModeOverride ? { effectiveEnvModeOverride } : {})}
280284
{...(activeThreadBranchOverride !== undefined ? { activeThreadBranchOverride } : {})}
281285
{...(onActiveThreadBranchOverrideChange ? { onActiveThreadBranchOverrideChange } : {})}
286+
startFromOrigin={startFromOrigin}
287+
onStartFromOriginChange={onStartFromOriginChange}
282288
{...(onCheckoutPullRequestRequest ? { onCheckoutPullRequestRequest } : {})}
283289
{...(onComposerFocusRequest ? { onComposerFocusRequest } : {})}
284290
/>

0 commit comments

Comments
 (0)