Skip to content
Source
Sign inSign up
Open History to see the last commit

git.ts

382 lines12.3 KB

1import type {
2 BranchNamingOptions,
3 VcsRef,
4 VcsStatusLocalResult,
5 VcsStatusRemoteResult,
6 VcsStatusResult,
7 VcsStatusStreamEvent,
8} from "@t3tools/contracts";
9import * as Arr from "effect/Array";
10import * as Result from "effect/Result";
11​
12export const WORKTREE_BRANCH_PREFIX = "t3";
13const TEMP_WORKTREE_HEX_TOKEN = "[0-9a-f]{8}";
14const TEMP_WORKTREE_UUID_V4_TOKEN =
15 "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
16const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(
17 `^(?:${WORKTREE_BRANCH_PREFIX}[-/]${TEMP_WORKTREE_HEX_TOKEN}|t3code(?:[-/]${TEMP_WORKTREE_HEX_TOKEN}|\\/${TEMP_WORKTREE_UUID_V4_TOKEN}))$`,
18);
19​
20export function sanitizeBranchFragment(raw: string): string {
21 const normalized = raw
22 .normalize("NFKD")
23 .replace(/[̀-ͯ]/g, "")
24 .trim()
25 .toLowerCase()
26 .replace(/['"`]/g, "")
27 .replace(/^[./\s_-]+|[./\s_-]+$/g, "");
28​
29 const branchFragment = normalized
30 .replace(/[^a-z0-9/_-]+/g, "-")
31 .replace(/\/+/g, "/")
32 .replace(/-+/g, "-")
33 .replace(/^[./_-]+|[./_-]+$/g, "")
34 .slice(0, 64)
35 .replace(/[./_-]+$/g, "");
36​
37 return branchFragment.length > 0 ? branchFragment : "update";
39​
40export function formatGeneratedBranchName(raw: string, naming?: BranchNamingOptions): string {
41 if (naming?.mode === "custom") return raw.trim();
42 const branch = sanitizeBranchFragment(raw);
43 if (naming?.mode !== "static") return branch;
44 const prefix = naming.prefix
45 .split("/")
46 .map((part) =>
47 part
48 .replace(/[^a-zA-Z0-9_-]+/g, "-")
49 .replace(/-+/g, "-")
50 .replace(/^-+|-+$/g, ""),
51 )
52 .filter(Boolean)
53 .join("/");
54 return prefix ? `${prefix}/${branch}` : branch;
56​
57export function sanitizeFeatureBranchName(raw: string): string {
58 const sanitized = sanitizeBranchFragment(raw);
59 return sanitized.includes("/") ? sanitized : `feature/${sanitized}`;
61​
62const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update";
63​
64export function resolveAutoFeatureBranchName(
65 existingBranchNames: readonly string[],
66 preferredBranch?: string,
67): string {
68 const preferred = preferredBranch?.trim();
69 const resolvedBase = sanitizeFeatureBranchName(
70 preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK,
71 );
72 const existingNames = existingBranchNames.map((refName) => refName.toLowerCase());
73 const existingRefs = new Set(existingNames);
74 const parts = resolvedBase.split("/");
75 let resolved = "";
76​
77 for (const [index, part] of parts.entries()) {
78 const base = resolved ? `${resolved}/${part}` : part;
79 let candidate = base;
80 let suffix = 2;
81 while (
82 existingRefs.has(candidate) ||
83 (index === parts.length - 1 &&
84 existingNames.some((refName) => refName.startsWith(`${candidate}/`)))
85 ) {
86 candidate = `${base}-${suffix}`;
87 suffix += 1;
88 }
89 resolved = candidate;
90 }
91​
92 return resolved;
94​
95export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {
96 const firstSeparatorIndex = branchName.indexOf("/");
97 if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) {
98 return branchName;
99 }
100 return branchName.slice(firstSeparatorIndex + 1);
102​
103export function buildTemporaryWorktreeBranchName(
104 randomHex: (byteLength: number) => string,
105): string {
106 const token = randomHex(4)
107 .toLowerCase()
108 .replace(/[^0-9a-f]/g, "")
109 .slice(0, 8);
110 return `${WORKTREE_BRANCH_PREFIX}/${token}`;
112​
113/** Flatten temporary t3/<hex> refs because an existing t3 branch blocks nested refs. */
114export function flattenTemporaryWorktreeBranchName(refName: string): string {
115 const normalized = refName.trim().toLowerCase();
116 const tokenStart = normalized.search(/[-/]/) + 1;
117 const token = normalized.slice(tokenStart, tokenStart + 8);
118 return `${WORKTREE_BRANCH_PREFIX}-${token}`;
120​
121export function isTemporaryWorktreeBranch(refName: string): boolean {
122 return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase());
124​
125function lowerCaseRemote(value: string): string {
126 return value
127 .trim()
128 .replace(/\/+$/g, "")
129 .replace(/\.git$/i, "")
130 .toLowerCase();
132​
133/**
134 * The host and repository path a remote names, lower case and without a port, or null for a
135 * remote that names no host, such as a local path.
136 */
137export function parseGitRemoteRepository(
138 value: string,
139): { readonly host: string; readonly path: string } | null {
140 const normalized = lowerCaseRemote(value);
141​
142 if (/^(?:ssh|https?|git):\/\//i.test(normalized)) {
143 try {
144 // Parse the raw remote: `.git` may sit before a query or fragment, so strip it from the path once.
145 const url = new URL(value.trim().toLowerCase());
146 const repositorySegments = url.pathname
147 .replace(/\/+$/, "")
148 .replace(/\.git$/, "")
149 .split("/")
150 .filter((segment) => segment.length > 0);
151 if (url.hostname && repositorySegments.length > 1) {
152 return { host: url.hostname, path: repositorySegments.join("/") };
153 }
154 } catch {
155 return null;
156 }
157 }
158​
159 // A userless one-letter prefix is a Windows drive, not an SCP host.
160 const scpStyleHostAndPath = /^[a-z]:/i.test(normalized)
161 ? null
162 : /^(?:[a-zA-Z0-9._-]+@)?([^@:/\\\s]+):\/?([^/\s]+(?:\/[^/\s]+)+)$/i.exec(normalized);
163 const scpHost = scpStyleHostAndPath?.[1];
164 const scpPath = scpStyleHostAndPath?.[2];
165 return scpHost && scpPath ? { host: scpHost, path: scpPath } : null;
167​
168/**
169 * `host/path` for a remote that names a host, otherwise the remote itself, lower case. A host that
170 * spells repositories differently over SSH has its source control registry's
171 * `repositoryKeyFromRemote` rewrite this, so persisted keys come from that, not from here.
172 */
173export function normalizeGitRemoteUrl(value: string): string {
174 const repository = parseGitRemoteRepository(value);
175 return repository === null ? lowerCaseRemote(value) : `${repository.host}/${repository.path}`;
177​
178function* gitConfigLines(configText: string) {
179 let pending = "";
180 let quoted = false;
181 for (const line of configText.split(/\r?\n/)) {
182 let continued = false;
183 if (line.endsWith("\\") || pending.length > 0) {
184 for (const [token] of line.matchAll(/\\[\s\S]?|["#;]/g)) {
185 if (token === '"') quoted = !quoted;
186 else if (!quoted && (token === "#" || token === ";")) break;
187 else if (token === "\\") continued = true;
188 }
189 }
190 if (continued) {
191 pending += line.slice(0, -1);
192 continue;
193 }
194 if (!quoted) yield pending + line;
195 pending = "";
196 quoted = false;
197 }
198 if (pending.length > 0 && !quoted) yield pending;
200​
201function parseGitConfigValue(raw: string): string {
202 let out = "";
203 let quoted = false;
204 for (let index = 0; index < raw.length; index += 1) {
205 const char = raw[index]!;
206 if (char === "\\" && index + 1 < raw.length) {
207 out += raw[index + 1];
208 index += 1;
209 continue;
210 }
211 if (char === '"') {
212 quoted = !quoted;
213 continue;
214 }
215 if (!quoted && (char === "#" || char === ";")) break;
216 out += char;
217 }
218 return out.trim();
220​
221function isTrueGitConfigValue(value: string): boolean {
222 if (value === "true" || value === "yes" || value === "on") return true;
223 const numeric = /^([+-]?)(0x[0-9a-f]+|0[0-7]*|[1-9][0-9]*)([kmg]?)$/.exec(value);
224 if (numeric === null) return false;
225 const digits = numeric[2]!;
226 const magnitude = digits.startsWith("0x")
227 ? Number.parseInt(digits.slice(2), 16)
228 : Number.parseInt(digits, digits.startsWith("0") ? 8 : 10);
229 const scale =
230 numeric[3] === "k" ? 1024 : numeric[3] === "m" ? 1024 ** 2 : numeric[3] === "g" ? 1024 ** 3 : 1;
231 const number = magnitude * scale * (numeric[1] === "-" ? -1 : 1);
232 // Git's boolean parser accepts integers only within its signed 32-bit range.
233 return number !== 0 && number >= -2147483648 && number <= 2147483647;
235​
236export function isBareGitConfig(configText: string): boolean {
237 let inCore = false;
238 let bare = false;
239 for (const rawLine of gitConfigLines(configText)) {
240 let line = rawLine.trim();
241 // Git accepts the first setting on the section header's own line.
242 const header = /^\[\s*([^\]]*?)\s*\](.*)$/.exec(line);
243 if (header !== null) {
244 inCore = header[1]!.toLowerCase() === "core";
245 line = header[2]!.trim();
246 }
247 if (!inCore) continue;
248 const match = /^bare\s*(?:=(.*)|[#;].*)?$/i.exec(line);
249 if (match === null) continue;
250 const value = match[1] === undefined ? "true" : parseGitConfigValue(match[1]).toLowerCase();
251 bare = isTrueGitConfigValue(value);
252 }
253 return bare;
255​
256export function parseOriginUrlFromGitConfig(configText: string): string | null {
257 let section: string | null = null;
258 let originUrl: string | null = null;
259 let firstRemoteUrl: string | null = null;
260 const joined = configText.replace(/\\\r?\n[ \t]*/g, "");
261 for (const rawLine of joined.split(/\r?\n/)) {
262 let line = rawLine.trim();
263 if (line.length === 0 || line.startsWith("#") || line.startsWith(";")) continue;
264 // Git preserves case in quoted remote subsections but lowercases legacy dotted ones,
265 // and accepts the first setting on the section header's own line.
266 const header = /^\[\s*remote(?:\s+"([^"]+)"|\.([^\]\s]+))\s*\](.*)$/i.exec(line);
267 if (header) {
268 section = header[1] ?? header[2]?.toLowerCase() ?? null;
269 line = header[3]!.trim();
270 } else if (line.startsWith("[")) {
271 section = null;
272 continue;
273 }
274 if (section === null) continue;
275 const match = /^url\s*=\s*(.*)$/i.exec(line);
276 if (!match) continue;
277 const url = parseGitConfigValue(match[1] ?? "");
278 if (url.length === 0) continue;
279 if (section === "origin") {
280 originUrl ??= url;
281 } else {
282 firstRemoteUrl ??= url;
283 }
284 }
285 return originUrl ?? firstRemoteUrl;
287​
288// Replace only Git-rejected whitespace; nonbreaking spaces are valid ref characters.
289export function sanitizeNewRefName(rawName: string): string {
290 return rawName.trim().replace(/[ \t\n\r\f\v]+/g, "-");
292​
293export function dedupeRemoteBranchesWithLocalMatches(
294 refs: ReadonlyArray<VcsRef>,
295): ReadonlyArray<VcsRef> {
296 const localBranchNames = new Set(
297 Arr.filterMap(refs, (refName) =>
298 refName.isRemote ? Result.failVoid : Result.succeed(refName.name),
299 ),
300 );
301​
302 return refs.filter((refName) => {
303 if (!refName.isRemote || refName.remoteName !== "origin") {
304 return true;
305 }
306​
307 const localBranchName = deriveLocalBranchNameFromRemoteRef(refName.name);
308 return localBranchName.length === 0 || !localBranchNames.has(localBranchName);
309 });
311​
312const EMPTY_GIT_STATUS_REMOTE: VcsStatusRemoteResult = {
313 hasUpstream: false,
314 aheadCount: 0,
315 behindCount: 0,
316 aheadOfDefaultCount: 0,
317 pr: null,
318};
319​
320export function mergeGitStatusParts(
321 local: VcsStatusLocalResult,
322 remote: VcsStatusRemoteResult | null,
323): VcsStatusResult {
324 return {
325 ...local,
326 ...(remote ?? EMPTY_GIT_STATUS_REMOTE),
327 };
329​
330function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult {
331 return {
332 hasUpstream: status.hasUpstream,
333 aheadCount: status.aheadCount,
334 behindCount: status.behindCount,
335 ...(status.aheadOfDefaultCount === undefined
336 ? {}
337 : { aheadOfDefaultCount: status.aheadOfDefaultCount }),
338 pr: status.pr,
339 };
341​
342function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult {
343 return {
344 isRepo: status.isRepo,
345 ...(status.sourceControlProvider
346 ? { sourceControlProvider: status.sourceControlProvider }
347 : {}),
348 hasPrimaryRemote: status.hasPrimaryRemote,
349 isDefaultRef: status.isDefaultRef,
350 refName: status.refName,
351 hasWorkingTreeChanges: status.hasWorkingTreeChanges,
352 workingTree: status.workingTree,
353 ...(status.branchChanges ? { branchChanges: status.branchChanges } : {}),
354 };
356​
357export function applyGitStatusStreamEvent(
358 current: VcsStatusResult | null,
359 event: VcsStatusStreamEvent,
360): VcsStatusResult {
361 switch (event._tag) {
362 case "snapshot":
363 return mergeGitStatusParts(event.local, event.remote);
364 case "localUpdated":
365 return mergeGitStatusParts(event.local, current ? toRemoteStatusPart(current) : null);
366 case "remoteUpdated":
367 if (current === null) {
368 return mergeGitStatusParts(
369 {
370 isRepo: true,
371 hasPrimaryRemote: false,
372 isDefaultRef: false,
373 refName: null,
374 hasWorkingTreeChanges: false,
375 workingTree: { files: [], insertions: 0, deletions: 0 },
376 },
377 event.remote,
378 );
379 }
380 return mergeGitStatusParts(toLocalStatusPart(current), event.remote);
381 }