magus-cli 7.12.2 → 7.14.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 +21 -0
- package/package.json +4 -4
- package/src/__tests__/market-bases.test.ts +112 -0
- package/src/__tests__/market-config.test.ts +84 -0
- package/src/__tests__/market-conflicts.test.ts +175 -0
- package/src/__tests__/market-consent.test.ts +185 -0
- package/src/__tests__/market-content.test.ts +183 -0
- package/src/__tests__/market-delivery.test.ts +308 -0
- package/src/__tests__/market-fixture.ts +1167 -0
- package/src/__tests__/market-git.test.ts +130 -0
- package/src/__tests__/market-identity.test.ts +127 -0
- package/src/__tests__/market-install.test.ts +143 -0
- package/src/__tests__/market-layout.test.ts +206 -0
- package/src/__tests__/market-publish.test.ts +263 -0
- package/src/__tests__/market-r2-adapters.test.ts +190 -0
- package/src/__tests__/market-r2-company-cli.test.ts +350 -0
- package/src/__tests__/market-r2-company-unit.test.ts +224 -0
- package/src/__tests__/market-r2-local-items.test.ts +197 -0
- package/src/__tests__/market-r2-plugin-add.test.ts +470 -0
- package/src/__tests__/market-r2-plugin-publish.test.ts +568 -0
- package/src/__tests__/market-r2-routing.test.ts +127 -0
- package/src/__tests__/market-r2-skill-create-plugin.test.ts +315 -0
- package/src/__tests__/market-r2b-display-path.test.ts +180 -0
- package/src/__tests__/market-r2b-fit-row.test.ts +287 -0
- package/src/__tests__/market-r2b-local-items.test.ts +253 -0
- package/src/__tests__/market-r2b-plugin-add.test.ts +130 -0
- package/src/__tests__/market-readonly.test.ts +162 -0
- package/src/__tests__/market-repositories.test.ts +263 -0
- package/src/__tests__/market-results.test.ts +198 -0
- package/src/__tests__/market-router.test.ts +65 -0
- package/src/__tests__/market-safety.test.ts +357 -0
- package/src/__tests__/market-teammates.test.ts +304 -0
- package/src/cli/install.ts +64 -5
- package/src/cli/market-view.ts +329 -0
- package/src/cli/market.ts +664 -0
- package/src/cli/router.ts +69 -3
- package/src/cli/update-view.ts +56 -0
- package/src/cli/update.ts +111 -9
- package/src/data/marketplaces.ts +8 -2
- package/src/main.tsx +20 -2
- package/src/market-tui.tsx +21 -0
- package/src/services/active-closure.ts +71 -9
- package/src/services/claude-cli.ts +140 -10
- package/src/services/claude-settings.ts +19 -25
- package/src/services/doctor.ts +5 -5
- package/src/services/git-write.ts +615 -0
- package/src/services/github-cli.ts +309 -0
- package/src/services/local-marketplace.ts +112 -50
- package/src/services/market-bases.ts +191 -0
- package/src/services/market-change.ts +242 -0
- package/src/services/market-clone.ts +572 -0
- package/src/services/market-fs.ts +138 -0
- package/src/services/market-install.ts +172 -0
- package/src/services/market-layout.ts +311 -0
- package/src/services/market-local.ts +93 -0
- package/src/services/market-marker.ts +49 -0
- package/src/services/market-ops-content.ts +277 -0
- package/src/services/market-ops-plugin.ts +655 -0
- package/src/services/market-ops-publish.ts +1227 -0
- package/src/services/market-ops-repo.ts +916 -0
- package/src/services/market-ops-skill.ts +788 -0
- package/src/services/market-ops.ts +686 -0
- package/src/services/market-publish.ts +468 -0
- package/src/services/market-registry.ts +382 -0
- package/src/services/market-types.ts +268 -0
- package/src/services/marketplace-conflicts.ts +127 -0
- package/src/services/marketplace-refresh.ts +33 -3
- package/src/services/marketplace-sync.ts +253 -22
- package/src/services/plugin-dependencies.ts +17 -0
- package/src/services/plugin-manager.ts +1 -1
- package/src/services/plugin-mcp-config.ts +6 -9
- package/src/services/plugin-requires.ts +20 -8
- package/src/services/profile-absorb.ts +88 -3
- package/src/services/profile-adopt.ts +10 -1
- package/src/services/profiles.ts +64 -1
- package/src/services/resolver.ts +17 -5
- package/src/services/settings-manager.ts +2 -2
- package/src/services/skills-manager.ts +2 -2
- package/src/services/update-plan.ts +67 -8
- package/src/tui-runtime.tsx +142 -0
- package/src/tui.tsx +7 -124
- package/src/types/index.ts +7 -0
- package/src/ui/adapters/marketAdapter.ts +366 -0
- package/src/ui/adapters/pluginsAdapter.ts +45 -3
- package/src/ui/components/layout/ScreenLayout.tsx +43 -17
- package/src/ui/components/modals/ConfirmModal.tsx +145 -8
- package/src/ui/components/modals/HelpOverlay.tsx +10 -3
- package/src/ui/components/modals/ModalContainer.tsx +47 -5
- package/src/ui/components/primitives/ListCategoryRow.tsx +22 -2
- package/src/ui/components/primitives/ScopeBadge.tsx +28 -0
- package/src/ui/components/primitives/ScopeVersionLine.tsx +27 -0
- package/src/ui/components/primitives/SelectableRow.tsx +14 -1
- package/src/ui/components/primitives/ToneLine.tsx +25 -0
- package/src/ui/components/primitives/index.ts +5 -0
- package/src/ui/keymap.ts +21 -5
- package/src/ui/market/MarketApp.tsx +59 -0
- package/src/ui/market/MarketplaceScreen.tsx +280 -0
- package/src/ui/market/MarketplacesDetail.snowflake.tsx +161 -0
- package/src/ui/market/MarketplacesScreen.tsx +264 -0
- package/src/ui/market/PluginDetail.snowflake.tsx +61 -0
- package/src/ui/market/SkillDetail.snowflake.tsx +71 -0
- package/src/ui/market/marketKeymap.ts +243 -0
- package/src/ui/market/useMarketState.ts +694 -0
- package/src/ui/renderers/pluginRenderers.tsx +31 -3
- package/src/ui/renderers/pluginStatusText.ts +6 -0
- package/src/ui/screens/PluginsScreen.tsx +49 -5
- package/src/ui/screens/SkillsScreen.tsx +2 -2
- package/src/ui/state/AppContext.tsx +14 -2
- package/src/ui/state/types.ts +9 -2
- package/src/utils/config-dir.ts +29 -9
- package/src/utils/display-path.ts +33 -0
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-write.ts — the git primitives `magus market` builds on.
|
|
3
|
+
*
|
|
4
|
+
* Every call goes through {@link runBounded} with an argv, never a shell, and
|
|
5
|
+
* with an environment in which git cannot prompt: `GIT_TERMINAL_PROMPT=0` and
|
|
6
|
+
* `GCM_INTERACTIVE=never`. Under the TUI an invisible credential prompt would
|
|
7
|
+
* otherwise sit until the deadline and read as a timeout; with prompting off,
|
|
8
|
+
* a private remote with no credentials fails at once and is classified `auth`.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here runs `git status`: on a read path it rewrites the index stat
|
|
11
|
+
* cache, which turns a read into a write.
|
|
12
|
+
*
|
|
13
|
+
* Every function answers a {@link GitResult}. A failure carries a class, so a
|
|
14
|
+
* caller maps it to its own error code without parsing git's words again.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from "node:fs";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import { runBounded } from "../utils/spawn-bounded.js";
|
|
21
|
+
|
|
22
|
+
/** What went wrong, in the terms a caller acts on. */
|
|
23
|
+
export type GitFailure =
|
|
24
|
+
| "no-network"
|
|
25
|
+
| "auth"
|
|
26
|
+
| "denied"
|
|
27
|
+
| "rejected-non-ff"
|
|
28
|
+
| "rejected-protected"
|
|
29
|
+
| "timeout"
|
|
30
|
+
| "signing-timeout"
|
|
31
|
+
| "other";
|
|
32
|
+
|
|
33
|
+
export type GitResult<T> =
|
|
34
|
+
| { ok: true; value: T }
|
|
35
|
+
| {
|
|
36
|
+
ok: false;
|
|
37
|
+
failure: GitFailure;
|
|
38
|
+
/** git's own words (stderr, else stdout), trimmed. */
|
|
39
|
+
message: string;
|
|
40
|
+
code: number | null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Local reads: rev-parse, ls-tree, cat-file, hash-object, check-ignore. */
|
|
44
|
+
const LOCAL_TIMEOUT_MS = 15_000;
|
|
45
|
+
/** Anything that talks to a remote: clone, fetch, ls-remote, push. */
|
|
46
|
+
const NETWORK_TIMEOUT_MS = 120_000;
|
|
47
|
+
/** A commit may wait for a signing agent, which can need a human to approve. */
|
|
48
|
+
const COMMIT_TIMEOUT_MS = 120_000;
|
|
49
|
+
/** How many paths one hash-object or check-ignore call takes. */
|
|
50
|
+
const PATH_BATCH = 200;
|
|
51
|
+
|
|
52
|
+
/** The environment every git spawn runs with: the caller's, minus any prompt. */
|
|
53
|
+
export function gitEnv(
|
|
54
|
+
base: NodeJS.ProcessEnv = process.env,
|
|
55
|
+
): NodeJS.ProcessEnv {
|
|
56
|
+
return { ...base, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Classify a failed git run. Order matters: an ssh auth failure ends with
|
|
61
|
+
* "Could not read from remote repository", which alone would read as a network
|
|
62
|
+
* problem, and a protected-branch refusal also says "declined".
|
|
63
|
+
*/
|
|
64
|
+
export function classifyGitFailure(
|
|
65
|
+
text: string,
|
|
66
|
+
opts: { timedOut: boolean; signing?: boolean },
|
|
67
|
+
): GitFailure {
|
|
68
|
+
if (opts.timedOut) return opts.signing ? "signing-timeout" : "timeout";
|
|
69
|
+
if (
|
|
70
|
+
opts.signing &&
|
|
71
|
+
/failed to fill whole buffer|gpg failed to sign|signing failed|error: (gpg|ssh-keygen)/i.test(
|
|
72
|
+
text,
|
|
73
|
+
)
|
|
74
|
+
) {
|
|
75
|
+
return "signing-timeout";
|
|
76
|
+
}
|
|
77
|
+
if (/protected branch|GH006|GH013|branch is protected/i.test(text)) {
|
|
78
|
+
return "rejected-protected";
|
|
79
|
+
}
|
|
80
|
+
if (
|
|
81
|
+
/non-fast-forward|\(fetch first\)|Updates were rejected because the (tip|remote)/i.test(
|
|
82
|
+
text,
|
|
83
|
+
)
|
|
84
|
+
) {
|
|
85
|
+
return "rejected-non-ff";
|
|
86
|
+
}
|
|
87
|
+
if (
|
|
88
|
+
/terminal prompts disabled|could not read (Username|Password)|Authentication failed|Invalid username or password|HTTP Basic: Access denied|Permission denied \(publickey|Host key verification failed/i.test(
|
|
89
|
+
text,
|
|
90
|
+
)
|
|
91
|
+
) {
|
|
92
|
+
return "auth";
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
/returned error: 403|Permission to \S+ denied|denied to \S+|write access to repository not granted|Repository not found|pre-receive hook declined/i.test(
|
|
96
|
+
text,
|
|
97
|
+
)
|
|
98
|
+
) {
|
|
99
|
+
return "denied";
|
|
100
|
+
}
|
|
101
|
+
if (
|
|
102
|
+
/Could not resolve host|Failed to connect|Connection (refused|timed out|reset)|Operation timed out|Network is unreachable|Could not read from remote repository|unable to access/i.test(
|
|
103
|
+
text,
|
|
104
|
+
)
|
|
105
|
+
) {
|
|
106
|
+
return "no-network";
|
|
107
|
+
}
|
|
108
|
+
return "other";
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
interface RunOpts {
|
|
112
|
+
cwd?: string;
|
|
113
|
+
timeoutMs?: number;
|
|
114
|
+
signal?: AbortSignal;
|
|
115
|
+
/** The command may sign, so a timeout is a signing timeout. */
|
|
116
|
+
signing?: boolean;
|
|
117
|
+
/** Extra exit codes that are answers, not failures (check-ignore's 1). */
|
|
118
|
+
okCodes?: readonly number[];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Run `git <args>`; ok on exit 0 (or an `okCodes` code), classified otherwise. */
|
|
122
|
+
async function git(
|
|
123
|
+
args: string[],
|
|
124
|
+
opts: RunOpts = {},
|
|
125
|
+
): Promise<GitResult<{ stdout: string; code: number }>> {
|
|
126
|
+
const r = await runBounded("git", args, {
|
|
127
|
+
cwd: opts.cwd,
|
|
128
|
+
env: gitEnv(),
|
|
129
|
+
timeoutMs: opts.timeoutMs ?? LOCAL_TIMEOUT_MS,
|
|
130
|
+
signal: opts.signal,
|
|
131
|
+
});
|
|
132
|
+
if (r.spawnError) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
failure: "other",
|
|
136
|
+
message: `git could not be started: ${r.spawnError.message}`,
|
|
137
|
+
code: null,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (r.aborted) {
|
|
141
|
+
return { ok: false, failure: "other", message: "aborted", code: r.code };
|
|
142
|
+
}
|
|
143
|
+
const code = r.code ?? -1;
|
|
144
|
+
if (!r.timedOut && (code === 0 || opts.okCodes?.includes(code))) {
|
|
145
|
+
return { ok: true, value: { stdout: r.stdout, code } };
|
|
146
|
+
}
|
|
147
|
+
const message =
|
|
148
|
+
r.stderr.trim() ||
|
|
149
|
+
r.stdout.trim() ||
|
|
150
|
+
(r.timedOut
|
|
151
|
+
? `git ${args[0]} did not finish in time`
|
|
152
|
+
: `git ${args[0]} failed`);
|
|
153
|
+
return {
|
|
154
|
+
ok: false,
|
|
155
|
+
failure: classifyGitFailure(`${r.stderr}\n${r.stdout}`, {
|
|
156
|
+
timedOut: r.timedOut,
|
|
157
|
+
signing: opts.signing,
|
|
158
|
+
}),
|
|
159
|
+
message,
|
|
160
|
+
code: r.code,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function map<T, U>(r: GitResult<T>, f: (v: T) => U): GitResult<U> {
|
|
165
|
+
return r.ok ? { ok: true, value: f(r.value) } : r;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function chunks<T>(items: readonly T[], size: number): T[][] {
|
|
169
|
+
const out: T[][] = [];
|
|
170
|
+
for (let i = 0; i < items.length; i += size)
|
|
171
|
+
out.push(items.slice(i, i + size));
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ─── Read-only ──────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
export interface RemoteHead {
|
|
178
|
+
/** The default branch, e.g. "main"; null for an empty repository. */
|
|
179
|
+
branch: string | null;
|
|
180
|
+
/** The default branch's commit; null for an empty repository. */
|
|
181
|
+
commit: string | null;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The remote's default branch and its commit, without a clone:
|
|
186
|
+
* `git ls-remote --symref <url> HEAD`.
|
|
187
|
+
*/
|
|
188
|
+
export async function lsRemoteHead(
|
|
189
|
+
url: string,
|
|
190
|
+
signal?: AbortSignal,
|
|
191
|
+
): Promise<GitResult<RemoteHead>> {
|
|
192
|
+
const r = await git(["ls-remote", "--symref", "--", url, "HEAD"], {
|
|
193
|
+
timeoutMs: NETWORK_TIMEOUT_MS,
|
|
194
|
+
signal,
|
|
195
|
+
});
|
|
196
|
+
return map(r, ({ stdout }) => parseLsRemoteHead(stdout));
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Parse `ls-remote --symref <url> HEAD` output. Exported for tests. */
|
|
200
|
+
export function parseLsRemoteHead(stdout: string): RemoteHead {
|
|
201
|
+
let branch: string | null = null;
|
|
202
|
+
let commit: string | null = null;
|
|
203
|
+
for (const line of stdout.split("\n")) {
|
|
204
|
+
const sym = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/.exec(line.trim());
|
|
205
|
+
if (sym) {
|
|
206
|
+
branch = sym[1] ?? null;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
const head = /^([0-9a-f]{40,64})\s+HEAD$/.exec(line.trim());
|
|
210
|
+
if (head) commit = head[1] ?? null;
|
|
211
|
+
}
|
|
212
|
+
return { branch, commit };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** `git rev-parse --verify <rev>^{commit}` — the commit a revision names. */
|
|
216
|
+
export async function revParse(
|
|
217
|
+
repo: string,
|
|
218
|
+
rev: string,
|
|
219
|
+
): Promise<GitResult<string>> {
|
|
220
|
+
const r = await git(
|
|
221
|
+
["rev-parse", "--verify", "--end-of-options", `${rev}^{commit}`],
|
|
222
|
+
{
|
|
223
|
+
cwd: repo,
|
|
224
|
+
},
|
|
225
|
+
);
|
|
226
|
+
return map(r, ({ stdout }) => stdout.trim());
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export interface TreeEntry {
|
|
230
|
+
/** Octal file mode: 100644, 100755, 120000 (symlink), 160000 (submodule). */
|
|
231
|
+
mode: string;
|
|
232
|
+
type: "blob" | "tree" | "commit";
|
|
233
|
+
oid: string;
|
|
234
|
+
/** Path from the repository root. */
|
|
235
|
+
path: string;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Every blob under `dir` (repository-root relative; "" for the whole tree) at
|
|
240
|
+
* `treeish`: `git ls-tree -r -z --full-tree <treeish> -- <dir>`.
|
|
241
|
+
*/
|
|
242
|
+
export async function lsTree(
|
|
243
|
+
repo: string,
|
|
244
|
+
treeish: string,
|
|
245
|
+
dir = "",
|
|
246
|
+
): Promise<GitResult<TreeEntry[]>> {
|
|
247
|
+
const args = ["ls-tree", "-r", "-z", "--full-tree", treeish];
|
|
248
|
+
if (dir) args.push("--", dir);
|
|
249
|
+
const r = await git(args, { cwd: repo });
|
|
250
|
+
return map(r, ({ stdout }) => parseLsTree(stdout));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Parse `ls-tree -z` output. Exported for tests. */
|
|
254
|
+
export function parseLsTree(stdout: string): TreeEntry[] {
|
|
255
|
+
const out: TreeEntry[] = [];
|
|
256
|
+
for (const record of stdout.split("\0")) {
|
|
257
|
+
if (!record) continue;
|
|
258
|
+
const tab = record.indexOf("\t");
|
|
259
|
+
if (tab < 0) continue;
|
|
260
|
+
const [mode, type, oid] = record.slice(0, tab).split(" ");
|
|
261
|
+
if (!mode || !type || !oid) continue;
|
|
262
|
+
out.push({
|
|
263
|
+
mode,
|
|
264
|
+
type: type as TreeEntry["type"],
|
|
265
|
+
oid,
|
|
266
|
+
path: record.slice(tab + 1),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* The content of a blob, by object id or `<rev>:<path>`, as text:
|
|
274
|
+
* `git cat-file blob <object>`. For manifests and other text files.
|
|
275
|
+
*/
|
|
276
|
+
export async function catFile(
|
|
277
|
+
repo: string,
|
|
278
|
+
object: string,
|
|
279
|
+
): Promise<GitResult<string>> {
|
|
280
|
+
const r = await git(["cat-file", "blob", object], { cwd: repo });
|
|
281
|
+
return map(r, ({ stdout }) => stdout);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The blob id of each file's bytes, without writing it to any object store:
|
|
286
|
+
* `git hash-object --no-filters -- <files>` (no `-w`). Paths are absolute or
|
|
287
|
+
* relative to `cwd`; the result is in the same order. No filter or line-ending
|
|
288
|
+
* rule of the repository at `cwd` applies, so the same bytes hash the same in
|
|
289
|
+
* a project and in a marketplace clone.
|
|
290
|
+
*/
|
|
291
|
+
export async function hashObject(
|
|
292
|
+
cwd: string,
|
|
293
|
+
files: readonly string[],
|
|
294
|
+
): Promise<GitResult<string[]>> {
|
|
295
|
+
const out: string[] = [];
|
|
296
|
+
for (const batch of chunks(files, PATH_BATCH)) {
|
|
297
|
+
const r = await git(["hash-object", "--no-filters", "--", ...batch], {
|
|
298
|
+
cwd,
|
|
299
|
+
});
|
|
300
|
+
if (!r.ok) return r;
|
|
301
|
+
const ids = r.value.stdout.split("\n").filter(Boolean);
|
|
302
|
+
if (ids.length !== batch.length) {
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
failure: "other",
|
|
306
|
+
message: `git hash-object returned ${ids.length} ids for ${batch.length} files`,
|
|
307
|
+
code: r.value.code,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
out.push(...ids);
|
|
311
|
+
}
|
|
312
|
+
return { ok: true, value: out };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Which of `paths` the repository at `repo` ignores: `git check-ignore`.
|
|
317
|
+
* Exit 1 means none is ignored, which is an answer, not a failure. Tracked
|
|
318
|
+
* files are never reported, as git itself does not ignore them.
|
|
319
|
+
*
|
|
320
|
+
* Paths go on the argv and come back one per line: `-z` is accepted only with
|
|
321
|
+
* `--stdin`, which this runner cannot feed. `core.quotePath=false` keeps
|
|
322
|
+
* non-ASCII names as they are; a name holding a quote, backslash or control
|
|
323
|
+
* character still comes back C-quoted and is unquoted here.
|
|
324
|
+
*/
|
|
325
|
+
export async function checkIgnore(
|
|
326
|
+
repo: string,
|
|
327
|
+
paths: readonly string[],
|
|
328
|
+
): Promise<GitResult<Set<string>>> {
|
|
329
|
+
const ignored = new Set<string>();
|
|
330
|
+
for (const batch of chunks(paths, PATH_BATCH)) {
|
|
331
|
+
const r = await git(
|
|
332
|
+
["-c", "core.quotePath=false", "check-ignore", "--", ...batch],
|
|
333
|
+
{ cwd: repo, okCodes: [1] },
|
|
334
|
+
);
|
|
335
|
+
if (!r.ok) return r;
|
|
336
|
+
for (const line of r.value.stdout.split("\n")) {
|
|
337
|
+
if (line) ignored.add(unquoteCPath(line));
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
return { ok: true, value: ignored };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const C_ESCAPES: Record<string, string> = {
|
|
344
|
+
a: "\x07",
|
|
345
|
+
b: "\b",
|
|
346
|
+
f: "\f",
|
|
347
|
+
n: "\n",
|
|
348
|
+
r: "\r",
|
|
349
|
+
t: "\t",
|
|
350
|
+
v: "\v",
|
|
351
|
+
'"': '"',
|
|
352
|
+
"\\": "\\",
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Undo git's C-style quoting of a path (`"a\tb"`, `"caf\303\251"`); a path
|
|
357
|
+
* that is not quoted is returned as it is. Exported for tests.
|
|
358
|
+
*/
|
|
359
|
+
export function unquoteCPath(text: string): string {
|
|
360
|
+
if (text.length < 2 || !text.startsWith('"') || !text.endsWith('"')) {
|
|
361
|
+
return text;
|
|
362
|
+
}
|
|
363
|
+
const body = text.slice(1, -1);
|
|
364
|
+
const bytes: number[] = [];
|
|
365
|
+
const encoder = new TextEncoder();
|
|
366
|
+
for (let i = 0; i < body.length; i++) {
|
|
367
|
+
const ch = body[i] as string;
|
|
368
|
+
if (ch !== "\\") {
|
|
369
|
+
bytes.push(...encoder.encode(ch));
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
const next = body[i + 1] ?? "";
|
|
373
|
+
const octal = /^[0-7]{1,3}/.exec(body.slice(i + 1, i + 4));
|
|
374
|
+
if (octal) {
|
|
375
|
+
bytes.push(Number.parseInt(octal[0], 8));
|
|
376
|
+
i += octal[0].length;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
bytes.push(...encoder.encode(C_ESCAPES[next] ?? next));
|
|
380
|
+
i += 1;
|
|
381
|
+
}
|
|
382
|
+
return new TextDecoder().decode(new Uint8Array(bytes));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The remote's URL as configured, before any `url.<base>.insteadOf` rewrite:
|
|
387
|
+
* `git config --get remote.<name>.url`. Null when the remote is not set.
|
|
388
|
+
*/
|
|
389
|
+
export async function remoteUrl(
|
|
390
|
+
repo: string,
|
|
391
|
+
remote = "origin",
|
|
392
|
+
): Promise<GitResult<string | null>> {
|
|
393
|
+
const r = await git(["config", "--get", `remote.${remote}.url`], {
|
|
394
|
+
cwd: repo,
|
|
395
|
+
okCodes: [1],
|
|
396
|
+
});
|
|
397
|
+
return map(r, ({ stdout, code }) =>
|
|
398
|
+
code === 1 ? null : stdout.trim() || null,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ─── Writing ────────────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
/** `git clone <url> <dest>` with full history, which a push needs. */
|
|
405
|
+
export async function cloneFull(
|
|
406
|
+
url: string,
|
|
407
|
+
dest: string,
|
|
408
|
+
signal?: AbortSignal,
|
|
409
|
+
): Promise<GitResult<void>> {
|
|
410
|
+
const r = await git(["clone", "--", url, dest], {
|
|
411
|
+
timeoutMs: NETWORK_TIMEOUT_MS,
|
|
412
|
+
signal,
|
|
413
|
+
});
|
|
414
|
+
return map(r, () => undefined);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export interface TempClone {
|
|
418
|
+
dir: string;
|
|
419
|
+
/** Remove the clone. Safe to call more than once. */
|
|
420
|
+
dispose(): void;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* A shallow clone with no checkout in a new directory under the OS temp dir:
|
|
425
|
+
* `git clone --depth 1 --no-checkout`. Read it with {@link catFile}
|
|
426
|
+
* (`HEAD:<path>`), then `dispose()` it in a `finally`. On failure the
|
|
427
|
+
* directory is already gone.
|
|
428
|
+
*/
|
|
429
|
+
export async function cloneShallowTemp(
|
|
430
|
+
url: string,
|
|
431
|
+
signal?: AbortSignal,
|
|
432
|
+
): Promise<GitResult<TempClone>> {
|
|
433
|
+
const parent = fs.mkdtempSync(path.join(os.tmpdir(), "magus-market-"));
|
|
434
|
+
const dispose = () => fs.rmSync(parent, { recursive: true, force: true });
|
|
435
|
+
const dir = path.join(parent, "repo");
|
|
436
|
+
const r = await git(
|
|
437
|
+
["clone", "--depth", "1", "--no-checkout", "--", url, dir],
|
|
438
|
+
{
|
|
439
|
+
timeoutMs: NETWORK_TIMEOUT_MS,
|
|
440
|
+
signal,
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
if (!r.ok) {
|
|
444
|
+
dispose();
|
|
445
|
+
return r;
|
|
446
|
+
}
|
|
447
|
+
return { ok: true, value: { dir, dispose } };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** `git fetch --prune <remote>`. */
|
|
451
|
+
export async function fetch(
|
|
452
|
+
repo: string,
|
|
453
|
+
remote = "origin",
|
|
454
|
+
signal?: AbortSignal,
|
|
455
|
+
): Promise<GitResult<void>> {
|
|
456
|
+
const r = await git(["fetch", "--prune", remote], {
|
|
457
|
+
cwd: repo,
|
|
458
|
+
timeoutMs: NETWORK_TIMEOUT_MS,
|
|
459
|
+
signal,
|
|
460
|
+
});
|
|
461
|
+
return map(r, () => undefined);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** `git reset --hard <remote>/<branch>`. */
|
|
465
|
+
export async function resetToRemote(
|
|
466
|
+
repo: string,
|
|
467
|
+
branch: string,
|
|
468
|
+
remote = "origin",
|
|
469
|
+
): Promise<GitResult<void>> {
|
|
470
|
+
const r = await git(["reset", "--hard", `${remote}/${branch}`], {
|
|
471
|
+
cwd: repo,
|
|
472
|
+
});
|
|
473
|
+
return map(r, () => undefined);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/** `git clean -ffdx`: every untracked and ignored file, nested repos included. */
|
|
477
|
+
export async function cleanAll(repo: string): Promise<GitResult<void>> {
|
|
478
|
+
const r = await git(["clean", "-ffdx"], { cwd: repo });
|
|
479
|
+
return map(r, () => undefined);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/**
|
|
483
|
+
* Stage exactly the given paths (additions, changes and deletions under them):
|
|
484
|
+
* `git add -A -- <paths>`. There is deliberately no "add everything".
|
|
485
|
+
*/
|
|
486
|
+
export async function addPaths(
|
|
487
|
+
repo: string,
|
|
488
|
+
paths: readonly string[],
|
|
489
|
+
): Promise<GitResult<void>> {
|
|
490
|
+
if (paths.length === 0) {
|
|
491
|
+
return {
|
|
492
|
+
ok: false,
|
|
493
|
+
failure: "other",
|
|
494
|
+
message: "no paths to stage",
|
|
495
|
+
code: null,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
for (const batch of chunks(paths, PATH_BATCH)) {
|
|
499
|
+
const r = await git(["add", "-A", "--", ...batch], { cwd: repo });
|
|
500
|
+
if (!r.ok) return r;
|
|
501
|
+
}
|
|
502
|
+
return { ok: true, value: undefined };
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
export interface StagedChange {
|
|
506
|
+
/** git's status letter: A, M, D, T, … */
|
|
507
|
+
status: string;
|
|
508
|
+
path: string;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* What the index holds against HEAD: `git diff --cached --name-status
|
|
513
|
+
* --no-renames -z`. Renames are reported as a delete plus an add, so every
|
|
514
|
+
* path appears exactly once.
|
|
515
|
+
*/
|
|
516
|
+
export async function stagedChanges(
|
|
517
|
+
repo: string,
|
|
518
|
+
): Promise<GitResult<StagedChange[]>> {
|
|
519
|
+
const r = await git(
|
|
520
|
+
["diff", "--cached", "--name-status", "--no-renames", "-z"],
|
|
521
|
+
{
|
|
522
|
+
cwd: repo,
|
|
523
|
+
},
|
|
524
|
+
);
|
|
525
|
+
return map(r, ({ stdout }) => parseNameStatus(stdout));
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/** Parse `--name-status -z` output (no renames). Exported for tests. */
|
|
529
|
+
export function parseNameStatus(stdout: string): StagedChange[] {
|
|
530
|
+
const parts = stdout.split("\0");
|
|
531
|
+
const out: StagedChange[] = [];
|
|
532
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
533
|
+
const status = parts[i];
|
|
534
|
+
const file = parts[i + 1];
|
|
535
|
+
if (!status || file === undefined || file === "") continue;
|
|
536
|
+
out.push({ status, path: file });
|
|
537
|
+
}
|
|
538
|
+
return out;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* `git commit -m <message>`, answering the new commit. Bounded at two minutes
|
|
543
|
+
* because a signing agent may wait for a human; a timeout there is
|
|
544
|
+
* `signing-timeout`. Signing is never disabled.
|
|
545
|
+
*/
|
|
546
|
+
export async function commit(
|
|
547
|
+
repo: string,
|
|
548
|
+
message: string,
|
|
549
|
+
): Promise<GitResult<string>> {
|
|
550
|
+
const r = await git(["commit", "--quiet", "-m", message], {
|
|
551
|
+
cwd: repo,
|
|
552
|
+
timeoutMs: COMMIT_TIMEOUT_MS,
|
|
553
|
+
signing: true,
|
|
554
|
+
});
|
|
555
|
+
if (!r.ok) return r;
|
|
556
|
+
return revParse(repo, "HEAD");
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** `git push [-u] <remote> <branch>`. */
|
|
560
|
+
export async function push(
|
|
561
|
+
repo: string,
|
|
562
|
+
branch: string,
|
|
563
|
+
opts: { remote?: string; setUpstream?: boolean; signal?: AbortSignal } = {},
|
|
564
|
+
): Promise<GitResult<void>> {
|
|
565
|
+
const args = ["push"];
|
|
566
|
+
if (opts.setUpstream) args.push("-u");
|
|
567
|
+
args.push(opts.remote ?? "origin", branch);
|
|
568
|
+
const r = await git(args, {
|
|
569
|
+
cwd: repo,
|
|
570
|
+
timeoutMs: NETWORK_TIMEOUT_MS,
|
|
571
|
+
signal: opts.signal,
|
|
572
|
+
});
|
|
573
|
+
return map(r, () => undefined);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Push a local ref to a named branch of a remote, which may be a URL:
|
|
578
|
+
* `git push <remote> <localRef>:refs/heads/<branch>`. For a pull-request
|
|
579
|
+
* branch, on the origin or on a fork.
|
|
580
|
+
*/
|
|
581
|
+
export async function pushRef(
|
|
582
|
+
repo: string,
|
|
583
|
+
remote: string,
|
|
584
|
+
localRef: string,
|
|
585
|
+
branch: string,
|
|
586
|
+
signal?: AbortSignal,
|
|
587
|
+
): Promise<GitResult<void>> {
|
|
588
|
+
const r = await git(
|
|
589
|
+
["push", "--", remote, `${localRef}:refs/heads/${branch}`],
|
|
590
|
+
{
|
|
591
|
+
cwd: repo,
|
|
592
|
+
timeoutMs: NETWORK_TIMEOUT_MS,
|
|
593
|
+
signal,
|
|
594
|
+
},
|
|
595
|
+
);
|
|
596
|
+
return map(r, () => undefined);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Make `dir` a new repository on `branch` with `url` as its origin:
|
|
601
|
+
* `git init -b <branch>`, then `git remote add origin <url>`.
|
|
602
|
+
*/
|
|
603
|
+
export async function initWithRemote(
|
|
604
|
+
dir: string,
|
|
605
|
+
url: string,
|
|
606
|
+
branch = "main",
|
|
607
|
+
): Promise<GitResult<void>> {
|
|
608
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
609
|
+
const init = await git(["init", "--quiet", "-b", branch], { cwd: dir });
|
|
610
|
+
if (!init.ok) return init;
|
|
611
|
+
const remote = await git(["remote", "add", "--", "origin", url], {
|
|
612
|
+
cwd: dir,
|
|
613
|
+
});
|
|
614
|
+
return map(remote, () => undefined);
|
|
615
|
+
}
|