@1agh/maude 0.34.0 → 0.36.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/apps/studio/client/app.jsx +90 -3
- package/apps/studio/client/panels/GitPanel.jsx +6 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +199 -68
- package/apps/studio/client/styles/3-shell-maude.css +22 -0
- package/apps/studio/dist/client.bundle.js +21 -53108
- package/apps/studio/dist/comment-mount.js +1 -2048
- package/apps/studio/dist/styles.css +1 -12220
- package/apps/studio/git/endpoints.ts +17 -0
- package/apps/studio/git/service.ts +483 -40
- package/apps/studio/github/endpoints.ts +28 -4
- package/apps/studio/github/identity-cache.ts +138 -0
- package/apps/studio/github/service.ts +40 -23
- package/apps/studio/http.ts +12 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +2 -0
- package/apps/studio/test/git-branches.test.ts +141 -3
- package/apps/studio/test/github-api.test.ts +58 -1
- package/apps/studio/test/identity-cache.test.ts +83 -0
- package/apps/studio/test/remote-ahead-behind-cache.test.ts +89 -0
- package/apps/studio/whats-new.json +18 -0
- package/package.json +15 -10
- package/plugins/flow/.claude-plugin/config.schema.json +4 -4
|
@@ -32,6 +32,56 @@ import git from 'isomorphic-git';
|
|
|
32
32
|
import http from 'isomorphic-git/http/node';
|
|
33
33
|
|
|
34
34
|
const USE_SYSTEM_GIT = /^(1|true|on|yes)$/i.test(process.env.MAUDE_USE_SYSTEM_GIT ?? '');
|
|
35
|
+
// DDR-133 (DDR-107 end-state): auto-prefer a detected system `git` for the NETWORK
|
|
36
|
+
// paths (gitFetchRemote / remoteAheadBehind — native fetch is instant + uses the
|
|
37
|
+
// user's own credential helper / SSH agent) AND the READ paths (status / list-
|
|
38
|
+
// branches / log / diff / show / unpushed / current-branch). The pure-JS iso engine
|
|
39
|
+
// is genuinely slow on a real-world repo — and worse, `git.statusMatrix` /
|
|
40
|
+
// `git.listBranches` can throw on some trees ("No obj for …") and wedge the 10 s
|
|
41
|
+
// Bun.serve idle window, which is exactly what made the switcher's branch list
|
|
42
|
+
// vanish + the dropdown hang. System git's `status --porcelain` / `for-each-ref`
|
|
43
|
+
// are instant and don't have that failure mode. iso remains the fallback when no
|
|
44
|
+
// `git` is on PATH (the zero-setup promise) and still backs the WRITE paths
|
|
45
|
+
// (commit / checkout / branch / fold / push / pull) unless forced. MAUDE_USE_SYSTEM_GIT=1
|
|
46
|
+
// forces system git everywhere; MAUDE_NO_SYSTEM_GIT=1 pins everything to iso
|
|
47
|
+
// (escape hatch / deterministic test engine).
|
|
48
|
+
// Read live (not a module-load const) so a test can scope MAUDE_NO_SYSTEM_GIT around
|
|
49
|
+
// a single assertion to deterministically exercise the iso engine without leaking.
|
|
50
|
+
const noSystemGit = (): boolean => /^(1|true|on|yes)$/i.test(process.env.MAUDE_NO_SYSTEM_GIT ?? '');
|
|
51
|
+
let systemGitProbe: Promise<boolean> | undefined;
|
|
52
|
+
/** True when a usable `git` binary is on PATH. Memoized per process — the sidecar
|
|
53
|
+
* respawns per repo (DDR-132), so one `git --version` probe per process is correct
|
|
54
|
+
* and cheap. The probe NEVER relaxes the DDR-131 transport gate: callers classify
|
|
55
|
+
* the remote URL first; this only picks which engine runs an already-vetted op. */
|
|
56
|
+
function systemGitAvailable(): Promise<boolean> {
|
|
57
|
+
if (USE_SYSTEM_GIT) return Promise.resolve(true);
|
|
58
|
+
if (noSystemGit()) return Promise.resolve(false);
|
|
59
|
+
if (!systemGitProbe) {
|
|
60
|
+
systemGitProbe = runGit(process.cwd(), ['--version'], undefined, 4000)
|
|
61
|
+
.then((r) => r.code === 0 && /git version/i.test(r.stdout))
|
|
62
|
+
.catch(() => false);
|
|
63
|
+
}
|
|
64
|
+
return systemGitProbe;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const TIMED_OUT = Symbol('maude-git-timeout');
|
|
68
|
+
/** Race `p` against a timeout. Returns `TIMED_OUT` if the deadline wins; a late
|
|
69
|
+
* rejection from the losing promise is swallowed so it never surfaces as an
|
|
70
|
+
* unhandledRejection. */
|
|
71
|
+
function withTimeout<T>(p: Promise<T>, ms: number): Promise<T | typeof TIMED_OUT> {
|
|
72
|
+
p.catch(() => {});
|
|
73
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
74
|
+
const t = new Promise<typeof TIMED_OUT>((res) => {
|
|
75
|
+
timer = setTimeout(() => res(TIMED_OUT), ms);
|
|
76
|
+
});
|
|
77
|
+
return Promise.race([p, t]).finally(() => clearTimeout(timer));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Bounds for the two unbounded network paths (DDR-133). A stalled remote must
|
|
81
|
+
* surface a fast, friendly result instead of hanging the popup / the unattended
|
|
82
|
+
* status poll. */
|
|
83
|
+
const FETCH_TIMEOUT_MS = 12_000;
|
|
84
|
+
const PROBE_TIMEOUT_MS = 8_000;
|
|
35
85
|
|
|
36
86
|
/** One changed file as the Changes panel renders it. `status` maps to the M/A/D/U
|
|
37
87
|
* badge (DDR-075 status hues): modified→M, added→A, deleted→D, untracked→U. */
|
|
@@ -214,13 +264,21 @@ interface RunResult {
|
|
|
214
264
|
code: number;
|
|
215
265
|
stdout: string;
|
|
216
266
|
stderr: string;
|
|
267
|
+
/** True when `timeoutMs` elapsed and the child was killed (DDR-133). */
|
|
268
|
+
timedOut?: boolean;
|
|
217
269
|
}
|
|
218
270
|
|
|
219
271
|
/** Run `git <args>` in `dir`. `tokenRemote` (when given) replaces the `origin`
|
|
220
272
|
* URL's userinfo with the token for this one invocation via `-c` config so the
|
|
221
273
|
* PAT never lands in the on-disk remote URL or the process title's argv beyond
|
|
222
|
-
* the ephemeral child.
|
|
223
|
-
|
|
274
|
+
* the ephemeral child. `timeoutMs` (DDR-133) hard-kills a stalled child so the
|
|
275
|
+
* network paths can never hang the UI / the unattended poll. */
|
|
276
|
+
function runGit(
|
|
277
|
+
dir: string,
|
|
278
|
+
args: string[],
|
|
279
|
+
env?: Record<string, string>,
|
|
280
|
+
timeoutMs?: number
|
|
281
|
+
): Promise<RunResult> {
|
|
224
282
|
return new Promise((resolveRun) => {
|
|
225
283
|
const child = spawn('git', args, {
|
|
226
284
|
cwd: dir,
|
|
@@ -229,14 +287,28 @@ function runGit(dir: string, args: string[], env?: Record<string, string>): Prom
|
|
|
229
287
|
});
|
|
230
288
|
let stdout = '';
|
|
231
289
|
let stderr = '';
|
|
290
|
+
let timedOut = false;
|
|
291
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
292
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
293
|
+
timer = setTimeout(() => {
|
|
294
|
+
timedOut = true;
|
|
295
|
+
child.kill('SIGKILL');
|
|
296
|
+
}, timeoutMs);
|
|
297
|
+
}
|
|
232
298
|
child.stdout.on('data', (d) => {
|
|
233
299
|
stdout += d.toString();
|
|
234
300
|
});
|
|
235
301
|
child.stderr.on('data', (d) => {
|
|
236
302
|
stderr += d.toString();
|
|
237
303
|
});
|
|
238
|
-
child.on('error', (e) =>
|
|
239
|
-
|
|
304
|
+
child.on('error', (e) => {
|
|
305
|
+
if (timer) clearTimeout(timer);
|
|
306
|
+
resolveRun({ code: 127, stdout, stderr: String(e), timedOut });
|
|
307
|
+
});
|
|
308
|
+
child.on('close', (code) => {
|
|
309
|
+
if (timer) clearTimeout(timer);
|
|
310
|
+
resolveRun({ code: timedOut ? 124 : (code ?? 1), stdout, stderr, timedOut });
|
|
311
|
+
});
|
|
240
312
|
});
|
|
241
313
|
}
|
|
242
314
|
|
|
@@ -247,7 +319,9 @@ export async function gitStatus(dir: string, opts: GitStatusOpts = {}): Promise<
|
|
|
247
319
|
return { repo: false, branch: null, files: [], clean: true, unpushed: 0 };
|
|
248
320
|
}
|
|
249
321
|
const prefix = normPrefix(opts.designPrefix);
|
|
250
|
-
const result =
|
|
322
|
+
const result = (await systemGitAvailable())
|
|
323
|
+
? await statusSystem(dir, prefix)
|
|
324
|
+
: await statusIso(dir, prefix);
|
|
251
325
|
|
|
252
326
|
// Local "saved but not published" count — no network (uses the cached
|
|
253
327
|
// remote-tracking ref). Lets the panel offer Publish even on a clean tree.
|
|
@@ -434,28 +508,171 @@ export async function gitDiscard(
|
|
|
434
508
|
// `.git/HEAD` watcher already turns into a Yjs flush + reload prompt (DDR-051) —
|
|
435
509
|
// this module does NOT duplicate that.
|
|
436
510
|
|
|
511
|
+
/** The default remote a managed Maude project tracks (DDR-111 clone). */
|
|
512
|
+
const DEFAULT_REMOTE = 'origin';
|
|
513
|
+
|
|
514
|
+
/** How we'll transport-fetch a remote, derived from its URL:
|
|
515
|
+
* - `http` → isomorphic-git (HTTP-only) OR system git with a token header.
|
|
516
|
+
* - `ssh` → system git only (user's own ssh-agent creds), NEVER a token.
|
|
517
|
+
* Covers `ssh://` / `git://` / the scp-like `git@github.com:org/repo` form.
|
|
518
|
+
* - `none` → no remote configured; nothing to fetch (benign).
|
|
519
|
+
* - `unsafe` → REFUSE — never hand to the git binary. The `ext::` / `fd::` /
|
|
520
|
+
* `transport::` helpers make `git fetch` run an ARBITRARY SHELL COMMAND from the
|
|
521
|
+
* config URL. A poisoned `.git/config` (which rides a folder/clone and no
|
|
522
|
+
* file-review sees) would otherwise be RCE the moment the unattended status
|
|
523
|
+
* poll fires. Also refuses `file://` / local-path (local-read vector) and any
|
|
524
|
+
* unknown scheme. See DDR-131 hardening + the adversarial review of 75a2f0d. */
|
|
525
|
+
type RemoteTransport = 'http' | 'ssh' | 'none' | 'unsafe';
|
|
526
|
+
|
|
527
|
+
function classifyRemoteUrl(url: string): RemoteTransport {
|
|
528
|
+
const u = (url || '').trim();
|
|
529
|
+
if (!u) return 'none';
|
|
530
|
+
// Transport helpers embed `::` (ext::, fd::, transport::) → arbitrary command. Reject first.
|
|
531
|
+
if (u.includes('::')) return 'unsafe';
|
|
532
|
+
if (/^https?:\/\//i.test(u)) return 'http';
|
|
533
|
+
if (/^(?:ssh|git):\/\//i.test(u)) return 'ssh';
|
|
534
|
+
// scp-like `user@host:path` (no scheme) — the common `git@github.com:org/repo` form.
|
|
535
|
+
if (/^[\w.+-]+@[\w.-]+:[^/]/.test(u)) return 'ssh';
|
|
536
|
+
// file://, bare local paths, and anything else: REFUSE. A managed project tracks
|
|
537
|
+
// a github.com http/ssh remote; a local/file transport is both unusual and a
|
|
538
|
+
// local-read vector, so we don't hand it to the git binary at all.
|
|
539
|
+
return 'unsafe';
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/** Read a remote's configured URL (empty string when missing). */
|
|
543
|
+
async function readRemoteUrl(dir: string, remote: string): Promise<string> {
|
|
544
|
+
return (await git.getConfig({ fs, dir, path: `remote.${remote}.url` }).catch(() => null)) || '';
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** The GitHub PAT (keychain) may ONLY be attached to a request bound for GitHub —
|
|
548
|
+
* never to an arbitrary HTTPS host an attacker put in `remote.origin.url` (PAT
|
|
549
|
+
* exfil / SSRF). HTTP(S) urls only; ssh carries no token regardless. */
|
|
550
|
+
function isTrustedTokenHost(url: string): boolean {
|
|
551
|
+
try {
|
|
552
|
+
const h = new URL(url).hostname.toLowerCase();
|
|
553
|
+
return h === 'github.com' || h.endsWith('.github.com');
|
|
554
|
+
} catch {
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** Defense-in-depth for any `runGit` that resolves a config remote URL: disable the
|
|
560
|
+
* command-EXECUTING transports at the git layer too (`classifyRemoteUrl` already
|
|
561
|
+
* refuses them before we spawn — this is the backstop). Deliberately does NOT
|
|
562
|
+
* block `file`/local object transfer (legitimate local-repo fetch), only the
|
|
563
|
+
* shell-spawning helpers. */
|
|
564
|
+
const HARDENED_REMOTE_FLAGS = ['-c', 'protocol.ext.allow=never', '-c', 'protocol.fd.allow=never'];
|
|
565
|
+
|
|
566
|
+
/** Non-empty, trimmed lines of a git stdout block. */
|
|
567
|
+
function splitLines(stdout: string): string[] {
|
|
568
|
+
return stdout
|
|
569
|
+
.split('\n')
|
|
570
|
+
.map((s) => s.trim())
|
|
571
|
+
.filter(Boolean);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Fold a remote-tracking ref into the merged draft map: a name already seen
|
|
575
|
+
* locally becomes `both` (recents = the newer of the two commit times); a name
|
|
576
|
+
* seen only on the remote becomes a `remote`-only draft. */
|
|
577
|
+
function mergeRemote(merged: Map<string, GitBranch>, name: string, updatedAt: number): void {
|
|
578
|
+
const existing = merged.get(name);
|
|
579
|
+
if (existing) {
|
|
580
|
+
merged.set(name, {
|
|
581
|
+
...existing,
|
|
582
|
+
where: 'both',
|
|
583
|
+
updatedAt: Math.max(existing.updatedAt, updatedAt),
|
|
584
|
+
});
|
|
585
|
+
} else {
|
|
586
|
+
merged.set(name, { name, current: false, updatedAt, where: 'remote' });
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
437
590
|
export interface GitBranch {
|
|
438
591
|
name: string;
|
|
439
592
|
current: boolean;
|
|
593
|
+
/** Last-commit time on this branch, unix seconds — drives the "recents" sort in
|
|
594
|
+
* the switcher. 0 when unknown (resolution failed / empty branch). */
|
|
595
|
+
updatedAt: number;
|
|
596
|
+
/** Where this draft lives. `local` = only here, `remote` = only on the team's
|
|
597
|
+
* remote (not downloaded yet — switching creates a tracking branch), `both` =
|
|
598
|
+
* present in both. The UI labels `remote` drafts "from your team". */
|
|
599
|
+
where: 'local' | 'remote' | 'both';
|
|
440
600
|
}
|
|
441
601
|
|
|
442
|
-
/** List
|
|
602
|
+
/** List drafts (branches) — LOCAL plus the remote-tracking refs already on disk
|
|
603
|
+
* (populated by the original clone / a prior fetch; a fresh teammate draft only
|
|
604
|
+
* appears after `gitFetchRemote`). Each carries its last-commit time so the UI
|
|
605
|
+
* can sort by recents, and a `where` tag so it can mark remote-only drafts.
|
|
606
|
+
* Returns [] when `dir` isn't a repo. */
|
|
443
607
|
export async function gitListBranches(dir: string): Promise<GitBranch[]> {
|
|
444
608
|
if (!isRepo(dir)) return [];
|
|
445
609
|
try {
|
|
446
|
-
|
|
610
|
+
const merged = new Map<string, GitBranch>();
|
|
611
|
+
if (await systemGitAvailable()) {
|
|
447
612
|
const cur = (await runGit(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim();
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
613
|
+
// Tab-separated so a branch name (no tabs/newlines, charset-guarded) can't
|
|
614
|
+
// collide with the delimiter; committerdate:unix is the recents key.
|
|
615
|
+
const fmt = '--format=%(refname:short)%09%(committerdate:unix)';
|
|
616
|
+
const local = await runGit(dir, ['for-each-ref', fmt, 'refs/heads']);
|
|
617
|
+
if (local.code !== 0) return [];
|
|
618
|
+
for (const line of splitLines(local.stdout)) {
|
|
619
|
+
const [name, ts] = line.split('\t');
|
|
620
|
+
merged.set(name, {
|
|
621
|
+
name,
|
|
622
|
+
current: name === cur,
|
|
623
|
+
updatedAt: Number(ts) || 0,
|
|
624
|
+
where: 'local',
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
// Remote refs come back as "origin/<name>" — strip the prefix, skip origin/HEAD.
|
|
628
|
+
// NB: `%(refname:short)` collapses the symbolic ref refs/remotes/origin/HEAD to
|
|
629
|
+
// the bare remote name ("origin"), so skip that too or it shows as a phantom branch.
|
|
630
|
+
const remote = await runGit(dir, ['for-each-ref', fmt, `refs/remotes/${DEFAULT_REMOTE}`]);
|
|
631
|
+
if (remote.code === 0) {
|
|
632
|
+
for (const line of splitLines(remote.stdout)) {
|
|
633
|
+
const [full, ts] = line.split('\t');
|
|
634
|
+
if (!full || full === DEFAULT_REMOTE) continue; // origin/HEAD short form
|
|
635
|
+
const name = full.startsWith(`${DEFAULT_REMOTE}/`)
|
|
636
|
+
? full.slice(DEFAULT_REMOTE.length + 1)
|
|
637
|
+
: full;
|
|
638
|
+
if (!name || name === 'HEAD') continue;
|
|
639
|
+
mergeRemote(merged, name, Number(ts) || 0);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return [...merged.values()];
|
|
455
643
|
}
|
|
644
|
+
// iso engine (default): merge local heads with refs/remotes/<remote>/*.
|
|
456
645
|
const cur = (await git.currentBranch({ fs, dir, fullname: false })) ?? null;
|
|
457
|
-
const
|
|
458
|
-
|
|
646
|
+
const at = async (ref: string): Promise<number> => {
|
|
647
|
+
try {
|
|
648
|
+
const oid = await git.resolveRef({ fs, dir, ref });
|
|
649
|
+
const { commit } = await git.readCommit({ fs, dir, oid });
|
|
650
|
+
return commit.committer?.timestamp || 0;
|
|
651
|
+
} catch {
|
|
652
|
+
return 0; // empty / unresolvable ref — sorts last
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
const localNames = await git.listBranches({ fs, dir });
|
|
656
|
+
await Promise.all(
|
|
657
|
+
localNames.map(async (name) => {
|
|
658
|
+
merged.set(name, {
|
|
659
|
+
name,
|
|
660
|
+
current: name === cur,
|
|
661
|
+
updatedAt: await at(name),
|
|
662
|
+
where: 'local',
|
|
663
|
+
});
|
|
664
|
+
})
|
|
665
|
+
);
|
|
666
|
+
// Remote enumeration is best-effort: a repo with no remote yields [].
|
|
667
|
+
const remoteNames = (
|
|
668
|
+
await git.listBranches({ fs, dir, remote: DEFAULT_REMOTE }).catch(() => [])
|
|
669
|
+
).filter((n) => n && n !== 'HEAD');
|
|
670
|
+
await Promise.all(
|
|
671
|
+
remoteNames.map(async (name) => {
|
|
672
|
+
mergeRemote(merged, name, await at(`refs/remotes/${DEFAULT_REMOTE}/${name}`));
|
|
673
|
+
})
|
|
674
|
+
);
|
|
675
|
+
return [...merged.values()];
|
|
459
676
|
} catch {
|
|
460
677
|
return [];
|
|
461
678
|
}
|
|
@@ -498,21 +715,43 @@ export async function gitCheckout(dir: string, name: string): Promise<GitBranchR
|
|
|
498
715
|
if (!isSafeGitPositional(name)) return { ok: false, error: 'Invalid draft name.' };
|
|
499
716
|
try {
|
|
500
717
|
if (USE_SYSTEM_GIT) {
|
|
718
|
+
// System git DWIMs `git checkout <name>` into a tracking branch when <name>
|
|
719
|
+
// exists on exactly one remote, so the local + remote-only cases share a path.
|
|
501
720
|
const r = await runGit(dir, ['checkout', name]);
|
|
502
721
|
if (r.code !== 0) {
|
|
503
722
|
const blob = `${r.stderr} ${r.stdout}`.toLowerCase();
|
|
504
723
|
if (blob.includes('would be overwritten') || blob.includes('local changes'))
|
|
505
724
|
return { ok: false, error: 'Save your changes before switching drafts.' };
|
|
725
|
+
if (
|
|
726
|
+
blob.includes('did not match') ||
|
|
727
|
+
blob.includes('pathspec') ||
|
|
728
|
+
blob.includes('invalid reference')
|
|
729
|
+
)
|
|
730
|
+
return { ok: false, error: "Couldn't find that draft — try Refresh." };
|
|
506
731
|
return { ok: false, error: r.stderr.trim() || 'Could not switch drafts.' };
|
|
507
732
|
}
|
|
508
733
|
} else {
|
|
509
|
-
|
|
734
|
+
// iso-git does NOT DWIM: a local ref checks out directly, but a remote-only
|
|
735
|
+
// draft must be created as a tracking branch from refs/remotes/<remote>/<name>.
|
|
736
|
+
const localNames = await git.listBranches({ fs, dir });
|
|
737
|
+
if (localNames.includes(name)) {
|
|
738
|
+
await git.checkout({ fs, dir, ref: name });
|
|
739
|
+
} else {
|
|
740
|
+
const remoteNames = await git
|
|
741
|
+
.listBranches({ fs, dir, remote: DEFAULT_REMOTE })
|
|
742
|
+
.catch(() => []);
|
|
743
|
+
if (!remoteNames.includes(name))
|
|
744
|
+
return { ok: false, error: "Couldn't find that draft — try Refresh." };
|
|
745
|
+
await git.checkout({ fs, dir, ref: name, remote: DEFAULT_REMOTE, track: true });
|
|
746
|
+
}
|
|
510
747
|
}
|
|
511
748
|
return { ok: true, branch: name };
|
|
512
749
|
} catch (e) {
|
|
513
750
|
const msg = errMsg(e);
|
|
514
751
|
if (/overwrit|local change|conflict/i.test(msg))
|
|
515
752
|
return { ok: false, error: 'Save your changes before switching drafts.' };
|
|
753
|
+
if (/not ?found|did not match|resolve/i.test(msg))
|
|
754
|
+
return { ok: false, error: "Couldn't find that draft — try Refresh." };
|
|
516
755
|
return { ok: false, error: msg };
|
|
517
756
|
}
|
|
518
757
|
}
|
|
@@ -543,6 +782,7 @@ export async function gitFoldDraft(
|
|
|
543
782
|
): Promise<GitFoldResult> {
|
|
544
783
|
if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
|
|
545
784
|
if (!isSafeGitPositional(draftName)) return { ok: false, error: 'Invalid draft name.' };
|
|
785
|
+
invalidateRemoteProbe(dir); // a fold changes ahead/behind — re-probe next status
|
|
546
786
|
const remote = opts.remote || 'origin';
|
|
547
787
|
const branches = await gitListBranches(dir);
|
|
548
788
|
const shared = branches.find((b) => SHARED_BRANCHES.has(b.name))?.name;
|
|
@@ -635,6 +875,7 @@ export async function gitPush(
|
|
|
635
875
|
opts: { remote?: string; ref?: string } = {}
|
|
636
876
|
): Promise<GitPushResult> {
|
|
637
877
|
if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
|
|
878
|
+
invalidateRemoteProbe(dir); // a publish changes ahead/behind — re-probe next status
|
|
638
879
|
const remote = opts.remote || 'origin';
|
|
639
880
|
return USE_SYSTEM_GIT
|
|
640
881
|
? pushSystem(dir, token, remote, opts.ref)
|
|
@@ -747,6 +988,7 @@ export async function gitPull(
|
|
|
747
988
|
opts: { remote?: string; ref?: string } = {}
|
|
748
989
|
): Promise<GitPullResult> {
|
|
749
990
|
if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
|
|
991
|
+
invalidateRemoteProbe(dir); // a pull changes ahead/behind — re-probe next status
|
|
750
992
|
return USE_SYSTEM_GIT
|
|
751
993
|
? pullSystem(dir, token, opts.remote || 'origin', opts.ref)
|
|
752
994
|
: pullIso(dir, token, opts.remote || 'origin', opts.ref);
|
|
@@ -805,6 +1047,118 @@ async function pullSystem(
|
|
|
805
1047
|
return { ok: false, error: r.stderr.trim() || 'Get latest failed.' };
|
|
806
1048
|
}
|
|
807
1049
|
|
|
1050
|
+
// ── fetch (Refresh drafts) ──────────────────────────────────────────────────
|
|
1051
|
+
|
|
1052
|
+
export interface GitFetchResult {
|
|
1053
|
+
ok: boolean;
|
|
1054
|
+
/** Unix seconds the refresh completed — the UI shows it as "as of <time>". */
|
|
1055
|
+
fetchedAt?: number;
|
|
1056
|
+
/** See GitPushResult.authRequired — a tokenless refresh on the iso engine. */
|
|
1057
|
+
authRequired?: boolean;
|
|
1058
|
+
/** The fetch exceeded FETCH_TIMEOUT_MS and was bounded (DDR-133). */
|
|
1059
|
+
timedOut?: boolean;
|
|
1060
|
+
error?: string;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/** Refresh the team's drafts: fetch ALL remote heads (no singleBranch) so brand-new
|
|
1064
|
+
* teammate drafts surface in `gitListBranches`. Prunes deleted remotes. Same
|
|
1065
|
+
* optional-token model as gitPull. Explicit user gesture only — never auto-run. */
|
|
1066
|
+
export async function gitFetchRemote(
|
|
1067
|
+
dir: string,
|
|
1068
|
+
token: string | undefined,
|
|
1069
|
+
opts: { remote?: string } = {}
|
|
1070
|
+
): Promise<GitFetchResult> {
|
|
1071
|
+
if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
|
|
1072
|
+
const remote = opts.remote || DEFAULT_REMOTE;
|
|
1073
|
+
if (!isSafeGitPositional(remote)) return { ok: false, error: 'Invalid remote name.' };
|
|
1074
|
+
// SECURITY (DDR-131 hardening): classify the configured remote URL BEFORE spawning
|
|
1075
|
+
// git. iso-git speaks HTTP(S) only, so an ssh/git remote must go to the system
|
|
1076
|
+
// binary — but a command-executing (`ext::`) / local (`file://`) URL must be
|
|
1077
|
+
// REFUSED, never handed to `git fetch` (it would run as the user). And the GitHub
|
|
1078
|
+
// token may only ride a github.com HTTPS request, never an attacker-chosen host.
|
|
1079
|
+
const url = await readRemoteUrl(dir, remote);
|
|
1080
|
+
const transport = classifyRemoteUrl(url);
|
|
1081
|
+
if (transport === 'none') return { ok: false, error: 'This project has no remote to refresh.' };
|
|
1082
|
+
if (transport === 'unsafe')
|
|
1083
|
+
return { ok: false, error: 'Maude can only refresh github.com (HTTPS or SSH) projects.' };
|
|
1084
|
+
const trustedHttp = transport === 'http' && isTrustedTokenHost(url);
|
|
1085
|
+
// Token/host POLICY is engine-independent (DDR-133): an HTTPS remote must be
|
|
1086
|
+
// github.com (token-host = fetch-host — never lend the PAT to another host) and
|
|
1087
|
+
// requires sign-in; an ssh remote uses the user's own key against whatever host
|
|
1088
|
+
// they configured. The engine choice below only decides HOW a vetted fetch runs.
|
|
1089
|
+
if (transport === 'http' && !trustedHttp)
|
|
1090
|
+
return { ok: false, error: 'Maude can only refresh github.com projects.' };
|
|
1091
|
+
// Tokenless github HTTPS: the iso engine can't authenticate (→ sign in), but system
|
|
1092
|
+
// git uses the developer's own credential helper, so only block when there's no
|
|
1093
|
+
// system git to fall back on (DDR-133). With system git, a tokenless fetch Just Works.
|
|
1094
|
+
if (transport === 'http' && !token && !(await systemGitAvailable()))
|
|
1095
|
+
return { ok: false, authRequired: true, error: 'Sign in with GitHub to refresh.' };
|
|
1096
|
+
try {
|
|
1097
|
+
if ((await systemGitAvailable()) || transport === 'ssh') {
|
|
1098
|
+
// System git (auto-preferred when present — DDR-133): attach the token header
|
|
1099
|
+
// ONLY for a trusted-host HTTPS remote (else fall back to the user's own
|
|
1100
|
+
// credential helper); an ssh remote authenticates with the user's own key.
|
|
1101
|
+
const args = trustedHttp ? tokenHeaderArgs(token) : [];
|
|
1102
|
+
args.push(...HARDENED_REMOTE_FLAGS, 'fetch', '--prune', remote);
|
|
1103
|
+
const r = await runGit(dir, args, undefined, FETCH_TIMEOUT_MS);
|
|
1104
|
+
if (r.timedOut)
|
|
1105
|
+
return {
|
|
1106
|
+
ok: false,
|
|
1107
|
+
timedOut: true,
|
|
1108
|
+
error: 'Refresh timed out — check your connection and try again.',
|
|
1109
|
+
};
|
|
1110
|
+
if (r.code === 127)
|
|
1111
|
+
return {
|
|
1112
|
+
ok: false,
|
|
1113
|
+
error: 'Refresh needs the git command-line tool for this project’s connection.',
|
|
1114
|
+
};
|
|
1115
|
+
if (r.code !== 0) {
|
|
1116
|
+
const blob = `${r.stderr}\n${r.stdout}`.toLowerCase();
|
|
1117
|
+
if (transport === 'http' && /auth|denied|credential|terminal prompts disabled/.test(blob))
|
|
1118
|
+
return { ok: false, authRequired: true, error: 'Sign in with GitHub to refresh.' };
|
|
1119
|
+
if (
|
|
1120
|
+
/permission denied|publickey|host key|authenticity|could not read from remote/.test(blob)
|
|
1121
|
+
)
|
|
1122
|
+
return {
|
|
1123
|
+
ok: false,
|
|
1124
|
+
error: 'Couldn’t reach the remote — check your connection or SSH key.',
|
|
1125
|
+
};
|
|
1126
|
+
return { ok: false, error: r.stderr.trim() || 'Could not refresh drafts.' };
|
|
1127
|
+
}
|
|
1128
|
+
} else {
|
|
1129
|
+
// iso engine: github HTTPS with a token (guaranteed by the policy guards above).
|
|
1130
|
+
const fetched = await withTimeout(
|
|
1131
|
+
git.fetch({
|
|
1132
|
+
fs,
|
|
1133
|
+
http,
|
|
1134
|
+
dir,
|
|
1135
|
+
remote,
|
|
1136
|
+
prune: true,
|
|
1137
|
+
onAuth: () => ({ username: token ?? '', password: '' }),
|
|
1138
|
+
}),
|
|
1139
|
+
FETCH_TIMEOUT_MS
|
|
1140
|
+
);
|
|
1141
|
+
if (fetched === TIMED_OUT)
|
|
1142
|
+
return {
|
|
1143
|
+
ok: false,
|
|
1144
|
+
timedOut: true,
|
|
1145
|
+
error: 'Refresh timed out — check your connection and try again.',
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
return { ok: true, fetchedAt: Math.floor(Date.now() / 1000) };
|
|
1149
|
+
} catch (e) {
|
|
1150
|
+
const msg = errMsg(e);
|
|
1151
|
+
if (/unrecognized transport|unsupported|protocol/i.test(msg))
|
|
1152
|
+
return {
|
|
1153
|
+
ok: false,
|
|
1154
|
+
error: 'Refresh needs the git command-line tool for this project’s connection.',
|
|
1155
|
+
};
|
|
1156
|
+
if (/auth|denied|credential|401|403/i.test(msg))
|
|
1157
|
+
return { ok: false, authRequired: true, error: 'Sign in with GitHub to refresh.' };
|
|
1158
|
+
return { ok: false, error: msg };
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
|
|
808
1162
|
// ── resolve (finish a Get-latest merge that hit a conflict) ─────────────────
|
|
809
1163
|
|
|
810
1164
|
/** Finish the merge `gitPull` left unresolved, applying one CHOICE to every
|
|
@@ -1001,7 +1355,9 @@ function mineCopyPath(rel: string): string {
|
|
|
1001
1355
|
* design tree). */
|
|
1002
1356
|
export async function gitLog(dir: string, limit = 30, filepath?: string): Promise<GitLogEntry[]> {
|
|
1003
1357
|
if (!isRepo(dir)) return [];
|
|
1004
|
-
return
|
|
1358
|
+
return (await systemGitAvailable())
|
|
1359
|
+
? logSystem(dir, limit, filepath)
|
|
1360
|
+
: logIso(dir, limit, filepath);
|
|
1005
1361
|
}
|
|
1006
1362
|
|
|
1007
1363
|
async function logIso(dir: string, limit: number, filepath?: string): Promise<GitLogEntry[]> {
|
|
@@ -1061,7 +1417,7 @@ export async function gitDiff(
|
|
|
1061
1417
|
): Promise<GitDiffEntry[]> {
|
|
1062
1418
|
if (!isRepo(dir)) return [];
|
|
1063
1419
|
const prefix = normPrefix(opts.designPrefix);
|
|
1064
|
-
return
|
|
1420
|
+
return (await systemGitAvailable()) ? diffSystem(dir, sha, prefix) : diffIso(dir, sha, prefix);
|
|
1065
1421
|
}
|
|
1066
1422
|
|
|
1067
1423
|
async function diffIso(dir: string, sha: string, prefix: string): Promise<GitDiffEntry[]> {
|
|
@@ -1128,7 +1484,7 @@ export async function gitShowFile(
|
|
|
1128
1484
|
const rel = repoRelPath.replace(/\\/g, '/');
|
|
1129
1485
|
if (!isContainedRepoPath(dir, rel)) return null;
|
|
1130
1486
|
try {
|
|
1131
|
-
if (
|
|
1487
|
+
if (await systemGitAvailable()) {
|
|
1132
1488
|
const r = await runGit(dir, ['show', `${sha}:${rel}`]);
|
|
1133
1489
|
return r.code === 0 ? r.stdout : null;
|
|
1134
1490
|
}
|
|
@@ -1152,7 +1508,7 @@ async function localUnpushed(dir: string, branch: string | null, remote: string)
|
|
|
1152
1508
|
// server-derived today, but never interpolate an unguarded positional into git
|
|
1153
1509
|
// argv (defense-in-depth so a future caller can't re-open the injection class).
|
|
1154
1510
|
if (!isSafeGitPositional(branch) || !isSafeGitPositional(remote)) return 0;
|
|
1155
|
-
if (
|
|
1511
|
+
if (await systemGitAvailable()) {
|
|
1156
1512
|
const r = await runGit(dir, ['rev-list', '--count', `${remote}/${branch}..HEAD`]);
|
|
1157
1513
|
if (r.code === 0) return Number(r.stdout.trim()) || 0;
|
|
1158
1514
|
// No tracking ref — count all commits if a remote exists, else 0.
|
|
@@ -1182,26 +1538,110 @@ async function localUnpushed(dir: string, branch: string | null, remote: string)
|
|
|
1182
1538
|
|
|
1183
1539
|
// ── remote ahead/behind (Get latest nudge) ─────────────────────────────────
|
|
1184
1540
|
|
|
1541
|
+
interface AheadBehind {
|
|
1542
|
+
ahead: number;
|
|
1543
|
+
behind: number;
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
// In-memory TTL cache + in-flight dedupe for the network probe (DDR-132). A
|
|
1547
|
+
// Changes-panel toggle, a 60 s tick, and effect re-runs all call status?remote=1;
|
|
1548
|
+
// without this they each fire a fresh `git fetch`. The cache is per FRESH process
|
|
1549
|
+
// — a repo switch respawns the sidecar (fresh process ⇒ one fresh probe), which is
|
|
1550
|
+
// exactly the desired semantics, so it deliberately never persists to disk.
|
|
1551
|
+
const REMOTE_PROBE_TTL_MS = 45_000;
|
|
1552
|
+
const probeCache = new Map<string, { at: number; val: AheadBehind }>();
|
|
1553
|
+
const probeInflight = new Map<string, Promise<AheadBehind>>();
|
|
1554
|
+
|
|
1555
|
+
/** Drop any cached/in-flight probe for `dir` so the next status re-fetches —
|
|
1556
|
+
* called after a push/pull/fold so the "Get latest" nudge updates immediately. */
|
|
1557
|
+
export function invalidateRemoteProbe(dir: string): void {
|
|
1558
|
+
const prefix = `${dir}\0`;
|
|
1559
|
+
for (const k of probeCache.keys()) if (k.startsWith(prefix)) probeCache.delete(k);
|
|
1560
|
+
for (const k of probeInflight.keys()) if (k.startsWith(prefix)) probeInflight.delete(k);
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
/** Resolve the current branch with the active engine — local-only, no network. */
|
|
1564
|
+
async function currentBranchOf(dir: string): Promise<string> {
|
|
1565
|
+
if (await systemGitAvailable()) {
|
|
1566
|
+
const r = await runGit(dir, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
1567
|
+
return r.stdout.trim() || 'main';
|
|
1568
|
+
}
|
|
1569
|
+
return (await git.currentBranch({ fs, dir, fullname: false })) || 'main';
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1185
1572
|
/** Fetch the tracking remote and count commits ahead (local-only) / behind
|
|
1186
1573
|
* (remote-only). `behind > 0` is what surfaces the "Get latest" banner. Network;
|
|
1187
|
-
* callers guard with try/catch so an offline poll never breaks local status.
|
|
1574
|
+
* callers guard with try/catch so an offline poll never breaks local status.
|
|
1575
|
+
*
|
|
1576
|
+
* TTL-cached + in-flight-deduped per `dir\0remote\0branch` (DDR-132): a value
|
|
1577
|
+
* younger than the TTL is served without a fetch, and concurrent callers share
|
|
1578
|
+
* one in-flight promise. Only the RESOLVED value is cached — a throw clears the
|
|
1579
|
+
* in-flight slot so a later call retries (failures are never cached). */
|
|
1188
1580
|
export async function remoteAheadBehind(
|
|
1189
1581
|
dir: string,
|
|
1190
1582
|
token: string | undefined,
|
|
1191
1583
|
remote = 'origin'
|
|
1192
|
-
): Promise<
|
|
1193
|
-
|
|
1194
|
-
const
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1584
|
+
): Promise<AheadBehind> {
|
|
1585
|
+
const branch = await currentBranchOf(dir);
|
|
1586
|
+
const key = `${dir}\0${remote}\0${branch}`;
|
|
1587
|
+
const hit = probeCache.get(key);
|
|
1588
|
+
if (hit && Date.now() - hit.at < REMOTE_PROBE_TTL_MS) return hit.val;
|
|
1589
|
+
const existing = probeInflight.get(key);
|
|
1590
|
+
if (existing) return existing;
|
|
1591
|
+
const p = remoteAheadBehindUncached(dir, token, remote, branch)
|
|
1592
|
+
.then((val) => {
|
|
1593
|
+
probeCache.set(key, { at: Date.now(), val });
|
|
1594
|
+
return val;
|
|
1595
|
+
})
|
|
1596
|
+
.finally(() => {
|
|
1597
|
+
probeInflight.delete(key);
|
|
1598
|
+
});
|
|
1599
|
+
probeInflight.set(key, p);
|
|
1600
|
+
return p;
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
async function remoteAheadBehindUncached(
|
|
1604
|
+
dir: string,
|
|
1605
|
+
token: string | undefined,
|
|
1606
|
+
remote: string,
|
|
1607
|
+
branch: string
|
|
1608
|
+
): Promise<AheadBehind> {
|
|
1609
|
+
// SECURITY (DDR-131 hardening): this probe runs UNATTENDED (status?remote=1 on a
|
|
1610
|
+
// 60s tick / post-action), so it's the highest-value RCE sink — classify the
|
|
1611
|
+
// remote URL before touching the git binary. A command-executing (`ext::`) or
|
|
1612
|
+
// local (`file://`) URL is refused (return 0/0, no spawn); the token rides only a
|
|
1613
|
+
// github.com HTTPS request, never an attacker-chosen host.
|
|
1614
|
+
const url = await readRemoteUrl(dir, remote);
|
|
1615
|
+
const transport = classifyRemoteUrl(url);
|
|
1616
|
+
if (transport === 'none' || transport === 'unsafe') return { ahead: 0, behind: 0 };
|
|
1617
|
+
const trustedHttp = transport === 'http' && isTrustedTokenHost(url);
|
|
1618
|
+
// SECURITY (DDR-133 fix — adversarial review F1): the host-allowlist MUST gate this
|
|
1619
|
+
// UNATTENDED probe BEFORE the engine branch, mirroring gitFetchRemote (`:1089`). With
|
|
1620
|
+
// system git now auto-preferred, an http transport otherwise reached the system path
|
|
1621
|
+
// for ANY host — turning a poisoned non-github `remote.origin.url` into an unattended
|
|
1622
|
+
// `git fetch` SSRF/beacon (the old `!trustedHttp` guard sat AFTER the engine branch,
|
|
1623
|
+
// so it only ever protected the now-dead iso path). github-only for http; ssh uses the
|
|
1624
|
+
// user's own key (DDR-131-accepted, same as the explicit Refresh).
|
|
1625
|
+
if (transport === 'http' && !trustedHttp) return { ahead: 0, behind: 0 };
|
|
1626
|
+
if ((await systemGitAvailable()) || transport === 'ssh')
|
|
1627
|
+
return remoteAheadBehindSystem(dir, trustedHttp ? token : undefined, remote, branch);
|
|
1628
|
+
// iso engine, HTTP(S) to github (trustedHttp guaranteed by the guard above).
|
|
1629
|
+
// Bounded (DDR-133): a timeout THROWS so the wrapper never caches it — the next
|
|
1630
|
+
// poll retries, and local status is unaffected (caller try/catches).
|
|
1631
|
+
const fetched = await withTimeout(
|
|
1632
|
+
git.fetch({
|
|
1633
|
+
fs,
|
|
1634
|
+
http,
|
|
1635
|
+
dir,
|
|
1636
|
+
remote,
|
|
1637
|
+
ref: branch,
|
|
1638
|
+
singleBranch: true,
|
|
1639
|
+
tags: false,
|
|
1640
|
+
onAuth: () => ({ username: token ?? '', password: '' }),
|
|
1641
|
+
}),
|
|
1642
|
+
PROBE_TIMEOUT_MS
|
|
1643
|
+
);
|
|
1644
|
+
if (fetched === TIMED_OUT) throw new Error('remote probe timed out');
|
|
1205
1645
|
const localOid = await git.resolveRef({ fs, dir, ref: branch });
|
|
1206
1646
|
const remoteOid = await git
|
|
1207
1647
|
.resolveRef({ fs, dir, ref: `refs/remotes/${remote}/${branch}` })
|
|
@@ -1224,15 +1664,18 @@ export async function remoteAheadBehind(
|
|
|
1224
1664
|
async function remoteAheadBehindSystem(
|
|
1225
1665
|
dir: string,
|
|
1226
1666
|
token: string | undefined,
|
|
1227
|
-
remote: string
|
|
1667
|
+
remote: string,
|
|
1668
|
+
branch: string
|
|
1228
1669
|
): Promise<{ ahead: number; behind: number }> {
|
|
1229
|
-
const branch = (await runGit(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim() || 'main';
|
|
1230
1670
|
if (!isSafeGitPositional(remote) || !isSafeGitPositional(branch)) {
|
|
1231
1671
|
throw new Error('invalid remote or branch');
|
|
1232
1672
|
}
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
const
|
|
1673
|
+
// Token only when the caller vetted a trusted HTTPS host; harden the transport
|
|
1674
|
+
// allowlist at the git layer too (defense-in-depth behind classifyRemoteUrl).
|
|
1675
|
+
const args = token ? tokenHeaderArgs(token) : [];
|
|
1676
|
+
args.push(...HARDENED_REMOTE_FLAGS, 'fetch', remote, branch);
|
|
1677
|
+
const f = await runGit(dir, args, undefined, PROBE_TIMEOUT_MS);
|
|
1678
|
+
if (f.timedOut) throw new Error('remote probe timed out');
|
|
1236
1679
|
if (f.code !== 0) throw new Error(f.stderr.trim() || 'fetch failed');
|
|
1237
1680
|
const counts = await runGit(dir, [
|
|
1238
1681
|
'rev-list',
|