| author | |
| committer | |
| log | b12b3f706d1257b79c69c34699babb6834a72b34 |
| tree | 95a391a1e1c0a8a47abb604a3e4e705b0414c2a0 |
| parent | 194c73f3f991558ca540ef6fe0ce75223bbe87a7 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { |
| 99 | 99 | readonly managedFolders?: Layer.Layer<ManagedProjectFolders.ManagedProjectFolders>; |
| 100 | 100 | readonly createWorktree?: GitWorkflow.GitWorkflowService["Service"]["createWorktree"]; |
| 101 | 101 | readonly fetchRemote?: GitWorkflow.GitWorkflowService["Service"]["fetchRemote"]; |
| 102 | readonly hasCommit?: GitWorkflow.GitWorkflowService["Service"]["hasCommit"]; | |
| 102 | 103 | readonly renameBranch?: GitWorkflow.GitWorkflowService["Service"]["renameBranch"]; |
| 103 | 104 | readonly runSetup?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"]; |
| 104 | 105 | readonly generateTitle?: TextGeneration.TextGeneration["Service"]["generateThreadTitle"]; |
| ... | ... | @@ -167,6 +168,7 @@ function makeHarness(options: HarnessOptions = {}) { |
| 167 | 168 | createWorktree, |
| 168 | 169 | renameBranch, |
| 169 | 170 | fetchRemote: options.fetchRemote ?? (() => Effect.void), |
| 171 | hasCommit: options.hasCommit ?? (() => Effect.succeed(false)), | |
| 170 | 172 | remoteExists: () => Effect.succeed(true), |
| 171 | 173 | remoteBranchExists: () => Effect.succeed(true), |
| 172 | 174 | removeWorktree, |
| ... | ... | @@ -1165,6 +1167,37 @@ it.effect("renames a temporary t3code/<hash> branch off the provisioning critica |
| 1165 | 1167 | }), |
| 1166 | 1168 | ); |
| 1167 | 1169 | |
| 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 | ||
| 1168 | 1201 | it.effect("keeps an explicit branch name instead of generating one", () => |
| 1169 | 1202 | Effect.gen(function* () { |
| 1170 | 1203 | const harness = makeHarness(); |
apps/server/src/orchestration-v2/ThreadLaunchService.ts+18-1| ... | ... | @@ -28,7 +28,12 @@ import * as Option from "effect/Option"; |
| 28 | 28 | import * as Ref from "effect/Ref"; |
| 29 | 29 | import * as Schema from "effect/Schema"; |
| 30 | 30 | 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"; | |
| 32 | 37 | |
| 33 | 38 | import * as GitWorkflow from "../git/GitWorkflowService.ts"; |
| 34 | 39 | import * as ProjectService from "../project/ProjectService.ts"; |
| ... | ... | @@ -363,6 +368,18 @@ const make = Effect.gen(function* () { |
| 363 | 368 | } |
| 364 | 369 | } |
| 365 | 370 | 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 | } | |
| 366 | 383 | yield* setupTracker.stageStatus(threadId, "checkout", "running"); |
| 367 | 384 | const worktree = yield* git |
| 368 | 385 | .createWorktree( |
packages/shared/src/git.test.ts+14| ... | ... | @@ -5,6 +5,7 @@ import { |
| 5 | 5 | applyGitStatusStreamEvent, |
| 6 | 6 | formatGeneratedBranchName, |
| 7 | 7 | buildTemporaryWorktreeBranchName, |
| 8 | flattenTemporaryWorktreeBranchName, | |
| 8 | 9 | isTemporaryWorktreeBranch, |
| 9 | 10 | normalizeGitRemoteUrl, |
| 10 | 11 | parseGitHubRepositoryNameWithOwnerFromRemoteUrl, |
| ... | ... | @@ -206,6 +207,19 @@ describe("isTemporaryWorktreeBranch", () => { |
| 206 | 207 | ).toBe(false); |
| 207 | 208 | }); |
| 208 | 209 | |
| 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 | ||
| 209 | 223 | it("rejects non-temporary refName names", () => { |
| 210 | 224 | expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/feature/demo`)).toBe(false); |
| 211 | 225 | expect(isTemporaryWorktreeBranch("main")).toBe(false); |
packages/shared/src/git.ts+15-1| ... | ... | @@ -16,8 +16,9 @@ export const WORKTREE_BRANCH_PREFIX = "t3code"; |
| 16 | 16 | // via Crypto.randomUUID() (always RFC 4122 v4), so the matcher also accepts exactly |
| 17 | 17 | // that shape — version nibble `4`, variant nibble `[89ab]` — to keep those threads |
| 18 | 18 | // 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. | |
| 19 | 20 | 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})$)`, | |
| 21 | 22 | ); |
| 22 | 23 | |
| 23 | 24 | /** |
| ... | ... | @@ -123,6 +124,19 @@ export function buildTemporaryWorktreeBranchName( |
| 123 | 124 | return `${WORKTREE_BRANCH_PREFIX}/${token}`; |
| 124 | 125 | } |
| 125 | 126 | |
| 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 | ||
| 126 | 140 | export function isTemporaryWorktreeBranch(refName: string): boolean { |
| 127 | 141 | return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase()); |
| 128 | 142 | } |