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) => {
38343834 }),
38353835 );
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
38373878 it.effect("featureBranch uses custom commit message and derives branch name", () =>
38383879 Effect.gen(function* () {
38393880 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) => {
353353 });
354354
355355 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");
357357 }),
358358 ),
359359 );
packages/shared/src/git.test.ts+55
......@@ -11,6 +11,8 @@ import {
1111 normalizeGitRemoteUrl,
1212 parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
1313 parseOriginUrlFromGitConfig,
14 resolveAutoFeatureBranchName,
15 sanitizeFeatureBranchName,
1416 WORKTREE_BRANCH_PREFIX,
1517} from "./git.ts";
1618
......@@ -294,6 +296,59 @@ describe("applyGitStatusStreamEvent", () => {
294296 });
295297});
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
297352describe("formatGeneratedBranchName", () => {
298353 it.each(["t3code", "t3code/"])("joins static prefix %s with one slash", (prefix) => {
299354 expect(
packages/shared/src/git.ts+24-18
......@@ -66,22 +66,19 @@ export function formatGeneratedBranchName(raw: string, naming?: BranchNamingOpti
6666}
6767
6868/**
69 * Sanitize a string into a `feature/…` refName name.
70 * Preserves an existing `feature/` prefix or slash-separated namespace.
69 * Sanitize a generated branch name, preserving any slash-separated namespace.
70 * Unprefixed fragments use the `feature/` namespace.
7171 */
7272export function sanitizeFeatureBranchName(raw: string): string {
7373 const sanitized = sanitizeBranchFragment(raw);
74 if (sanitized.includes("/")) {
75 return sanitized.startsWith("feature/") ? sanitized : `feature/${sanitized}`;
76 }
77 return `feature/${sanitized}`;
74 return sanitized.includes("/") ? sanitized : `feature/${sanitized}`;
7875}
7976
8077const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update";
8178
8279/**
83 * Resolve a unique `feature/…` refName name that doesn't collide with
84 * any existing refName. Appends a numeric suffix when needed.
80 * Resolve a unique generated refName, suffixing only the path component
81 * blocked by an existing ref or, for the final component, its descendants.
8582 */
8683export function resolveAutoFeatureBranchName(
8784 existingBranchNames: readonly string[],
......@@ -91,18 +88,27 @@ export function resolveAutoFeatureBranchName(
9188 const resolvedBase = sanitizeFeatureBranchName(
9289 preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK,
9390 );
94 const existingNames = new Set(existingBranchNames.map((refName) => refName.toLowerCase()));
95
96 if (!existingNames.has(resolvedBase)) {
97 return resolvedBase;
98 }
99
100 let suffix = 2;
101 while (existingNames.has(`${resolvedBase}-${suffix}`)) {
102 suffix += 1;
91 const existingNames = existingBranchNames.map((refName) => refName.toLowerCase());
92 const existingRefs = new Set(existingNames);
93 const parts = resolvedBase.split("/");
94 let resolved = "";
95
96 for (const [index, part] of parts.entries()) {
97 const base = resolved ? `${resolved}/${part}` : part;
98 let candidate = base;
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;
103109 }
104110
105 return `${resolvedBase}-${suffix}`;
111 return resolved;
106112}
107113
108114/**