diff --git a/apps/mobile/src/features/archive/archivedThreadList.test.ts b/apps/mobile/src/features/archive/archivedThreadList.test.ts index 5a1ae8d092f8..e305a8941371 100644 --- a/apps/mobile/src/features/archive/archivedThreadList.test.ts +++ b/apps/mobile/src/features/archive/archivedThreadList.test.ts @@ -15,6 +15,7 @@ function makeProject( repositoryIdentity: null, defaultModelSelection: null, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/features/home/homeListItems.test.ts b/apps/mobile/src/features/home/homeListItems.test.ts index 13c9c323623b..6d932b90306e 100644 --- a/apps/mobile/src/features/home/homeListItems.test.ts +++ b/apps/mobile/src/features/home/homeListItems.test.ts @@ -27,6 +27,7 @@ function makeProject(id: string, title: string): EnvironmentProject { repositoryIdentity: null, defaultModelSelection: null, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 1dc983230e0f..a2f2f9f29223 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -19,6 +19,7 @@ function makeProject( repositoryIdentity: null, defaultModelSelection: null, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index d8303ace8f29..7ac1333fc034 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -307,6 +307,7 @@ export function buildHomeThreadGroups(input: { repositoryIdentity: null, defaultModelSelection: null, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: pendingTask.message.createdAt, updatedAt: pendingTask.message.createdAt, diff --git a/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts b/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts index 178db10dc5e9..12838f6afc6b 100644 --- a/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts +++ b/apps/mobile/src/features/showcase/showcasePendingTasks.test.ts @@ -16,6 +16,7 @@ const projects: ReadonlyArray = [ repositoryIdentity: null, defaultModelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: "2026-07-16T08:00:00.000Z", updatedAt: "2026-07-16T08:00:00.000Z", @@ -28,6 +29,7 @@ const projects: ReadonlyArray = [ repositoryIdentity: null, defaultModelSelection: null, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: "2026-07-16T08:00:00.000Z", updatedAt: "2026-07-16T08:00:00.000Z", diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 7d2375dd05a3..c6996b1a28c4 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -261,6 +261,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { repositoryIdentity: null, defaultModelSelection: editingPendingTask.modelSelection ?? null, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: editingPendingTask.createdAt, updatedAt: editingPendingTask.createdAt, diff --git a/apps/mobile/src/features/threads/use-project-actions.ts b/apps/mobile/src/features/threads/use-project-actions.ts index 9d03dde59a93..d048a77c4fe9 100644 --- a/apps/mobile/src/features/threads/use-project-actions.ts +++ b/apps/mobile/src/features/threads/use-project-actions.ts @@ -74,7 +74,10 @@ export function useCreateProjectThread() { branch: input.branch, worktreePath: input.worktreePath, startFromOrigin: input.startFromOrigin ?? false, - worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), + worktreeBranchName: buildTemporaryWorktreeBranchName( + randomHex, + input.project.worktreeBranchPrefix, + ), }), }); if (AsyncResult.isFailure(result)) { diff --git a/apps/mobile/src/lib/repositoryGroups.test.ts b/apps/mobile/src/lib/repositoryGroups.test.ts index e801571589a3..26d43f0d861c 100644 --- a/apps/mobile/src/lib/repositoryGroups.test.ts +++ b/apps/mobile/src/lib/repositoryGroups.test.ts @@ -13,6 +13,7 @@ function makeProject( repositoryIdentity: null, defaultModelSelection: null, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 3559fa140fe8..b27413a76cf1 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -270,6 +270,9 @@ export function useThreadOutboxDrain(): void { branch: creation.branch, worktreePath: creation.worktreePath, startFromOrigin: creation.startFromOrigin ?? false, + // Queued creations don't carry the project's prefix; this is only the + // temporary branch, which the server renames to the project's + // configured `/` on the first turn. worktreeBranchName: buildTemporaryWorktreeBranchName(randomHex), }), }); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index c7dcefaf84f7..552f0b90e1ee 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -124,6 +124,7 @@ describe("OrchestrationEngine", () => { model: "gpt-5-codex", }, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: "2026-03-03T00:00:00.000Z", updatedAt: "2026-03-03T00:00:01.000Z", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 11d6188fd98e..2aa9dba7b9d4 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -496,6 +496,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti workspaceRoot: event.payload.workspaceRoot, defaultModelSelection: event.payload.defaultModelSelection, gitHubAccount: event.payload.gitHubAccount ?? null, + worktreeBranchPrefix: event.payload.worktreeBranchPrefix ?? null, scripts: event.payload.scripts, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -522,6 +523,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.gitHubAccount !== undefined ? { gitHubAccount: event.payload.gitHubAccount } : {}), + ...(event.payload.worktreeBranchPrefix !== undefined + ? { worktreeBranchPrefix: event.payload.worktreeBranchPrefix } + : {}), ...(event.payload.scripts !== undefined ? { scripts: event.payload.scripts } : {}), updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index c96084a25c25..73258edbac42 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -267,6 +267,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { model: "gpt-5-codex", }, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [ { id: "script-1", @@ -383,6 +384,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { model: "gpt-5-codex", }, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [ { id: "script-1", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 4784d2883df0..f681e42b1488 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -237,6 +237,7 @@ function mapProjectShellRow( repositoryIdentity, defaultModelSelection: row.defaultModelSelection, gitHubAccount: row.gitHubAccount, + worktreeBranchPrefix: row.worktreeBranchPrefix, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -311,6 +312,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", github_account_json AS "gitHubAccount", + worktree_branch_prefix AS "worktreeBranchPrefix", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -685,6 +687,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", github_account_json AS "gitHubAccount", + worktree_branch_prefix AS "worktreeBranchPrefix", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -708,6 +711,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", github_account_json AS "gitHubAccount", + worktree_branch_prefix AS "worktreeBranchPrefix", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1190,6 +1194,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { repositoryIdentity: repositoryIdentities.get(row.projectId) ?? null, defaultModelSelection: row.defaultModelSelection, gitHubAccount: row.gitHubAccount, + worktreeBranchPrefix: row.worktreeBranchPrefix, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1317,6 +1322,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { workspaceRoot: row.workspaceRoot, defaultModelSelection: row.defaultModelSelection, gitHubAccount: row.gitHubAccount, + worktreeBranchPrefix: row.worktreeBranchPrefix, scripts: row.scripts, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1766,6 +1772,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { repositoryIdentity, defaultModelSelection: option.value.defaultModelSelection, gitHubAccount: option.value.gitHubAccount, + worktreeBranchPrefix: option.value.worktreeBranchPrefix, scripts: option.value.scripts, createdAt: option.value.createdAt, updatedAt: option.value.updatedAt, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 3044cc6029d8..78e82acc0882 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -12,7 +12,7 @@ import { type RuntimeMode, type TurnId, } from "@t3tools/contracts"; -import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; +import { isTemporaryWorktreeBranch, sanitizeWorktreeBranchPrefix } from "@t3tools/shared/git"; import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; @@ -166,15 +166,16 @@ function stalePendingRequestDetail( return `Stale pending ${requestKind} request: ${requestId}. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.`; } -function buildGeneratedWorktreeBranchName(raw: string): string { +function buildGeneratedWorktreeBranchName(raw: string, prefix?: string | null): string { + const resolvedPrefix = sanitizeWorktreeBranchPrefix(prefix); const normalized = raw .trim() .toLowerCase() .replace(/^refs\/heads\//, "") .replace(/['"`]/g, ""); - const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`) - ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length) + const withoutPrefix = normalized.startsWith(`${resolvedPrefix}/`) + ? normalized.slice(`${resolvedPrefix}/`.length) : normalized; const branchFragment = withoutPrefix @@ -186,7 +187,7 @@ function buildGeneratedWorktreeBranchName(raw: string): string { .replace(/[./_-]+$/g, ""); const safeFragment = branchFragment.length > 0 ? branchFragment : "update"; - return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; + return `${resolvedPrefix}/${safeFragment}`; } const make = Effect.gen(function* () { @@ -677,6 +678,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly branch: string | null; readonly worktreePath: string | null; + readonly worktreeBranchPrefix?: string | null; readonly messageText: string; readonly attachments?: ReadonlyArray; }) { @@ -708,7 +710,10 @@ const make = Effect.gen(function* () { }); if (!generated) return; - const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); + // Prefer the project's prefix, then the global default setting; an empty + // string or null resolves to the built-in default inside the builder. + const effectivePrefix = input.worktreeBranchPrefix ?? settings.worktreeBranchPrefix; + const targetBranch = buildGeneratedWorktreeBranchName(generated.branch, effectivePrefix); if (targetBranch === oldBranch) return; const renamed = yield* gitWorkflow.renameBranch({ cwd, oldBranch, newBranch: targetBranch }); @@ -822,6 +827,7 @@ const make = Effect.gen(function* () { threadId: event.payload.threadId, branch: thread.branch, worktreePath: thread.worktreePath, + worktreeBranchPrefix: project?.worktreeBranchPrefix ?? null, ...generationInput, }).pipe(Effect.forkScoped); diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 824930ded3a4..2825a5115d48 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -36,6 +36,7 @@ const readModel: OrchestrationReadModel = { model: "gpt-5-codex", }, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: now, updatedAt: now, @@ -50,6 +51,7 @@ const readModel: OrchestrationReadModel = { model: "gpt-5-codex", }, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: now, updatedAt: now, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 16d02496d65f..82be7530295a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -251,6 +251,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" workspaceRoot: command.workspaceRoot, defaultModelSelection: command.defaultModelSelection ?? null, gitHubAccount: command.gitHubAccount ?? null, + worktreeBranchPrefix: command.worktreeBranchPrefix ?? null, scripts: [], createdAt: command.createdAt, updatedAt: command.createdAt, @@ -289,6 +290,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? { defaultModelSelection: command.defaultModelSelection } : {}), ...(command.gitHubAccount !== undefined ? { gitHubAccount: command.gitHubAccount } : {}), + ...(command.worktreeBranchPrefix !== undefined + ? { worktreeBranchPrefix: command.worktreeBranchPrefix } + : {}), ...(command.scripts !== undefined ? { scripts: command.scripts } : {}), updatedAt: occurredAt, }, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 1fe96dec0459..22010cf654d1 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -212,6 +212,7 @@ export function projectEvent( workspaceRoot: payload.workspaceRoot, defaultModelSelection: payload.defaultModelSelection, gitHubAccount: payload.gitHubAccount ?? null, + worktreeBranchPrefix: payload.worktreeBranchPrefix ?? null, scripts: payload.scripts, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -247,6 +248,9 @@ export function projectEvent( ...(payload.gitHubAccount !== undefined ? { gitHubAccount: payload.gitHubAccount } : {}), + ...(payload.worktreeBranchPrefix !== undefined + ? { worktreeBranchPrefix: payload.worktreeBranchPrefix } + : {}), ...(payload.scripts !== undefined ? { scripts: payload.scripts } : {}), updatedAt: payload.updatedAt, } diff --git a/apps/server/src/persistence/Layers/ProjectionProjects.ts b/apps/server/src/persistence/Layers/ProjectionProjects.ts index d5667dd33e9c..84838eabe486 100644 --- a/apps/server/src/persistence/Layers/ProjectionProjects.ts +++ b/apps/server/src/persistence/Layers/ProjectionProjects.ts @@ -37,6 +37,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root, default_model_selection_json, github_account_json, + worktree_branch_prefix, scripts_json, created_at, updated_at, @@ -48,6 +49,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { ${row.workspaceRoot}, ${row.defaultModelSelection !== null ? JSON.stringify(row.defaultModelSelection) : null}, ${row.gitHubAccount !== null ? JSON.stringify(row.gitHubAccount) : null}, + ${row.worktreeBranchPrefix}, ${JSON.stringify(row.scripts)}, ${row.createdAt}, ${row.updatedAt}, @@ -59,6 +61,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root = excluded.workspace_root, default_model_selection_json = excluded.default_model_selection_json, github_account_json = excluded.github_account_json, + worktree_branch_prefix = excluded.worktree_branch_prefix, scripts_json = excluded.scripts_json, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -77,6 +80,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", github_account_json AS "gitHubAccount", + worktree_branch_prefix AS "worktreeBranchPrefix", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", @@ -97,6 +101,7 @@ const makeProjectionProjectRepository = Effect.gen(function* () { workspace_root AS "workspaceRoot", default_model_selection_json AS "defaultModelSelection", github_account_json AS "gitHubAccount", + worktree_branch_prefix AS "worktreeBranchPrefix", scripts_json AS "scripts", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 135af03237d1..5e06871e208f 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -34,6 +34,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { model: "gpt-5.4", }, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 02ea71010715..a612e4e51b48 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -48,6 +48,7 @@ import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; import Migration0035 from "./Migrations/035_ProjectionProjectsGitHubAccount.ts"; +import Migration0036 from "./Migrations/036_ProjectionProjectsWorktreeBranchPrefix.ts"; /** * Migration loader with all migrations defined inline. @@ -95,6 +96,7 @@ export const migrationEntries = [ [33, "ProjectionThreadsSettled", Migration0033], [34, "ProjectionThreadsSnoozed", Migration0034], [35, "ProjectionProjectsGitHubAccount", Migration0035], + [36, "ProjectionProjectsWorktreeBranchPrefix", Migration0036], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/036_ProjectionProjectsWorktreeBranchPrefix.ts b/apps/server/src/persistence/Migrations/036_ProjectionProjectsWorktreeBranchPrefix.ts new file mode 100644 index 000000000000..81a36d476c17 --- /dev/null +++ b/apps/server/src/persistence/Migrations/036_ProjectionProjectsWorktreeBranchPrefix.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_projects) + `; + + if (!columns.some((column) => column.name === "worktree_branch_prefix")) { + yield* sql` + ALTER TABLE projection_projects + ADD COLUMN worktree_branch_prefix TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionProjects.ts b/apps/server/src/persistence/Services/ProjectionProjects.ts index 7fd5332ad124..5a9129ab9113 100644 --- a/apps/server/src/persistence/Services/ProjectionProjects.ts +++ b/apps/server/src/persistence/Services/ProjectionProjects.ts @@ -26,6 +26,7 @@ export const ProjectionProject = Schema.Struct({ workspaceRoot: Schema.String, defaultModelSelection: Schema.NullOr(ModelSelection), gitHubAccount: Schema.NullOr(GitHubAccountRef), + worktreeBranchPrefix: Schema.NullOr(Schema.String), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index e22f3bc92dd8..fcd66d2becbb 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -19,6 +19,7 @@ const makeProject = (scripts: OrchestrationProject["scripts"]): OrchestrationPro workspaceRoot: "/repo/project", defaultModelSelection: null, gitHubAccount: null, + worktreeBranchPrefix: null, scripts, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index b186e9b8a007..a0c2d86d74ee 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -429,6 +429,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { repositoryIdentity: null, defaultModelSelection: null, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: now, updatedAt: now, @@ -588,6 +589,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { repositoryIdentity: null, defaultModelSelection: null, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: now, updatedAt: now, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 12b01784d2c4..7cdab76dd074 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -154,6 +154,7 @@ const makeDefaultOrchestrationReadModel = () => { workspaceRoot: "/tmp/default-project", defaultModelSelection, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: now, updatedAt: now, @@ -5689,6 +5690,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { workspaceRoot: "/tmp/project-a", defaultModelSelection, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: now, updatedAt: now, diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 6777daae5b13..220a9fdeedb0 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -148,6 +148,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa workspaceRoot: "/tmp/startup-project", defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapDefaultModelSelection(), gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", diff --git a/apps/server/src/sourceControl/GitHubAccountResolver.ts b/apps/server/src/sourceControl/GitHubAccountResolver.ts index 16dfe50e66ca..8d297fcab65b 100644 --- a/apps/server/src/sourceControl/GitHubAccountResolver.ts +++ b/apps/server/src/sourceControl/GitHubAccountResolver.ts @@ -65,6 +65,32 @@ export function gitHubAccountGhEnv(resolved: ResolvedGitHubAccount): NodeJS.Proc }; } +/** + * Environment that makes BOTH `gh` and plain `git` (push/fetch over HTTPS) act + * as `resolved`'s account, for a single process (e.g. an interactive terminal + * or a one-off git command) — without touching global config or running + * `gh auth switch`. + * + * On top of {@link gitHubAccountGhEnv} (which points `gh` at the account via + * `GH_TOKEN`/`GH_ENTERPRISE_TOKEN` + `GH_HOST`), this wires git's credential + * helper to GitHub CLI via `GIT_CONFIG_*` env keys — equivalent to + * `gh auth setup-git` but scoped to this process only. The leading empty helper + * resets any inherited helper (OS keychain, global config) so the selected + * account wins; `!gh auth git-credential` then returns the `GH_TOKEN` above. + * This is the same mechanism CI uses (`GH_TOKEN` + gh credential helper). + */ +export function gitHubAccountAuthEnv(resolved: ResolvedGitHubAccount): NodeJS.ProcessEnv { + const baseUrl = `https://${resolved.account.host}`; + return { + ...gitHubAccountGhEnv(resolved), + GIT_CONFIG_COUNT: "2", + GIT_CONFIG_KEY_0: `credential.${baseUrl}.helper`, + GIT_CONFIG_VALUE_0: "", + GIT_CONFIG_KEY_1: `credential.${baseUrl}.helper`, + GIT_CONFIG_VALUE_1: "!gh auth git-credential", + }; +} + /** * True when `cwd` is `base` itself or lives beneath it. Compares normalized, * separator-terminated paths so `/a/repo` does not match `/a/repo-2`. diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 9e8a68295667..dec121a5b2d5 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -207,33 +207,22 @@ it.effect("creates GitHub PRs through provider-neutral input names", () => }), ); +const GH_AUTH_STATUS_ACTIVE_PLUS_STALE = `github.com + ✓ Logged in to github.com account active-user (keyring) + - Active account: true + - Git operations protocol: ssh + - Token: gho_************************************ + - Token scopes: 'gist', 'read:org', 'repo' + + X Failed to log in to github.com account stale-user (keyring) + - The token in keyring is invalid. + - To re-authenticate, run: gh auth login -h github.com + - To forget about this account, run: gh auth logout -h github.com -u stale-user +`; + it("accepts active authenticated GitHub accounts when another account fails", () => { const auth = GitHubSourceControlProvider.discovery.parseAuth( - processResult( - JSON.stringify({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "active-user", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - { - state: "error", - active: false, - host: "github.com", - login: "stale-user", - tokenSource: "keyring", - gitProtocol: "ssh", - error: "The token in keyring is invalid.", - }, - ], - }, - }), - ), + processResult(GH_AUTH_STATUS_ACTIVE_PLUS_STALE), ); assert.deepStrictEqual( @@ -250,25 +239,14 @@ it("accepts active authenticated GitHub accounts when another account fails", () ); }); -it("parses GitHub auth JSON from stdout when stderr has warnings", () => { +it("parses GitHub auth status from stderr when stdout is empty (older gh)", () => { const auth = GitHubSourceControlProvider.discovery.parseAuth( - processResult( - JSON.stringify({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "active-user", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - }, - }), - { stderr: "warning: ignored diagnostic from gh\n" }, - ), + processResult("", { + stderr: `github.com + ✓ Logged in to github.com account active-user (keyring) + - Active account: true +`, + }), ); assert.deepStrictEqual( @@ -288,38 +266,19 @@ it("parses GitHub auth JSON from stdout when stderr has warnings", () => { it("parses GitHub auth status accounts by host and active state", () => { assert.deepStrictEqual( parseGitHubAuthStatus( - JSON.stringify({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "active-user", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - { - state: "error", - active: false, - host: "github.com", - login: "stale-user", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - "github.example.test": [ - { - state: "success", - active: false, - host: "github.example.test", - login: "enterprise-user", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - }, - }), + `github.com + ✓ Logged in to github.com account active-user (keyring) + - Active account: true + - Git operations protocol: ssh + + X Failed to log in to github.com account stale-user (keyring) + - The token in keyring is invalid. + +github.example.test + ✓ Logged in to github.example.test account enterprise-user (keyring) + - Active account: false + - Git operations protocol: ssh +`, ).accounts, [ { @@ -334,7 +293,7 @@ it("parses GitHub auth status accounts by host and active state", () => { account: "stale-user", authenticated: false, active: false, - error: null, + error: "The token in keyring is invalid.", }, { host: "github.example.test", @@ -347,24 +306,15 @@ it("parses GitHub auth status accounts by host and active state", () => { ); }); -it("reports unauthenticated when GitHub JSON has accounts but none are valid", () => { +it("reports unauthenticated when GitHub accounts exist but none are valid", () => { const auth = GitHubSourceControlProvider.discovery.parseAuth( processResult( - JSON.stringify({ - hosts: { - "github.com": [ - { - state: "error", - active: true, - host: "github.com", - login: "stale-user", - tokenSource: "keyring", - gitProtocol: "ssh", - error: "The token in keyring is invalid.", - }, - ], - }, - }), + `github.com + X Failed to log in to github.com account stale-user (keyring) + - The token in keyring is invalid. + - To re-authenticate, run: gh auth login -h github.com +`, + { exitCode: ChildProcessSpawner.ExitCode(1) }, ), ); diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index cfe140fea545..4d2c6611b65c 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -44,7 +44,7 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq function parseGitHubAuth(input: SourceControlAuthProbeInput) { const output = combinedAuthOutput(input); - const authStatus = parseGitHubAuthStatus(input.stdout); + const authStatus = parseGitHubAuthStatus(output); const authenticatedAccount = findAuthenticatedGitHubAccount(authStatus.accounts); const host = authenticatedAccount?.host; @@ -83,7 +83,7 @@ function parseGitHubAuth(input: SourceControlAuthProbeInput) { } function parseGitHubAccounts(input: SourceControlAuthProbeInput) { - return parseGitHubAuthStatus(input.stdout).accounts.map((account) => ({ + return parseGitHubAuthStatus(combinedAuthOutput(input)).accounts.map((account) => ({ host: account.host, login: account.account, authenticated: account.authenticated, @@ -97,7 +97,7 @@ export const discovery = { label: "GitHub", executable: "gh", versionArgs: ["--version"], - authArgs: ["auth", "status", "--json", "hosts"], + authArgs: ["auth", "status"], parseAuth: parseGitHubAuth, parseAccounts: parseGitHubAccounts, installHint: diff --git a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts index 9e4702af04cd..71396db99737 100644 --- a/apps/server/src/sourceControl/SourceControlDiscovery.test.ts +++ b/apps/server/src/sourceControl/SourceControlDiscovery.test.ts @@ -59,24 +59,13 @@ it.effect("reports implemented tools separately from locally available executabl if (input.command === "gh" && input.args[0] === "--version") { return Effect.succeed(processOutput("gh version 2.83.0\n")); } - if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { + if (input.command === "gh" && input.args.join(" ") === "auth status") { return Effect.succeed( - processOutput( - JSON.stringify({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "juliusmarminge", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - }, - }), - ), + processOutput(`github.com + ✓ Logged in to github.com account juliusmarminge (keyring) + - Active account: true + - Git operations protocol: ssh +`), ); } return Effect.fail( @@ -175,24 +164,13 @@ it.effect("probes provider authentication without exposing token details", () => if (input.args[0] === "--version") { return Effect.succeed(processOutput(`${input.command} version test\n`)); } - if (input.command === "gh" && input.args.join(" ") === "auth status --json hosts") { + if (input.command === "gh" && input.args.join(" ") === "auth status") { return Effect.succeed( - processOutput( - JSON.stringify({ - hosts: { - "github.com": [ - { - state: "success", - active: true, - host: "github.com", - login: "octocat", - tokenSource: "keyring", - gitProtocol: "ssh", - }, - ], - }, - }), - ), + processOutput(`github.com + ✓ Logged in to github.com account octocat (keyring) + - Active account: true + - Git operations protocol: ssh +`), ); } if (input.command === "glab" && input.args.join(" ") === "auth status") { diff --git a/apps/server/src/sourceControl/gitHubAuthStatus.ts b/apps/server/src/sourceControl/gitHubAuthStatus.ts index d58909c560c2..4e234882ff64 100644 --- a/apps/server/src/sourceControl/gitHubAuthStatus.ts +++ b/apps/server/src/sourceControl/gitHubAuthStatus.ts @@ -1,22 +1,3 @@ -import * as Option from "effect/Option"; -import * as Schema from "effect/Schema"; - -const GitHubAuthStatusAccountSchema = Schema.Struct({ - state: Schema.String, - error: Schema.optional(Schema.String), - active: Schema.Boolean, - host: Schema.String, - login: Schema.String, -}); - -const GitHubAuthStatusSchema = Schema.Struct({ - hosts: Schema.Record(Schema.String, Schema.Array(GitHubAuthStatusAccountSchema)), -}); - -const decodeGitHubAuthStatusJson = Schema.decodeUnknownOption( - Schema.fromJsonString(GitHubAuthStatusSchema), -); - export interface GitHubAuthStatusAccount { readonly host: string; readonly account: string; @@ -30,36 +11,101 @@ export interface GitHubAuthStatus { readonly accounts: ReadonlyArray; } +interface MutableGitHubAuthStatusAccount { + host: string; + account: string; + authenticated: boolean; + active: boolean; + error: string | null; +} + function nonEmptyString(value: string): string | null { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } +// `gh auth status` has no `--json` flag; it only prints a human-readable +// report. Each account is introduced by a header line and followed by +// indented `- key: value` detail lines, e.g.: +// +// github.com +// ✓ Logged in to github.com account octocat (keyring) +// - Active account: true +// - Git operations protocol: https +// - Token: gho_************************************ +// - Token scopes: 'gist', 'read:org', 'repo' +// +// X Failed to log in to github.com account stale-user (keyring) +// - The token in keyring is invalid. +// - To re-authenticate, run: gh auth login -h github.com +// +// We match on the stable "Logged in to" / "Failed to log in to" phrases +// rather than the colored status glyph, which varies by terminal/version. +const LOGGED_IN_PATTERN = /Logged in to (\S+) account (\S+)/; +const FAILED_PATTERN = /Failed to log in to (\S+) account (\S+)/; +const ACTIVE_ACCOUNT_PATTERN = /^-\s*Active account:\s*(true|false)/i; +const KNOWN_DETAIL_PATTERN = + /^(?:Active account:|Git operations protocol:|Token(?:\s+scopes)?:|To (?:re-authenticate|forget))/i; + export function parseGitHubAuthStatus(text: string): GitHubAuthStatus { - return Option.match(decodeGitHubAuthStatusJson(text), { - onNone: () => ({ parsed: false, accounts: [] }), - onSome: (status) => - ({ - parsed: true, - accounts: Object.values(status.hosts).flatMap((accounts) => - accounts.flatMap((account) => { - const host = nonEmptyString(account.host); - const login = nonEmptyString(account.login); - if (host === null || login === null) return []; + const accounts: MutableGitHubAuthStatusAccount[] = []; + let current: MutableGitHubAuthStatusAccount | null = null; + let sawAccountHeader = false; + + const finalize = () => { + if (current !== null) { + accounts.push(current); + current = null; + } + }; + + for (const rawLine of text.split(/\r?\n/)) { + const line = rawLine.trim(); + if (line.length === 0) continue; + + const loggedIn = LOGGED_IN_PATTERN.exec(line); + const failed = loggedIn ? null : FAILED_PATTERN.exec(line); + const header = loggedIn ?? failed; + if (header) { + finalize(); + sawAccountHeader = true; + const host = nonEmptyString(header[1] ?? ""); + const login = nonEmptyString(header[2] ?? ""); + current = + host === null || login === null + ? null + : { + host: host.toLowerCase(), + account: login, + authenticated: loggedIn !== null, + active: false, + error: null, + }; + continue; + } + + if (current === null) continue; + + const activeMatch = ACTIVE_ACCOUNT_PATTERN.exec(line); + if (activeMatch) { + current.active = activeMatch[1]?.toLowerCase() === "true"; + continue; + } + + // The first descriptive detail line under a failed account is its reason. + if (!current.authenticated && current.error === null && line.startsWith("-")) { + const detail = line.replace(/^-\s*/, "").trim(); + if (detail.length > 0 && !KNOWN_DETAIL_PATTERN.test(detail)) { + current.error = detail; + } + } + } + finalize(); - return [ - { - host: host.toLowerCase(), - account: login, - authenticated: account.state === "success", - active: account.active, - error: account.error?.trim() || null, - }, - ]; - }), - ), - }) satisfies GitHubAuthStatus, - }); + return { + parsed: sawAccountHeader, + accounts: accounts.map((account) => ({ ...account })), + }; } export function findAuthenticatedGitHubAccount( diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index caa5106bb9fd..33ed64f6b070 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -59,6 +59,10 @@ import { } from "../observability/Metrics.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "../preview/PortScanner.ts"; +import { + GitHubAccountResolver, + gitHubAccountAuthEnv, +} from "../sourceControl/GitHubAccountResolver.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; export { @@ -1845,6 +1849,21 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return yield* trySpawn(shellCandidates, spawnEnv, session, index + 1, spawnError); }); + /** + * Env that makes `gh`/`git` in the spawned shell act as the GitHub account + * selected for the project owning `cwd`. Returns `{}` when no account is + * attached or the resolver isn't provided, leaving ambient auth untouched. + */ + const resolveTerminalAccountEnv = (cwd: string): Effect.Effect => + Effect.gen(function* () { + const resolverOption = yield* Effect.serviceOption(GitHubAccountResolver); + if (Option.isNone(resolverOption)) { + return {}; + } + const resolved = yield* resolverOption.value.resolveForCwd(cwd); + return resolved === null ? {} : gitHubAccountAuthEnv(resolved); + }); + const startSession = Effect.fn("terminal.startSession")(function* ( session: TerminalSessionState, input: TerminalStartInput, @@ -1885,7 +1904,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.gen(function* () { const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); - const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); + // Make `gh` and `git` in this terminal act as the project's selected + // GitHub account (if any), so pushes/PRs use the right identity + // without the user running `gh auth switch`. + const accountEnv = yield* resolveTerminalAccountEnv(session.cwd); + const spawnResult = yield* trySpawn( + shellCandidates, + { ...terminalEnv, ...accountEnv }, + session, + ); ptyProcess = spawnResult.process; startedShell = spawnResult.shellLabel; diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3aa7575d5909..3fefd662b31b 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -35,6 +35,10 @@ import { parseRemoteNamesInGitOrder, parseRemoteRefWithRemoteNames, } from "../git/remoteRefs.ts"; +import { + GitHubAccountResolver, + gitHubAccountAuthEnv, +} from "../sourceControl/GitHubAccountResolver.ts"; import { ServerConfig } from "../config.ts"; const DEFAULT_TIMEOUT_MS = 30_000; @@ -904,6 +908,30 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ): Effect.Effect => executeGit(operation, cwd, args, { allowNonZeroExit }).pipe(Effect.asVoid); + /** + * Resolve the env that authenticates git/gh as the project's selected GitHub + * account for `cwd`. Returns `{}` when no account is attached or the resolver + * isn't provided (tests/minimal layers), leaving ambient auth untouched. + */ + const resolveAccountAuthEnv = (cwd: string): Effect.Effect => + Effect.gen(function* () { + const resolverOption = yield* Effect.serviceOption(GitHubAccountResolver); + if (Option.isNone(resolverOption)) { + return {}; + } + const resolved = yield* resolverOption.value.resolveForCwd(cwd); + return resolved === null ? {} : gitHubAccountAuthEnv(resolved); + }); + + const runGitWithEnv = ( + operation: string, + cwd: string, + args: readonly string[], + env: NodeJS.ProcessEnv, + allowNonZeroExit = false, + ): Effect.Effect => + executeGit(operation, cwd, args, { allowNonZeroExit, env }).pipe(Effect.asVoid); + const runGitStdout = ( operation: string, cwd: string, @@ -1828,15 +1856,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); } + // Authenticate the push as the project's selected GitHub account (if any). + const authEnv = yield* resolveAccountAuthEnv(cwd); + const requestedRemoteName = options?.remoteName?.trim() || null; if (requestedRemoteName) { const publishBranch = yield* resolvePublishBranchName(cwd, branch); - yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", cwd, [ - "push", - "-u", - requestedRemoteName, - `HEAD:refs/heads/${publishBranch}`, - ]); + yield* runGitWithEnv( + "GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", + cwd, + ["push", "-u", requestedRemoteName, `HEAD:refs/heads/${publishBranch}`], + authEnv, + ); return { status: "pushed" as const, branch, @@ -1894,12 +1925,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); } const publishBranch = yield* resolvePublishBranchName(cwd, branch); - yield* runGit("GitVcsDriver.pushCurrentBranch.pushWithUpstream", cwd, [ - "push", - "-u", - publishRemoteName, - `HEAD:refs/heads/${publishBranch}`, - ]); + yield* runGitWithEnv( + "GitVcsDriver.pushCurrentBranch.pushWithUpstream", + cwd, + ["push", "-u", publishRemoteName, `HEAD:refs/heads/${publishBranch}`], + authEnv, + ); return { status: "pushed" as const, branch, @@ -1912,11 +1943,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.orElseSucceed(() => null), ); if (currentUpstream) { - yield* runGit("GitVcsDriver.pushCurrentBranch.pushUpstream", cwd, [ - "push", - currentUpstream.remoteName, - `HEAD:refs/heads/${currentUpstream.branchName}`, - ]); + yield* runGitWithEnv( + "GitVcsDriver.pushCurrentBranch.pushUpstream", + cwd, + ["push", currentUpstream.remoteName, `HEAD:refs/heads/${currentUpstream.branchName}`], + authEnv, + ); return { status: "pushed" as const, branch, @@ -1925,7 +1957,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }; } - yield* runGit("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"]); + yield* runGitWithEnv("GitVcsDriver.pushCurrentBranch.push", cwd, ["push"], authEnv); return { status: "pushed" as const, branch, @@ -2576,12 +2608,13 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const fetchRemote: GitVcsDriver.GitVcsDriver["Service"]["fetchRemote"] = Effect.fn("fetchRemote")( function* (input) { + const authEnv = yield* resolveAccountAuthEnv(input.cwd); yield* executeGit( "GitVcsDriver.fetchRemote", input.cwd, ["fetch", "--quiet", input.remoteName], { - env: STATUS_UPSTREAM_REFRESH_ENV, + env: { ...STATUS_UPSTREAM_REFRESH_ENV, ...authEnv }, fallbackErrorDetail: `git fetch ${input.remoteName} failed`, }, ); @@ -2609,13 +2642,19 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const fetchRemoteBranch: GitVcsDriver.GitVcsDriver["Service"]["fetchRemoteBranch"] = Effect.fn( "fetchRemoteBranch", )(function* (input) { - yield* runGit("GitVcsDriver.fetchRemoteBranch.fetch", input.cwd, [ - "fetch", - "--quiet", - "--no-tags", - input.remoteName, - `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, - ]); + const authEnv = yield* resolveAccountAuthEnv(input.cwd); + yield* runGitWithEnv( + "GitVcsDriver.fetchRemoteBranch.fetch", + input.cwd, + [ + "fetch", + "--quiet", + "--no-tags", + input.remoteName, + `+refs/heads/${input.remoteBranch}:refs/remotes/${input.remoteName}/${input.remoteBranch}`, + ], + authEnv, + ); const localBranchAlreadyExists = yield* branchExists(input.cwd, input.localBranch); const targetRef = `${input.remoteName}/${input.remoteBranch}`; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1fa5e489a651..8e55ce11ef85 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -913,24 +913,44 @@ const makeWsRpcLayer = ( } if (bootstrap?.prepareWorktree) { - let worktreeBaseRef = bootstrap.prepareWorktree.baseBranch; - if (bootstrap.prepareWorktree.startFromOrigin) { - yield* gitWorkflow.fetchRemote({ - cwd: bootstrap.prepareWorktree.projectCwd, - remoteName: "origin", - }); - const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ - cwd: bootstrap.prepareWorktree.projectCwd, - refName: bootstrap.prepareWorktree.baseBranch, - fallbackRemoteName: "origin", - }); - worktreeBaseRef = resolvedRemoteBase.commitSha; + const prepareWorktree = bootstrap.prepareWorktree; + let worktreeBaseRef = prepareWorktree.baseBranch; + if (prepareWorktree.startFromOrigin) { + // Basing the new worktree on the freshest origin commit is a + // best-effort optimization. If origin is unreachable (offline, + // auth failure, no remote), `git fetch origin` errors — but that + // must not block getting into the worktree. Fall back to the + // local base branch and continue instead of failing the turn. + worktreeBaseRef = yield* Effect.gen(function* () { + yield* gitWorkflow.fetchRemote({ + cwd: prepareWorktree.projectCwd, + remoteName: "origin", + }); + const resolvedRemoteBase = yield* gitWorkflow.resolveRemoteTrackingCommit({ + cwd: prepareWorktree.projectCwd, + refName: prepareWorktree.baseBranch, + fallbackRemoteName: "origin", + }); + return resolvedRemoteBase.commitSha; + }).pipe( + Effect.catch((error) => + Effect.logWarning( + "bootstrap turn start could not start worktree from origin; using local base branch instead", + { + threadId: command.threadId, + projectCwd: prepareWorktree.projectCwd, + baseBranch: prepareWorktree.baseBranch, + detail: error.message, + }, + ).pipe(Effect.as(prepareWorktree.baseBranch)), + ), + ); } const worktree = yield* gitWorkflow.createWorktree({ - cwd: bootstrap.prepareWorktree.projectCwd, + cwd: prepareWorktree.projectCwd, refName: worktreeBaseRef, - newRefName: bootstrap.prepareWorktree.branch, - baseRefName: bootstrap.prepareWorktree.baseBranch, + newRefName: prepareWorktree.branch, + baseRefName: prepareWorktree.baseBranch, path: null, }); targetWorktreePath = worktree.worktree.path; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index d532c8b1233b..370cbd5e9db6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4790,7 +4790,10 @@ function ChatViewContent(props: ChatViewProps) { prepareWorktree: { projectCwd: activeProject.workspaceRoot, baseBranch: baseBranchForWorktree, - branch: buildTemporaryWorktreeBranchName(randomHex), + branch: buildTemporaryWorktreeBranchName( + randomHex, + activeProject.worktreeBranchPrefix ?? settings.worktreeBranchPrefix, + ), ...(startFromOrigin ? { startFromOrigin: true } : {}), }, runSetupScript: true, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 5339c395d5d9..11bad3afa3b1 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1058,6 +1058,7 @@ function makeProject(overrides: Partial = {}): Project { updatedAt: "2026-03-09T10:00:00.000Z", scripts: [], gitHubAccount: null, + worktreeBranchPrefix: null, ...rest, }; } diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 99df66bb50f8..5f9f305b04d4 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -18,6 +18,7 @@ import type { ScopedThreadRef, SidebarProjectGroupingMode, } from "@t3tools/contracts"; +import { sanitizeWorktreeBranchPrefix, WORKTREE_BRANCH_PREFIX } from "@t3tools/shared/git"; import { AlarmClockIcon, AlarmClockOffIcon, @@ -28,6 +29,7 @@ import { CircleDashedIcon, ClockIcon, CopyIcon, + FilePenIcon, FolderIcon, FolderPlusIcon, GitBranchIcon, @@ -95,7 +97,11 @@ import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; -import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; +import { + environmentServerConfigsAtom, + primaryServerKeybindingsAtom, + primaryServerSettingsAtom, +} from "../state/server"; import { vcsEnvironment } from "../state/vcs"; import { threadEnvironment } from "../state/threads"; import { projectEnvironment } from "../state/projects"; @@ -969,6 +975,17 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { )} {prBadge} + {gitStatus.data?.hasWorkingTreeChanges ? ( + + + {gitStatus.data.workingTree.files.length} + + ) : null} {diff ? ( +{diff.insertions}{" "} @@ -1243,6 +1260,7 @@ export default function SidebarV2() { [sidebarProjectSortOrder, threads, unsortedProjectGroups], ); const serverProviders = useAtomValue(primaryServerProvidersAtom); + const globalWorktreeBranchPrefix = useAtomValue(primaryServerSettingsAtom).worktreeBranchPrefix; const providerEntryByInstanceId = useMemo( () => new Map( @@ -1511,6 +1529,29 @@ export default function SidebarV2() { [updateProject], ); + const updateProjectWorktreeBranchPrefix = useCallback( + async (member: SidebarProjectGroupMember, rawValue: string) => { + const trimmed = rawValue.trim(); + const nextPrefix = trimmed.length === 0 ? null : sanitizeWorktreeBranchPrefix(trimmed); + if ((member.worktreeBranchPrefix ?? null) === nextPrefix) return; + const result = await updateProject({ + environmentId: member.environmentId, + input: { projectId: member.id, worktreeBranchPrefix: nextPrefix }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to update branch prefix", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [updateProject], + ); + const updateProjectGroupingPreference = useCallback( (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => { const overrideKey = deriveProjectGroupingOverrideKey(member); @@ -2858,6 +2899,35 @@ export default function SidebarV2() { member={member} onSelect={updateProjectGitHubAccount} /> + + + updateSettings({ + worktreeBranchPrefix: DEFAULT_UNIFIED_SETTINGS.worktreeBranchPrefix, + }) + } + /> + ) : null + } + control={ + updateSettings({ worktreeBranchPrefix: next })} + placeholder={WORKTREE_BRANCH_PREFIX} + spellCheck={false} + aria-label="Worktree branch prefix" + /> + } + /> + = {}): Project { updatedAt: "2026-01-01T00:00:00.000Z", scripts: [], gitHubAccount: null, + worktreeBranchPrefix: null, ...overrides, }; } diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts index 6f43afd6dd9b..3cfa2bf4a771 100644 --- a/packages/client-runtime/src/operations/projects.test.ts +++ b/packages/client-runtime/src/operations/projects.test.ts @@ -103,6 +103,7 @@ describe("add project shared logic", () => { repositoryIdentity: null, defaultModelSelection: null, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], }, { @@ -115,6 +116,7 @@ describe("add project shared logic", () => { repositoryIdentity: null, defaultModelSelection: null, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], }, ]; diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index cc0f37274d60..08c43c5287ff 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -116,6 +116,7 @@ const SNAPSHOT: OrchestrationShellSnapshot = { repositoryIdentity: null, defaultModelSelection: null, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", @@ -127,6 +128,7 @@ const SNAPSHOT: OrchestrationShellSnapshot = { repositoryIdentity: null, defaultModelSelection: null, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: "2026-06-01T00:00:00.000Z", updatedAt: "2026-06-01T00:00:00.000Z", diff --git a/packages/client-runtime/src/state/shellReducer.test.ts b/packages/client-runtime/src/state/shellReducer.test.ts index 518b4c26a6a8..aef92ec0f8f9 100644 --- a/packages/client-runtime/src/state/shellReducer.test.ts +++ b/packages/client-runtime/src/state/shellReducer.test.ts @@ -19,6 +19,7 @@ const stubProject = { repositoryIdentity: null, defaultModelSelection: null, gitHubAccount: null, + worktreeBranchPrefix: null, scripts: [], createdAt: "2026-04-01T00:00:00.000Z", updatedAt: "2026-04-01T00:00:00.000Z", diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index bd89e1e4e0bd..544a2e20cb2f 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -222,6 +222,13 @@ export const OrchestrationProject = Schema.Struct({ gitHubAccount: Schema.NullOr(GitHubAccountRef).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + /** + * Branch-name prefix for auto-created worktree branches (e.g. `feature` + * produces `feature/`). Null falls back to the default prefix. + */ + worktreeBranchPrefix: Schema.NullOr(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -402,6 +409,9 @@ export const OrchestrationProjectShell = Schema.Struct({ gitHubAccount: Schema.NullOr(GitHubAccountRef).pipe( Schema.withDecodingDefault(Effect.succeed(null)), ), + worktreeBranchPrefix: Schema.NullOr(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -532,6 +542,7 @@ export const ProjectCreateCommand = Schema.Struct({ createWorkspaceRootIfMissing: Schema.optional(Schema.Boolean), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), gitHubAccount: Schema.optional(Schema.NullOr(GitHubAccountRef)), + worktreeBranchPrefix: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), createdAt: IsoDateTime, }); @@ -543,6 +554,7 @@ const ProjectMetaUpdateCommand = Schema.Struct({ workspaceRoot: Schema.optional(TrimmedNonEmptyString), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), gitHubAccount: Schema.optional(Schema.NullOr(GitHubAccountRef)), + worktreeBranchPrefix: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), scripts: Schema.optional(Schema.Array(ProjectScript)), }); @@ -930,6 +942,7 @@ export const ProjectCreatedPayload = Schema.Struct({ repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.NullOr(ModelSelection), gitHubAccount: Schema.optional(Schema.NullOr(GitHubAccountRef)), + worktreeBranchPrefix: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), scripts: Schema.Array(ProjectScript), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -942,6 +955,7 @@ export const ProjectMetaUpdatedPayload = Schema.Struct({ repositoryIdentity: Schema.optional(Schema.NullOr(RepositoryIdentity)), defaultModelSelection: Schema.optional(Schema.NullOr(ModelSelection)), gitHubAccount: Schema.optional(Schema.NullOr(GitHubAccountRef)), + worktreeBranchPrefix: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), scripts: Schema.optional(Schema.Array(ProjectScript)), updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 74fa5a8bb849..5e1cba80bf95 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -517,6 +517,13 @@ export const ServerSettings = Schema.Struct({ Schema.withDecodingDefault(Effect.succeed(true)), ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), + /** + * Global default branch-name prefix for auto-created worktree branches + * (e.g. `feature` → `feature/`). Empty falls back to the built-in + * default (`t3code`). A project's own `worktreeBranchPrefix`, when set, + * overrides this. + */ + worktreeBranchPrefix: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( Effect.succeed({ @@ -665,6 +672,7 @@ export const ServerSettingsPatch = Schema.Struct({ defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), + worktreeBranchPrefix: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), sourceControlWritingStyle: Schema.optionalKey( Schema.Struct({ diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 96539f0aae24..7fa77ae3c09e 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -7,6 +7,7 @@ import { isTemporaryWorktreeBranch, normalizeGitRemoteUrl, parseGitHubRepositoryNameWithOwnerFromRemoteUrl, + sanitizeWorktreeBranchPrefix, WORKTREE_BRANCH_PREFIX, } from "./git.ts"; @@ -99,6 +100,34 @@ describe("isTemporaryWorktreeBranch", () => { expect(isTemporaryWorktreeBranch("main")).toBe(false); expect(isTemporaryWorktreeBranch(`${WORKTREE_BRANCH_PREFIX}/deadbeef-extra`)).toBe(false); }); + + it("recognizes temporary branches created under a custom prefix", () => { + const branch = buildTemporaryWorktreeBranchName(() => "deadbeef", "feature"); + expect(branch).toBe("feature/deadbeef"); + expect(isTemporaryWorktreeBranch(branch)).toBe(true); + expect(isTemporaryWorktreeBranch("feature/f4ae4e0e-f971-4d48-b4f2-9cf0aa54ab12")).toBe(true); + }); +}); + +describe("sanitizeWorktreeBranchPrefix", () => { + it("falls back to the default prefix for empty or nullish input", () => { + expect(sanitizeWorktreeBranchPrefix(null)).toBe(WORKTREE_BRANCH_PREFIX); + expect(sanitizeWorktreeBranchPrefix(undefined)).toBe(WORKTREE_BRANCH_PREFIX); + expect(sanitizeWorktreeBranchPrefix(" ")).toBe(WORKTREE_BRANCH_PREFIX); + expect(sanitizeWorktreeBranchPrefix("///")).toBe(WORKTREE_BRANCH_PREFIX); + }); + + it("normalizes a prefix into a single lowercase ref segment", () => { + expect(sanitizeWorktreeBranchPrefix("Feature")).toBe("feature"); + expect(sanitizeWorktreeBranchPrefix("my feature")).toBe("my-feature"); + expect(sanitizeWorktreeBranchPrefix("team/wip")).toBe("team-wip"); + }); + + it("keeps the sanitized prefix as a valid single-segment temp branch", () => { + const branch = buildTemporaryWorktreeBranchName(() => "deadbeef", "team/wip"); + expect(branch).toBe("team-wip/deadbeef"); + expect(isTemporaryWorktreeBranch(branch)).toBe(true); + }); }); describe("applyGitStatusStreamEvent", () => { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 71fe2e806cfc..dd9181ad1102 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -11,13 +11,15 @@ import * as Result from "effect/Result"; import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts"; export const WORKTREE_BRANCH_PREFIX = "t3code"; -// Canonical form is `t3code/<8 hex>`. Older mobile builds generated `t3code/` +// Canonical form is `/<8 hex>`, where `` is per-project +// configurable (default `t3code`). Older mobile builds generated `/` // 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. -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})$`, -); +// The prefix is intentionally matched as any single ref segment rather than a fixed +// literal so branches created under a custom prefix are still recognized. +const TEMP_WORKTREE_BRANCH_PATTERN = + /^[^/\s]+\/(?:[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})$/; /** * Sanitize an arbitrary string into a valid, lowercase git refName fragment. @@ -92,8 +94,32 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { return branchName.slice(firstSeparatorIndex + 1); } +/** + * Normalize a user-provided worktree branch prefix into a single, valid, + * lowercase git ref segment (no slashes). Falls back to the default prefix + * when the input is empty/null or sanitizes to nothing. + */ +export function sanitizeWorktreeBranchPrefix(raw: string | null | undefined): string { + if (raw === null || raw === undefined) { + return WORKTREE_BRANCH_PREFIX; + } + const normalized = raw + .trim() + .toLowerCase() + .replace(/['"`]/g, "") + .replace(/[^a-z0-9/_-]+/g, "-") + // A prefix must be a single ref segment so the temp branch stays `prefix/token`. + .replace(/[/]+/g, "-") + .replace(/-+/g, "-") + .replace(/^[._-]+|[._-]+$/g, "") + .slice(0, 64) + .replace(/[._-]+$/g, ""); + return normalized.length > 0 ? normalized : WORKTREE_BRANCH_PREFIX; +} + export function buildTemporaryWorktreeBranchName( randomHex: (byteLength: number) => string, + prefix?: string | null, ): string { // Normalize to exactly 8 lowercase hex chars so a UUID-shaped callback // still produces the canonical temporary branch form. @@ -101,7 +127,7 @@ export function buildTemporaryWorktreeBranchName( .toLowerCase() .replace(/[^0-9a-f]/g, "") .slice(0, 8); - return `${WORKTREE_BRANCH_PREFIX}/${token}`; + return `${sanitizeWorktreeBranchPrefix(prefix)}/${token}`; } export function isTemporaryWorktreeBranch(refName: string): boolean {