CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
branch-rename.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. | |
| 3 | * | |
| 4 | * Pure helpers for validating + planning a branch rename. The actual git | |
| 5 | * work + the DB cascades (pull_requests, branch_protection, merge_queue, | |
| 6 | * default branch) live in the route — this file has no IO and is safe to | |
| 7 | * unit test. | |
| 8 | * | |
| 9 | * Validation follows `git-check-ref-format(1)`. Rules (strict): | |
| 10 | * | |
| 11 | * - must be non-empty, at most `MAX_BRANCH_NAME_LENGTH` chars | |
| 12 | * - can't start or end with `/` | |
| 13 | * - can't contain `//` | |
| 14 | * - can't contain `..`, `@{`, a bare `@`, or any of ``` ~^:?*[\ ``` | |
| 15 | * - can't contain ASCII control chars (0x00–0x1F, DEL) | |
| 16 | * - can't start with `-` or `.` | |
| 17 | * - can't end with `.` or `.lock` | |
| 18 | * - each slash-separated component must be non-empty, can't begin or end | |
| 19 | * with `.`, and can't end with `.lock` | |
| 20 | * | |
| 21 | * These mirror what `git check-ref-format --branch` rejects. We do NOT | |
| 22 | * allow a single `@` (git's own rule). | |
| 23 | */ | |
| 24 | ||
| 25 | export const MAX_BRANCH_NAME_LENGTH = 250; | |
| 26 | ||
| 27 | export type ValidateBranchResult = | |
| 28 | | { ok: true } | |
| 29 | | { ok: false; reason: BranchValidationReason }; | |
| 30 | ||
| 31 | export type BranchValidationReason = | |
| 32 | | "not_string" | |
| 33 | | "empty" | |
| 34 | | "too_long" | |
| 35 | | "slash_boundary" | |
| 36 | | "leading_dash" | |
| 37 | | "dot_boundary" | |
| 38 | | "consecutive_slashes" | |
| 39 | | "double_dot" | |
| 40 | | "at_brace" | |
| 41 | | "only_at" | |
| 42 | | "lock_suffix" | |
| 43 | | "forbidden_char" | |
| 44 | | "control_char" | |
| 45 | | "empty_component" | |
| 46 | | "dot_component" | |
| 47 | | "lock_component"; | |
| 48 | ||
| 49 | const FORBIDDEN_RE = /[\s~^:?*\[\\\x7f]/; | |
| 50 | const CONTROL_RE = /[\x00-\x1f]/; | |
| 51 | ||
| 52 | export function validateBranchName(name: unknown): ValidateBranchResult { | |
| 53 | if (typeof name !== "string") return { ok: false, reason: "not_string" }; | |
| 54 | const n = name; | |
| 55 | if (n.length === 0) return { ok: false, reason: "empty" }; | |
| 56 | if (n.length > MAX_BRANCH_NAME_LENGTH) | |
| 57 | return { ok: false, reason: "too_long" }; | |
| 58 | if (n === "@") return { ok: false, reason: "only_at" }; | |
| 59 | if (n.startsWith("/") || n.endsWith("/")) | |
| 60 | return { ok: false, reason: "slash_boundary" }; | |
| 61 | if (n.startsWith("-")) return { ok: false, reason: "leading_dash" }; | |
| 62 | if (n.startsWith(".") || n.endsWith(".")) | |
| 63 | return { ok: false, reason: "dot_boundary" }; | |
| 64 | if (n.includes("//")) return { ok: false, reason: "consecutive_slashes" }; | |
| 65 | if (n.includes("..")) return { ok: false, reason: "double_dot" }; | |
| 66 | if (n.includes("@{")) return { ok: false, reason: "at_brace" }; | |
| 67 | if (n.endsWith(".lock")) return { ok: false, reason: "lock_suffix" }; | |
| 68 | if (CONTROL_RE.test(n)) return { ok: false, reason: "control_char" }; | |
| 69 | if (FORBIDDEN_RE.test(n)) return { ok: false, reason: "forbidden_char" }; | |
| 70 | ||
| 71 | const parts = n.split("/"); | |
| 72 | for (const p of parts) { | |
| 73 | if (p.length === 0) return { ok: false, reason: "empty_component" }; | |
| 74 | if (p.startsWith(".") || p.endsWith(".")) | |
| 75 | return { ok: false, reason: "dot_component" }; | |
| 76 | if (p.endsWith(".lock")) return { ok: false, reason: "lock_component" }; | |
| 77 | } | |
| 78 | return { ok: true }; | |
| 79 | } | |
| 80 | ||
| 81 | export function branchValidationMessage( | |
| 82 | r: BranchValidationReason | |
| 83 | ): string { | |
| 84 | switch (r) { | |
| 85 | case "not_string": | |
| 86 | case "empty": | |
| 87 | return "Branch name is required."; | |
| 88 | case "too_long": | |
| 89 | return `Branch name must be ${MAX_BRANCH_NAME_LENGTH} characters or fewer.`; | |
| 90 | case "slash_boundary": | |
| 91 | return "Branch name cannot start or end with '/'."; | |
| 92 | case "leading_dash": | |
| 93 | return "Branch name cannot start with '-'."; | |
| 94 | case "dot_boundary": | |
| 95 | return "Branch name cannot start or end with '.'."; | |
| 96 | case "consecutive_slashes": | |
| 97 | return "Branch name cannot contain '//'."; | |
| 98 | case "double_dot": | |
| 99 | return "Branch name cannot contain '..'."; | |
| 100 | case "at_brace": | |
| 101 | return "Branch name cannot contain '@{'."; | |
| 102 | case "only_at": | |
| 103 | return "Branch name cannot be '@'."; | |
| 104 | case "lock_suffix": | |
| 105 | case "lock_component": | |
| 106 | return "Branch name components cannot end with '.lock'."; | |
| 107 | case "forbidden_char": | |
| 108 | return "Branch name cannot contain whitespace or any of ~ ^ : ? * [ \\."; | |
| 109 | case "control_char": | |
| 110 | return "Branch name cannot contain control characters."; | |
| 111 | case "empty_component": | |
| 112 | return "Branch name cannot contain an empty path component."; | |
| 113 | case "dot_component": | |
| 114 | return "Branch name components cannot start or end with '.'."; | |
| 115 | } | |
| 116 | } | |
| 117 | ||
| 118 | export interface PlanRenameInput { | |
| 119 | from: string; | |
| 120 | to: string; | |
| 121 | existingBranches: readonly string[]; | |
| 122 | defaultBranch: string | null; | |
| 123 | } | |
| 124 | ||
| 125 | export type PlanRenameResult = | |
| 126 | | { | |
| 127 | ok: true; | |
| 128 | from: string; | |
| 129 | to: string; | |
| 130 | /** True when renaming the repo's default branch. */ | |
| 131 | updatesDefault: boolean; | |
| 132 | } | |
| 133 | | { | |
| 134 | ok: false; | |
| 135 | reason: | |
| 136 | | "same_name" | |
| 137 | | "invalid_from" | |
| 138 | | "invalid_to" | |
| 139 | | "from_missing" | |
| 140 | | "to_exists"; | |
| 141 | detail?: BranchValidationReason; | |
| 142 | }; | |
| 143 | ||
| 144 | /** | |
| 145 | * Compute whether `from → to` is a legal rename given the current state. | |
| 146 | * Never performs IO. Case-sensitivity matches git (refs are | |
| 147 | * case-sensitive, so "Main" and "main" are different branches). | |
| 148 | */ | |
| 149 | export function planRename(input: PlanRenameInput): PlanRenameResult { | |
| 150 | const { from, to, existingBranches, defaultBranch } = input; | |
| 151 | ||
| 152 | const vFrom = validateBranchName(from); | |
| 153 | if (!vFrom.ok) | |
| 154 | return { ok: false, reason: "invalid_from", detail: vFrom.reason }; | |
| 155 | const vTo = validateBranchName(to); | |
| 156 | if (!vTo.ok) | |
| 157 | return { ok: false, reason: "invalid_to", detail: vTo.reason }; | |
| 158 | ||
| 159 | if (from === to) return { ok: false, reason: "same_name" }; | |
| 160 | ||
| 161 | const existing = new Set(existingBranches); | |
| 162 | if (!existing.has(from)) return { ok: false, reason: "from_missing" }; | |
| 163 | if (existing.has(to)) return { ok: false, reason: "to_exists" }; | |
| 164 | ||
| 165 | return { | |
| 166 | ok: true, | |
| 167 | from, | |
| 168 | to, | |
| 169 | updatesDefault: defaultBranch === from, | |
| 170 | }; | |
| 171 | } | |
| 172 | ||
| 173 | /** | |
| 174 | * Given a glob/exact branch-protection pattern and the rename, decide | |
| 175 | * whether the pattern itself should be updated. Only exact (non-glob) | |
| 176 | * matches are rewritten — globs like `release/*` are left alone because | |
| 177 | * the rename doesn't change how they match the namespace. | |
| 178 | */ | |
| 179 | export function shouldRewriteProtectionPattern( | |
| 180 | pattern: string, | |
| 181 | from: string | |
| 182 | ): boolean { | |
| 183 | if (pattern !== from) return false; | |
| 184 | // Conservative: only exact matches, no glob characters present. | |
| 185 | if (/[*?\[]/.test(pattern)) return false; | |
| 186 | return true; | |
| 187 | } | |
| 188 | ||
| 189 | export const __internal = { | |
| 190 | MAX_BRANCH_NAME_LENGTH, | |
| 191 | FORBIDDEN_RE, | |
| 192 | CONTROL_RE, | |
| 193 | validateBranchName, | |
| 194 | branchValidationMessage, | |
| 195 | planRename, | |
| 196 | shouldRewriteProtectionPattern, | |
| 197 | }; |