Skip to content

Commit b12b3f7

Browse files
fix(server): worktree threads survive a local branch named t3code (#16167)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 194c73f commit b12b3f7

4 files changed

Lines changed: 80 additions & 2 deletions

File tree

‎apps/server/src/orchestration-v2/ThreadLaunchService.test.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ interface HarnessOptions {
9999
readonly managedFolders?: Layer.Layer<ManagedProjectFolders.ManagedProjectFolders>;
100100
readonly createWorktree?: GitWorkflow.GitWorkflowService["Service"]["createWorktree"];
101101
readonly fetchRemote?: GitWorkflow.GitWorkflowService["Service"]["fetchRemote"];
102+
readonly hasCommit?: GitWorkflow.GitWorkflowService["Service"]["hasCommit"];
102103
readonly renameBranch?: GitWorkflow.GitWorkflowService["Service"]["renameBranch"];
103104
readonly runSetup?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"];
104105
readonly generateTitle?: TextGeneration.TextGeneration["Service"]["generateThreadTitle"];
@@ -167,6 +168,7 @@ function makeHarness(options: HarnessOptions = {}) {
167168
createWorktree,
168169
renameBranch,
169170
fetchRemote: options.fetchRemote ?? (() => Effect.void),
171+
hasCommit: options.hasCommit ?? (() => Effect.succeed(false)),
170172
remoteExists: () => Effect.succeed(true),
171173
remoteBranchExists: () => Effect.succeed(true),
172174
removeWorktree,
@@ -1165,6 +1167,37 @@ it.effect("renames a temporary t3code/<hash> branch off the provisioning critica
11651167
}),
11661168
);
11671169

1170+
it.effect("provisions under t3code-<hash> when a plain t3code branch blocks t3code/*", () =>
1171+
Effect.gen(function* () {
1172+
const harness = makeHarness({
1173+
hasCommit: (input) => Effect.succeed(input.refName === "refs/heads/t3code"),
1174+
createWorktree: (input) =>
1175+
Effect.succeed({
1176+
worktree: { path: "/repo-worktrees/temp", refName: input.newRefName, headSha: "abc" },
1177+
} as never),
1178+
});
1179+
yield* Effect.gen(function* () {
1180+
const launches = yield* ThreadLaunch.ThreadLaunchService;
1181+
const threads = yield* ThreadManagement.ThreadManagementService;
1182+
const launched = yield* launches.launch(
1183+
launchInput({
1184+
command: "command:launch:blocked-namespace",
1185+
thread: "thread:launch:blocked-namespace",
1186+
message: "Build the feature",
1187+
workspace: { type: "worktree", baseRef: "main", branch: "t3code/abcd1234" },
1188+
}),
1189+
);
1190+
yield* waitUntil(() =>
1191+
threads
1192+
.getThreadProjection(launched.threadId)
1193+
.pipe(Effect.map((projection) => projection.thread.branch === "generated-branch")),
1194+
);
1195+
assert.equal(harness.createWorktree.mock.calls[0]?.[0]?.newRefName, "t3code-abcd1234");
1196+
assert.equal(harness.renameBranch.mock.calls[0]?.[0]?.oldBranch, "t3code-abcd1234");
1197+
}).pipe(Effect.provide(harness.layer));
1198+
}),
1199+
);
1200+
11681201
it.effect("keeps an explicit branch name instead of generating one", () =>
11691202
Effect.gen(function* () {
11701203
const harness = makeHarness();

‎apps/server/src/orchestration-v2/ThreadLaunchService.ts‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,12 @@ import * as Option from "effect/Option";
2828
import * as Ref from "effect/Ref";
2929
import * as Schema from "effect/Schema";
3030
import * as Scope from "effect/Scope";
31-
import { buildTemporaryWorktreeBranchName, isTemporaryWorktreeBranch } from "@t3tools/shared/git";
31+
import {
32+
buildTemporaryWorktreeBranchName,
33+
flattenTemporaryWorktreeBranchName,
34+
isTemporaryWorktreeBranch,
35+
WORKTREE_BRANCH_PREFIX,
36+
} from "@t3tools/shared/git";
3237

3338
import * as GitWorkflow from "../git/GitWorkflowService.ts";
3439
import * as ProjectService from "../project/ProjectService.ts";
@@ -363,6 +368,18 @@ const make = Effect.gen(function* () {
363368
}
364369
}
365370
if (startFromOrigin) yield* setupTracker.stageStatus(threadId, "fetch", "done");
371+
if (
372+
branch !== null &&
373+
isTemporaryWorktreeBranch(branch) &&
374+
(yield* git
375+
.hasCommit({
376+
cwd: project.workspaceRoot,
377+
refName: `refs/heads/${WORKTREE_BRANCH_PREFIX}`,
378+
})
379+
.pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))))
380+
) {
381+
branch = flattenTemporaryWorktreeBranchName(branch);
382+
}
366383
yield* setupTracker.stageStatus(threadId, "checkout", "running");
367384
const worktree = yield* git
368385
.createWorktree(

‎packages/shared/src/git.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
applyGitStatusStreamEvent,
66
formatGeneratedBranchName,
77
buildTemporaryWorktreeBranchName,
8+
flattenTemporaryWorktreeBranchName,
89
isTemporaryWorktreeBranch,
910
normalizeGitRemoteUrl,
1011
parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
@@ -206,6 +207,19 @@ describe("isTemporaryWorktreeBranch", () => {
206207
).toBe(false);
207208
});
208209

210+
it("matches the flat fallback used when a plain t3code branch exists", () => {
211+
const flat = flattenTemporaryWorktreeBranchName(`${WORKTREE_BRANCH_PREFIX}/deadbeef`);
212+
expect(flat).toBe(`${WORKTREE_BRANCH_PREFIX}-deadbeef`);
213+
expect(isTemporaryWorktreeBranch(flat)).toBe(true);
214+
expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}-deadbeef-extra`)).toBe(false);
215+
expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}-feature`)).toBe(false);
216+
expect(
217+
flattenTemporaryWorktreeBranchName(
218+
`${WORKTREE_BRANCH_PREFIX}/f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12`,
219+
),
220+
).toBe(`${WORKTREE_BRANCH_PREFIX}-f4ae4e0e`);
221+
});
222+
209223
it("rejects non-temporary refName names", () => {
210224
expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/feature/demo`)).toBe(false);
211225
expect(isTemporaryWorktreeBranch("main")).toBe(false);

‎packages/shared/src/git.ts‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ export const WORKTREE_BRANCH_PREFIX = "t3code";
1616
// via Crypto.randomUUID() (always RFC 4122 v4), so the matcher also accepts exactly
1717
// that shape — version nibble `4`, variant nibble `[89ab]` — to keep those threads
1818
// eligible for branch regeneration without loosening beyond what was ever generated.
19+
// `t3code-<8 hex>` is the fallback when a plain `t3code` branch blocks the namespace.
1920
const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(
20-
`^${WORKTREE_BRANCH_PREFIX}\\/(?:[0-9a-f]{8}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$`,
21+
`^${WORKTREE_BRANCH_PREFIX}(?:-[0-9a-f]{8}$|\\/(?:[0-9a-f]{8}|[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12})$)`,
2122
);
2223

2324
/**
@@ -123,6 +124,19 @@ export function buildTemporaryWorktreeBranchName(
123124
return `${WORKTREE_BRANCH_PREFIX}/${token}`;
124125
}
125126

127+
/**
128+
* Git stores refs as paths, so a plain `t3code` branch makes every `t3code/<hex>`
129+
* ref impossible. This moves a temporary name to the flat `t3code-<hex>` sibling.
130+
*/
131+
export function flattenTemporaryWorktreeBranchName(refName: string): string {
132+
// Keep only the canonical 8-hex token so legacy UUID names stay recognizable.
133+
const token = refName
134+
.trim()
135+
.toLowerCase()
136+
.slice(WORKTREE_BRANCH_PREFIX.length + 1, WORKTREE_BRANCH_PREFIX.length + 9);
137+
return `${WORKTREE_BRANCH_PREFIX}-${token}`;
138+
}
139+
126140
export function isTemporaryWorktreeBranch(refName: string): boolean {
127141
return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase());
128142
}

0 commit comments

Comments
 (0)