dsh-git-branch-manage 1.1.0 → 1.2.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.
@@ -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;
@@ -1,217 +1,143 @@
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).
43
- */
44
- import { chmodSync, mkdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
45
- import { tmpdir } from 'node:os';
46
- import { dirname, join } from 'node:path';
47
- import { fileURLToPath } from 'node:url';
48
- import { credentialFilePath, createCredentialStore, } from './credentials.js';
49
- /** Env var carrying the credential file path to the helper script. */
50
- export const BRIDGE_HELPER_ENV = 'DGB_BRIDGE_HELPER';
51
- /** Env var carrying a debug log path to the helper script (unset by default). */
52
- export const BRIDGE_DEBUG_ENV = 'DGB_BRIDGE_DEBUG';
53
- /** Env var pointing git at the generated session configuration file. */
54
- export const SESSION_GITCONFIG_ENV = 'GIT_CONFIG_GLOBAL';
55
- /**
56
- * Quote one executable or path for git's `!` shell command. Backslashes are
57
- * normalized to forward slashes first: git runs `!` helpers through `sh`,
58
- * where `C:\\…` would be read as escape sequences.
59
- * @param value - an absolute path.
60
- */
61
- export function shellQuote(value) {
62
- return `"${value.replace(/\\/g, '/')}"`;
63
- }
64
- /**
65
- * The helper command git runs for one configured host: a `!`-prefixed shell
66
- * command, because git does not word-split a helper value by itself (a bare
67
- * `node "C:/…/bridge.js"` is looked up as the program `git-credential-node`).
68
- * @param nodePath - the node executable.
69
- * @param helperPath - the compiled helper script.
70
- */
71
- export function bridgeCommand(nodePath, helperPath) {
72
- return `!${shellQuote(nodePath)} ${shellQuote(helperPath)}`;
73
- }
74
- /**
75
- * Escape one value for a gitconfig line: a literal `"` becomes `\"`, which is
76
- * how a quoted path survives git's config parser and still reaches `sh`
77
- * intact. Without this the quotes are eaten and `sh` splits
78
- * `C:/Program Files/…` on the space.
79
- * @param value - the raw helper command.
80
- */
81
- export function gitconfigEscape(value) {
82
- return value.replace(/"/g, '\\"');
83
- }
84
- /**
85
- * The session gitconfig body for the given hosts: the user's own config
86
- * included (read-only), the whole machine helper list reset to empty, and per
87
- * host a URL-scoped reset followed by the bridge command. The empty value
88
- * before each command is what cuts the included `gh` helper (and any other
89
- * helper the user configures for that URL) out of that scope.
90
- * @param hosts - normalized hosts with a stored credential.
91
- * @param options - the node/helper pair and the user config to include.
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).
92
32
  */
93
- export function sessionGitconfigBody(hosts, options) {
94
- const unique = [...new Set(hosts)];
95
- const lines = [
96
- '# Generated by dsh-git-branch-manage (shell credential bridge, decision 0034). Do not edit.',
97
- '# Regenerated whenever the credential store changes; safe to delete.',
98
- ];
99
- if (options.userGitconfig !== undefined) {
100
- // Read-only include: the user\'s identity, proxies and TLS settings keep
101
- // working, and nothing is ever written back to this file.
102
- lines.push('[include]', `\tpath = ${options.userGitconfig.replace(/\\/g, '/')}`);
103
- }
104
- // Reset the WHOLE helper list first, even when no host is stored. Without
105
- // it, any host without a stored credential still reaches the machine chain
106
- // (`helper-selector` → GCM), which raises a GUI prompt no agent shell can
107
- // answer. With it, such a host fails at once with "terminal prompts
108
- // disabled", and the URL-scoped pairs below are the only way credentials
109
- // get back into a shell.
110
- lines.push('[credential]', '\thelper =');
111
- const command = gitconfigEscape(bridgeCommand(options.nodePath, options.helperPath));
112
- for (const host of unique) {
113
- lines.push(`[credential "https://${host}/"]`,
114
- // Empty first: it resets whatever helper list the included user config
115
- // accumulated for this URL (the local `gh` helper), or git would answer
116
- // with that account before the bridge is ever consulted.
117
- '\thelper =', `\thelper = ${command}`);
118
- }
119
- return `${lines.join('\n')}\n`;
120
- }
33
+ import { rm } from 'node:fs/promises';
34
+ import { join } from 'node:path';
35
+ import { credentialIncludeBody, credentialIncludeFile, createLocalCredentialStore, ensureGitconfigInclude, removeGitconfigInclude, writeFileAtomic, } from './credentials.js';
36
+ /** The retired 0032 channel's count variable. */
37
+ export const LEGACY_COUNT_ENV = 'GIT_CONFIG_COUNT';
38
+ /** Names of the retired 0032 channel's per-entry pairs. */
39
+ const LEGACY_KEY_NAME = /^GIT_CONFIG_KEY_\d+$/;
40
+ const LEGACY_VALUE_NAME = /^GIT_CONFIG_VALUE_\d+$/;
41
+ /** Fail fast instead of parking a shell on a prompt or a GUI dialog. */
42
+ const FAIL_FAST = [
43
+ ['GIT_TERMINAL_PROMPT', '0'],
44
+ ['GCM_INTERACTIVE', 'never'],
45
+ ];
121
46
  /**
122
- * The environment assignments pointing git at the session config: the config
123
- * path itself, the credential file the helper reads, and the two fail-fast
124
- * switches. `hosts` live in the file now, not in the environment.
125
- * @param options - the session config path and the credential file.
47
+ * Environment names left over from the retired 0032 channel that would make
48
+ * git abort with `fatal: unable to parse command-line config`.
49
+ *
50
+ * The DSH subprocess scrubber deletes every `GIT_CONFIG_KEY_<n>` (the name
51
+ * contains `KEY`) while `GIT_CONFIG_COUNT` / `GIT_CONFIG_VALUE_<n>` survive, so
52
+ * the channel is injected half-way and git can no longer parse it. Those
53
+ * leftovers outlive the code that wrote them: upgrading the plugin from 1.0.10
54
+ * leaves them in the host process environment, where they break every `git`
55
+ * command in every derived shell until someone deletes them by hand.
56
+ *
57
+ * A count whose pairs are all present is valid git input (a caller may set the
58
+ * channel deliberately), so it is left untouched; only a broken channel — or a
59
+ * non-numeric count — is reported for removal.
60
+ * @param env - the environment to inspect.
126
61
  */
127
- export function shellBridgeEnv(options) {
128
- return [
129
- [SESSION_GITCONFIG_ENV, options.configPath],
130
- [BRIDGE_HELPER_ENV, options.credentialFile],
131
- // Fail fast instead of parking a shell on a prompt or a GUI dialog.
132
- ['GIT_TERMINAL_PROMPT', '0'],
133
- ['GCM_INTERACTIVE', 'never'],
134
- ];
62
+ export function legacyConfigNames(env) {
63
+ if (env[LEGACY_COUNT_ENV] === undefined)
64
+ return [];
65
+ const count = Number(env[LEGACY_COUNT_ENV]);
66
+ const paired = Number.isInteger(count) && count >= 0
67
+ && Array.from({ length: count }, (_, index) => `GIT_CONFIG_KEY_${index}`)
68
+ .every(name => env[name] !== undefined);
69
+ if (paired)
70
+ return [];
71
+ return Object.keys(env).filter(name => name === LEGACY_COUNT_ENV || LEGACY_KEY_NAME.test(name) || LEGACY_VALUE_NAME.test(name));
135
72
  }
136
73
  /**
137
- * Build the bridge over one host process. Every {@link ShellBridge.apply}
138
- * rewrites the session config for the current store and re-points the
139
- * environment at it, so a deleted host loses its helper scope just like a
140
- * freshly added one gains it. A config file that cannot be written leaves
141
- * `GIT_CONFIG_GLOBAL` untouched (git then runs with the machine config —
142
- * unauthenticated at worst) while the fail-fast switches still go out.
74
+ * Build the setup over one host process. Every {@link ShellBridge.apply}
75
+ * rewrites the include for the current store, so a deleted host loses its
76
+ * helper scope just like a freshly added one gains it. A failing write never
77
+ * throws: the worst case is a stale include until the next credential save,
78
+ * and git keeps working with the machine's own configuration.
143
79
  * @param deps - host seams.
144
80
  */
145
81
  export function createShellBridge(deps) {
146
82
  const env = deps.env ?? process.env;
147
- const configPath = deps.configPath
148
- ?? join((deps.configDir ?? tmpdir)(), `dsh-git-branch-${String(process.pid)}.gitconfig`);
83
+ const includePath = credentialIncludeFile(deps.home(), deps.envHome());
84
+ const gitconfig = () => deps.userGitconfig ?? join(deps.home(), '.gitconfig');
85
+ const store = () => deps.store ?? createLocalCredentialStore(deps.home(), deps.envHome());
149
86
  let applied = [];
150
- const credentialFile = () => credentialFilePath(deps.home(), deps.envHome());
151
- /** Write the session config atomically with owner-only permissions. */
152
- const writeConfig = (body) => {
153
- mkdirSync(dirname(configPath), { recursive: true });
154
- const partial = `${configPath}.tmp`;
155
- writeFileSync(partial, body, { encoding: 'utf8', mode: 0o600 });
156
- renameSync(partial, configPath);
157
- try {
158
- chmodSync(configPath, 0o600);
159
- }
160
- catch {
161
- // Best effort: Windows ignores the mode, the file holds no secret.
87
+ /** Delete the retired 0032 channel's leftovers before git can see them. */
88
+ const dropLegacy = () => {
89
+ for (const key of legacyConfigNames(env)) {
90
+ delete env[key];
91
+ applied.push(key);
162
92
  }
163
93
  };
164
94
  return {
165
- configPath: () => configPath,
95
+ configPath: () => includePath,
166
96
  async apply() {
167
97
  let hosts = [];
168
98
  try {
169
- hosts = (await (deps.store ?? createCredentialStore(credentialFile())).load()).map(entry => entry.host);
99
+ hosts = (await store().load()).map(entry => entry.host);
170
100
  }
171
101
  catch {
172
- // An unreadable store means no host can be bridged. The global helper
173
- // reset still goes out, so shell git fails fast rather than popping a
174
- // credential dialog nobody can answer.
102
+ // An unreadable store means no host can be scoped. The include file
103
+ // is still rewritten empty, so a stale host does not keep a helper
104
+ // pointing at a file that no longer holds its credential.
175
105
  hosts = [];
176
106
  }
177
- const helperPath = deps.helperPath
178
- ?? fileURLToPath(new URL('./helpers/git-credential-bridge.js', import.meta.url));
179
- const body = sessionGitconfigBody(hosts, {
180
- nodePath: deps.nodePath ?? process.execPath,
181
- helperPath,
182
- userGitconfig: deps.userGitconfig ?? join(deps.home(), '.gitconfig'),
183
- });
184
- let assignments = [
185
- ['GIT_TERMINAL_PROMPT', '0'],
186
- ['GCM_INTERACTIVE', 'never'],
187
- ];
188
107
  try {
189
- writeConfig(body);
190
- assignments = shellBridgeEnv({ configPath, credentialFile: credentialFile() });
108
+ // Write the include first: if `~/.gitconfig` cannot be updated, the
109
+ // file it already points at is at least current.
110
+ await writeFileAtomic(includePath, credentialIncludeBody(hosts), 0o600);
111
+ await ensureGitconfigInclude(gitconfig(), includePath);
191
112
  }
192
113
  catch {
193
- // The session config could not be written. Do not point
194
- // GIT_CONFIG_GLOBAL at a missing file (that would drop the reset and
195
- // let the machine chain pop a dialog); only the fail-fast switches
196
- // remain, so a shell git runs unauthenticated but never hangs.
114
+ // Deliberately swallowed; see the contract above.
197
115
  }
116
+ dropLegacy();
198
117
  for (const key of applied)
199
118
  delete env[key];
200
119
  applied = [];
201
- for (const [key, value] of assignments) {
120
+ for (const [key, value] of FAIL_FAST) {
202
121
  env[key] = value;
203
122
  applied.push(key);
204
123
  }
205
124
  },
206
125
  async dispose() {
126
+ dropLegacy();
207
127
  for (const key of applied)
208
128
  delete env[key];
209
129
  applied = [];
210
130
  try {
211
- rmSync(configPath, { force: true });
131
+ await removeGitconfigInclude(gitconfig(), includePath);
132
+ }
133
+ catch {
134
+ // Best effort: a leftover include still points at the file below.
135
+ }
136
+ try {
137
+ await rm(includePath, { force: true });
212
138
  }
213
139
  catch {
214
- // Best effort: a leftover file holds no secret and apply rewrites it.
140
+ // Best effort: the file holds no secret and `apply` rewrites it.
215
141
  }
216
142
  },
217
143
  };
package/lib/tool.js CHANGED
@@ -14,7 +14,7 @@
14
14
  *
15
15
  * @module dsh-git-branch/tool
16
16
  */
17
- import { basicUsername, createCredentialStore, credentialFilePath, maskSecret, } from './credentials.js';
17
+ import { basicUsername, createLocalCredentialStore, maskSecret, } from './credentials.js';
18
18
  /** Model-facing tool name. */
19
19
  export const GIT_CREDENTIALS_TOOL_NAME = 'git_credentials';
20
20
  /** Project one stored entry onto its model-facing row. */
@@ -48,7 +48,7 @@ export function renderGitCredentials(value) {
48
48
  * @returns a definition ready for `ctx.tools.register`.
49
49
  */
50
50
  export function gitCredentialsTool(deps) {
51
- const storeOf = () => deps.credentialsService?.store ?? deps.store ?? createCredentialStore(credentialFilePath(deps.home(), deps.envHome()));
51
+ const storeOf = () => deps.credentialsService?.store ?? deps.store ?? createLocalCredentialStore(deps.home(), deps.envHome());
52
52
  /** Read the store, folding a read failure into the result value. */
53
53
  const read = async (args) => {
54
54
  const store = storeOf();
package/lib/wire.d.ts CHANGED
@@ -70,6 +70,9 @@ export declare const ROUTE_CREDENTIALS_OAUTH_START = "/plugin/git-worktree/crede
70
70
  /** POST ROUTE_PREFIX/credentials-oauth-poll — poll a started browser sign-in;
71
71
  * stores the token and answers the fresh masked list once the user approves. */
72
72
  export declare const ROUTE_CREDENTIALS_OAUTH_POLL = "/plugin/git-worktree/credentials-oauth-poll";
73
+ /** POST ROUTE_PREFIX/credentials-import — copy the credentials the machine's
74
+ * own helpers already answer for into the managed store (decision 0038). */
75
+ export declare const ROUTE_CREDENTIALS_IMPORT = "/plugin/git-worktree/credentials-import";
73
76
  /** POST ROUTE_PREFIX/remotes — the repository's configured remotes. */
74
77
  export declare const ROUTE_REMOTES = "/plugin/git-worktree/remotes";
75
78
  /** POST ROUTE_PREFIX/remote — add, re-point or remove ONE remote. */
@@ -626,6 +629,32 @@ export interface CredentialDeleteBody {
626
629
  }
627
630
  /** POST credentials-delete response body: the updated masked list. */
628
631
  export type CredentialDeleteResult = CredentialsResult;
632
+ /** POST credentials-import request body — absent hosts means "whatever the
633
+ * machine's gitconfig points git at". */
634
+ export interface CredentialImportBody {
635
+ /** Explicit hosts to probe; omitted, the gitconfig's URL-scoped hosts are
636
+ * the candidates. */
637
+ hosts?: string[];
638
+ }
639
+ /** One host the import copied into the store (secret never included). */
640
+ export interface CredentialImportEntry {
641
+ host: string;
642
+ /** `oauth2` for a token credential, the helper's user name otherwise. */
643
+ username: string;
644
+ method: CredentialMethod;
645
+ }
646
+ /** One host whose machine helper had nothing to give; the store is untouched. */
647
+ export interface CredentialImportFailure {
648
+ host: string;
649
+ error: string;
650
+ }
651
+ /** POST credentials-import response body: the fresh masked list plus what the
652
+ * import did per host. */
653
+ export interface CredentialImportResult extends CredentialsResult {
654
+ imported: CredentialImportEntry[];
655
+ skipped: string[];
656
+ failed: CredentialImportFailure[];
657
+ }
629
658
  /** POST credentials-oauth-start request body. */
630
659
  export interface CredentialOAuthStartBody {
631
660
  /** Remote host whose browser sign-in to start (must support device flow). */
package/lib/wire.js CHANGED
@@ -70,6 +70,9 @@ export const ROUTE_CREDENTIALS_OAUTH_START = `${ROUTE_PREFIX}/credentials-oauth-
70
70
  /** POST ROUTE_PREFIX/credentials-oauth-poll — poll a started browser sign-in;
71
71
  * stores the token and answers the fresh masked list once the user approves. */
72
72
  export const ROUTE_CREDENTIALS_OAUTH_POLL = `${ROUTE_PREFIX}/credentials-oauth-poll`;
73
+ /** POST ROUTE_PREFIX/credentials-import — copy the credentials the machine's
74
+ * own helpers already answer for into the managed store (decision 0038). */
75
+ export const ROUTE_CREDENTIALS_IMPORT = `${ROUTE_PREFIX}/credentials-import`;
73
76
  /** POST ROUTE_PREFIX/remotes — the repository's configured remotes. */
74
77
  export const ROUTE_REMOTES = `${ROUTE_PREFIX}/remotes`;
75
78
  /** POST ROUTE_PREFIX/remote — add, re-point or remove ONE remote. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-git-branch-manage",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "提供 git 分支与 worktree 管理:输入框工具栏显示当前分支、可原地切换;支持 IDEA 风格分支选择器、远程分支检出、右键操作菜单(新建/重命名/删除/复制)、推送与更新当前分支,以及把分支隔离到独立 worktree 工作区,并管理其创建与删除。",
5
5
  "keywords": [
6
6
  "dsh",