-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathgit-refs.ts
More file actions
208 lines (186 loc) · 7.54 KB
/
git-refs.ts
File metadata and controls
208 lines (186 loc) · 7.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
import { spawnGitAsync } from "./git.js";
// ---------------------------------------------------------------------------
// Merge conflict helpers (shared between git_merge and git_cherry_pick)
// ---------------------------------------------------------------------------
/** Paths with unresolved merge conflicts (`--diff-filter=U`). */
export async function conflictPaths(gitTop: string): Promise<string[]> {
const r = await spawnGitAsync(gitTop, ["diff", "--name-only", "--diff-filter=U"]);
if (!r.ok) return [];
return r.stdout
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
}
// ---------------------------------------------------------------------------
// Protected branch names — never auto-delete, never cascade destructive ops onto
// ---------------------------------------------------------------------------
const PROTECTED_EXACT = new Set([
"main",
"master",
"dev",
"develop",
"stable",
"trunk",
"prod",
"production",
"HEAD",
]);
const PROTECTED_PATTERN = /^(release|hotfix)[-/].+$/i;
/** True when a branch name is on the protected list and must not be auto-deleted. */
export function isProtectedBranch(name: string): boolean {
const t = name.trim();
if (t === "") return true;
if (PROTECTED_EXACT.has(t)) return true;
return PROTECTED_PATTERN.test(t);
}
// ---------------------------------------------------------------------------
// Ref/branch name validation (argv-safe subset of git's ref-format rules)
// ---------------------------------------------------------------------------
/**
* Conservative check for branch/ref names passed to git argv.
* Rejects anything outside the ASCII subset `A-Z a-z 0-9 _ . / + -`,
* sequences git itself rejects (`..`, `@{`, leading `-`, trailing `.lock`/`/`),
* and pathological tokens.
*/
export function isSafeGitRefToken(s: string): boolean {
const t = s.trim();
if (t.length === 0 || t.length > 256) return false;
if (t.startsWith("-")) return false;
if (t.endsWith("/") || t.endsWith(".lock") || t.endsWith(".")) return false;
if (t.includes("..")) return false;
if (t.includes("@{")) return false;
if (t.includes("//")) return false;
return /^[A-Za-z0-9_./+-]+$/.test(t);
}
/**
* Same as `isSafeGitRefToken` but also allows `~N` / `^N` ancestor notation used
* by `git reset --soft HEAD~3`. Permits `~` and `^` suffix characters.
*/
export function isSafeGitAncestorRef(s: string): boolean {
const t = s.trim();
if (t.length === 0 || t.length > 256) return false;
if (t.startsWith("-")) return false;
return /^[A-Za-z0-9_./+~^-]+$/.test(t);
}
/**
* Same as `isSafeGitRefToken` but also allows the `A..B` / `A...B` range forms
* used by `git log` / `git cherry-pick`. Splits once and validates each side.
*/
export function isSafeGitRangeToken(s: string): boolean {
const t = s.trim();
if (t.includes("...")) {
const parts = t.split("...");
return parts.length === 2 && parts.every((p) => isSafeGitRefToken(p));
}
if (t.includes("..")) {
const parts = t.split("..");
return parts.length === 2 && parts.every((p) => isSafeGitRefToken(p));
}
return isSafeGitRefToken(t);
}
// ---------------------------------------------------------------------------
// Async helpers
// ---------------------------------------------------------------------------
/** Current branch name; `null` if detached HEAD. */
export async function getCurrentBranch(cwd: string): Promise<string | null> {
const r = await spawnGitAsync(cwd, ["symbolic-ref", "--short", "-q", "HEAD"]);
if (!r.ok) return null;
const name = r.stdout.trim();
return name === "" ? null : name;
}
/** Resolve a ref to its full SHA; `null` if unknown. */
export async function resolveRef(cwd: string, ref: string): Promise<string | null> {
if (!isSafeGitRefToken(ref)) return null;
const r = await spawnGitAsync(cwd, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
if (!r.ok) return null;
const sha = r.stdout.trim();
return sha === "" ? null : sha;
}
/** Working tree clean (no staged, no unstaged, no untracked). */
export async function isWorkingTreeClean(cwd: string): Promise<boolean> {
const r = await spawnGitAsync(cwd, ["status", "--porcelain"]);
if (!r.ok) return false;
return r.stdout.trim() === "";
}
/** True when every commit on `branch` is reachable from `target`. */
export async function isFullyMergedInto(
cwd: string,
branch: string,
target: string,
): Promise<boolean> {
if (!isSafeGitRefToken(branch) || !isSafeGitRefToken(target)) return false;
const r = await spawnGitAsync(cwd, ["merge-base", "--is-ancestor", branch, target]);
return r.ok;
}
/**
* SHAs of commits in `exclude..include`, oldest-first (cherry-pick feed order).
* Returns `null` on git failure.
*/
export async function commitListBetween(
cwd: string,
excludeRef: string,
includeRef: string,
): Promise<string[] | null> {
if (!isSafeGitRefToken(excludeRef) || !isSafeGitRefToken(includeRef)) return null;
const r = await spawnGitAsync(cwd, ["rev-list", "--reverse", `${excludeRef}..${includeRef}`]);
if (!r.ok) return null;
return r.stdout
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
}
// ---------------------------------------------------------------------------
// Worktree lookup
// ---------------------------------------------------------------------------
export interface WorktreeEntry {
path: string;
branch: string | null;
head: string | null;
}
/** Parse `git worktree list --porcelain` into structured entries. */
export async function listWorktrees(cwd: string): Promise<WorktreeEntry[]> {
const r = await spawnGitAsync(cwd, ["worktree", "list", "--porcelain"]);
if (!r.ok) return [];
const out: WorktreeEntry[] = [];
let cur: Partial<WorktreeEntry> = {};
for (const line of r.stdout.split("\n")) {
if (line.startsWith("worktree ")) {
if (cur.path)
out.push({ path: cur.path, branch: cur.branch ?? null, head: cur.head ?? null });
cur = { path: line.slice("worktree ".length).trim() };
} else if (line.startsWith("HEAD ")) {
cur.head = line.slice("HEAD ".length).trim();
} else if (line.startsWith("branch ")) {
// e.g. `branch refs/heads/foo`
const ref = line.slice("branch ".length).trim();
cur.branch = ref.startsWith("refs/heads/") ? ref.slice("refs/heads/".length) : ref;
} else if (line === "detached") {
cur.branch = null;
}
}
if (cur.path) out.push({ path: cur.path, branch: cur.branch ?? null, head: cur.head ?? null });
return out;
}
/** Path of the worktree currently checked out on `branch`; `null` if none. */
export async function worktreeForBranch(cwd: string, branch: string): Promise<string | null> {
const trees = await listWorktrees(cwd);
const hit = trees.find((t) => t.branch === branch);
return hit?.path ?? null;
}
// ---------------------------------------------------------------------------
// Push helpers
// ---------------------------------------------------------------------------
/**
* Probe `@{u}` and extract the remote name from the tracking ref.
* Returns an error payload when no upstream is configured.
*/
export async function inferRemoteFromUpstream(
cwd: string,
): Promise<{ ok: true; remote: string; upstream: string } | { ok: false; detail: string }> {
const r = await spawnGitAsync(cwd, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
if (!r.ok) return { ok: false, detail: (r.stderr || r.stdout).trim() };
const upstream = r.stdout.trim();
const slash = upstream.indexOf("/");
const remote = slash > 0 ? upstream.slice(0, slash) : "origin";
return { ok: true, remote, upstream };
}