| author | |
| committer | |
| log | 27839622efccb65982fd297847973fdedf6365aa |
| tree | 5ba714fb93854cf859f306f4774b6607f5063f52 |
| parent | 1d58b82eadd043f7f7034023d81661affcf94a90 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 | 3834 | }), |
| 3835 | 3835 | ); |
| 3836 | 3836 | |
| 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 | 3878 | it.effect("featureBranch uses custom commit message and derives branch name", () => |
| 3838 | 3879 | Effect.gen(function* () { |
| 3839 | 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 | 353 | }); |
| 354 | 354 | |
| 355 | 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 | 11 | normalizeGitRemoteUrl, |
| 12 | 12 | parseGitHubRepositoryNameWithOwnerFromRemoteUrl, |
| 13 | 13 | parseOriginUrlFromGitConfig, |
| 14 | resolveAutoFeatureBranchName, | |
| 15 | sanitizeFeatureBranchName, | |
| 14 | 16 | WORKTREE_BRANCH_PREFIX, |
| 15 | 17 | } from "./git.ts"; |
| 16 | 18 | |
| ... | ... | @@ -294,6 +296,59 @@ describe("applyGitStatusStreamEvent", () => { |
| 294 | 296 | }); |
| 295 | 297 | }); |
| 296 | 298 | |
| 299 | describe("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 | ||
| 326 | describe("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 | ||
| 297 | 352 | describe("formatGeneratedBranchName", () => { |
| 298 | 353 | it.each(["t3code", "t3code/"])("joins static prefix %s with one slash", (prefix) => { |
| 299 | 354 | expect( |
packages/shared/src/git.ts+24-18| ... | ... | @@ -66,22 +66,19 @@ export function formatGeneratedBranchName(raw: string, naming?: BranchNamingOpti |
| 66 | 66 | } |
| 67 | 67 | |
| 68 | 68 | /** |
| 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. | |
| 71 | 71 | */ |
| 72 | 72 | export function sanitizeFeatureBranchName(raw: string): string { |
| 73 | 73 | 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}`; | |
| 78 | 75 | } |
| 79 | 76 | |
| 80 | 77 | const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update"; |
| 81 | 78 | |
| 82 | 79 | /** |
| 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. | |
| 85 | 82 | */ |
| 86 | 83 | export function resolveAutoFeatureBranchName( |
| 87 | 84 | existingBranchNames: readonly string[], |
| ... | ... | @@ -91,18 +88,27 @@ export function resolveAutoFeatureBranchName( |
| 91 | 88 | const resolvedBase = sanitizeFeatureBranchName( |
| 92 | 89 | preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK, |
| 93 | 90 | ); |
| 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; | |
| 103 | 109 | } |
| 104 | 110 | |
| 105 | return `${resolvedBase}-${suffix}`; | |
| 111 | return resolved; | |
| 106 | 112 | } |
| 107 | 113 | |
| 108 | 114 | /** |