History

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

Julius Marminge committed 8 months ago

Commit 8416ecd1 parent 206eb6a18 files · +897 −81Browse files

Unified · Split

18 files
  1. apps/server/src/git/Layers/CodexTextGeneration.test.ts +96 −0
  2. apps/server/src/git/Layers/CodexTextGeneration.ts +167 −4
  3. apps/server/src/git/Layers/GitCore.test.ts +96 −0
  4. apps/server/src/git/Layers/GitCore.ts +58 −0
  5. apps/server/src/git/Layers/GitManager.test.ts +19 −0
  6. apps/server/src/git/Services/GitCore.ts +9 −0
  7. apps/server/src/git/Services/TextGeneration.ts +19 −0
  8. apps/server/src/imageMime.ts +64 −0
  9. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +2 −28
  10. apps/server/src/projectFaviconRoute.ts +1 −24
  11. apps/server/src/wsServer.test.ts +129 −1
  12. apps/server/src/wsServer.ts +59 −0
  13. apps/web/src/components/ChatView.tsx +108 −20
  14. apps/web/src/worktreeCleanup.test.ts +4 −4
  15. apps/web/src/wsNativeApi.ts +4 −0
  16. packages/contracts/src/git.ts +42 −0
  17. packages/contracts/src/ipc.ts +11 −0
  18. packages/contracts/src/ws.ts +9 −0
apps/server/src/git/Layers/CodexTextGeneration.test.ts+96−0
@@ -21,12 +21,23 @@ function makeFakeCodexBinary(dir: string) { · 20 unmodified lines
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>( · 15 unmodified lines
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>( · 6 unmodified lines
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;
63 76
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>( · 10 unmodified lines
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 });
80 99
81 return {100 return {
@@ -83,6 +102,7 @@ function withFakeCodexEnv<A, E, R>( · 1 unmodified line
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>( · 18 unmodified lines
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) => { · 57 unmodified lines
170 ),196 ),
171 );197 );
172 198
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" · 3 unmodified lines
4import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";4import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
5 5
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 = { · 24 unmodified lines
35 additionalProperties: false,38 additionalProperties: false,
36} as const;39} as const;
37 40
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 } { · 56 unmodified lines
97 return { title, body };109 return { title, body };
98}110}
99 111
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 { · 21 unmodified lines
124 return "Update project changes";148 return "Update project changes";
125}149}
126 150
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* () { · 38 unmodified lines
168 );210 );
169 };211 };
170 212
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));
173 239
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* () { · 20 unmodified lines
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* () { · 40 unmodified lines
254 }363 }
255 });364 });
256 365
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);
260 372
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* () { · 121 unmodified lines
384 );496 );
385 };497 };
386 498
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});
392 555
apps/server/src/git/Layers/GitCore.test.ts+96−0
@@ -106,6 +106,7 @@ const makeIsolatedGitCore = (gitService: GitServiceShape) => · 105 unmodified lines
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]) · 42 unmodified lines
154 });155 });
155}156}
156 157
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;
@@ -650,6 +658,94 @@ it.layer(TestLayer)("git integration", (it) => { · 490 unmodified lines
650 );658 );
651 });659 });
652 660
661 // ── renameGitBranch ──
662
663 describe("renameGitBranch", () => {
664 it.effect("renames the current branch", () =>
665 Effect.gen(function* () {
666 const tmp = yield* makeTmpDir();
667 yield* initRepoWithCommit(tmp);
668 yield* createGitBranch({ cwd: tmp, branch: "feature/old-name" });
669 yield* checkoutGitBranch({ cwd: tmp, branch: "feature/old-name" });
670
671 const renamed = yield* renameGitBranch({
672 cwd: tmp,
673 oldBranch: "feature/old-name",
674 newBranch: "feature/new-name",
675 });
676
677 expect(renamed.branch).toBe("feature/new-name");
678
679 const branches = yield* listGitBranches({ cwd: tmp });
680 expect(branches.branches.some((branch) => branch.name === "feature/old-name")).toBe(false);
681 const current = branches.branches.find((branch) => branch.current);
682 expect(current?.name).toBe("feature/new-name");
683 }),
684 );
685
686 it.effect("returns success without git invocation when old/new names match", () =>
687 Effect.gen(function* () {
688 const tmp = yield* makeTmpDir();
689 yield* initRepoWithCommit(tmp);
690 const current = (yield* listGitBranches({ cwd: tmp })).branches.find((b) => b.current)!;
691
692 const renamed = yield* renameGitBranch({
693 cwd: tmp,
694 oldBranch: current.name,
695 newBranch: current.name,
696 });
697
698 expect(renamed.branch).toBe(current.name);
699 }),
700 );
701
702 it.effect("appends numeric suffix when target branch already exists", () =>
703 Effect.gen(function* () {
704 const tmp = yield* makeTmpDir();
705 yield* initRepoWithCommit(tmp);
706 yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" });
707 yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
708 yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
709
710 const renamed = yield* renameGitBranch({
711 cwd: tmp,
712 oldBranch: "t3code/tmp-working",
713 newBranch: "t3code/feat/session",
714 });
715
716 expect(renamed.branch).toBe("t3code/feat/session-1");
717 const branches = yield* listGitBranches({ cwd: tmp });
718 expect(branches.branches.some((branch) => branch.name === "t3code/feat/session")).toBe(
719 true,
720 );
721 expect(branches.branches.some((branch) => branch.name === "t3code/feat/session-1")).toBe(
722 true,
723 );
724 const current = branches.branches.find((branch) => branch.current);
725 expect(current?.name).toBe("t3code/feat/session-1");
726 }),
727 );
728
729 it.effect("increments suffix until it finds an available branch name", () =>
730 Effect.gen(function* () {
731 const tmp = yield* makeTmpDir();
732 yield* initRepoWithCommit(tmp);
733 yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session" });
734 yield* createGitBranch({ cwd: tmp, branch: "t3code/feat/session-1" });
735 yield* createGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
736 yield* checkoutGitBranch({ cwd: tmp, branch: "t3code/tmp-working" });
737
738 const renamed = yield* renameGitBranch({
739 cwd: tmp,
740 oldBranch: "t3code/tmp-working",
741 newBranch: "t3code/feat/session",
742 });
743
744 expect(renamed.branch).toBe("t3code/feat/session-2");
745 }),
746 );
747 });
748
653 // ── createGitWorktree + removeGitWorktree ──749 // ── createGitWorktree + removeGitWorktree ──
654 750
655 describe("createGitWorktree", () => {751 describe("createGitWorktree", () => {
apps/server/src/git/Layers/GitCore.ts+58−0
@@ -158,6 +158,43 @@ const makeGitCore = Effect.gen(function* () { · 157 unmodified lines
158 Effect.map((result) => result.stdout),158 Effect.map((result) => result.stdout),
159 );159 );
160 160
161 const branchExists = (cwd: string, branch: string): Effect.Effect<boolean, GitCommandError> =>
162 executeGit(
163 "GitCore.branchExists",
164 cwd,
165 ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
166 {
167 allowNonZeroExit: true,
168 timeoutMs: 5_000,
169 },
170 ).pipe(Effect.map((result) => result.code === 0));
171
172 const resolveAvailableBranchName = (
173 cwd: string,
174 desiredBranch: string,
175 ): Effect.Effect<string, GitCommandError> =>
176 Effect.gen(function* () {
177 const isDesiredTaken = yield* branchExists(cwd, desiredBranch);
178 if (!isDesiredTaken) {
179 return desiredBranch;
180 }
181
182 for (let suffix = 1; suffix <= 100; suffix += 1) {
183 const candidate = `${desiredBranch}-${suffix}`;
184 const isCandidateTaken = yield* branchExists(cwd, candidate);
185 if (!isCandidateTaken) {
186 return candidate;
187 }
188 }
189
190 return yield* createGitCommandError(
191 "GitCore.renameBranch",
192 cwd,
193 ["branch", "-m", desiredBranch],
194 `Could not find an available branch name for '${desiredBranch}'.`,
195 );
196 });
197
161 const resolveCurrentUpstream = (198 const resolveCurrentUpstream = (
162 cwd: string,199 cwd: string,
163 ): Effect.Effect<200 ): Effect.Effect<
@@ -711,6 +748,26 @@ const makeGitCore = Effect.gen(function* () { · 547 unmodified lines
711 );748 );
712 });749 });
713 750
751 const renameBranch: GitCoreShape["renameBranch"] = (input) =>
752 Effect.gen(function* () {
753 if (input.oldBranch === input.newBranch) {
754 return { branch: input.newBranch };
755 }
756 const targetBranch = yield* resolveAvailableBranchName(input.cwd, input.newBranch);
757
758 yield* executeGit(
759 "GitCore.renameBranch",
760 input.cwd,
761 ["branch", "-m", input.oldBranch, targetBranch],
762 {
763 timeoutMs: 10_000,
764 fallbackErrorMessage: "git branch rename failed",
765 },
766 );
767
768 return { branch: targetBranch };
769 });
770
714 const createBranch: GitCoreShape["createBranch"] = (input) =>771 const createBranch: GitCoreShape["createBranch"] = (input) =>
715 executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {772 executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {
716 timeoutMs: 10_000,773 timeoutMs: 10_000,
@@ -750,6 +807,7 @@ const makeGitCore = Effect.gen(function* () { · 33 unmodified lines
750 listBranches,807 listBranches,
751 createWorktree,808 createWorktree,
752 removeWorktree,809 removeWorktree,
810 renameBranch,
753 createBranch,811 createBranch,
754 checkoutBranch,812 checkoutBranch,
755 initRepo,813 initRepo,
apps/server/src/git/Layers/GitManager.test.ts+19−0
@@ -41,6 +41,10 @@ interface FakeGitTextGeneration { · 40 unmodified lines
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}
45 49
46function makeTempDir(50function makeTempDir(
@@ -110,6 +114,10 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T · 63 unmodified lines
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 };
115 123
@@ -136,6 +144,17 @@ function createTextGeneration(overrides: Partial<FakeGitTextGeneration> = {}): T · 20 unmodified lines
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}
141 160
apps/server/src/git/Services/GitCore.ts+9−0
@@ -17,6 +17,8 @@ import type { · 16 unmodified lines
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 { · 101 unmodified lines
124 */126 */
125 readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>;127 readonly removeWorktree: (input: GitRemoveWorktreeInput) => Effect.Effect<void, GitCommandError>;
126 128
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−0
@@ -8,6 +8,7 @@ · 7 unmodified lines
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";
11 12
12import type { TextGenerationError } from "../Errors.ts";13import type { TextGenerationError } from "../Errors.ts";
13 14
@@ -37,11 +38,22 @@ export interface PrContentGenerationResult { · 23 unmodified lines
37 body: string;38 body: string;
38}39}
39 40
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}
46 58
47/**59/**
@@ -61,6 +73,13 @@ export interface TextGenerationShape { · 13 unmodified lines
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}
65 84
66/**85/**
apps/server/src/imageMime.ts+64−0
@@ -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 @@
1import Mime from "@effect/platform-node/Mime";
2import {1import {
3 ApprovalRequestId,2 ApprovalRequestId,
4 type ChatAttachment,3 type ChatAttachment,
@@ -40,9 +39,8 @@ import { · 35 unmodified lines
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";
46 44
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 { · 43 unmodified lines
92 return segment;90 return segment;
93}91}
94 92
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: { · 37 unmodified lines
159 if (bytes.byteLength === 0) return attachment;133 if (bytes.byteLength === 0) return attachment;
160 134
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 @@
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";
4 5
5export const ATTACHMENTS_ROUTE_PREFIX = "/attachments";6export const ATTACHMENTS_ROUTE_PREFIX = "/attachments";
6 7
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"; · 45 unmodified lines
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";
51 52
@@ -369,7 +370,11 @@ describe("WebSocket Server", () => { · 317 unmodified lines
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", () => { · 26 unmodified lines
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", () => { · 1060 unmodified lines
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", () => { · 10 unmodified lines
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", () => { · 15 unmodified lines
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 });
1511 1639
1512 it("supports git.status over websocket", async () => {1640 it("supports git.status over websocket", async () => {
apps/server/src/wsServer.ts+59−0
@@ -54,6 +54,7 @@ import { clamp } from "effect/Number"; · 53 unmodified lines
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";
58 59
59/**60/**
@@ -135,6 +136,31 @@ function stripRequestTag<T extends { _tag: string }>(body: T) { · 75 unmodified lines
135 return Struct.omit(body, ["_tag"]);136 return Struct.omit(body, ["_tag"]);
136}137}
137 138
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 = · 5 unmodified lines
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< · 32 unmodified lines
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;
189 217
@@ -572,6 +600,37 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< · 382 unmodified lines
572 return yield* git.createWorktree(body);600 return yield* git.createWorktree(body);
573 }601 }
574 602
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[] = []; · 128 unmodified lines
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";
132 133
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> { · 97 unmodified lines
232 });233 });
233}234}
234 235
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) { · 1240 unmodified lines
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) { · 22 unmodified lines
1506 setComposerHighlightedItemId(null);1578 setComposerHighlightedItemId(null);
1507 1579
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) { · 14 unmodified lines
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) { · 21 unmodified lines
1558 }1661 }
1559 1662
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) { · 15 unmodified lines
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", () => { · 87 unmodified lines
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 });
95 95
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 });
102 102
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−0
@@ -165,6 +165,10 @@ export function createWsNativeApi(): NativeApi { · 164 unmodified lines
165 runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input),165 runStackedAction: (input) => transport.request(WS_METHODS.gitRunStackedAction, input),
166 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),166 listBranches: (input) => transport.request(WS_METHODS.gitListBranches, input),
167 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),167 createWorktree: (input) => transport.request(WS_METHODS.gitCreateWorktree, input),
168 generateBranchName: (input) => transport.request(WS_METHODS.gitGenerateBranchName, input),
169 generateAndRenameBranch: (input) =>
170 transport.request(WS_METHODS.gitGenerateAndRenameBranch, input),
171 renameBranch: (input) => transport.request(WS_METHODS.gitRenameBranch, input),
168 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),172 removeWorktree: (input) => transport.request(WS_METHODS.gitRemoveWorktree, input),
169 createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input),173 createBranch: (input) => transport.request(WS_METHODS.gitCreateBranch, input),
170 checkout: (input) => transport.request(WS_METHODS.gitCheckout, input),174 checkout: (input) => transport.request(WS_METHODS.gitCheckout, input),
packages/contracts/src/git.ts+42−0
@@ -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";
3 4
4const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;5const TrimmedNonEmptyStringSchema = TrimmedNonEmptyString;
5 6
@@ -67,6 +68,32 @@ export const GitCreateWorktreeInput = Schema.Struct({ · 61 unmodified lines
67});68});
68export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type;69export type GitCreateWorktreeInput = typeof GitCreateWorktreeInput.Type;
69 70
71export const GitGenerateBranchNameInput = Schema.Struct({
72 cwd: TrimmedNonEmptyStringSchema,
73 message: TrimmedNonEmptyStringSchema,
74 attachments: Schema.optional(
75 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
76 ),
77});
78export type GitGenerateBranchNameInput = typeof GitGenerateBranchNameInput.Type;
79
80export const GitGenerateAndRenameBranchInput = Schema.Struct({
81 cwd: TrimmedNonEmptyStringSchema,
82 oldBranch: TrimmedNonEmptyStringSchema,
83 message: TrimmedNonEmptyStringSchema,
84 attachments: Schema.optional(
85 Schema.Array(ChatAttachment).check(Schema.isMaxLength(PROVIDER_SEND_TURN_MAX_ATTACHMENTS)),
86 ),
87});
88export type GitGenerateAndRenameBranchInput = typeof GitGenerateAndRenameBranchInput.Type;
89
90export const GitRenameBranchInput = Schema.Struct({
91 cwd: TrimmedNonEmptyStringSchema,
92 oldBranch: TrimmedNonEmptyStringSchema,
93 newBranch: TrimmedNonEmptyStringSchema,
94});
95export type GitRenameBranchInput = typeof GitRenameBranchInput.Type;
96
70export const GitRemoveWorktreeInput = Schema.Struct({97export const GitRemoveWorktreeInput = Schema.Struct({
71 cwd: TrimmedNonEmptyStringSchema,98 cwd: TrimmedNonEmptyStringSchema,
72 path: TrimmedNonEmptyStringSchema,99 path: TrimmedNonEmptyStringSchema,
@@ -135,6 +162,21 @@ export const GitCreateWorktreeResult = Schema.Struct({ · 62 unmodified lines
135});162});
136export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type;163export type GitCreateWorktreeResult = typeof GitCreateWorktreeResult.Type;
137 164
165export const GitGenerateBranchNameResult = Schema.Struct({
166 branch: Schema.NullOr(TrimmedNonEmptyStringSchema),
167});
168export type GitGenerateBranchNameResult = typeof GitGenerateBranchNameResult.Type;
169
170export const GitGenerateAndRenameBranchResult = Schema.Struct({
171 branch: Schema.NullOr(TrimmedNonEmptyStringSchema),
172});
173export type GitGenerateAndRenameBranchResult = typeof GitGenerateAndRenameBranchResult.Type;
174
175export const GitRenameBranchResult = Schema.Struct({
176 branch: TrimmedNonEmptyStringSchema,
177});
178export type GitRenameBranchResult = typeof GitRenameBranchResult.Type;
179
138export const GitRunStackedActionResult = Schema.Struct({180export const GitRunStackedActionResult = Schema.Struct({
139 action: GitStackedAction,181 action: GitStackedAction,
140 commit: Schema.Struct({182 commit: Schema.Struct({
packages/contracts/src/ipc.ts+11−0
@@ -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 { · 67 unmodified lines
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−0
@@ -11,11 +11,14 @@ import { · 10 unmodified lines
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 = { · 28 unmodified lines
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([ · 55 unmodified lines
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),