authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-10-05 13:21:09-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-05 20:21:09+00:00
logb12b3f706d1257b79c69c34699babb6834a72b34
tree95a391a1e1c0a8a47abb604a3e4e705b0414c2a0
parent194c73f3f991558ca540ef6fe0ce75223bbe87a7
signature Signed by PGP key B5690EEEBB952194

fix(server): worktree threads survive a local branch named t3code (#16167)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

4 files changed, 80 insertions(+), 2 deletions(-)

apps/server/src/orchestration-v2/ThreadLaunchService.test.ts+33
......@@ -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
1170it.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
11681201it.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+18-1
......@@ -28,7 +28,12 @@ import * as Option from "effect/Option";
2828import * as Ref from "effect/Ref";
2929import * as Schema from "effect/Schema";
3030import * as Scope from "effect/Scope";
31import { buildTemporaryWorktreeBranchName, isTemporaryWorktreeBranch } from "@t3tools/shared/git";
31import {
32 buildTemporaryWorktreeBranchName,
33 flattenTemporaryWorktreeBranchName,
34 isTemporaryWorktreeBranch,
35 WORKTREE_BRANCH_PREFIX,
36} from "@t3tools/shared/git";
3237
3338import * as GitWorkflow from "../git/GitWorkflowService.ts";
3439import * 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+14
......@@ -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+15-1
......@@ -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.
1920const 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 */
131export 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
126140export function isTemporaryWorktreeBranch(refName: string): boolean {
127141 return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase());
128142}