dsh-git-branch-manage 1.2.0 → 1.3.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/lib/git.d.ts CHANGED
@@ -11,6 +11,8 @@ export interface ExecResult {
11
11
  stderr: string;
12
12
  /** Node-level failure text when the process never produced stderr. */
13
13
  errorMessage?: string;
14
+ /** True when the executor killed the process at its timeout budget. */
15
+ timedOut?: boolean;
14
16
  }
15
17
  /**
16
18
  * Process executor seam. Runs `file args` with cwd set, captures output as
@@ -19,14 +21,25 @@ export interface ExecResult {
19
21
  export type Exec = (file: string, args: readonly string[], options: {
20
22
  cwd: string;
21
23
  timeoutMs?: number;
24
+ env?: Record<string, string | undefined>;
22
25
  }) => Promise<ExecResult>;
23
26
  /** Failure of one git invocation: non-zero exit with the stderr text. */
24
27
  export declare class GitError extends Error {
25
28
  readonly args: readonly string[];
26
29
  readonly code: number;
27
30
  readonly stderr: string;
28
- constructor(args: readonly string[], code: number, stderr: string);
31
+ /** True when the executor killed the command at its budget — no verdict. */
32
+ readonly timedOut: boolean;
33
+ constructor(args: readonly string[], code: number, stderr: string,
34
+ /** True when the executor killed the command at its budget — no verdict. */
35
+ timedOut?: boolean);
29
36
  }
37
+ /**
38
+ * Whether an endpoint speaks the SSH transport (`ssh://…`, `git@host:…`, or
39
+ * any `user@host:path` scp-style URL) — none of the network channels apply.
40
+ * @param url - a remote URL as git printed it, or undefined.
41
+ */
42
+ export declare function isSshEndpoint(url: string | undefined): boolean;
30
43
  /** Facts the status route assembles: branches, worktrees, repo identity. */
31
44
  export interface RepoFacts {
32
45
  /** Absolute main worktree path (`dirname` of `--git-common-dir`, not the
@@ -58,15 +71,15 @@ export interface RepoFacts {
58
71
  /** Default hard timeout for one git call: generous for every LOCAL
59
72
  * operation (status/log/commit/worktree) while still bounding hangs. */
60
73
  export declare const GIT_TIMEOUT_MS = 20000;
61
- /** Network operations (fetch/push) get a far wider budget: a slow link with
62
- * a real pack transfer outruns the local default, and a killed push may have
63
- * ALREADY updated the remote refs — an error toast for a push that actually
64
- * landed. 120s still bounds a hung credential prompt. */
65
- export declare const NETWORK_TIMEOUT_MS = 120000;
74
+ /** Network budgets live in `network.ts` (probe/fetch/push/verify): a slow
75
+ * link with a real pack transfer outruns the local default, and a killed
76
+ * push may have ALREADY updated the remote refs — an error toast for a push
77
+ * that actually landed. */
66
78
  /** Real executor over node:child_process with a hard timeout. */
67
79
  export declare function childProcessExec(file: string, args: readonly string[], options: {
68
80
  cwd: string;
69
81
  timeoutMs?: number;
82
+ env?: Record<string, string | undefined>;
70
83
  }): Promise<ExecResult>;
71
84
  /** Directory-existence seam over the parsed worktree registrations. */
72
85
  export type DirExists = (path: string) => boolean;
@@ -256,7 +269,7 @@ export declare function accelArgs(prefix: string | undefined): readonly string[]
256
269
  * @param authArgs - optional per-host credential `-c` pairs (see
257
270
  * `credentials.ts`); prepended before the subcommand like the accel rewrite.
258
271
  */
259
- export declare function fetchAll(exec: Exec, repoRoot: string, accelPrefix?: string, authArgs?: readonly string[]): Promise<void>;
272
+ export declare function fetchAll(exec: Exec, repoRoot: string, accelPrefix?: string, authArgs?: readonly string[], options?: NetworkOptions): Promise<void>;
260
273
  /** Outcome of {@link updateBranch}: the branch touched and whether it moved. */
261
274
  export interface UpdateOutcome {
262
275
  branch: string;
@@ -283,7 +296,62 @@ export declare function updateBranch(exec: Exec, cwd: string, accelPrefix?: stri
283
296
  export interface PushOutcome {
284
297
  branch: string;
285
298
  pushed: boolean;
299
+ /** Set when the push ran out of time but the remote ref already matches
300
+ * the local HEAD — the push landed, the connection just did not say so. */
301
+ note?: 'timed-out-but-landed';
302
+ }
303
+ /** The endpoint a write would use, resolved so it can be probed first. */
304
+ export interface PushTarget {
305
+ /** Remote name taken from the upstream (`origin`). */
306
+ remote: string;
307
+ /** The URL `git push` uses (a separate `pushurl` is respected); absent
308
+ * when git could not name one, in which case the remote name is the
309
+ * endpoint and the probe resolves it the same way push does. */
310
+ url?: string;
311
+ }
312
+ /** Per-command routing for a network command (a proxy's `-c` pairs). */
313
+ export interface NetworkOptions {
314
+ /** Argv placed before the subcommand that selects a network channel;
315
+ * empty/absent = the direct connection. */
316
+ channelArgs?: readonly string[];
317
+ /**
318
+ * Consulted ONLY after the direct channel's probe failed in a way another
319
+ * channel could plausibly fix (unreachable/reset/timeout), in order, until
320
+ * one of them answers argv that ALSO passes its own probe. Each provider
321
+ * answers undefined when it does not apply (a mirror URL, an unconfigured
322
+ * proxy) — that is a skip, not a failure. Empty/absent = direct only.
323
+ */
324
+ fallbackChannels?: readonly ChannelProvider[];
286
325
  }
326
+ /** Answers the argv for one replacement channel, or undefined when it does not apply. */
327
+ export type ChannelProvider = (endpoint: string) => Promise<readonly string[] | undefined>;
328
+ /**
329
+ * Resolve where a write to `@{u}` would go, WITHOUT sending anything.
330
+ * `remote get-url --push` is the one source of truth for the endpoint: a
331
+ * remote may configure a separate `pushurl`, and probing the fetch URL
332
+ * would validate a host the push never talks to.
333
+ * @param exec - executor seam.
334
+ * @param cwd - directory whose checked-out branch supplies the upstream.
335
+ * @param authArgs - per-host credential argv (kept for symmetry; `rev-parse`
336
+ * and `remote` are local and never authenticate).
337
+ * @returns the remote (and its push URL when git names one), or undefined
338
+ * when the branch has no upstream — that case belongs to git's own message.
339
+ */
340
+ export declare function resolvePushTarget(exec: Exec, cwd: string, authArgs?: readonly string[]): Promise<PushTarget | undefined>;
341
+ /**
342
+ * Reachability probe for a write: a READ of the push endpoint. A host that
343
+ * cannot answer `ls-remote` cannot receive a pack either, so failing here
344
+ * costs the probe budget (10s) instead of the push budget (600s), and the
345
+ * caller learns which endpoint was unreachable before any object moved.
346
+ * Terminal prompts are disabled for the probe: a probe must fail, never
347
+ * block on a credential dialog the user cannot see.
348
+ * @param exec - executor seam.
349
+ * @param cwd - any directory inside the repository.
350
+ * @param target - endpoint from {@link resolvePushTarget}.
351
+ * @param options - channel argv (proxy) to route the probe the same way.
352
+ * @param authArgs - per-host credential argv.
353
+ */
354
+ export declare function probePushTarget(exec: Exec, cwd: string, target: PushTarget, options?: NetworkOptions, authArgs?: readonly string[]): Promise<void>;
287
355
  /**
288
356
  * Push the branch checked out by `cwd` to its upstream (`@{u}`) — the
289
357
  * counterpart of {@link updateBranch}. Deliberately a bare `git push`: the
@@ -298,11 +366,13 @@ export interface PushOutcome {
298
366
  * pushed content to a third party.
299
367
  * @param exec - executor seam.
300
368
  * @param cwd - directory whose checked-out branch is pushed.
369
+ * @param authArgs - per-host credential argv.
370
+ * @param options - channel argv (proxy) applied to the probe and the push.
301
371
  * @returns the branch and whether the remote-tracking ref changed. A branch
302
372
  * without an upstream can still be handed to `git push`, so Git's own
303
373
  * push-specific diagnostic is preserved; in that case `pushed` is false.
304
374
  */
305
- export declare function pushBranch(exec: Exec, cwd: string, authArgs?: readonly string[]): Promise<PushOutcome>;
375
+ export declare function pushBranch(exec: Exec, cwd: string, authArgs?: readonly string[], options?: NetworkOptions): Promise<PushOutcome>;
306
376
  /** Every LOCAL tag name of the repository, in git's own sort order. */
307
377
  export declare function listTagNames(exec: Exec, cwd: string): Promise<string[]>;
308
378
  /** Where a one-off push should go, or why no remote could be picked. */
@@ -328,6 +398,9 @@ export interface TagPushOutcome {
328
398
  tag: string;
329
399
  remote: string;
330
400
  pushed: boolean;
401
+ /** Set when the push timed out but the remote tag already names the local
402
+ * object — landed, with the connection gone before it could say so. */
403
+ note?: 'timed-out-but-landed';
331
404
  }
332
405
  /**
333
406
  * Push ONE existing local tag to `remote` (`git push <remote>
@@ -353,7 +426,7 @@ export interface TagPushOutcome {
353
426
  * @param remote - the remote to publish to (see {@link resolvePushRemote}).
354
427
  * @returns the tag, the remote, and whether the push had to run.
355
428
  */
356
- export declare function pushTag(exec: Exec, cwd: string, name: string, remote: string, authArgs?: readonly string[]): Promise<TagPushOutcome>;
429
+ export declare function pushTag(exec: Exec, cwd: string, name: string, remote: string, authArgs?: readonly string[], options?: NetworkOptions): Promise<TagPushOutcome>;
357
430
  /** Guard for route inputs: a non-empty absolute directory path. */
358
431
  export declare function isAbsoluteDir(value: string): boolean;
359
432
  /**
package/lib/git.js CHANGED
@@ -7,52 +7,90 @@ import { execFile } from 'node:child_process';
7
7
  import { existsSync, readdirSync } from 'node:fs';
8
8
  import { appendFile, mkdir, readFile, rm } from 'node:fs/promises';
9
9
  import { basename, dirname, isAbsolute, join, normalize, resolve } from 'node:path';
10
+ import { classifyGitFailure, FETCH_TIMEOUT_MS, PROBE_TIMEOUT_MS, PUSH_TIMEOUT_MS, VERIFY_TIMEOUT_MS } from './network.js';
10
11
  import { accelerateUrlError, localBranchName, normalizeAccelerateUrl } from './normalize.js';
11
12
  /** Failure of one git invocation: non-zero exit with the stderr text. */
12
13
  export class GitError extends Error {
13
14
  args;
14
15
  code;
15
16
  stderr;
16
- constructor(args, code, stderr) {
17
+ timedOut;
18
+ constructor(args, code, stderr,
19
+ /** True when the executor killed the command at its budget — no verdict. */
20
+ timedOut = false) {
17
21
  super(`git ${args.join(' ')} failed (exit ${code}): ${stderr.trim()}`);
18
22
  this.args = args;
19
23
  this.code = code;
20
24
  this.stderr = stderr;
25
+ this.timedOut = timedOut;
21
26
  this.name = 'GitError';
22
27
  }
23
28
  }
29
+ /** Appended to reachability errors on an SSH remote: the HTTP channels
30
+ * (mirror prefix, DNS pin, http proxy) cannot carry an SSH transport, so the
31
+ * user has to change the endpoint itself. */
32
+ const SSH_REMOTE_HINT = 'SSH remote: 代理与 IP 优选只作用于 https 远端;网络到 github.com:22 不通时可把远端改成 https://github.com/<owner>/<repo>.git。';
33
+ /**
34
+ * Whether an endpoint speaks the SSH transport (`ssh://…`, `git@host:…`, or
35
+ * any `user@host:path` scp-style URL) — none of the network channels apply.
36
+ * @param url - a remote URL as git printed it, or undefined.
37
+ */
38
+ export function isSshEndpoint(url) {
39
+ if (url === undefined)
40
+ return false;
41
+ const text = url.trim();
42
+ return text.startsWith('ssh://') || /^[^\s/]+@[^\s/]+:/.test(text);
43
+ }
44
+ /** Re-throw a failure with the SSH hint appended once. */
45
+ function withSshHint(error) {
46
+ if (!(error instanceof GitError) || error.stderr.includes(SSH_REMOTE_HINT))
47
+ return error;
48
+ return new GitError(error.args, error.code, `${error.stderr.trimEnd()}\n\n${SSH_REMOTE_HINT}`, error.timedOut);
49
+ }
24
50
  /** Default hard timeout for one git call: generous for every LOCAL
25
51
  * operation (status/log/commit/worktree) while still bounding hangs. */
26
52
  export const GIT_TIMEOUT_MS = 20_000;
27
- /** Network operations (fetch/push) get a far wider budget: a slow link with
28
- * a real pack transfer outruns the local default, and a killed push may have
29
- * ALREADY updated the remote refs — an error toast for a push that actually
30
- * landed. 120s still bounds a hung credential prompt. */
31
- export const NETWORK_TIMEOUT_MS = 120_000;
53
+ /** Network budgets live in `network.ts` (probe/fetch/push/verify): a slow
54
+ * link with a real pack transfer outruns the local default, and a killed
55
+ * push may have ALREADY updated the remote refs — an error toast for a push
56
+ * that actually landed. */
32
57
  /** Real executor over node:child_process with a hard timeout. */
33
58
  export function childProcessExec(file, args, options) {
34
59
  return new Promise((resolvePromise) => {
35
- execFile(file, args, { cwd: options.cwd, encoding: 'utf8', timeout: options.timeoutMs ?? GIT_TIMEOUT_MS, windowsHide: true, maxBuffer: 4 * 1024 * 1024 }, (error, stdout, stderr) => {
60
+ execFile(file, args, {
61
+ cwd: options.cwd,
62
+ encoding: 'utf8',
63
+ timeout: options.timeoutMs ?? GIT_TIMEOUT_MS,
64
+ windowsHide: true,
65
+ maxBuffer: 4 * 1024 * 1024,
66
+ ...(options.env === undefined ? {} : { env: { ...process.env, ...options.env } }),
67
+ }, (error, stdout, stderr) => {
36
68
  const code = error === null ? 0 : typeof error.code === 'number' ? error.code : 1;
69
+ // execFile sets `killed` when its own timeout fired; the exit code is a
70
+ // synthetic 1, so without this flag a timeout is indistinguishable from
71
+ // a genuine refusal and a push would be reported failed although the
72
+ // remote may have applied it.
73
+ const timedOut = error !== null && error.killed === true;
37
74
  resolvePromise({
38
75
  code,
39
76
  stdout: stdout ?? '',
40
77
  stderr: stderr ?? '',
41
78
  ...(error instanceof Error ? { errorMessage: error.message } : {}),
79
+ ...(timedOut ? { timedOut } : {}),
42
80
  });
43
81
  });
44
82
  });
45
83
  }
46
84
  /** Run one git command; a non-zero exit raises {@link GitError} with stderr. */
47
- async function git(exec, cwd, args, timeoutMs) {
48
- const result = await exec('git', args, { cwd, ...(timeoutMs === undefined ? {} : { timeoutMs }) });
85
+ async function git(exec, cwd, args, timeoutMs, env) {
86
+ const result = await exec('git', args, { cwd, ...(timeoutMs === undefined ? {} : { timeoutMs }), ...(env === undefined ? {} : { env }) });
49
87
  if (result.code !== 0) {
50
88
  const diagnostic = result.stderr.trim() !== ''
51
89
  ? result.stderr
52
90
  : result.stdout.trim() !== ''
53
91
  ? result.stdout
54
92
  : result.errorMessage ?? 'git produced no diagnostic output';
55
- throw new GitError(args, result.code, diagnostic);
93
+ throw new GitError(args, result.code, diagnostic, result.timedOut === true);
56
94
  }
57
95
  return result.stdout;
58
96
  }
@@ -509,7 +547,11 @@ export async function deleteBranch(exec, repoRoot, name) {
509
547
  export function accelArgs(prefix) {
510
548
  if (prefix === undefined || accelerateUrlError(prefix) !== undefined)
511
549
  return [];
512
- return ['-c', `url.${normalizeAccelerateUrl(prefix)}.insteadOf=https://github.com/`];
550
+ const normalized = normalizeAccelerateUrl(prefix);
551
+ // The setting is a proxy prefix, not a replacement for github.com. Also
552
+ // accept a prefix that already includes the complete GitHub base URL.
553
+ const base = normalized.endsWith('/https://github.com/') ? normalized : `${normalized}https://github.com/`;
554
+ return ['-c', `url.${base}.insteadOf=https://github.com/`];
513
555
  }
514
556
  /**
515
557
  * Sync remote-tracking refs: fetch every remote and prune tracking branches
@@ -524,8 +566,51 @@ export function accelArgs(prefix) {
524
566
  * @param authArgs - optional per-host credential `-c` pairs (see
525
567
  * `credentials.ts`); prepended before the subcommand like the accel rewrite.
526
568
  */
527
- export async function fetchAll(exec, repoRoot, accelPrefix, authArgs = []) {
528
- await git(exec, repoRoot, [...accelArgs(accelPrefix), ...authArgs, 'fetch', '--all', '--prune'], NETWORK_TIMEOUT_MS);
569
+ export async function fetchAll(exec, repoRoot, accelPrefix, authArgs = [], options = {}) {
570
+ const accel = accelArgs(accelPrefix);
571
+ const base = [...accel, ...authArgs];
572
+ try {
573
+ await git(exec, repoRoot, [...base, 'fetch', '--all', '--prune'], FETCH_TIMEOUT_MS);
574
+ }
575
+ catch (firstError) {
576
+ let error = firstError;
577
+ // The mirror is only an accelerator, never an authority: a private repo
578
+ // answers 401 there, a bad day answers 5xx, and neither should hide the
579
+ // direct URL. Reads are idempotent, so one direct retry costs nothing.
580
+ if (accel.length > 0) {
581
+ try {
582
+ await git(exec, repoRoot, [...authArgs, 'fetch', '--all', '--prune'], FETCH_TIMEOUT_MS);
583
+ return;
584
+ }
585
+ catch (directError) {
586
+ error = directError;
587
+ }
588
+ }
589
+ // Reading is idempotent, so a retry on another channel cannot duplicate
590
+ // anything. The first remote's URL stands for the fetch: with one remote
591
+ // (the normal case) it IS the fetch.
592
+ const failure = error instanceof GitError ? classifyGitFailure(error.stderr, { timedOut: error.timedOut }) : undefined;
593
+ if (failure === undefined || !failure.retryable || (options.fallbackChannels ?? []).length === 0)
594
+ throw error;
595
+ const names = (await gitMaybe(exec, repoRoot, ['remote']))?.split('\n').map(item => item.trim()).filter(item => item !== '') ?? [];
596
+ const first = names[0];
597
+ if (first === undefined)
598
+ throw error;
599
+ const endpoint = (await gitMaybe(exec, repoRoot, ['remote', 'get-url', first]))?.trim() ?? '';
600
+ if (endpoint === '')
601
+ throw error;
602
+ // An SSH remote has no HTTP channel to switch to; the hint is the only
603
+ // actionable answer (and saves three probes that can never succeed).
604
+ if (isSshEndpoint(endpoint))
605
+ throw withSshHint(error);
606
+ // The probe IS the retry here: reading is idempotent, so a successful
607
+ // channel probe has already performed the work. A channel pins the real
608
+ // host, so the mirror rewrite is dropped with it.
609
+ await firstWorkingChannel(endpoint, options, async (channel) => {
610
+ await git(exec, repoRoot, [...channel, ...authArgs, 'fetch', '--all', '--prune'], FETCH_TIMEOUT_MS);
611
+ })
612
+ .catch(() => { throw error; });
613
+ }
529
614
  }
530
615
  /**
531
616
  * Update the CURRENT checkout: fetch every remote, then fast-forward the
@@ -551,6 +636,114 @@ export async function updateBranch(exec, cwd, accelPrefix, authArgs = []) {
551
636
  const branch = (await gitMaybe(exec, cwd, ['branch', '--show-current']))?.trim() ?? '';
552
637
  return { branch, updated: before !== after };
553
638
  }
639
+ /**
640
+ * Walk the fallback chain until one provider yields a channel that answers
641
+ * its own probe. A provider that returns argv which cannot reach the endpoint
642
+ * is skipped, not fatal: the direct error is still the one worth reporting.
643
+ * @param endpoint - URL the command talks to.
644
+ * @param options - caller's channel list.
645
+ * @param probe - probe for one candidate argv (throws when it fails).
646
+ */
647
+ async function firstWorkingChannel(endpoint, options, probe) {
648
+ for (const provider of options.fallbackChannels ?? []) {
649
+ const args = await provider(endpoint);
650
+ if (args === undefined)
651
+ continue;
652
+ try {
653
+ await probe(args);
654
+ return args;
655
+ }
656
+ catch {
657
+ // Try the next provider; the caller reports the direct failure if none works.
658
+ }
659
+ }
660
+ return undefined;
661
+ }
662
+ /**
663
+ * Pick the channel a write will use: the direct one when its probe answers,
664
+ * otherwise the fallback channel when the failure is retryable and the
665
+ * caller supplied one. The fallback is probed too — a fallback that cannot
666
+ * reach the endpoint must not replace a clear direct error with a vague one.
667
+ * @param exec - executor seam.
668
+ * @param cwd - any directory inside the repository.
669
+ * @param target - resolved endpoint of the write.
670
+ * @param options - caller's channels.
671
+ * @param authArgs - per-host credential argv.
672
+ * @returns the argv to prepend to the push and its verification read.
673
+ */
674
+ async function chooseChannel(exec, cwd, target, options, authArgs) {
675
+ const direct = options.channelArgs ?? [];
676
+ if (target.url === undefined)
677
+ return direct;
678
+ try {
679
+ await probePushTarget(exec, cwd, target, { channelArgs: direct }, authArgs);
680
+ return direct;
681
+ }
682
+ catch (error) {
683
+ // A credential or divergence failure is about state, not reachability:
684
+ // pinning an IP would just fail again with the same message. An SSH remote
685
+ // cannot use any HTTP channel at all — say so instead of probing three
686
+ // times into the same wall.
687
+ if (isSshEndpoint(target.url))
688
+ throw withSshHint(error);
689
+ const failure = error instanceof GitError ? classifyGitFailure(error.stderr, { timedOut: error.timedOut }) : undefined;
690
+ if (failure === undefined || !failure.retryable)
691
+ throw error;
692
+ const fallback = await firstWorkingChannel(target.url, options, async (args) => {
693
+ await probePushTarget(exec, cwd, target, { channelArgs: args }, authArgs);
694
+ });
695
+ if (fallback === undefined)
696
+ throw error;
697
+ return fallback;
698
+ }
699
+ }
700
+ /**
701
+ * Resolve where a write to `@{u}` would go, WITHOUT sending anything.
702
+ * `remote get-url --push` is the one source of truth for the endpoint: a
703
+ * remote may configure a separate `pushurl`, and probing the fetch URL
704
+ * would validate a host the push never talks to.
705
+ * @param exec - executor seam.
706
+ * @param cwd - directory whose checked-out branch supplies the upstream.
707
+ * @param authArgs - per-host credential argv (kept for symmetry; `rev-parse`
708
+ * and `remote` are local and never authenticate).
709
+ * @returns the remote (and its push URL when git names one), or undefined
710
+ * when the branch has no upstream — that case belongs to git's own message.
711
+ */
712
+ export async function resolvePushTarget(exec, cwd, authArgs = []) {
713
+ const upstream = (await gitMaybe(exec, cwd, [...authArgs, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}']))?.trim() ?? '';
714
+ const slash = upstream.indexOf('/');
715
+ if (slash <= 0)
716
+ return undefined;
717
+ const remote = upstream.slice(0, slash);
718
+ const url = (await gitMaybe(exec, cwd, ['remote', 'get-url', '--push', remote]))?.trim() ?? '';
719
+ return { remote, ...(url === '' ? {} : { url }) };
720
+ }
721
+ /**
722
+ * Reachability probe for a write: a READ of the push endpoint. A host that
723
+ * cannot answer `ls-remote` cannot receive a pack either, so failing here
724
+ * costs the probe budget (10s) instead of the push budget (600s), and the
725
+ * caller learns which endpoint was unreachable before any object moved.
726
+ * Terminal prompts are disabled for the probe: a probe must fail, never
727
+ * block on a credential dialog the user cannot see.
728
+ * @param exec - executor seam.
729
+ * @param cwd - any directory inside the repository.
730
+ * @param target - endpoint from {@link resolvePushTarget}.
731
+ * @param options - channel argv (proxy) to route the probe the same way.
732
+ * @param authArgs - per-host credential argv.
733
+ */
734
+ export async function probePushTarget(exec, cwd, target, options = {}, authArgs = []) {
735
+ await git(exec, cwd, [...(options.channelArgs ?? []), ...authArgs, 'ls-remote', '--heads', target.url ?? target.remote], PROBE_TIMEOUT_MS, { GIT_TERMINAL_PROMPT: '0' });
736
+ }
737
+ /**
738
+ * Read one remote ref through the same channel a write used. The single
739
+ * line answering `ls-remote --refs <endpoint> <ref>` is `<sha>\t<ref>`;
740
+ * anything else (no output, a diagnostic) answers undefined.
741
+ */
742
+ async function remoteRefOf(exec, cwd, target, ref, options, authArgs) {
743
+ const listed = await gitMaybe(exec, cwd, [...(options.channelArgs ?? []), ...authArgs, 'ls-remote', '--refs', target.url ?? target.remote, ref], VERIFY_TIMEOUT_MS);
744
+ const line = (listed ?? '').split('\n').map(item => item.trim()).find(item => item !== '');
745
+ return line?.split(/\s+/)[0];
746
+ }
554
747
  /**
555
748
  * Push the branch checked out by `cwd` to its upstream (`@{u}`) — the
556
749
  * counterpart of {@link updateBranch}. Deliberately a bare `git push`: the
@@ -565,16 +758,41 @@ export async function updateBranch(exec, cwd, accelPrefix, authArgs = []) {
565
758
  * pushed content to a third party.
566
759
  * @param exec - executor seam.
567
760
  * @param cwd - directory whose checked-out branch is pushed.
761
+ * @param authArgs - per-host credential argv.
762
+ * @param options - channel argv (proxy) applied to the probe and the push.
568
763
  * @returns the branch and whether the remote-tracking ref changed. A branch
569
764
  * without an upstream can still be handed to `git push`, so Git's own
570
765
  * push-specific diagnostic is preserved; in that case `pushed` is false.
571
766
  */
572
- export async function pushBranch(exec, cwd, authArgs = []) {
767
+ export async function pushBranch(exec, cwd, authArgs = [], options = {}) {
573
768
  const before = await gitMaybe(exec, cwd, ['rev-parse', '@{u}']);
574
- await git(exec, cwd, [...authArgs, 'push'], NETWORK_TIMEOUT_MS);
575
- const after = await gitMaybe(exec, cwd, ['rev-parse', '@{u}']);
769
+ if (before === undefined) {
770
+ // No upstream: let git push speak (its message is the actionable one)
771
+ // and skip a probe that could only replace it with a reachability error.
772
+ await git(exec, cwd, [...(options.channelArgs ?? []), ...authArgs, 'push'], PUSH_TIMEOUT_MS);
773
+ const branch = (await gitMaybe(exec, cwd, ['branch', '--show-current']))?.trim() ?? '';
774
+ return { branch, pushed: false };
775
+ }
576
776
  const branch = (await gitMaybe(exec, cwd, ['branch', '--show-current']))?.trim() ?? '';
577
- return { branch, pushed: before !== undefined && after !== undefined && before.trim() !== after.trim() };
777
+ const head = (await git(exec, cwd, ['rev-parse', 'HEAD'])).trim();
778
+ const target = await resolvePushTarget(exec, cwd, authArgs);
779
+ const channel = target === undefined ? (options.channelArgs ?? []) : await chooseChannel(exec, cwd, target, options, authArgs);
780
+ try {
781
+ await git(exec, cwd, [...channel, ...authArgs, 'push'], PUSH_TIMEOUT_MS);
782
+ }
783
+ catch (error) {
784
+ // A killed push carries no verdict: the remote may have applied it before
785
+ // the socket died. One `ls-remote` settles it; anything else (a genuine
786
+ // refusal, a rejected pack) is reported as-is and never retried.
787
+ if (error instanceof GitError && error.timedOut && target !== undefined && branch !== '') {
788
+ const landed = await remoteRefOf(exec, cwd, target, `refs/heads/${branch}`, { channelArgs: channel }, authArgs);
789
+ if (landed === head)
790
+ return { branch, pushed: true, note: 'timed-out-but-landed' };
791
+ }
792
+ throw error;
793
+ }
794
+ const after = await gitMaybe(exec, cwd, ['rev-parse', '@{u}']);
795
+ return { branch, pushed: after !== undefined && before.trim() !== after.trim() };
578
796
  }
579
797
  /** Every LOCAL tag name of the repository, in git's own sort order. */
580
798
  export async function listTagNames(exec, cwd) {
@@ -631,16 +849,34 @@ export async function resolvePushRemote(exec, cwd) {
631
849
  * @param remote - the remote to publish to (see {@link resolvePushRemote}).
632
850
  * @returns the tag, the remote, and whether the push had to run.
633
851
  */
634
- export async function pushTag(exec, cwd, name, remote, authArgs = []) {
852
+ export async function pushTag(exec, cwd, name, remote, authArgs = [], options = {}) {
635
853
  const ref = `refs/tags/${name}`;
636
854
  const local = (await git(exec, cwd, ['rev-parse', '--verify', ref])).trim();
637
- const listed = await gitMaybe(exec, cwd, [...authArgs, 'ls-remote', '--refs', remote, ref], NETWORK_TIMEOUT_MS);
638
- const present = (listed ?? '').split('\n')
639
- .map(line => line.trim().split(/\s+/)[0] ?? '')
640
- .includes(local);
641
- if (present)
855
+ // The existence check doubles as the reachability probe and as the
856
+ // verification a timed-out push needs: the same read decides all three.
857
+ // Same channel policy as a branch push, but only when a fallback exists:
858
+ // without one a tag needs no extra probe (and no extra remote lookup).
859
+ let target = { remote };
860
+ let channel = options.channelArgs ?? [];
861
+ if ((options.fallbackChannels ?? []).length > 0) {
862
+ const url = (await gitMaybe(exec, cwd, ['remote', 'get-url', '--push', remote]))?.trim() ?? '';
863
+ target = url === '' ? { remote } : { remote, url };
864
+ channel = await chooseChannel(exec, cwd, target, options, authArgs);
865
+ }
866
+ const already = await remoteRefOf(exec, cwd, target, ref, { channelArgs: channel }, authArgs);
867
+ if (already === local)
642
868
  return { tag: name, remote, pushed: false };
643
- await git(exec, cwd, [...authArgs, 'push', remote, `${ref}:${ref}`], NETWORK_TIMEOUT_MS);
869
+ try {
870
+ await git(exec, cwd, [...channel, ...authArgs, 'push', remote, `${ref}:${ref}`], PUSH_TIMEOUT_MS);
871
+ }
872
+ catch (error) {
873
+ if (error instanceof GitError && error.timedOut) {
874
+ const landed = await remoteRefOf(exec, cwd, target, ref, { channelArgs: channel }, authArgs);
875
+ if (landed === local)
876
+ return { tag: name, remote, pushed: true, note: 'timed-out-but-landed' };
877
+ }
878
+ throw error;
879
+ }
644
880
  return { tag: name, remote, pushed: true };
645
881
  }
646
882
  /** Guard for route inputs: a non-empty absolute directory path. */
package/lib/index.d.ts CHANGED
@@ -30,6 +30,9 @@ export interface Config {
30
30
  cnAccelerate?: Volatile<boolean> | boolean;
31
31
  /** cn-accelerate mirror prefix; absent = the shipped default. */
32
32
  cnAccelerateUrl?: Volatile<string> | string;
33
+ /** Explicit proxy URL (http/https/socks5) tried before any other fallback;
34
+ * empty/absent = no proxy channel. */
35
+ networkProxy?: Volatile<string> | string;
33
36
  }
34
37
  /** The shipped prune cap (whole storage root, valid git worktrees only). */
35
38
  export declare const KEEP_WORKTREES_DEFAULT = 30;
@@ -48,6 +51,12 @@ export declare function validateConfig(config: Config): void;
48
51
  * not a fetchable remote. Absent = the shipped default, which passes.
49
52
  */
50
53
  export declare function validateCnAccelerateUrl(value: string | undefined): void;
54
+ /**
55
+ * An explicit proxy must be a URL git can hand to libcurl: no credentials
56
+ * (they would be stored and displayed in clear text), no path. Empty means
57
+ * "no proxy channel", which is the default and always valid.
58
+ */
59
+ export declare function validateNetworkProxy(value: string | undefined): void;
51
60
  /**
52
61
  * The prune cap must be an integer >= 1: 0 would read as "create then
53
62
  * immediately self-delete" (the fresh worktree is excluded, but every other
@@ -76,6 +85,8 @@ export interface SectionConfig {
76
85
  cnAccelerate?: boolean;
77
86
  /** cn-accelerate mirror prefix; absent = the shipped default. */
78
87
  cnAccelerateUrl?: string;
88
+ /** Explicit proxy URL; absent/blank = no proxy channel. */
89
+ networkProxy?: string;
79
90
  }
80
91
  /** The section-shaped view of a config: absent keys stay absent
81
92
  * (`exactOptionalPropertyTypes`) except the switches, which spell their