dsh-git-branch-manage 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.0",
4
4
  "description": "提供 git 分支与 worktree 管理:输入框工具栏显示当前分支、可原地切换;支持 IDEA 风格分支选择器、远程分支检出、右键操作菜单(新建/重命名/删除/复制)、推送与更新当前分支,以及把分支隔离到独立 worktree 工作区,并管理其创建与删除。",
5
5
  "keywords": [
6
6
  "dsh",