Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/server/src/orchestration-v2/ThreadLaunchService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ interface HarnessOptions {
readonly managedFolders?: Layer.Layer<ManagedProjectFolders.ManagedProjectFolders>;
readonly createWorktree?: GitWorkflow.GitWorkflowService["Service"]["createWorktree"];
readonly fetchRemote?: GitWorkflow.GitWorkflowService["Service"]["fetchRemote"];
readonly hasCommit?: GitWorkflow.GitWorkflowService["Service"]["hasCommit"];
readonly renameBranch?: GitWorkflow.GitWorkflowService["Service"]["renameBranch"];
readonly runSetup?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]["runForThread"];
readonly generateTitle?: TextGeneration.TextGeneration["Service"]["generateThreadTitle"];
Expand Down Expand Up @@ -167,6 +168,7 @@ function makeHarness(options: HarnessOptions = {}) {
createWorktree,
renameBranch,
fetchRemote: options.fetchRemote ?? (() => Effect.void),
hasCommit: options.hasCommit ?? (() => Effect.succeed(false)),
remoteExists: () => Effect.succeed(true),
remoteBranchExists: () => Effect.succeed(true),
removeWorktree,
Expand Down Expand Up @@ -1165,6 +1167,37 @@ it.effect("renames a temporary t3code/<hash> branch off the provisioning critica
}),
);

it.effect("provisions under t3code-<hash> when a plain t3code branch blocks t3code/*", () =>
Effect.gen(function* () {
const harness = makeHarness({
hasCommit: (input) => Effect.succeed(input.refName === "refs/heads/t3code"),
createWorktree: (input) =>
Effect.succeed({
worktree: { path: "/repo-worktrees/temp", refName: input.newRefName, headSha: "abc" },
} as never),
});
yield* Effect.gen(function* () {
const launches = yield* ThreadLaunch.ThreadLaunchService;
const threads = yield* ThreadManagement.ThreadManagementService;
const launched = yield* launches.launch(
launchInput({
command: "command:launch:blocked-namespace",
thread: "thread:launch:blocked-namespace",
message: "Build the feature",
workspace: { type: "worktree", baseRef: "main", branch: "t3code/abcd1234" },
}),
);
yield* waitUntil(() =>
threads
.getThreadProjection(launched.threadId)
.pipe(Effect.map((projection) => projection.thread.branch === "generated-branch")),
);
assert.equal(harness.createWorktree.mock.calls[0]?.[0]?.newRefName, "t3code-abcd1234");
assert.equal(harness.renameBranch.mock.calls[0]?.[0]?.oldBranch, "t3code-abcd1234");
}).pipe(Effect.provide(harness.layer));
}),
);

it.effect("keeps an explicit branch name instead of generating one", () =>
Effect.gen(function* () {
const harness = makeHarness();
Expand Down
19 changes: 18 additions & 1 deletion apps/server/src/orchestration-v2/ThreadLaunchService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,12 @@ import * as Option from "effect/Option";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
import { buildTemporaryWorktreeBranchName, isTemporaryWorktreeBranch } from "@t3tools/shared/git";
import {
buildTemporaryWorktreeBranchName,
flattenTemporaryWorktreeBranchName,
isTemporaryWorktreeBranch,
WORKTREE_BRANCH_PREFIX,
} from "@t3tools/shared/git";

import * as GitWorkflow from "../git/GitWorkflowService.ts";
import * as ProjectService from "../project/ProjectService.ts";
Expand Down Expand Up @@ -363,6 +368,18 @@ const make = Effect.gen(function* () {
}
}
if (startFromOrigin) yield* setupTracker.stageStatus(threadId, "fetch", "done");
if (
branch !== null &&
isTemporaryWorktreeBranch(branch) &&
(yield* git
.hasCommit({
cwd: project.workspaceRoot,
refName: `refs/heads/${WORKTREE_BRANCH_PREFIX}`,
})
.pipe(Effect.mapError(mapError(input, "provision-worktree", threadId))))
) {
branch = flattenTemporaryWorktreeBranchName(branch);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
}
yield* setupTracker.stageStatus(threadId, "checkout", "running");
const worktree = yield* git
.createWorktree(
Expand Down
14 changes: 14 additions & 0 deletions packages/shared/src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
applyGitStatusStreamEvent,
formatGeneratedBranchName,
buildTemporaryWorktreeBranchName,
flattenTemporaryWorktreeBranchName,
isTemporaryWorktreeBranch,
normalizeGitRemoteUrl,
parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
Expand Down Expand Up @@ -206,6 +207,19 @@ describe("isTemporaryWorktreeBranch", () => {
).toBe(false);
});

it("matches the flat fallback used when a plain t3code branch exists", () => {
const flat = flattenTemporaryWorktreeBranchName(`${WORKTREE_BRANCH_PREFIX}/deadbeef`);
expect(flat).toBe(`${WORKTREE_BRANCH_PREFIX}-deadbeef`);
expect(isTemporaryWorktreeBranch(flat)).toBe(true);
expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}-deadbeef-extra`)).toBe(false);
expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}-feature`)).toBe(false);
expect(
flattenTemporaryWorktreeBranchName(
`${WORKTREE_BRANCH_PREFIX}/f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12`,
),
).toBe(`${WORKTREE_BRANCH_PREFIX}-f4ae4e0e`);
});

it("rejects non-temporary refName names", () => {
expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/feature/demo`)).toBe(false);
expect(isTemporaryWorktreeBranch("main")).toBe(false);
Expand Down
16 changes: 15 additions & 1 deletion packages/shared/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,9 @@ export const WORKTREE_BRANCH_PREFIX = "t3code";
// via Crypto.randomUUID() (always RFC 4122 v4), so the matcher also accepts exactly
// that shape — version nibble `4`, variant nibble `[89ab]` — to keep those threads
// eligible for branch regeneration without loosening beyond what was ever generated.
// `t3code-<8 hex>` is the fallback when a plain `t3code` branch blocks the namespace.
const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(
`^${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})$`,
`^${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})$)`,
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
);

/**
Expand Down Expand Up @@ -123,6 +124,19 @@ export function buildTemporaryWorktreeBranchName(
return `${WORKTREE_BRANCH_PREFIX}/${token}`;
}

/**
* Git stores refs as paths, so a plain `t3code` branch makes every `t3code/<hex>`
* ref impossible. This moves a temporary name to the flat `t3code-<hex>` sibling.
*/
export function flattenTemporaryWorktreeBranchName(refName: string): string {
// Keep only the canonical 8-hex token so legacy UUID names stay recognizable.
const token = refName
.trim()
.toLowerCase()
.slice(WORKTREE_BRANCH_PREFIX.length + 1, WORKTREE_BRANCH_PREFIX.length + 9);
return `${WORKTREE_BRANCH_PREFIX}-${token}`;
}

export function isTemporaryWorktreeBranch(refName: string): boolean {
return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase());
}
Expand Down
Loading