Blame · Line-by-line history
branch-rename.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| d6daf49 | 1 | /** |
| 2 | * Block J24 — Branch rename. Pure validation + plan tests. | |
| 3 | */ | |
| 4 | ||
| 5 | import { describe, it, expect } from "bun:test"; | |
| 6 | import { | |
| 7 | MAX_BRANCH_NAME_LENGTH, | |
| 8 | validateBranchName, | |
| 9 | branchValidationMessage, | |
| 10 | planRename, | |
| 11 | shouldRewriteProtectionPattern, | |
| 12 | __internal, | |
| 13 | } from "../lib/branch-rename"; | |
| 14 | ||
| 15 | describe("branch-rename — validateBranchName (valid cases)", () => { | |
| 16 | it("accepts simple names", () => { | |
| 17 | expect(validateBranchName("main").ok).toBe(true); | |
| 18 | expect(validateBranchName("develop").ok).toBe(true); | |
| 19 | expect(validateBranchName("release").ok).toBe(true); | |
| 20 | }); | |
| 21 | ||
| 22 | it("accepts slash-separated components", () => { | |
| 23 | expect(validateBranchName("feat/login").ok).toBe(true); | |
| 24 | expect(validateBranchName("release/v1.0").ok).toBe(true); | |
| 25 | expect(validateBranchName("release/v1.0.0-rc.1").ok).toBe(true); | |
| 26 | }); | |
| 27 | ||
| 28 | it("accepts numeric + underscore + hyphen", () => { | |
| 29 | expect(validateBranchName("123abc").ok).toBe(true); | |
| 30 | expect(validateBranchName("snake_case_branch").ok).toBe(true); | |
| 31 | expect(validateBranchName("kebab-case-branch").ok).toBe(true); | |
| 32 | }); | |
| 33 | ||
| 34 | it("accepts names up to MAX_BRANCH_NAME_LENGTH", () => { | |
| 35 | const maxName = "a".repeat(MAX_BRANCH_NAME_LENGTH); | |
| 36 | expect(validateBranchName(maxName).ok).toBe(true); | |
| 37 | }); | |
| 38 | ||
| 39 | it("accepts single dots inside components", () => { | |
| 40 | expect(validateBranchName("v1.0.0").ok).toBe(true); | |
| 41 | }); | |
| 42 | }); | |
| 43 | ||
| 44 | describe("branch-rename — validateBranchName (invalid cases)", () => { | |
| 45 | it("rejects non-string", () => { | |
| 46 | expect(validateBranchName(null).ok).toBe(false); | |
| 47 | expect(validateBranchName(undefined).ok).toBe(false); | |
| 48 | expect(validateBranchName(123).ok).toBe(false); | |
| 49 | }); | |
| 50 | ||
| 51 | it("rejects empty string", () => { | |
| 52 | const r = validateBranchName(""); | |
| 53 | expect(r.ok).toBe(false); | |
| 54 | if (!r.ok) expect(r.reason).toBe("empty"); | |
| 55 | }); | |
| 56 | ||
| 57 | it("rejects too-long names", () => { | |
| 58 | const r = validateBranchName("a".repeat(MAX_BRANCH_NAME_LENGTH + 1)); | |
| 59 | expect(r.ok).toBe(false); | |
| 60 | if (!r.ok) expect(r.reason).toBe("too_long"); | |
| 61 | }); | |
| 62 | ||
| 63 | it("rejects a bare '@'", () => { | |
| 64 | const r = validateBranchName("@"); | |
| 65 | expect(r.ok).toBe(false); | |
| 66 | if (!r.ok) expect(r.reason).toBe("only_at"); | |
| 67 | }); | |
| 68 | ||
| 69 | it("rejects '@{'", () => { | |
| 70 | const r = validateBranchName("foo@{1}"); | |
| 71 | expect(r.ok).toBe(false); | |
| 72 | if (!r.ok) expect(r.reason).toBe("at_brace"); | |
| 73 | }); | |
| 74 | ||
| 75 | it("rejects leading or trailing '/'", () => { | |
| 76 | expect(validateBranchName("/foo").ok).toBe(false); | |
| 77 | expect(validateBranchName("foo/").ok).toBe(false); | |
| 78 | }); | |
| 79 | ||
| 80 | it("rejects '//' anywhere", () => { | |
| 81 | expect(validateBranchName("foo//bar").ok).toBe(false); | |
| 82 | }); | |
| 83 | ||
| 84 | it("rejects leading '-'", () => { | |
| 85 | const r = validateBranchName("-no"); | |
| 86 | expect(r.ok).toBe(false); | |
| 87 | if (!r.ok) expect(r.reason).toBe("leading_dash"); | |
| 88 | }); | |
| 89 | ||
| 90 | it("rejects leading or trailing '.'", () => { | |
| 91 | expect(validateBranchName(".foo").ok).toBe(false); | |
| 92 | expect(validateBranchName("foo.").ok).toBe(false); | |
| 93 | }); | |
| 94 | ||
| 95 | it("rejects '..'", () => { | |
| 96 | const r = validateBranchName("foo..bar"); | |
| 97 | expect(r.ok).toBe(false); | |
| 98 | if (!r.ok) expect(r.reason).toBe("double_dot"); | |
| 99 | }); | |
| 100 | ||
| 101 | it("rejects '.lock' suffix", () => { | |
| 102 | expect(validateBranchName("foo.lock").ok).toBe(false); | |
| 103 | }); | |
| 104 | ||
| 105 | it("rejects '.lock' on a component", () => { | |
| 106 | const r = validateBranchName("release/v1.lock"); | |
| 107 | expect(r.ok).toBe(false); | |
| 108 | if (!r.ok) { | |
| 109 | // either `lock_suffix` (whole name) or `lock_component` — both valid | |
| 110 | expect(["lock_suffix", "lock_component"]).toContain(r.reason); | |
| 111 | } | |
| 112 | }); | |
| 113 | ||
| 114 | it("rejects whitespace", () => { | |
| 115 | expect(validateBranchName("has space").ok).toBe(false); | |
| 116 | expect(validateBranchName("has\ttab").ok).toBe(false); | |
| 117 | }); | |
| 118 | ||
| 119 | it("rejects forbidden characters", () => { | |
| 120 | for (const ch of ["~", "^", ":", "?", "*", "[", "\\"]) { | |
| 121 | expect(validateBranchName(`foo${ch}bar`).ok).toBe(false); | |
| 122 | } | |
| 123 | }); | |
| 124 | ||
| 125 | it("rejects control characters", () => { | |
| 126 | expect(validateBranchName("foo\u0001bar").ok).toBe(false); | |
| 127 | expect(validateBranchName("foo\u001fbar").ok).toBe(false); | |
| 128 | expect(validateBranchName("foo\u007fbar").ok).toBe(false); | |
| 129 | }); | |
| 130 | ||
| 131 | it("rejects component starting or ending with '.'", () => { | |
| 132 | expect(validateBranchName("release/.hidden").ok).toBe(false); | |
| 133 | expect(validateBranchName("release/trailing.").ok).toBe(false); | |
| 134 | }); | |
| 135 | }); | |
| 136 | ||
| 137 | describe("branch-rename — branchValidationMessage", () => { | |
| 138 | it("returns non-empty strings for every reason code", () => { | |
| 139 | const reasons = [ | |
| 140 | "not_string", | |
| 141 | "empty", | |
| 142 | "too_long", | |
| 143 | "slash_boundary", | |
| 144 | "leading_dash", | |
| 145 | "dot_boundary", | |
| 146 | "consecutive_slashes", | |
| 147 | "double_dot", | |
| 148 | "at_brace", | |
| 149 | "only_at", | |
| 150 | "lock_suffix", | |
| 151 | "forbidden_char", | |
| 152 | "control_char", | |
| 153 | "empty_component", | |
| 154 | "dot_component", | |
| 155 | "lock_component", | |
| 156 | ] as const; | |
| 157 | for (const r of reasons) { | |
| 158 | const msg = branchValidationMessage(r); | |
| 159 | expect(typeof msg).toBe("string"); | |
| 160 | expect(msg.length).toBeGreaterThan(0); | |
| 161 | } | |
| 162 | }); | |
| 163 | }); | |
| 164 | ||
| 165 | describe("branch-rename — planRename", () => { | |
| 166 | const existing = ["main", "develop", "feat/login"]; | |
| 167 | ||
| 168 | it("approves a valid rename", () => { | |
| 169 | const r = planRename({ | |
| 170 | from: "feat/login", | |
| 171 | to: "feat/auth", | |
| 172 | existingBranches: existing, | |
| 173 | defaultBranch: "main", | |
| 174 | }); | |
| 175 | expect(r.ok).toBe(true); | |
| 176 | if (r.ok) { | |
| 177 | expect(r.from).toBe("feat/login"); | |
| 178 | expect(r.to).toBe("feat/auth"); | |
| 179 | expect(r.updatesDefault).toBe(false); | |
| 180 | } | |
| 181 | }); | |
| 182 | ||
| 183 | it("flags rename of the default branch", () => { | |
| 184 | const r = planRename({ | |
| 185 | from: "main", | |
| 186 | to: "trunk", | |
| 187 | existingBranches: existing, | |
| 188 | defaultBranch: "main", | |
| 189 | }); | |
| 190 | expect(r.ok).toBe(true); | |
| 191 | if (r.ok) expect(r.updatesDefault).toBe(true); | |
| 192 | }); | |
| 193 | ||
| 194 | it("rejects same-name rename", () => { | |
| 195 | const r = planRename({ | |
| 196 | from: "main", | |
| 197 | to: "main", | |
| 198 | existingBranches: existing, | |
| 199 | defaultBranch: "main", | |
| 200 | }); | |
| 201 | expect(r.ok).toBe(false); | |
| 202 | if (!r.ok) expect(r.reason).toBe("same_name"); | |
| 203 | }); | |
| 204 | ||
| 205 | it("rejects missing source branch", () => { | |
| 206 | const r = planRename({ | |
| 207 | from: "ghost", | |
| 208 | to: "new", | |
| 209 | existingBranches: existing, | |
| 210 | defaultBranch: "main", | |
| 211 | }); | |
| 212 | expect(r.ok).toBe(false); | |
| 213 | if (!r.ok) expect(r.reason).toBe("from_missing"); | |
| 214 | }); | |
| 215 | ||
| 216 | it("rejects when destination already exists", () => { | |
| 217 | const r = planRename({ | |
| 218 | from: "feat/login", | |
| 219 | to: "develop", | |
| 220 | existingBranches: existing, | |
| 221 | defaultBranch: "main", | |
| 222 | }); | |
| 223 | expect(r.ok).toBe(false); | |
| 224 | if (!r.ok) expect(r.reason).toBe("to_exists"); | |
| 225 | }); | |
| 226 | ||
| 227 | it("rejects invalid source name", () => { | |
| 228 | const r = planRename({ | |
| 229 | from: "bad..name", | |
| 230 | to: "ok", | |
| 231 | existingBranches: existing, | |
| 232 | defaultBranch: "main", | |
| 233 | }); | |
| 234 | expect(r.ok).toBe(false); | |
| 235 | if (!r.ok) { | |
| 236 | expect(r.reason).toBe("invalid_from"); | |
| 237 | expect(r.detail).toBe("double_dot"); | |
| 238 | } | |
| 239 | }); | |
| 240 | ||
| 241 | it("rejects invalid destination name", () => { | |
| 242 | const r = planRename({ | |
| 243 | from: "main", | |
| 244 | to: "has space", | |
| 245 | existingBranches: existing, | |
| 246 | defaultBranch: "main", | |
| 247 | }); | |
| 248 | expect(r.ok).toBe(false); | |
| 249 | if (!r.ok) { | |
| 250 | expect(r.reason).toBe("invalid_to"); | |
| 251 | expect(r.detail).toBe("forbidden_char"); | |
| 252 | } | |
| 253 | }); | |
| 254 | ||
| 255 | it("is case-sensitive ('Main' and 'main' are different)", () => { | |
| 256 | const r = planRename({ | |
| 257 | from: "main", | |
| 258 | to: "Main", | |
| 259 | existingBranches: ["main"], | |
| 260 | defaultBranch: "main", | |
| 261 | }); | |
| 262 | expect(r.ok).toBe(true); | |
| 263 | }); | |
| 264 | ||
| 265 | it("handles empty existingBranches", () => { | |
| 266 | const r = planRename({ | |
| 267 | from: "main", | |
| 268 | to: "trunk", | |
| 269 | existingBranches: [], | |
| 270 | defaultBranch: null, | |
| 271 | }); | |
| 272 | expect(r.ok).toBe(false); | |
| 273 | if (!r.ok) expect(r.reason).toBe("from_missing"); | |
| 274 | }); | |
| 275 | ||
| 276 | it("does not mark updatesDefault when defaultBranch is null", () => { | |
| 277 | const r = planRename({ | |
| 278 | from: "main", | |
| 279 | to: "trunk", | |
| 280 | existingBranches: ["main"], | |
| 281 | defaultBranch: null, | |
| 282 | }); | |
| 283 | expect(r.ok).toBe(true); | |
| 284 | if (r.ok) expect(r.updatesDefault).toBe(false); | |
| 285 | }); | |
| 286 | }); | |
| 287 | ||
| 288 | describe("branch-rename — shouldRewriteProtectionPattern", () => { | |
| 289 | it("rewrites an exact match", () => { | |
| 290 | expect(shouldRewriteProtectionPattern("main", "main")).toBe(true); | |
| 291 | }); | |
| 292 | ||
| 293 | it("does not rewrite a different exact", () => { | |
| 294 | expect(shouldRewriteProtectionPattern("main", "develop")).toBe(false); | |
| 295 | }); | |
| 296 | ||
| 297 | it("does not rewrite globs even if they match", () => { | |
| 298 | expect(shouldRewriteProtectionPattern("release/*", "release/*")).toBe( | |
| 299 | false | |
| 300 | ); | |
| 301 | }); | |
| 302 | ||
| 303 | it("does not rewrite ? or [ globs", () => { | |
| 304 | expect(shouldRewriteProtectionPattern("feat/?", "feat/?")).toBe(false); | |
| 305 | expect(shouldRewriteProtectionPattern("feat/[abc]", "feat/[abc]")).toBe( | |
| 306 | false | |
| 307 | ); | |
| 308 | }); | |
| 309 | }); | |
| 310 | ||
| 311 | describe("branch-rename — routes", () => { | |
| 312 | it("GET /settings/branches redirects unauthenticated users", async () => { | |
| 313 | const { default: app } = await import("../app"); | |
| 314 | const res = await app.request("/alice/repo/settings/branches"); | |
| 315 | expect([302, 401, 403, 404]).toContain(res.status); | |
| 316 | }); | |
| 317 | ||
| 318 | it("POST rename redirects unauthenticated users", async () => { | |
| 319 | const { default: app } = await import("../app"); | |
| 320 | const form = new FormData(); | |
| 321 | form.append("from", "main"); | |
| 322 | form.append("to", "trunk"); | |
| 323 | const res = await app.request( | |
| 324 | "/alice/repo/settings/branches/rename", | |
| 325 | { method: "POST", body: form } | |
| 326 | ); | |
| 327 | expect([302, 401, 403, 404]).toContain(res.status); | |
| 328 | }); | |
| 329 | }); | |
| 330 | ||
| 331 | describe("branch-rename — __internal parity", () => { | |
| 332 | it("re-exports helpers", () => { | |
| 333 | expect(__internal.validateBranchName).toBe(validateBranchName); | |
| 334 | expect(__internal.planRename).toBe(planRename); | |
| 335 | expect(__internal.branchValidationMessage).toBe(branchValidationMessage); | |
| 336 | expect(__internal.shouldRewriteProtectionPattern).toBe( | |
| 337 | shouldRewriteProtectionPattern | |
| 338 | ); | |
| 339 | expect(__internal.MAX_BRANCH_NAME_LENGTH).toBe(MAX_BRANCH_NAME_LENGTH); | |
| 340 | }); | |
| 341 | }); |