dsh-git-branch-manage 1.0.6 → 1.0.8

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.
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Global git credential store (host half). Credentials belong to a remote
3
+ * HOST, not to a project: the same `github.com` token serves every repository
4
+ * the panel touches, so the record lives in one file under `$DSH_HOME`
5
+ * (`~/.dsh`) instead of the per-repository settings document. The secret
6
+ * never travels to the browser — the list route answers masked tails only —
7
+ * and the store is read/written by the host alone.
8
+ *
9
+ * Authentication rides `git -c http.https://<host>/.extraHeader=…` on the
10
+ * panel's own network commands: the user's global git config and credential
11
+ * helpers are left untouched. Only `https://` remotes receive the header;
12
+ * ssh and plain-http remotes are never sent a secret.
13
+ */
14
+ import type { CredentialMethod } from './wire.js';
15
+ /**
16
+ * The Basic-auth name git sends when a token was stored without a username.
17
+ * GitHub, GitLab and Gitee all accept any non-empty name for a personal access
18
+ * token, and `oauth2` is the conventional placeholder.
19
+ */
20
+ export declare const TOKEN_USERNAME = "oauth2";
21
+ /** One stored credential: exactly one per remote host. */
22
+ export interface GitCredential {
23
+ /** Normalized remote host (`github.com`, `git.example.com:8443`). */
24
+ host: string;
25
+ /** Basic-auth username (a real account name, or a vendor-form placeholder
26
+ * such as `x-access-token` / `oauth2`). */
27
+ username: string;
28
+ /** Token or password, verbatim (never returned to the client). */
29
+ secret: string;
30
+ /** How the secret is meant to be used — informational for the UI; git
31
+ * itself only sees the Basic header. */
32
+ method: CredentialMethod;
33
+ /** Epoch millis of the last save. */
34
+ updatedAt: number;
35
+ }
36
+ /** Load/save seam for the credential file (tests substitute an in-memory one). */
37
+ export interface CredentialStore {
38
+ /** Absolute file the store persists to (for diagnostics/UI hints). */
39
+ readonly path: string;
40
+ /** Every stored credential, one per host, sorted by host. A missing file
41
+ * is the empty list; an unreadable/malformed one throws
42
+ * {@link CredentialStoreError}. */
43
+ load: () => Promise<GitCredential[]>;
44
+ /** Replace the whole set atomically (temp file + rename). */
45
+ save: (entries: readonly GitCredential[]) => Promise<void>;
46
+ }
47
+ /** A credential file the store could not read or understand. */
48
+ export declare class CredentialStoreError extends Error {
49
+ readonly path: string;
50
+ constructor(path: string, message: string);
51
+ }
52
+ /**
53
+ * Resolve the credential file location: `<dsh-home>/git-credentials.json`.
54
+ * @param home - user home directory seam.
55
+ * @param envHome - `$DSH_HOME` environment value seam (blank = unset).
56
+ */
57
+ export declare function credentialFilePath(home: string, envHome: string | undefined): string;
58
+ /** Why a user-typed host is not usable yet (undefined = fine). */
59
+ export type CredentialHostIssue = 'empty' | 'invalid';
60
+ /**
61
+ * Normalize a remote host typed or pasted by the user. Accepts a bare host
62
+ * (`github.com`), a host with a port (`git.example.com:8443`), a full remote
63
+ * URL (`https://github.com/org/repo.git`), an scp-like remote
64
+ * (`git@github.com:org/repo.git`) and a URL with userinfo — the host is the
65
+ * only part the credential needs. Lowercased, no scheme, path, userinfo or
66
+ * trailing slash.
67
+ * @param value - raw draft text.
68
+ * @returns the normalized host, or undefined when nothing usable is left.
69
+ */
70
+ export declare function normalizeCredentialHost(value: string): string | undefined;
71
+ /**
72
+ * Pre-flight check of a user-typed credential host.
73
+ * @param value - raw draft text.
74
+ */
75
+ export declare function credentialHostIssue(value: string): CredentialHostIssue | undefined;
76
+ /**
77
+ * Why a username is not usable yet (undefined = fine). A token credential may
78
+ * leave the username empty: vendors accept any non-empty Basic-auth name
79
+ * alongside a personal access token, so only password mode requires one.
80
+ * @param value - raw draft text.
81
+ * @param method - the method the draft targets (defaults to password mode).
82
+ */
83
+ export declare function credentialUsernameIssue(value: string, method?: CredentialMethod): 'empty' | 'illegal' | undefined;
84
+ /**
85
+ * The Basic-auth username git actually sends for one entry.
86
+ * @param entry - the stored credential.
87
+ * @returns the stored username, or {@link TOKEN_USERNAME} for a token saved
88
+ * without one.
89
+ */
90
+ export declare function basicUsername(entry: GitCredential): string;
91
+ /** Why a secret is not usable yet (undefined = fine). */
92
+ export declare function credentialSecretIssue(value: string): 'empty' | 'illegal' | undefined;
93
+ /**
94
+ * Mask a stored secret for the list route: the tail only, never the value.
95
+ * @param secret - the stored secret.
96
+ */
97
+ export declare function maskSecret(secret: string): string;
98
+ /**
99
+ * The git config argv that authenticates network commands against the stored
100
+ * hosts: one `http.<https-url>.extraHeader` per host. Git applies a
101
+ * `http.<url>.*` section only to a matching remote URL, so injecting every
102
+ * stored host at once cannot leak one host's header to another; only
103
+ * `https://` URLs match (ssh/http never receive it).
104
+ * @param entries - stored credentials (duplicates collapsed by host).
105
+ * @returns the `-c <key>=<value>` pairs, ready to prepend to a subcommand.
106
+ */
107
+ export declare function authConfigArgs(entries: readonly GitCredential[]): readonly string[];
108
+ /**
109
+ * Insert or update one credential; the pair `(host, updatedAt)` changes,
110
+ * everything else is replaced. An existing host keeps its position.
111
+ * @param entries - current credentials.
112
+ * @param next - the entry to store (host already normalized).
113
+ * @returns a NEW sorted array.
114
+ */
115
+ export declare function upsertCredential(entries: readonly GitCredential[], next: GitCredential): GitCredential[];
116
+ /**
117
+ * Drop one host's credential.
118
+ * @param entries - current credentials.
119
+ * @param host - normalized host to forget.
120
+ * @returns a NEW sorted array.
121
+ */
122
+ export declare function removeCredential(entries: readonly GitCredential[], host: string): GitCredential[];
123
+ /** Stable host order for the file and the UI. */
124
+ export declare function sortCredentials(entries: readonly GitCredential[]): GitCredential[];
125
+ /**
126
+ * Parse the credential file. Unknown extra fields are tolerated (a
127
+ * hand-edited file), but a malformed entry is an error rather than a silent
128
+ * drop — losing a credential behind the user's back is worse than an
129
+ * explicit message. Hosts are re-normalized and de-duplicated.
130
+ * @param text - raw file contents.
131
+ * @param path - file path (for the error message).
132
+ */
133
+ export declare function parseCredentialFile(text: string, path?: string): GitCredential[];
134
+ /**
135
+ * Serialize the credential set for the file.
136
+ * @param entries - stored credentials.
137
+ */
138
+ export declare function serializeCredentialFile(entries: readonly GitCredential[]): string;
139
+ /**
140
+ * The real file-backed store: atomic replace, `0600` where the platform
141
+ * honours it. A missing file reads as "no credentials yet".
142
+ * @param path - absolute credential file path.
143
+ */
144
+ export declare function createCredentialStore(path: string): CredentialStore;
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Global git credential store (host half). Credentials belong to a remote
3
+ * HOST, not to a project: the same `github.com` token serves every repository
4
+ * the panel touches, so the record lives in one file under `$DSH_HOME`
5
+ * (`~/.dsh`) instead of the per-repository settings document. The secret
6
+ * never travels to the browser — the list route answers masked tails only —
7
+ * and the store is read/written by the host alone.
8
+ *
9
+ * Authentication rides `git -c http.https://<host>/.extraHeader=…` on the
10
+ * panel's own network commands: the user's global git config and credential
11
+ * helpers are left untouched. Only `https://` remotes receive the header;
12
+ * ssh and plain-http remotes are never sent a secret.
13
+ */
14
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
15
+ import { dirname, join } from 'node:path';
16
+ import { resolveDshHome } from './settings.js';
17
+ /**
18
+ * The Basic-auth name git sends when a token was stored without a username.
19
+ * GitHub, GitLab and Gitee all accept any non-empty name for a personal access
20
+ * token, and `oauth2` is the conventional placeholder.
21
+ */
22
+ export const TOKEN_USERNAME = 'oauth2';
23
+ /** A credential file the store could not read or understand. */
24
+ export class CredentialStoreError extends Error {
25
+ path;
26
+ constructor(path, message) {
27
+ super(`credential file "${path}" ${message}`);
28
+ this.path = path;
29
+ this.name = 'CredentialStoreError';
30
+ }
31
+ }
32
+ /**
33
+ * Resolve the credential file location: `<dsh-home>/git-credentials.json`.
34
+ * @param home - user home directory seam.
35
+ * @param envHome - `$DSH_HOME` environment value seam (blank = unset).
36
+ */
37
+ export function credentialFilePath(home, envHome) {
38
+ return join(resolveDshHome(home, envHome), 'git-credentials.json');
39
+ }
40
+ /**
41
+ * Normalize a remote host typed or pasted by the user. Accepts a bare host
42
+ * (`github.com`), a host with a port (`git.example.com:8443`), a full remote
43
+ * URL (`https://github.com/org/repo.git`), an scp-like remote
44
+ * (`git@github.com:org/repo.git`) and a URL with userinfo — the host is the
45
+ * only part the credential needs. Lowercased, no scheme, path, userinfo or
46
+ * trailing slash.
47
+ * @param value - raw draft text.
48
+ * @returns the normalized host, or undefined when nothing usable is left.
49
+ */
50
+ export function normalizeCredentialHost(value) {
51
+ let host = value.trim().toLowerCase();
52
+ // Scheme (`https://`) and everything after the authority (`/org/repo`).
53
+ host = host.replace(/^[a-z][a-z0-9+.-]*:\/\//, '');
54
+ const slash = host.search(/[/?#]/);
55
+ if (slash >= 0)
56
+ host = host.slice(0, slash);
57
+ // Userinfo (`user:pass@host`); scp-like `git@host:path` keeps the host part.
58
+ const at = host.lastIndexOf('@');
59
+ if (at >= 0)
60
+ host = host.slice(at + 1);
61
+ // scp-like `host:path` — keep the host before the colon, unless the colon
62
+ // introduces a numeric port.
63
+ const colon = host.indexOf(':');
64
+ if (colon >= 0 && !/^\d{1,5}$/.test(host.slice(colon + 1)))
65
+ host = host.slice(0, colon);
66
+ if (host.endsWith('.'))
67
+ host = host.slice(0, -1);
68
+ if (host === '')
69
+ return undefined;
70
+ // Hostname (single-label intranet hosts allowed) with an optional port.
71
+ if (!/^(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?::\d{1,5})?$/.test(host))
72
+ return undefined;
73
+ return host;
74
+ }
75
+ /**
76
+ * Pre-flight check of a user-typed credential host.
77
+ * @param value - raw draft text.
78
+ */
79
+ export function credentialHostIssue(value) {
80
+ if (value.trim() === '')
81
+ return 'empty';
82
+ return normalizeCredentialHost(value) === undefined ? 'invalid' : undefined;
83
+ }
84
+ /**
85
+ * Why a username is not usable yet (undefined = fine). A token credential may
86
+ * leave the username empty: vendors accept any non-empty Basic-auth name
87
+ * alongside a personal access token, so only password mode requires one.
88
+ * @param value - raw draft text.
89
+ * @param method - the method the draft targets (defaults to password mode).
90
+ */
91
+ export function credentialUsernameIssue(value, method = 'password') {
92
+ if (value.trim() === '')
93
+ return method === 'token' ? undefined : 'empty';
94
+ if (/[\u0000-\u001f\u007f]/.test(value))
95
+ return 'illegal';
96
+ return undefined;
97
+ }
98
+ /**
99
+ * The Basic-auth username git actually sends for one entry.
100
+ * @param entry - the stored credential.
101
+ * @returns the stored username, or {@link TOKEN_USERNAME} for a token saved
102
+ * without one.
103
+ */
104
+ export function basicUsername(entry) {
105
+ return entry.username.trim() === '' ? TOKEN_USERNAME : entry.username;
106
+ }
107
+ /** Why a secret is not usable yet (undefined = fine). */
108
+ export function credentialSecretIssue(value) {
109
+ if (value.trim() === '')
110
+ return 'empty';
111
+ // A pasted multi-line blob (or a stray control character) would corrupt
112
+ // the Basic header; interior spaces stay legal — passwords may carry them.
113
+ if (/[\u0000-\u001f\u007f]/.test(value))
114
+ return 'illegal';
115
+ return undefined;
116
+ }
117
+ /**
118
+ * Mask a stored secret for the list route: the tail only, never the value.
119
+ * @param secret - the stored secret.
120
+ */
121
+ export function maskSecret(secret) {
122
+ const tail = secret.slice(-4);
123
+ return secret.length <= 4 ? '****' : `****${tail}`;
124
+ }
125
+ /**
126
+ * The git config argv that authenticates network commands against the stored
127
+ * hosts: one `http.<https-url>.extraHeader` per host. Git applies a
128
+ * `http.<url>.*` section only to a matching remote URL, so injecting every
129
+ * stored host at once cannot leak one host's header to another; only
130
+ * `https://` URLs match (ssh/http never receive it).
131
+ * @param entries - stored credentials (duplicates collapsed by host).
132
+ * @returns the `-c <key>=<value>` pairs, ready to prepend to a subcommand.
133
+ */
134
+ export function authConfigArgs(entries) {
135
+ // Collapse duplicates by host, LAST entry winning: a hand-edited file with
136
+ // two rows for one host must hand git the row the list route shows last.
137
+ const byHost = new Map();
138
+ for (const entry of entries)
139
+ byHost.set(entry.host, entry);
140
+ const args = [];
141
+ for (const entry of byHost.values()) {
142
+ const basic = Buffer.from(`${basicUsername(entry)}:${entry.secret}`, 'utf8').toString('base64');
143
+ args.push('-c', `http.https://${entry.host}/.extraHeader=AUTHORIZATION: basic ${basic}`);
144
+ }
145
+ return args;
146
+ }
147
+ /**
148
+ * Insert or update one credential; the pair `(host, updatedAt)` changes,
149
+ * everything else is replaced. An existing host keeps its position.
150
+ * @param entries - current credentials.
151
+ * @param next - the entry to store (host already normalized).
152
+ * @returns a NEW sorted array.
153
+ */
154
+ export function upsertCredential(entries, next) {
155
+ const rest = entries.filter(entry => entry.host !== next.host);
156
+ return sortCredentials([...rest, next]);
157
+ }
158
+ /**
159
+ * Drop one host's credential.
160
+ * @param entries - current credentials.
161
+ * @param host - normalized host to forget.
162
+ * @returns a NEW sorted array.
163
+ */
164
+ export function removeCredential(entries, host) {
165
+ return entries.filter(entry => entry.host !== host);
166
+ }
167
+ /** Stable host order for the file and the UI. */
168
+ export function sortCredentials(entries) {
169
+ return [...entries].sort((left, right) => left.host.localeCompare(right.host));
170
+ }
171
+ /**
172
+ * Parse the credential file. Unknown extra fields are tolerated (a
173
+ * hand-edited file), but a malformed entry is an error rather than a silent
174
+ * drop — losing a credential behind the user's back is worse than an
175
+ * explicit message. Hosts are re-normalized and de-duplicated.
176
+ * @param text - raw file contents.
177
+ * @param path - file path (for the error message).
178
+ */
179
+ export function parseCredentialFile(text, path = 'git-credentials.json') {
180
+ let parsed;
181
+ try {
182
+ parsed = JSON.parse(text);
183
+ }
184
+ catch (error) {
185
+ throw new CredentialStoreError(path, `is not valid JSON (${error instanceof Error ? error.message : String(error)})`);
186
+ }
187
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
188
+ throw new CredentialStoreError(path, 'must contain a JSON object');
189
+ }
190
+ const raw = parsed.credentials;
191
+ if (!Array.isArray(raw))
192
+ throw new CredentialStoreError(path, 'has no "credentials" array');
193
+ const byHost = new Map();
194
+ for (const [index, item] of raw.entries()) {
195
+ if (typeof item !== 'object' || item === null || Array.isArray(item)) {
196
+ throw new CredentialStoreError(path, `entry #${String(index + 1)} is not an object`);
197
+ }
198
+ const record = item;
199
+ const host = typeof record.host === 'string' ? normalizeCredentialHost(record.host) : undefined;
200
+ const username = typeof record.username === 'string' ? record.username : undefined;
201
+ const secret = typeof record.secret === 'string' ? record.secret : undefined;
202
+ const method = record.method;
203
+ if (host === undefined || username === undefined || secret === undefined || (method !== 'token' && method !== 'password')) {
204
+ throw new CredentialStoreError(path, `entry #${String(index + 1)} is missing a valid host/username/secret/method`);
205
+ }
206
+ const updatedAt = typeof record.updatedAt === 'number' && Number.isFinite(record.updatedAt) ? record.updatedAt : 0;
207
+ byHost.set(host, { host, username, secret, method, updatedAt });
208
+ }
209
+ return sortCredentials([...byHost.values()]);
210
+ }
211
+ /**
212
+ * Serialize the credential set for the file.
213
+ * @param entries - stored credentials.
214
+ */
215
+ export function serializeCredentialFile(entries) {
216
+ const document = { version: 1, credentials: sortCredentials(entries) };
217
+ return `${JSON.stringify(document, null, 2)}\n`;
218
+ }
219
+ /**
220
+ * The real file-backed store: atomic replace, `0600` where the platform
221
+ * honours it. A missing file reads as "no credentials yet".
222
+ * @param path - absolute credential file path.
223
+ */
224
+ export function createCredentialStore(path) {
225
+ return {
226
+ path,
227
+ load: async () => {
228
+ let text;
229
+ try {
230
+ text = await readFile(path, 'utf8');
231
+ }
232
+ catch (error) {
233
+ if (error.code === 'ENOENT')
234
+ return [];
235
+ throw new CredentialStoreError(path, `could not be read (${error instanceof Error ? error.message : String(error)})`);
236
+ }
237
+ if (text.trim() === '')
238
+ return [];
239
+ return parseCredentialFile(text, path);
240
+ },
241
+ save: async (entries) => {
242
+ const directory = dirname(path);
243
+ await mkdir(directory, { recursive: true });
244
+ // Write-then-rename keeps a crashed save from truncating the real file.
245
+ const temp = `${path}.${String(process.pid)}.${String(Date.now())}.tmp`;
246
+ try {
247
+ await writeFile(temp, serializeCredentialFile(entries), { encoding: 'utf8', mode: 0o600 });
248
+ await rename(temp, path);
249
+ }
250
+ catch (error) {
251
+ await rm(temp, { force: true }).catch(() => undefined);
252
+ throw new CredentialStoreError(path, `could not be written (${error instanceof Error ? error.message : String(error)})`);
253
+ }
254
+ },
255
+ };
256
+ }
package/lib/git.d.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  * injectable `exec` seam (tests substitute it); every method is plain
4
4
  * argument assembly plus porcelain parsing, no ambient state.
5
5
  */
6
- import type { BranchEntry, WorktreeEntry, WorkspaceGitFacts } from './wire.js';
6
+ import type { BranchEntry, RemoteEntry, WorktreeEntry, WorkspaceGitFacts } from './wire.js';
7
7
  /** One process result as the seam reports it. */
8
8
  export interface ExecResult {
9
9
  code: number;
@@ -253,8 +253,10 @@ export declare function accelArgs(prefix: string | undefined): readonly string[]
253
253
  * @param repoRoot - main worktree directory (fetch is repository-wide).
254
254
  * @param accelPrefix - optional cn-accelerate mirror prefix (see
255
255
  * {@link accelArgs}); only the READ half of the network commands gets it.
256
+ * @param authArgs - optional per-host credential `-c` pairs (see
257
+ * `credentials.ts`); prepended before the subcommand like the accel rewrite.
256
258
  */
257
- export declare function fetchAll(exec: Exec, repoRoot: string, accelPrefix?: string): Promise<void>;
259
+ export declare function fetchAll(exec: Exec, repoRoot: string, accelPrefix?: string, authArgs?: readonly string[]): Promise<void>;
258
260
  /** Outcome of {@link updateBranch}: the branch touched and whether it moved. */
259
261
  export interface UpdateOutcome {
260
262
  branch: string;
@@ -276,7 +278,7 @@ export interface UpdateOutcome {
276
278
  * @returns the branch name and whether HEAD actually moved (false = the
277
279
  * upstream already contained it).
278
280
  */
279
- export declare function updateBranch(exec: Exec, cwd: string, accelPrefix?: string): Promise<UpdateOutcome>;
281
+ export declare function updateBranch(exec: Exec, cwd: string, accelPrefix?: string, authArgs?: readonly string[]): Promise<UpdateOutcome>;
280
282
  /** Outcome of {@link pushBranch}: the branch touched and whether it moved. */
281
283
  export interface PushOutcome {
282
284
  branch: string;
@@ -300,9 +302,96 @@ export interface PushOutcome {
300
302
  * without an upstream can still be handed to `git push`, so Git's own
301
303
  * push-specific diagnostic is preserved; in that case `pushed` is false.
302
304
  */
303
- export declare function pushBranch(exec: Exec, cwd: string): Promise<PushOutcome>;
305
+ export declare function pushBranch(exec: Exec, cwd: string, authArgs?: readonly string[]): Promise<PushOutcome>;
306
+ /** Every LOCAL tag name of the repository, in git's own sort order. */
307
+ export declare function listTagNames(exec: Exec, cwd: string): Promise<string[]>;
308
+ /** Where a one-off push should go, or why no remote could be picked. */
309
+ export type PushRemoteChoice = {
310
+ readonly ok: true;
311
+ readonly remote: string;
312
+ } | {
313
+ readonly ok: false;
314
+ readonly message: string;
315
+ };
316
+ /**
317
+ * Pick the remote ONE tag push goes to, in fallback order: the checked-out
318
+ * branch's upstream remote, `remote.pushDefault`, then the only remote there
319
+ * is. Ambiguity is reported instead of guessed — a tag pushed to the wrong
320
+ * remote is a published mistake, not an inconvenience.
321
+ * @param exec - executor seam.
322
+ * @param cwd - directory whose checked-out branch supplies the upstream.
323
+ * @returns the remote name, or the caller-side reason none could be chosen.
324
+ */
325
+ export declare function resolvePushRemote(exec: Exec, cwd: string): Promise<PushRemoteChoice>;
326
+ /** Outcome of {@link pushTag}: the tag, where it went, and whether it moved. */
327
+ export interface TagPushOutcome {
328
+ tag: string;
329
+ remote: string;
330
+ pushed: boolean;
331
+ }
332
+ /**
333
+ * Push ONE existing local tag to `remote` (`git push <remote>
334
+ * refs/tags/<name>:refs/tags/<name>`) — the publish step a tag needs and a
335
+ * branch push never performs: `git push` sends the current branch alone, so a
336
+ * tag made on an already-pushed commit is invisible to the remote.
337
+ *
338
+ * The tag must already exist locally (the route verifies it against
339
+ * {@link listTagNames}); a remote that already carries the SAME tag object is
340
+ * reported as `pushed: false` without running the push at all. The refspec
341
+ * always names both sides explicitly, so no configured push refspec (a mirror,
342
+ * a `push.default=upstream` branch) can widen what a menu click publishes, and
343
+ * tags are never deleted or overwritten: `--force` is deliberately absent, and
344
+ * a remote tag pointing somewhere else makes git refuse (mapped to 400).
345
+ *
346
+ * The cn-accelerate rewrite is never injected here (no {@link accelArgs}
347
+ * parameter exists, like {@link pushBranch}): a mirror has no `receive-pack` —
348
+ * and the pre-push probe must see the very remote the push targets, or the
349
+ * `pushed` flag would compare two different servers.
350
+ * @param exec - executor seam.
351
+ * @param cwd - any directory inside the repository (tag refs are shared).
352
+ * @param name - an existing local tag name.
353
+ * @param remote - the remote to publish to (see {@link resolvePushRemote}).
354
+ * @returns the tag, the remote, and whether the push had to run.
355
+ */
356
+ export declare function pushTag(exec: Exec, cwd: string, name: string, remote: string, authArgs?: readonly string[]): Promise<TagPushOutcome>;
304
357
  /** Guard for route inputs: a non-empty absolute directory path. */
305
358
  export declare function isAbsoluteDir(value: string): boolean;
359
+ /**
360
+ * Every configured remote of the repository (`git remote -v`), with the fetch
361
+ * and push URLs git would actually use. A remote with only one side set
362
+ * mirrors that URL into both fields — git falls back to it the same way.
363
+ * @param exec - executor seam.
364
+ * @param cwd - any directory inside the repository.
365
+ */
366
+ export declare function listRemotes(exec: Exec, cwd: string): Promise<RemoteEntry[]>;
367
+ /**
368
+ * Register a new remote (`git remote add`). The name/URL were validated by
369
+ * the route; git still refuses a duplicate name — its stderr rides the
370
+ * GitError, mapped to 400 for the caller.
371
+ * @param exec - executor seam.
372
+ * @param cwd - any directory inside the repository.
373
+ * @param name - the new remote name.
374
+ * @param url - its URL (fetch and push; a push URL is not set apart).
375
+ */
376
+ export declare function addRemote(exec: Exec, cwd: string, name: string, url: string): Promise<void>;
377
+ /**
378
+ * Re-point an existing remote's fetch AND push URL (`git remote set-url`).
379
+ * Deliberately both: a separate push URL would survive as a stale target the
380
+ * UI cannot show, so the panel keeps one address per remote.
381
+ * @param exec - executor seam.
382
+ * @param cwd - any directory inside the repository.
383
+ * @param name - an existing remote name.
384
+ * @param url - the new URL.
385
+ */
386
+ export declare function setRemoteUrl(exec: Exec, cwd: string, name: string, url: string): Promise<void>;
387
+ /**
388
+ * Forget one remote (`git remote remove`): deletes its remote-tracking refs
389
+ * and its config section, never the objects the local branches share.
390
+ * @param exec - executor seam.
391
+ * @param cwd - any directory inside the repository.
392
+ * @param name - an existing remote name.
393
+ */
394
+ export declare function removeRemote(exec: Exec, cwd: string, name: string): Promise<void>;
306
395
  /** Pre-delete facts of one worktree directory, for the remove-confirm dialog. */
307
396
  export interface WorktreeInspect {
308
397
  /** Uncommitted-change file rows (`git status --porcelain` line count). */