authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-11 23:59:44+00:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 23:59:44+00:00
log27839622efccb65982fd297847973fdedf6365aa
tree5ba714fb93854cf859f306f4774b6607f5063f52
parent1d58b82eadd043f7f7034023d81661affcf94a90
signature Signed by PGP key B5690EEEBB952194

fix(shared): preserve generated branch namespaces (#15615)


4 files changed, 121 insertions(+), 19 deletions(-)

apps/server/src/git/GitManager.test.ts+41
...@@ -3834,6 +3834,47 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {...@@ -3834,6 +3834,47 @@ it.layer(layerGitManagerTest)("GitManager", (it) => {
3834 }),3834 }),
3835 );3835 );
38363836
3837 it.effect.each([
3838 { branch: "fix/name", existingBranches: ["fix"], expected: "fix-2/name" },
3839 { branch: "fix/name", existingBranches: ["fix/name/child"], expected: "fix/name-2" },
3840 ])("commits on $expected when $branch has ref namespace collisions", (scenario) =>
3841 Effect.gen(function* () {
3842 const repoDir = yield* makeTempDir("t3code-git-manager-");
3843 yield* initRepo(repoDir);
3844 const mainSha = (yield* runGit(repoDir, ["rev-parse", "main"])).stdout.trim();
3845 for (const branch of scenario.existingBranches) {
3846 yield* runGit(repoDir, ["branch", branch]);
3847 }
3848 NodeFS.writeFileSync(NodePath.join(repoDir, "README.md"), "hello\nnamespace-collision\n");
3849
3850 const { manager } = yield* makeManager({
3851 textGeneration: {
3852 generateCommitMessage: () =>
3853 Effect.succeed({
3854 subject: "Fix namespace collision",
3855 body: "",
3856 branch: scenario.branch,
3857 }),
3858 },
3859 });
3860 const result = yield* runStackedAction(manager, {
3861 cwd: repoDir,
3862 action: "commit",
3863 featureBranch: true,
3864 });
3865
3866 expect(result.branch).toEqual({ status: "created", name: scenario.expected });
3867 expect(result.commit.status).toBe("created");
3868 expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe(
3869 scenario.expected,
3870 );
3871 expect((yield* runGit(repoDir, ["status", "--porcelain"])).stdout.trim()).toBe("");
3872 for (const branch of ["main", ...scenario.existingBranches]) {
3873 expect((yield* runGit(repoDir, ["rev-parse", branch])).stdout.trim()).toBe(mainSha);
3874 }
3875 }),
3876 );
3877
3837 it.effect("featureBranch uses custom commit message and derives branch name", () =>3878 it.effect("featureBranch uses custom commit message and derives branch name", () =>
3838 Effect.gen(function* () {3879 Effect.gen(function* () {
3839 const repoDir = yield* makeTempDir("t3code-git-manager-");3880 const repoDir = yield* makeTempDir("t3code-git-manager-");
apps/server/src/textGeneration/CodexTextGeneration.test.ts+1-1
...@@ -353,7 +353,7 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => {...@@ -353,7 +353,7 @@ it.layer(layerCodexTextGenerationTest)("CodexTextGeneration", (it) => {
353 });353 });
354354
355 expect(generated.subject).toBe("Add important change");355 expect(generated.subject).toBe("Add important change");
356 expect(generated.branch).toBe("feature/fix/important-system-change");356 expect(generated.branch).toBe("fix/important-system-change");
357 }),357 }),
358 ),358 ),
359 );359 );
packages/shared/src/git.test.ts+55
...@@ -11,6 +11,8 @@ import {...@@ -11,6 +11,8 @@ import {
11 normalizeGitRemoteUrl,11 normalizeGitRemoteUrl,
12 parseGitHubRepositoryNameWithOwnerFromRemoteUrl,12 parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
13 parseOriginUrlFromGitConfig,13 parseOriginUrlFromGitConfig,
14 resolveAutoFeatureBranchName,
15 sanitizeFeatureBranchName,
14 WORKTREE_BRANCH_PREFIX,16 WORKTREE_BRANCH_PREFIX,
15} from "./git.ts";17} from "./git.ts";
1618
...@@ -294,6 +296,59 @@ describe("applyGitStatusStreamEvent", () => {...@@ -294,6 +296,59 @@ describe("applyGitStatusStreamEvent", () => {
294 });296 });
295});297});
296298
299describe("sanitizeFeatureBranchName", () => {
300 it.each(["feature", "fix", "team/jules"])("preserves the %s namespace", (namespace) => {
301 expect(sanitizeFeatureBranchName(`${namespace}/refine-toolbar`)).toBe(
302 `${namespace}/refine-toolbar`,
303 );
304 });
305
306 it("sanitizes an explicit namespace before preserving it", () => {
307 expect(sanitizeFeatureBranchName(' "FIX//Calendar recruitment filter" ')).toBe(
308 "fix/calendar-recruitment-filter",
309 );
310 });
311
312 it.each([
313 ["refine toolbar", "feature/refine-toolbar"],
314 ["", "feature/update"],
315 [" /?. / ", "feature/update"],
316 ["fix///", "feature/fix"],
317 ])("keeps the fallback for unprefixed input %s", (input, expected) => {
318 expect(sanitizeFeatureBranchName(input)).toBe(expected);
319 });
320
321 it("keeps the existing length limit for namespaced branches", () => {
322 expect(sanitizeFeatureBranchName(`fix/${"x".repeat(80)}`)).toBe(`fix/${"x".repeat(60)}`);
323 });
324});
325
326describe("resolveAutoFeatureBranchName", () => {
327 it("resolves case-insensitive collisions within the generated namespace", () => {
328 expect(
329 resolveAutoFeatureBranchName(
330 ["FIX/refine-toolbar", "fix/refine-toolbar-2"],
331 "fix/refine-toolbar",
332 ),
333 ).toBe("fix/refine-toolbar-3");
334 });
335
336 it("keeps the fallback when no preferred branch is supplied", () => {
337 expect(resolveAutoFeatureBranchName(["feature/update"])).toBe("feature/update-2");
338 });
339
340 it.each([
341 ["fix/name", ["fix"], "fix-2/name"],
342 ["team/jules/fix/name", ["team/jules"], "team/jules-2/fix/name"],
343 ["fix/name", ["fix/name/child"], "fix/name-2"],
344 ["FIX/name", ["FIX", "FIX-2"], "fix-3/name"],
345 ["fix/name", ["fixes", "fix/name-extra/child"], "fix/name"],
346 [undefined, ["feature", "feature-2"], "feature-3/update"],
347 ])("resolves %s against namespace blockers %j", (preferredBranch, existingNames, expected) => {
348 expect(resolveAutoFeatureBranchName(existingNames, preferredBranch)).toBe(expected);
349 });
350});
351
297describe("formatGeneratedBranchName", () => {352describe("formatGeneratedBranchName", () => {
298 it.each(["t3code", "t3code/"])("joins static prefix %s with one slash", (prefix) => {353 it.each(["t3code", "t3code/"])("joins static prefix %s with one slash", (prefix) => {
299 expect(354 expect(
packages/shared/src/git.ts+24-18
...@@ -66,22 +66,19 @@ export function formatGeneratedBranchName(raw: string, naming?: BranchNamingOpti...@@ -66,22 +66,19 @@ export function formatGeneratedBranchName(raw: string, naming?: BranchNamingOpti
66}66}
6767
68/**68/**
69 * Sanitize a string into a `feature/…` refName name.69 * Sanitize a generated branch name, preserving any slash-separated namespace.
70 * Preserves an existing `feature/` prefix or slash-separated namespace.70 * Unprefixed fragments use the `feature/` namespace.
71 */71 */
72export function sanitizeFeatureBranchName(raw: string): string {72export function sanitizeFeatureBranchName(raw: string): string {
73 const sanitized = sanitizeBranchFragment(raw);73 const sanitized = sanitizeBranchFragment(raw);
74 if (sanitized.includes("/")) {74 return sanitized.includes("/") ? sanitized : `feature/${sanitized}`;
75 return sanitized.startsWith("feature/") ? sanitized : `feature/${sanitized}`;
76 }
77 return `feature/${sanitized}`;
78}75}
7976
80const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update";77const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update";
8178
82/**79/**
83 * Resolve a unique `feature/…` refName name that doesn't collide with80 * Resolve a unique generated refName, suffixing only the path component
84 * any existing refName. Appends a numeric suffix when needed.81 * blocked by an existing ref or, for the final component, its descendants.
85 */82 */
86export function resolveAutoFeatureBranchName(83export function resolveAutoFeatureBranchName(
87 existingBranchNames: readonly string[],84 existingBranchNames: readonly string[],
...@@ -91,18 +88,27 @@ export function resolveAutoFeatureBranchName(...@@ -91,18 +88,27 @@ export function resolveAutoFeatureBranchName(
91 const resolvedBase = sanitizeFeatureBranchName(88 const resolvedBase = sanitizeFeatureBranchName(
92 preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK,89 preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK,
93 );90 );
94 const existingNames = new Set(existingBranchNames.map((refName) => refName.toLowerCase()));91 const existingNames = existingBranchNames.map((refName) => refName.toLowerCase());
9592 const existingRefs = new Set(existingNames);
96 if (!existingNames.has(resolvedBase)) {93 const parts = resolvedBase.split("/");
97 return resolvedBase;94 let resolved = "";
98 }95
9996 for (const [index, part] of parts.entries()) {
100 let suffix = 2;97 const base = resolved ? `${resolved}/${part}` : part;
101 while (existingNames.has(`${resolvedBase}-${suffix}`)) {98 let candidate = base;
102 suffix += 1;99 let suffix = 2;
100 while (
101 existingRefs.has(candidate) ||
102 (index === parts.length - 1 &&
103 existingNames.some((refName) => refName.startsWith(`${candidate}/`)))
104 ) {
105 candidate = `${base}-${suffix}`;
106 suffix += 1;
107 }
108 resolved = candidate;
103 }109 }
104110
105 return `${resolvedBase}-${suffix}`;111 return resolved;
106}112}
107113
108/**114/**