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/network.js
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
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 const PROBE_TIMEOUT_MS = 10_000;
|
|
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 const FETCH_TIMEOUT_MS = 300_000;
|
|
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 const PUSH_TIMEOUT_MS = 600_000;
|
|
24
|
+
/** Budget for the post-timeout reconciliation (`ls-remote` after a push). */
|
|
25
|
+
export const VERIFY_TIMEOUT_MS = 10_000;
|
|
26
|
+
/** Patterns are matched in order: the first hit wins. */
|
|
27
|
+
const RULES = [
|
|
28
|
+
{
|
|
29
|
+
kind: 'proxy',
|
|
30
|
+
retryable: false,
|
|
31
|
+
hint: 'The configured proxy refused or failed the CONNECT tunnel; check the proxy address or turn it off.',
|
|
32
|
+
test: text => /over proxy/i.test(text)
|
|
33
|
+
|| /Proxy CONNECT aborted/i.test(text)
|
|
34
|
+
|| /CONNECT tunnel failed/i.test(text)
|
|
35
|
+
|| /Received HTTP code 407/i.test(text),
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
kind: 'credentials',
|
|
39
|
+
retryable: false,
|
|
40
|
+
hint: 'Git could not read usable credentials; sign in for this host in the credentials panel, then retry.',
|
|
41
|
+
test: text => /terminal prompts disabled/i.test(text)
|
|
42
|
+
|| /could not read Username/i.test(text)
|
|
43
|
+
|| /Authentication failed/i.test(text)
|
|
44
|
+
|| /Permission denied \(publickey/i.test(text)
|
|
45
|
+
|| /HTTP 40[13]\b/.test(text),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
kind: 'unreachable',
|
|
49
|
+
retryable: true,
|
|
50
|
+
hint: 'The host could not be reached; retry, then pin an IP or use a proxy.',
|
|
51
|
+
test: text => /Could not resolve host/i.test(text)
|
|
52
|
+
|| /Failed to connect/i.test(text)
|
|
53
|
+
|| /Could not connect to server/i.test(text)
|
|
54
|
+
|| /Connection refused/i.test(text)
|
|
55
|
+
|| /Network is unreachable/i.test(text),
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
kind: 'reset',
|
|
59
|
+
retryable: true,
|
|
60
|
+
hint: 'The connection was reset mid-transfer; retry, then pin an IP or use a proxy.',
|
|
61
|
+
test: text => /Connection was reset/i.test(text)
|
|
62
|
+
|| /Recv failure/i.test(text)
|
|
63
|
+
|| /RPC failed/i.test(text)
|
|
64
|
+
|| /early EOF/i.test(text)
|
|
65
|
+
|| /remote end hung up unexpectedly/i.test(text)
|
|
66
|
+
|| /unexpected disconnect/i.test(text),
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
kind: 'tls',
|
|
70
|
+
retryable: false,
|
|
71
|
+
hint: 'TLS negotiation failed; the network is intercepting the connection or the certificate is untrusted.',
|
|
72
|
+
test: text => /SSL certificate problem/i.test(text)
|
|
73
|
+
|| /schannel/i.test(text)
|
|
74
|
+
|| /SSL_ERROR/i.test(text)
|
|
75
|
+
|| /tlsv1 alert/i.test(text),
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
kind: 'diverged',
|
|
79
|
+
retryable: false,
|
|
80
|
+
hint: 'The remote has commits you do not have; pull or rebase before pushing (the plugin never forces).',
|
|
81
|
+
test: text => /non-fast-forward/i.test(text)
|
|
82
|
+
|| /fetch first/i.test(text)
|
|
83
|
+
|| /Not possible to fast-forward/i.test(text)
|
|
84
|
+
|| /\[rejected\]/i.test(text),
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
kind: 'protected',
|
|
88
|
+
retryable: false,
|
|
89
|
+
hint: 'A server-side rule refused the update (protected branch or hook); resolve it on the host, not in the network.',
|
|
90
|
+
test: text => /protected branch/i.test(text)
|
|
91
|
+
|| /hook declined/i.test(text)
|
|
92
|
+
|| /pre-receive hook/i.test(text),
|
|
93
|
+
},
|
|
94
|
+
];
|
|
95
|
+
/**
|
|
96
|
+
* Classify one failed git network command from its diagnostic text.
|
|
97
|
+
* @param stderr - git's diagnostic (stderr, or the executor's fallback text).
|
|
98
|
+
* @param options - `timedOut` when the executor killed the process at its
|
|
99
|
+
* budget; that outranks the text, because a killed push carries no verdict.
|
|
100
|
+
* @returns the kind, whether another channel may help, and a one-line hint.
|
|
101
|
+
*/
|
|
102
|
+
export function classifyGitFailure(stderr, options = {}) {
|
|
103
|
+
if (options.timedOut === true) {
|
|
104
|
+
return {
|
|
105
|
+
kind: 'timeout',
|
|
106
|
+
retryable: true,
|
|
107
|
+
hint: 'The command did not finish within its time budget; the remote may still be applying it — verify before retrying a push.',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
const text = stderr.trim();
|
|
111
|
+
for (const rule of RULES) {
|
|
112
|
+
if (rule.test(text))
|
|
113
|
+
return { kind: rule.kind, retryable: rule.retryable, hint: rule.hint };
|
|
114
|
+
}
|
|
115
|
+
return { kind: 'unknown', retryable: false, hint: undefined };
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Append a classified hint to git's own message, once: the raw diagnostic
|
|
119
|
+
* stays first so the user can still copy it into a bug report.
|
|
120
|
+
* @param message - the existing error text.
|
|
121
|
+
* @param failure - classifier output.
|
|
122
|
+
* @returns the message with the hint appended, unchanged when there is none.
|
|
123
|
+
*/
|
|
124
|
+
export function withHint(message, failure) {
|
|
125
|
+
if (failure.hint === undefined || message.includes(failure.hint))
|
|
126
|
+
return message;
|
|
127
|
+
return `${message}\n\n${failure.hint}`;
|
|
128
|
+
}
|
package/lib/normalize.d.ts
CHANGED
|
@@ -75,8 +75,8 @@ export type AccelerateUrlIssue = 'empty' | 'invalid';
|
|
|
75
75
|
export declare function accelerateUrlError(value: string): AccelerateUrlIssue | undefined;
|
|
76
76
|
/**
|
|
77
77
|
* Normalize an accepted accelerate URL: trim, and append the trailing `/`
|
|
78
|
-
* the prefix concatenation requires (`https://gh-proxy.com/` +
|
|
79
|
-
*
|
|
78
|
+
* the prefix concatenation requires (`https://gh-proxy.com/` + the complete
|
|
79
|
+
* `https://github.com/o/r.git` URL). accelArgs builds the Git rewrite base.
|
|
80
80
|
* @param value - an accepted (non-empty, http(s), whitespace-free) URL.
|
|
81
81
|
* @returns the prefix ending in `/`.
|
|
82
82
|
*/
|
|
@@ -105,3 +105,15 @@ export type RemoteUrlIssue = 'empty' | 'whitespace' | 'invalid';
|
|
|
105
105
|
* @returns the issue kind, or undefined when acceptable.
|
|
106
106
|
*/
|
|
107
107
|
export declare function remoteUrlIssue(url: string): RemoteUrlIssue | undefined;
|
|
108
|
+
/** Schemes git's libcurl accepts for an HTTP(S) remote. */
|
|
109
|
+
export declare const PROXY_SCHEMES: readonly string[];
|
|
110
|
+
/**
|
|
111
|
+
* Why this proxy value cannot be used, or undefined when it can.
|
|
112
|
+
*
|
|
113
|
+
* Credentials are rejected on purpose: the value is stored in the plugin
|
|
114
|
+
* config and passed as one `-c` argument, so `user:pass@` would be readable
|
|
115
|
+
* in the settings UI and in any diagnostics (0043).
|
|
116
|
+
* @param value - candidate proxy URL.
|
|
117
|
+
* @returns the user-facing reason, or undefined.
|
|
118
|
+
*/
|
|
119
|
+
export declare function proxyUrlError(value: string): string | undefined;
|
package/lib/normalize.js
CHANGED
|
@@ -117,8 +117,8 @@ export function accelerateUrlError(value) {
|
|
|
117
117
|
}
|
|
118
118
|
/**
|
|
119
119
|
* Normalize an accepted accelerate URL: trim, and append the trailing `/`
|
|
120
|
-
* the prefix concatenation requires (`https://gh-proxy.com/` +
|
|
121
|
-
*
|
|
120
|
+
* the prefix concatenation requires (`https://gh-proxy.com/` + the complete
|
|
121
|
+
* `https://github.com/o/r.git` URL). accelArgs builds the Git rewrite base.
|
|
122
122
|
* @param value - an accepted (non-empty, http(s), whitespace-free) URL.
|
|
123
123
|
* @returns the prefix ending in `/`.
|
|
124
124
|
*/
|
|
@@ -173,3 +173,39 @@ export function remoteUrlIssue(url) {
|
|
|
173
173
|
return undefined;
|
|
174
174
|
return 'invalid';
|
|
175
175
|
}
|
|
176
|
+
/** Schemes git's libcurl accepts for an HTTP(S) remote. */
|
|
177
|
+
export const PROXY_SCHEMES = ['http', 'https', 'socks5', 'socks5h'];
|
|
178
|
+
/**
|
|
179
|
+
* Why this proxy value cannot be used, or undefined when it can.
|
|
180
|
+
*
|
|
181
|
+
* Credentials are rejected on purpose: the value is stored in the plugin
|
|
182
|
+
* config and passed as one `-c` argument, so `user:pass@` would be readable
|
|
183
|
+
* in the settings UI and in any diagnostics (0043).
|
|
184
|
+
* @param value - candidate proxy URL.
|
|
185
|
+
* @returns the user-facing reason, or undefined.
|
|
186
|
+
*/
|
|
187
|
+
export function proxyUrlError(value) {
|
|
188
|
+
let url;
|
|
189
|
+
try {
|
|
190
|
+
url = new URL(value);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
return 'must be an absolute proxy URL such as http://127.0.0.1:7890';
|
|
194
|
+
}
|
|
195
|
+
if (!PROXY_SCHEMES.includes(url.protocol.replace(':', ''))) {
|
|
196
|
+
return `scheme must be one of ${PROXY_SCHEMES.join(', ')}`;
|
|
197
|
+
}
|
|
198
|
+
if (url.hostname === '')
|
|
199
|
+
return 'must name a proxy host';
|
|
200
|
+
if (url.port === '')
|
|
201
|
+
return 'must name a proxy port';
|
|
202
|
+
const port = Number(url.port);
|
|
203
|
+
if (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
204
|
+
return 'proxy port must be 1-65535';
|
|
205
|
+
if (url.username !== '' || url.password !== '')
|
|
206
|
+
return 'credentials in the proxy URL are not supported';
|
|
207
|
+
if ((url.pathname !== '' && url.pathname !== '/') || url.search !== '' || url.hash !== '') {
|
|
208
|
+
return 'must not carry a path, query, or fragment';
|
|
209
|
+
}
|
|
210
|
+
return undefined;
|
|
211
|
+
}
|
package/lib/routes.d.ts
CHANGED
|
@@ -6,13 +6,21 @@ import { type CopyFilesSeams } from './copy-files.js';
|
|
|
6
6
|
import { type CredentialStore } from './credentials.js';
|
|
7
7
|
import type { CredentialService } from './credential-service.js';
|
|
8
8
|
import { type LocalImportReport } from './local-import.js';
|
|
9
|
-
import { type DirExists, type ExcludeSeams, type Exec } from './git.js';
|
|
10
|
-
import type { AiCommitMessageResult, ChangesResult, CommitFilesResult, CommitResult, CreateBranchResult, CreateTagResult, CreateWorktreeResult, CredentialOAuthPollResult, CredentialOAuthStartResult, CredentialImportResult, CredentialsResult, EnsureDirectoryResult, FetchResult, GroupWorkspacesResult, InspectWorktreeResult, LogResult, PathExistsResult, PurgeDirectoryResult, PushTagResult, RemoveWorktreeResult, RemotesResult, RepoStatus, RouteError, StageResult, SwitchResult, UpdateResult, WorktreesAllResult } from './wire.js';
|
|
9
|
+
import { type ChannelProvider, type DirExists, type ExcludeSeams, type Exec } from './git.js';
|
|
10
|
+
import type { NetworkProbeResult, AiCommitMessageResult, ChangesResult, CommitFilesResult, CommitResult, CreateBranchResult, CreateTagResult, CreateWorktreeResult, CredentialOAuthPollResult, CredentialOAuthStartResult, CredentialImportResult, CredentialsResult, EnsureDirectoryResult, FetchResult, GroupWorkspacesResult, InspectWorktreeResult, LogResult, PathExistsResult, PurgeDirectoryResult, PushTagResult, RemoveWorktreeResult, RemotesResult, RepoStatus, RouteError, StageResult, SwitchResult, UpdateResult, WorktreesAllResult } from './wire.js';
|
|
11
11
|
/** Everything the handlers need from the host half. */
|
|
12
12
|
export interface RouteDeps {
|
|
13
13
|
exec: Exec;
|
|
14
|
+
/** Fallback channels for a network command, in priority order: the
|
|
15
|
+
* explicit proxy first, then the IP-optimized channel (absent = direct only; tests leave
|
|
16
|
+
* it out so no real lookup, race, or proxy argv ever runs). */
|
|
17
|
+
networkChannels?: (exec: Exec, cwd: string, authArgs: readonly string[]) => readonly ChannelProvider[];
|
|
14
18
|
/** The settings-resolved rootDir (absent/blank = the default location). */
|
|
15
19
|
sectionRootDir: () => string | undefined;
|
|
20
|
+
/** Explicit proxy URL for the proxy channel; absent/blank = none. */
|
|
21
|
+
sectionNetworkProxy?: () => string | undefined;
|
|
22
|
+
/** The proxy Windows itself has enabled (read-only WinINET probe); absent = not looked up. */
|
|
23
|
+
sectionSystemProxy?: () => Promise<string | undefined>;
|
|
16
24
|
/** Whether a worktree creation syncs the remotes first (absent = off). */
|
|
17
25
|
sectionFetchBeforeCreate: () => boolean | undefined;
|
|
18
26
|
/** Post-create configuration files to copy when .worktreeinclude is absent. */
|
|
@@ -68,7 +76,7 @@ export declare function fsMkdirRecursive(path: string): Promise<void>;
|
|
|
68
76
|
/** One route outcome: HTTP status plus the JSON body. */
|
|
69
77
|
export interface RouteOutcome {
|
|
70
78
|
status: number;
|
|
71
|
-
body: RepoStatus | CreateWorktreeResult | SwitchResult | CreateBranchResult | FetchResult | UpdateResult | GroupWorkspacesResult | InspectWorktreeResult | RemoveWorktreeResult | PathExistsResult | EnsureDirectoryResult | WorktreesAllResult | PurgeDirectoryResult | ChangesResult | StageResult | CommitResult | LogResult | CommitFilesResult | CreateTagResult | PushTagResult | AiCommitMessageResult | CredentialsResult | CredentialOAuthStartResult | CredentialOAuthPollResult | CredentialImportResult | RemotesResult | RouteError;
|
|
79
|
+
body: RepoStatus | CreateWorktreeResult | SwitchResult | CreateBranchResult | FetchResult | UpdateResult | GroupWorkspacesResult | InspectWorktreeResult | RemoveWorktreeResult | PathExistsResult | EnsureDirectoryResult | WorktreesAllResult | PurgeDirectoryResult | ChangesResult | StageResult | CommitResult | LogResult | CommitFilesResult | CreateTagResult | PushTagResult | AiCommitMessageResult | CredentialsResult | CredentialOAuthStartResult | CredentialOAuthPollResult | CredentialImportResult | RemotesResult | NetworkProbeResult | RouteError;
|
|
72
80
|
}
|
|
73
81
|
/**
|
|
74
82
|
* POST /group — git belonging facts for a batch of workspace directories.
|
|
@@ -440,3 +448,16 @@ export declare function handleRemotes(deps: RouteDeps, body: unknown): Promise<R
|
|
|
440
448
|
* @param body - parsed request body: `{ repoPath, action, name, url? }`.
|
|
441
449
|
*/
|
|
442
450
|
export declare function handleRemote(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
|
|
451
|
+
/**
|
|
452
|
+
* Report which network channel can read the repository's endpoint right now.
|
|
453
|
+
*
|
|
454
|
+
* Diagnostics only: nothing here writes to git or to the config. Each
|
|
455
|
+
* channel gets its own budget (PROBE_TIMEOUT_MS), so a dead proxy costs one
|
|
456
|
+
* timeout and the answer still names the channels that work. The system
|
|
457
|
+
* proxy is reported when the plugin has none of its own — git ignores
|
|
458
|
+
* WinINET, so a value here is a suggestion the user must opt into (0043).
|
|
459
|
+
* @param deps - route dependencies (exec, channel providers, config readers).
|
|
460
|
+
* @param body - `{ repoPath }`.
|
|
461
|
+
* @returns one report per channel, in the order the write path would try them.
|
|
462
|
+
*/
|
|
463
|
+
export declare function handleNetworkProbe(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
|
package/lib/routes.js
CHANGED
|
@@ -7,7 +7,9 @@ import { mkdir, readdir, rm, stat } from 'node:fs/promises';
|
|
|
7
7
|
import { runPostCreateCopy } from './copy-files.js';
|
|
8
8
|
import { credentialHostIssue, credentialSecretIssue, credentialUsernameIssue, createLocalCredentialStore, maskSecret, normalizeCredentialHost, removeCredential, sortCredentials, upsertCredential } from './credentials.js';
|
|
9
9
|
import { importLocalCredentials as runLocalImport } from './local-import.js';
|
|
10
|
-
import { appendWorktreeExclude, fsExcludeSeams, GitError, addRemote, addWorktree, addWorktreeCutout, collectAiDiff, commitFiles, commitIndex, createBranch, createTag, cutoutBranchName, deleteBranch, fetchAll, fsDirExists, inspectWorktree, isAbsoluteDir, isStageablePath, listRemotes, listTagNames, logHistory, probeRepo, probeWorkspaceGit, pushTag, removeRemote, removeWorktree, renameBranch, resolveBranch, resolvePushRemote, pushBranch, setRemoteUrl, stagePaths, switchBranch, updateBranch, workingChanges } from './git.js';
|
|
10
|
+
import { appendWorktreeExclude, fsExcludeSeams, GitError, addRemote, addWorktree, addWorktreeCutout, collectAiDiff, commitFiles, commitIndex, createBranch, createTag, cutoutBranchName, deleteBranch, fetchAll, fsDirExists, inspectWorktree, isAbsoluteDir, isStageablePath, listRemotes, listTagNames, logHistory, probeRepo, probeWorkspaceGit, pushTag, removeRemote, removeWorktree, renameBranch, resolveBranch, resolvePushRemote, resolvePushTarget, pushBranch, setRemoteUrl, stagePaths, switchBranch, updateBranch, workingChanges } from './git.js';
|
|
11
|
+
import { classifyGitFailure, withHint } from './network.js';
|
|
12
|
+
import { channelReport } from './network-probe.js';
|
|
11
13
|
import { ACCEL_DEFAULT_URL, accelerateUrlError, isAbsoluteConfigPath, normalizeAccelerateUrl, remoteNameIssue, remoteUrlIssue, sanitizeBranchDir } from './normalize.js';
|
|
12
14
|
import { deviceFlowClientId, isDeviceFlowHost, pollDeviceFlow, startDeviceFlow } from './oauth.js';
|
|
13
15
|
import { resolveRootDir } from './settings.js';
|
|
@@ -54,6 +56,11 @@ function accelPrefix(deps) {
|
|
|
54
56
|
const url = deps.sectionCnAccelerateUrl?.() ?? ACCEL_DEFAULT_URL;
|
|
55
57
|
return accelerateUrlError(url) === undefined ? normalizeAccelerateUrl(url) : undefined;
|
|
56
58
|
}
|
|
59
|
+
/** The fallback chain for one command: the host's providers, or none at all. */
|
|
60
|
+
function networkOptions(deps, cwd, authArgs) {
|
|
61
|
+
const channels = deps.networkChannels?.(deps.exec, cwd, authArgs) ?? [];
|
|
62
|
+
return channels.length === 0 ? {} : { fallbackChannels: channels };
|
|
63
|
+
}
|
|
57
64
|
/** The global credential store: the service's shared instance (decision 0037),
|
|
58
65
|
* else the wired store, else the local `~/.git-credentials`-backed store. */
|
|
59
66
|
function credentialStore(deps) {
|
|
@@ -313,6 +320,10 @@ export async function handlePurgeDirectory(deps, body) {
|
|
|
313
320
|
}
|
|
314
321
|
/** GitError to outcome: usage-shaped failures are 400, the rest 500. */
|
|
315
322
|
function gitFailure(error) {
|
|
323
|
+
// Every git failure the routes surface passes through one classifier so
|
|
324
|
+
// the toast explains WHAT broke (host, credentials, divergence) instead of
|
|
325
|
+
// repeating git's raw text alone.
|
|
326
|
+
const failure = classifyGitFailure(error.stderr, { timedOut: error.timedOut });
|
|
316
327
|
const usage = error.stderr.includes('usage:')
|
|
317
328
|
|| error.stderr.includes('fatal: invalid')
|
|
318
329
|
// A user-typed branch name git refuses (`'bad..name' is not a valid
|
|
@@ -347,7 +358,7 @@ function gitFailure(error) {
|
|
|
347
358
|
// (a stale sidebar row racing a terminal-side removal).
|
|
348
359
|
|| error.stderr.includes('cannot remove the main worktree')
|
|
349
360
|
|| error.stderr.includes('is not a registered worktree');
|
|
350
|
-
return fail(usage ? 400 : 500, error.message);
|
|
361
|
+
return fail(usage ? 400 : 500, withHint(error.message, failure));
|
|
351
362
|
}
|
|
352
363
|
/**
|
|
353
364
|
* GET /status 鈥?repository facts for one directory.
|
|
@@ -462,7 +473,10 @@ export async function handleCreateWorktree(deps, body) {
|
|
|
462
473
|
const resolved = await resolveBranch(deps.exec, facts.repoRoot, branch);
|
|
463
474
|
if (resolved?.remote !== undefined) {
|
|
464
475
|
try {
|
|
465
|
-
|
|
476
|
+
{
|
|
477
|
+
const authArgs = await authArgsFor(deps);
|
|
478
|
+
await fetchAll(deps.exec, facts.repoRoot, accelPrefix(deps), authArgs, networkOptions(deps, facts.repoRoot, authArgs));
|
|
479
|
+
}
|
|
466
480
|
}
|
|
467
481
|
catch (error) {
|
|
468
482
|
fetchWarning = error instanceof GitError ? error.stderr.trim() : error instanceof Error ? error.message : String(error);
|
|
@@ -697,7 +711,10 @@ export async function handleFetch(deps, body) {
|
|
|
697
711
|
const facts = await probeRepo(deps.exec, repoPath, deps.dirExists);
|
|
698
712
|
if (facts === undefined)
|
|
699
713
|
return fail(400, `"${repoPath}" is not inside a git repository`);
|
|
700
|
-
|
|
714
|
+
{
|
|
715
|
+
const authArgs = await authArgsFor(deps);
|
|
716
|
+
await fetchAll(deps.exec, facts.repoRoot, accelPrefix(deps), authArgs, networkOptions(deps, facts.repoRoot, authArgs));
|
|
717
|
+
}
|
|
701
718
|
const result = { remote: 'all' };
|
|
702
719
|
return { status: 200, body: result };
|
|
703
720
|
}
|
|
@@ -760,8 +777,9 @@ export async function handlePush(deps, body) {
|
|
|
760
777
|
return fail(400, `"${repoPath}" is not inside a git repository`);
|
|
761
778
|
// The session's OWN worktree, not the main checkout: in a linked-worktree
|
|
762
779
|
// session "push current branch" means the branch that worktree holds.
|
|
763
|
-
const
|
|
764
|
-
const
|
|
780
|
+
const authArgs = await authArgsFor(deps);
|
|
781
|
+
const outcome = await pushBranch(deps.exec, facts.toplevel, authArgs, networkOptions(deps, facts.toplevel, authArgs));
|
|
782
|
+
const result = { branch: outcome.branch, pushed: outcome.pushed, ...(outcome.note === undefined ? {} : { note: outcome.note }) };
|
|
765
783
|
return { status: 200, body: result };
|
|
766
784
|
}
|
|
767
785
|
catch (error) {
|
|
@@ -1192,8 +1210,9 @@ export async function handlePushTag(deps, body) {
|
|
|
1192
1210
|
const choice = await resolvePushRemote(deps.exec, facts.toplevel);
|
|
1193
1211
|
if (!choice.ok)
|
|
1194
1212
|
return fail(400, choice.message);
|
|
1195
|
-
const
|
|
1196
|
-
const
|
|
1213
|
+
const authArgs = await authArgsFor(deps);
|
|
1214
|
+
const result = await pushTag(deps.exec, facts.toplevel, name, choice.remote, authArgs, networkOptions(deps, facts.toplevel, authArgs));
|
|
1215
|
+
const body = { ...result, ...(result.note === undefined ? {} : { note: result.note }) };
|
|
1197
1216
|
return { status: 200, body };
|
|
1198
1217
|
}
|
|
1199
1218
|
catch (error) {
|
|
@@ -1671,3 +1690,73 @@ export async function handleRemote(deps, body) {
|
|
|
1671
1690
|
return fail(500, error instanceof Error ? error.message : String(error));
|
|
1672
1691
|
}
|
|
1673
1692
|
}
|
|
1693
|
+
/**
|
|
1694
|
+
* Report which network channel can read the repository's endpoint right now.
|
|
1695
|
+
*
|
|
1696
|
+
* Diagnostics only: nothing here writes to git or to the config. Each
|
|
1697
|
+
* channel gets its own budget (PROBE_TIMEOUT_MS), so a dead proxy costs one
|
|
1698
|
+
* timeout and the answer still names the channels that work. The system
|
|
1699
|
+
* proxy is reported when the plugin has none of its own — git ignores
|
|
1700
|
+
* WinINET, so a value here is a suggestion the user must opt into (0043).
|
|
1701
|
+
* @param deps - route dependencies (exec, channel providers, config readers).
|
|
1702
|
+
* @param body - `{ repoPath }`.
|
|
1703
|
+
* @returns one report per channel, in the order the write path would try them.
|
|
1704
|
+
*/
|
|
1705
|
+
export async function handleNetworkProbe(deps, body) {
|
|
1706
|
+
const parsed = readBody(body, ['repoPath']);
|
|
1707
|
+
if (isOutcome(parsed))
|
|
1708
|
+
return parsed;
|
|
1709
|
+
const { repoPath } = parsed;
|
|
1710
|
+
if (!isAbsoluteDir(repoPath))
|
|
1711
|
+
return fail(400, '"repoPath" must be an absolute directory');
|
|
1712
|
+
try {
|
|
1713
|
+
const facts = await probeRepo(deps.exec, repoPath, deps.dirExists);
|
|
1714
|
+
if (facts === undefined)
|
|
1715
|
+
return fail(400, JSON.stringify(repoPath) + " is not inside a git repository");
|
|
1716
|
+
const authArgs = await authArgsFor(deps);
|
|
1717
|
+
const url = await probeEndpoint(deps.exec, facts.repoRoot, authArgs);
|
|
1718
|
+
if (url === undefined)
|
|
1719
|
+
return fail(400, '"repoPath" has no remote to probe');
|
|
1720
|
+
const channels = [await channelReport(deps.exec, facts.repoRoot, 'direct', url, [], authArgs)];
|
|
1721
|
+
for (const provider of deps.networkChannels?.(deps.exec, facts.repoRoot, authArgs) ?? []) {
|
|
1722
|
+
const argv = await provider(url);
|
|
1723
|
+
if (argv === undefined || argv.length === 0)
|
|
1724
|
+
continue;
|
|
1725
|
+
const channel = argv.some(item => item.startsWith('http.proxy=')) ? 'proxy' : 'ip';
|
|
1726
|
+
if (channels.some(report => report.channel === channel))
|
|
1727
|
+
continue;
|
|
1728
|
+
channels.push(await channelReport(deps.exec, facts.repoRoot, channel, url, argv, authArgs));
|
|
1729
|
+
}
|
|
1730
|
+
const systemProxy = await deps.sectionSystemProxy?.();
|
|
1731
|
+
const result = {
|
|
1732
|
+
url,
|
|
1733
|
+
channels,
|
|
1734
|
+
...(systemProxy === undefined || systemProxy === '' ? {} : { systemProxy }),
|
|
1735
|
+
};
|
|
1736
|
+
return { status: 200, body: result };
|
|
1737
|
+
}
|
|
1738
|
+
catch (error) {
|
|
1739
|
+
if (error instanceof GitError)
|
|
1740
|
+
return gitFailure(error);
|
|
1741
|
+
throw error;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
/**
|
|
1745
|
+
* The endpoint a probe should read: the upstream's push URL when the branch
|
|
1746
|
+
* tracks one, otherwise the push URL of `origin` (or the first remote).
|
|
1747
|
+
* @param exec - executor seam.
|
|
1748
|
+
* @param cwd - any directory inside the repository.
|
|
1749
|
+
* @param authArgs - credential argv for the host.
|
|
1750
|
+
* @returns the URL, or undefined when the repository has no remote at all.
|
|
1751
|
+
*/
|
|
1752
|
+
async function probeEndpoint(exec, cwd, authArgs) {
|
|
1753
|
+
const target = await resolvePushTarget(exec, cwd, authArgs);
|
|
1754
|
+
if (target?.url !== undefined && target.url !== '')
|
|
1755
|
+
return target.url;
|
|
1756
|
+
const remotes = await listRemotes(exec, cwd);
|
|
1757
|
+
const chosen = remotes.find(remote => remote.name === 'origin') ?? remotes[0];
|
|
1758
|
+
if (chosen === undefined)
|
|
1759
|
+
return undefined;
|
|
1760
|
+
const url = chosen.pushUrl !== '' ? chosen.pushUrl : chosen.fetchUrl;
|
|
1761
|
+
return url === '' ? undefined : url;
|
|
1762
|
+
}
|
package/lib/wire.d.ts
CHANGED
|
@@ -57,6 +57,8 @@ export declare const ROUTE_COMMIT_FILES = "/plugin/git-worktree/commit-files";
|
|
|
57
57
|
export declare const ROUTE_TAG = "/plugin/git-worktree/tag";
|
|
58
58
|
/** POST ROUTE_PREFIX/tag-push — push ONE existing local tag to its remote. */
|
|
59
59
|
export declare const ROUTE_TAG_PUSH = "/plugin/git-worktree/tag-push";
|
|
60
|
+
/** POST ROUTE_PREFIX/network-probe — read the endpoint through every configured channel (diagnostics; no state change). */
|
|
61
|
+
export declare const ROUTE_NETWORK_PROBE = "/plugin/git-worktree/network-probe";
|
|
60
62
|
/** POST ROUTE_PREFIX/credentials — the global per-host credentials, secrets
|
|
61
63
|
* masked (the stored secret never leaves the host). */
|
|
62
64
|
export declare const ROUTE_CREDENTIALS = "/plugin/git-worktree/credentials";
|
|
@@ -270,6 +272,9 @@ export interface PushResult {
|
|
|
270
272
|
branch: string;
|
|
271
273
|
/** False when the upstream already contained the branch (nothing moved). */
|
|
272
274
|
pushed: boolean;
|
|
275
|
+
/** Set when the push timed out but the remote ref already matched HEAD:
|
|
276
|
+
* the push landed and only the confirmation was lost. */
|
|
277
|
+
note?: 'timed-out-but-landed';
|
|
273
278
|
}
|
|
274
279
|
/** Error envelope every non-2xx response carries. */
|
|
275
280
|
export interface RouteError {
|
|
@@ -546,6 +551,9 @@ export interface PushTagResult {
|
|
|
546
551
|
/** False when the remote already carried this exact tag object — nothing
|
|
547
552
|
* was sent. True once the push command actually ran. */
|
|
548
553
|
pushed: boolean;
|
|
554
|
+
/** Set when the push timed out but the remote tag already named the local
|
|
555
|
+
* object: the tag landed and only the confirmation was lost. */
|
|
556
|
+
note?: 'timed-out-but-landed';
|
|
549
557
|
}
|
|
550
558
|
/** One file row of POST commit-files. */
|
|
551
559
|
export interface CommitFileEntry {
|
|
@@ -731,3 +739,30 @@ export interface RemoteMutationBody {
|
|
|
731
739
|
}
|
|
732
740
|
/** POST remote response body: the repository's remotes after the change. */
|
|
733
741
|
export type RemoteMutationResult = RemotesResult;
|
|
742
|
+
/** The network channels a probe can report, in the order they are tried. */
|
|
743
|
+
export type NetworkChannelId = 'direct' | 'proxy' | 'ip';
|
|
744
|
+
/** One channel's verdict from ROUTE_NETWORK_PROBE. */
|
|
745
|
+
export interface NetworkChannelReport {
|
|
746
|
+
/** Which channel was attempted. */
|
|
747
|
+
channel: NetworkChannelId;
|
|
748
|
+
/** Whether an ls-remote answered within the probe budget. */
|
|
749
|
+
ok: boolean;
|
|
750
|
+
/** Wall-clock milliseconds the attempt took. */
|
|
751
|
+
elapsedMs: number;
|
|
752
|
+
/** Why it failed, trimmed for display; absent when ok. */
|
|
753
|
+
detail?: string;
|
|
754
|
+
}
|
|
755
|
+
/** POST network-probe request body. */
|
|
756
|
+
export interface NetworkProbeBody {
|
|
757
|
+
/** Any directory inside the repository. */
|
|
758
|
+
repoPath: string;
|
|
759
|
+
}
|
|
760
|
+
/** POST network-probe response body: one verdict per configured channel. */
|
|
761
|
+
export interface NetworkProbeResult {
|
|
762
|
+
/** The endpoint that was probed (the push URL when the branch has one). */
|
|
763
|
+
url: string;
|
|
764
|
+
/** One entry per configured channel. */
|
|
765
|
+
channels: NetworkChannelReport[];
|
|
766
|
+
/** The WinINET proxy when one is enabled but not configured in the plugin. */
|
|
767
|
+
systemProxy?: string;
|
|
768
|
+
}
|
package/lib/wire.js
CHANGED
|
@@ -57,6 +57,8 @@ export const ROUTE_COMMIT_FILES = `${ROUTE_PREFIX}/commit-files`;
|
|
|
57
57
|
export const ROUTE_TAG = `${ROUTE_PREFIX}/tag`;
|
|
58
58
|
/** POST ROUTE_PREFIX/tag-push — push ONE existing local tag to its remote. */
|
|
59
59
|
export const ROUTE_TAG_PUSH = `${ROUTE_PREFIX}/tag-push`;
|
|
60
|
+
/** POST ROUTE_PREFIX/network-probe — read the endpoint through every configured channel (diagnostics; no state change). */
|
|
61
|
+
export const ROUTE_NETWORK_PROBE = `${ROUTE_PREFIX}/network-probe`;
|
|
60
62
|
/** POST ROUTE_PREFIX/credentials — the global per-host credentials, secrets
|
|
61
63
|
* masked (the stored secret never leaves the host). */
|
|
62
64
|
export const ROUTE_CREDENTIALS = `${ROUTE_PREFIX}/credentials`;
|
package/package.json
CHANGED