authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-03-02 09:58:52-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-03-02 20:46:05-08:00
logb1608f41adff7ac9670c5a762624e13984d820c8
treec50db3f5a0ad95881d7978a17dc215ce7cfd3806
parent78d89e3d353a9ce7770b34f3546e39f415cddc37

Extract branch sanitization to shared contracts module

Move sanitizeBranchFragment and sanitizeFeatureBranchName from three duplicate definitions (CodexTextGeneration, GitManager, GitActionsControl) into packages/contracts/src/git.ts. Remove the redundant double-sanitization of branch names between the TextGeneration and GitManager layers — CodexTextGeneration now owns sanitization of model output while GitManager trusts the result and only sanitizes the custom commit message path. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

4 files changed, 47 insertions(+), 79 deletions(-)

apps/server/src/git/Layers/CodexTextGeneration.ts+3-27
......@@ -3,6 +3,8 @@ import { randomUUID } from "node:crypto";
33import { Effect, FileSystem, Layer, Option, Path, Schema, Stream } from "effect";
44import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
55
6import { sanitizeBranchFragment, sanitizeFeatureBranchName } from "@t3tools/contracts";
7
68import { resolveAttachmentPath } from "../../attachmentStore.ts";
79import { ServerConfig } from "../../config.ts";
810import { TextGenerationError } from "../Errors.ts";
......@@ -93,32 +95,6 @@ function sanitizePrTitle(raw: string): string {
9395 return "Update project changes";
9496}
9597
96function sanitizeBranchName(raw: string): string {
97 const normalized = raw
98 .trim()
99 .toLowerCase()
100 .replace(/['"`]/g, "")
101 .replace(/^[./\s_-]+|[./\s_-]+$/g, "");
102
103 const branchFragment = normalized
104 .replace(/[^a-z0-9/_-]+/g, "-")
105 .replace(/\/+/g, "/")
106 .replace(/-+/g, "-")
107 .replace(/^[./_-]+|[./_-]+$/g, "")
108 .slice(0, 64)
109 .replace(/[./_-]+$/g, "");
110
111 return branchFragment.length > 0 ? branchFragment : "update";
112}
113
114function sanitizeFeatureBranchName(raw: string): string {
115 const sanitized = sanitizeBranchName(raw);
116 if (sanitized.includes("/")) {
117 return sanitized.startsWith("feature/") ? sanitized : `feature/${sanitized}`;
118 }
119 return `feature/${sanitized}`;
120}
121
12298const makeCodexTextGeneration = Effect.gen(function* () {
12399 const fileSystem = yield* FileSystem.FileSystem;
124100 const path = yield* Path.Path;
......@@ -475,7 +451,7 @@ const makeCodexTextGeneration = Effect.gen(function* () {
475451 });
476452
477453 return {
478 branch: sanitizeBranchName(generated.branch),
454 branch: sanitizeBranchFragment(generated.branch),
479455 } satisfies BranchNameGenerationResult;
480456 });
481457 };
apps/server/src/git/Layers/GitManager.ts+3-30
......@@ -1,6 +1,7 @@
11import { randomUUID } from "node:crypto";
22
33import { Effect, FileSystem, Layer, Path } from "effect";
4import { sanitizeFeatureBranchName } from "@t3tools/contracts";
45
56import { GitManagerError } from "../Errors.ts";
67import { GitManager, type GitManagerShape } from "../Services/GitManager.ts";
......@@ -104,32 +105,6 @@ function sanitizeCommitMessage(generated: {
104105 };
105106}
106107
107function sanitizeBranchFragment(raw: string): string {
108 const normalized = raw
109 .trim()
110 .toLowerCase()
111 .replace(/['"`]/g, "")
112 .replace(/^[./\s_-]+|[./\s_-]+$/g, "");
113
114 const branchFragment = normalized
115 .replace(/[^a-z0-9/_-]+/g, "-")
116 .replace(/\/+/g, "/")
117 .replace(/-+/g, "-")
118 .replace(/^[./_-]+|[./_-]+$/g, "")
119 .slice(0, 64)
120 .replace(/[./_-]+$/g, "");
121
122 return branchFragment.length > 0 ? branchFragment : "update";
123}
124
125function sanitizeFeatureBranchName(raw: string): string {
126 const sanitized = sanitizeBranchFragment(raw);
127 if (sanitized.includes("/")) {
128 return sanitized.startsWith("feature/") ? sanitized : `feature/${sanitized}`;
129 }
130 return `feature/${sanitized}`;
131}
132
133108function formatCommitMessage(subject: string, body: string): string {
134109 const trimmedBody = body.trim();
135110 if (trimmedBody.length === 0) {
......@@ -324,16 +299,14 @@ export const makeGitManager = Effect.gen(function* () {
324299 branch: input.branch,
325300 stagedSummary: limitContext(context.stagedSummary, 8_000),
326301 stagedPatch: limitContext(context.stagedPatch, 50_000),
327 ...(input.includeBranch !== undefined ? { includeBranch: input.includeBranch } : {}),
302 ...(input.includeBranch ? { includeBranch: true } : {}),
328303 })
329304 .pipe(Effect.map((result) => sanitizeCommitMessage(result)));
330305
331306 return {
332307 subject: generated.subject,
333308 body: generated.body,
334 ...(generated.branch !== undefined
335 ? { branch: sanitizeFeatureBranchName(generated.branch) }
336 : {}),
309 ...(generated.branch !== undefined ? { branch: generated.branch } : {}),
337310 commitMessage: formatCommitMessage(generated.subject, generated.body),
338311 };
339312 });
apps/web/src/components/GitActionsControl.logic.ts+5-22
......@@ -1,7 +1,8 @@
1import type {
2 GitRunStackedActionResult,
3 GitStackedAction,
4 GitStatusResult,
1import {
2 type GitRunStackedActionResult,
3 type GitStackedAction,
4 type GitStatusResult,
5 sanitizeBranchFragment,
56} from "@t3tools/contracts";
67
78export type GitActionIconName = "commit" | "push" | "pr";
......@@ -321,24 +322,6 @@ export function resolveDefaultBranchActionDialogCopy(input: {
321322 };
322323}
323324
324function sanitizeBranchFragment(raw: string): string {
325 const normalized = raw
326 .trim()
327 .toLowerCase()
328 .replace(/['"`]/g, "")
329 .replace(/^[./\s_-]+|[./\s_-]+$/g, "");
330
331 const branchFragment = normalized
332 .replace(/[^a-z0-9/_-]+/g, "-")
333 .replace(/\/+/g, "/")
334 .replace(/-+/g, "-")
335 .replace(/^[./_-]+|[./_-]+$/g, "")
336 .slice(0, 64)
337 .replace(/[./_-]+$/g, "");
338
339 return branchFragment.length > 0 ? branchFragment : "update";
340}
341
342325export function resolveAutoFeatureBranchName(
343326 existingBranchNames: readonly string[],
344327 preferredBranch?: string,
packages/contracts/src/git.ts+36
......@@ -179,3 +179,39 @@ export const GitPullResult = Schema.Struct({
179179 upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr),
180180});
181181export type GitPullResult = typeof GitPullResult.Type;
182
183// Branch name utilities
184
185/**
186 * Sanitize an arbitrary string into a valid, lowercase git branch fragment.
187 * Strips quotes, collapses separators, limits to 64 chars.
188 */
189export function sanitizeBranchFragment(raw: string): string {
190 const normalized = raw
191 .trim()
192 .toLowerCase()
193 .replace(/['"`]/g, "")
194 .replace(/^[./\s_-]+|[./\s_-]+$/g, "");
195
196 const branchFragment = normalized
197 .replace(/[^a-z0-9/_-]+/g, "-")
198 .replace(/\/+/g, "/")
199 .replace(/-+/g, "-")
200 .replace(/^[./_-]+|[./_-]+$/g, "")
201 .slice(0, 64)
202 .replace(/[./_-]+$/g, "");
203
204 return branchFragment.length > 0 ? branchFragment : "update";
205}
206
207/**
208 * Sanitize a string into a `feature/…` branch name.
209 * Preserves an existing `feature/` prefix or slash-separated namespace.
210 */
211export function sanitizeFeatureBranchName(raw: string): string {
212 const sanitized = sanitizeBranchFragment(raw);
213 if (sanitized.includes("/")) {
214 return sanitized.startsWith("feature/") ? sanitized : `feature/${sanitized}`;
215 }
216 return `feature/${sanitized}`;
217}