dsh-git-branch-manage 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -5,13 +5,22 @@
5
5
  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
- import { type DirExists, type ExcludeSeams, type Exec } from './git.js';
9
- import type { AiCommitMessageResult, ChangesResult, CommitFilesResult, CommitResult, CreateBranchResult, CreateTagResult, CreateWorktreeResult, CredentialOAuthPollResult, CredentialOAuthStartResult, CredentialsResult, EnsureDirectoryResult, FetchResult, GroupWorkspacesResult, InspectWorktreeResult, LogResult, PathExistsResult, PurgeDirectoryResult, PushTagResult, RemoveWorktreeResult, RemotesResult, RepoStatus, RouteError, StageResult, SwitchResult, UpdateResult, WorktreesAllResult } from './wire.js';
8
+ import { type LocalImportReport } from './local-import.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';
10
11
  /** Everything the handlers need from the host half. */
11
12
  export interface RouteDeps {
12
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[];
13
18
  /** The settings-resolved rootDir (absent/blank = the default location). */
14
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>;
15
24
  /** Whether a worktree creation syncs the remotes first (absent = off). */
16
25
  sectionFetchBeforeCreate: () => boolean | undefined;
17
26
  /** Post-create configuration files to copy when .worktreeinclude is absent. */
@@ -51,6 +60,10 @@ export interface RouteDeps {
51
60
  /** Re-derive the shell credential bridge after the store changed (absent =
52
61
  * a host that does not provide the bridge; tests omit it). */
53
62
  refreshShellBridge?: () => Promise<void>;
63
+ /** Machine-credential import seam (decision 0038): probes the helpers the
64
+ * user's own gitconfig points git at, for the hosts it names. Tests
65
+ * substitute; absent = the real `git credential fill` probe. */
66
+ importLocalCredentials?: (store: CredentialStore, hosts?: readonly string[]) => Promise<LocalImportReport>;
54
67
  }
55
68
  /** Real fs-backed one-level directory listing (names only). Plain FILES are
56
69
  * filtered out — the storage root can carry strays (a legacy settings.json
@@ -63,7 +76,7 @@ export declare function fsMkdirRecursive(path: string): Promise<void>;
63
76
  /** One route outcome: HTTP status plus the JSON body. */
64
77
  export interface RouteOutcome {
65
78
  status: number;
66
- 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 | 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;
67
80
  }
68
81
  /**
69
82
  * POST /group — git belonging facts for a batch of workspace directories.
@@ -387,6 +400,18 @@ export declare function handleCredentialsSave(deps: RouteDeps, body: unknown): P
387
400
  * @param body - parsed request body: `{ host }`.
388
401
  */
389
402
  export declare function handleCredentialsDelete(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
403
+ /**
404
+ * POST /credentials-import — copy what the machine's own credential helpers
405
+ * already answer for into the managed store, so a machine signed in through
406
+ * gh/GCM/Keychain stops needing a second sign-in here (decision 0038).
407
+ *
408
+ * The candidates are the URL-scoped hosts of the user's gitconfig; a host the
409
+ * panel already manages is skipped (the panel wins), and a host whose helper
410
+ * answers nothing is reported per host instead of failing the whole request.
411
+ * @param deps - host dependencies.
412
+ * @param body - parsed request body: absent, `{}` or `{ hosts?: string[] }`.
413
+ */
414
+ export declare function handleCredentialsImport(deps: RouteDeps, body: unknown): Promise<RouteOutcome>;
390
415
  /**
391
416
  * POST /credentials-oauth-start — start a browser sign-in for ONE host
392
417
  * (GitHub device flow). The answer carries a one-time user code and the
@@ -423,3 +448,16 @@ export declare function handleRemotes(deps: RouteDeps, body: unknown): Promise<R
423
448
  * @param body - parsed request body: `{ repoPath, action, name, url? }`.
424
449
  */
425
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
@@ -5,8 +5,11 @@
5
5
  import { basename, dirname, join, resolve } from 'node:path';
6
6
  import { mkdir, readdir, rm, stat } from 'node:fs/promises';
7
7
  import { runPostCreateCopy } from './copy-files.js';
8
- import { authConfigArgs, credentialFilePath, createCredentialStore, credentialHostIssue, credentialSecretIssue, credentialUsernameIssue, maskSecret, normalizeCredentialHost, removeCredential, sortCredentials, upsertCredential } from './credentials.js';
9
- 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';
8
+ import { credentialHostIssue, credentialSecretIssue, credentialUsernameIssue, createLocalCredentialStore, maskSecret, normalizeCredentialHost, removeCredential, sortCredentials, upsertCredential } from './credentials.js';
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, resolvePushTarget, pushBranch, setRemoteUrl, stagePaths, switchBranch, updateBranch, workingChanges } from './git.js';
11
+ import { classifyGitFailure, withHint } from './network.js';
12
+ import { channelReport } from './network-probe.js';
10
13
  import { ACCEL_DEFAULT_URL, accelerateUrlError, isAbsoluteConfigPath, normalizeAccelerateUrl, remoteNameIssue, remoteUrlIssue, sanitizeBranchDir } from './normalize.js';
11
14
  import { deviceFlowClientId, isDeviceFlowHost, pollDeviceFlow, startDeviceFlow } from './oauth.js';
12
15
  import { resolveRootDir } from './settings.js';
@@ -53,28 +56,25 @@ function accelPrefix(deps) {
53
56
  const url = deps.sectionCnAccelerateUrl?.() ?? ACCEL_DEFAULT_URL;
54
57
  return accelerateUrlError(url) === undefined ? normalizeAccelerateUrl(url) : undefined;
55
58
  }
56
- /** The global credential store: the service's shared instance (decision 0034),
57
- * else the wired store, else the fs-backed file at the resolved dsh home
58
- * (`<dshHome>/git-credentials.json`). */
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
+ }
64
+ /** The global credential store: the service's shared instance (decision 0037),
65
+ * else the wired store, else the local `~/.git-credentials`-backed store. */
59
66
  function credentialStore(deps) {
60
- return deps.credentialsService?.store ?? deps.credentialsStore ?? createCredentialStore(credentialFilePath(deps.home(), deps.envHome()));
67
+ return deps.credentialsService?.store ?? deps.credentialsStore ?? createLocalCredentialStore(deps.home(), deps.envHome());
61
68
  }
62
69
  /**
63
- * `-c http.<url>.extraHeader` pairs for every stored credential, ready to
64
- * prepend to a git network command. A broken credential FILE must never break
65
- * git itself: an unreadable store degrades to "no credentials" (the network
66
- * command then runs unauthenticated, as it always did) — the credentials
67
- * route is where the file problem surfaces.
70
+ * Credential argv for one network command. Empty by design since 0037: the
71
+ * token reaches git through the URL-scoped `helper = store` include that
72
+ * `~/.gitconfig` carries, so there is nothing to inject. The seam stays so
73
+ * every call site keeps one place to change if a command ever needs an
74
+ * explicit override again.
68
75
  */
69
- async function authArgsFor(deps) {
70
- if (deps.credentialsService !== undefined)
71
- return deps.credentialsService.authConfigArgs();
72
- try {
73
- return authConfigArgs(await credentialStore(deps).load());
74
- }
75
- catch {
76
- return [];
77
- }
76
+ async function authArgsFor(_deps) {
77
+ return [];
78
78
  }
79
79
  /**
80
80
  * Re-derive the shell credential bridge after a credential changed, so a
@@ -320,6 +320,10 @@ export async function handlePurgeDirectory(deps, body) {
320
320
  }
321
321
  /** GitError to outcome: usage-shaped failures are 400, the rest 500. */
322
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 });
323
327
  const usage = error.stderr.includes('usage:')
324
328
  || error.stderr.includes('fatal: invalid')
325
329
  // A user-typed branch name git refuses (`'bad..name' is not a valid
@@ -354,7 +358,7 @@ function gitFailure(error) {
354
358
  // (a stale sidebar row racing a terminal-side removal).
355
359
  || error.stderr.includes('cannot remove the main worktree')
356
360
  || error.stderr.includes('is not a registered worktree');
357
- return fail(usage ? 400 : 500, error.message);
361
+ return fail(usage ? 400 : 500, withHint(error.message, failure));
358
362
  }
359
363
  /**
360
364
  * GET /status 鈥?repository facts for one directory.
@@ -469,7 +473,10 @@ export async function handleCreateWorktree(deps, body) {
469
473
  const resolved = await resolveBranch(deps.exec, facts.repoRoot, branch);
470
474
  if (resolved?.remote !== undefined) {
471
475
  try {
472
- 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
+ }
473
480
  }
474
481
  catch (error) {
475
482
  fetchWarning = error instanceof GitError ? error.stderr.trim() : error instanceof Error ? error.message : String(error);
@@ -704,7 +711,10 @@ export async function handleFetch(deps, body) {
704
711
  const facts = await probeRepo(deps.exec, repoPath, deps.dirExists);
705
712
  if (facts === undefined)
706
713
  return fail(400, `"${repoPath}" is not inside a git repository`);
707
- 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
+ }
708
718
  const result = { remote: 'all' };
709
719
  return { status: 200, body: result };
710
720
  }
@@ -767,8 +777,9 @@ export async function handlePush(deps, body) {
767
777
  return fail(400, `"${repoPath}" is not inside a git repository`);
768
778
  // The session's OWN worktree, not the main checkout: in a linked-worktree
769
779
  // session "push current branch" means the branch that worktree holds.
770
- const outcome = await pushBranch(deps.exec, facts.toplevel, await authArgsFor(deps));
771
- 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 }) };
772
783
  return { status: 200, body: result };
773
784
  }
774
785
  catch (error) {
@@ -1199,8 +1210,9 @@ export async function handlePushTag(deps, body) {
1199
1210
  const choice = await resolvePushRemote(deps.exec, facts.toplevel);
1200
1211
  if (!choice.ok)
1201
1212
  return fail(400, choice.message);
1202
- const result = await pushTag(deps.exec, facts.toplevel, name, choice.remote, await authArgsFor(deps));
1203
- 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 }) };
1204
1216
  return { status: 200, body };
1205
1217
  }
1206
1218
  catch (error) {
@@ -1419,6 +1431,56 @@ export async function handleCredentialsDelete(deps, body) {
1419
1431
  return storeFailure(error);
1420
1432
  }
1421
1433
  }
1434
+ /**
1435
+ * POST /credentials-import — copy what the machine's own credential helpers
1436
+ * already answer for into the managed store, so a machine signed in through
1437
+ * gh/GCM/Keychain stops needing a second sign-in here (decision 0038).
1438
+ *
1439
+ * The candidates are the URL-scoped hosts of the user's gitconfig; a host the
1440
+ * panel already manages is skipped (the panel wins), and a host whose helper
1441
+ * answers nothing is reported per host instead of failing the whole request.
1442
+ * @param deps - host dependencies.
1443
+ * @param body - parsed request body: absent, `{}` or `{ hosts?: string[] }`.
1444
+ */
1445
+ export async function handleCredentialsImport(deps, body) {
1446
+ const record = body ?? {};
1447
+ if (typeof record !== 'object' || record === null || Array.isArray(record)) {
1448
+ return fail(400, 'request body must be a JSON object');
1449
+ }
1450
+ const fields = record;
1451
+ for (const key of Object.keys(fields)) {
1452
+ if (key !== 'hosts')
1453
+ return fail(400, `unknown body key "${key}"`);
1454
+ }
1455
+ const rawHosts = fields.hosts;
1456
+ let hosts;
1457
+ if (rawHosts !== undefined) {
1458
+ if (!Array.isArray(rawHosts) || rawHosts.some(value => typeof value !== 'string')) {
1459
+ return fail(400, '"hosts" must be an array of remote hosts');
1460
+ }
1461
+ hosts = rawHosts.map(value => value.trim()).filter(value => value !== '');
1462
+ if (hosts.length === 0)
1463
+ return fail(400, '"hosts" must not be empty');
1464
+ }
1465
+ try {
1466
+ const store = credentialStore(deps);
1467
+ const importNow = deps.importLocalCredentials ?? ((target, wanted) => runLocalImport(target, {}, wanted));
1468
+ const report = await importNow(store, hosts);
1469
+ if (report.imported.length > 0)
1470
+ await refreshBridge(deps);
1471
+ const entries = await store.load();
1472
+ const result = {
1473
+ credentials: sortCredentials(entries).map(credentialView),
1474
+ imported: report.imported,
1475
+ skipped: report.skipped,
1476
+ failed: report.failed,
1477
+ };
1478
+ return { status: 200, body: result };
1479
+ }
1480
+ catch (error) {
1481
+ return storeFailure(error);
1482
+ }
1483
+ }
1422
1484
  /** The HTTP seam: the wired fake in tests, global fetch in production. */
1423
1485
  function fetchFor(deps) {
1424
1486
  return deps.fetchImpl ?? globalThis.fetch;
@@ -1628,3 +1690,73 @@ export async function handleRemote(deps, body) {
1628
1690
  return fail(500, error instanceof Error ? error.message : String(error));
1629
1691
  }
1630
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
+ }
@@ -1,143 +1,85 @@
1
1
  /**
2
- * Shell credential bridge (host half) — session gitconfig channel. The
3
- * credential panel stores one token per remote host, but 0029 could only
4
- * inject it into the panel's own routes and the `git_network` tool; a plain
5
- * `git push` run in a shell authenticated as whatever the machine's
6
- * credential helper held — often a different account — or hung on an
7
- * interactive prompt nobody could answer.
2
+ * Shell credential setup (host half) — decision 0037.
8
3
  *
9
- * 0032 closed that gap with `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_n`/
10
- * `GIT_CONFIG_VALUE_n` environment entries. That channel is dead on a DSH
11
- * host: the subprocess environment scrubber
12
- * (`@deepseek-ai/dsh-subprocess`, `SENSITIVE_ENV_PATTERN = /KEY|PASSWORD|SECRET|TOKEN/i`
13
- * plus every `DSH_*` name) strips the `GIT_CONFIG_KEY_*` names and keeps
14
- * `GIT_CONFIG_COUNT`/`VALUE_*`, so git starts with a half-parsed config and
15
- * dies with `fatal: unable to parse command-line config` — every git command
16
- * in the shell, not only the networked ones (0034).
4
+ * Before 0037 the panel's token only reached commands the plugin itself ran;
5
+ * a plain `git push` in a shell authenticated as whatever the machine's
6
+ * helper held (often a different account) or hung on a prompt nobody could
7
+ * answer. 0032/0034 tried to bridge that through the process environment
8
+ * (`GIT_CONFIG_COUNT`, then a generated `GIT_CONFIG_GLOBAL` session file) —
9
+ * both channels existed only in the host process's own environment, so a
10
+ * shell that received a different environment snapshot saw nothing.
17
11
  *
18
- * The replacement writes ONE generated gitconfig per host process and points
19
- * git at it with `GIT_CONFIG_GLOBAL` — a name the scrubber keeps. The file:
12
+ * 0037 stops fighting the environment and uses git's own mechanisms:
20
13
  *
21
- * 1. `[include]`s the user's own `~/.gitconfig` (read-only; 0021's "never
22
- * write the user's git config files" stands) so identity, proxies and
23
- * `http.sslBackend` keep working;
24
- * 2. resets the whole `credential.helper` list to empty, cutting the machine
25
- * chain (`helper-selector` → GCM) and any helper the included user config
26
- * carries (the local `gh` helper being the motivating case);
27
- * 3. per stored host, resets that URL scope and then installs the bundled
28
- * helper (`helpers/git-credential-bridge.js`) as a `!`-shell command.
14
+ * 1. `<dsh-home>/git-credentials.include.gitconfig` is generated with one
15
+ * URL-scoped section per managed host;
16
+ * 2. `~/.gitconfig` gains exactly one `[include] path = <that file>` line
17
+ * (idempotent; see `ensureGitconfigInclude` in `credentials.ts`);
18
+ * 3. every git process — the plugin's own routes, `git_network`, and a
19
+ * plain shell — reads that include and therefore resolves the host
20
+ * through `credential-store`, i.e. `~/.git-credentials`.
29
21
  *
30
- * A host with no stored credential then fails at once ("terminal prompts
31
- * disabled") instead of reaching the machine chain, whose interactive GUI
32
- * dialog nobody in an agent shell can answer. `GIT_TERMINAL_PROMPT=0` and
33
- * `GCM_INTERACTIVE=never` ride along as the last line of defence.
22
+ * Nothing rides the process environment any more, which is why the module is
23
+ * now only a gitconfig-install + fail-fast switch manager. `GIT_TERMINAL_PROMPT=0`
24
+ * and `GCM_INTERACTIVE=never` stay: a host without a stored credential should
25
+ * fail at once instead of parking an agent shell on a GUI dialog.
34
26
  *
35
- * Environment contract (all names survive the scrubber — no KEY/PASSWORD/
36
- * SECRET/TOKEN substring, no `DSH_` prefix):
37
- * - `GIT_CONFIG_GLOBAL` — the generated session config (0034).
38
- * - `DGB_BRIDGE_HELPER` — credential file path for the helper script.
39
- * - `GIT_TERMINAL_PROMPT` / `GCM_INTERACTIVE` — fail fast, never prompt.
40
- *
41
- * The file is re-derived whenever the credential store changes
42
- * (`src/routes.ts` calls the bridge after every save / delete / sign-in).
27
+ * `apply`/`dispose` still delete the retired 0032 channel's leftovers
28
+ * (`GIT_CONFIG_COUNT` with a broken `GIT_CONFIG_KEY_n` / `GIT_CONFIG_VALUE_n`
29
+ * set). The DSH subprocess scrubber strips the KEY names while the count and
30
+ * values survive, so a half-injected channel — left behind by an upgrade from
31
+ * 1.0.10 — makes git abort every command; see `legacyConfigNames` (0036).
43
32
  */
44
33
  import { type CredentialStore } from './credentials.js';
45
- /** Env var carrying the credential file path to the helper script. */
46
- export declare const BRIDGE_HELPER_ENV = "DGB_BRIDGE_HELPER";
47
- /** Env var carrying a debug log path to the helper script (unset by default). */
48
- export declare const BRIDGE_DEBUG_ENV = "DGB_BRIDGE_DEBUG";
49
- /** Env var pointing git at the generated session configuration file. */
50
- export declare const SESSION_GITCONFIG_ENV = "GIT_CONFIG_GLOBAL";
51
- /** Host-side seams the bridge reads through (tests substitute all of them). */
34
+ /** Host-side seams the setup reads through (tests substitute all of them). */
52
35
  export interface ShellBridgeDeps {
53
36
  /** User home directory seam. */
54
37
  readonly home: () => string;
55
38
  /** `$DSH_HOME` seam (blank/undefined = unset). */
56
39
  readonly envHome: () => string | undefined;
57
- /** Store seam; the fs-backed store at the resolved path when absent. */
40
+ /** Store seam; the fs-backed local store when absent. */
58
41
  readonly store?: CredentialStore;
59
42
  /** The environment object to mutate; `process.env` when absent. */
60
43
  readonly env?: Record<string, string | undefined>;
61
- /** Absolute helper script path; the compiled sibling under `helpers/` when
62
- * absent. */
63
- readonly helperPath?: string;
64
- /** Node executable that runs the helper; `process.execPath` when absent. */
65
- readonly nodePath?: string;
66
- /** Directory for the session config; the OS temp dir when absent. */
67
- readonly configDir?: () => string;
68
- /** Exact session config path (tests); `<configDir>/dsh-git-branch-<pid>.gitconfig` when absent. */
69
- readonly configPath?: string;
70
- /** User global config the session file includes; `<home>/.gitconfig` when absent. */
44
+ /** User global config the generated file is included from; `<home>/.gitconfig` when absent. */
71
45
  readonly userGitconfig?: string;
72
46
  }
73
- /** The bridge's operations: re-derive the environment, and undo it. */
47
+ /** The setup's operations: re-derive the include, and undo it. */
74
48
  export interface ShellBridge {
75
- /** Read the credential store and rewrite the injected environment.
76
- * A store that cannot be read drops every URL-scoped helper (no host can be
77
- * bridged), but keeps the global reset, so shell git still fails fast
78
- * instead of opening an interactive credential dialog. */
49
+ /** The generated include file (diagnostics/tests). */
50
+ readonly configPath: () => string;
51
+ /** Read the credential store and rewrite the include + `~/.gitconfig`.
52
+ * A store that cannot be read writes an empty include (no host is
53
+ * scoped), while the fail-fast switches still go out. */
79
54
  readonly apply: () => Promise<void>;
80
- /** Remove the injected environment and delete the session config file. */
55
+ /** Remove the fail-fast switches, the include line and the generated file. */
81
56
  readonly dispose: () => Promise<void>;
82
- /** The session config path this bridge writes. */
83
- readonly configPath: () => string;
84
57
  }
85
- /** One ordered environment assignment. */
86
- export type BridgeAssignment = readonly [key: string, value: string];
87
- /**
88
- * Quote one executable or path for git's `!` shell command. Backslashes are
89
- * normalized to forward slashes first: git runs `!` helpers through `sh`,
90
- * where `C:\\…` would be read as escape sequences.
91
- * @param value - an absolute path.
92
- */
93
- export declare function shellQuote(value: string): string;
94
- /**
95
- * The helper command git runs for one configured host: a `!`-prefixed shell
96
- * command, because git does not word-split a helper value by itself (a bare
97
- * `node "C:/…/bridge.js"` is looked up as the program `git-credential-node`).
98
- * @param nodePath - the node executable.
99
- * @param helperPath - the compiled helper script.
100
- */
101
- export declare function bridgeCommand(nodePath: string, helperPath: string): string;
58
+ /** The retired 0032 channel's count variable. */
59
+ export declare const LEGACY_COUNT_ENV = "GIT_CONFIG_COUNT";
102
60
  /**
103
- * Escape one value for a gitconfig line: a literal `"` becomes `\"`, which is
104
- * how a quoted path survives git's config parser and still reaches `sh`
105
- * intact. Without this the quotes are eaten and `sh` splits
106
- * `C:/Program Files/…` on the space.
107
- * @param value - the raw helper command.
108
- */
109
- export declare function gitconfigEscape(value: string): string;
110
- /**
111
- * The session gitconfig body for the given hosts: the user's own config
112
- * included (read-only), the whole machine helper list reset to empty, and per
113
- * host a URL-scoped reset followed by the bridge command. The empty value
114
- * before each command is what cuts the included `gh` helper (and any other
115
- * helper the user configures for that URL) out of that scope.
116
- * @param hosts - normalized hosts with a stored credential.
117
- * @param options - the node/helper pair and the user config to include.
118
- */
119
- export declare function sessionGitconfigBody(hosts: readonly string[], options: {
120
- readonly nodePath: string;
121
- readonly helperPath: string;
122
- readonly userGitconfig?: string;
123
- }): string;
124
- /**
125
- * The environment assignments pointing git at the session config: the config
126
- * path itself, the credential file the helper reads, and the two fail-fast
127
- * switches. `hosts` live in the file now, not in the environment.
128
- * @param options - the session config path and the credential file.
61
+ * Environment names left over from the retired 0032 channel that would make
62
+ * git abort with `fatal: unable to parse command-line config`.
63
+ *
64
+ * The DSH subprocess scrubber deletes every `GIT_CONFIG_KEY_<n>` (the name
65
+ * contains `KEY`) while `GIT_CONFIG_COUNT` / `GIT_CONFIG_VALUE_<n>` survive, so
66
+ * the channel is injected half-way and git can no longer parse it. Those
67
+ * leftovers outlive the code that wrote them: upgrading the plugin from 1.0.10
68
+ * leaves them in the host process environment, where they break every `git`
69
+ * command in every derived shell until someone deletes them by hand.
70
+ *
71
+ * A count whose pairs are all present is valid git input (a caller may set the
72
+ * channel deliberately), so it is left untouched; only a broken channel — or a
73
+ * non-numeric count — is reported for removal.
74
+ * @param env - the environment to inspect.
129
75
  */
130
- export declare function shellBridgeEnv(options: {
131
- readonly configPath: string;
132
- readonly credentialFile: string;
133
- }): readonly BridgeAssignment[];
76
+ export declare function legacyConfigNames(env: Record<string, string | undefined>): string[];
134
77
  /**
135
- * Build the bridge over one host process. Every {@link ShellBridge.apply}
136
- * rewrites the session config for the current store and re-points the
137
- * environment at it, so a deleted host loses its helper scope just like a
138
- * freshly added one gains it. A config file that cannot be written leaves
139
- * `GIT_CONFIG_GLOBAL` untouched (git then runs with the machine config —
140
- * unauthenticated at worst) while the fail-fast switches still go out.
78
+ * Build the setup over one host process. Every {@link ShellBridge.apply}
79
+ * rewrites the include for the current store, so a deleted host loses its
80
+ * helper scope just like a freshly added one gains it. A failing write never
81
+ * throws: the worst case is a stale include until the next credential save,
82
+ * and git keeps working with the machine's own configuration.
141
83
  * @param deps - host seams.
142
84
  */
143
85
  export declare function createShellBridge(deps: ShellBridgeDeps): ShellBridge;