dsh-git-branch-manage 1.2.0 → 1.3.0
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 +17 -0
- package/lib/client.js +189 -13
- package/lib/git.d.ts +82 -9
- package/lib/git.js +260 -24
- package/lib/index.d.ts +11 -0
- package/lib/index.js +34 -3
- package/lib/network-ip.d.ts +99 -0
- package/lib/network-ip.js +206 -0
- package/lib/network-probe.d.ts +28 -0
- package/lib/network-probe.js +47 -0
- package/lib/network-proxy.d.ts +35 -0
- package/lib/network-proxy.js +55 -0
- package/lib/network-tool.js +3 -2
- package/lib/network.d.ts +58 -0
- package/lib/network.js +128 -0
- package/lib/normalize.d.ts +14 -2
- package/lib/normalize.js +38 -2
- package/lib/routes.d.ts +24 -3
- package/lib/routes.js +97 -8
- package/lib/wire.d.ts +35 -0
- package/lib/wire.js +2 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
|
28
|
-
* a real pack transfer outruns the local default, and a killed
|
|
29
|
-
* ALREADY updated the remote refs — an error toast for a push
|
|
30
|
-
*
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
575
|
-
|
|
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
|
-
|
|
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
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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
|
-
|
|
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
|
package/lib/index.js
CHANGED
|
@@ -8,13 +8,15 @@
|
|
|
8
8
|
import { homedir } from 'node:os';
|
|
9
9
|
import z from '@deepseek-ai/schemastery';
|
|
10
10
|
import { childProcessExec } from './git.js';
|
|
11
|
+
import { pinnedChannelFor, pinnedProbe } from './network-ip.js';
|
|
12
|
+
import { proxyArgs, proxyUrlError, systemProxyUrl } from './network-proxy.js';
|
|
11
13
|
import { ACCEL_DEFAULT_URL, accelerateUrlError } from './normalize.js';
|
|
12
|
-
import { handleCredentials, handleCredentialsDelete, handleCredentialsImport, handleCredentialsOAuthPoll, handleCredentialsOAuthStart, handleCredentialsSave, handleRemote, handleRemotes, handleAiCommitMessage, handleChanges, handleCommit, handleCommitFiles, handleCreateBranch, handleCreateTag, handleCreateWorktree, handleDeleteBranch, handleEnsureDirectory, handleFetch, handleGroupWorktrees, handleInspectWorktree, handleLog, handlePathExists, handlePurgeDirectory, handlePushTag, handleRemoveWorktree, handleRenameBranch, handleStage, handleStatus, handleSwitch, handleUpdate, handlePush, handleWorktreesAll, } from './routes.js';
|
|
14
|
+
import { handleCredentials, handleCredentialsDelete, handleCredentialsImport, handleCredentialsOAuthPoll, handleCredentialsOAuthStart, handleCredentialsSave, handleRemote, handleRemotes, handleAiCommitMessage, handleChanges, handleCommit, handleCommitFiles, handleCreateBranch, handleCreateTag, handleCreateWorktree, handleDeleteBranch, handleEnsureDirectory, handleFetch, handleGroupWorktrees, handleInspectWorktree, handleLog, handlePathExists, handlePurgeDirectory, handlePushTag, handleRemoveWorktree, handleRenameBranch, handleStage, handleStatus, handleSwitch, handleUpdate, handlePush, handleNetworkProbe, handleWorktreesAll, } from './routes.js';
|
|
13
15
|
import { validateRootDir } from './settings.js';
|
|
14
16
|
import { gitNetworkTool } from './network-tool.js';
|
|
15
17
|
import { gitCredentialsTool } from './tool.js';
|
|
16
18
|
import { createCredentialService } from './credential-service.js';
|
|
17
|
-
import { ROUTE_AI_COMMIT_MESSAGE, ROUTE_BRANCH, ROUTE_BRANCH_DELETE, ROUTE_BRANCH_RENAME, ROUTE_CHANGES, ROUTE_COMMIT, ROUTE_COMMIT_FILES, ROUTE_CREDENTIALS, ROUTE_CREDENTIALS_DELETE, ROUTE_CREDENTIALS_IMPORT, ROUTE_CREDENTIALS_OAUTH_POLL, ROUTE_CREDENTIALS_OAUTH_START, ROUTE_CREDENTIALS_SAVE, ROUTE_ENSURE_DIRECTORY, ROUTE_EXISTS, ROUTE_FETCH, ROUTE_GROUP, ROUTE_INSPECT, ROUTE_LOG, ROUTE_PUSH, ROUTE_PURGE, ROUTE_REMOTE, ROUTE_REMOTES, ROUTE_REMOVE, ROUTE_STAGE, ROUTE_STATUS, ROUTE_SWITCH, ROUTE_TAG, ROUTE_TAG_PUSH, ROUTE_UPDATE, ROUTE_WORKTREE, ROUTE_WORKTREES_ALL } from './wire.js';
|
|
19
|
+
import { ROUTE_AI_COMMIT_MESSAGE, ROUTE_BRANCH, ROUTE_BRANCH_DELETE, ROUTE_BRANCH_RENAME, ROUTE_CHANGES, ROUTE_COMMIT, ROUTE_COMMIT_FILES, ROUTE_CREDENTIALS, ROUTE_CREDENTIALS_DELETE, ROUTE_CREDENTIALS_IMPORT, ROUTE_CREDENTIALS_OAUTH_POLL, ROUTE_CREDENTIALS_OAUTH_START, ROUTE_CREDENTIALS_SAVE, ROUTE_ENSURE_DIRECTORY, ROUTE_EXISTS, ROUTE_FETCH, ROUTE_GROUP, ROUTE_INSPECT, ROUTE_LOG, ROUTE_NETWORK_PROBE, ROUTE_PUSH, ROUTE_PURGE, ROUTE_REMOTE, ROUTE_REMOTES, ROUTE_REMOVE, ROUTE_STAGE, ROUTE_STATUS, ROUTE_SWITCH, ROUTE_TAG, ROUTE_TAG_PUSH, ROUTE_UPDATE, ROUTE_WORKTREE, ROUTE_WORKTREES_ALL } from './wire.js';
|
|
18
20
|
export const name = 'dsh-git-branch';
|
|
19
21
|
export const inject = [];
|
|
20
22
|
/** Largest accepted request body (bytes) — these payloads are a few strings. */
|
|
@@ -43,10 +45,11 @@ export const Config = z.object({
|
|
|
43
45
|
postCreateCopyFiles: z.array(z.string()).default([]).volatile(),
|
|
44
46
|
cnAccelerate: z.boolean().default(false).volatile(),
|
|
45
47
|
cnAccelerateUrl: z.string().default(ACCEL_DEFAULT_URL).volatile(),
|
|
48
|
+
networkProxy: z.string().default('').volatile(),
|
|
46
49
|
});
|
|
47
50
|
/** Reject stale or misspelled config keys before defaults can hide them. */
|
|
48
51
|
export function validateConfig(config) {
|
|
49
|
-
const unknown = Object.keys(config).find(key => key !== 'rootDir' && key !== 'groupSidebar' && key !== 'fetchBeforeCreate' && key !== 'autoPruneWorktrees' && key !== 'keepWorktrees' && key !== 'postCreateCopyFiles' && key !== 'cnAccelerate' && key !== 'cnAccelerateUrl');
|
|
52
|
+
const unknown = Object.keys(config).find(key => key !== 'rootDir' && key !== 'groupSidebar' && key !== 'fetchBeforeCreate' && key !== 'autoPruneWorktrees' && key !== 'keepWorktrees' && key !== 'postCreateCopyFiles' && key !== 'cnAccelerate' && key !== 'cnAccelerateUrl' && key !== 'networkProxy');
|
|
50
53
|
if (unknown !== undefined) {
|
|
51
54
|
throw new Error(`GitWorktreeConfig: unknown key "${unknown}"`);
|
|
52
55
|
}
|
|
@@ -77,6 +80,7 @@ export function validateConfig(config) {
|
|
|
77
80
|
throw new Error('GitWorktreeConfig: "cnAccelerate" must be a boolean');
|
|
78
81
|
}
|
|
79
82
|
validateCnAccelerateUrl(readVolatile(config.cnAccelerateUrl));
|
|
83
|
+
validateNetworkProxy(readVolatile(config.networkProxy));
|
|
80
84
|
validateKeepWorktrees(readVolatile(config.keepWorktrees));
|
|
81
85
|
validateRootDir(root);
|
|
82
86
|
}
|
|
@@ -93,6 +97,18 @@ export function validateCnAccelerateUrl(value) {
|
|
|
93
97
|
throw new Error('GitWorktreeConfig: "cnAccelerateUrl" must be an http(s) URL without whitespace');
|
|
94
98
|
}
|
|
95
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* An explicit proxy must be a URL git can hand to libcurl: no credentials
|
|
102
|
+
* (they would be stored and displayed in clear text), no path. Empty means
|
|
103
|
+
* "no proxy channel", which is the default and always valid.
|
|
104
|
+
*/
|
|
105
|
+
export function validateNetworkProxy(value) {
|
|
106
|
+
if (value === undefined || value === '')
|
|
107
|
+
return;
|
|
108
|
+
if (typeof value !== 'string' || proxyUrlError(value) !== undefined) {
|
|
109
|
+
throw new Error('GitWorktreeConfig: "networkProxy" must be an http(s)/socks5 proxy URL without credentials');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
96
112
|
/**
|
|
97
113
|
* The prune cap must be an integer >= 1: 0 would read as "create then
|
|
98
114
|
* immediately self-delete" (the fresh worktree is excluded, but every other
|
|
@@ -120,10 +136,12 @@ export function sectionOf(config) {
|
|
|
120
136
|
const keepWorktrees = readVolatile(config.keepWorktrees);
|
|
121
137
|
const cnAccelerate = readVolatile(config.cnAccelerate);
|
|
122
138
|
const cnAccelerateUrl = readVolatile(config.cnAccelerateUrl);
|
|
139
|
+
const networkProxy = readVolatile(config.networkProxy);
|
|
123
140
|
return {
|
|
124
141
|
...(rootDir === undefined ? {} : { rootDir }),
|
|
125
142
|
...(postCreateCopyFiles === undefined ? {} : { postCreateCopyFiles }),
|
|
126
143
|
...(cnAccelerateUrl === undefined ? {} : { cnAccelerateUrl }),
|
|
144
|
+
...(networkProxy === undefined || networkProxy === '' ? {} : { networkProxy }),
|
|
127
145
|
// The composition layer spells the shipped defaults so a user-layer
|
|
128
146
|
// unset can always fall back to them.
|
|
129
147
|
groupSidebar: groupSidebar ?? true,
|
|
@@ -163,11 +181,23 @@ export function apply(ctx, config = {}) {
|
|
|
163
181
|
}, 'git-worktree: credential service dispose');
|
|
164
182
|
const deps = () => ({
|
|
165
183
|
exec: childProcessExec,
|
|
184
|
+
networkChannels: (exec, cwd, authArgs) => {
|
|
185
|
+
const providers = [];
|
|
186
|
+
// An explicit proxy outranks the IP heuristic: the user configured it
|
|
187
|
+
// because it is the path they trust. Absent/blank = no proxy channel.
|
|
188
|
+
const proxy = readVolatile(currentConfig().networkProxy);
|
|
189
|
+
if (proxy !== undefined && proxy !== '')
|
|
190
|
+
providers.push(async () => proxyArgs(proxy));
|
|
191
|
+
providers.push(endpoint => pinnedChannelFor({ endpoint, probe: pinnedProbe(exec, cwd, endpoint, authArgs) }));
|
|
192
|
+
return providers;
|
|
193
|
+
},
|
|
166
194
|
sectionRootDir: () => readVolatile(currentConfig().rootDir),
|
|
167
195
|
sectionFetchBeforeCreate: () => readVolatile(currentConfig().fetchBeforeCreate),
|
|
168
196
|
sectionPostCreateCopyFiles: () => readVolatile(currentConfig().postCreateCopyFiles),
|
|
169
197
|
sectionCnAccelerate: () => readVolatile(currentConfig().cnAccelerate),
|
|
170
198
|
sectionCnAccelerateUrl: () => readVolatile(currentConfig().cnAccelerateUrl),
|
|
199
|
+
sectionNetworkProxy: () => readVolatile(currentConfig().networkProxy),
|
|
200
|
+
sectionSystemProxy: () => systemProxyUrl(childProcessExec, homedir()),
|
|
171
201
|
home: () => homedir(),
|
|
172
202
|
envHome: () => process.env.DSH_HOME,
|
|
173
203
|
credentialsService: credentials,
|
|
@@ -253,6 +283,7 @@ export function apply(ctx, config = {}) {
|
|
|
253
283
|
postRoute(ROUTE_GROUP, 'group', handleGroupWorktrees);
|
|
254
284
|
postRoute(ROUTE_UPDATE, 'update', handleUpdate);
|
|
255
285
|
postRoute(ROUTE_PUSH, 'push', handlePush);
|
|
286
|
+
postRoute(ROUTE_NETWORK_PROBE, 'network-probe', handleNetworkProbe);
|
|
256
287
|
postRoute(ROUTE_INSPECT, 'inspect', handleInspectWorktree);
|
|
257
288
|
postRoute(ROUTE_REMOVE, 'remove', handleRemoveWorktree);
|
|
258
289
|
postRoute(ROUTE_EXISTS, 'exists', handlePathExists);
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The second network channel: resolve the host yourself, then race a few
|
|
3
|
+
* edge IPs with a real `ls-remote` and pin the winner with
|
|
4
|
+
* `http.curloptResolve`.
|
|
5
|
+
*
|
|
6
|
+
* Why a runtime lookup and not a built-in table: measured on 2026-11-03, a
|
|
7
|
+
* static list of eight GitHub edge IPs had seven TCP-reachable but only four
|
|
8
|
+
* that could answer `ls-remote`, two of them taking 88s and 211s. Edge
|
|
9
|
+
* assignment moves; a table baked into a release rots. So the table is
|
|
10
|
+
* fetched from DNS-over-HTTPS at the moment of need, and every candidate is
|
|
11
|
+
* validated by the same command the push will run.
|
|
12
|
+
*
|
|
13
|
+
* TCP reachability is deliberately NOT the judge: `connect()` succeeding
|
|
14
|
+
* says nothing about TLS termination or the git endpoint behind it.
|
|
15
|
+
*/
|
|
16
|
+
import type { Exec } from './git.js';
|
|
17
|
+
/** Budget for ONE pinned probe; candidates run in parallel, so the race is bounded by this. */
|
|
18
|
+
export declare const IP_PROBE_TIMEOUT_MS = 5000;
|
|
19
|
+
/** How long a winning channel is reused before the IPs are re-resolved. */
|
|
20
|
+
export declare const IP_CACHE_TTL_MS = 600000;
|
|
21
|
+
/**
|
|
22
|
+
* Concurrent candidates. Probes run in parallel, so the wall clock is one
|
|
23
|
+
* timeout no matter how many run; this only bounds process count.
|
|
24
|
+
*/
|
|
25
|
+
export declare const MAX_IP_CANDIDATES = 6;
|
|
26
|
+
/** Drop every cached channel (tests, and a user-triggered re-probe). */
|
|
27
|
+
export declare function resetIpChannelCache(): void;
|
|
28
|
+
/**
|
|
29
|
+
* Pull the IPv4 answers out of one DoH JSON body. Anything malformed answers
|
|
30
|
+
* `[]` rather than throwing: a resolver hiccup must degrade to the direct
|
|
31
|
+
* channel, not fail the push before it starts.
|
|
32
|
+
*/
|
|
33
|
+
export declare function parseDohIps(payload: unknown): string[];
|
|
34
|
+
/** Budget for one DoH query. Three resolvers are asked in parallel, so this bounds resolution. */
|
|
35
|
+
export declare const DOH_TIMEOUT_MS = 3000;
|
|
36
|
+
/**
|
|
37
|
+
* Ask every resolver about one host, in parallel, and union the answers.
|
|
38
|
+
*
|
|
39
|
+
* Sequential fallback was measured to be the wrong shape: the resolvers that
|
|
40
|
+
* are blocked here are blocked for the full timeout each, so a "fallback
|
|
41
|
+
* chain" spends three timeouts to learn what one parallel round trip tells.
|
|
42
|
+
* @param host - hostname to resolve.
|
|
43
|
+
* @param fetchImpl - fetch seam (tests inject a stub).
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveHostIps(host: string, fetchImpl?: typeof fetch): Promise<string[]>;
|
|
46
|
+
/**
|
|
47
|
+
* Candidates to race for one endpoint: the endpoint's own answers first, then
|
|
48
|
+
* the sibling services' answers (only for `github.com`), deduplicated and
|
|
49
|
+
* capped at {@link MAX_IP_CANDIDATES}. Everything runs in parallel, so the
|
|
50
|
+
* whole lookup is bounded by {@link DOH_TIMEOUT_MS}.
|
|
51
|
+
* @param host - endpoint hostname.
|
|
52
|
+
* @param fetchImpl - fetch seam.
|
|
53
|
+
*/
|
|
54
|
+
export declare function candidateIpsFor(host: string, fetchImpl?: typeof fetch): Promise<string[]>;
|
|
55
|
+
/** Whether this endpoint is one the pinned-IP channel may rewrite. */
|
|
56
|
+
export declare function isOptimizableEndpoint(endpoint: string): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* The argv that pins `host:443` to one IP for a single command. `-c` goes
|
|
59
|
+
* before the subcommand, per git's global-option rule.
|
|
60
|
+
*/
|
|
61
|
+
export declare function pinArgs(host: string, ip: string): readonly string[];
|
|
62
|
+
/** Seam for the race: resolves when the candidate answered `ls-remote`. */
|
|
63
|
+
export type Probe = (args: readonly string[]) => Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Race up to {@link MAX_IP_CANDIDATES} pinned channels and answer the argv of
|
|
66
|
+
* the first that actually answers `ls-remote`. "First" means first to
|
|
67
|
+
* SUCCEED: all candidates start together and a slow-but-working IP can beat
|
|
68
|
+
* one that fails later.
|
|
69
|
+
* @param ips - candidates in resolver order.
|
|
70
|
+
* @param host - hostname being pinned.
|
|
71
|
+
* @param probe - one pinned probe (the caller supplies the budget + argv).
|
|
72
|
+
* @returns the winning `-c` argv, or undefined when every candidate failed.
|
|
73
|
+
*/
|
|
74
|
+
export declare function racePinnedChannels(ips: readonly string[], host: string, probe: Probe): Promise<readonly string[] | undefined>;
|
|
75
|
+
/**
|
|
76
|
+
* The channel a read/write may fall back to when the direct one fails in a
|
|
77
|
+
* retryable way. Wraps resolution + race + caching; answers undefined for
|
|
78
|
+
* endpoints outside {@link OPTIMIZABLE_HOSTS} or when nothing works.
|
|
79
|
+
* @param options.endpoint - the URL the command would use.
|
|
80
|
+
* @param options.probe - one pinned probe, built by {@link pinnedProbe}.
|
|
81
|
+
* @param options.now - clock seam for the cache.
|
|
82
|
+
* @param options.fetchImpl - DoH fetch seam.
|
|
83
|
+
*/
|
|
84
|
+
export declare function pinnedChannelFor(options: {
|
|
85
|
+
endpoint: string;
|
|
86
|
+
probe: Probe;
|
|
87
|
+
now?: () => number;
|
|
88
|
+
fetchImpl?: typeof fetch;
|
|
89
|
+
}): Promise<readonly string[] | undefined>;
|
|
90
|
+
/**
|
|
91
|
+
* Build the probe the race runs: one pinned `ls-remote` at the probe budget,
|
|
92
|
+
* with terminal prompts disabled so a credentialed endpoint fails instead of
|
|
93
|
+
* hanging on an invisible dialog.
|
|
94
|
+
* @param exec - executor seam.
|
|
95
|
+
* @param cwd - any directory inside the repository.
|
|
96
|
+
* @param endpoint - URL being probed.
|
|
97
|
+
* @param authArgs - per-host credential argv.
|
|
98
|
+
*/
|
|
99
|
+
export declare function pinnedProbe(exec: Exec, cwd: string, endpoint: string, authArgs?: readonly string[]): Probe;
|