1import type {
2 BranchNamingOptions,
3 VcsRef,
4 SourceControlProviderInfo,
5 VcsStatusLocalResult,
6 VcsStatusRemoteResult,
7 VcsStatusResult,
8 VcsStatusStreamEvent,
9} from "@t3tools/contracts";
10import * as Arr from "effect/Array";
11import * as Result from "effect/Result";
12import { detectSourceControlProviderFromRemoteUrl } from "./sourceControl.ts";
13
14export const WORKTREE_BRANCH_PREFIX = "t3";
15// Canonical form is `t3/<8 hex>`. `t3-<8 hex>` is the fallback when a plain `t3`
16// branch blocks the namespace. The matcher also accepts every legacy shape, so
17// existing threads stay eligible for branch regeneration: `t3code/<8 hex>` and
18// `t3code-<8 hex>` from before the prefix was shortened, and `t3code/<uuid>` from
19// older mobile builds that used Crypto.randomUUID() (always RFC 4122 v4, so version
20// nibble `4` and variant nibble `[89ab]`). Nothing looser than what was generated.
21const TEMP_WORKTREE_HEX_TOKEN = "[0-9a-f]{8}";
22const TEMP_WORKTREE_UUID_V4_TOKEN =
23 "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}";
24const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(
25 `^(?:${WORKTREE_BRANCH_PREFIX}[-/]${TEMP_WORKTREE_HEX_TOKEN}|t3code(?:[-/]${TEMP_WORKTREE_HEX_TOKEN}|\\/${TEMP_WORKTREE_UUID_V4_TOKEN}))$`,
26);
27
28/**
29 * Sanitize an arbitrary string into a valid, lowercase git refName fragment.
30 * Strips quotes, collapses separators, limits to 64 chars.
31 */
32export function sanitizeBranchFragment(raw: string): string {
33 const normalized = raw
34 .trim()
35 .toLowerCase()
36 .replace(/['"`]/g, "")
37 .replace(/^[./\s_-]+|[./\s_-]+$/g, "");
38
39 const branchFragment = normalized
40 .replace(/[^a-z0-9/_-]+/g, "-")
41 .replace(/\/+/g, "/")
42 .replace(/-+/g, "-")
43 .replace(/^[./_-]+|[./_-]+$/g, "")
44 .slice(0, 64)
45 .replace(/[./_-]+$/g, "");
46
47 return branchFragment.length > 0 ? branchFragment : "update";
48}
49
50/** Custom naming preserves the model's complete ref; Git validates it on rename. */
51export function formatGeneratedBranchName(raw: string, naming?: BranchNamingOptions): string {
52 if (naming?.mode === "custom") return raw.trim();
53 const branch = sanitizeBranchFragment(raw);
54 if (naming?.mode !== "static") return branch;
55 const prefix = naming.prefix
56 .split("/")
57 .map((part) =>
58 part
59 .replace(/[^a-zA-Z0-9_-]+/g, "-")
60 .replace(/-+/g, "-")
61 .replace(/^-+|-+$/g, ""),
62 )
63 .filter(Boolean)
64 .join("/");
65 return prefix ? `${prefix}/${branch}` : branch;
66}
67
68/**
69 * Sanitize a generated branch name, preserving any slash-separated namespace.
70 * Unprefixed fragments use the `feature/` namespace.
71 */
72export function sanitizeFeatureBranchName(raw: string): string {
73 const sanitized = sanitizeBranchFragment(raw);
74 return sanitized.includes("/") ? sanitized : `feature/${sanitized}`;
75}
76
77const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update";
78
79/**
80 * Resolve a unique generated refName, suffixing only the path component
81 * blocked by an existing ref or, for the final component, its descendants.
82 */
83export function resolveAutoFeatureBranchName(
84 existingBranchNames: readonly string[],
85 preferredBranch?: string,
86): string {
87 const preferred = preferredBranch?.trim();
88 const resolvedBase = sanitizeFeatureBranchName(
89 preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK,
90 );
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;
109 }
110
111 return resolved;
112}
113
114/**
115 * Strip the remote prefix from a remote ref such as `origin/feature/demo`.
116 */
117export function deriveLocalBranchNameFromRemoteRef(branchName: string): string {
118 const firstSeparatorIndex = branchName.indexOf("/");
119 if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) {
120 return branchName;
121 }
122 return branchName.slice(firstSeparatorIndex + 1);
123}
124
125export function buildTemporaryWorktreeBranchName(
126 randomHex: (byteLength: number) => string,
127): string {
128 // Normalize to exactly 8 lowercase hex chars so a UUID-shaped callback
129 // still produces the canonical temporary branch form.
130 const token = randomHex(4)
131 .toLowerCase()
132 .replace(/[^0-9a-f]/g, "")
133 .slice(0, 8);
134 return `${WORKTREE_BRANCH_PREFIX}/${token}`;
135}
136
137/**
138 * Git stores refs as paths, so a plain `t3` branch makes every `t3/<hex>`
139 * ref impossible. This moves a temporary name to the flat `t3-<hex>` sibling.
140 */
141export function flattenTemporaryWorktreeBranchName(refName: string): string {
142 // Keep only the canonical 8-hex token so legacy `t3code/` and UUID names map cleanly.
143 const normalized = refName.trim().toLowerCase();
144 const tokenStart = normalized.search(/[-/]/) + 1;
145 const token = normalized.slice(tokenStart, tokenStart + 8);
146 return `${WORKTREE_BRANCH_PREFIX}-${token}`;
147}
148
149export function isTemporaryWorktreeBranch(refName: string): boolean {
150 return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase());
151}
152
153/**
154 * The web spelling of an Azure DevOps repository reached over SSH, or null for anything else.
155 *
156 * Azure alone addresses one repository under two names that share no part: `ssh.dev.azure.com` and
157 * `v3/{org}/{project}/{repo}` over SSH, against `dev.azure.com` and `{org}/{project}/_git/{repo}`
158 * everywhere a person sees it. A project cloned over SSH would otherwise be a different repository
159 * to every comparison made against a pull request URL, which arrives in the web spelling. So the
160 * web spelling is the one both are keyed by.
161 */
162function azureDevOpsRepositoryKey(host: string, segments: ReadonlyArray<string>): string | null {
163 if (host !== "ssh.dev.azure.com" && host !== "vs-ssh.visualstudio.com") return null;
164 const [marker, organization, project, repository] = segments;
165 if (segments.length !== 4 || marker !== "v3") return null;
166 if (!organization || !project || !repository) return null;
167 return host === "ssh.dev.azure.com"
168 ? `dev.azure.com/${organization}/${project}/_git/${repository}`
169 : `${organization}.visualstudio.com/${project}/_git/${repository}`;
170}
171
172/**
173 * Normalize a git remote URL into a stable comparison key.
174 */
175export function normalizeGitRemoteUrl(value: string): string {
176 const normalized = value
177 .trim()
178 .replace(/\/+$/g, "")
179 .replace(/\.git$/i, "")
180 .toLowerCase();
181
182 if (/^(?:ssh|https?|git):\/\//i.test(normalized)) {
183 try {
184 const url = new URL(normalized);
185 const repositorySegments = url.pathname.split("/").filter((segment) => segment.length > 0);
186 if (url.hostname && repositorySegments.length > 1) {
187 return (
188 azureDevOpsRepositoryKey(url.hostname, repositorySegments) ??
189 `${url.hostname}/${repositorySegments.join("/")}`
190 );
191 }
192 } catch {
193 return normalized;
194 }
195 }
196
197 const scpStyleHostAndPath = /^[a-zA-Z0-9._-]+@([^:/\s]+):([^/\s]+(?:\/[^/\s]+)+)$/i.exec(
198 normalized,
199 );
200 const scpHost = scpStyleHostAndPath?.[1];
201 const scpPath = scpStyleHostAndPath?.[2];
202 if (scpHost && scpPath) {
203 return azureDevOpsRepositoryKey(scpHost, scpPath.split("/")) ?? `${scpHost}/${scpPath}`;
204 }
205
206 return normalized;
207}
208
209/**
210 * Unquote a git config value: strip an inline `#` or `;` comment outside
211 * quotes, then drop surrounding quotes and backslash escapes.
212 */
213function parseGitConfigValue(raw: string): string {
214 let out = "";
215 let quoted = false;
216 for (let index = 0; index < raw.length; index += 1) {
217 const char = raw[index]!;
218 if (char === "\\" && index + 1 < raw.length) {
219 out += raw[index + 1];
220 index += 1;
221 continue;
222 }
223 if (char === '"') {
224 quoted = !quoted;
225 continue;
226 }
227 if (!quoted && (char === "#" || char === ";")) break;
228 out += char;
229 }
230 return out.trim();
231}
232
233/**
234 * Read the primary remote URL from raw `.git/config` text without spawning
235 * git. Prefers `remote.origin.url` and falls back to the first remote so
236 * clones made with `git clone --origin <name>` still resolve.
237 */
238export function parseOriginUrlFromGitConfig(configText: string): string | null {
239 let section: string | null = null;
240 let originUrl: string | null = null;
241 let firstRemoteUrl: string | null = null;
242 // A trailing backslash continues the value on the next line.
243 const joined = configText.replace(/\\\r?\n[ \t]*/g, "");
244 for (const rawLine of joined.split(/\r?\n/)) {
245 const line = rawLine.trim();
246 if (line.length === 0 || line.startsWith("#") || line.startsWith(";")) continue;
247 // Both `[remote "origin"]` and the legacy `[remote.origin]` form, with an
248 // optional trailing comment. Git keeps quoted subsections case-sensitive
249 // but folds the dotted form to lowercase.
250 const header = /^\[\s*remote(?:\s+"([^"]+)"|\.([^\]\s]+))\s*\](?:\s*[#;].*)?$/i.exec(line);
251 if (header) {
252 section = header[1] ?? header[2]?.toLowerCase() ?? null;
253 continue;
254 }
255 if (line.startsWith("[")) {
256 section = null;
257 continue;
258 }
259 if (section === null) continue;
260 const match = /^url\s*=\s*(.*)$/i.exec(line);
261 if (!match) continue;
262 const url = parseGitConfigValue(match[1] ?? "");
263 if (url.length === 0) continue;
264 if (section === "origin") {
265 originUrl ??= url;
266 } else {
267 firstRemoteUrl ??= url;
268 }
269 }
270 return originUrl ?? firstRemoteUrl;
271}
272
273/**
274 * Best-effort parse of a GitHub `owner/repo` identifier from common remote URL shapes.
275 */
276export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null {
277 const trimmed = url?.trim() ?? "";
278 if (trimmed.length === 0) {
279 return null;
280 }
281
282 const match =
283 /^(?:git@github\.com:|ssh:\/\/(?:git@)?github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec(
284 trimmed,
285 );
286 const repositoryNameWithOwner = match?.[1]?.trim() ?? "";
287 return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null;
288}
289
290function deriveLocalBranchNameCandidatesFromRemoteRef(
291 branchName: string,
292 remoteName?: string,
293): ReadonlyArray<string> {
294 const candidates = new Set<string>();
295 const firstSlashCandidate = deriveLocalBranchNameFromRemoteRef(branchName);
296 if (firstSlashCandidate.length > 0) {
297 candidates.add(firstSlashCandidate);
298 }
299
300 if (remoteName) {
301 const remotePrefix = `${remoteName}/`;
302 if (branchName.startsWith(remotePrefix) && branchName.length > remotePrefix.length) {
303 candidates.add(branchName.slice(remotePrefix.length));
304 }
305 }
306
307 return [...candidates];
308}
309
310// Git rejects ASCII space and the ASCII control characters (tab, newline and
311// friends) in ref names, so the picker's "Create new ref" entry can only fail
312// for a typed name like "new branch". Replacing runs of those with a dash makes
313// the name usable without reimplementing check-ref-format: names invalid for
314// other reasons still surface the git error. Only the whitespace git actually
315// rejects is replaced — git accepts U+00A0 and friends, and rewriting those
316// would silently create a ref the user never asked for. Case and existing
317// dashes are left alone, since ref names are case sensitive and consecutive
318// dashes are valid.
319export function sanitizeNewRefName(rawName: string): string {
320 return rawName.trim().replace(/[ \t\n\r\f\v]+/g, "-");
321}
322
323/**
324 * Hide `origin/*` remote refs when a matching local refName already exists.
325 */
326export function dedupeRemoteBranchesWithLocalMatches(
327 refs: ReadonlyArray<VcsRef>,
328): ReadonlyArray<VcsRef> {
329 const localBranchNames = new Set(
330 Arr.filterMap(refs, (refName) =>
331 refName.isRemote ? Result.failVoid : Result.succeed(refName.name),
332 ),
333 );
334
335 return refs.filter((refName) => {
336 if (!refName.isRemote) {
337 return true;
338 }
339
340 if (refName.remoteName !== "origin") {
341 return true;
342 }
343
344 const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef(
345 refName.name,
346 refName.remoteName,
347 );
348 return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate));
349 });
350}
351
352export function detectSourceControlProviderFromGitRemoteUrl(
353 remoteUrl: string,
354): SourceControlProviderInfo | null {
355 return detectSourceControlProviderFromRemoteUrl(remoteUrl);
356}
357
358const EMPTY_GIT_STATUS_REMOTE: VcsStatusRemoteResult = {
359 hasUpstream: false,
360 aheadCount: 0,
361 behindCount: 0,
362 aheadOfDefaultCount: 0,
363 pr: null,
364};
365
366export function mergeGitStatusParts(
367 local: VcsStatusLocalResult,
368 remote: VcsStatusRemoteResult | null,
369): VcsStatusResult {
370 return {
371 ...local,
372 ...(remote ?? EMPTY_GIT_STATUS_REMOTE),
373 };
374}
375
376function toRemoteStatusPart(status: VcsStatusResult): VcsStatusRemoteResult {
377 return {
378 hasUpstream: status.hasUpstream,
379 aheadCount: status.aheadCount,
380 behindCount: status.behindCount,
381 ...(status.aheadOfDefaultCount === undefined
382 ? {}
383 : { aheadOfDefaultCount: status.aheadOfDefaultCount }),
384 pr: status.pr,
385 };
386}
387
388function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult {
389 return {
390 isRepo: status.isRepo,
391 ...(status.isJjWorkspace === undefined ? {} : { isJjWorkspace: status.isJjWorkspace }),
392 ...(status.sourceControlProvider
393 ? { sourceControlProvider: status.sourceControlProvider }
394 : {}),
395 hasPrimaryRemote: status.hasPrimaryRemote,
396 isDefaultRef: status.isDefaultRef,
397 refName: status.refName,
398 hasWorkingTreeChanges: status.hasWorkingTreeChanges,
399 workingTree: status.workingTree,
400 ...(status.branchChanges ? { branchChanges: status.branchChanges } : {}),
401 };
402}
403
404export function applyGitStatusStreamEvent(
405 current: VcsStatusResult | null,
406 event: VcsStatusStreamEvent,
407): VcsStatusResult {
408 switch (event._tag) {
409 case "snapshot":
410 return mergeGitStatusParts(event.local, event.remote);
411 case "localUpdated":
412 return mergeGitStatusParts(event.local, current ? toRemoteStatusPart(current) : null);
413 case "remoteUpdated":
414 if (current === null) {
415 return mergeGitStatusParts(
416 {
417 isRepo: true,
418 hasPrimaryRemote: false,
419 isDefaultRef: false,
420 refName: null,
421 hasWorkingTreeChanges: false,
422 workingTree: { files: [], insertions: 0, deletions: 0 },
423 },
424 event.remote,
425 );
426 }
427 return mergeGitStatusParts(toLocalStatusPart(current), event.remote);
428 }
429}