@senad-d/branchme 0.1.3 → 0.1.5

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 CHANGED
@@ -1,6 +1,10 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import {
3
+ GIT_CONTEXT_CHANGE_LIMIT,
4
+ GIT_CONTEXT_RECENT_COMMIT_LIMIT,
5
+ GIT_CONTEXT_VALUE_LIMIT_CHARS,
3
6
  GIT_MUTATION_TIMEOUT_MS,
7
+ GIT_PULL_TIMEOUT_MS,
4
8
  GIT_PUSH_TIMEOUT_MS,
5
9
  GIT_STATUS_TIMEOUT_MS,
6
10
  MAX_SUMMARY_OUTPUT_CHARS,
@@ -13,7 +17,12 @@ import type {
13
17
  CreateBranchDetails,
14
18
  CurrentBranchInfo,
15
19
  GitExecResult,
20
+ GitFileChange,
21
+ GitFileChangeSummary,
22
+ PullBranchDetails,
16
23
  PushBranchDetails,
24
+ RecentCommit,
25
+ WorkingTreeDetails,
17
26
  } from "./types.ts";
18
27
 
19
28
  export interface GitCommandContext {
@@ -175,6 +184,12 @@ export async function getUpstreamBranch(
175
184
  return upstream || null;
176
185
  }
177
186
 
187
+ interface ConfiguredUpstreamTarget {
188
+ upstream: string;
189
+ remote: string;
190
+ remoteRef: string;
191
+ }
192
+
178
193
  interface PushTarget {
179
194
  upstream: string | null;
180
195
  mode: "push" | "publish";
@@ -184,21 +199,23 @@ interface PushTarget {
184
199
  args: string[];
185
200
  }
186
201
 
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 '-'.");
202
+ type UpstreamOperation = "pull" | "push";
203
+
204
+ function validateRemoteName(remote: string, operation: UpstreamOperation): void {
205
+ if (!remote) throw new Error(`Unable to ${operation} current branch: upstream remote is missing.`);
206
+ if (remote === ".") throw new Error(`Unable to ${operation} current branch: upstream is a local branch, not a remote.`);
207
+ if (remote.startsWith("-")) throw new Error(`Unable to ${operation} current branch: upstream remote cannot start with '-'.`);
191
208
  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.");
209
+ throw new Error(`Unable to ${operation} current branch: upstream remote name cannot be a URL or user-prefixed target.`);
193
210
  }
194
211
  if (/[\u0000-\u001f\u007f]/u.test(remote) || /\s/u.test(remote)) {
195
- throw new Error("Unable to push current branch: upstream remote contains whitespace or control characters.");
212
+ throw new Error(`Unable to ${operation} current branch: upstream remote contains whitespace or control characters.`);
196
213
  }
197
214
  }
198
215
 
199
- function normalizeRemoteHeadRef(mergeRef: string): string {
216
+ function normalizeRemoteHeadRef(mergeRef: string, operation: UpstreamOperation): string {
200
217
  if (!mergeRef.startsWith("refs/heads/")) {
201
- throw new Error("Unable to push current branch: upstream merge ref is not a branch ref.");
218
+ throw new Error(`Unable to ${operation} current branch: upstream merge ref is not a branch ref.`);
202
219
  }
203
220
 
204
221
  const branchName = mergeRef.slice("refs/heads/".length);
@@ -224,15 +241,39 @@ async function getBranchConfigValue(
224
241
  return value || null;
225
242
  }
226
243
 
227
- async function resolvePushTarget(
244
+ async function resolveConfiguredUpstreamTarget(
228
245
  pi: Pick<ExtensionAPI, "exec">,
229
246
  ctx: GitCommandContext,
230
247
  currentBranch: string,
248
+ operation: UpstreamOperation,
231
249
  signal?: AbortSignal,
232
- ): Promise<PushTarget> {
250
+ ): Promise<ConfiguredUpstreamTarget | null> {
233
251
  validateBranchNameInput(currentBranch);
234
252
  const upstream = await getUpstreamBranch(pi, ctx, signal);
235
- if (!upstream) {
253
+ if (!upstream) return null;
254
+
255
+ const remote = await getBranchConfigValue(pi, ctx, currentBranch, "remote", signal);
256
+ const mergeRef = await getBranchConfigValue(pi, ctx, currentBranch, "merge", signal);
257
+ if (!remote || !mergeRef) {
258
+ throw new Error(`Unable to ${operation} current branch: upstream exists but branch remote/merge configuration is incomplete.`);
259
+ }
260
+
261
+ validateRemoteName(remote, operation);
262
+ return {
263
+ upstream,
264
+ remote,
265
+ remoteRef: normalizeRemoteHeadRef(mergeRef, operation),
266
+ };
267
+ }
268
+
269
+ async function resolvePushTarget(
270
+ pi: Pick<ExtensionAPI, "exec">,
271
+ ctx: GitCommandContext,
272
+ currentBranch: string,
273
+ signal?: AbortSignal,
274
+ ): Promise<PushTarget> {
275
+ const upstreamTarget = await resolveConfiguredUpstreamTarget(pi, ctx, currentBranch, "push", signal);
276
+ if (!upstreamTarget) {
236
277
  const remoteRef = `refs/heads/${currentBranch}`;
237
278
  return {
238
279
  upstream: null,
@@ -244,22 +285,12 @@ async function resolvePushTarget(
244
285
  };
245
286
  }
246
287
 
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}`;
288
+ const refspec = `HEAD:${upstreamTarget.remoteRef}`;
256
289
  return {
257
- upstream,
290
+ ...upstreamTarget,
258
291
  mode: "push",
259
- remote,
260
- remoteRef,
261
292
  refspec,
262
- args: ["push", remote, refspec],
293
+ args: ["push", upstreamTarget.remote, refspec],
263
294
  };
264
295
  }
265
296
 
@@ -277,6 +308,251 @@ export async function hasWorkingTreeChanges(
277
308
  .some((line) => line.length > 0 && !line.startsWith("## "));
278
309
  }
279
310
 
311
+ export interface WorkingTreeStatus {
312
+ workingTree: WorkingTreeDetails;
313
+ unstagedChanges: GitFileChangeSummary;
314
+ }
315
+
316
+ const UNMERGED_GIT_STATUSES = new Set(["DD", "AU", "UD", "UA", "DU", "AA", "UU"]);
317
+ const GIT_STATUS_CODE_PATTERN = /^[ MADRCUT?!]{2}$/u;
318
+ const GIT_LOG_FIELD_SEPARATOR = "\u001f";
319
+ const NUL_SEPARATOR = "\u0000";
320
+ const GIT_LOG_FORMAT = `%x00%H%x1f%h%x1f%ad%x1f%s`;
321
+
322
+ function truncateGitContextValue(value: string): string {
323
+ if (value.length <= GIT_CONTEXT_VALUE_LIMIT_CHARS) return value;
324
+
325
+ let end = GIT_CONTEXT_VALUE_LIMIT_CHARS - 1;
326
+ const lastCodePoint = value.codePointAt(end - 1);
327
+ if (lastCodePoint !== undefined && lastCodePoint > 0xffff) end -= 1;
328
+ return `${value.slice(0, end)}…`;
329
+ }
330
+
331
+ function escapeGitContextControlCharacter(character: string): string {
332
+ const codePoint = character.codePointAt(0);
333
+ return codePoint === undefined ? "" : String.raw`\u${codePoint.toString(16).padStart(4, "0")}`;
334
+ }
335
+
336
+ function safeGitContextValue(value: string): string {
337
+ const redacted = redactSecrets(value);
338
+ const escaped = redacted.replace(/[\p{Cc}\p{Cf}\u2028\u2029]/gu, escapeGitContextControlCharacter);
339
+ return truncateGitContextValue(escaped);
340
+ }
341
+
342
+ function isRenameOrCopyStatus(status: string): boolean {
343
+ return status.startsWith("R") || status.startsWith("C") || status.endsWith("R") || status.endsWith("C");
344
+ }
345
+
346
+ function parsePorcelainRecord(record: string): { status: string; path: string } {
347
+ if (record.length < 4 || record[2] !== " ") {
348
+ throw new TypeError("Unable to parse working-tree state: malformed git status record.");
349
+ }
350
+
351
+ const status = record.slice(0, 2);
352
+ const path = record.slice(3);
353
+ if (!GIT_STATUS_CODE_PATTERN.test(status) || status === " " || path.length === 0) {
354
+ throw new TypeError("Unable to parse working-tree state: malformed git status record.");
355
+ }
356
+ return { status, path };
357
+ }
358
+
359
+ function appendUnstagedChange(
360
+ entries: GitFileChange[],
361
+ change: GitFileChange,
362
+ omitted: number,
363
+ ): number {
364
+ if (entries.length < GIT_CONTEXT_CHANGE_LIMIT) {
365
+ entries.push(change);
366
+ return omitted;
367
+ }
368
+ return omitted + 1;
369
+ }
370
+
371
+ interface ParsedPorcelainChange {
372
+ status: string;
373
+ path: string;
374
+ originalPath?: string;
375
+ nextIndex: number;
376
+ }
377
+
378
+ interface WorkingTreeChangeCounts {
379
+ staged: number;
380
+ unstaged: number;
381
+ untracked: number;
382
+ includeInUnstagedChanges: boolean;
383
+ }
384
+
385
+ function parsePorcelainChange(records: string[], index: number): ParsedPorcelainChange {
386
+ const parsed = parsePorcelainRecord(records[index]);
387
+ if (!isRenameOrCopyStatus(parsed.status)) return { ...parsed, nextIndex: index };
388
+
389
+ const originalPath = records[index + 1];
390
+ if (originalPath === undefined || originalPath.length === 0) {
391
+ throw new TypeError("Unable to parse working-tree state: rename or copy source path is missing.");
392
+ }
393
+ return { ...parsed, originalPath, nextIndex: index + 1 };
394
+ }
395
+
396
+ function workingTreeChangeCounts(status: string): WorkingTreeChangeCounts {
397
+ const isUntracked = status === "??";
398
+ const isUnmerged = UNMERGED_GIT_STATUSES.has(status);
399
+ const hasStagedChange = !isUntracked && !isUnmerged && !status.startsWith(" ");
400
+ const hasUnstagedChange = !isUntracked && (isUnmerged || !status.endsWith(" "));
401
+
402
+ return {
403
+ staged: Number(hasStagedChange),
404
+ unstaged: Number(hasUnstagedChange),
405
+ untracked: Number(isUntracked),
406
+ includeInUnstagedChanges: isUntracked || hasUnstagedChange,
407
+ };
408
+ }
409
+
410
+ function gitFileChange(parsed: ParsedPorcelainChange): GitFileChange {
411
+ return {
412
+ status: parsed.status,
413
+ path: safeGitContextValue(parsed.path),
414
+ ...(parsed.originalPath === undefined
415
+ ? {}
416
+ : { originalPath: safeGitContextValue(parsed.originalPath) }),
417
+ };
418
+ }
419
+
420
+ export function parseWorkingTreeStatus(output: string): WorkingTreeStatus {
421
+ const records = output.split(NUL_SEPARATOR);
422
+ const entries: GitFileChange[] = [];
423
+ let staged = 0;
424
+ let unstaged = 0;
425
+ let untracked = 0;
426
+ let omitted = 0;
427
+ let dirty = false;
428
+ let skipNextRecord = false;
429
+
430
+ for (const [index, record] of records.entries()) {
431
+ if (skipNextRecord) {
432
+ skipNextRecord = false;
433
+ continue;
434
+ }
435
+ if (record.length === 0) continue;
436
+
437
+ const parsed = parsePorcelainChange(records, index);
438
+ skipNextRecord = parsed.nextIndex > index;
439
+ if (parsed.status === "!!") continue;
440
+ dirty = true;
441
+
442
+ const counts = workingTreeChangeCounts(parsed.status);
443
+ staged += counts.staged;
444
+ unstaged += counts.unstaged;
445
+ untracked += counts.untracked;
446
+ if (!counts.includeInUnstagedChanges) continue;
447
+
448
+ omitted = appendUnstagedChange(entries, gitFileChange(parsed), omitted);
449
+ }
450
+
451
+ return {
452
+ workingTree: {
453
+ state: dirty ? "dirty" : "clean",
454
+ staged,
455
+ unstaged,
456
+ untracked,
457
+ },
458
+ unstagedChanges: { entries, omitted },
459
+ };
460
+ }
461
+
462
+ export async function getWorkingTreeStatus(
463
+ pi: Pick<ExtensionAPI, "exec">,
464
+ ctx: GitCommandContext,
465
+ signal?: AbortSignal,
466
+ ): Promise<WorkingTreeStatus> {
467
+ const repoRoot = await getGitRoot(pi, ctx, signal);
468
+ const args = ["status", "--porcelain=v1", "-z", "--untracked-files=normal"];
469
+ const result = await runGit(pi, { cwd: repoRoot }, args, {
470
+ signal,
471
+ timeout: GIT_STATUS_TIMEOUT_MS,
472
+ });
473
+ return parseWorkingTreeStatus(result.stdout);
474
+ }
475
+
476
+ function stripGitLogRecordTerminator(record: string): string {
477
+ if (record.endsWith("\r\n")) return record.slice(0, -2);
478
+ if (record.endsWith("\n")) return record.slice(0, -1);
479
+ return record;
480
+ }
481
+
482
+ function parseRecentCommitRecord(record: string): RecentCommit {
483
+ const firstSeparator = record.indexOf(GIT_LOG_FIELD_SEPARATOR);
484
+ const secondSeparator = record.indexOf(GIT_LOG_FIELD_SEPARATOR, firstSeparator + 1);
485
+ const thirdSeparator = record.indexOf(GIT_LOG_FIELD_SEPARATOR, secondSeparator + 1);
486
+ if (firstSeparator < 1 || secondSeparator <= firstSeparator + 1 || thirdSeparator <= secondSeparator + 1) {
487
+ throw new TypeError("Unable to parse recent commits: malformed git log output.");
488
+ }
489
+
490
+ const hash = record.slice(0, firstSeparator);
491
+ const shortHash = record.slice(firstSeparator + 1, secondSeparator);
492
+ const date = record.slice(secondSeparator + 1, thirdSeparator);
493
+ const subject = record.slice(thirdSeparator + 1);
494
+ if (
495
+ !/^[0-9a-f]{40,64}$/iu.test(hash) ||
496
+ !/^[0-9a-f]{4,64}$/iu.test(shortHash) ||
497
+ !hash.toLowerCase().startsWith(shortHash.toLowerCase()) ||
498
+ !/^\d{4}-\d{2}-\d{2}$/u.test(date)
499
+ ) {
500
+ throw new TypeError("Unable to parse recent commits: malformed git log output.");
501
+ }
502
+
503
+ return {
504
+ hash,
505
+ shortHash,
506
+ date,
507
+ subject: safeGitContextValue(subject),
508
+ };
509
+ }
510
+
511
+ export function parseRecentCommits(output: string): RecentCommit[] {
512
+ const records = output.split(NUL_SEPARATOR);
513
+ const commits: RecentCommit[] = [];
514
+ for (const rawRecord of records) {
515
+ if (rawRecord.length === 0) continue;
516
+ const record = stripGitLogRecordTerminator(rawRecord);
517
+ if (record.length === 0) continue;
518
+ commits.push(parseRecentCommitRecord(record));
519
+ if (commits.length === GIT_CONTEXT_RECENT_COMMIT_LIMIT) break;
520
+ }
521
+ return commits;
522
+ }
523
+
524
+ export async function getRecentCommits(
525
+ pi: Pick<ExtensionAPI, "exec">,
526
+ ctx: GitCommandContext,
527
+ signal?: AbortSignal,
528
+ ): Promise<RecentCommit[]> {
529
+ const repoRoot = await getGitRoot(pi, ctx, signal);
530
+ const rootCtx = { cwd: repoRoot };
531
+ const args = [
532
+ "log",
533
+ "-n",
534
+ String(GIT_CONTEXT_RECENT_COMMIT_LIMIT),
535
+ "--date=short",
536
+ `--format=${GIT_LOG_FORMAT}`,
537
+ "HEAD",
538
+ ];
539
+ const result = await runGit(pi, rootCtx, args, {
540
+ signal,
541
+ timeout: GIT_STATUS_TIMEOUT_MS,
542
+ allowFailure: true,
543
+ });
544
+ if (result.code === 0) return parseRecentCommits(result.stdout);
545
+ if (signal?.aborted) throw new Error(formatGitFailure(args, result));
546
+
547
+ const verifyHead = await runGit(pi, rootCtx, ["rev-parse", "--verify", "HEAD"], {
548
+ signal,
549
+ timeout: GIT_STATUS_TIMEOUT_MS,
550
+ allowFailure: true,
551
+ });
552
+ if (verifyHead.code !== 0) return [];
553
+ throw new Error(formatGitFailure(args, result));
554
+ }
555
+
280
556
  export async function getAheadBehindCount(
281
557
  pi: Pick<ExtensionAPI, "exec">,
282
558
  ctx: GitCommandContext,
@@ -424,6 +700,41 @@ export async function changeExistingLocalBranch(
424
700
  });
425
701
  }
426
702
 
703
+ export async function pullCurrentBranch(
704
+ pi: Pick<ExtensionAPI, "exec">,
705
+ ctx: GitCommandContext,
706
+ signal?: AbortSignal,
707
+ ): Promise<PullBranchDetails> {
708
+ const repoRoot = await getGitRoot(pi, ctx, signal);
709
+ const rootCtx = { cwd: repoRoot };
710
+
711
+ return withRepositoryMutationQueue(repoRoot, async () => {
712
+ const currentBranch = await requireCurrentBranch(pi, rootCtx, signal);
713
+ if (await hasWorkingTreeChanges(pi, rootCtx, signal)) {
714
+ throw new Error("Working tree has uncommitted changes; clean it before pulling the current branch.");
715
+ }
716
+
717
+ const target = await resolveConfiguredUpstreamTarget(pi, rootCtx, currentBranch, "pull", signal);
718
+ if (!target) {
719
+ throw new Error(`Unable to pull current branch '${safeDetail(currentBranch)}': no upstream is configured.`);
720
+ }
721
+
722
+ const result = await runGit(pi, rootCtx, ["pull", "--ff-only", "--no-rebase", "--no-autostash", target.remote, target.remoteRef], {
723
+ signal,
724
+ timeout: GIT_PULL_TIMEOUT_MS,
725
+ });
726
+
727
+ return {
728
+ repoRoot,
729
+ currentBranch: safeDetail(currentBranch),
730
+ upstream: safeDetail(target.upstream),
731
+ remote: safeDetail(target.remote),
732
+ remoteRef: safeDetail(target.remoteRef),
733
+ output: safeOutput(result.stdout || result.stderr),
734
+ };
735
+ });
736
+ }
737
+
427
738
  export async function pushCurrentBranch(
428
739
  pi: Pick<ExtensionAPI, "exec">,
429
740
  ctx: GitCommandContext,