dsh-git-branch-manage 1.2.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/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
+ }
@@ -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/` + `o/r.git`
79
- * must stay one valid URL). Callers pass the result to `url.<base>.insteadOf`.
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/` + `o/r.git`
121
- * must stay one valid URL). Callers pass the result to `url.<base>.insteadOf`.
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
- await fetchAll(deps.exec, facts.repoRoot, accelPrefix(deps), await authArgsFor(deps));
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
- await fetchAll(deps.exec, facts.repoRoot, accelPrefix(deps), await authArgsFor(deps));
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 outcome = await pushBranch(deps.exec, facts.toplevel, await authArgsFor(deps));
764
- const result = { branch: outcome.branch, pushed: outcome.pushed };
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 result = await pushTag(deps.exec, facts.toplevel, name, choice.remote, await authArgsFor(deps));
1196
- const body = result;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-git-branch-manage",
3
- "version": "1.2.0",
3
+ "version": "1.3.1",
4
4
  "description": "提供 git 分支与 worktree 管理:输入框工具栏显示当前分支、可原地切换;支持 IDEA 风格分支选择器、远程分支检出、右键操作菜单(新建/重命名/删除/复制)、推送与更新当前分支,以及把分支隔离到独立 worktree 工作区,并管理其创建与删除。",
5
5
  "keywords": [
6
6
  "dsh",