Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
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.

branch-rename.tsBlame197 lines · 1 contributor
d6daf49Claude1/**
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
25export const MAX_BRANCH_NAME_LENGTH = 250;
26
27export type ValidateBranchResult =
28 | { ok: true }
29 | { ok: false; reason: BranchValidationReason };
30
31export 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
49const FORBIDDEN_RE = /[\s~^:?*\[\\\x7f]/;
50const CONTROL_RE = /[\x00-\x1f]/;
51
52export 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
81export 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
118export interface PlanRenameInput {
119 from: string;
120 to: string;
121 existingBranches: readonly string[];
122 defaultBranch: string | null;
123}
124
125export 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 */
149export 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 */
179export 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
189export const __internal = {
190 MAX_BRANCH_NAME_LENGTH,
191 FORBIDDEN_RE,
192 CONTROL_RE,
193 validateBranchName,
194 branchValidationMessage,
195 planRename,
196 shouldRewriteProtectionPattern,
197};