@senad-d/branchme 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/git.ts ADDED
@@ -0,0 +1,503 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ GIT_MUTATION_TIMEOUT_MS,
4
+ GIT_PUSH_TIMEOUT_MS,
5
+ GIT_STATUS_TIMEOUT_MS,
6
+ MAX_SUMMARY_OUTPUT_CHARS,
7
+ } from "./constants.ts";
8
+ import { redactSecrets } from "./redaction.ts";
9
+ import type {
10
+ AheadBehindCount,
11
+ BranchStatusDetails,
12
+ ChangeBranchDetails,
13
+ CreateBranchDetails,
14
+ CurrentBranchInfo,
15
+ GitExecResult,
16
+ PushBranchDetails,
17
+ } from "./types.ts";
18
+
19
+ export interface GitCommandContext {
20
+ cwd: string;
21
+ }
22
+
23
+ export interface GitRunOptions {
24
+ signal?: AbortSignal;
25
+ timeout?: number;
26
+ allowFailure?: boolean;
27
+ }
28
+
29
+ const repositoryMutationQueues = new Map<string, Promise<void>>();
30
+
31
+ export async function withRepositoryMutationQueue<T>(repoRoot: string, operation: () => Promise<T>): Promise<T> {
32
+ const previous = repositoryMutationQueues.get(repoRoot) ?? Promise.resolve();
33
+ let releaseCurrent!: () => void;
34
+ const current = new Promise<void>((resolve) => {
35
+ releaseCurrent = resolve;
36
+ });
37
+ const queued = previous.catch(() => undefined).then(() => current);
38
+ repositoryMutationQueues.set(repoRoot, queued);
39
+
40
+ await previous.catch(() => undefined);
41
+
42
+ try {
43
+ return await operation();
44
+ } finally {
45
+ releaseCurrent();
46
+ if (repositoryMutationQueues.get(repoRoot) === queued) {
47
+ void queued.finally(() => {
48
+ if (repositoryMutationQueues.get(repoRoot) === queued) repositoryMutationQueues.delete(repoRoot);
49
+ });
50
+ }
51
+ }
52
+ }
53
+
54
+ function trimOutput(value: string): string {
55
+ return value.replace(/\s+$/u, "");
56
+ }
57
+
58
+ function compactOutput(value: string): string {
59
+ const text = trimOutput(value);
60
+ if (text.length <= MAX_SUMMARY_OUTPUT_CHARS) return text;
61
+ return `${text.slice(0, MAX_SUMMARY_OUTPUT_CHARS)}… [truncated]`;
62
+ }
63
+
64
+ function safeOutput(value: string, tokens: readonly string[] = []): string {
65
+ return compactOutput(redactSecrets(value, tokens));
66
+ }
67
+
68
+ function safeDetail(value: string): string {
69
+ return redactSecrets(value);
70
+ }
71
+
72
+ function safeNullableDetail(value: string | null): string | null {
73
+ return value === null ? null : safeDetail(value);
74
+ }
75
+
76
+ function commandLabel(args: readonly string[]): string {
77
+ return `git ${args.join(" ")}`;
78
+ }
79
+
80
+ function safeCommandLabel(args: readonly string[], tokens: readonly string[] = []): string {
81
+ return redactSecrets(commandLabel(args), tokens);
82
+ }
83
+
84
+ export function formatGitFailure(args: readonly string[], result: GitExecResult, tokens: readonly string[] = []): string {
85
+ const fallbackReason = result.killed ? "command was killed or timed out" : `exit code ${result.code}`;
86
+ const reason = safeOutput(result.stderr || result.stdout, tokens) || fallbackReason;
87
+ const killed = result.killed ? " (killed)" : "";
88
+ return `${safeCommandLabel(args, tokens)} failed${killed}: ${reason}`;
89
+ }
90
+
91
+ export async function runGit(
92
+ pi: Pick<ExtensionAPI, "exec">,
93
+ ctx: GitCommandContext,
94
+ args: string[],
95
+ options: GitRunOptions = {},
96
+ ): Promise<GitExecResult> {
97
+ const result = await pi.exec("git", args, {
98
+ cwd: ctx.cwd,
99
+ signal: options.signal,
100
+ timeout: options.timeout ?? GIT_STATUS_TIMEOUT_MS,
101
+ });
102
+
103
+ if (result.killed) {
104
+ throw new Error(formatGitFailure(args, result));
105
+ }
106
+
107
+ if (!options.allowFailure && result.code !== 0) {
108
+ throw new Error(formatGitFailure(args, result));
109
+ }
110
+
111
+ return result;
112
+ }
113
+
114
+ export async function getGitRoot(
115
+ pi: Pick<ExtensionAPI, "exec">,
116
+ ctx: GitCommandContext,
117
+ signal?: AbortSignal,
118
+ ): Promise<string> {
119
+ const args = ["rev-parse", "--show-toplevel"];
120
+ const result = await runGit(pi, ctx, args, { signal, timeout: GIT_STATUS_TIMEOUT_MS, allowFailure: true });
121
+ if (result.code !== 0) {
122
+ throw new Error(`Not a git repository: ${safeOutput(result.stderr || result.stdout) || "git rev-parse failed"}`);
123
+ }
124
+
125
+ const root = trimOutput(result.stdout);
126
+ if (!root) throw new Error("Not a git repository: git did not return a repository root.");
127
+ return root;
128
+ }
129
+
130
+ export async function getCurrentBranch(
131
+ pi: Pick<ExtensionAPI, "exec">,
132
+ ctx: GitCommandContext,
133
+ signal?: AbortSignal,
134
+ ): Promise<CurrentBranchInfo> {
135
+ const args = ["symbolic-ref", "--quiet", "--short", "HEAD"];
136
+ const result = await runGit(pi, ctx, args, { signal, timeout: GIT_STATUS_TIMEOUT_MS, allowFailure: true });
137
+ if (result.code === 0) {
138
+ const branch = trimOutput(result.stdout);
139
+ if (!branch) throw new Error("Unable to determine current branch: git returned an empty branch name.");
140
+ return { currentBranch: branch, detached: false };
141
+ }
142
+
143
+ const verify = await runGit(pi, ctx, ["rev-parse", "--verify", "HEAD"], {
144
+ signal,
145
+ timeout: GIT_STATUS_TIMEOUT_MS,
146
+ allowFailure: true,
147
+ });
148
+ if (verify.code === 0) return { currentBranch: null, detached: true };
149
+
150
+ throw new Error(`Unable to determine current branch: ${safeOutput(result.stderr || verify.stderr) || "HEAD is invalid"}`);
151
+ }
152
+
153
+ export async function requireCurrentBranch(
154
+ pi: Pick<ExtensionAPI, "exec">,
155
+ ctx: GitCommandContext,
156
+ signal?: AbortSignal,
157
+ ): Promise<string> {
158
+ const current = await getCurrentBranch(pi, ctx, signal);
159
+ if (current.detached || !current.currentBranch) {
160
+ throw new Error("Cannot continue while HEAD is detached. Checkout a branch first.");
161
+ }
162
+ return current.currentBranch;
163
+ }
164
+
165
+ export async function getUpstreamBranch(
166
+ pi: Pick<ExtensionAPI, "exec">,
167
+ ctx: GitCommandContext,
168
+ signal?: AbortSignal,
169
+ ): Promise<string | null> {
170
+ const args = ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"];
171
+ const result = await runGit(pi, ctx, args, { signal, timeout: GIT_STATUS_TIMEOUT_MS, allowFailure: true });
172
+ if (result.code !== 0) return null;
173
+
174
+ const upstream = trimOutput(result.stdout);
175
+ return upstream || null;
176
+ }
177
+
178
+ interface PushTarget {
179
+ upstream: string | null;
180
+ mode: "push" | "publish";
181
+ remote: string;
182
+ remoteRef: string;
183
+ refspec: string;
184
+ args: string[];
185
+ }
186
+
187
+ function validateRemoteName(remote: string): void {
188
+ if (!remote) throw new Error("Unable to push current branch: upstream remote is missing.");
189
+ if (remote === ".") throw new Error("Unable to push current branch: upstream is a local branch, not a remote.");
190
+ if (remote.startsWith("-")) throw new Error("Unable to push current branch: upstream remote cannot start with '-'.");
191
+ if (remote.includes(":") || remote.includes("@")) {
192
+ throw new Error("Unable to push current branch: upstream remote name cannot be a URL or user-prefixed target.");
193
+ }
194
+ if (/[\u0000-\u001f\u007f\s]/u.test(remote)) {
195
+ throw new Error("Unable to push current branch: upstream remote contains whitespace or control characters.");
196
+ }
197
+ }
198
+
199
+ function normalizeRemoteHeadRef(mergeRef: string): string {
200
+ if (!mergeRef.startsWith("refs/heads/")) {
201
+ throw new Error("Unable to push current branch: upstream merge ref is not a branch ref.");
202
+ }
203
+
204
+ const branchName = mergeRef.slice("refs/heads/".length);
205
+ validateBranchNameInput(branchName);
206
+ return mergeRef;
207
+ }
208
+
209
+ async function getBranchConfigValue(
210
+ pi: Pick<ExtensionAPI, "exec">,
211
+ ctx: GitCommandContext,
212
+ currentBranch: string,
213
+ key: "remote" | "merge",
214
+ signal?: AbortSignal,
215
+ ): Promise<string | null> {
216
+ const result = await runGit(pi, ctx, ["config", "--get", `branch.${currentBranch}.${key}`], {
217
+ signal,
218
+ timeout: GIT_STATUS_TIMEOUT_MS,
219
+ allowFailure: true,
220
+ });
221
+ if (result.code !== 0) return null;
222
+
223
+ const value = trimOutput(result.stdout);
224
+ return value || null;
225
+ }
226
+
227
+ async function resolvePushTarget(
228
+ pi: Pick<ExtensionAPI, "exec">,
229
+ ctx: GitCommandContext,
230
+ currentBranch: string,
231
+ signal?: AbortSignal,
232
+ ): Promise<PushTarget> {
233
+ validateBranchNameInput(currentBranch);
234
+ const upstream = await getUpstreamBranch(pi, ctx, signal);
235
+ if (!upstream) {
236
+ const remoteRef = `refs/heads/${currentBranch}`;
237
+ return {
238
+ upstream: null,
239
+ mode: "publish",
240
+ remote: "origin",
241
+ remoteRef,
242
+ refspec: currentBranch,
243
+ args: ["push", "--set-upstream", "origin", currentBranch],
244
+ };
245
+ }
246
+
247
+ const remote = await getBranchConfigValue(pi, ctx, currentBranch, "remote", signal);
248
+ const mergeRef = await getBranchConfigValue(pi, ctx, currentBranch, "merge", signal);
249
+ if (!remote || !mergeRef) {
250
+ throw new Error("Unable to push current branch: upstream exists but branch remote/merge configuration is incomplete.");
251
+ }
252
+
253
+ validateRemoteName(remote);
254
+ const remoteRef = normalizeRemoteHeadRef(mergeRef);
255
+ const refspec = `HEAD:${remoteRef}`;
256
+ return {
257
+ upstream,
258
+ mode: "push",
259
+ remote,
260
+ remoteRef,
261
+ refspec,
262
+ args: ["push", remote, refspec],
263
+ };
264
+ }
265
+
266
+ export async function hasWorkingTreeChanges(
267
+ pi: Pick<ExtensionAPI, "exec">,
268
+ ctx: GitCommandContext,
269
+ signal?: AbortSignal,
270
+ ): Promise<boolean> {
271
+ const result = await runGit(pi, ctx, ["status", "--porcelain=v1", "--branch"], {
272
+ signal,
273
+ timeout: GIT_STATUS_TIMEOUT_MS,
274
+ });
275
+ return result.stdout
276
+ .split("\n")
277
+ .some((line) => line.length > 0 && !line.startsWith("## "));
278
+ }
279
+
280
+ export async function getAheadBehindCount(
281
+ pi: Pick<ExtensionAPI, "exec">,
282
+ ctx: GitCommandContext,
283
+ signal?: AbortSignal,
284
+ ): Promise<AheadBehindCount> {
285
+ const result = await runGit(pi, ctx, ["rev-list", "--left-right", "--count", "HEAD...@{u}"], {
286
+ signal,
287
+ timeout: GIT_STATUS_TIMEOUT_MS,
288
+ });
289
+ const [aheadText, behindText] = trimOutput(result.stdout).split(/\s+/u);
290
+ const ahead = Number(aheadText);
291
+ const behind = Number(behindText);
292
+ if (!Number.isFinite(ahead) || !Number.isFinite(behind)) {
293
+ throw new Error(`Unable to parse ahead/behind counts from git output: ${safeOutput(result.stdout)}`);
294
+ }
295
+ return { ahead, behind };
296
+ }
297
+
298
+ export function validateBranchNameInput(branchName: unknown, label = "Branch name"): asserts branchName is string {
299
+ if (typeof branchName !== "string") throw new Error(`${label} must be a string.`);
300
+ if (branchName.length === 0) throw new Error(`${label} is required.`);
301
+ if (branchName.trim().length === 0) throw new Error(`${label} cannot be blank.`);
302
+ if (branchName !== branchName.trim()) throw new Error(`${label} cannot start or end with whitespace.`);
303
+ if (branchName.startsWith("-")) throw new Error(`${label} cannot start with '-'.`);
304
+ if (/[\u0000-\u001f\u007f]/u.test(branchName)) {
305
+ throw new Error(`${label} cannot contain NUL, newline, or control characters.`);
306
+ }
307
+ if (/\s/u.test(branchName)) throw new Error(`${label} cannot contain whitespace.`);
308
+ }
309
+
310
+ export async function validateBranchName(
311
+ pi: Pick<ExtensionAPI, "exec">,
312
+ ctx: GitCommandContext,
313
+ branchName: string,
314
+ signal?: AbortSignal,
315
+ ): Promise<void> {
316
+ validateBranchNameInput(branchName);
317
+ const args = ["check-ref-format", "--branch", branchName];
318
+ const result = await runGit(pi, ctx, args, { signal, timeout: GIT_STATUS_TIMEOUT_MS, allowFailure: true });
319
+ if (result.code !== 0) {
320
+ throw new Error(`Invalid branch name '${redactSecrets(branchName)}': ${safeOutput(result.stderr || result.stdout) || "git rejected the ref"}`);
321
+ }
322
+ }
323
+
324
+ export async function localBranchExists(
325
+ pi: Pick<ExtensionAPI, "exec">,
326
+ ctx: GitCommandContext,
327
+ branchName: string,
328
+ signal?: AbortSignal,
329
+ ): Promise<boolean> {
330
+ const result = await runGit(pi, ctx, ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], {
331
+ signal,
332
+ timeout: GIT_STATUS_TIMEOUT_MS,
333
+ allowFailure: true,
334
+ });
335
+ return result.code === 0;
336
+ }
337
+
338
+ export async function getLocalBranchCommit(
339
+ pi: Pick<ExtensionAPI, "exec">,
340
+ ctx: GitCommandContext,
341
+ branchName: string,
342
+ signal?: AbortSignal,
343
+ ): Promise<string> {
344
+ validateBranchNameInput(branchName);
345
+ const result = await runGit(pi, ctx, ["rev-parse", "--verify", `refs/heads/${branchName}^{commit}`], {
346
+ signal,
347
+ timeout: GIT_STATUS_TIMEOUT_MS,
348
+ });
349
+ const commit = trimOutput(result.stdout);
350
+ if (!/^[0-9a-f]{40,64}$/iu.test(commit)) {
351
+ throw new Error(`Unable to resolve local branch '${redactSecrets(branchName)}' to a commit: ${safeOutput(result.stdout) || "empty output"}`);
352
+ }
353
+ return commit;
354
+ }
355
+
356
+ export async function createLocalBranch(
357
+ pi: Pick<ExtensionAPI, "exec">,
358
+ ctx: GitCommandContext,
359
+ branchName: string,
360
+ signal?: AbortSignal,
361
+ ): Promise<CreateBranchDetails> {
362
+ const repoRoot = await getGitRoot(pi, ctx, signal);
363
+ const rootCtx = { cwd: repoRoot };
364
+
365
+ return withRepositoryMutationQueue(repoRoot, async () => {
366
+ const previousBranch = await requireCurrentBranch(pi, rootCtx, signal);
367
+ await validateBranchName(pi, rootCtx, branchName, signal);
368
+
369
+ if (await localBranchExists(pi, rootCtx, branchName, signal)) {
370
+ throw new Error(`Local branch '${redactSecrets(branchName)}' already exists.`);
371
+ }
372
+
373
+ await runGit(pi, rootCtx, ["switch", "-c", branchName], {
374
+ signal,
375
+ timeout: GIT_MUTATION_TIMEOUT_MS,
376
+ });
377
+
378
+ return { repoRoot, previousBranch: safeDetail(previousBranch), newBranch: safeDetail(branchName) };
379
+ });
380
+ }
381
+
382
+ export async function changeExistingLocalBranch(
383
+ pi: Pick<ExtensionAPI, "exec">,
384
+ ctx: GitCommandContext,
385
+ branchName: string,
386
+ signal?: AbortSignal,
387
+ ): Promise<ChangeBranchDetails> {
388
+ const repoRoot = await getGitRoot(pi, ctx, signal);
389
+ const rootCtx = { cwd: repoRoot };
390
+
391
+ return withRepositoryMutationQueue(repoRoot, async () => {
392
+ await validateBranchName(pi, rootCtx, branchName, signal);
393
+
394
+ if (!(await localBranchExists(pi, rootCtx, branchName, signal))) {
395
+ throw new Error(`Local branch '${redactSecrets(branchName)}' does not exist.`);
396
+ }
397
+
398
+ const previous = await getCurrentBranch(pi, rootCtx, signal);
399
+ if (!previous.detached && previous.currentBranch === branchName) {
400
+ throw new Error(`Already on branch '${redactSecrets(branchName)}'.`);
401
+ }
402
+
403
+ if (await hasWorkingTreeChanges(pi, rootCtx, signal)) {
404
+ throw new Error("Working tree has uncommitted changes; clean it before changing branches.");
405
+ }
406
+
407
+ await runGit(pi, rootCtx, ["switch", branchName], {
408
+ signal,
409
+ timeout: GIT_MUTATION_TIMEOUT_MS,
410
+ });
411
+
412
+ const current = await getCurrentBranch(pi, rootCtx, signal);
413
+ if (current.detached || current.currentBranch !== branchName) {
414
+ throw new Error(`git switch did not end on branch '${redactSecrets(branchName)}'.`);
415
+ }
416
+
417
+ return {
418
+ repoRoot,
419
+ previousBranch: safeNullableDetail(previous.currentBranch),
420
+ previousDetached: previous.detached,
421
+ currentBranch: safeDetail(current.currentBranch),
422
+ hasChangesBeforeSwitch: false,
423
+ };
424
+ });
425
+ }
426
+
427
+ export async function pushCurrentBranch(
428
+ pi: Pick<ExtensionAPI, "exec">,
429
+ ctx: GitCommandContext,
430
+ signal?: AbortSignal,
431
+ ): Promise<PushBranchDetails> {
432
+ const repoRoot = await getGitRoot(pi, ctx, signal);
433
+ const rootCtx = { cwd: repoRoot };
434
+
435
+ return withRepositoryMutationQueue(repoRoot, async () => {
436
+ const currentBranch = await requireCurrentBranch(pi, rootCtx, signal);
437
+ const target = await resolvePushTarget(pi, rootCtx, currentBranch, signal);
438
+ const result = await runGit(pi, rootCtx, target.args, {
439
+ signal,
440
+ timeout: GIT_PUSH_TIMEOUT_MS,
441
+ });
442
+
443
+ return {
444
+ repoRoot,
445
+ currentBranch: safeDetail(currentBranch),
446
+ upstream: safeNullableDetail(target.upstream),
447
+ mode: target.mode,
448
+ remote: safeDetail(target.remote),
449
+ remoteRef: safeDetail(target.remoteRef),
450
+ refspec: safeDetail(target.refspec),
451
+ output: safeOutput(result.stdout || result.stderr),
452
+ };
453
+ });
454
+ }
455
+
456
+ export async function getOriginUrl(
457
+ pi: Pick<ExtensionAPI, "exec">,
458
+ ctx: GitCommandContext,
459
+ signal?: AbortSignal,
460
+ ): Promise<string | null> {
461
+ const result = await runGit(pi, ctx, ["remote", "get-url", "origin"], {
462
+ signal,
463
+ timeout: GIT_STATUS_TIMEOUT_MS,
464
+ allowFailure: true,
465
+ });
466
+ if (result.code !== 0) return null;
467
+
468
+ const url = trimOutput(result.stdout);
469
+ return url || null;
470
+ }
471
+
472
+ export async function getBranchStatus(
473
+ pi: Pick<ExtensionAPI, "exec">,
474
+ ctx: GitCommandContext,
475
+ signal?: AbortSignal,
476
+ ): Promise<BranchStatusDetails> {
477
+ const repoRoot = await getGitRoot(pi, ctx, signal);
478
+ const current = await getCurrentBranch(pi, ctx, signal);
479
+ const upstream = current.detached ? null : await getUpstreamBranch(pi, ctx, signal);
480
+ const hasChanges = await hasWorkingTreeChanges(pi, ctx, signal);
481
+ const warnings: string[] = [];
482
+ let counts: AheadBehindCount = { ahead: null, behind: null };
483
+ if (upstream) {
484
+ try {
485
+ counts = await getAheadBehindCount(pi, ctx, signal);
486
+ } catch (error) {
487
+ if (signal?.aborted) throw error;
488
+ const message = safeOutput(error instanceof Error ? error.message : String(error)) || "git rev-list failed";
489
+ warnings.push(`ahead/behind unavailable: ${message}`);
490
+ }
491
+ }
492
+
493
+ return {
494
+ repoRoot,
495
+ currentBranch: safeNullableDetail(current.currentBranch),
496
+ detached: current.detached,
497
+ upstream: safeNullableDetail(upstream),
498
+ hasChanges,
499
+ ahead: counts.ahead,
500
+ behind: counts.behind,
501
+ ...(warnings.length > 0 ? { warnings } : {}),
502
+ };
503
+ }