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/CHANGELOG.md +23 -0
- package/README.md +165 -1
- package/lib/client.js +204 -26
- 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/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;
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/** Budget for ONE pinned probe; candidates run in parallel, so the race is bounded by this. */
|
|
2
|
+
export const IP_PROBE_TIMEOUT_MS = 5_000;
|
|
3
|
+
/** How long a winning channel is reused before the IPs are re-resolved. */
|
|
4
|
+
export const IP_CACHE_TTL_MS = 600_000;
|
|
5
|
+
/**
|
|
6
|
+
* Concurrent candidates. Probes run in parallel, so the wall clock is one
|
|
7
|
+
* timeout no matter how many run; this only bounds process count.
|
|
8
|
+
*/
|
|
9
|
+
export const MAX_IP_CANDIDATES = 6;
|
|
10
|
+
/** Hosts this channel applies to. A mirror or an enterprise host has its own DNS. */
|
|
11
|
+
const OPTIMIZABLE_HOSTS = ['github.com'];
|
|
12
|
+
/** DNS-over-HTTPS resolvers, tried in order until one answers. */
|
|
13
|
+
const DOH_URLS = [
|
|
14
|
+
host => `https://dns.alidns.com/resolve?name=${encodeURIComponent(host)}&type=A`,
|
|
15
|
+
host => `https://doh.pub/dns-query?name=${encodeURIComponent(host)}&type=A`,
|
|
16
|
+
host => `https://doh.360.cn/resolve?name=${encodeURIComponent(host)}&type=A`,
|
|
17
|
+
];
|
|
18
|
+
/**
|
|
19
|
+
* Hostnames whose answers widen the candidate pool for `github.com`.
|
|
20
|
+
*
|
|
21
|
+
* Measured on 2026-11-03: `dns.alidns.com` answered a single address for
|
|
22
|
+
* `github.com` (20.205.243.166) and `doh.pub` answered a different one
|
|
23
|
+
* (140.82.112.3) — one resolver gives one edge, and that edge may be dead.
|
|
24
|
+
* GitHub's edge fleet serves `api.`/`codeload.` from the same ranges, so their
|
|
25
|
+
* answers are extra tickets in the same lottery. Every candidate still has to
|
|
26
|
+
* answer a real `ls-remote` before it is used, so a wrong guess costs one
|
|
27
|
+
* parallel probe and nothing else.
|
|
28
|
+
*/
|
|
29
|
+
const SIBLING_SOURCES = ['api.github.com', 'codeload.github.com'];
|
|
30
|
+
const cache = new Map();
|
|
31
|
+
/** Drop every cached channel (tests, and a user-triggered re-probe). */
|
|
32
|
+
export function resetIpChannelCache() {
|
|
33
|
+
cache.clear();
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Pull the IPv4 answers out of one DoH JSON body. Anything malformed answers
|
|
37
|
+
* `[]` rather than throwing: a resolver hiccup must degrade to the direct
|
|
38
|
+
* channel, not fail the push before it starts.
|
|
39
|
+
*/
|
|
40
|
+
export function parseDohIps(payload) {
|
|
41
|
+
if (typeof payload !== 'object' || payload === null)
|
|
42
|
+
return [];
|
|
43
|
+
const answer = payload.Answer;
|
|
44
|
+
if (!Array.isArray(answer))
|
|
45
|
+
return [];
|
|
46
|
+
const ips = [];
|
|
47
|
+
for (const item of answer) {
|
|
48
|
+
if (typeof item !== 'object' || item === null)
|
|
49
|
+
continue;
|
|
50
|
+
const record = item;
|
|
51
|
+
if (record.type !== 1 || typeof record.data !== 'string')
|
|
52
|
+
continue;
|
|
53
|
+
if (!/^\d{1,3}(?:\.\d{1,3}){3}$/.test(record.data))
|
|
54
|
+
continue;
|
|
55
|
+
if (!record.data.split('.').every(part => Number(part) <= 255))
|
|
56
|
+
continue;
|
|
57
|
+
if (!ips.includes(record.data))
|
|
58
|
+
ips.push(record.data);
|
|
59
|
+
}
|
|
60
|
+
return ips;
|
|
61
|
+
}
|
|
62
|
+
/** Budget for one DoH query. Three resolvers are asked in parallel, so this bounds resolution. */
|
|
63
|
+
export const DOH_TIMEOUT_MS = 3_000;
|
|
64
|
+
/** One DoH question, or no answers (a blocked resolver is a normal state, not an error). */
|
|
65
|
+
async function askResolver(url, fetchImpl) {
|
|
66
|
+
try {
|
|
67
|
+
const response = await fetchImpl(url, {
|
|
68
|
+
headers: { accept: 'application/dns-json' },
|
|
69
|
+
signal: AbortSignal.timeout(DOH_TIMEOUT_MS),
|
|
70
|
+
});
|
|
71
|
+
if (!response.ok)
|
|
72
|
+
return [];
|
|
73
|
+
return parseDohIps(await response.json());
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Ask every resolver about one host, in parallel, and union the answers.
|
|
81
|
+
*
|
|
82
|
+
* Sequential fallback was measured to be the wrong shape: the resolvers that
|
|
83
|
+
* are blocked here are blocked for the full timeout each, so a "fallback
|
|
84
|
+
* chain" spends three timeouts to learn what one parallel round trip tells.
|
|
85
|
+
* @param host - hostname to resolve.
|
|
86
|
+
* @param fetchImpl - fetch seam (tests inject a stub).
|
|
87
|
+
*/
|
|
88
|
+
export async function resolveHostIps(host, fetchImpl = fetch) {
|
|
89
|
+
const answers = await Promise.all(DOH_URLS.map(url => askResolver(url(host), fetchImpl)));
|
|
90
|
+
const ips = [];
|
|
91
|
+
for (const answer of answers) {
|
|
92
|
+
for (const ip of answer) {
|
|
93
|
+
if (!ips.includes(ip))
|
|
94
|
+
ips.push(ip);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return ips;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Candidates to race for one endpoint: the endpoint's own answers first, then
|
|
101
|
+
* the sibling services' answers (only for `github.com`), deduplicated and
|
|
102
|
+
* capped at {@link MAX_IP_CANDIDATES}. Everything runs in parallel, so the
|
|
103
|
+
* whole lookup is bounded by {@link DOH_TIMEOUT_MS}.
|
|
104
|
+
* @param host - endpoint hostname.
|
|
105
|
+
* @param fetchImpl - fetch seam.
|
|
106
|
+
*/
|
|
107
|
+
export async function candidateIpsFor(host, fetchImpl = fetch) {
|
|
108
|
+
const sources = host === 'github.com' ? [host, ...SIBLING_SOURCES] : [host];
|
|
109
|
+
const answers = await Promise.all(sources.map(source => resolveHostIps(source, fetchImpl)));
|
|
110
|
+
const ips = [];
|
|
111
|
+
for (const answer of answers) {
|
|
112
|
+
for (const ip of answer) {
|
|
113
|
+
if (!ips.includes(ip))
|
|
114
|
+
ips.push(ip);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return ips.slice(0, MAX_IP_CANDIDATES);
|
|
118
|
+
}
|
|
119
|
+
/** Whether this endpoint is one the pinned-IP channel may rewrite. */
|
|
120
|
+
export function isOptimizableEndpoint(endpoint) {
|
|
121
|
+
try {
|
|
122
|
+
const url = new URL(endpoint);
|
|
123
|
+
return url.protocol === 'https:' && OPTIMIZABLE_HOSTS.includes(url.hostname);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The argv that pins `host:443` to one IP for a single command. `-c` goes
|
|
131
|
+
* before the subcommand, per git's global-option rule.
|
|
132
|
+
*/
|
|
133
|
+
export function pinArgs(host, ip) {
|
|
134
|
+
return ['-c', `http.curloptResolve=${host}:443:${ip}`];
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Race up to {@link MAX_IP_CANDIDATES} pinned channels and answer the argv of
|
|
138
|
+
* the first that actually answers `ls-remote`. "First" means first to
|
|
139
|
+
* SUCCEED: all candidates start together and a slow-but-working IP can beat
|
|
140
|
+
* one that fails later.
|
|
141
|
+
* @param ips - candidates in resolver order.
|
|
142
|
+
* @param host - hostname being pinned.
|
|
143
|
+
* @param probe - one pinned probe (the caller supplies the budget + argv).
|
|
144
|
+
* @returns the winning `-c` argv, or undefined when every candidate failed.
|
|
145
|
+
*/
|
|
146
|
+
export async function racePinnedChannels(ips, host, probe) {
|
|
147
|
+
const candidates = ips.slice(0, MAX_IP_CANDIDATES);
|
|
148
|
+
const results = await Promise.all(candidates.map(async (ip) => {
|
|
149
|
+
const args = pinArgs(host, ip);
|
|
150
|
+
try {
|
|
151
|
+
await probe(args);
|
|
152
|
+
return args;
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
}));
|
|
158
|
+
// Promise.all keeps resolver order, so a tie goes to the first answer.
|
|
159
|
+
return results.find(item => item !== undefined);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* The channel a read/write may fall back to when the direct one fails in a
|
|
163
|
+
* retryable way. Wraps resolution + race + caching; answers undefined for
|
|
164
|
+
* endpoints outside {@link OPTIMIZABLE_HOSTS} or when nothing works.
|
|
165
|
+
* @param options.endpoint - the URL the command would use.
|
|
166
|
+
* @param options.probe - one pinned probe, built by {@link pinnedProbe}.
|
|
167
|
+
* @param options.now - clock seam for the cache.
|
|
168
|
+
* @param options.fetchImpl - DoH fetch seam.
|
|
169
|
+
*/
|
|
170
|
+
export async function pinnedChannelFor(options) {
|
|
171
|
+
const { endpoint, probe } = options;
|
|
172
|
+
if (!isOptimizableEndpoint(endpoint))
|
|
173
|
+
return undefined;
|
|
174
|
+
const host = new URL(endpoint).hostname;
|
|
175
|
+
const now = options.now ?? Date.now;
|
|
176
|
+
const cached = cache.get(endpoint);
|
|
177
|
+
if (cached !== undefined && now() - cached.at < IP_CACHE_TTL_MS)
|
|
178
|
+
return cached.args;
|
|
179
|
+
const ips = await candidateIpsFor(host, options.fetchImpl);
|
|
180
|
+
if (ips.length === 0)
|
|
181
|
+
return undefined;
|
|
182
|
+
const args = await racePinnedChannels(ips, host, probe);
|
|
183
|
+
if (args !== undefined)
|
|
184
|
+
cache.set(endpoint, { args, at: now() });
|
|
185
|
+
return args;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Build the probe the race runs: one pinned `ls-remote` at the probe budget,
|
|
189
|
+
* with terminal prompts disabled so a credentialed endpoint fails instead of
|
|
190
|
+
* hanging on an invisible dialog.
|
|
191
|
+
* @param exec - executor seam.
|
|
192
|
+
* @param cwd - any directory inside the repository.
|
|
193
|
+
* @param endpoint - URL being probed.
|
|
194
|
+
* @param authArgs - per-host credential argv.
|
|
195
|
+
*/
|
|
196
|
+
export function pinnedProbe(exec, cwd, endpoint, authArgs = []) {
|
|
197
|
+
return async (args) => {
|
|
198
|
+
const result = await exec('git', [...args, ...authArgs, 'ls-remote', '--heads', endpoint], {
|
|
199
|
+
cwd,
|
|
200
|
+
timeoutMs: IP_PROBE_TIMEOUT_MS,
|
|
201
|
+
env: { GIT_TERMINAL_PROMPT: '0' },
|
|
202
|
+
});
|
|
203
|
+
if (result.code !== 0)
|
|
204
|
+
throw new Error(result.errorMessage ?? result.stderr ?? 'pinned probe failed');
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel diagnostics for the settings UI: one read of the endpoint per
|
|
3
|
+
* channel, so a user whose network is failing can see WHICH path reaches
|
|
4
|
+
* GitHub instead of guessing.
|
|
5
|
+
*
|
|
6
|
+
* A probe is the same judgement the write path makes (0041/0042), run on
|
|
7
|
+
* demand: the channel is good only when `ls-remote` exits 0. A TCP-level
|
|
8
|
+
* "reachable" proves nothing — measured on 2026-11-03, 7 of 8 GitHub edges
|
|
9
|
+
* accepted a connection and still failed the read.
|
|
10
|
+
*
|
|
11
|
+
* @module git-worktree/network-probe
|
|
12
|
+
*/
|
|
13
|
+
import type { Exec } from './git.js';
|
|
14
|
+
import type { NetworkChannelId, NetworkChannelReport } from './wire.js';
|
|
15
|
+
/**
|
|
16
|
+
* Probe one channel by reading the endpoint through it.
|
|
17
|
+
*
|
|
18
|
+
* Failures are returned as data: a dead channel must not hide the next one,
|
|
19
|
+
* and the caller renders every report side by side.
|
|
20
|
+
* @param exec - executor seam.
|
|
21
|
+
* @param cwd - any directory inside the repository.
|
|
22
|
+
* @param channel - which channel this argv stands for.
|
|
23
|
+
* @param url - endpoint to read.
|
|
24
|
+
* @param channelArgs - the channel's argv prefix (empty for direct).
|
|
25
|
+
* @param authArgs - credential argv for this host.
|
|
26
|
+
* @returns the channel's verdict, failures included.
|
|
27
|
+
*/
|
|
28
|
+
export declare function channelReport(exec: Exec, cwd: string, channel: NetworkChannelId, url: string, channelArgs: readonly string[], authArgs?: readonly string[]): Promise<NetworkChannelReport>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Channel diagnostics for the settings UI: one read of the endpoint per
|
|
3
|
+
* channel, so a user whose network is failing can see WHICH path reaches
|
|
4
|
+
* GitHub instead of guessing.
|
|
5
|
+
*
|
|
6
|
+
* A probe is the same judgement the write path makes (0041/0042), run on
|
|
7
|
+
* demand: the channel is good only when `ls-remote` exits 0. A TCP-level
|
|
8
|
+
* "reachable" proves nothing — measured on 2026-11-03, 7 of 8 GitHub edges
|
|
9
|
+
* accepted a connection and still failed the read.
|
|
10
|
+
*
|
|
11
|
+
* @module git-worktree/network-probe
|
|
12
|
+
*/
|
|
13
|
+
import { PROBE_TIMEOUT_MS } from './network.js';
|
|
14
|
+
const DETAIL_LIMIT = 300;
|
|
15
|
+
/**
|
|
16
|
+
* Keep a failure readable in a toast: the first non-empty line, capped.
|
|
17
|
+
* @param text - raw stderr or error message.
|
|
18
|
+
*/
|
|
19
|
+
function firstLine(text) {
|
|
20
|
+
const line = text.split(/\r?\n/).find(item => item.trim() !== '') ?? '';
|
|
21
|
+
return line.length > DETAIL_LIMIT ? `${line.slice(0, DETAIL_LIMIT)}…` : line;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Probe one channel by reading the endpoint through it.
|
|
25
|
+
*
|
|
26
|
+
* Failures are returned as data: a dead channel must not hide the next one,
|
|
27
|
+
* and the caller renders every report side by side.
|
|
28
|
+
* @param exec - executor seam.
|
|
29
|
+
* @param cwd - any directory inside the repository.
|
|
30
|
+
* @param channel - which channel this argv stands for.
|
|
31
|
+
* @param url - endpoint to read.
|
|
32
|
+
* @param channelArgs - the channel's argv prefix (empty for direct).
|
|
33
|
+
* @param authArgs - credential argv for this host.
|
|
34
|
+
* @returns the channel's verdict, failures included.
|
|
35
|
+
*/
|
|
36
|
+
export async function channelReport(exec, cwd, channel, url, channelArgs, authArgs = []) {
|
|
37
|
+
const started = Date.now();
|
|
38
|
+
const result = await exec('git', [...channelArgs, ...authArgs, 'ls-remote', '--heads', url], {
|
|
39
|
+
cwd,
|
|
40
|
+
timeoutMs: PROBE_TIMEOUT_MS,
|
|
41
|
+
env: { GIT_TERMINAL_PROMPT: '0' },
|
|
42
|
+
});
|
|
43
|
+
const elapsedMs = Date.now() - started;
|
|
44
|
+
if (result.code === 0)
|
|
45
|
+
return { channel, ok: true, elapsedMs };
|
|
46
|
+
return { channel, ok: false, elapsedMs, detail: firstLine(result.errorMessage ?? result.stderr) };
|
|
47
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The third network channel: an explicit proxy, or the one Windows already
|
|
3
|
+
* has configured.
|
|
4
|
+
*
|
|
5
|
+
* Why the registry matters: git links libcurl, and libcurl does NOT read the
|
|
6
|
+
* WinINET/PAC settings that every browser on the machine honours. Measured on
|
|
7
|
+
* 2026-11-03 with a dead local proxy: `git ls-remote` failed with
|
|
8
|
+
* `Failed to connect to github.com:443 over proxy 127.0.0.1` the moment
|
|
9
|
+
* `-c http.proxy=…` was passed, i.e. git honours the argv and ignores the
|
|
10
|
+
* system setting entirely. A `gitport`-style tool bundles its own proxy; this
|
|
11
|
+
* plugin instead borrows the user's, and needs a way to find it.
|
|
12
|
+
*/
|
|
13
|
+
import type { Exec } from './git.js';
|
|
14
|
+
import { proxyUrlError } from './normalize.js';
|
|
15
|
+
/** A registry read is local and cheap; it must not become the slowest step. */
|
|
16
|
+
export declare const SYSTEM_PROXY_TIMEOUT_MS = 3000;
|
|
17
|
+
/** Re-exported so host callers keep one import site for the proxy rules. */
|
|
18
|
+
export { proxyUrlError };
|
|
19
|
+
/** The argv that routes one command through the proxy; `-c` precedes the subcommand. */
|
|
20
|
+
export declare function proxyArgs(url: string): readonly string[];
|
|
21
|
+
/**
|
|
22
|
+
* Parse the `ProxyServer` value WinINET stores, which is either `host:port` or
|
|
23
|
+
* a per-scheme list like `http=host:port;https=host:port`. The https entry
|
|
24
|
+
* wins; a plain value is used as is. Anything malformed answers undefined.
|
|
25
|
+
* @param raw - stdout of `reg query … /v ProxyServer`.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseProxyServer(raw: string): string | undefined;
|
|
28
|
+
/**
|
|
29
|
+
* The system proxy Windows already has configured, when it is enabled and
|
|
30
|
+
* points somewhere usable; undefined otherwise (off Windows, disabled, empty,
|
|
31
|
+
* or unparseable). Read-only: nothing here changes the machine's settings.
|
|
32
|
+
* @param exec - executor seam.
|
|
33
|
+
* @param cwd - directory for the `reg` calls.
|
|
34
|
+
*/
|
|
35
|
+
export declare function systemProxyUrl(exec: Exec, cwd: string): Promise<string | undefined>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { proxyUrlError } from './normalize.js';
|
|
2
|
+
/** A registry read is local and cheap; it must not become the slowest step. */
|
|
3
|
+
export const SYSTEM_PROXY_TIMEOUT_MS = 3_000;
|
|
4
|
+
const INTERNET_SETTINGS = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings';
|
|
5
|
+
/** Re-exported so host callers keep one import site for the proxy rules. */
|
|
6
|
+
export { proxyUrlError };
|
|
7
|
+
/** The argv that routes one command through the proxy; `-c` precedes the subcommand. */
|
|
8
|
+
export function proxyArgs(url) {
|
|
9
|
+
return ['-c', `http.proxy=${url}`];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Parse the `ProxyServer` value WinINET stores, which is either `host:port` or
|
|
13
|
+
* a per-scheme list like `http=host:port;https=host:port`. The https entry
|
|
14
|
+
* wins; a plain value is used as is. Anything malformed answers undefined.
|
|
15
|
+
* @param raw - stdout of `reg query … /v ProxyServer`.
|
|
16
|
+
*/
|
|
17
|
+
export function parseProxyServer(raw) {
|
|
18
|
+
const line = raw.split(/\r?\n/).find(item => /ProxyServer\s+REG_SZ/i.test(item));
|
|
19
|
+
if (line === undefined)
|
|
20
|
+
return undefined;
|
|
21
|
+
const value = line.replace(/^.*?ProxyServer\s+REG_SZ\s*/i, '').trim();
|
|
22
|
+
if (value === '')
|
|
23
|
+
return undefined;
|
|
24
|
+
if (!value.includes('='))
|
|
25
|
+
return value.includes(':') ? `http://${value}` : undefined;
|
|
26
|
+
const entries = value.split(';').map(item => item.trim()).filter(item => item !== '');
|
|
27
|
+
const https = entries.find(item => /^https=/i.test(item));
|
|
28
|
+
const chosen = https ?? entries.find(item => /^http=/i.test(item)) ?? entries[0];
|
|
29
|
+
const address = chosen?.replace(/^[a-z]+=/i, '').trim();
|
|
30
|
+
if (address === undefined || address === '' || !address.includes(':'))
|
|
31
|
+
return undefined;
|
|
32
|
+
const scheme = https === undefined ? 'http' : 'https';
|
|
33
|
+
return `${scheme}://${address}`;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The system proxy Windows already has configured, when it is enabled and
|
|
37
|
+
* points somewhere usable; undefined otherwise (off Windows, disabled, empty,
|
|
38
|
+
* or unparseable). Read-only: nothing here changes the machine's settings.
|
|
39
|
+
* @param exec - executor seam.
|
|
40
|
+
* @param cwd - directory for the `reg` calls.
|
|
41
|
+
*/
|
|
42
|
+
export async function systemProxyUrl(exec, cwd) {
|
|
43
|
+
if (process.platform !== 'win32')
|
|
44
|
+
return undefined;
|
|
45
|
+
const enabled = await exec('reg', ['query', INTERNET_SETTINGS, '/v', 'ProxyEnable'], { cwd, timeoutMs: SYSTEM_PROXY_TIMEOUT_MS });
|
|
46
|
+
if (enabled.code !== 0 || !/ProxyEnable\s+REG_DWORD\s+0x1\b/i.test(enabled.stdout))
|
|
47
|
+
return undefined;
|
|
48
|
+
const server = await exec('reg', ['query', INTERNET_SETTINGS, '/v', 'ProxyServer'], { cwd, timeoutMs: SYSTEM_PROXY_TIMEOUT_MS });
|
|
49
|
+
if (server.code !== 0)
|
|
50
|
+
return undefined;
|
|
51
|
+
const url = parseProxyServer(server.stdout);
|
|
52
|
+
if (url === undefined)
|
|
53
|
+
return undefined;
|
|
54
|
+
return proxyUrlError(url) === undefined ? url : undefined;
|
|
55
|
+
}
|
package/lib/network-tool.js
CHANGED
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
import { isAbsolute } from 'node:path';
|
|
20
20
|
import { basicUsername, createLocalCredentialStore, } from './credentials.js';
|
|
21
|
-
import { childProcessExec
|
|
21
|
+
import { childProcessExec } from './git.js';
|
|
22
|
+
import { FETCH_TIMEOUT_MS } from './network.js';
|
|
22
23
|
/** Model-facing tool name. */
|
|
23
24
|
export const GIT_NETWORK_TOOL_NAME = 'git_network';
|
|
24
25
|
/** The only git subcommands this tool will run. */
|
|
@@ -224,7 +225,7 @@ export function gitNetworkTool(deps) {
|
|
|
224
225
|
// the same token serves this command and a plain shell `git push`.
|
|
225
226
|
const result = await exec('git', request.args, {
|
|
226
227
|
cwd: request.repoPath,
|
|
227
|
-
timeoutMs:
|
|
228
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
228
229
|
});
|
|
229
230
|
const stdout = cap(redactSecrets(result.stdout, entries));
|
|
230
231
|
const stderrSource = result.stderr.trim() === '' && result.errorMessage !== undefined
|
package/lib/network.d.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network policy for the host half: how long each remote operation may take,
|
|
3
|
+
* what a failed one actually means, and how a push that timed out is
|
|
4
|
+
* reconciled with the remote afterwards. Nothing here spawns a process — the
|
|
5
|
+
* budgets and the classifier are plain data so both the git wrapper and the
|
|
6
|
+
* routes can use them without a second seam.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Budget for one reachability probe (`ls-remote`): a probe exists to fail
|
|
10
|
+
* FAST, so a dead host costs seconds, not the minutes a real transfer gets.
|
|
11
|
+
*/
|
|
12
|
+
export declare const PROBE_TIMEOUT_MS = 10000;
|
|
13
|
+
/**
|
|
14
|
+
* Budget for the READ half of the network (fetch/update): a slow link with a
|
|
15
|
+
* real pack transfer outruns the local default, so this is deliberately wide.
|
|
16
|
+
*/
|
|
17
|
+
export declare const FETCH_TIMEOUT_MS = 300000;
|
|
18
|
+
/**
|
|
19
|
+
* Budget for the WRITE half (push/push-tag). Wider than fetch: a push that
|
|
20
|
+
* is killed mid-transfer may already have updated the remote refs, and the
|
|
21
|
+
* caller pays for that ambiguity with a verification round-trip.
|
|
22
|
+
*/
|
|
23
|
+
export declare const PUSH_TIMEOUT_MS = 600000;
|
|
24
|
+
/** Budget for the post-timeout reconciliation (`ls-remote` after a push). */
|
|
25
|
+
export declare const VERIFY_TIMEOUT_MS = 10000;
|
|
26
|
+
/** What a failed git network command most likely means. */
|
|
27
|
+
export type GitFailureKind = 'timeout' | 'unreachable' | 'reset' | 'proxy' | 'tls' | 'credentials' | 'diverged' | 'protected' | 'unknown';
|
|
28
|
+
/** Diagnosis of one failed remote command. */
|
|
29
|
+
export interface GitFailure {
|
|
30
|
+
/** The shape of the failure; the UI may key wording off it later. */
|
|
31
|
+
kind: GitFailureKind;
|
|
32
|
+
/**
|
|
33
|
+
* Whether another network channel (a pinned IP, a proxy) could plausibly
|
|
34
|
+
* make the SAME command succeed. Credential, TLS, divergence and hook
|
|
35
|
+
* failures are about state, not reachability, so they never retry.
|
|
36
|
+
*/
|
|
37
|
+
retryable: boolean;
|
|
38
|
+
/** One sentence the caller may append to the toast; undefined = say nothing. */
|
|
39
|
+
hint: string | undefined;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Classify one failed git network command from its diagnostic text.
|
|
43
|
+
* @param stderr - git's diagnostic (stderr, or the executor's fallback text).
|
|
44
|
+
* @param options - `timedOut` when the executor killed the process at its
|
|
45
|
+
* budget; that outranks the text, because a killed push carries no verdict.
|
|
46
|
+
* @returns the kind, whether another channel may help, and a one-line hint.
|
|
47
|
+
*/
|
|
48
|
+
export declare function classifyGitFailure(stderr: string, options?: {
|
|
49
|
+
timedOut?: boolean;
|
|
50
|
+
}): GitFailure;
|
|
51
|
+
/**
|
|
52
|
+
* Append a classified hint to git's own message, once: the raw diagnostic
|
|
53
|
+
* stays first so the user can still copy it into a bug report.
|
|
54
|
+
* @param message - the existing error text.
|
|
55
|
+
* @param failure - classifier output.
|
|
56
|
+
* @returns the message with the hint appended, unchanged when there is none.
|
|
57
|
+
*/
|
|
58
|
+
export declare function withHint(message: string, failure: GitFailure): string;
|