From a21095bc9ae48f9555f6344a97834528ec6891af Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 28 Feb 2026 22:01:48 -0800 Subject: [PATCH] Add AI-generated worktree branch naming and safe branch rename - add `git.generateBranchName`, `git.generateAndRenameBranch`, and `git.renameBranch` websocket flows - pass image attachments into Codex branch-name generation and normalize/sanitize results - add GitCore branch rename with collision-safe numeric suffixing - extract shared image MIME/data-url helpers and expand server/web tests for new behavior --- .../git/Layers/CodexTextGeneration.test.ts | 96 ++++++++++ .../src/git/Layers/CodexTextGeneration.ts | 171 +++++++++++++++++- apps/server/src/git/Layers/GitCore.test.ts | 96 ++++++++++ apps/server/src/git/Layers/GitCore.ts | 58 ++++++ apps/server/src/git/Layers/GitManager.test.ts | 19 ++ apps/server/src/git/Services/GitCore.ts | 9 + .../server/src/git/Services/TextGeneration.ts | 19 ++ apps/server/src/imageMime.ts | 64 +++++++ .../Layers/ProjectionPipeline.ts | 30 +-- apps/server/src/projectFaviconRoute.ts | 25 +-- apps/server/src/wsServer.test.ts | 130 ++++++++++++- apps/server/src/wsServer.ts | 59 ++++++ apps/web/src/components/ChatView.tsx | 128 +++++++++++-- apps/web/src/worktreeCleanup.test.ts | 8 +- apps/web/src/wsNativeApi.ts | 4 + packages/contracts/src/git.ts | 42 +++++ packages/contracts/src/ipc.ts | 11 ++ packages/contracts/src/ws.ts | 9 + 18 files changed, 897 insertions(+), 81 deletions(-) create mode 100644 apps/server/src/imageMime.ts diff --git a/apps/server/src/git/Layers/CodexTextGeneration.test.ts b/apps/server/src/git/Layers/CodexTextGeneration.test.ts index bbc23fb04097b2ebc1c5bd130976436148bc4f1a..d6930948cc6ce7d2d40cb407e772644d3289b2b6 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.test.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.test.ts @@ -21,12 +21,23 @@ function makeFakeCodexBinary(dir: string) { "#!/bin/sh", 'output_path=""', "while [ $# -gt 0 ]; do", + ' if [ "$1" = "--image" ]; then', + " shift", + ' if [ -n "$1" ]; then', + ' seen_image="1"', + " fi", + " continue", + " fi", ' if [ "$1" = "--output-last-message" ]; then', " shift", ' output_path="$1"', " fi", " shift", "done", + 'if [ "$T3_FAKE_CODEX_REQUIRE_IMAGE" = "1" ] && [ "$seen_image" != "1" ]; then', + ' printf "%s\\n" "missing --image input" >&2', + " exit 2", + "fi", "cat >/dev/null", 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then', ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2', @@ -48,6 +59,7 @@ function withFakeCodexEnv( output: string; exitCode?: number; stderr?: string; + requireImage?: boolean; }, effect: Effect.Effect, ) { @@ -60,6 +72,7 @@ function withFakeCodexEnv( const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64; const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE; const previousStderr = process.env.T3_FAKE_CODEX_STDERR; + const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; yield* Effect.sync(() => { process.env.PATH = `${binDir}:${previousPath ?? ""}`; @@ -76,6 +89,12 @@ function withFakeCodexEnv( } else { delete process.env.T3_FAKE_CODEX_STDERR; } + + if (input.requireImage) { + process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = "1"; + } else { + delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; + } }); return { @@ -83,6 +102,7 @@ function withFakeCodexEnv( previousOutput, previousExitCode, previousStderr, + previousRequireImage, }; }), () => effect, @@ -107,6 +127,12 @@ function withFakeCodexEnv( } else { process.env.T3_FAKE_CODEX_STDERR = previous.previousStderr; } + + if (previous.previousRequireImage === undefined) { + delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE; + } else { + process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage; + } }), ); } @@ -170,6 +196,76 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => { ), ); + it.effect("generates branch names and normalizes branch fragments", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + branch: " Feat/Session ", + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "Please update session handling.", + }); + + expect(generated.branch).toBe("feat/session"); + }), + ), + ); + + it.effect("passes image attachments through as codex image inputs", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + branch: "fix/ui-regression", + }), + requireImage: true, + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "Fix layout bug from screenshot.", + attachments: [ + { + type: "image", + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "data:image/png;base64,SGVsbG8=", + }, + ], + }); + + expect(generated.branch).toBe("fix/ui-regression"); + }), + ), + ); + + it.effect("returns null branch when codex returns the wrong object shape", () => + withFakeCodexEnv( + { + output: JSON.stringify({ + title: "This is not a branch payload", + }), + }, + Effect.gen(function* () { + const textGeneration = yield* TextGeneration; + + const generated = yield* textGeneration.generateBranchName({ + cwd: process.cwd(), + message: "Fix websocket reconnect flake", + }); + + expect(generated.branch).toBeNull(); + }), + ), + ); + it.effect("returns typed TextGenerationError when codex exits non-zero", () => withFakeCodexEnv( { diff --git a/apps/server/src/git/Layers/CodexTextGeneration.ts b/apps/server/src/git/Layers/CodexTextGeneration.ts index 3fbeb1aac76e907ed102fd5451f8daa43f07ed10..a24a3e90bde54f23fc6d95b4e3d72bedc01e9b02 100644 --- a/apps/server/src/git/Layers/CodexTextGeneration.ts +++ b/apps/server/src/git/Layers/CodexTextGeneration.ts @@ -4,7 +4,10 @@ import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect" import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { TextGenerationError } from "../Errors.ts"; +import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts"; import { + type BranchNameGenerationInput, + type BranchNameGenerationResult, type CommitMessageGenerationResult, type PrContentGenerationResult, type TextGenerationShape, @@ -35,6 +38,15 @@ const PR_OUTPUT_SCHEMA_JSON = { additionalProperties: false, } as const; +const BRANCH_NAME_OUTPUT_SCHEMA_JSON = { + type: "object", + properties: { + branch: { type: "string" }, + }, + required: ["branch"], + additionalProperties: false, +} as const; + function normalizeCodexError( operation: string, error: unknown, @@ -97,6 +109,18 @@ function parsePrOutput(raw: unknown): { title: string; body: string } { return { title, body }; } +function parseBranchNameOutput(raw: unknown): { branch: string } { + if (!raw || typeof raw !== "object") { + throw new Error("Codex returned a non-object branch payload."); + } + const record = raw as Record; + const branch = typeof record.branch === "string" ? record.branch.trim() : ""; + if (branch.length === 0) { + throw new Error("Codex returned an empty branch name payload."); + } + return { branch }; +} + function limitSection(value: string, maxChars: number): string { if (value.length <= maxChars) return value; const truncated = value.slice(0, maxChars); @@ -124,6 +148,24 @@ function sanitizePrTitle(raw: string): string { return "Update project changes"; } +function sanitizeBranchName(raw: string): string { + const normalized = raw + .trim() + .toLowerCase() + .replace(/['"`]/g, "") + .replace(/^[./\s_-]+|[./\s_-]+$/g, ""); + + const branchFragment = normalized + .replace(/[^a-z0-9/_-]+/g, "-") + .replace(/\/+/g, "/") + .replace(/-+/g, "-") + .replace(/^[./_-]+|[./_-]+$/g, "") + .slice(0, 64) + .replace(/[./_-]+$/g, ""); + + return branchFragment.length > 0 ? branchFragment : "update"; +} + const makeCodexTextGeneration = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -168,20 +210,86 @@ const makeCodexTextGeneration = Effect.gen(function* () { ); }; + const writeTempBinaryFile = ( + operation: string, + prefix: string, + bytes: Uint8Array, + extension: string, + ): Effect.Effect => { + const normalizedExtension = extension.startsWith(".") ? extension : `.${extension}`; + const filePath = path.join( + tempDir, + `t3code-${prefix}-${process.pid}-${randomUUID()}${normalizedExtension}`, + ); + return fileSystem.writeFile(filePath, bytes).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation, + detail: `Failed to write temp file at ${filePath}.`, + cause, + }), + ), + Effect.as(filePath), + ); + }; + const safeUnlink = (filePath: string): Effect.Effect => fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void)); + const materializeImageAttachments = ( + operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName", + attachments: BranchNameGenerationInput["attachments"], + ): Effect.Effect, TextGenerationError> => + Effect.gen(function* () { + if (!attachments || attachments.length === 0) { + return []; + } + + const imagePaths: string[] = []; + for (const [index, attachment] of attachments.entries()) { + if (attachment.type !== "image") { + continue; + } + + const parsed = parseBase64DataUrl(attachment.dataUrl); + if (!parsed || !parsed.mimeType.startsWith("image/")) { + continue; + } + + const bytes = Buffer.from(parsed.base64, "base64"); + if (bytes.byteLength === 0) { + continue; + } + + const extension = inferImageExtension({ + mimeType: parsed.mimeType, + fileName: attachment.name, + }); + const imagePath = yield* writeTempBinaryFile( + operation, + `codex-image-${index}`, + bytes, + extension, + ); + imagePaths.push(imagePath); + } + return imagePaths; + }); + const runCodexJson = ({ operation, cwd, prompt, outputSchemaJson, + imagePaths = [], parse, }: { - operation: "generateCommitMessage" | "generatePrContent"; + operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName"; cwd: string; prompt: string; outputSchemaJson: object; + imagePaths?: ReadonlyArray; parse: (raw: unknown) => T; }): Effect.Effect => Effect.gen(function* () { @@ -208,6 +316,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { schemaPath, "--output-last-message", outputPath, + ...imagePaths.flatMap((imagePath) => ["--image", imagePath]), "-", ], { @@ -254,9 +363,12 @@ const makeCodexTextGeneration = Effect.gen(function* () { } }); - const cleanup = Effect.all([safeUnlink(schemaPath), safeUnlink(outputPath)], { - concurrency: "unbounded", - }).pipe(Effect.asVoid); + const cleanup = Effect.all( + [schemaPath, outputPath, ...imagePaths].map((filePath) => safeUnlink(filePath)), + { + concurrency: "unbounded", + }, + ).pipe(Effect.asVoid); return yield* Effect.gen(function* () { yield* runCodexCommand.pipe( @@ -384,9 +496,60 @@ const makeCodexTextGeneration = Effect.gen(function* () { ); }; + const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => { + return Effect.gen(function* () { + const imagePaths = yield* materializeImageAttachments("generateBranchName", input.attachments); + const attachmentLines = (input.attachments ?? []).map( + (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`, + ); + + const prompt = [ + "You generate concise git branch names.", + "Return a JSON object with key: branch.", + "Rules:", + "- Branch should describe the requested work from the user message.", + "- Keep it short and specific (2-6 words).", + "- Use plain words only, no issue prefixes and no punctuation-heavy text.", + "- If images are attached, use them as primary context for visual/UI issues.", + "", + `Image attachments supplied to the model: ${imagePaths.length}`, + "", + "User message:", + limitSection(input.message, 8_000), + "", + "Attachment metadata:", + attachmentLines.length > 0 ? limitSection(attachmentLines.join("\n"), 4_000) : "- none", + ].join("\n"); + + const generated = yield* runCodexJson({ + operation: "generateBranchName", + cwd: input.cwd, + prompt, + outputSchemaJson: BRANCH_NAME_OUTPUT_SCHEMA_JSON, + imagePaths, + parse: (raw) => parseBranchNameOutput(raw), + }).pipe( + Effect.catch((error) => + Effect.gen(function* () { + yield* Effect.logWarning("branch-name generation failed, skipping rename", { + operation: "generateBranchName", + reason: error instanceof Error ? error.message : String(error), + }); + return { branch: null }; + }), + ), + ); + + return { + branch: generated.branch ? sanitizeBranchName(generated.branch) : null, + } satisfies BranchNameGenerationResult; + }); + }; + return { generateCommitMessage, generatePrContent, + generateBranchName, } satisfies TextGenerationShape; }); diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index f94bd7631fa82baccb85478ab5fdea9b156fb51c..19bd998a36a8f30d6c3d5cf726961e201b178e66 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -106,6 +106,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => listBranches: (input) => core.listBranches(input), createWorktree: (input) => core.createWorktree(input), removeWorktree: (input) => core.removeWorktree(input), + renameBranch: (input) => core.renameBranch(input), createBranch: (input) => core.createBranch(input), checkoutBranch: (input) => core.checkoutBranch(input), initRepo: (input) => core.initRepo(input), @@ -154,6 +155,13 @@ function removeGitWorktree(input: Parameters[0]) }); } +function renameGitBranch(input: Parameters[0]) { + return Effect.gen(function* () { + const core = yield* GitCore; + return yield* core.renameBranch(input); + }); +} + function pullGitBranch({ cwd }: { cwd: string }) { return Effect.gen(function* () { const core = yield* GitCore; @@ -819,6 +827,94 @@ it.layer(TestLayer)("git integration", (it) => { ); }); + // ── renameGitBranch ── + + describe("renameGitBranch", () => { + it.effect("renames the current branch", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" }); + yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" }); + + const renamed = yield* renameGitBranch({ + cwd: tmp, + oldBranch: "feature/old-name", + newBranch: "feature/new-name", + }); + + expect(renamed.branch).toBe("feature/new-name"); + + const branches = yield* listGitBranches({ cwd: tmp }); + expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false); + const current = branches.branches.find((branch) => branch.current); + expect(current?.name).toBe("feature/new-name"); + }), + ); + + it.effect("returns success without git invocation when old/new names match", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + const current = (yield* listGitBranches({ cwd: tmp })).branches.find((b) => b.current)!; + + const renamed = yield* renameGitBranch({ + cwd: tmp, + oldBranch: current.name, + newBranch: current.name, + }); + + expect(renamed.branch).toBe(current.name); + }), + ); + + it.effect("appends numeric suffix when target branch already exists", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" }); + yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); + yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); + + const renamed = yield* renameGitBranch({ + cwd: tmp, + oldBranch: "t3code/tmp-working", + newBranch: "t3code/feat/session", + }); + + expect(renamed.branch).toBe("t3code/feat/session-1"); + const branches = yield* listGitBranches({ cwd: tmp }); + expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe( + true, + ); + expect(branches.branches.some((branch) => branch.name === "t3code/feat/session-1")).toBe( + true, + ); + const current = branches.branches.find((branch) => branch.current); + expect(current?.name).toBe("t3code/feat/session-1"); + }), + ); + + it.effect("increments suffix until it finds an available branch name", () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + yield* initRepoWithCommit(tmp); + yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" }); + yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session-1" }); + yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); + yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" }); + + const renamed = yield* renameGitBranch({ + cwd: tmp, + oldBranch: "t3code/tmp-working", + newBranch: "t3code/feat/session", + }); + + expect(renamed.branch).toBe("t3code/feat/session-2"); + }), + ); + }); + // ── createGitWorktree + removeGitWorktree ── describe("createGitWorktree", () => { diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 2a2d358bd3811e38e660fa12d1e67c119b9e230c..8807918c0ff5686b647f6beedb18911eaa448c5a 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -250,6 +250,43 @@ const makeGitCore = Effect.gen(function* () { Effect.map((result) => result.stdout), ); + const branchExists = (cwd: string, branch: string): Effect.Effect => + executeGit( + "GitCore.branchExists", + cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], + { + allowNonZeroExit: true, + timeoutMs: 5_000, + }, + ).pipe(Effect.map((result) => result.code === 0)); + + const resolveAvailableBranchName = ( + cwd: string, + desiredBranch: string, + ): Effect.Effect => + Effect.gen(function* () { + const isDesiredTaken = yield* branchExists(cwd, desiredBranch); + if (!isDesiredTaken) { + return desiredBranch; + } + + for (let suffix = 1; suffix <= 100; suffix += 1) { + const candidate = `${desiredBranch}-${suffix}`; + const isCandidateTaken = yield* branchExists(cwd, candidate); + if (!isCandidateTaken) { + return candidate; + } + } + + return yield* createGitCommandError( + "GitCore.renameBranch", + cwd, + ["branch", "-m", desiredBranch], + `Could not find an available branch name for '${desiredBranch}'.`, + ); + }); + const resolveCurrentUpstream = ( cwd: string, ): Effect.Effect< @@ -1028,6 +1065,26 @@ const makeGitCore = Effect.gen(function* () { ); }); + const renameBranch: GitCoreShape["renameBranch"] = (input) => + Effect.gen(function* () { + if (input.oldBranch === input.newBranch) { + return { branch: input.newBranch }; + } + const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch); + + yield* executeGit( + "GitCore.renameBranch", + input.cwd, + ["branch", "-m", input.oldBranch, targetBranch], + { + timeoutMs: 10_000, + fallbackErrorMessage: "git branch rename failed", + }, + ); + + return { branch: targetBranch }; + }); + const createBranch: GitCoreShape["createBranch"] = (input) => executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], { timeoutMs: 10_000, @@ -1135,6 +1192,7 @@ const makeGitCore = Effect.gen(function* () { listBranches, createWorktree, removeWorktree, + renameBranch, createBranch, checkoutBranch, initRepo, diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index 0e870503d8cb73543bff10522eefed705cb37fa5..33a820ac3fb23ab636bc7cea9ab4d4442b88d72b 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -41,6 +41,10 @@ interface FakeGitTextGeneration { diffSummary: string; diffPatch: string; }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>; + generateBranchName: (input: { + cwd: string; + message: string; + }) => Effect.Effect<{ branch: string }, TextGenerationError>; } function makeTempDir( @@ -110,6 +114,10 @@ function createTextGeneration(overrides: Partial = {}): T title: "Add stacked git actions", body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run", }), + generateBranchName: () => + Effect.succeed({ + branch: "update-workflow", + }), ...overrides, }; @@ -136,6 +144,17 @@ function createTextGeneration(overrides: Partial = {}): T }), ), ), + generateBranchName: (input) => + implementation.generateBranchName(input).pipe( + Effect.mapError( + (cause) => + new TextGenerationError({ + operation: "generateBranchName", + detail: "fake text generation failed", + ...(cause !== undefined ? { cause } : {}), + }), + ), + ), }; } diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index c0c08af739e462b8b9d0eac2dd378ffaca30888a..0c5d540075ae6e803d71550a58b72969a8ce6109 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -17,6 +17,8 @@ import type { GitListBranchesInput, GitListBranchesResult, GitPullResult, + GitRenameBranchInput, + GitRenameBranchResult, GitRemoveWorktreeInput, GitStatusInput, GitStatusResult, @@ -124,6 +126,13 @@ export interface GitCoreShape { */ readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect; + /** + * Rename an existing local branch. + */ + readonly renameBranch: ( + input: GitRenameBranchInput, + ) => Effect.Effect; + /** * Create a local branch. */ diff --git a/apps/server/src/git/Services/TextGeneration.ts b/apps/server/src/git/Services/TextGeneration.ts index 6073553ac85bca7a4072c1a69e8b4864a8f6221b..155349f174ddc12577269022b67f971b565a3d5c 100644 --- a/apps/server/src/git/Services/TextGeneration.ts +++ b/apps/server/src/git/Services/TextGeneration.ts @@ -8,6 +8,7 @@ */ import { ServiceMap } from "effect"; import type { Effect } from "effect"; +import type { ChatAttachment } from "@t3tools/contracts"; import type { TextGenerationError } from "../Errors.ts"; @@ -37,11 +38,22 @@ export interface PrContentGenerationResult { body: string; } +export interface BranchNameGenerationInput { + cwd: string; + message: string; + attachments?: ReadonlyArray | undefined; +} + +export interface BranchNameGenerationResult { + branch: string | null; +} + export interface TextGenerationService { generateCommitMessage( input: CommitMessageGenerationInput, ): Promise; generatePrContent(input: PrContentGenerationInput): Promise; + generateBranchName(input: BranchNameGenerationInput): Promise; } /** @@ -61,6 +73,13 @@ export interface TextGenerationShape { readonly generatePrContent: ( input: PrContentGenerationInput, ) => Effect.Effect; + + /** + * Generate a concise branch name from a user message. + */ + readonly generateBranchName: ( + input: BranchNameGenerationInput, + ) => Effect.Effect; } /** diff --git a/apps/server/src/imageMime.ts b/apps/server/src/imageMime.ts new file mode 100644 index 0000000000000000000000000000000000000000..249deff0ca8efb37a8708c6fbe3039a9aa3a2ca4 --- /dev/null +++ b/apps/server/src/imageMime.ts @@ -0,0 +1,64 @@ +import Mime from "@effect/platform-node/Mime"; + +export const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { + "image/avif": ".avif", + "image/bmp": ".bmp", + "image/gif": ".gif", + "image/heic": ".heic", + "image/heif": ".heif", + "image/jpeg": ".jpg", + "image/jpg": ".jpg", + "image/png": ".png", + "image/svg+xml": ".svg", + "image/tiff": ".tiff", + "image/webp": ".webp", +}; + +export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ + ".avif", + ".bmp", + ".gif", + ".heic", + ".heif", + ".ico", + ".jpeg", + ".jpg", + ".png", + ".svg", + ".tiff", + ".webp", +]); + +export function parseBase64DataUrl( + dataUrl: string, +): { readonly mimeType: string; readonly base64: string } | null { + const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(dataUrl.trim()); + if (!match) return null; + + const mimeType = match[1]?.trim().toLowerCase(); + const base64 = match[2]?.replace(/\s+/g, ""); + if (!mimeType || !base64) return null; + + return { mimeType, base64 }; +} + +export function inferImageExtension(input: { mimeType: string; fileName?: string }): string { + const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[input.mimeType.toLowerCase()]; + if (fromMime) { + return fromMime; + } + + const fromMimeExtension = Mime.getExtension(input.mimeType); + if (fromMimeExtension && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension)) { + return fromMimeExtension; + } + + const fileName = input.fileName?.trim() ?? ""; + const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName); + const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : ""; + if (SAFE_IMAGE_FILE_EXTENSIONS.has(fileNameExtension)) { + return fileNameExtension; + } + + return ".bin"; +} diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index c518744d138d807bcec461883abae83924590357..49ba13d824650f4de4370bddad4e77db432bd59e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,4 +1,3 @@ -import Mime from "@effect/platform-node/Mime"; import { ApprovalRequestId, type ChatAttachment, @@ -40,9 +39,8 @@ import { } from "../Services/ProjectionPipeline.ts"; import { ATTACHMENTS_ROUTE_PREFIX, - IMAGE_EXTENSION_BY_MIME_TYPE, - SAFE_IMAGE_FILE_EXTENSIONS, } from "../../projectFaviconRoute.ts"; +import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts"; export const ORCHESTRATION_PROJECTOR_NAMES = { projects: "projection.projects", @@ -92,30 +90,6 @@ function toSafeThreadAttachmentSegment(threadId: string): string | null { return segment; } -function parseBase64DataUrl( - dataUrl: string, -): { readonly mimeType: string; readonly base64: string } | null { - const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(dataUrl.trim()); - if (!match) return null; - - const mimeType = match[1]?.trim().toLowerCase(); - const base64 = match[2]?.replace(/\s+/g, ""); - if (!mimeType || !base64) return null; - - return { mimeType, base64 }; -} - -function inferImageExtension(attachment: Extract): string { - const normalizedMimeType = attachment.mimeType.toLowerCase(); - if (normalizedMimeType.startsWith("image/")) { - const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[normalizedMimeType]; - if (fromMime) return fromMime; - } - const ext = Mime.getExtension(attachment.mimeType); - if (ext && SAFE_IMAGE_FILE_EXTENSIONS.has(ext)) return ext; - return ".bin"; -} - const materializeAttachmentsForProjection = Effect.fn(function* (input: { readonly threadId: string; readonly messageId: string; @@ -159,8 +133,8 @@ const materializeAttachmentsForProjection = Effect.fn(function* (input: { if (bytes.byteLength === 0) return attachment; const fileName = `${index}${inferImageExtension({ - ...attachment, mimeType: parsed.mimeType, + fileName: attachment.name, })}`; const uniqueFileName = `${messageSegment}-${fileName}`; const relativePath = `${threadSegment}/${uniqueFileName}`; diff --git a/apps/server/src/projectFaviconRoute.ts b/apps/server/src/projectFaviconRoute.ts index cddf2c15af1ae1e944750b9c798ea156a29fd989..65d9b50c3df622ff79e56b30004a70faea9124bf 100644 --- a/apps/server/src/projectFaviconRoute.ts +++ b/apps/server/src/projectFaviconRoute.ts @@ -1,33 +1,10 @@ import fs from "node:fs"; import http from "node:http"; import path from "node:path"; +export { IMAGE_EXTENSION_BY_MIME_TYPE, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts"; export const ATTACHMENTS_ROUTE_PREFIX = "/attachments"; -export const IMAGE_EXTENSION_BY_MIME_TYPE: Record = { - "image/png": ".png", - "image/jpeg": ".jpg", - "image/jpg": ".jpg", - "image/gif": ".gif", - "image/webp": ".webp", - "image/svg+xml": ".svg", - "image/bmp": ".bmp", - "image/tiff": ".tiff", - "image/heic": ".heic", -}; - -export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([ - ".jpg", - ".jpeg", - ".png", - ".gif", - ".webp", - ".bmp", - ".tiff", - ".svg", - ".ico", -]); - const FAVICON_MIME_TYPES: Record = { ".png": "image/png", ".jpg": "image/jpeg", diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index d821ac446e6c57e90a6cc792314d2a729c959a56..6d4b02a6c2d1e8d40b70c937270387d06a1ec3ac 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -46,6 +46,7 @@ import { Open, type OpenShape } from "./open"; import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts"; import type { GitCoreShape } from "./git/Services/GitCore.ts"; import { GitCore } from "./git/Services/GitCore.ts"; +import { TextGeneration, type TextGenerationShape } from "./git/Services/TextGeneration.ts"; import { GitCommandError, GitManagerError } from "./git/Errors.ts"; import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator"; @@ -369,7 +370,11 @@ describe("WebSocket Server", () => { providerLayer?: Layer.Layer; open?: OpenShape; gitManager?: GitManagerShape; - gitCore?: Pick; + gitCore?: Pick< + GitCoreShape, + "listBranches" | "initRepo" | "pullCurrentBranch" | "renameBranch" + >; + textGeneration?: Pick; terminalManager?: TerminalManagerShape; } = {}, ): Promise { @@ -402,6 +407,9 @@ describe("WebSocket Server", () => { options.gitCore ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape) : Layer.empty, + options.textGeneration + ? Layer.succeed(TextGeneration, options.textGeneration as unknown as TextGenerationShape) + : Layer.empty, options.terminalManager ? Layer.succeed(TerminalManager, options.terminalManager) : Layer.empty, @@ -1468,6 +1476,8 @@ describe("WebSocket Server", () => { }), ); const initRepo = vi.fn(() => Effect.void); + const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" })); + const generateBranchName = vi.fn(() => Effect.succeed({ branch: "new-name" as const })); const pullCurrentBranch = vi.fn(() => Effect.fail( new GitCommandError({ @@ -1484,8 +1494,12 @@ describe("WebSocket Server", () => { gitCore: { listBranches, initRepo, + renameBranch, pullCurrentBranch, }, + textGeneration: { + generateBranchName, + }, }); const addr = server.address(); const port = typeof addr === "object" && addr !== null ? addr.port : 0; @@ -1507,6 +1521,120 @@ describe("WebSocket Server", () => { expect(pullResponse.result).toBeUndefined(); expect(pullResponse.error?.message).toContain("No upstream configured"); expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path"); + + const generatedBranchResponse = await sendRequest(ws, WS_METHODS.gitGenerateBranchName, { + cwd: "/repo/path", + message: "Add retry logic for websocket reconnects", + attachments: [ + { + type: "image", + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "data:image/png;base64,SGVsbG8=", + }, + ], + }); + expect(generatedBranchResponse.error).toBeUndefined(); + expect(generatedBranchResponse.result).toEqual({ branch: "new-name" }); + expect(generateBranchName).toHaveBeenCalledWith({ + cwd: "/repo/path", + message: "Add retry logic for websocket reconnects", + attachments: [ + { + type: "image", + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "data:image/png;base64,SGVsbG8=", + }, + ], + }); + + const generateAndRenameResponse = await sendRequest( + ws, + WS_METHODS.gitGenerateAndRenameBranch, + { + cwd: "/repo/path", + oldBranch: "t3code/tmp-123", + message: "Add retry logic for websocket reconnects", + attachments: [ + { + type: "image", + name: "bug.png", + mimeType: "image/png", + sizeBytes: 5, + dataUrl: "data:image/png;base64,SGVsbG8=", + }, + ], + }, + ); + expect(generateAndRenameResponse.error).toBeUndefined(); + expect(generateAndRenameResponse.result).toEqual({ branch: "t3code/new-name" }); + expect(renameBranch).toHaveBeenCalledWith({ + cwd: "/repo/path", + oldBranch: "t3code/tmp-123", + newBranch: "t3code/new-name", + }); + + const renameResponse = await sendRequest(ws, WS_METHODS.gitRenameBranch, { + cwd: "/repo/path", + oldBranch: "t3code/tmp-456", + newBranch: "t3code/new-name", + }); + expect(renameResponse.error).toBeUndefined(); + expect(renameResponse.result).toEqual({ branch: "t3code/new-name" }); + expect(renameBranch).toHaveBeenCalledWith({ + cwd: "/repo/path", + oldBranch: "t3code/tmp-456", + newBranch: "t3code/new-name", + }); + }); + + it("skips rename in git.generateAndRenameBranch when branch generation returns null", async () => { + const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" })); + const generateBranchName = vi.fn(() => Effect.succeed({ branch: null })); + + server = await createTestServer({ + cwd: "/test", + gitCore: { + listBranches: () => + Effect.succeed({ + branches: [], + isRepo: false, + }), + initRepo: () => Effect.void, + renameBranch, + pullCurrentBranch: () => + Effect.fail( + new GitCommandError({ + operation: "GitCore.test.pullCurrentBranch", + detail: "No upstream configured", + command: "git pull", + cwd: "/repo/path", + }), + ), + }, + textGeneration: { + generateBranchName, + }, + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + const ws = await connectWs(port); + connections.push(ws); + await waitForMessage(ws); + + const response = await sendRequest(ws, WS_METHODS.gitGenerateAndRenameBranch, { + cwd: "/repo/path", + oldBranch: "t3code/tmp-123", + message: "Fix visual regression", + attachments: [], + }); + + expect(response.error).toBeUndefined(); + expect(response.result).toEqual({ branch: null }); + expect(renameBranch).not.toHaveBeenCalled(); }); it("supports git.status over websocket", async () => { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 5f4fb9d616a3d53c8ef6911c5a405167ec9a169b..838b4c50b0fe9e0a93a9fcd38f2ffd75a978b9cf 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -54,6 +54,7 @@ import { clamp } from "effect/Number"; import { Open } from "./open"; import { ServerConfig } from "./config"; import { GitCore } from "./git/Services/GitCore.ts"; +import { TextGeneration } from "./git/Services/TextGeneration.ts"; import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute"; /** @@ -135,6 +136,31 @@ function stripRequestTag(body: T) { return Struct.omit(body, ["_tag"]); } +const WORKTREE_BRANCH_PREFIX = "t3code"; + +function buildGeneratedWorktreeBranchName(raw: string): string { + const normalized = raw + .trim() + .toLowerCase() + .replace(/^refs\/heads\//, "") + .replace(/['"`]/g, ""); + + const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`) + ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length) + : normalized; + + const branchFragment = withoutPrefix + .replace(/[^a-z0-9/_-]+/g, "-") + .replace(/\/+/g, "/") + .replace(/-+/g, "-") + .replace(/^[./_-]+|[./_-]+$/g, "") + .slice(0, 64) + .replace(/[./_-]+$/g, ""); + + const safeFragment = branchFragment.length > 0 ? branchFragment : "update"; + return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`; +} + export type ServerCoreRuntimeServices = | OrchestrationEngineService | ProjectionSnapshotQuery @@ -146,6 +172,7 @@ export type ServerRuntimeServices = | ServerCoreRuntimeServices | GitManager | GitCore + | TextGeneration | TerminalManager | Keybindings | Open; @@ -184,6 +211,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const terminalManager = yield* TerminalManager; const keybindingsManager = yield* Keybindings; const git = yield* GitCore; + const textGeneration = yield* TextGeneration; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -572,6 +600,37 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* git.createWorktree(body); } + case WS_METHODS.gitGenerateBranchName: { + const body = stripRequestTag(request.body); + return yield* textGeneration.generateBranchName(body); + } + + case WS_METHODS.gitGenerateAndRenameBranch: { + const body = stripRequestTag(request.body); + const generated = yield* textGeneration.generateBranchName({ + cwd: body.cwd, + message: body.message, + ...(body.attachments !== undefined ? { attachments: body.attachments } : {}), + }); + if (!generated.branch) { + return { branch: null }; + } + const targetBranch = buildGeneratedWorktreeBranchName(generated.branch); + if (targetBranch === body.oldBranch) { + return { branch: body.oldBranch }; + } + return yield* git.renameBranch({ + cwd: body.cwd, + oldBranch: body.oldBranch, + newBranch: targetBranch, + }); + } + + case WS_METHODS.gitRenameBranch: { + const body = stripRequestTag(request.body); + return yield* git.renameBranch(body); + } + case WS_METHODS.gitRemoveWorktree: { const body = stripRequestTag(request.body); return yield* git.removeWorktree(body); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 597d067c8fc83262d3cd8ea64b24a3794894a709..56c31c299d358bd63ad87836f09b97f7f504aba1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -129,6 +129,7 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; +const WORKTREE_BRANCH_PREFIX = "t3code"; function readLastInvokedScriptByProjectFromStorage(): Record { const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY); @@ -232,6 +233,57 @@ function readFileAsDataUrl(file: File): Promise { }); } +function hashBranchSeed(value: string): string { + let hash = 2166136261; + for (let index = 0; index < value.length; index++) { + hash ^= value.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return (hash >>> 0).toString(16).padStart(8, "0"); +} + +function buildTemporaryWorktreeBranchName(message: string): string { + const seed = `${Date.now()}:${message.trim().toLowerCase()}:${crypto.randomUUID()}`; + return `${WORKTREE_BRANCH_PREFIX}/${hashBranchSeed(seed)}`; +} + +function imageReferenceForBranchNaming(image: ComposerImageAttachment): string { + const maybePath = (image.file as File & { path?: unknown }).path; + if (typeof maybePath === "string" && maybePath.trim().length > 0) { + return maybePath.trim(); + } + return image.name; +} + +function buildBranchNameGenerationMessage( + text: string, + images: ReadonlyArray, +): string { + const trimmedText = text.trim(); + const imageReferences = Array.from( + new Set( + images + .map((image) => imageReferenceForBranchNaming(image).trim()) + .filter((reference) => reference.length > 0), + ), + ); + const attachmentsSection = + imageReferences.length > 0 + ? `Attached images:\n${imageReferences.map((reference) => `- ${reference}`).join("\n")}` + : ""; + + if (trimmedText.length > 0 && attachmentsSection.length > 0) { + return `${trimmedText}\n\n${attachmentsSection}`; + } + if (trimmedText.length > 0) { + return trimmedText; + } + if (attachmentsSection.length > 0) { + return attachmentsSection; + } + return "New worktree change"; +} + const VscodeEntryIcon = memo(function VscodeEntryIcon(props: { pathValue: string; kind: "file" | "directory"; @@ -1478,6 +1530,26 @@ export default function ChatView({ threadId }: ChatViewProps) { const composerImagesSnapshot = [...composerImages]; const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); + const branchNameMessage = buildBranchNameGenerationMessage(trimmed, composerImagesSnapshot); + const turnAttachmentsPromise = Promise.all( + composerImagesSnapshot.map( + async ( + image, + ): Promise<{ + type: "image"; + name: string; + mimeType: string; + sizeBytes: number; + dataUrl: string; + }> => ({ + type: "image", + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: await readFileAsDataUrl(image.file), + }), + ), + ); const optimisticAttachments = composerImagesSnapshot.map((image) => ({ type: "image" as const, id: image.id, @@ -1506,11 +1578,12 @@ export default function ChatView({ threadId }: ChatViewProps) { setComposerHighlightedItemId(null); let attemptedTurnStart = false; + let pendingWorktreeBranchRename: Promise | null = null; try { // On first message: lock in branch + create worktree if needed. if (baseBranchForWorktree) { setSendPhase("preparing-worktree"); - const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`; + const newBranch = buildTemporaryWorktreeBranchName(trimmed); const result = await createWorktreeMutation.mutateAsync({ cwd: activeProject.cwd, branch: baseBranchForWorktree, @@ -1531,6 +1604,36 @@ export default function ChatView({ threadId }: ChatViewProps) { branch: result.worktree.branch, worktreePath: result.worktree.path, }); + pendingWorktreeBranchRename = (async () => { + const turnAttachments = await turnAttachmentsPromise; + const renamed = await api.git.generateAndRenameBranch({ + cwd: result.worktree.path, + oldBranch: result.worktree.branch, + message: branchNameMessage, + attachments: turnAttachments, + }); + if (!renamed.branch || renamed.branch === result.worktree.branch) { + return; + } + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: threadIdForSend, + branch: renamed.branch, + worktreePath: result.worktree.path, + }); + dispatch({ + type: "SET_THREAD_BRANCH", + threadId: threadIdForSend, + branch: renamed.branch, + worktreePath: result.worktree.path, + }); + })().catch((error) => { + console.warn("Failed to rename generated worktree branch", { + threadId: threadIdForSend, + error: error instanceof Error ? error.message : String(error), + }); + }); const setupScript = setupProjectScript(activeProject.scripts); if (setupScript) { await runProjectScript(setupScript, { @@ -1558,25 +1661,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } setSendPhase("sending-turn"); - const turnAttachments = await Promise.all( - composerImagesSnapshot.map( - async ( - image, - ): Promise<{ - type: "image"; - name: string; - mimeType: string; - sizeBytes: number; - dataUrl: string; - }> => ({ - type: "image", - name: image.name, - mimeType: image.mimeType, - sizeBytes: image.sizeBytes, - dataUrl: await readFileAsDataUrl(image.file), - }), - ), - ); + const turnAttachments = await turnAttachmentsPromise; attemptedTurnStart = true; const approvalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request"; const sandboxMode = @@ -1598,6 +1683,9 @@ export default function ChatView({ threadId }: ChatViewProps) { sandboxMode, createdAt: messageCreatedAt, }); + if (pendingWorktreeBranchRename) { + void pendingWorktreeBranchRename; + } } catch (err) { if ( !attemptedTurnStart && diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 2d997f2f8dd34a64c98eec5e9b4fbbb8fbccdfac..4b4d90227a5969468d0d441802c9e31314240e52 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -88,16 +88,16 @@ describe("getOrphanedWorktreePathForThread", () => { describe("formatWorktreePathForDisplay", () => { it("shows only the last path segment for unix-like paths", () => { const result = formatWorktreePathForDisplay( - "/Users/julius/.t3/worktrees/codething-mvp/codething-4e609bb8", + "/Users/julius/.t3/worktrees/t3code-mvp/t3code-4e609bb8", ); - expect(result).toBe("codething-4e609bb8"); + expect(result).toBe("t3code-4e609bb8"); }); it("normalizes windows separators before selecting the final segment", () => { const result = formatWorktreePathForDisplay( - "C:\\Users\\julius\\.t3\\worktrees\\codething-mvp\\codething-4e609bb8", + "C:\\Users\\julius\\.t3\\worktrees\\t3code-mvp\\t3code-4e609bb8", ); - expect(result).toBe("codething-4e609bb8"); + expect(result).toBe("t3code-4e609bb8"); }); it("uses the final segment even when outside ~/.t3/worktrees", () => { diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index f7ac04a9b07a6b4b10dc3214ea53b41b067a7e31..d7b08779258324091ef682d69e25cd8cb0fd12d8 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -164,6 +164,10 @@ export function createWsNativeApi(): NativeApi { runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input), listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input), createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input), + generateBranchName: (input) => transport.request(WS_METHODS.gitGenerateBranchName, input), + generateAndRenameBranch: (input) => + transport.request(WS_METHODS.gitGenerateAndRenameBranch, input), + renameBranch: (input) => transport.request(WS_METHODS.gitRenameBranch, input), removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input), createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input), checkout: (input) => transport.request(WS_METHODS.gitCheckout, input), diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index e0441366ef5aaae7ed893f26f7c5b14a4e97eda2..4f9df0ccc1f90ab51d6cddfe2852a63756c25f2e 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -1,5 +1,6 @@ import { Schema } from "effect"; import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas"; +import { ChatAttachment, PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "./orchestration"; const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString; @@ -69,6 +70,32 @@ export const GitCreateWorktreeInput = Schema.Struct({ }); export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type; +export const GitGenerateBranchNameInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + message: TrimmedNonEmptyStringSchema, + attachments: Schema.optional( + Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), + ), +}); +export type GitGenerateBranchNameInput = typeof GitGenerateBranchNameInput.Type; + +export const GitGenerateAndRenameBranchInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + oldBranch: TrimmedNonEmptyStringSchema, + message: TrimmedNonEmptyStringSchema, + attachments: Schema.optional( + Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)), + ), +}); +export type GitGenerateAndRenameBranchInput = typeof GitGenerateAndRenameBranchInput.Type; + +export const GitRenameBranchInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + oldBranch: TrimmedNonEmptyStringSchema, + newBranch: TrimmedNonEmptyStringSchema, +}); +export type GitRenameBranchInput = typeof GitRenameBranchInput.Type; + export const GitRemoveWorktreeInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, path: TrimmedNonEmptyStringSchema, @@ -137,6 +164,21 @@ export const GitCreateWorktreeResult = Schema.Struct({ }); export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type; +export const GitGenerateBranchNameResult = Schema.Struct({ + branch: Schema.NullOr(TrimmedNonEmptyStringSchema), +}); +export type GitGenerateBranchNameResult = typeof GitGenerateBranchNameResult.Type; + +export const GitGenerateAndRenameBranchResult = Schema.Struct({ + branch: Schema.NullOr(TrimmedNonEmptyStringSchema), +}); +export type GitGenerateAndRenameBranchResult = typeof GitGenerateAndRenameBranchResult.Type; + +export const GitRenameBranchResult = Schema.Struct({ + branch: TrimmedNonEmptyStringSchema, +}); +export type GitRenameBranchResult = typeof GitRenameBranchResult.Type; + export const GitRunStackedActionResult = Schema.Struct({ action: GitStackedAction, commit: Schema.Struct({ diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ce1c0305a54a918515205b55ba98a2fcfad11c0b..c68ba8e0bf1ea6cdc05d2d938261e57b6506c3a2 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,6 +1,12 @@ import type { GitCheckoutInput, GitCreateBranchInput, + GitGenerateAndRenameBranchInput, + GitGenerateAndRenameBranchResult, + GitGenerateBranchNameInput, + GitGenerateBranchNameResult, + GitRenameBranchInput, + GitRenameBranchResult, GitCreateWorktreeInput, GitCreateWorktreeResult, GitInitInput, @@ -74,6 +80,11 @@ export interface NativeApi { // Existing branch/worktree API listBranches: (input: GitListBranchesInput) => Promise; createWorktree: (input: GitCreateWorktreeInput) => Promise; + generateBranchName: (input: GitGenerateBranchNameInput) => Promise; + generateAndRenameBranch: ( + input: GitGenerateAndRenameBranchInput, + ) => Promise; + renameBranch: (input: GitRenameBranchInput) => Promise; removeWorktree: (input: GitRemoveWorktreeInput) => Promise; createBranch: (input: GitCreateBranchInput) => Promise; checkout: (input: GitCheckoutInput) => Promise; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 7f94615bdd47cc146697ac238a5b4590627b4c4f..42f3c3f0f9b13f83c1f978f549491122f1602e03 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -11,11 +11,14 @@ import { } from "./orchestration"; import { GitCheckoutInput, + GitGenerateAndRenameBranchInput, GitCreateBranchInput, + GitGenerateBranchNameInput, GitCreateWorktreeInput, GitInitInput, GitListBranchesInput, GitPullInput, + GitRenameBranchInput, GitRemoveWorktreeInput, GitRunStackedActionInput, GitStatusInput, @@ -50,6 +53,9 @@ export const WS_METHODS = { gitRunStackedAction: "git.runStackedAction", gitListBranches: "git.listBranches", gitCreateWorktree: "git.createWorktree", + gitGenerateBranchName: "git.generateBranchName", + gitGenerateAndRenameBranch: "git.generateAndRenameBranch", + gitRenameBranch: "git.renameBranch", gitRemoveWorktree: "git.removeWorktree", gitCreateBranch: "git.createBranch", gitCheckout: "git.checkout", @@ -111,6 +117,9 @@ export const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput), tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput), tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput), + tagRequestBody(WS_METHODS.gitGenerateBranchName, GitGenerateBranchNameInput), + tagRequestBody(WS_METHODS.gitGenerateAndRenameBranch, GitGenerateAndRenameBranchInput), + tagRequestBody(WS_METHODS.gitRenameBranch, GitRenameBranchInput), tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput), tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput), tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput), -- 2.54.0