pi-better-subagents 0.1.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/README.md +420 -0
- package/batch.mjs +208 -0
- package/capacity.mjs +112 -0
- package/completion.mjs +165 -0
- package/completion.ts +11 -0
- package/config.json +14 -0
- package/config.ts +104 -0
- package/extensions.mjs +147 -0
- package/extensions.ts +19 -0
- package/finalization.ts +145 -0
- package/git-remotes.ts +413 -0
- package/git-workspace.ts +430 -0
- package/health-observation.ts +670 -0
- package/health-surface.mjs +276 -0
- package/health.ts +303 -0
- package/index.ts +1235 -0
- package/lifecycle.ts +333 -0
- package/list.mjs +123 -0
- package/list.ts +17 -0
- package/navigator.mjs +1188 -0
- package/navigator.ts +38 -0
- package/package.json +43 -0
- package/parse.ts +1144 -0
- package/registry.ts +236 -0
- package/sandbox.ts +164 -0
- package/spawn.ts +78 -0
- package/stop.ts +155 -0
- package/tools.ts +399 -0
- package/widget.mjs +218 -0
- package/widget.ts +28 -0
package/finalization.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-party finalization + result assembly for child exits.
|
|
3
|
+
*
|
|
4
|
+
* Kept free of the pi host package so tests can execute the real
|
|
5
|
+
* parser/classifier/registry/callback path against durable run metadata.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { buildCompletionDelivery } from "./completion.ts";
|
|
9
|
+
import {
|
|
10
|
+
classifyChildExit,
|
|
11
|
+
formatSubagentResult,
|
|
12
|
+
resolveLifecycle,
|
|
13
|
+
type ChildExitOutcome,
|
|
14
|
+
} from "./lifecycle.ts";
|
|
15
|
+
import { parseRunForLifecycle, tailLog } from "./parse.ts";
|
|
16
|
+
import {
|
|
17
|
+
canExitFinalize,
|
|
18
|
+
effectiveStatus,
|
|
19
|
+
isFinalResultStatus,
|
|
20
|
+
readMeta,
|
|
21
|
+
writeMeta,
|
|
22
|
+
type RunMeta,
|
|
23
|
+
} from "./registry.ts";
|
|
24
|
+
import { fmtElapsed, fmtSpend } from "./widget.ts";
|
|
25
|
+
|
|
26
|
+
export interface FinalizeHooks {
|
|
27
|
+
renderWidget?: () => void;
|
|
28
|
+
notify?: (message: string, level: "info" | "warning") => void;
|
|
29
|
+
sendMessage?: (
|
|
30
|
+
message: { customType: string; content: string; display: boolean },
|
|
31
|
+
options: Record<string, unknown>,
|
|
32
|
+
) => void;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface FinalizeResult {
|
|
36
|
+
applied: boolean;
|
|
37
|
+
meta?: RunMeta;
|
|
38
|
+
outcome?: ChildExitOutcome;
|
|
39
|
+
delivery?: { content: string; options: Record<string, unknown> };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Finalize a run once its child exits. Idempotent: a run already marked
|
|
44
|
+
* terminal is left alone. Persists lifecycle classification + failureReason
|
|
45
|
+
* and builds the completion callback delivery from real log evidence.
|
|
46
|
+
*/
|
|
47
|
+
export function finalizeRun(
|
|
48
|
+
id: string,
|
|
49
|
+
code: number | null,
|
|
50
|
+
hooks: FinalizeHooks = {},
|
|
51
|
+
): FinalizeResult {
|
|
52
|
+
const meta = readMeta(id);
|
|
53
|
+
// Coherent child-exit evidence may supersede provisional orphaned/lost
|
|
54
|
+
// reconciliation, but never overwrites a true terminal record.
|
|
55
|
+
if (!meta || !canExitFinalize(meta.status)) return { applied: false };
|
|
56
|
+
|
|
57
|
+
// Lifecycle authority streams the complete NDJSON log; result text stays bounded.
|
|
58
|
+
const r = parseRunForLifecycle(id);
|
|
59
|
+
const outcome = classifyChildExit(code, r);
|
|
60
|
+
meta.status = outcome.status;
|
|
61
|
+
meta.lifecycleClassification = outcome.classification;
|
|
62
|
+
if (outcome.incomplete) meta.failureReason = "incomplete-stream";
|
|
63
|
+
meta.exitCode = code;
|
|
64
|
+
meta.endedAt = Date.now();
|
|
65
|
+
writeMeta(meta);
|
|
66
|
+
|
|
67
|
+
const label = meta.name ? `${meta.name} (${id})` : id;
|
|
68
|
+
const verdict = outcome.verdict;
|
|
69
|
+
const el = fmtElapsed(meta.endedAt - meta.startedAt);
|
|
70
|
+
const spend = fmtSpend(r.usage);
|
|
71
|
+
const stat = `${el}${spend ? ` · ${spend}` : ""}`;
|
|
72
|
+
const tools = r.toolCalls.length ? r.toolCalls.join(", ") : undefined;
|
|
73
|
+
|
|
74
|
+
// A finished run is no longer in the widget; redraw (and stop the ticker if
|
|
75
|
+
// it was the last one).
|
|
76
|
+
hooks.renderWidget?.();
|
|
77
|
+
|
|
78
|
+
// Best-effort human toast. ctx may be stale by now; never let it throw.
|
|
79
|
+
try {
|
|
80
|
+
hooks.notify?.(
|
|
81
|
+
`Subagent ${label} ${verdict} · ${stat}`,
|
|
82
|
+
meta.status === "completed" ? "info" : "warning",
|
|
83
|
+
);
|
|
84
|
+
} catch {
|
|
85
|
+
/* ignore */
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const callback = meta.callback !== false; // default: trigger completion
|
|
89
|
+
// buildCompletionDelivery is the single place sendMessage content/options are
|
|
90
|
+
// assembled. resultText is accepted here so callers/tests can pass it without
|
|
91
|
+
// breaking, but it is NEVER put into content — the result lives in subagent_result.
|
|
92
|
+
const delivery = buildCompletionDelivery({
|
|
93
|
+
id,
|
|
94
|
+
label,
|
|
95
|
+
verdict,
|
|
96
|
+
stat,
|
|
97
|
+
tools,
|
|
98
|
+
callback,
|
|
99
|
+
incomplete: outcome.incomplete,
|
|
100
|
+
lifecycleClassification: outcome.classification,
|
|
101
|
+
resultText: r.finalText || r.lastActivity || "",
|
|
102
|
+
});
|
|
103
|
+
hooks.sendMessage?.(
|
|
104
|
+
{ customType: "subagent-complete", content: delivery.content, display: true },
|
|
105
|
+
delivery.options,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
applied: true,
|
|
110
|
+
meta: readMeta(id),
|
|
111
|
+
outcome,
|
|
112
|
+
delivery,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Assemble the user-visible `subagent_result` body from durable meta + log.
|
|
118
|
+
* Returns null when the run is still live so the tool can emit its running message.
|
|
119
|
+
*/
|
|
120
|
+
export function buildSubagentResultText(id: string): string | null {
|
|
121
|
+
const meta = readMeta(id);
|
|
122
|
+
if (!meta) throw new Error(`Unknown run id: ${id}`);
|
|
123
|
+
const st = effectiveStatus(meta);
|
|
124
|
+
// Non-final statuses (running / orphaned) have no final result body.
|
|
125
|
+
if (!isFinalResultStatus(st)) return null;
|
|
126
|
+
|
|
127
|
+
const exit = meta.exitCode === undefined ? "?" : String(meta.exitCode);
|
|
128
|
+
// Re-derive lifecycle diagnostics from the complete stream even when meta is stale.
|
|
129
|
+
const r = parseRunForLifecycle(id);
|
|
130
|
+
const el = fmtElapsed((meta.endedAt ?? Date.now()) - meta.startedAt);
|
|
131
|
+
const spend = fmtSpend(r.usage);
|
|
132
|
+
const statSeg = ` · ${el}${spend ? ` · ${spend}` : ""}`;
|
|
133
|
+
const tools = r.toolCalls.length ? ` · tools: ${r.toolCalls.join(", ")}` : "";
|
|
134
|
+
const lifecycle = resolveLifecycle(meta, r);
|
|
135
|
+
return formatSubagentResult({
|
|
136
|
+
id,
|
|
137
|
+
status: st,
|
|
138
|
+
exitCode: exit,
|
|
139
|
+
statSeg,
|
|
140
|
+
toolsSeg: tools,
|
|
141
|
+
run: r,
|
|
142
|
+
rawLogTail: tailLog(id, 40),
|
|
143
|
+
lifecycle,
|
|
144
|
+
});
|
|
145
|
+
}
|
package/git-remotes.ts
ADDED
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* First-class Git remote semantics for disposable clone workspaces
|
|
3
|
+
* (issue #103 normal fetch-URL + pushurl topologies + issue #109 edge cases).
|
|
4
|
+
*
|
|
5
|
+
* Invariant (`git-remote-preservation`): disposable clone preparation must
|
|
6
|
+
* preserve every source remote name, **every** configured `remote.<name>.url`
|
|
7
|
+
* value, and **every** configured `remote.<name>.pushurl` value — not just the
|
|
8
|
+
* first of either. Git permits multi-valued keys for both:
|
|
9
|
+
*
|
|
10
|
+
* - With no explicit pushurl, `git remote get-url --push --all` returns all
|
|
11
|
+
* fetch URLs and one `git push` reaches every URL.
|
|
12
|
+
* - With explicit pushurl(s), push uses those destinations only.
|
|
13
|
+
* - Push-only remotes (issue #109): zero `remote.<name>.url` entries and one or
|
|
14
|
+
* more `remote.<name>.pushurl` entries. Model `urls` as an empty ordered list;
|
|
15
|
+
* never invent a fetch URL from the first pushurl.
|
|
16
|
+
*
|
|
17
|
+
* Collapsing either multi-valued set rewrites producer push/fetch topology.
|
|
18
|
+
*
|
|
19
|
+
* Values are read from null-delimited `git config` output rather than
|
|
20
|
+
* `git remote -v` line parsing, which drops any URL containing spaces.
|
|
21
|
+
*
|
|
22
|
+
* Source remote read-failure safety (issue #109): if source remote config cannot
|
|
23
|
+
* be read because the source is missing/unreadable/not a Git repo,
|
|
24
|
+
* `syncGitRemotes` fails before mutating the target. A valid Git repo with no
|
|
25
|
+
* remote keys still returns `[]` and may clear target remotes.
|
|
26
|
+
*
|
|
27
|
+
* Consumed by disposable clone workspace preparation (issue #78 / PR #89).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { execFileSync } from "node:child_process";
|
|
31
|
+
|
|
32
|
+
/** One configured Git remote, including every fetch URL and every explicit push URL. */
|
|
33
|
+
export interface GitRemote {
|
|
34
|
+
name: string;
|
|
35
|
+
/**
|
|
36
|
+
* Every configured `remote.<name>.url`, in config order.
|
|
37
|
+
* Empty for push-only remotes (zero fetch URLs, one or more pushurls).
|
|
38
|
+
* Git fetches from the first when present and, when no pushurl is set,
|
|
39
|
+
* pushes to all.
|
|
40
|
+
*/
|
|
41
|
+
urls: string[];
|
|
42
|
+
/**
|
|
43
|
+
* Every configured `remote.<name>.pushurl`, in config order.
|
|
44
|
+
* Empty when the remote has no explicit push URL (Git then pushes to every
|
|
45
|
+
* entry in `urls`).
|
|
46
|
+
*/
|
|
47
|
+
pushUrls: string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function runGit(cwd: string, args: string[]): string {
|
|
51
|
+
try {
|
|
52
|
+
return execFileSync("git", args, {
|
|
53
|
+
cwd,
|
|
54
|
+
encoding: "utf-8",
|
|
55
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
56
|
+
}).trim();
|
|
57
|
+
} catch (err) {
|
|
58
|
+
const message = (err as Error).message ?? String(err);
|
|
59
|
+
throw new Error(`git ${args.join(" ")} failed in ${cwd}: ${message}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Confirm `dir` is a readable Git repository (work tree or bare).
|
|
65
|
+
*
|
|
66
|
+
* `git config --get-regexp` exits 1 both when there are no matching keys in a
|
|
67
|
+
* valid repo and when the directory is not a Git repo, so callers must validate
|
|
68
|
+
* the repository first. Missing paths and permission errors also surface here.
|
|
69
|
+
*/
|
|
70
|
+
function assertGitRepository(dir: string): void {
|
|
71
|
+
try {
|
|
72
|
+
execFileSync("git", ["rev-parse", "--git-dir"], {
|
|
73
|
+
cwd: dir,
|
|
74
|
+
encoding: "utf-8",
|
|
75
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
76
|
+
});
|
|
77
|
+
} catch (err) {
|
|
78
|
+
const message = (err as Error).message ?? String(err);
|
|
79
|
+
throw new Error(
|
|
80
|
+
`cannot read Git remotes from ${dir}: not a readable Git repository (${message})`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Read remote fetch/push URLs from structured Git config.
|
|
87
|
+
*
|
|
88
|
+
* Uses `git config --null --get-regexp` so URL values may contain spaces, tabs,
|
|
89
|
+
* or other whitespace without being truncated. Collects **every**
|
|
90
|
+
* `remote.<name>.url` and **every** `remote.<name>.pushurl` entry (Git allows
|
|
91
|
+
* multiple of each; multi-url with no pushurl is a multi-destination push set;
|
|
92
|
+
* push-only remotes keep `urls: []`).
|
|
93
|
+
*
|
|
94
|
+
* A valid Git repository with no matching remote keys returns `[]`.
|
|
95
|
+
* Missing/unreadable/non-git paths throw (do not conflate with empty).
|
|
96
|
+
*/
|
|
97
|
+
export function readGitRemotes(dir: string): GitRemote[] {
|
|
98
|
+
// Validate the repository before interpreting get-regexp exit status.
|
|
99
|
+
// git config --get-regexp exits 1 for "no match" AND for "not a git repo",
|
|
100
|
+
// so empty-valid vs operational failure is only distinguishable after this.
|
|
101
|
+
assertGitRepository(dir);
|
|
102
|
+
|
|
103
|
+
let raw: string;
|
|
104
|
+
try {
|
|
105
|
+
// Call git directly (not runGit) so we do not .trim() away trailing
|
|
106
|
+
// structure from null-delimited config output.
|
|
107
|
+
raw = execFileSync(
|
|
108
|
+
"git",
|
|
109
|
+
["config", "--null", "--get-regexp", "^remote\\..*\\.(url|pushurl)$"],
|
|
110
|
+
{
|
|
111
|
+
cwd: dir,
|
|
112
|
+
encoding: "utf-8",
|
|
113
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
} catch (err) {
|
|
117
|
+
const status = (err as { status?: number }).status;
|
|
118
|
+
// Exit 1 with empty output = no matching keys in a valid repo.
|
|
119
|
+
if (status === 1) {
|
|
120
|
+
return [];
|
|
121
|
+
}
|
|
122
|
+
const message = (err as Error).message ?? String(err);
|
|
123
|
+
throw new Error(
|
|
124
|
+
`cannot read Git remote config from ${dir}: ${message}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (!raw) return [];
|
|
128
|
+
|
|
129
|
+
const byName = new Map<string, { fetch: string[]; push: string[] }>();
|
|
130
|
+
for (const record of raw.split("\0")) {
|
|
131
|
+
if (!record) continue;
|
|
132
|
+
const nl = record.indexOf("\n");
|
|
133
|
+
if (nl < 0) continue;
|
|
134
|
+
const key = record.slice(0, nl);
|
|
135
|
+
const value = record.slice(nl + 1);
|
|
136
|
+
// Empty values are not meaningful remote URLs; skip.
|
|
137
|
+
if (value === "") continue;
|
|
138
|
+
|
|
139
|
+
// remote.<name>.url | remote.<name>.pushurl — name may itself contain dots.
|
|
140
|
+
const match = key.match(/^remote\.(.+)\.(url|pushurl)$/);
|
|
141
|
+
if (!match) continue;
|
|
142
|
+
const [, name, kind] = match;
|
|
143
|
+
const entry = byName.get(name) ?? { fetch: [], push: [] };
|
|
144
|
+
if (kind === "url") {
|
|
145
|
+
// Preserve every fetch URL in config order — do not collapse to one.
|
|
146
|
+
entry.fetch.push(value);
|
|
147
|
+
} else {
|
|
148
|
+
// Preserve every pushurl in config order — do not collapse to one.
|
|
149
|
+
entry.push.push(value);
|
|
150
|
+
}
|
|
151
|
+
byName.set(name, entry);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const remotes: GitRemote[] = [];
|
|
155
|
+
for (const [name, urls] of byName.entries()) {
|
|
156
|
+
// Push-only remotes (issue #109): zero url keys, one or more pushurls.
|
|
157
|
+
// Keep urls as an empty ordered list — never invent a fetch URL.
|
|
158
|
+
if (urls.fetch.length === 0 && urls.push.length === 0) continue;
|
|
159
|
+
remotes.push({
|
|
160
|
+
name,
|
|
161
|
+
urls: [...urls.fetch],
|
|
162
|
+
pushUrls: [...urls.push],
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
// Stable order by remote name so tests and sync are deterministic.
|
|
166
|
+
remotes.sort((a, b) => a.name.localeCompare(b.name));
|
|
167
|
+
return remotes;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Make `targetDir`'s remotes match `sourceDir`'s remote contract: names, every
|
|
172
|
+
* fetch URL (possibly zero for push-only remotes), and every configured push
|
|
173
|
+
* URL. Removes stale remotes that exist only on the target (typical after a
|
|
174
|
+
* path-style clone).
|
|
175
|
+
*
|
|
176
|
+
* Reads and validates the source remote set **before** mutating the target.
|
|
177
|
+
* If source remote config cannot be read, throws and leaves the target unchanged.
|
|
178
|
+
*/
|
|
179
|
+
export function syncGitRemotes(sourceDir: string, targetDir: string): void {
|
|
180
|
+
// Read source first. Any throw here (missing/unreadable/not-a-repo) aborts
|
|
181
|
+
// before target remotes are inspected or mutated (#109 non-mutation).
|
|
182
|
+
const sourceRemotes = readGitRemotes(sourceDir);
|
|
183
|
+
const targetRemotes = readGitRemotes(targetDir);
|
|
184
|
+
const sourceNames = new Set(sourceRemotes.map((remote) => remote.name));
|
|
185
|
+
|
|
186
|
+
// Drop remotes the source does not have (typical case: clone-from-path set
|
|
187
|
+
// origin to the parent working tree, or leftover scratch remotes).
|
|
188
|
+
for (const remote of targetRemotes) {
|
|
189
|
+
if (!sourceNames.has(remote.name)) {
|
|
190
|
+
runGit(targetDir, ["remote", "remove", remote.name]);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const remote of sourceRemotes) {
|
|
195
|
+
const existing = targetRemotes.find((entry) => entry.name === remote.name);
|
|
196
|
+
if (!existing) {
|
|
197
|
+
ensureRemoteExists(targetDir, remote);
|
|
198
|
+
}
|
|
199
|
+
applyFetchUrls(
|
|
200
|
+
targetDir,
|
|
201
|
+
remote.name,
|
|
202
|
+
remote.urls,
|
|
203
|
+
existing?.urls ?? (remote.urls.length > 0 ? [remote.urls[0]] : []),
|
|
204
|
+
);
|
|
205
|
+
applyPushUrls(
|
|
206
|
+
targetDir,
|
|
207
|
+
remote.name,
|
|
208
|
+
remote.pushUrls,
|
|
209
|
+
existing?.pushUrls ?? [],
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Ensure a remote name exists on the target so url/pushurl keys can be applied.
|
|
216
|
+
*
|
|
217
|
+
* `git remote add` requires a fetch URL, so push-only remotes are created via
|
|
218
|
+
* `git config` (fetch refspec + pushurls) rather than inventing a fetch URL.
|
|
219
|
+
*/
|
|
220
|
+
function ensureRemoteExists(dir: string, remote: GitRemote): void {
|
|
221
|
+
if (remote.urls.length > 0) {
|
|
222
|
+
// Normal path: create with the first fetch URL; applyFetchUrls rebuilds the full set.
|
|
223
|
+
runGit(dir, ["remote", "add", remote.name, remote.urls[0]]);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
// Push-only: create the remote section without a url key.
|
|
227
|
+
// A fetch refspec is enough for Git to list the remote name.
|
|
228
|
+
try {
|
|
229
|
+
runGit(dir, ["config", "--get", `remote.${remote.name}.fetch`]);
|
|
230
|
+
} catch {
|
|
231
|
+
runGit(dir, [
|
|
232
|
+
"config",
|
|
233
|
+
`remote.${remote.name}.fetch`,
|
|
234
|
+
`+refs/heads/*:refs/remotes/${remote.name}/*`,
|
|
235
|
+
]);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Replace the target remote's fetch URL set with `desired`.
|
|
241
|
+
*
|
|
242
|
+
* Same multi-value constraint as pushurls: `git remote set-url <name> <url>`
|
|
243
|
+
* fatals when the key already has multiple values. Always clear the complete
|
|
244
|
+
* existing url set first (`git config --unset-all`), then rebuild with
|
|
245
|
+
* set-url + --add when desired is non-empty. When desired is empty (push-only),
|
|
246
|
+
* leave the remote with no `remote.<name>.url` keys.
|
|
247
|
+
*
|
|
248
|
+
* Never use regex `--delete` of URL text.
|
|
249
|
+
*/
|
|
250
|
+
function applyFetchUrls(
|
|
251
|
+
dir: string,
|
|
252
|
+
name: string,
|
|
253
|
+
desired: string[],
|
|
254
|
+
existing: string[],
|
|
255
|
+
): void {
|
|
256
|
+
// Fast path: already identical in order — nothing to do (including both empty).
|
|
257
|
+
if (
|
|
258
|
+
existing.length === desired.length &&
|
|
259
|
+
existing.every((url, i) => url === desired[i])
|
|
260
|
+
) {
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Always clear the complete existing url set first. A bare `set-url`
|
|
265
|
+
// cannot replace a multi-valued set (Git fatals). For push-only desired
|
|
266
|
+
// sets this is the whole operation (no rebuild).
|
|
267
|
+
clearAllFetchUrls(dir, name);
|
|
268
|
+
|
|
269
|
+
if (desired.length === 0) {
|
|
270
|
+
// Push-only: remote remains with fetch refspec + pushurls only.
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Rebuild desired ordered set: first set-url, then --add.
|
|
275
|
+
// If the remote has no url key (e.g. was push-only), set-url still works
|
|
276
|
+
// when the remote section exists; otherwise ensureRemoteExists already added it.
|
|
277
|
+
runGit(dir, ["remote", "set-url", name, desired[0]]);
|
|
278
|
+
for (let i = 1; i < desired.length; i++) {
|
|
279
|
+
runGit(dir, ["remote", "set-url", "--add", name, desired[i]]);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Replace the target remote's push URL set with `desired`.
|
|
285
|
+
*
|
|
286
|
+
* Git has no "set all pushurls" primitive. Critically, `git remote set-url
|
|
287
|
+
* --push <name> <url>` is rejected when the target already has multiple
|
|
288
|
+
* pushurl values (`warning: remote.<name>.pushurl has multiple values;
|
|
289
|
+
* fatal: could not set ...`). Always clear the complete existing pushurl
|
|
290
|
+
* set first, then rebuild the desired ordered set with set-url + --add.
|
|
291
|
+
* When the desired set is empty, every existing pushurl is deleted so push
|
|
292
|
+
* falls back to every configured fetch URL.
|
|
293
|
+
*
|
|
294
|
+
* For push-only remotes (no fetch URL), rebuild via `git config --add
|
|
295
|
+
* remote.<name>.pushurl` because `git remote set-url --push` may require an
|
|
296
|
+
* existing remote url section depending on Git version; config keys are the
|
|
297
|
+
* durable representation either way.
|
|
298
|
+
*/
|
|
299
|
+
function applyPushUrls(
|
|
300
|
+
dir: string,
|
|
301
|
+
name: string,
|
|
302
|
+
desired: string[],
|
|
303
|
+
existing: string[],
|
|
304
|
+
): void {
|
|
305
|
+
// Fast path: already identical in order — nothing to do (desired empty or not).
|
|
306
|
+
if (
|
|
307
|
+
existing.length === desired.length &&
|
|
308
|
+
existing.every((url, i) => url === desired[i])
|
|
309
|
+
) {
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Always clear the complete existing pushurl set first. A bare
|
|
314
|
+
// `set-url --push` cannot replace a multi-valued set (Git fatals).
|
|
315
|
+
clearAllPushUrls(dir, name);
|
|
316
|
+
|
|
317
|
+
if (desired.length === 0) {
|
|
318
|
+
// Desired is empty: after unset-all, Git's default (push → all fetch
|
|
319
|
+
// URLs) holds when fetch URLs exist. `get-url --push --all` reports
|
|
320
|
+
// fetch URL(s) when no pushurl key exists, so treat remaining entries
|
|
321
|
+
// equal to fetch set as already-default.
|
|
322
|
+
const remaining = safePushUrlsAll(dir, name);
|
|
323
|
+
const fetchUrls = safeFetchUrlsAll(dir, name);
|
|
324
|
+
const isDefaultOnly =
|
|
325
|
+
remaining.length === 0 ||
|
|
326
|
+
(remaining.length === fetchUrls.length &&
|
|
327
|
+
remaining.every((url, i) => url === fetchUrls[i]));
|
|
328
|
+
if (isDefaultOnly) return;
|
|
329
|
+
|
|
330
|
+
// Retry once if values somehow remain (should not happen after unset-all).
|
|
331
|
+
clearAllPushUrls(dir, name);
|
|
332
|
+
const still = safePushUrlsAll(dir, name);
|
|
333
|
+
const stillDefaultOnly =
|
|
334
|
+
still.length === 0 ||
|
|
335
|
+
(still.length === fetchUrls.length &&
|
|
336
|
+
still.every((url, i) => url === fetchUrls[i]));
|
|
337
|
+
if (!stillDefaultOnly) {
|
|
338
|
+
throw new Error(
|
|
339
|
+
`unable to clear multi-valued remote.${name}.pushurl in ${dir}`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Prefer git-config direct writes so push-only remotes (no url key) work
|
|
346
|
+
// the same as normal remotes. set-url --push can behave oddly when there
|
|
347
|
+
// is no fetch URL (it may treat the remote name as a URL).
|
|
348
|
+
for (const url of desired) {
|
|
349
|
+
runGit(dir, ["config", "--add", `remote.${name}.pushurl`, url]);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Delete every configured fetch URL for `name` as a complete set.
|
|
355
|
+
*
|
|
356
|
+
* Must NOT use `git remote set-url --delete <name> <url>`: that treats the
|
|
357
|
+
* final argument as a regex, so legal local paths containing unmatched
|
|
358
|
+
* metacharacters (e.g. `[`) fail to match and leave stale destinations intact.
|
|
359
|
+
* `git config --unset-all remote.<name>.url` drops the multi-valued key
|
|
360
|
+
* wholesale without interpreting URL text as a pattern.
|
|
361
|
+
*
|
|
362
|
+
* Note: after unset-all the remote still exists (fetch refspec remains) but
|
|
363
|
+
* has no url until rebuild. Callers must rebuild immediately when desired
|
|
364
|
+
* is non-empty; empty desired is the push-only topology.
|
|
365
|
+
*/
|
|
366
|
+
function clearAllFetchUrls(dir: string, name: string): void {
|
|
367
|
+
try {
|
|
368
|
+
runGit(dir, ["config", "--unset-all", `remote.${name}.url`]);
|
|
369
|
+
} catch {
|
|
370
|
+
// Key already absent (git exits non-zero) — nothing to clear.
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Delete every configured pushurl for `name` as a complete set.
|
|
376
|
+
*
|
|
377
|
+
* Must NOT use `git remote set-url --push --delete <name> <url>`: that treats
|
|
378
|
+
* the final argument as a regex, so legal local paths containing unmatched
|
|
379
|
+
* metacharacters (e.g. `[`) fail to match and leave stale destinations intact.
|
|
380
|
+
* `git config --unset-all remote.<name>.pushurl` drops the multi-valued key
|
|
381
|
+
* wholesale without interpreting URL text as a pattern.
|
|
382
|
+
*/
|
|
383
|
+
function clearAllPushUrls(dir: string, name: string): void {
|
|
384
|
+
try {
|
|
385
|
+
runGit(dir, ["config", "--unset-all", `remote.${name}.pushurl`]);
|
|
386
|
+
} catch {
|
|
387
|
+
// Key already absent (git exits non-zero) — nothing to clear.
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function safePushUrlsAll(dir: string, name: string): string[] {
|
|
392
|
+
try {
|
|
393
|
+
const out = runGit(dir, ["remote", "get-url", "--push", "--all", name]);
|
|
394
|
+
return out
|
|
395
|
+
.split("\n")
|
|
396
|
+
.map((line) => line.trim())
|
|
397
|
+
.filter(Boolean);
|
|
398
|
+
} catch {
|
|
399
|
+
return [];
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function safeFetchUrlsAll(dir: string, name: string): string[] {
|
|
404
|
+
try {
|
|
405
|
+
const out = runGit(dir, ["remote", "get-url", "--all", name]);
|
|
406
|
+
return out
|
|
407
|
+
.split("\n")
|
|
408
|
+
.map((line) => line.trim())
|
|
409
|
+
.filter(Boolean);
|
|
410
|
+
} catch {
|
|
411
|
+
return [];
|
|
412
|
+
}
|
|
413
|
+
}
|