authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-28 22:01:48-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-01 20:06:21-08:00
loga21095bc9ae48f9555f6344a97834528ec6891af
tree89192c499ace997f992160bd06bb7e3444006843
parentba03241df35b56a43c8f676a0b12c70b04ce004c

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

18 files changed, 897 insertions(+), 81 deletions(-)

apps/server/src/git/Layers/CodexTextGeneration.test.ts+96
...@@ -21,12 +21,23 @@ function makeFakeCodexBinary(dir: string) {...@@ -21,12 +21,23 @@ function makeFakeCodexBinary(dir: string) {
21 "#!/bin/sh",21 "#!/bin/sh",
22 'output_path=""',22 'output_path=""',
23 "while [ $# -gt 0 ]; do",23 "while [ $# -gt 0 ]; do",
24 ' if [ "$1" = "--image" ]; then',
25 " shift",
26 ' if [ -n "$1" ]; then',
27 ' seen_image="1"',
28 " fi",
29 " continue",
30 " fi",
24 ' if [ "$1" = "--output-last-message" ]; then',31 ' if [ "$1" = "--output-last-message" ]; then',
25 " shift",32 " shift",
26 ' output_path="$1"',33 ' output_path="$1"',
27 " fi",34 " fi",
28 " shift",35 " shift",
29 "done",36 "done",
37 'if [ "$T3_FAKE_CODEX_REQUIRE_IMAGE" = "1" ] && [ "$seen_image" != "1" ]; then',
38 ' printf "%s\\n" "missing --image input" >&2',
39 " exit 2",
40 "fi",
30 "cat >/dev/null",41 "cat >/dev/null",
31 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then',42 'if [ -n "$T3_FAKE_CODEX_STDERR" ]; then',
32 ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2',43 ' printf "%s\\n" "$T3_FAKE_CODEX_STDERR" >&2',
...@@ -48,6 +59,7 @@ function withFakeCodexEnv<A, E, R>(...@@ -48,6 +59,7 @@ function withFakeCodexEnv<A, E, R>(
48 output: string;59 output: string;
49 exitCode?: number;60 exitCode?: number;
50 stderr?: string;61 stderr?: string;
62 requireImage?: boolean;
51 },63 },
52 effect: Effect.Effect<A, E, R>,64 effect: Effect.Effect<A, E, R>,
53) {65) {
...@@ -60,6 +72,7 @@ function withFakeCodexEnv<A, E, R>(...@@ -60,6 +72,7 @@ function withFakeCodexEnv<A, E, R>(
60 const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64;72 const previousOutput = process.env.T3_FAKE_CODEX_OUTPUT_B64;
61 const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE;73 const previousExitCode = process.env.T3_FAKE_CODEX_EXIT_CODE;
62 const previousStderr = process.env.T3_FAKE_CODEX_STDERR;74 const previousStderr = process.env.T3_FAKE_CODEX_STDERR;
75 const previousRequireImage = process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
6376
64 yield* Effect.sync(() => {77 yield* Effect.sync(() => {
65 process.env.PATH = `${binDir}:${previousPath ?? ""}`;78 process.env.PATH = `${binDir}:${previousPath ?? ""}`;
...@@ -76,6 +89,12 @@ function withFakeCodexEnv<A, E, R>(...@@ -76,6 +89,12 @@ function withFakeCodexEnv<A, E, R>(
76 } else {89 } else {
77 delete process.env.T3_FAKE_CODEX_STDERR;90 delete process.env.T3_FAKE_CODEX_STDERR;
78 }91 }
92
93 if (input.requireImage) {
94 process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = "1";
95 } else {
96 delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
97 }
79 });98 });
8099
81 return {100 return {
...@@ -83,6 +102,7 @@ function withFakeCodexEnv<A, E, R>(...@@ -83,6 +102,7 @@ function withFakeCodexEnv<A, E, R>(
83 previousOutput,102 previousOutput,
84 previousExitCode,103 previousExitCode,
85 previousStderr,104 previousStderr,
105 previousRequireImage,
86 };106 };
87 }),107 }),
88 () => effect,108 () => effect,
...@@ -107,6 +127,12 @@ function withFakeCodexEnv<A, E, R>(...@@ -107,6 +127,12 @@ function withFakeCodexEnv<A, E, R>(
107 } else {127 } else {
108 process.env.T3_FAKE_CODEX_STDERR = previous.previousStderr;128 process.env.T3_FAKE_CODEX_STDERR = previous.previousStderr;
109 }129 }
130
131 if (previous.previousRequireImage === undefined) {
132 delete process.env.T3_FAKE_CODEX_REQUIRE_IMAGE;
133 } else {
134 process.env.T3_FAKE_CODEX_REQUIRE_IMAGE = previous.previousRequireImage;
135 }
110 }),136 }),
111 );137 );
112}138}
...@@ -170,6 +196,76 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {...@@ -170,6 +196,76 @@ it.layer(CodexTextGenerationTestLayer)("CodexTextGenerationLive", (it) => {
170 ),196 ),
171 );197 );
172198
199 it.effect("generates branch names and normalizes branch fragments", () =>
200 withFakeCodexEnv(
201 {
202 output: JSON.stringify({
203 branch: " Feat/Session ",
204 }),
205 },
206 Effect.gen(function* () {
207 const textGeneration = yield* TextGeneration;
208
209 const generated = yield* textGeneration.generateBranchName({
210 cwd: process.cwd(),
211 message: "Please update session handling.",
212 });
213
214 expect(generated.branch).toBe("feat/session");
215 }),
216 ),
217 );
218
219 it.effect("passes image attachments through as codex image inputs", () =>
220 withFakeCodexEnv(
221 {
222 output: JSON.stringify({
223 branch: "fix/ui-regression",
224 }),
225 requireImage: true,
226 },
227 Effect.gen(function* () {
228 const textGeneration = yield* TextGeneration;
229
230 const generated = yield* textGeneration.generateBranchName({
231 cwd: process.cwd(),
232 message: "Fix layout bug from screenshot.",
233 attachments: [
234 {
235 type: "image",
236 name: "bug.png",
237 mimeType: "image/png",
238 sizeBytes: 5,
239 dataUrl: "data:image/png;base64,SGVsbG8=",
240 },
241 ],
242 });
243
244 expect(generated.branch).toBe("fix/ui-regression");
245 }),
246 ),
247 );
248
249 it.effect("returns null branch when codex returns the wrong object shape", () =>
250 withFakeCodexEnv(
251 {
252 output: JSON.stringify({
253 title: "This is not a branch payload",
254 }),
255 },
256 Effect.gen(function* () {
257 const textGeneration = yield* TextGeneration;
258
259 const generated = yield* textGeneration.generateBranchName({
260 cwd: process.cwd(),
261 message: "Fix websocket reconnect flake",
262 });
263
264 expect(generated.branch).toBeNull();
265 }),
266 ),
267 );
268
173 it.effect("returns typed TextGenerationError when codex exits non-zero", () =>269 it.effect("returns typed TextGenerationError when codex exits non-zero", () =>
174 withFakeCodexEnv(270 withFakeCodexEnv(
175 {271 {
apps/server/src/git/Layers/CodexTextGeneration.ts+167-4
...@@ -4,7 +4,10 @@ import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect"...@@ -4,7 +4,10 @@ import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect"
4import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";4import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
55
6import { TextGenerationError } from "../Errors.ts";6import { TextGenerationError } from "../Errors.ts";
7import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";
7import {8import {
9 type BranchNameGenerationInput,
10 type BranchNameGenerationResult,
8 type CommitMessageGenerationResult,11 type CommitMessageGenerationResult,
9 type PrContentGenerationResult,12 type PrContentGenerationResult,
10 type TextGenerationShape,13 type TextGenerationShape,
...@@ -35,6 +38,15 @@ const PR_OUTPUT_SCHEMA_JSON = {...@@ -35,6 +38,15 @@ const PR_OUTPUT_SCHEMA_JSON = {
35 additionalProperties: false,38 additionalProperties: false,
36} as const;39} as const;
3740
41const BRANCH_NAME_OUTPUT_SCHEMA_JSON = {
42 type: "object",
43 properties: {
44 branch: { type: "string" },
45 },
46 required: ["branch"],
47 additionalProperties: false,
48} as const;
49
38function normalizeCodexError(50function normalizeCodexError(
39 operation: string,51 operation: string,
40 error: unknown,52 error: unknown,
...@@ -97,6 +109,18 @@ function parsePrOutput(raw: unknown): { title: string; body: string } {...@@ -97,6 +109,18 @@ function parsePrOutput(raw: unknown): { title: string; body: string } {
97 return { title, body };109 return { title, body };
98}110}
99111
112function parseBranchNameOutput(raw: unknown): { branch: string } {
113 if (!raw || typeof raw !== "object") {
114 throw new Error("Codex returned a non-object branch payload.");
115 }
116 const record = raw as Record<string, unknown>;
117 const branch = typeof record.branch === "string" ? record.branch.trim() : "";
118 if (branch.length === 0) {
119 throw new Error("Codex returned an empty branch name payload.");
120 }
121 return { branch };
122}
123
100function limitSection(value: string, maxChars: number): string {124function limitSection(value: string, maxChars: number): string {
101 if (value.length <= maxChars) return value;125 if (value.length <= maxChars) return value;
102 const truncated = value.slice(0, maxChars);126 const truncated = value.slice(0, maxChars);
...@@ -124,6 +148,24 @@ function sanitizePrTitle(raw: string): string {...@@ -124,6 +148,24 @@ function sanitizePrTitle(raw: string): string {
124 return "Update project changes";148 return "Update project changes";
125}149}
126150
151function sanitizeBranchName(raw: string): string {
152 const normalized = raw
153 .trim()
154 .toLowerCase()
155 .replace(/['"`]/g, "")
156 .replace(/^[./\s_-]+|[./\s_-]+$/g, "");
157
158 const branchFragment = normalized
159 .replace(/[^a-z0-9/_-]+/g, "-")
160 .replace(/\/+/g, "/")
161 .replace(/-+/g, "-")
162 .replace(/^[./_-]+|[./_-]+$/g, "")
163 .slice(0, 64)
164 .replace(/[./_-]+$/g, "");
165
166 return branchFragment.length > 0 ? branchFragment : "update";
167}
168
127const makeCodexTextGeneration = Effect.gen(function* () {169const makeCodexTextGeneration = Effect.gen(function* () {
128 const fileSystem = yield* FileSystem.FileSystem;170 const fileSystem = yield* FileSystem.FileSystem;
129 const path = yield* Path.Path;171 const path = yield* Path.Path;
...@@ -168,20 +210,86 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -168,20 +210,86 @@ const makeCodexTextGeneration = Effect.gen(function* () {
168 );210 );
169 };211 };
170212
213 const writeTempBinaryFile = (
214 operation: string,
215 prefix: string,
216 bytes: Uint8Array,
217 extension: string,
218 ): Effect.Effect<string, TextGenerationError> => {
219 const normalizedExtension = extension.startsWith(".") ? extension : `.${extension}`;
220 const filePath = path.join(
221 tempDir,
222 `t3code-${prefix}-${process.pid}-${randomUUID()}${normalizedExtension}`,
223 );
224 return fileSystem.writeFile(filePath, bytes).pipe(
225 Effect.mapError(
226 (cause) =>
227 new TextGenerationError({
228 operation,
229 detail: `Failed to write temp file at ${filePath}.`,
230 cause,
231 }),
232 ),
233 Effect.as(filePath),
234 );
235 };
236
171 const safeUnlink = (filePath: string): Effect.Effect<void, never> =>237 const safeUnlink = (filePath: string): Effect.Effect<void, never> =>
172 fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void));238 fileSystem.remove(filePath).pipe(Effect.catch(() => Effect.void));
173239
240 const materializeImageAttachments = (
241 operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName",
242 attachments: BranchNameGenerationInput["attachments"],
243 ): Effect.Effect<ReadonlyArray<string>, TextGenerationError> =>
244 Effect.gen(function* () {
245 if (!attachments || attachments.length === 0) {
246 return [];
247 }
248
249 const imagePaths: string[] = [];
250 for (const [index, attachment] of attachments.entries()) {
251 if (attachment.type !== "image") {
252 continue;
253 }
254
255 const parsed = parseBase64DataUrl(attachment.dataUrl);
256 if (!parsed || !parsed.mimeType.startsWith("image/")) {
257 continue;
258 }
259
260 const bytes = Buffer.from(parsed.base64, "base64");
261 if (bytes.byteLength === 0) {
262 continue;
263 }
264
265 const extension = inferImageExtension({
266 mimeType: parsed.mimeType,
267 fileName: attachment.name,
268 });
269 const imagePath = yield* writeTempBinaryFile(
270 operation,
271 `codex-image-${index}`,
272 bytes,
273 extension,
274 );
275 imagePaths.push(imagePath);
276 }
277 return imagePaths;
278 });
279
174 const runCodexJson = <T>({280 const runCodexJson = <T>({
175 operation,281 operation,
176 cwd,282 cwd,
177 prompt,283 prompt,
178 outputSchemaJson,284 outputSchemaJson,
285 imagePaths = [],
179 parse,286 parse,
180 }: {287 }: {
181 operation: "generateCommitMessage" | "generatePrContent";288 operation: "generateCommitMessage" | "generatePrContent" | "generateBranchName";
182 cwd: string;289 cwd: string;
183 prompt: string;290 prompt: string;
184 outputSchemaJson: object;291 outputSchemaJson: object;
292 imagePaths?: ReadonlyArray<string>;
185 parse: (raw: unknown) => T;293 parse: (raw: unknown) => T;
186 }): Effect.Effect<T, TextGenerationError> =>294 }): Effect.Effect<T, TextGenerationError> =>
187 Effect.gen(function* () {295 Effect.gen(function* () {
...@@ -208,6 +316,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -208,6 +316,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
208 schemaPath,316 schemaPath,
209 "--output-last-message",317 "--output-last-message",
210 outputPath,318 outputPath,
319 ...imagePaths.flatMap((imagePath) => ["--image", imagePath]),
211 "-",320 "-",
212 ],321 ],
213 {322 {
...@@ -254,9 +363,12 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -254,9 +363,12 @@ const makeCodexTextGeneration = Effect.gen(function* () {
254 }363 }
255 });364 });
256365
257 const cleanup = Effect.all([safeUnlink(schemaPath), safeUnlink(outputPath)], {366 const cleanup = Effect.all(
258 concurrency: "unbounded",367 [schemaPath, outputPath, ...imagePaths].map((filePath) => safeUnlink(filePath)),
259 }).pipe(Effect.asVoid);368 {
369 concurrency: "unbounded",
370 },
371 ).pipe(Effect.asVoid);
260372
261 return yield* Effect.gen(function* () {373 return yield* Effect.gen(function* () {
262 yield* runCodexCommand.pipe(374 yield* runCodexCommand.pipe(
...@@ -384,9 +496,60 @@ const makeCodexTextGeneration = Effect.gen(function* () {...@@ -384,9 +496,60 @@ const makeCodexTextGeneration = Effect.gen(function* () {
384 );496 );
385 };497 };
386498
499 const generateBranchName: TextGenerationShape["generateBranchName"] = (input) => {
500 return Effect.gen(function* () {
501 const imagePaths = yield* materializeImageAttachments("generateBranchName", input.attachments);
502 const attachmentLines = (input.attachments ?? []).map(
503 (attachment) => `- ${attachment.name} (${attachment.mimeType}, ${attachment.sizeBytes} bytes)`,
504 );
505
506 const prompt = [
507 "You generate concise git branch names.",
508 "Return a JSON object with key: branch.",
509 "Rules:",
510 "- Branch should describe the requested work from the user message.",
511 "- Keep it short and specific (2-6 words).",
512 "- Use plain words only, no issue prefixes and no punctuation-heavy text.",
513 "- If images are attached, use them as primary context for visual/UI issues.",
514 "",
515 `Image attachments supplied to the model: ${imagePaths.length}`,
516 "",
517 "User message:",
518 limitSection(input.message, 8_000),
519 "",
520 "Attachment metadata:",
521 attachmentLines.length > 0 ? limitSection(attachmentLines.join("\n"), 4_000) : "- none",
522 ].join("\n");
523
524 const generated = yield* runCodexJson({
525 operation: "generateBranchName",
526 cwd: input.cwd,
527 prompt,
528 outputSchemaJson: BRANCH_NAME_OUTPUT_SCHEMA_JSON,
529 imagePaths,
530 parse: (raw) => parseBranchNameOutput(raw),
531 }).pipe(
532 Effect.catch((error) =>
533 Effect.gen(function* () {
534 yield* Effect.logWarning("branch-name generation failed, skipping rename", {
535 operation: "generateBranchName",
536 reason: error instanceof Error ? error.message : String(error),
537 });
538 return { branch: null };
539 }),
540 ),
541 );
542
543 return {
544 branch: generated.branch ? sanitizeBranchName(generated.branch) : null,
545 } satisfies BranchNameGenerationResult;
546 });
547 };
548
387 return {549 return {
388 generateCommitMessage,550 generateCommitMessage,
389 generatePrContent,551 generatePrContent,
552 generateBranchName,
390 } satisfies TextGenerationShape;553 } satisfies TextGenerationShape;
391});554});
392555
apps/server/src/git/Layers/GitCore.test.ts+96
...@@ -106,6 +106,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) =>...@@ -106,6 +106,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) =>
106 listBranches: (input) => core.listBranches(input),106 listBranches: (input) => core.listBranches(input),
107 createWorktree: (input) => core.createWorktree(input),107 createWorktree: (input) => core.createWorktree(input),
108 removeWorktree: (input) => core.removeWorktree(input),108 removeWorktree: (input) => core.removeWorktree(input),
109 renameBranch: (input) => core.renameBranch(input),
109 createBranch: (input) => core.createBranch(input),110 createBranch: (input) => core.createBranch(input),
110 checkoutBranch: (input) => core.checkoutBranch(input),111 checkoutBranch: (input) => core.checkoutBranch(input),
111 initRepo: (input) => core.initRepo(input),112 initRepo: (input) => core.initRepo(input),
...@@ -154,6 +155,13 @@ function removeGitWorktree(input: Parameters<GitCoreShape["removeWorktree"]>[0])...@@ -154,6 +155,13 @@ function removeGitWorktree(input: Parameters<GitCoreShape["removeWorktree"]>[0])
154 });155 });
155}156}
156157
158function renameGitBranch(input: Parameters<GitCoreShape["renameBranch"]>[0]) {
159 return Effect.gen(function* () {
160 const core = yield* GitCore;
161 return yield* core.renameBranch(input);
162 });
163}
164
157function pullGitBranch({ cwd }: { cwd: string }) {165function pullGitBranch({ cwd }: { cwd: string }) {
158 return Effect.gen(function* () {166 return Effect.gen(function* () {
159 const core = yield* GitCore;167 const core = yield* GitCore;
...@@ -819,6 +827,94 @@ it.layer(TestLayer)("git integration", (it) => {...@@ -819,6 +827,94 @@ it.layer(TestLayer)("git integration", (it) => {
819 );827 );
820 });828 });
821829
830 // ── renameGitBranch ──
831
832 describe("renameGitBranch", () => {
833 it.effect("renames the current branch", () =>
834 Effect.gen(function* () {
835 const tmp = yield* makeTmpDir();
836 yield* initRepoWithCommit(tmp);
837 yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" });
838 yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" });
839
840 const renamed = yield* renameGitBranch({
841 cwd: tmp,
842 oldBranch: "feature/old-name",
843 newBranch: "feature/new-name",
844 });
845
846 expect(renamed.branch).toBe("feature/new-name");
847
848 const branches = yield* listGitBranches({ cwd: tmp });
849 expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false);
850 const current = branches.branches.find((branch) => branch.current);
851 expect(current?.name).toBe("feature/new-name");
852 }),
853 );
854
855 it.effect("returns success without git invocation when old/new names match", () =>
856 Effect.gen(function* () {
857 const tmp = yield* makeTmpDir();
858 yield* initRepoWithCommit(tmp);
859 const current = (yield* listGitBranches({ cwd: tmp })).branches.find((b) => b.current)!;
860
861 const renamed = yield* renameGitBranch({
862 cwd: tmp,
863 oldBranch: current.name,
864 newBranch: current.name,
865 });
866
867 expect(renamed.branch).toBe(current.name);
868 }),
869 );
870
871 it.effect("appends numeric suffix when target branch already exists", () =>
872 Effect.gen(function* () {
873 const tmp = yield* makeTmpDir();
874 yield* initRepoWithCommit(tmp);
875 yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" });
876 yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
877 yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
878
879 const renamed = yield* renameGitBranch({
880 cwd: tmp,
881 oldBranch: "t3code/tmp-working",
882 newBranch: "t3code/feat/session",
883 });
884
885 expect(renamed.branch).toBe("t3code/feat/session-1");
886 const branches = yield* listGitBranches({ cwd: tmp });
887 expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe(
888 true,
889 );
890 expect(branches.branches.some((branch) => branch.name === "t3code/feat/session-1")).toBe(
891 true,
892 );
893 const current = branches.branches.find((branch) => branch.current);
894 expect(current?.name).toBe("t3code/feat/session-1");
895 }),
896 );
897
898 it.effect("increments suffix until it finds an available branch name", () =>
899 Effect.gen(function* () {
900 const tmp = yield* makeTmpDir();
901 yield* initRepoWithCommit(tmp);
902 yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" });
903 yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session-1" });
904 yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
905 yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
906
907 const renamed = yield* renameGitBranch({
908 cwd: tmp,
909 oldBranch: "t3code/tmp-working",
910 newBranch: "t3code/feat/session",
911 });
912
913 expect(renamed.branch).toBe("t3code/feat/session-2");
914 }),
915 );
916 });
917
822 // ── createGitWorktree + removeGitWorktree ──918 // ── createGitWorktree + removeGitWorktree ──
823919
824 describe("createGitWorktree", () => {920 describe("createGitWorktree", () => {
apps/server/src/git/Layers/GitCore.ts+58
...@@ -250,6 +250,43 @@ const makeGitCore = Effect.gen(function* () {...@@ -250,6 +250,43 @@ const makeGitCore = Effect.gen(function* () {
250 Effect.map((result) => result.stdout),250 Effect.map((result) => result.stdout),
251 );251 );
252252
253 const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> =>
254 executeGit(
255 "GitCore.branchExists",
256 cwd,
257 ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
258 {
259 allowNonZeroExit: true,
260 timeoutMs: 5_000,
261 },
262 ).pipe(Effect.map((result) => result.code === 0));
263
264 const resolveAvailableBranchName = (
265 cwd: string,
266 desiredBranch: string,
267 ): Effect.Effect<string, GitCommandError> =>
268 Effect.gen(function* () {
269 const isDesiredTaken = yield* branchExists(cwd, desiredBranch);
270 if (!isDesiredTaken) {
271 return desiredBranch;
272 }
273
274 for (let suffix = 1; suffix <= 100; suffix += 1) {
275 const candidate = `${desiredBranch}-${suffix}`;
276 const isCandidateTaken = yield* branchExists(cwd, candidate);
277 if (!isCandidateTaken) {
278 return candidate;
279 }
280 }
281
282 return yield* createGitCommandError(
283 "GitCore.renameBranch",
284 cwd,
285 ["branch", "-m", desiredBranch],
286 `Could not find an available branch name for '${desiredBranch}'.`,
287 );
288 });
289
253 const resolveCurrentUpstream = (290 const resolveCurrentUpstream = (
254 cwd: string,291 cwd: string,
255 ): Effect.Effect<292 ): Effect.Effect<
...@@ -1028,6 +1065,26 @@ const makeGitCore = Effect.gen(function* () {...@@ -1028,6 +1065,26 @@ const makeGitCore = Effect.gen(function* () {
1028 );1065 );
1029 });1066 });
10301067
1068 const renameBranch: GitCoreShape["renameBranch"] = (input) =>
1069 Effect.gen(function* () {
1070 if (input.oldBranch === input.newBranch) {
1071 return { branch: input.newBranch };
1072 }
1073 const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch);
1074
1075 yield* executeGit(
1076 "GitCore.renameBranch",
1077 input.cwd,
1078 ["branch", "-m", input.oldBranch, targetBranch],
1079 {
1080 timeoutMs: 10_000,
1081 fallbackErrorMessage: "git branch rename failed",
1082 },
1083 );
1084
1085 return { branch: targetBranch };
1086 });
1087
1031 const createBranch: GitCoreShape["createBranch"] = (input) =>1088 const createBranch: GitCoreShape["createBranch"] = (input) =>
1032 executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {1089 executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {
1033 timeoutMs: 10_000,1090 timeoutMs: 10_000,
...@@ -1135,6 +1192,7 @@ const makeGitCore = Effect.gen(function* () {...@@ -1135,6 +1192,7 @@ const makeGitCore = Effect.gen(function* () {
1135 listBranches,1192 listBranches,
1136 createWorktree,1193 createWorktree,
1137 removeWorktree,1194 removeWorktree,
1195 renameBranch,
1138 createBranch,1196 createBranch,
1139 checkoutBranch,1197 checkoutBranch,
1140 initRepo,1198 initRepo,
apps/server/src/git/Layers/GitManager.test.ts+19
...@@ -41,6 +41,10 @@ interface FakeGitTextGeneration {...@@ -41,6 +41,10 @@ interface FakeGitTextGeneration {
41 diffSummary: string;41 diffSummary: string;
42 diffPatch: string;42 diffPatch: string;
43 }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>;43 }) => Effect.Effect<{ title: string; body: string }, TextGenerationError>;
44 generateBranchName: (input: {
45 cwd: string;
46 message: string;
47 }) => Effect.Effect<{ branch: string }, TextGenerationError>;
44}48}
4549
46function makeTempDir(50function makeTempDir(
...@@ -110,6 +114,10 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T...@@ -110,6 +114,10 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
110 title: "Add stacked git actions",114 title: "Add stacked git actions",
111 body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run",115 body: "## Summary\n- Add stacked git workflow\n\n## Testing\n- Not run",
112 }),116 }),
117 generateBranchName: () =>
118 Effect.succeed({
119 branch: "update-workflow",
120 }),
113 ...overrides,121 ...overrides,
114 };122 };
115123
...@@ -136,6 +144,17 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T...@@ -136,6 +144,17 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T
136 }),144 }),
137 ),145 ),
138 ),146 ),
147 generateBranchName: (input) =>
148 implementation.generateBranchName(input).pipe(
149 Effect.mapError(
150 (cause) =>
151 new TextGenerationError({
152 operation: "generateBranchName",
153 detail: "fake text generation failed",
154 ...(cause !== undefined ? { cause } : {}),
155 }),
156 ),
157 ),
139 };158 };
140}159}
141160
apps/server/src/git/Services/GitCore.ts+9
...@@ -17,6 +17,8 @@ import type {...@@ -17,6 +17,8 @@ import type {
17 GitListBranchesInput,17 GitListBranchesInput,
18 GitListBranchesResult,18 GitListBranchesResult,
19 GitPullResult,19 GitPullResult,
20 GitRenameBranchInput,
21 GitRenameBranchResult,
20 GitRemoveWorktreeInput,22 GitRemoveWorktreeInput,
21 GitStatusInput,23 GitStatusInput,
22 GitStatusResult,24 GitStatusResult,
...@@ -124,6 +126,13 @@ export interface GitCoreShape {...@@ -124,6 +126,13 @@ export interface GitCoreShape {
124 */126 */
125 readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>;127 readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>;
126128
129 /**
130 * Rename an existing local branch.
131 */
132 readonly renameBranch: (
133 input: GitRenameBranchInput,
134 ) => Effect.Effect<GitRenameBranchResult, GitCommandError>;
135
127 /**136 /**
128 * Create a local branch.137 * Create a local branch.
129 */138 */
apps/server/src/git/Services/TextGeneration.ts+19
...@@ -8,6 +8,7 @@...@@ -8,6 +8,7 @@
8 */8 */
9import { ServiceMap } from "effect";9import { ServiceMap } from "effect";
10import type { Effect } from "effect";10import type { Effect } from "effect";
11import type { ChatAttachment } from "@t3tools/contracts";
1112
12import type { TextGenerationError } from "../Errors.ts";13import type { TextGenerationError } from "../Errors.ts";
1314
...@@ -37,11 +38,22 @@ export interface PrContentGenerationResult {...@@ -37,11 +38,22 @@ export interface PrContentGenerationResult {
37 body: string;38 body: string;
38}39}
3940
41export interface BranchNameGenerationInput {
42 cwd: string;
43 message: string;
44 attachments?: ReadonlyArray<ChatAttachment> | undefined;
45}
46
47export interface BranchNameGenerationResult {
48 branch: string | null;
49}
50
40export interface TextGenerationService {51export interface TextGenerationService {
41 generateCommitMessage(52 generateCommitMessage(
42 input: CommitMessageGenerationInput,53 input: CommitMessageGenerationInput,
43 ): Promise<CommitMessageGenerationResult>;54 ): Promise<CommitMessageGenerationResult>;
44 generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>;55 generatePrContent(input: PrContentGenerationInput): Promise<PrContentGenerationResult>;
56 generateBranchName(input: BranchNameGenerationInput): Promise<BranchNameGenerationResult>;
45}57}
4658
47/**59/**
...@@ -61,6 +73,13 @@ export interface TextGenerationShape {...@@ -61,6 +73,13 @@ export interface TextGenerationShape {
61 readonly generatePrContent: (73 readonly generatePrContent: (
62 input: PrContentGenerationInput,74 input: PrContentGenerationInput,
63 ) => Effect.Effect<PrContentGenerationResult, TextGenerationError>;75 ) => Effect.Effect<PrContentGenerationResult, TextGenerationError>;
76
77 /**
78 * Generate a concise branch name from a user message.
79 */
80 readonly generateBranchName: (
81 input: BranchNameGenerationInput,
82 ) => Effect.Effect<BranchNameGenerationResult, TextGenerationError>;
64}83}
6584
66/**85/**
apps/server/src/imageMime.ts created+64
...@@ -0,0 +1,64 @@
1import Mime from "@effect/platform-node/Mime";
2
3export const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = {
4 "image/avif": ".avif",
5 "image/bmp": ".bmp",
6 "image/gif": ".gif",
7 "image/heic": ".heic",
8 "image/heif": ".heif",
9 "image/jpeg": ".jpg",
10 "image/jpg": ".jpg",
11 "image/png": ".png",
12 "image/svg+xml": ".svg",
13 "image/tiff": ".tiff",
14 "image/webp": ".webp",
15};
16
17export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([
18 ".avif",
19 ".bmp",
20 ".gif",
21 ".heic",
22 ".heif",
23 ".ico",
24 ".jpeg",
25 ".jpg",
26 ".png",
27 ".svg",
28 ".tiff",
29 ".webp",
30]);
31
32export function parseBase64DataUrl(
33 dataUrl: string,
34): { readonly mimeType: string; readonly base64: string } | null {
35 const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(dataUrl.trim());
36 if (!match) return null;
37
38 const mimeType = match[1]?.trim().toLowerCase();
39 const base64 = match[2]?.replace(/\s+/g, "");
40 if (!mimeType || !base64) return null;
41
42 return { mimeType, base64 };
43}
44
45export function inferImageExtension(input: { mimeType: string; fileName?: string }): string {
46 const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[input.mimeType.toLowerCase()];
47 if (fromMime) {
48 return fromMime;
49 }
50
51 const fromMimeExtension = Mime.getExtension(input.mimeType);
52 if (fromMimeExtension && SAFE_IMAGE_FILE_EXTENSIONS.has(fromMimeExtension)) {
53 return fromMimeExtension;
54 }
55
56 const fileName = input.fileName?.trim() ?? "";
57 const extensionMatch = /\.([a-z0-9]{1,8})$/i.exec(fileName);
58 const fileNameExtension = extensionMatch ? `.${extensionMatch[1]!.toLowerCase()}` : "";
59 if (SAFE_IMAGE_FILE_EXTENSIONS.has(fileNameExtension)) {
60 return fileNameExtension;
61 }
62
63 return ".bin";
64}
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+2-28
...@@ -1,4 +1,3 @@...@@ -1,4 +1,3 @@
1import Mime from "@effect/platform-node/Mime";
2import {1import {
3 ApprovalRequestId,2 ApprovalRequestId,
4 type ChatAttachment,3 type ChatAttachment,
...@@ -40,9 +39,8 @@ import {...@@ -40,9 +39,8 @@ import {
40} from "../Services/ProjectionPipeline.ts";39} from "../Services/ProjectionPipeline.ts";
41import {40import {
42 ATTACHMENTS_ROUTE_PREFIX,41 ATTACHMENTS_ROUTE_PREFIX,
43 IMAGE_EXTENSION_BY_MIME_TYPE,
44 SAFE_IMAGE_FILE_EXTENSIONS,
45} from "../../projectFaviconRoute.ts";42} from "../../projectFaviconRoute.ts";
43import { inferImageExtension, parseBase64DataUrl } from "../../imageMime.ts";
4644
47export const ORCHESTRATION_PROJECTOR_NAMES = {45export const ORCHESTRATION_PROJECTOR_NAMES = {
48 projects: "projection.projects",46 projects: "projection.projects",
...@@ -92,30 +90,6 @@ function toSafeThreadAttachmentSegment(threadId: string): string | null {...@@ -92,30 +90,6 @@ function toSafeThreadAttachmentSegment(threadId: string): string | null {
92 return segment;90 return segment;
93}91}
9492
95function parseBase64DataUrl(
96 dataUrl: string,
97): { readonly mimeType: string; readonly base64: string } | null {
98 const match = /^data:([^;,]+);base64,([a-z0-9+/=\r\n]+)$/i.exec(dataUrl.trim());
99 if (!match) return null;
100
101 const mimeType = match[1]?.trim().toLowerCase();
102 const base64 = match[2]?.replace(/\s+/g, "");
103 if (!mimeType || !base64) return null;
104
105 return { mimeType, base64 };
106}
107
108function inferImageExtension(attachment: Extract<ChatAttachment, { type: "image" }>): string {
109 const normalizedMimeType = attachment.mimeType.toLowerCase();
110 if (normalizedMimeType.startsWith("image/")) {
111 const fromMime = IMAGE_EXTENSION_BY_MIME_TYPE[normalizedMimeType];
112 if (fromMime) return fromMime;
113 }
114 const ext = Mime.getExtension(attachment.mimeType);
115 if (ext && SAFE_IMAGE_FILE_EXTENSIONS.has(ext)) return ext;
116 return ".bin";
117}
118
119const materializeAttachmentsForProjection = Effect.fn(function* (input: {93const materializeAttachmentsForProjection = Effect.fn(function* (input: {
120 readonly threadId: string;94 readonly threadId: string;
121 readonly messageId: string;95 readonly messageId: string;
...@@ -159,8 +133,8 @@ const materializeAttachmentsForProjection = Effect.fn(function* (input: {...@@ -159,8 +133,8 @@ const materializeAttachmentsForProjection = Effect.fn(function* (input: {
159 if (bytes.byteLength === 0) return attachment;133 if (bytes.byteLength === 0) return attachment;
160134
161 const fileName = `${index}${inferImageExtension({135 const fileName = `${index}${inferImageExtension({
162 ...attachment,
163 mimeType: parsed.mimeType,136 mimeType: parsed.mimeType,
137 fileName: attachment.name,
164 })}`;138 })}`;
165 const uniqueFileName = `${messageSegment}-${fileName}`;139 const uniqueFileName = `${messageSegment}-${fileName}`;
166 const relativePath = `${threadSegment}/${uniqueFileName}`;140 const relativePath = `${threadSegment}/${uniqueFileName}`;
apps/server/src/projectFaviconRoute.ts+1-24
...@@ -1,33 +1,10 @@...@@ -1,33 +1,10 @@
1import fs from "node:fs";1import fs from "node:fs";
2import http from "node:http";2import http from "node:http";
3import path from "node:path";3import path from "node:path";
4export { IMAGE_EXTENSION_BY_MIME_TYPE, SAFE_IMAGE_FILE_EXTENSIONS } from "./imageMime.ts";
45
5export const ATTACHMENTS_ROUTE_PREFIX = "/attachments";6export const ATTACHMENTS_ROUTE_PREFIX = "/attachments";
67
7export const IMAGE_EXTENSION_BY_MIME_TYPE: Record<string, string> = {
8 "image/png": ".png",
9 "image/jpeg": ".jpg",
10 "image/jpg": ".jpg",
11 "image/gif": ".gif",
12 "image/webp": ".webp",
13 "image/svg+xml": ".svg",
14 "image/bmp": ".bmp",
15 "image/tiff": ".tiff",
16 "image/heic": ".heic",
17};
18
19export const SAFE_IMAGE_FILE_EXTENSIONS = new Set([
20 ".jpg",
21 ".jpeg",
22 ".png",
23 ".gif",
24 ".webp",
25 ".bmp",
26 ".tiff",
27 ".svg",
28 ".ico",
29]);
30
31const FAVICON_MIME_TYPES: Record<string, string> = {8const FAVICON_MIME_TYPES: Record<string, string> = {
32 ".png": "image/png",9 ".png": "image/png",
33 ".jpg": "image/jpeg",10 ".jpg": "image/jpeg",
apps/server/src/wsServer.test.ts+129-1
...@@ -46,6 +46,7 @@ import { Open, type OpenShape } from "./open";...@@ -46,6 +46,7 @@ import { Open, type OpenShape } from "./open";
46import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";46import { GitManager, type GitManagerShape } from "./git/Services/GitManager.ts";
47import type { GitCoreShape } from "./git/Services/GitCore.ts";47import type { GitCoreShape } from "./git/Services/GitCore.ts";
48import { GitCore } from "./git/Services/GitCore.ts";48import { GitCore } from "./git/Services/GitCore.ts";
49import { TextGeneration, type TextGenerationShape } from "./git/Services/TextGeneration.ts";
49import { GitCommandError, GitManagerError } from "./git/Errors.ts";50import { GitCommandError, GitManagerError } from "./git/Errors.ts";
50import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";51import { MigrationError } from "@effect/sql-sqlite-bun/SqliteMigrator";
5152
...@@ -369,7 +370,11 @@ describe("WebSocket Server", () => {...@@ -369,7 +370,11 @@ describe("WebSocket Server", () => {
369 providerLayer?: Layer.Layer<ProviderService, never>;370 providerLayer?: Layer.Layer<ProviderService, never>;
370 open?: OpenShape;371 open?: OpenShape;
371 gitManager?: GitManagerShape;372 gitManager?: GitManagerShape;
372 gitCore?: Pick<GitCoreShape, "listBranches" | "initRepo" | "pullCurrentBranch">;373 gitCore?: Pick<
374 GitCoreShape,
375 "listBranches" | "initRepo" | "pullCurrentBranch" | "renameBranch"
376 >;
377 textGeneration?: Pick<TextGenerationShape, "generateBranchName">;
373 terminalManager?: TerminalManagerShape;378 terminalManager?: TerminalManagerShape;
374 } = {},379 } = {},
375 ): Promise<Http.Server> {380 ): Promise<Http.Server> {
...@@ -402,6 +407,9 @@ describe("WebSocket Server", () => {...@@ -402,6 +407,9 @@ describe("WebSocket Server", () => {
402 options.gitCore407 options.gitCore
403 ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape)408 ? Layer.succeed(GitCore, options.gitCore as unknown as GitCoreShape)
404 : Layer.empty,409 : Layer.empty,
410 options.textGeneration
411 ? Layer.succeed(TextGeneration, options.textGeneration as unknown as TextGenerationShape)
412 : Layer.empty,
405 options.terminalManager413 options.terminalManager
406 ? Layer.succeed(TerminalManager, options.terminalManager)414 ? Layer.succeed(TerminalManager, options.terminalManager)
407 : Layer.empty,415 : Layer.empty,
...@@ -1468,6 +1476,8 @@ describe("WebSocket Server", () => {...@@ -1468,6 +1476,8 @@ describe("WebSocket Server", () => {
1468 }),1476 }),
1469 );1477 );
1470 const initRepo = vi.fn(() => Effect.void);1478 const initRepo = vi.fn(() => Effect.void);
1479 const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" }));
1480 const generateBranchName = vi.fn(() => Effect.succeed({ branch: "new-name" as const }));
1471 const pullCurrentBranch = vi.fn(() =>1481 const pullCurrentBranch = vi.fn(() =>
1472 Effect.fail(1482 Effect.fail(
1473 new GitCommandError({1483 new GitCommandError({
...@@ -1484,8 +1494,12 @@ describe("WebSocket Server", () => {...@@ -1484,8 +1494,12 @@ describe("WebSocket Server", () => {
1484 gitCore: {1494 gitCore: {
1485 listBranches,1495 listBranches,
1486 initRepo,1496 initRepo,
1497 renameBranch,
1487 pullCurrentBranch,1498 pullCurrentBranch,
1488 },1499 },
1500 textGeneration: {
1501 generateBranchName,
1502 },
1489 });1503 });
1490 const addr = server.address();1504 const addr = server.address();
1491 const port = typeof addr === "object" && addr !== null ? addr.port : 0;1505 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
...@@ -1507,6 +1521,120 @@ describe("WebSocket Server", () => {...@@ -1507,6 +1521,120 @@ describe("WebSocket Server", () => {
1507 expect(pullResponse.result).toBeUndefined();1521 expect(pullResponse.result).toBeUndefined();
1508 expect(pullResponse.error?.message).toContain("No upstream configured");1522 expect(pullResponse.error?.message).toContain("No upstream configured");
1509 expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path");1523 expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path");
1524
1525 const generatedBranchResponse = await sendRequest(ws, WS_METHODS.gitGenerateBranchName, {
1526 cwd: "/repo/path",
1527 message: "Add retry logic for websocket reconnects",
1528 attachments: [
1529 {
1530 type: "image",
1531 name: "bug.png",
1532 mimeType: "image/png",
1533 sizeBytes: 5,
1534 dataUrl: "data:image/png;base64,SGVsbG8=",
1535 },
1536 ],
1537 });
1538 expect(generatedBranchResponse.error).toBeUndefined();
1539 expect(generatedBranchResponse.result).toEqual({ branch: "new-name" });
1540 expect(generateBranchName).toHaveBeenCalledWith({
1541 cwd: "/repo/path",
1542 message: "Add retry logic for websocket reconnects",
1543 attachments: [
1544 {
1545 type: "image",
1546 name: "bug.png",
1547 mimeType: "image/png",
1548 sizeBytes: 5,
1549 dataUrl: "data:image/png;base64,SGVsbG8=",
1550 },
1551 ],
1552 });
1553
1554 const generateAndRenameResponse = await sendRequest(
1555 ws,
1556 WS_METHODS.gitGenerateAndRenameBranch,
1557 {
1558 cwd: "/repo/path",
1559 oldBranch: "t3code/tmp-123",
1560 message: "Add retry logic for websocket reconnects",
1561 attachments: [
1562 {
1563 type: "image",
1564 name: "bug.png",
1565 mimeType: "image/png",
1566 sizeBytes: 5,
1567 dataUrl: "data:image/png;base64,SGVsbG8=",
1568 },
1569 ],
1570 },
1571 );
1572 expect(generateAndRenameResponse.error).toBeUndefined();
1573 expect(generateAndRenameResponse.result).toEqual({ branch: "t3code/new-name" });
1574 expect(renameBranch).toHaveBeenCalledWith({
1575 cwd: "/repo/path",
1576 oldBranch: "t3code/tmp-123",
1577 newBranch: "t3code/new-name",
1578 });
1579
1580 const renameResponse = await sendRequest(ws, WS_METHODS.gitRenameBranch, {
1581 cwd: "/repo/path",
1582 oldBranch: "t3code/tmp-456",
1583 newBranch: "t3code/new-name",
1584 });
1585 expect(renameResponse.error).toBeUndefined();
1586 expect(renameResponse.result).toEqual({ branch: "t3code/new-name" });
1587 expect(renameBranch).toHaveBeenCalledWith({
1588 cwd: "/repo/path",
1589 oldBranch: "t3code/tmp-456",
1590 newBranch: "t3code/new-name",
1591 });
1592 });
1593
1594 it("skips rename in git.generateAndRenameBranch when branch generation returns null", async () => {
1595 const renameBranch = vi.fn(() => Effect.succeed({ branch: "t3code/new-name" }));
1596 const generateBranchName = vi.fn(() => Effect.succeed({ branch: null }));
1597
1598 server = await createTestServer({
1599 cwd: "/test",
1600 gitCore: {
1601 listBranches: () =>
1602 Effect.succeed({
1603 branches: [],
1604 isRepo: false,
1605 }),
1606 initRepo: () => Effect.void,
1607 renameBranch,
1608 pullCurrentBranch: () =>
1609 Effect.fail(
1610 new GitCommandError({
1611 operation: "GitCore.test.pullCurrentBranch",
1612 detail: "No upstream configured",
1613 command: "git pull",
1614 cwd: "/repo/path",
1615 }),
1616 ),
1617 },
1618 textGeneration: {
1619 generateBranchName,
1620 },
1621 });
1622 const addr = server.address();
1623 const port = typeof addr === "object" && addr !== null ? addr.port : 0;
1624 const ws = await connectWs(port);
1625 connections.push(ws);
1626 await waitForMessage(ws);
1627
1628 const response = await sendRequest(ws, WS_METHODS.gitGenerateAndRenameBranch, {
1629 cwd: "/repo/path",
1630 oldBranch: "t3code/tmp-123",
1631 message: "Fix visual regression",
1632 attachments: [],
1633 });
1634
1635 expect(response.error).toBeUndefined();
1636 expect(response.result).toEqual({ branch: null });
1637 expect(renameBranch).not.toHaveBeenCalled();
1510 });1638 });
15111639
1512 it("supports git.status over websocket", async () => {1640 it("supports git.status over websocket", async () => {
apps/server/src/wsServer.ts+59
...@@ -54,6 +54,7 @@ import { clamp } from "effect/Number";...@@ -54,6 +54,7 @@ import { clamp } from "effect/Number";
54import { Open } from "./open";54import { Open } from "./open";
55import { ServerConfig } from "./config";55import { ServerConfig } from "./config";
56import { GitCore } from "./git/Services/GitCore.ts";56import { GitCore } from "./git/Services/GitCore.ts";
57import { TextGeneration } from "./git/Services/TextGeneration.ts";
57import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute";58import { ATTACHMENTS_ROUTE_PREFIX, tryHandleProjectFaviconRequest } from "./projectFaviconRoute";
5859
59/**60/**
...@@ -135,6 +136,31 @@ function stripRequestTag<T extends { _tag: string }>(body: T) {...@@ -135,6 +136,31 @@ function stripRequestTag<T extends { _tag: string }>(body: T) {
135 return Struct.omit(body, ["_tag"]);136 return Struct.omit(body, ["_tag"]);
136}137}
137138
139const WORKTREE_BRANCH_PREFIX = "t3code";
140
141function buildGeneratedWorktreeBranchName(raw: string): string {
142 const normalized = raw
143 .trim()
144 .toLowerCase()
145 .replace(/^refs\/heads\//, "")
146 .replace(/['"`]/g, "");
147
148 const withoutPrefix = normalized.startsWith(`${WORKTREE_BRANCH_PREFIX}/`)
149 ? normalized.slice(`${WORKTREE_BRANCH_PREFIX}/`.length)
150 : normalized;
151
152 const branchFragment = withoutPrefix
153 .replace(/[^a-z0-9/_-]+/g, "-")
154 .replace(/\/+/g, "/")
155 .replace(/-+/g, "-")
156 .replace(/^[./_-]+|[./_-]+$/g, "")
157 .slice(0, 64)
158 .replace(/[./_-]+$/g, "");
159
160 const safeFragment = branchFragment.length > 0 ? branchFragment : "update";
161 return `${WORKTREE_BRANCH_PREFIX}/${safeFragment}`;
162}
163
138export type ServerCoreRuntimeServices =164export type ServerCoreRuntimeServices =
139 | OrchestrationEngineService165 | OrchestrationEngineService
140 | ProjectionSnapshotQuery166 | ProjectionSnapshotQuery
...@@ -146,6 +172,7 @@ export type ServerRuntimeServices =...@@ -146,6 +172,7 @@ export type ServerRuntimeServices =
146 | ServerCoreRuntimeServices172 | ServerCoreRuntimeServices
147 | GitManager173 | GitManager
148 | GitCore174 | GitCore
175 | TextGeneration
149 | TerminalManager176 | TerminalManager
150 | Keybindings177 | Keybindings
151 | Open;178 | Open;
...@@ -184,6 +211,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -184,6 +211,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
184 const terminalManager = yield* TerminalManager;211 const terminalManager = yield* TerminalManager;
185 const keybindingsManager = yield* Keybindings;212 const keybindingsManager = yield* Keybindings;
186 const git = yield* GitCore;213 const git = yield* GitCore;
214 const textGeneration = yield* TextGeneration;
187 const fileSystem = yield* FileSystem.FileSystem;215 const fileSystem = yield* FileSystem.FileSystem;
188 const path = yield* Path.Path;216 const path = yield* Path.Path;
189217
...@@ -572,6 +600,37 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<...@@ -572,6 +600,37 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
572 return yield* git.createWorktree(body);600 return yield* git.createWorktree(body);
573 }601 }
574602
603 case WS_METHODS.gitGenerateBranchName: {
604 const body = stripRequestTag(request.body);
605 return yield* textGeneration.generateBranchName(body);
606 }
607
608 case WS_METHODS.gitGenerateAndRenameBranch: {
609 const body = stripRequestTag(request.body);
610 const generated = yield* textGeneration.generateBranchName({
611 cwd: body.cwd,
612 message: body.message,
613 ...(body.attachments !== undefined ? { attachments: body.attachments } : {}),
614 });
615 if (!generated.branch) {
616 return { branch: null };
617 }
618 const targetBranch = buildGeneratedWorktreeBranchName(generated.branch);
619 if (targetBranch === body.oldBranch) {
620 return { branch: body.oldBranch };
621 }
622 return yield* git.renameBranch({
623 cwd: body.cwd,
624 oldBranch: body.oldBranch,
625 newBranch: targetBranch,
626 });
627 }
628
629 case WS_METHODS.gitRenameBranch: {
630 const body = stripRequestTag(request.body);
631 return yield* git.renameBranch(body);
632 }
633
575 case WS_METHODS.gitRemoveWorktree: {634 case WS_METHODS.gitRemoveWorktree: {
576 const body = stripRequestTag(request.body);635 const body = stripRequestTag(request.body);
577 return yield* git.removeWorktree(body);636 return yield* git.removeWorktree(body);
apps/web/src/components/ChatView.tsx+108-20
...@@ -129,6 +129,7 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];...@@ -129,6 +129,7 @@ const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = [];
129const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;129const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120;
130const SCRIPT_TERMINAL_COLS = 120;130const SCRIPT_TERMINAL_COLS = 120;
131const SCRIPT_TERMINAL_ROWS = 30;131const SCRIPT_TERMINAL_ROWS = 30;
132const WORKTREE_BRANCH_PREFIX = "t3code";
132133
133function readLastInvokedScriptByProjectFromStorage(): Record<string, string> {134function readLastInvokedScriptByProjectFromStorage(): Record<string, string> {
134 const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY);135 const stored = localStorage.getItem(LAST_INVOKED_SCRIPT_BY_PROJECT_KEY);
...@@ -232,6 +233,57 @@ function readFileAsDataUrl(file: File): Promise<string> {...@@ -232,6 +233,57 @@ function readFileAsDataUrl(file: File): Promise<string> {
232 });233 });
233}234}
234235
236function hashBranchSeed(value: string): string {
237 let hash = 2166136261;
238 for (let index = 0; index < value.length; index++) {
239 hash ^= value.charCodeAt(index);
240 hash = Math.imul(hash, 16777619);
241 }
242 return (hash >>> 0).toString(16).padStart(8, "0");
243}
244
245function buildTemporaryWorktreeBranchName(message: string): string {
246 const seed = `${Date.now()}:${message.trim().toLowerCase()}:${crypto.randomUUID()}`;
247 return `${WORKTREE_BRANCH_PREFIX}/${hashBranchSeed(seed)}`;
248}
249
250function imageReferenceForBranchNaming(image: ComposerImageAttachment): string {
251 const maybePath = (image.file as File & { path?: unknown }).path;
252 if (typeof maybePath === "string" && maybePath.trim().length > 0) {
253 return maybePath.trim();
254 }
255 return image.name;
256}
257
258function buildBranchNameGenerationMessage(
259 text: string,
260 images: ReadonlyArray<ComposerImageAttachment>,
261): string {
262 const trimmedText = text.trim();
263 const imageReferences = Array.from(
264 new Set(
265 images
266 .map((image) => imageReferenceForBranchNaming(image).trim())
267 .filter((reference) => reference.length > 0),
268 ),
269 );
270 const attachmentsSection =
271 imageReferences.length > 0
272 ? `Attached images:\n${imageReferences.map((reference) => `- ${reference}`).join("\n")}`
273 : "";
274
275 if (trimmedText.length > 0 && attachmentsSection.length > 0) {
276 return `${trimmedText}\n\n${attachmentsSection}`;
277 }
278 if (trimmedText.length > 0) {
279 return trimmedText;
280 }
281 if (attachmentsSection.length > 0) {
282 return attachmentsSection;
283 }
284 return "New worktree change";
285}
286
235const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {287const VscodeEntryIcon = memo(function VscodeEntryIcon(props: {
236 pathValue: string;288 pathValue: string;
237 kind: "file" | "directory";289 kind: "file" | "directory";
...@@ -1478,6 +1530,26 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1478,6 +1530,26 @@ export default function ChatView({ threadId }: ChatViewProps) {
1478 const composerImagesSnapshot = [...composerImages];1530 const composerImagesSnapshot = [...composerImages];
1479 const messageIdForSend = newMessageId();1531 const messageIdForSend = newMessageId();
1480 const messageCreatedAt = new Date().toISOString();1532 const messageCreatedAt = new Date().toISOString();
1533 const branchNameMessage = buildBranchNameGenerationMessage(trimmed, composerImagesSnapshot);
1534 const turnAttachmentsPromise = Promise.all(
1535 composerImagesSnapshot.map(
1536 async (
1537 image,
1538 ): Promise<{
1539 type: "image";
1540 name: string;
1541 mimeType: string;
1542 sizeBytes: number;
1543 dataUrl: string;
1544 }> => ({
1545 type: "image",
1546 name: image.name,
1547 mimeType: image.mimeType,
1548 sizeBytes: image.sizeBytes,
1549 dataUrl: await readFileAsDataUrl(image.file),
1550 }),
1551 ),
1552 );
1481 const optimisticAttachments = composerImagesSnapshot.map((image) => ({1553 const optimisticAttachments = composerImagesSnapshot.map((image) => ({
1482 type: "image" as const,1554 type: "image" as const,
1483 id: image.id,1555 id: image.id,
...@@ -1506,11 +1578,12 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1506,11 +1578,12 @@ export default function ChatView({ threadId }: ChatViewProps) {
1506 setComposerHighlightedItemId(null);1578 setComposerHighlightedItemId(null);
15071579
1508 let attemptedTurnStart = false;1580 let attemptedTurnStart = false;
1581 let pendingWorktreeBranchRename: Promise<void> | null = null;
1509 try {1582 try {
1510 // On first message: lock in branch + create worktree if needed.1583 // On first message: lock in branch + create worktree if needed.
1511 if (baseBranchForWorktree) {1584 if (baseBranchForWorktree) {
1512 setSendPhase("preparing-worktree");1585 setSendPhase("preparing-worktree");
1513 const newBranch = `codething/${crypto.randomUUID().slice(0, 8)}`;1586 const newBranch = buildTemporaryWorktreeBranchName(trimmed);
1514 const result = await createWorktreeMutation.mutateAsync({1587 const result = await createWorktreeMutation.mutateAsync({
1515 cwd: activeProject.cwd,1588 cwd: activeProject.cwd,
1516 branch: baseBranchForWorktree,1589 branch: baseBranchForWorktree,
...@@ -1531,6 +1604,36 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1531,6 +1604,36 @@ export default function ChatView({ threadId }: ChatViewProps) {
1531 branch: result.worktree.branch,1604 branch: result.worktree.branch,
1532 worktreePath: result.worktree.path,1605 worktreePath: result.worktree.path,
1533 });1606 });
1607 pendingWorktreeBranchRename = (async () => {
1608 const turnAttachments = await turnAttachmentsPromise;
1609 const renamed = await api.git.generateAndRenameBranch({
1610 cwd: result.worktree.path,
1611 oldBranch: result.worktree.branch,
1612 message: branchNameMessage,
1613 attachments: turnAttachments,
1614 });
1615 if (!renamed.branch || renamed.branch === result.worktree.branch) {
1616 return;
1617 }
1618 await api.orchestration.dispatchCommand({
1619 type: "thread.meta.update",
1620 commandId: newCommandId(),
1621 threadId: threadIdForSend,
1622 branch: renamed.branch,
1623 worktreePath: result.worktree.path,
1624 });
1625 dispatch({
1626 type: "SET_THREAD_BRANCH",
1627 threadId: threadIdForSend,
1628 branch: renamed.branch,
1629 worktreePath: result.worktree.path,
1630 });
1631 })().catch((error) => {
1632 console.warn("Failed to rename generated worktree branch", {
1633 threadId: threadIdForSend,
1634 error: error instanceof Error ? error.message : String(error),
1635 });
1636 });
1534 const setupScript = setupProjectScript(activeProject.scripts);1637 const setupScript = setupProjectScript(activeProject.scripts);
1535 if (setupScript) {1638 if (setupScript) {
1536 await runProjectScript(setupScript, {1639 await runProjectScript(setupScript, {
...@@ -1558,25 +1661,7 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1558,25 +1661,7 @@ export default function ChatView({ threadId }: ChatViewProps) {
1558 }1661 }
15591662
1560 setSendPhase("sending-turn");1663 setSendPhase("sending-turn");
1561 const turnAttachments = await Promise.all(1664 const turnAttachments = await turnAttachmentsPromise;
1562 composerImagesSnapshot.map(
1563 async (
1564 image,
1565 ): Promise<{
1566 type: "image";
1567 name: string;
1568 mimeType: string;
1569 sizeBytes: number;
1570 dataUrl: string;
1571 }> => ({
1572 type: "image",
1573 name: image.name,
1574 mimeType: image.mimeType,
1575 sizeBytes: image.sizeBytes,
1576 dataUrl: await readFileAsDataUrl(image.file),
1577 }),
1578 ),
1579 );
1580 attemptedTurnStart = true;1665 attemptedTurnStart = true;
1581 const approvalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";1666 const approvalPolicy = state.runtimeMode === "full-access" ? "never" : "on-request";
1582 const sandboxMode =1667 const sandboxMode =
...@@ -1598,6 +1683,9 @@ export default function ChatView({ threadId }: ChatViewProps) {...@@ -1598,6 +1683,9 @@ export default function ChatView({ threadId }: ChatViewProps) {
1598 sandboxMode,1683 sandboxMode,
1599 createdAt: messageCreatedAt,1684 createdAt: messageCreatedAt,
1600 });1685 });
1686 if (pendingWorktreeBranchRename) {
1687 void pendingWorktreeBranchRename;
1688 }
1601 } catch (err) {1689 } catch (err) {
1602 if (1690 if (
1603 !attemptedTurnStart &&1691 !attemptedTurnStart &&
apps/web/src/worktreeCleanup.test.ts+4-4
...@@ -88,16 +88,16 @@ describe("getOrphanedWorktreePathForThread", () => {...@@ -88,16 +88,16 @@ describe("getOrphanedWorktreePathForThread", () => {
88describe("formatWorktreePathForDisplay", () => {88describe("formatWorktreePathForDisplay", () => {
89 it("shows only the last path segment for unix-like paths", () => {89 it("shows only the last path segment for unix-like paths", () => {
90 const result = formatWorktreePathForDisplay(90 const result = formatWorktreePathForDisplay(
91 "/Users/julius/.t3/worktrees/codething-mvp/codething-4e609bb8",91 "/Users/julius/.t3/worktrees/t3code-mvp/t3code-4e609bb8",
92 );92 );
93 expect(result).toBe("codething-4e609bb8");93 expect(result).toBe("t3code-4e609bb8");
94 });94 });
9595
96 it("normalizes windows separators before selecting the final segment", () => {96 it("normalizes windows separators before selecting the final segment", () => {
97 const result = formatWorktreePathForDisplay(97 const result = formatWorktreePathForDisplay(
98 "C:\\Users\\julius\\.t3\\worktrees\\codething-mvp\\codething-4e609bb8",98 "C:\\Users\\julius\\.t3\\worktrees\\t3code-mvp\\t3code-4e609bb8",
99 );99 );
100 expect(result).toBe("codething-4e609bb8");100 expect(result).toBe("t3code-4e609bb8");
101 });101 });
102102
103 it("uses the final segment even when outside ~/.t3/worktrees", () => {103 it("uses the final segment even when outside ~/.t3/worktrees", () => {
apps/web/src/wsNativeApi.ts+4
...@@ -164,6 +164,10 @@ export function createWsNativeApi(): NativeApi {...@@ -164,6 +164,10 @@ export function createWsNativeApi(): NativeApi {
164 runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input),164 runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input),
165 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),165 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
166 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),166 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),
167 generateBranchName: (input) => transport.request(WS_METHODS.gitGenerateBranchName, input),
168 generateAndRenameBranch: (input) =>
169 transport.request(WS_METHODS.gitGenerateAndRenameBranch, input),
170 renameBranch: (input) => transport.request(WS_METHODS.gitRenameBranch, input),
167 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),171 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),
168 createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input),172 createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input),
169 checkout: (input) => transport.request(WS_METHODS.gitCheckout, input),173 checkout: (input) => transport.request(WS_METHODS.gitCheckout, input),
packages/contracts/src/git.ts+42
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import { Schema } from "effect";1import { Schema } from "effect";
2import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";2import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas";
3import { ChatAttachment, PROVIDER_SEND_TURN_MAX_ATTACHMENTS } from "./orchestration";
34
4const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;5const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
56
...@@ -69,6 +70,32 @@ export const GitCreateWorktreeInput = Schema.Struct({...@@ -69,6 +70,32 @@ export const GitCreateWorktreeInput = Schema.Struct({
69});70});
70export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type;71export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type;
7172
73export const GitGenerateBranchNameInput = Schema.Struct({
74 cwd: TrimmedNonEmptyStringSchema,
75 message: TrimmedNonEmptyStringSchema,
76 attachments: Schema.optional(
77 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
78 ),
79});
80export type GitGenerateBranchNameInput = typeof GitGenerateBranchNameInput.Type;
81
82export const GitGenerateAndRenameBranchInput = Schema.Struct({
83 cwd: TrimmedNonEmptyStringSchema,
84 oldBranch: TrimmedNonEmptyStringSchema,
85 message: TrimmedNonEmptyStringSchema,
86 attachments: Schema.optional(
87 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
88 ),
89});
90export type GitGenerateAndRenameBranchInput = typeof GitGenerateAndRenameBranchInput.Type;
91
92export const GitRenameBranchInput = Schema.Struct({
93 cwd: TrimmedNonEmptyStringSchema,
94 oldBranch: TrimmedNonEmptyStringSchema,
95 newBranch: TrimmedNonEmptyStringSchema,
96});
97export type GitRenameBranchInput = typeof GitRenameBranchInput.Type;
98
72export const GitRemoveWorktreeInput = Schema.Struct({99export const GitRemoveWorktreeInput = Schema.Struct({
73 cwd: TrimmedNonEmptyStringSchema,100 cwd: TrimmedNonEmptyStringSchema,
74 path: TrimmedNonEmptyStringSchema,101 path: TrimmedNonEmptyStringSchema,
...@@ -137,6 +164,21 @@ export const GitCreateWorktreeResult = Schema.Struct({...@@ -137,6 +164,21 @@ export const GitCreateWorktreeResult = Schema.Struct({
137});164});
138export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type;165export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type;
139166
167export const GitGenerateBranchNameResult = Schema.Struct({
168 branch: Schema.NullOr(TrimmedNonEmptyStringSchema),
169});
170export type GitGenerateBranchNameResult = typeof GitGenerateBranchNameResult.Type;
171
172export const GitGenerateAndRenameBranchResult = Schema.Struct({
173 branch: Schema.NullOr(TrimmedNonEmptyStringSchema),
174});
175export type GitGenerateAndRenameBranchResult = typeof GitGenerateAndRenameBranchResult.Type;
176
177export const GitRenameBranchResult = Schema.Struct({
178 branch: TrimmedNonEmptyStringSchema,
179});
180export type GitRenameBranchResult = typeof GitRenameBranchResult.Type;
181
140export const GitRunStackedActionResult = Schema.Struct({182export const GitRunStackedActionResult = Schema.Struct({
141 action: GitStackedAction,183 action: GitStackedAction,
142 commit: Schema.Struct({184 commit: Schema.Struct({
packages/contracts/src/ipc.ts+11
...@@ -1,6 +1,12 @@...@@ -1,6 +1,12 @@
1import type {1import type {
2 GitCheckoutInput,2 GitCheckoutInput,
3 GitCreateBranchInput,3 GitCreateBranchInput,
4 GitGenerateAndRenameBranchInput,
5 GitGenerateAndRenameBranchResult,
6 GitGenerateBranchNameInput,
7 GitGenerateBranchNameResult,
8 GitRenameBranchInput,
9 GitRenameBranchResult,
4 GitCreateWorktreeInput,10 GitCreateWorktreeInput,
5 GitCreateWorktreeResult,11 GitCreateWorktreeResult,
6 GitInitInput,12 GitInitInput,
...@@ -74,6 +80,11 @@ export interface NativeApi {...@@ -74,6 +80,11 @@ export interface NativeApi {
74 // Existing branch/worktree API80 // Existing branch/worktree API
75 listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>;81 listBranches: (input: GitListBranchesInput) => Promise<GitListBranchesResult>;
76 createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>;82 createWorktree: (input: GitCreateWorktreeInput) => Promise<GitCreateWorktreeResult>;
83 generateBranchName: (input: GitGenerateBranchNameInput) => Promise<GitGenerateBranchNameResult>;
84 generateAndRenameBranch: (
85 input: GitGenerateAndRenameBranchInput,
86 ) => Promise<GitGenerateAndRenameBranchResult>;
87 renameBranch: (input: GitRenameBranchInput) => Promise<GitRenameBranchResult>;
77 removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>;88 removeWorktree: (input: GitRemoveWorktreeInput) => Promise<void>;
78 createBranch: (input: GitCreateBranchInput) => Promise<void>;89 createBranch: (input: GitCreateBranchInput) => Promise<void>;
79 checkout: (input: GitCheckoutInput) => Promise<void>;90 checkout: (input: GitCheckoutInput) => Promise<void>;
packages/contracts/src/ws.ts+9
...@@ -11,11 +11,14 @@ import {...@@ -11,11 +11,14 @@ import {
11} from "./orchestration";11} from "./orchestration";
12import {12import {
13 GitCheckoutInput,13 GitCheckoutInput,
14 GitGenerateAndRenameBranchInput,
14 GitCreateBranchInput,15 GitCreateBranchInput,
16 GitGenerateBranchNameInput,
15 GitCreateWorktreeInput,17 GitCreateWorktreeInput,
16 GitInitInput,18 GitInitInput,
17 GitListBranchesInput,19 GitListBranchesInput,
18 GitPullInput,20 GitPullInput,
21 GitRenameBranchInput,
19 GitRemoveWorktreeInput,22 GitRemoveWorktreeInput,
20 GitRunStackedActionInput,23 GitRunStackedActionInput,
21 GitStatusInput,24 GitStatusInput,
...@@ -50,6 +53,9 @@ export const WS_METHODS = {...@@ -50,6 +53,9 @@ export const WS_METHODS = {
50 gitRunStackedAction: "git.runStackedAction",53 gitRunStackedAction: "git.runStackedAction",
51 gitListBranches: "git.listBranches",54 gitListBranches: "git.listBranches",
52 gitCreateWorktree: "git.createWorktree",55 gitCreateWorktree: "git.createWorktree",
56 gitGenerateBranchName: "git.generateBranchName",
57 gitGenerateAndRenameBranch: "git.generateAndRenameBranch",
58 gitRenameBranch: "git.renameBranch",
53 gitRemoveWorktree: "git.removeWorktree",59 gitRemoveWorktree: "git.removeWorktree",
54 gitCreateBranch: "git.createBranch",60 gitCreateBranch: "git.createBranch",
55 gitCheckout: "git.checkout",61 gitCheckout: "git.checkout",
...@@ -111,6 +117,9 @@ export const WebSocketRequestBody = Schema.Union([...@@ -111,6 +117,9 @@ export const WebSocketRequestBody = Schema.Union([
111 tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput),117 tagRequestBody(WS_METHODS.gitRunStackedAction, GitRunStackedActionInput),
112 tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput),118 tagRequestBody(WS_METHODS.gitListBranches, GitListBranchesInput),
113 tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput),119 tagRequestBody(WS_METHODS.gitCreateWorktree, GitCreateWorktreeInput),
120 tagRequestBody(WS_METHODS.gitGenerateBranchName, GitGenerateBranchNameInput),
121 tagRequestBody(WS_METHODS.gitGenerateAndRenameBranch, GitGenerateAndRenameBranchInput),
122 tagRequestBody(WS_METHODS.gitRenameBranch, GitRenameBranchInput),
114 tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput),123 tagRequestBody(WS_METHODS.gitRemoveWorktree, GitRemoveWorktreeInput),
115 tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput),124 tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput),
116 tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput),125 tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput),