@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/CHANGELOG.md +6 -4
- package/README.md +56 -25
- package/SECURITY.md +24 -7
- package/docs/SMOKE_TEST.md +8 -6
- package/docs/STRUCTURE.md +27 -17
- package/docs/TUI_CAPTURE.md +10 -7
- package/package.json +3 -3
- package/src/commands/branchme-command.ts +8 -5
- package/src/constants.ts +9 -0
- package/src/extension.ts +2 -0
- package/src/git-context.ts +363 -0
- package/src/git.ts +335 -24
- package/src/github.ts +305 -2
- package/src/tools/branchme-tools.ts +39 -21
- package/src/types.ts +57 -0
- package/src/ui/branchme-panel.ts +2 -1
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (remote
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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<
|
|
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
|
|
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
|
-
|
|
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,
|