@1agh/maude 0.30.0 → 0.31.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.
Files changed (90) hide show
  1. package/README.md +5 -5
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/annotations-layer.tsx +6 -1
  8. package/apps/studio/api.ts +225 -66
  9. package/apps/studio/bin/chat-open.sh +44 -0
  10. package/apps/studio/canvas-lib.tsx +112 -19
  11. package/apps/studio/canvas-list-watch.ts +177 -0
  12. package/apps/studio/canvas-shell.tsx +22 -2
  13. package/apps/studio/client/app.jsx +781 -26
  14. package/apps/studio/client/canvas-url.js +5 -0
  15. package/apps/studio/client/github.js +99 -0
  16. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  17. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  18. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  19. package/apps/studio/client/panels/DiffView.jsx +590 -0
  20. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  21. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  23. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  24. package/apps/studio/client/panels/acp-runtime.js +286 -0
  25. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  26. package/apps/studio/client/panels/git-grouping.js +86 -0
  27. package/apps/studio/client/styles/0-reset.css +4 -0
  28. package/apps/studio/client/styles/3-shell-maude.css +613 -3
  29. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  30. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  31. package/apps/studio/client/styles/_index.css +2 -0
  32. package/apps/studio/client/tour/collab-tour.js +61 -0
  33. package/apps/studio/client/tour/overlay.jsx +11 -1
  34. package/apps/studio/client/whats-new.jsx +25 -10
  35. package/apps/studio/collab/registry.ts +13 -0
  36. package/apps/studio/collab/room.ts +36 -0
  37. package/apps/studio/cursors-overlay.tsx +17 -1
  38. package/apps/studio/dist/client.bundle.js +28780 -1534
  39. package/apps/studio/dist/comment-mount.js +4 -2
  40. package/apps/studio/dist/styles.css +5633 -1617
  41. package/apps/studio/git/endpoints.ts +338 -0
  42. package/apps/studio/git/service.ts +1334 -0
  43. package/apps/studio/git/watch.ts +97 -0
  44. package/apps/studio/github/endpoints.ts +358 -0
  45. package/apps/studio/github/service.ts +231 -0
  46. package/apps/studio/github/token.ts +53 -0
  47. package/apps/studio/hmr-broadcast.ts +9 -2
  48. package/apps/studio/http.ts +384 -1
  49. package/apps/studio/participants-chrome.tsx +69 -9
  50. package/apps/studio/paths.ts +12 -0
  51. package/apps/studio/scaffold-design.ts +57 -0
  52. package/apps/studio/server.ts +65 -2
  53. package/apps/studio/sync/agent.ts +81 -1
  54. package/apps/studio/sync/codec.ts +24 -0
  55. package/apps/studio/sync/cold-start.ts +40 -0
  56. package/apps/studio/sync/hub-link.ts +137 -0
  57. package/apps/studio/test/acp-bridge.test.ts +127 -0
  58. package/apps/studio/test/acp-env.test.ts +65 -0
  59. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  60. package/apps/studio/test/acp-transcript.test.ts +112 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  62. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  63. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  64. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  65. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  66. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  67. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  68. package/apps/studio/test/editing-presence.test.ts +103 -0
  69. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  70. package/apps/studio/test/git-api.test.ts +0 -0
  71. package/apps/studio/test/git-branches.test.ts +106 -0
  72. package/apps/studio/test/git-grouping.test.ts +106 -0
  73. package/apps/studio/test/git-watch.test.ts +97 -0
  74. package/apps/studio/test/github-api.test.ts +465 -0
  75. package/apps/studio/test/hub-link.test.ts +69 -0
  76. package/apps/studio/test/participants-chrome.test.ts +36 -1
  77. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  78. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  79. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  80. package/apps/studio/tool-palette.tsx +18 -9
  81. package/apps/studio/use-chrome-visibility.tsx +66 -0
  82. package/apps/studio/use-collab.tsx +414 -187
  83. package/apps/studio/whats-new.json +73 -0
  84. package/apps/studio/ws.ts +44 -1
  85. package/cli/commands/design.mjs +1 -0
  86. package/cli/lib/gitignore-block.mjs +16 -3
  87. package/cli/lib/gitignore-block.test.mjs +13 -1
  88. package/package.json +11 -9
  89. package/plugins/design/dependencies.json +17 -0
  90. package/plugins/design/templates/_shell.html +30 -8
@@ -0,0 +1,1334 @@
1
+ // Phase 27 (epic E2) — git service for the in-UI git-awareness layer.
2
+ //
3
+ // Wraps `isomorphic-git` (DDR-107) so the `/_api/git/*` endpoints can answer the
4
+ // non-technical persona's only real question — "what have I changed, and how do
5
+ // I Save / Publish / Get latest?" — WITHOUT a terminal. Vocabulary is enforced at
6
+ // the UI layer (Save version=commit · Publish=push · Get latest=pull · History=
7
+ // log); this module speaks plain git internally and never leaks the words.
8
+ //
9
+ // Two engines, one surface (Task 3 gotcha):
10
+ // • DEFAULT — pure-JS isomorphic-git. Zero system-git dependency, so a managed
11
+ // clone works on a machine without git installed (the non-technical default).
12
+ // • MAUDE_USE_SYSTEM_GIT=1 — shell out to the `git` binary via Bun.spawn (the
13
+ // api.ts gitCurrentUser pattern). For users who prefer their configured
14
+ // credential helper / SSH agent over a request-body token.
15
+ //
16
+ // `dir` is the git WORKING DIRECTORY (the repo root that holds `.git`), NOT the
17
+ // designRoot. For a managed Maude project (DDR-111) the cloned repo IS the
18
+ // project, so dir === repoRoot. A `designPrefix` (the designRoot's path relative
19
+ // to the repo, e.g. ".design") scopes status/diff to the user's design files so
20
+ // unrelated repo churn never shows up in the non-technical Changes panel.
21
+ //
22
+ // SECURITY: the GitHub token reaches gitPush/gitPull as an argument and is used
23
+ // ONLY for the isomorphic-git `onAuth` callback (or the system-git remote URL).
24
+ // It is NEVER logged, persisted, or written to `_server.json`. The endpoint layer
25
+ // (http.ts) keeps it main-origin-only + loopback-only (DDR-054 / DDR-109).
26
+
27
+ import { spawn } from 'node:child_process';
28
+ import fs, { existsSync } from 'node:fs';
29
+ import { isAbsolute, join, relative, sep } from 'node:path';
30
+
31
+ import git from 'isomorphic-git';
32
+ import http from 'isomorphic-git/http/node';
33
+
34
+ const USE_SYSTEM_GIT = /^(1|true|on|yes)$/i.test(process.env.MAUDE_USE_SYSTEM_GIT ?? '');
35
+
36
+ /** One changed file as the Changes panel renders it. `status` maps to the M/A/D/U
37
+ * badge (DDR-075 status hues): modified→M, added→A, deleted→D, untracked→U. */
38
+ export type GitFileState = 'modified' | 'added' | 'deleted' | 'untracked';
39
+
40
+ export interface GitFileStatus {
41
+ /** Path relative to the git working dir, forward-slashed (e.g. `.design/ui/Pricing v3.tsx`). */
42
+ path: string;
43
+ status: GitFileState;
44
+ }
45
+
46
+ export interface GitStatusResult {
47
+ /** False when `dir` is not inside a git repo — the UI shows the "not versioned yet" state. */
48
+ repo: boolean;
49
+ branch: string | null;
50
+ files: GitFileStatus[];
51
+ clean: boolean;
52
+ /** LOCAL count of saved versions not yet published (commits ahead of the
53
+ * remote-tracking ref) — computed with NO network, so the panel can offer
54
+ * Publish even when the working tree is clean. 0 = up to date / no remote. */
55
+ unpushed: number;
56
+ /** Populated only when a remote check was requested (token given + `checkRemote`). */
57
+ ahead?: number;
58
+ behind?: number;
59
+ remoteAhead?: boolean;
60
+ }
61
+
62
+ export interface GitStatusOpts {
63
+ /** Scope status to files under this repo-relative prefix (the designRoot). Omit = whole repo. */
64
+ designPrefix?: string;
65
+ /** When true AND `token` given, fetch the tracking remote and compute ahead/behind. */
66
+ checkRemote?: boolean;
67
+ token?: string;
68
+ remote?: string;
69
+ }
70
+
71
+ export interface GitCommitResult {
72
+ ok: boolean;
73
+ sha?: string;
74
+ error?: string;
75
+ }
76
+
77
+ export interface GitPushResult {
78
+ ok: boolean;
79
+ /** True when the remote rejected a non-fast-forward push — the only Publish conflict. */
80
+ conflict?: boolean;
81
+ /** True when the operation needs a GitHub sign-in we don't have yet (phase-28
82
+ * keychain). The iso-git engine can't use a system credential helper, so a
83
+ * tokenless publish on the default engine lands here → "Sign in to publish". */
84
+ authRequired?: boolean;
85
+ error?: string;
86
+ }
87
+
88
+ export interface GitPullResult {
89
+ ok: boolean;
90
+ /** True when the merge hit a real content conflict; `files` lists the conflicted paths. */
91
+ conflict?: boolean;
92
+ files?: string[];
93
+ /** See GitPushResult.authRequired. */
94
+ authRequired?: boolean;
95
+ error?: string;
96
+ }
97
+
98
+ export interface GitResolveResult {
99
+ ok: boolean;
100
+ /** Conflicted paths that still couldn't be auto-resolved (empty on success). */
101
+ unresolved?: string[];
102
+ /** "Keep both" copies written alongside (zero data loss). */
103
+ copies?: string[];
104
+ /** See GitPushResult.authRequired. */
105
+ authRequired?: boolean;
106
+ error?: string;
107
+ }
108
+
109
+ export type ResolveChoice = 'mine' | 'theirs' | 'both';
110
+
111
+ export interface GitLogEntry {
112
+ sha: string;
113
+ message: string;
114
+ author: string;
115
+ email: string;
116
+ /** ISO-8601 commit date. */
117
+ date: string;
118
+ }
119
+
120
+ export interface GitDiffEntry {
121
+ file: string;
122
+ before: string;
123
+ after: 'workdir';
124
+ }
125
+
126
+ // ── helpers ──────────────────────────────────────────────────────────────────
127
+
128
+ /** Resolve the `.git` dir for `dir`, or null if `dir` isn't in a git repo. We
129
+ * only support a `.git` directly at `dir` (the managed-clone layout, DDR-111) —
130
+ * no walk-up, so a `.design/` nested in a larger repo doesn't accidentally
131
+ * surface the parent repo's unrelated history. */
132
+ function isRepo(dir: string): boolean {
133
+ return existsSync(join(dir, '.git'));
134
+ }
135
+
136
+ /** Normalize a status-matrix prefix: strip leading/trailing slashes, forward-slash. */
137
+ function normPrefix(p?: string): string {
138
+ if (!p) return '';
139
+ return p.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
140
+ }
141
+
142
+ function underPrefix(filepath: string, prefix: string): boolean {
143
+ if (!prefix) return true;
144
+ return filepath === prefix || filepath.startsWith(`${prefix}/`);
145
+ }
146
+
147
+ /** Maude's own per-machine / per-user runtime state under the design root —
148
+ * NEVER versioned design content. It must never surface in the Changes panel
149
+ * nor be swept up by a "Save all" commit. The canonical IGNORED set is the
150
+ * DDR-115 taxonomy (also mirrored by `cli/lib/gitignore-block.mjs` + the repo
151
+ * `.gitignore`). A real managed project gitignores these; this is the backstop
152
+ * for a project that lacks the gitignore block.
153
+ *
154
+ * DDR-115 divergence — the rule used to claim BOTH comments and annotations
155
+ * were versionable. It now splits:
156
+ * - `*.annotations.svg` → VERSIONED (durable visual markup, no other
157
+ * transport) → NOT hidden here.
158
+ * - `_comments/` → hub-sync-only (DDR-102 CRDT) → HIDDEN, so it never
159
+ * double-transports through git. */
160
+ function isMaudeRuntimeState(p: string): boolean {
161
+ return (
162
+ /(^|\/)_(?:server|active|sync|preflight|locator|export-history)\.json$/.test(p) ||
163
+ /(^|\/)_server\.(?:lock|log)$/.test(p) ||
164
+ /(^|\/)_(?:history|trash|draw|smoke|canvas-state|state|chat|comments|untrusted)(?:\/|$)/.test(p)
165
+ );
166
+ }
167
+
168
+ /** Map an isomorphic-git statusMatrix row [head, workdir, stage] → our state, or
169
+ * null when the file is unmodified (so it's dropped from the Changes list).
170
+ * head: 0 absent in HEAD, 1 present.
171
+ * workdir: 0 absent, 1 == HEAD, 2 differs.
172
+ * stage: 0 absent, 1 == HEAD, 2 == workdir, 3 differs from both. */
173
+ function classify(head: number, workdir: number, _stage: number): GitFileState | null {
174
+ if (head === 0 && workdir === 0) return null; // never existed / fully removed-and-staged-away
175
+ if (head === 1 && workdir === 0) return 'deleted'; // tracked, now gone
176
+ if (head === 0 && workdir === 2) {
177
+ // New file. "Added" once git has it staged; otherwise brand-new "untracked".
178
+ return _stage === 0 ? 'untracked' : 'added';
179
+ }
180
+ if (head === 1 && workdir === 1) return null; // identical to HEAD
181
+ if (head === 1 && workdir === 2) return 'modified';
182
+ return null;
183
+ }
184
+
185
+ interface GitAuthor {
186
+ name: string;
187
+ email: string;
188
+ }
189
+
190
+ async function resolveAuthor(dir: string): Promise<GitAuthor> {
191
+ // git config user.name / user.email against the repo, with a Maude default so a
192
+ // commit never fails on an unconfigured identity (the non-technical case).
193
+ let name = '';
194
+ let email = '';
195
+ try {
196
+ name = (await git.getConfig({ fs, dir, path: 'user.name' })) ?? '';
197
+ } catch {
198
+ /* unset */
199
+ }
200
+ try {
201
+ email = (await git.getConfig({ fs, dir, path: 'user.email' })) ?? '';
202
+ } catch {
203
+ /* unset */
204
+ }
205
+ return {
206
+ name: name.trim() || 'Maude',
207
+ email: email.trim() || 'maude@localhost',
208
+ };
209
+ }
210
+
211
+ // ── system-git fallback (MAUDE_USE_SYSTEM_GIT=1) ─────────────────────────────
212
+
213
+ interface RunResult {
214
+ code: number;
215
+ stdout: string;
216
+ stderr: string;
217
+ }
218
+
219
+ /** Run `git <args>` in `dir`. `tokenRemote` (when given) replaces the `origin`
220
+ * URL's userinfo with the token for this one invocation via `-c` config so the
221
+ * PAT never lands in the on-disk remote URL or the process title's argv beyond
222
+ * the ephemeral child. */
223
+ function runGit(dir: string, args: string[], env?: Record<string, string>): Promise<RunResult> {
224
+ return new Promise((resolveRun) => {
225
+ const child = spawn('git', args, {
226
+ cwd: dir,
227
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0', ...env },
228
+ stdio: ['ignore', 'pipe', 'pipe'],
229
+ });
230
+ let stdout = '';
231
+ let stderr = '';
232
+ child.stdout.on('data', (d) => {
233
+ stdout += d.toString();
234
+ });
235
+ child.stderr.on('data', (d) => {
236
+ stderr += d.toString();
237
+ });
238
+ child.on('error', (e) => resolveRun({ code: 127, stdout, stderr: String(e) }));
239
+ child.on('close', (code) => resolveRun({ code: code ?? 1, stdout, stderr }));
240
+ });
241
+ }
242
+
243
+ // ── status ───────────────────────────────────────────────────────────────────
244
+
245
+ export async function gitStatus(dir: string, opts: GitStatusOpts = {}): Promise<GitStatusResult> {
246
+ if (!isRepo(dir)) {
247
+ return { repo: false, branch: null, files: [], clean: true, unpushed: 0 };
248
+ }
249
+ const prefix = normPrefix(opts.designPrefix);
250
+ const result = USE_SYSTEM_GIT ? await statusSystem(dir, prefix) : await statusIso(dir, prefix);
251
+
252
+ // Local "saved but not published" count — no network (uses the cached
253
+ // remote-tracking ref). Lets the panel offer Publish even on a clean tree.
254
+ result.unpushed = await localUnpushed(dir, result.branch, opts.remote || 'origin').catch(() => 0);
255
+
256
+ if (opts.checkRemote) {
257
+ try {
258
+ const { ahead, behind } = await remoteAheadBehind(dir, opts.token, opts.remote);
259
+ result.ahead = ahead;
260
+ result.behind = behind;
261
+ result.remoteAhead = behind > 0;
262
+ } catch {
263
+ // Remote unreachable / no tracking branch — leave the nudge fields unset so
264
+ // the UI just doesn't show a "Get latest" banner. Never fatal to status.
265
+ }
266
+ }
267
+ return result;
268
+ }
269
+
270
+ async function statusIso(dir: string, prefix: string): Promise<GitStatusResult> {
271
+ const branch = (await git.currentBranch({ fs, dir, fullname: false })) ?? null;
272
+ const matrix = await git.statusMatrix({
273
+ fs,
274
+ dir,
275
+ filter: prefix ? (f) => underPrefix(f, prefix) : undefined,
276
+ });
277
+ const files: GitFileStatus[] = [];
278
+ for (const [filepath, head, workdir, stage] of matrix) {
279
+ if (isMaudeRuntimeState(filepath)) continue;
280
+ const state = classify(head, workdir, stage);
281
+ if (state) files.push({ path: filepath, status: state });
282
+ }
283
+ files.sort((a, b) => a.path.localeCompare(b.path));
284
+ return { repo: true, branch, files, clean: files.length === 0, unpushed: 0 };
285
+ }
286
+
287
+ async function statusSystem(dir: string, prefix: string): Promise<GitStatusResult> {
288
+ const br = await runGit(dir, ['rev-parse', '--abbrev-ref', 'HEAD']);
289
+ const branch = br.code === 0 ? br.stdout.trim() || null : null;
290
+ // -z NUL-delimited porcelain v1 so filenames with spaces survive intact.
291
+ const st = await runGit(dir, ['status', '--porcelain', '-z', '--untracked-files=all']);
292
+ const files: GitFileStatus[] = [];
293
+ if (st.code === 0) {
294
+ const records = st.stdout.split('\0').filter(Boolean);
295
+ for (const rec of records) {
296
+ // Each record: `XY <path>` (rename's second path arrives as its own record).
297
+ const xy = rec.slice(0, 2);
298
+ const path = rec.slice(3).replace(/\\/g, '/');
299
+ if (!path || !underPrefix(path, prefix) || isMaudeRuntimeState(path)) continue;
300
+ const state = classifyPorcelain(xy);
301
+ if (state) files.push({ path, status: state });
302
+ }
303
+ }
304
+ files.sort((a, b) => a.path.localeCompare(b.path));
305
+ return { repo: true, branch, files, clean: files.length === 0, unpushed: 0 };
306
+ }
307
+
308
+ function classifyPorcelain(xy: string): GitFileState | null {
309
+ if (xy === '??') return 'untracked';
310
+ const x = xy[0];
311
+ const y = xy[1];
312
+ if (x === 'D' || y === 'D') return 'deleted';
313
+ if (x === 'A') return 'added';
314
+ if (x === 'M' || y === 'M' || x === 'R' || x === 'C') return 'modified';
315
+ return null;
316
+ }
317
+
318
+ // ── commit (Save version) ─────────────────────────────────────────────────────
319
+
320
+ /** Save selected files as one version. `files` are repo-relative paths the user
321
+ * checked; an empty/undefined list means "Save all" (every changed file under
322
+ * `designPrefix`). Each file is staged add-or-remove based on its workdir
323
+ * presence, then one commit lands. Returns the new sha. */
324
+ export async function gitCommit(
325
+ dir: string,
326
+ message: string,
327
+ files?: string[],
328
+ opts: { designPrefix?: string } = {}
329
+ ): Promise<GitCommitResult> {
330
+ if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
331
+ const msg = (message ?? '').trim();
332
+ if (!msg) return { ok: false, error: 'A version needs a short message.' };
333
+
334
+ const prefix = normPrefix(opts.designPrefix);
335
+ // Resolve the working set: explicit selection, else every changed file in scope.
336
+ const status = await gitStatus(dir, { designPrefix: prefix });
337
+ if (status.clean) return { ok: false, error: 'Nothing to save.' };
338
+
339
+ let selected: GitFileStatus[];
340
+ if (files?.length) {
341
+ const want = new Set(files.map((f) => f.replace(/\\/g, '/')));
342
+ selected = status.files.filter((f) => want.has(f.path));
343
+ if (!selected.length) return { ok: false, error: 'None of the selected files have changes.' };
344
+ } else {
345
+ selected = status.files;
346
+ }
347
+
348
+ return USE_SYSTEM_GIT ? commitSystem(dir, msg, selected) : commitIso(dir, msg, selected);
349
+ }
350
+
351
+ async function commitIso(
352
+ dir: string,
353
+ message: string,
354
+ selected: GitFileStatus[]
355
+ ): Promise<GitCommitResult> {
356
+ try {
357
+ for (const f of selected) {
358
+ if (f.status === 'deleted') {
359
+ await git.remove({ fs, dir, filepath: f.path });
360
+ } else {
361
+ await git.add({ fs, dir, filepath: f.path });
362
+ }
363
+ }
364
+ const author = await resolveAuthor(dir);
365
+ const sha = await git.commit({ fs, dir, message, author });
366
+ return { ok: true, sha };
367
+ } catch (e) {
368
+ return { ok: false, error: errMsg(e) };
369
+ }
370
+ }
371
+
372
+ async function commitSystem(
373
+ dir: string,
374
+ message: string,
375
+ selected: GitFileStatus[]
376
+ ): Promise<GitCommitResult> {
377
+ for (const f of selected) {
378
+ const args = f.status === 'deleted' ? ['rm', '--', f.path] : ['add', '--', f.path];
379
+ const r = await runGit(dir, args);
380
+ if (r.code !== 0) return { ok: false, error: r.stderr.trim() || 'stage failed' };
381
+ }
382
+ // -m via argv (message is server-side; no shell interpolation through spawn).
383
+ const c = await runGit(dir, ['commit', '-m', message]);
384
+ if (c.code !== 0) return { ok: false, error: c.stderr.trim() || 'commit failed' };
385
+ const head = await runGit(dir, ['rev-parse', 'HEAD']);
386
+ return { ok: true, sha: head.stdout.trim() };
387
+ }
388
+
389
+ // ── discard (revert a change) ───────────────────────────────────────────────
390
+
391
+ export interface GitDiscardResult {
392
+ ok: boolean;
393
+ discarded?: string[];
394
+ error?: string;
395
+ }
396
+
397
+ /** Throw away the unsaved changes to `files` — the Changes-panel per-file undo.
398
+ * A tracked file (modified/deleted) is restored from HEAD; an untracked file is
399
+ * deleted (it has no HEAD version to restore). Destructive by intent; the UI
400
+ * confirms first. Each path is the endpoint-validated repo-relative form. */
401
+ export async function gitDiscard(
402
+ dir: string,
403
+ files: string[],
404
+ opts: { designPrefix?: string } = {}
405
+ ): Promise<GitDiscardResult> {
406
+ if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
407
+ if (!files?.length) return { ok: false, error: 'Nothing selected to discard.' };
408
+ const prefix = normPrefix(opts.designPrefix);
409
+ const status = await gitStatus(dir, { designPrefix: prefix });
410
+ const byPath = new Map(status.files.map((f) => [f.path, f.status]));
411
+ const targets = files.map((f) => f.replace(/\\/g, '/')).filter((f) => byPath.has(f));
412
+ if (!targets.length) return { ok: false, error: 'None of those files have changes.' };
413
+
414
+ try {
415
+ for (const f of targets) {
416
+ if (byPath.get(f) === 'untracked') {
417
+ await fs.promises.rm(join(dir, f), { force: true });
418
+ } else if (USE_SYSTEM_GIT) {
419
+ const r = await runGit(dir, ['checkout', 'HEAD', '--', f]);
420
+ if (r.code !== 0) return { ok: false, error: r.stderr.trim() || 'discard failed' };
421
+ } else {
422
+ await git.checkout({ fs, dir, filepaths: [f], ref: 'HEAD', force: true });
423
+ }
424
+ }
425
+ return { ok: true, discarded: targets };
426
+ } catch (e) {
427
+ return { ok: false, error: errMsg(e) };
428
+ }
429
+ }
430
+
431
+ // ── branches (DRAFTS — phase 29 / E4) ────────────────────────────────────────
432
+ // The UI never says "branch": a side branch is a "draft", and `main`/`master` is
433
+ // the "Shared version". Switching a draft moves HEAD, which the git-lifecycle.ts
434
+ // `.git/HEAD` watcher already turns into a Yjs flush + reload prompt (DDR-051) —
435
+ // this module does NOT duplicate that.
436
+
437
+ export interface GitBranch {
438
+ name: string;
439
+ current: boolean;
440
+ }
441
+
442
+ /** List local branches (drafts). Returns [] when `dir` isn't a repo. */
443
+ export async function gitListBranches(dir: string): Promise<GitBranch[]> {
444
+ if (!isRepo(dir)) return [];
445
+ try {
446
+ if (USE_SYSTEM_GIT) {
447
+ const cur = (await runGit(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim();
448
+ const r = await runGit(dir, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']);
449
+ if (r.code !== 0) return [];
450
+ return r.stdout
451
+ .split('\n')
452
+ .map((s) => s.trim())
453
+ .filter(Boolean)
454
+ .map((name) => ({ name, current: name === cur }));
455
+ }
456
+ const cur = (await git.currentBranch({ fs, dir, fullname: false })) ?? null;
457
+ const names = await git.listBranches({ fs, dir });
458
+ return names.map((name) => ({ name, current: name === cur }));
459
+ } catch {
460
+ return [];
461
+ }
462
+ }
463
+
464
+ export interface GitBranchResult {
465
+ ok: boolean;
466
+ branch?: string;
467
+ error?: string;
468
+ }
469
+
470
+ /** Create a new draft off HEAD and switch to it. The name is validated against the
471
+ * same dash-led / charset guard as every other positional (defense-in-depth). */
472
+ export async function gitCreateBranch(dir: string, name: string): Promise<GitBranchResult> {
473
+ if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
474
+ if (!isSafeGitPositional(name))
475
+ return { ok: false, error: "That draft name has characters we can't use." };
476
+ try {
477
+ const existing = await gitListBranches(dir);
478
+ if (existing.some((b) => b.name === name))
479
+ return { ok: false, error: 'A draft with that name already exists.' };
480
+ if (USE_SYSTEM_GIT) {
481
+ const r = await runGit(dir, ['checkout', '-b', name]);
482
+ if (r.code !== 0)
483
+ return { ok: false, error: r.stderr.trim() || 'Could not create the draft.' };
484
+ } else {
485
+ await git.branch({ fs, dir, ref: name, checkout: true });
486
+ }
487
+ return { ok: true, branch: name };
488
+ } catch (e) {
489
+ return { ok: false, error: errMsg(e) };
490
+ }
491
+ }
492
+
493
+ /** Switch to an existing draft (or back to the Shared version). A dirty tree that
494
+ * would be clobbered surfaces a plain "Save your changes first" rather than a
495
+ * raw git error. */
496
+ export async function gitCheckout(dir: string, name: string): Promise<GitBranchResult> {
497
+ if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
498
+ if (!isSafeGitPositional(name)) return { ok: false, error: 'Invalid draft name.' };
499
+ try {
500
+ if (USE_SYSTEM_GIT) {
501
+ const r = await runGit(dir, ['checkout', name]);
502
+ if (r.code !== 0) {
503
+ const blob = `${r.stderr} ${r.stdout}`.toLowerCase();
504
+ if (blob.includes('would be overwritten') || blob.includes('local changes'))
505
+ return { ok: false, error: 'Save your changes before switching drafts.' };
506
+ return { ok: false, error: r.stderr.trim() || 'Could not switch drafts.' };
507
+ }
508
+ } else {
509
+ await git.checkout({ fs, dir, ref: name });
510
+ }
511
+ return { ok: true, branch: name };
512
+ } catch (e) {
513
+ const msg = errMsg(e);
514
+ if (/overwrit|local change|conflict/i.test(msg))
515
+ return { ok: false, error: 'Save your changes before switching drafts.' };
516
+ return { ok: false, error: msg };
517
+ }
518
+ }
519
+
520
+ /** The "Shared version" is whichever of these exists (main preferred). */
521
+ const SHARED_BRANCHES = new Set(['main', 'master']);
522
+
523
+ export interface GitFoldResult {
524
+ ok: boolean;
525
+ /** A non-FF / rejected publish reuses the plain "Get latest first" path, never a merge UI. */
526
+ conflict?: boolean;
527
+ authRequired?: boolean;
528
+ error?: string;
529
+ /** The Shared-version branch the draft was added to. */
530
+ shared?: string;
531
+ }
532
+
533
+ /** "Add this draft to the Shared version" (phase-29 / E4, Task 7): merge the draft
534
+ * into the Shared version (main/master, FF when possible), publish it, then remove
535
+ * the draft. A content conflict or a rejected publish surfaces the plain "Get latest
536
+ * first" path (no 3-way merge UI). The local draft is removed ONLY after a clean
537
+ * publish, so a rejected publish leaves a recoverable state. */
538
+ export async function gitFoldDraft(
539
+ dir: string,
540
+ draftName: string,
541
+ token: string | undefined,
542
+ opts: { remote?: string } = {}
543
+ ): Promise<GitFoldResult> {
544
+ if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
545
+ if (!isSafeGitPositional(draftName)) return { ok: false, error: 'Invalid draft name.' };
546
+ const remote = opts.remote || 'origin';
547
+ const branches = await gitListBranches(dir);
548
+ const shared = branches.find((b) => SHARED_BRANCHES.has(b.name))?.name;
549
+ if (!shared) return { ok: false, error: 'This project has no Shared version yet.' };
550
+ if (draftName === shared) return { ok: false, error: "That's already the Shared version." };
551
+ if (!branches.some((b) => b.name === draftName))
552
+ return { ok: false, error: "That draft doesn't exist." };
553
+
554
+ // Merge the draft into the Shared version (FF when possible, else a merge commit).
555
+ try {
556
+ if (USE_SYSTEM_GIT) {
557
+ const co = await runGit(dir, ['checkout', shared]);
558
+ if (co.code !== 0) return { ok: false, error: 'Save your changes before adding the draft.' };
559
+ const mg = await runGit(dir, ['merge', draftName]);
560
+ if (mg.code !== 0) {
561
+ await runGit(dir, ['merge', '--abort']).catch(() => {});
562
+ await runGit(dir, ['checkout', draftName]).catch(() => {});
563
+ return {
564
+ ok: false,
565
+ conflict: true,
566
+ error: 'Get the latest Shared version first, then add your draft.',
567
+ };
568
+ }
569
+ } else {
570
+ await git.checkout({ fs, dir, ref: shared });
571
+ const author = await resolveAuthor(dir);
572
+ await git.merge({
573
+ fs,
574
+ dir,
575
+ ours: shared,
576
+ theirs: draftName,
577
+ author,
578
+ fastForward: true,
579
+ message: `Add draft "${draftName}" to the Shared version`,
580
+ });
581
+ await git.checkout({ fs, dir, ref: shared, force: true });
582
+ }
583
+ } catch (e) {
584
+ if (!USE_SYSTEM_GIT) {
585
+ try {
586
+ await git.checkout({ fs, dir, ref: draftName, force: true });
587
+ } catch {
588
+ /* best effort — leave the user on whatever checked out */
589
+ }
590
+ }
591
+ const msg = errMsg(e);
592
+ if (/overwrit|local change|save/i.test(msg))
593
+ return { ok: false, error: 'Save your changes before adding the draft.' };
594
+ return {
595
+ ok: false,
596
+ conflict: true,
597
+ error: 'Get the latest Shared version first, then add your draft.',
598
+ };
599
+ }
600
+
601
+ // Publish the Shared version.
602
+ const push = await gitPush(dir, token, { remote, ref: shared });
603
+ if (!push.ok) {
604
+ if (push.authRequired) return { ok: false, authRequired: true, error: push.error };
605
+ if (push.conflict)
606
+ return {
607
+ ok: false,
608
+ conflict: true,
609
+ error: 'Someone else published — Get latest first, then add your draft.',
610
+ };
611
+ return { ok: false, error: push.error ?? 'Could not publish the Shared version.' };
612
+ }
613
+
614
+ // The draft's work is now in the Shared version — remove the draft (local only).
615
+ try {
616
+ if (USE_SYSTEM_GIT) await runGit(dir, ['branch', '-D', draftName]);
617
+ else await git.deleteBranch({ fs, dir, ref: draftName });
618
+ } catch {
619
+ /* non-fatal: the fold + publish succeeded; a leftover draft ref is harmless */
620
+ }
621
+
622
+ return { ok: true, shared };
623
+ }
624
+
625
+ // ── push (Publish) ─────────────────────────────────────────────────────────
626
+
627
+ /** Publish. `token` is optional in phase-27: the system-git engine falls back to
628
+ * the user's configured credential helper / SSH, so a developer-ish user who
629
+ * cloned with system git can publish today (no in-UI token). The iso-git default
630
+ * engine needs the token (no helper integration) → `authRequired` when absent,
631
+ * which the UI renders as "Sign in to publish" (phase-28 keychain fills it). */
632
+ export async function gitPush(
633
+ dir: string,
634
+ token: string | undefined,
635
+ opts: { remote?: string; ref?: string } = {}
636
+ ): Promise<GitPushResult> {
637
+ if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
638
+ const remote = opts.remote || 'origin';
639
+ return USE_SYSTEM_GIT
640
+ ? pushSystem(dir, token, remote, opts.ref)
641
+ : pushIso(dir, token, remote, opts.ref);
642
+ }
643
+
644
+ async function pushIso(
645
+ dir: string,
646
+ token: string | undefined,
647
+ remote: string,
648
+ ref?: string
649
+ ): Promise<GitPushResult> {
650
+ if (!token) return { ok: false, authRequired: true, error: 'Sign in to publish.' };
651
+ try {
652
+ const branch = ref || (await git.currentBranch({ fs, dir, fullname: false })) || 'main';
653
+ const res = await git.push({
654
+ fs,
655
+ http,
656
+ dir,
657
+ remote,
658
+ ref: branch,
659
+ // GitHub PAT over HTTPS basic-auth: token as username, empty password
660
+ // (Task 3 gotcha — NOT a Bearer header). isomorphic-git never logs this.
661
+ onAuth: () => ({ username: token, password: '' }),
662
+ });
663
+ // PushResult.ok is true on success; a rejected ref carries an error string.
664
+ if (res.ok) {
665
+ // iso-git's push does NOT advance the local remote-tracking ref, so the
666
+ // "ready to publish" count (localUnpushed, which compares HEAD against
667
+ // refs/remotes/<remote>/<branch>) would keep counting the commits we just
668
+ // pushed. Point the tracking ref at what we pushed so it clears to 0.
669
+ const oid = await git.resolveRef({ fs, dir, ref: branch }).catch(() => null);
670
+ if (oid) {
671
+ await git
672
+ .writeRef({ fs, dir, ref: `refs/remotes/${remote}/${branch}`, value: oid, force: true })
673
+ .catch(() => {});
674
+ }
675
+ return { ok: true };
676
+ }
677
+ const errors = Object.values(res.refs ?? {})
678
+ .map((r) => (r as { error?: string }).error)
679
+ .filter(Boolean) as string[];
680
+ const blob = `${res.error ?? ''} ${errors.join(' ')}`.toLowerCase();
681
+ if (isNonFastForward(blob)) return { ok: false, conflict: true };
682
+ return { ok: false, error: errors[0] || res.error || 'Publish failed.' };
683
+ } catch (e) {
684
+ const msg = errMsg(e);
685
+ if (isNonFastForward(msg)) return { ok: false, conflict: true };
686
+ return { ok: false, error: msg };
687
+ }
688
+ }
689
+
690
+ async function pushSystem(
691
+ dir: string,
692
+ token: string | undefined,
693
+ remote: string,
694
+ ref?: string
695
+ ): Promise<GitPushResult> {
696
+ const branch = ref || (await runGit(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim();
697
+ if (!isSafeGitPositional(remote) || (branch && !isSafeGitPositional(branch))) {
698
+ return { ok: false, error: 'Invalid remote or draft name.' };
699
+ }
700
+ // With a token, inject it via an ephemeral http.extraheader (never lands on
701
+ // disk). Without one, fall through to the user's configured credential helper /
702
+ // SSH agent — the phase-27 "I cloned with system git" publish path.
703
+ const args = tokenHeaderArgs(token);
704
+ args.push('push', remote, branch || 'HEAD');
705
+ const r = await runGit(dir, args);
706
+ if (r.code === 0) return { ok: true };
707
+ if (isNonFastForward(`${r.stderr} ${r.stdout}`.toLowerCase()))
708
+ return { ok: false, conflict: true };
709
+ return { ok: false, error: r.stderr.trim() || 'Publish failed.' };
710
+ }
711
+
712
+ /** Defense-in-depth (security re-review): the endpoint already validates
713
+ * `remote`/`ref`, but the system-git engine passes them as bare argv positionals,
714
+ * so a dash-led value would be parsed as an OPTION (argument injection, A1). This
715
+ * is a SECOND guard — a future relaxation of the endpoint regex can't silently
716
+ * re-open the class. A real git remote/ref/branch never starts with `-`. */
717
+ const SAFE_GIT_POSITIONAL = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
718
+ function isSafeGitPositional(v: string): boolean {
719
+ return SAFE_GIT_POSITIONAL.test(v);
720
+ }
721
+
722
+ /** Ephemeral `git -c http.extraheader=…` args carrying a token as HTTPS basic
723
+ * auth, or `[]` when no token (fall back to the user's credential helper). The
724
+ * header is per-invocation so the PAT never lands in the on-disk remote URL. */
725
+ function tokenHeaderArgs(token: string | undefined): string[] {
726
+ if (!token) return [];
727
+ const auth = Buffer.from(`x-access-token:${token}`).toString('base64');
728
+ return ['-c', `http.extraheader=Authorization: Basic ${auth}`];
729
+ }
730
+
731
+ function isNonFastForward(blob: string): boolean {
732
+ return (
733
+ blob.includes('non-fast-forward') ||
734
+ blob.includes('not a simple fast-forward') ||
735
+ blob.includes('fetch first') ||
736
+ blob.includes('rejected') ||
737
+ blob.includes('updates were rejected')
738
+ );
739
+ }
740
+
741
+ // ── pull (Get latest) ─────────────────────────────────────────────────────
742
+
743
+ /** Get latest. Same optional-token model as gitPush (see its doc comment). */
744
+ export async function gitPull(
745
+ dir: string,
746
+ token: string | undefined,
747
+ opts: { remote?: string; ref?: string } = {}
748
+ ): Promise<GitPullResult> {
749
+ if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
750
+ return USE_SYSTEM_GIT
751
+ ? pullSystem(dir, token, opts.remote || 'origin', opts.ref)
752
+ : pullIso(dir, token, opts.remote || 'origin', opts.ref);
753
+ }
754
+
755
+ async function pullIso(
756
+ dir: string,
757
+ token: string | undefined,
758
+ remote: string,
759
+ ref?: string
760
+ ): Promise<GitPullResult> {
761
+ if (!token) return { ok: false, authRequired: true, error: 'Sign in to get the latest.' };
762
+ try {
763
+ const branch = ref || (await git.currentBranch({ fs, dir, fullname: false })) || 'main';
764
+ const author = await resolveAuthor(dir);
765
+ await git.pull({
766
+ fs,
767
+ http,
768
+ dir,
769
+ remote,
770
+ ref: branch,
771
+ singleBranch: true,
772
+ author,
773
+ onAuth: () => ({ username: token, password: '' }),
774
+ });
775
+ return { ok: true };
776
+ } catch (e) {
777
+ // isomorphic-git surfaces a real content conflict as MergeConflictError, whose
778
+ // `data` is the list of conflicted filepaths. DiffView opens on these.
779
+ const conflictFiles = mergeConflictFiles(e);
780
+ if (conflictFiles) return { ok: false, conflict: true, files: conflictFiles };
781
+ return { ok: false, error: errMsg(e) };
782
+ }
783
+ }
784
+
785
+ async function pullSystem(
786
+ dir: string,
787
+ token: string | undefined,
788
+ remote: string,
789
+ ref?: string
790
+ ): Promise<GitPullResult> {
791
+ const branch = ref || (await runGit(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim();
792
+ if (!isSafeGitPositional(remote) || (branch && !isSafeGitPositional(branch))) {
793
+ return { ok: false, error: 'Invalid remote or draft name.' };
794
+ }
795
+ const args = tokenHeaderArgs(token);
796
+ args.push('pull', '--no-rebase', remote, branch || 'HEAD');
797
+ const r = await runGit(dir, args);
798
+ if (r.code === 0) return { ok: true };
799
+ const blob = `${r.stderr}\n${r.stdout}`;
800
+ if (/conflict/i.test(blob)) {
801
+ // Parse `CONFLICT (content): Merge conflict in <path>` lines.
802
+ const files = [...blob.matchAll(/Merge conflict in (.+)/g)].map((m) => m[1].trim());
803
+ return { ok: false, conflict: true, files: files.length ? files : undefined };
804
+ }
805
+ return { ok: false, error: r.stderr.trim() || 'Get latest failed.' };
806
+ }
807
+
808
+ // ── resolve (finish a Get-latest merge that hit a conflict) ─────────────────
809
+
810
+ /** Finish the merge `gitPull` left unresolved, applying one CHOICE to every
811
+ * conflicted file:
812
+ * • `mine` — keep our version (their edits set aside)
813
+ * • `theirs` — take the incoming version
814
+ * • `both` — take theirs AND save ours as a "<name> (mine)<ext>" copy (the
815
+ * DiffView zero-data-loss default).
816
+ * Produces the two-parent merge commit so a subsequent Publish fast-forwards. */
817
+ export async function gitResolve(
818
+ dir: string,
819
+ choice: ResolveChoice,
820
+ token: string | undefined,
821
+ opts: { remote?: string; ref?: string } = {}
822
+ ): Promise<GitResolveResult> {
823
+ if (!isRepo(dir)) return { ok: false, error: 'This project is not versioned yet.' };
824
+ if (choice !== 'mine' && choice !== 'theirs' && choice !== 'both')
825
+ return { ok: false, error: 'Pick how to resolve: keep mine, theirs, or both.' };
826
+ return USE_SYSTEM_GIT
827
+ ? resolveSystem(dir, choice, opts.remote || 'origin', opts.ref)
828
+ : resolveIso(dir, choice, token, opts.remote || 'origin', opts.ref);
829
+ }
830
+
831
+ async function resolveIso(
832
+ dir: string,
833
+ choice: ResolveChoice,
834
+ _token: string | undefined,
835
+ remote: string,
836
+ ref?: string
837
+ ): Promise<GitResolveResult> {
838
+ try {
839
+ const branch = ref || (await git.currentBranch({ fs, dir, fullname: false })) || 'main';
840
+ if (!isSafeGitPositional(remote) || !isSafeGitPositional(branch))
841
+ return { ok: false, error: 'Invalid remote or draft name.' };
842
+ const author = await resolveAuthor(dir);
843
+ const theirsRef = `${remote}/${branch}`;
844
+
845
+ // `gitPull` already fetched `theirsRef`; resolve OIDs from local refs (no net).
846
+ const ourOid = await git.resolveRef({ fs, dir, ref: branch });
847
+ try {
848
+ await git.resolveRef({ fs, dir, ref: theirsRef });
849
+ } catch {
850
+ return { ok: false, error: 'Get the latest again — the shared copy moved.' };
851
+ }
852
+
853
+ // Authoritative conflicted set (dry-run merge with the default marker driver
854
+ // throws MergeConflictError listing them) — drives the "both" copies.
855
+ let conflicted: string[] = [];
856
+ try {
857
+ await git.merge({ fs, dir, ours: branch, theirs: theirsRef, author, dryRun: true });
858
+ } catch (e) {
859
+ conflicted = mergeConflictFiles(e) || [];
860
+ }
861
+
862
+ // A mergeDriver that returns ONE whole side per blob is always a clean merge,
863
+ // so git.merge resolves every conflict and writes the two-parent merge commit;
864
+ // non-conflicting changes from both sides still merge normally.
865
+ const wantOurs = choice === 'mine';
866
+ const mergeDriver = ({ contents }: { contents: string[] }) => ({
867
+ mergedText: wantOurs ? contents[1] : contents[2], // [base, ours, theirs]
868
+ cleanMerge: true,
869
+ });
870
+ await git.merge({
871
+ fs,
872
+ dir,
873
+ ours: branch,
874
+ theirs: theirsRef,
875
+ author,
876
+ message: `Get latest — kept ${choice}`,
877
+ mergeDriver,
878
+ });
879
+ await git.checkout({ fs, dir, ref: branch, force: true });
880
+
881
+ // "Keep both": theirs won the merged file; write OUR version as a sibling copy
882
+ // and commit it so nothing is lost.
883
+ const copies: string[] = [];
884
+ if (choice === 'both') {
885
+ for (const fp of conflicted) {
886
+ try {
887
+ const copyRel = mineCopyPath(fp);
888
+ // Containment guard (audit F-1/D-2): every other write in this module
889
+ // goes through isContainedRepoPath; the copy path is git-tree-derived
890
+ // (can't hold `..` today) but the invariant must hold unconditionally.
891
+ if (!isContainedRepoPath(dir, copyRel)) continue;
892
+ const { blob } = await git.readBlob({ fs, dir, oid: ourOid, filepath: fp });
893
+ fs.writeFileSync(join(dir, copyRel), Buffer.from(blob));
894
+ await git.add({ fs, dir, filepath: copyRel });
895
+ copies.push(copyRel);
896
+ } catch {
897
+ /* add/delete conflict — no our-side blob to copy; skip */
898
+ }
899
+ }
900
+ if (copies.length)
901
+ await git.commit({ fs, dir, author, message: 'Saved my version as a copy' });
902
+ }
903
+ return { ok: true, copies: copies.length ? copies : undefined };
904
+ } catch (e) {
905
+ const cf = mergeConflictFiles(e);
906
+ if (cf)
907
+ return { ok: false, unresolved: cf, error: 'Could not finish the merge automatically.' };
908
+ return { ok: false, error: errMsg(e) };
909
+ }
910
+ }
911
+
912
+ async function resolveSystem(
913
+ dir: string,
914
+ choice: ResolveChoice,
915
+ _remote: string,
916
+ _ref?: string
917
+ ): Promise<GitResolveResult> {
918
+ // After `git pull --no-rebase` hit a conflict the repo is mid-merge (MERGE_HEAD
919
+ // + unmerged index). Resolve each unmerged path with the chosen side, commit.
920
+ const u = await runGit(dir, ['diff', '--name-only', '--diff-filter=U']);
921
+ const files = u.stdout
922
+ .split('\n')
923
+ .map((s) => s.trim())
924
+ .filter(Boolean);
925
+ if (!files.length) return { ok: false, error: 'Nothing to resolve — get the latest first.' };
926
+ const copies: string[] = [];
927
+ // Paths come from git's own unmerged list and are passed after `--`, so a
928
+ // dash-led name can't be parsed as an option.
929
+ for (const fp of files) {
930
+ let ourContent: Buffer | null = null;
931
+ if (choice === 'both') {
932
+ const show = await runGit(dir, ['show', `:2:${fp}`]); // stage 2 = ours
933
+ if (show.code === 0) ourContent = Buffer.from(show.stdout);
934
+ }
935
+ const side = choice === 'mine' ? '--ours' : '--theirs';
936
+ const co = await runGit(dir, ['checkout', side, '--', fp]);
937
+ if (co.code !== 0) return { ok: false, error: co.stderr.trim() || `Could not resolve ${fp}.` };
938
+ await runGit(dir, ['add', '--', fp]);
939
+ if (choice === 'both' && ourContent) {
940
+ const copyRel = mineCopyPath(fp);
941
+ // Containment guard (audit F-1/D-2) — same invariant as resolveIso.
942
+ if (isContainedRepoPath(dir, copyRel)) {
943
+ fs.writeFileSync(join(dir, copyRel), ourContent);
944
+ await runGit(dir, ['add', '--', copyRel]);
945
+ copies.push(copyRel);
946
+ }
947
+ }
948
+ }
949
+ const c = await runGit(dir, ['commit', '--no-edit']);
950
+ if (c.code !== 0) return { ok: false, error: c.stderr.trim() || 'Could not finish the merge.' };
951
+ return { ok: true, copies: copies.length ? copies : undefined };
952
+ }
953
+
954
+ function mergeConflictFiles(e: unknown): string[] | null {
955
+ if (!e || typeof e !== 'object') return null;
956
+ const err = e as { code?: string; caller?: string; data?: unknown };
957
+ const isConflict =
958
+ err.code === 'MergeConflictError' ||
959
+ err.code === 'MergeNotSupportedError' ||
960
+ err.code === 'CheckoutConflictError';
961
+ if (!isConflict) return null;
962
+ // isomorphic-git ≥1.x throws MergeConflictError with `data` shaped as an OBJECT
963
+ // ({ filepaths, bothModified, deleteByUs, deleteByTheirs }) — NOT a bare array.
964
+ // The original `Array.isArray(err.data)` check therefore always fell through to
965
+ // `[]`, so a real conflict surfaced with NO files and the DiffView resolver
966
+ // never opened (the project wedged). Handle both shapes; an empty list still
967
+ // means "conflict, but no parseable paths".
968
+ if (Array.isArray(err.data)) return err.data.map(String);
969
+ const d = err.data as { filepaths?: unknown; bothModified?: unknown } | null | undefined;
970
+ if (d && typeof d === 'object') {
971
+ const list = Array.isArray(d.filepaths)
972
+ ? d.filepaths
973
+ : Array.isArray(d.bothModified)
974
+ ? d.bothModified
975
+ : null;
976
+ if (list) return list.map(String);
977
+ }
978
+ return [];
979
+ }
980
+
981
+ /** Repo-relative sibling path for the zero-loss "keep both" copy:
982
+ * `.design/ui/Foo.tsx` → `.design/ui/Foo (mine).tsx`. */
983
+ function mineCopyPath(rel: string): string {
984
+ const slash = rel.lastIndexOf('/');
985
+ const dir = slash >= 0 ? rel.slice(0, slash + 1) : '';
986
+ const base = slash >= 0 ? rel.slice(slash + 1) : rel;
987
+ const dot = base.lastIndexOf('.');
988
+ const stem = dot > 0 ? base.slice(0, dot) : base;
989
+ const ext = dot > 0 ? base.slice(dot) : '';
990
+ return `${dir}${stem} (mine)${ext}`;
991
+ }
992
+
993
+ // ── log (History) ─────────────────────────────────────────────────────────
994
+
995
+ /** `filepath` (repo-relative, forward slashes) scopes History to a single
996
+ * canvas — the per-file version list that drives the History click-to-preview
997
+ * and the DiffView "Saved version" picker (phase-27.1). Omit for the repo-wide
998
+ * log (byte-identical to the pre-27.1 behaviour). The caller is responsible for
999
+ * containment-validating `filepath` (it reaches system-git positionally after
1000
+ * `--`, so no option injection, but it must not address a file outside the
1001
+ * design tree). */
1002
+ export async function gitLog(dir: string, limit = 30, filepath?: string): Promise<GitLogEntry[]> {
1003
+ if (!isRepo(dir)) return [];
1004
+ return USE_SYSTEM_GIT ? logSystem(dir, limit, filepath) : logIso(dir, limit, filepath);
1005
+ }
1006
+
1007
+ async function logIso(dir: string, limit: number, filepath?: string): Promise<GitLogEntry[]> {
1008
+ try {
1009
+ const commits = await git.log({ fs, dir, depth: limit, ...(filepath ? { filepath } : {}) });
1010
+ return commits.map((c) => {
1011
+ const a = c.commit.author;
1012
+ return {
1013
+ sha: c.oid,
1014
+ message: c.commit.message.trim().split('\n')[0] ?? '',
1015
+ author: a.name,
1016
+ email: a.email,
1017
+ date: new Date(a.timestamp * 1000).toISOString(),
1018
+ };
1019
+ });
1020
+ } catch {
1021
+ return [];
1022
+ }
1023
+ }
1024
+
1025
+ async function logSystem(dir: string, limit: number, filepath?: string): Promise<GitLogEntry[]> {
1026
+ // Unit-separator field delimiter, record-separator line delimiter — survives
1027
+ // any message punctuation.
1028
+ const fmt = '%H%x1f%s%x1f%an%x1f%ae%x1f%aI%x1e';
1029
+ const args = ['log', `-n${limit}`, `--pretty=format:${fmt}`];
1030
+ // `--` makes `filepath` strictly positional — git can't read it as an option
1031
+ // (no argument injection even if it began with a dash, which containment
1032
+ // validation already rejects upstream).
1033
+ if (filepath) args.push('--', filepath);
1034
+ // GIT_LITERAL_PATHSPECS — match `filepath` VERBATIM, never as pathspec magic
1035
+ // (`:(top)`, `:(exclude)`, globs). The endpoint already restricts it to the
1036
+ // design tree; this makes the system-git engine treat it as a plain path
1037
+ // regardless, closing the pathspec-magic surface the `--` terminator alone
1038
+ // doesn't (security re-review, phase-27.1).
1039
+ const r = await runGit(dir, args, filepath ? { GIT_LITERAL_PATHSPECS: '1' } : undefined);
1040
+ if (r.code !== 0) return [];
1041
+ return r.stdout
1042
+ .split('\x1e')
1043
+ .map((rec) => rec.replace(/^\n/, '').trim())
1044
+ .filter(Boolean)
1045
+ .map((rec) => {
1046
+ const [sha, message, author, email, date] = rec.split('\x1f');
1047
+ return { sha, message, author, email, date };
1048
+ });
1049
+ }
1050
+
1051
+ // ── diff (visual before/after) ─────────────────────────────────────────────
1052
+
1053
+ /** Files that differ between commit `sha` and the working tree, scoped to
1054
+ * `designPrefix`. DiffView uses each entry to drive the screenshot pipeline
1055
+ * (render `before` sha vs `after`=workdir). `sha` defaults to HEAD, in which
1056
+ * case this is exactly the current dirty set. */
1057
+ export async function gitDiff(
1058
+ dir: string,
1059
+ sha = 'HEAD',
1060
+ opts: { designPrefix?: string } = {}
1061
+ ): Promise<GitDiffEntry[]> {
1062
+ if (!isRepo(dir)) return [];
1063
+ const prefix = normPrefix(opts.designPrefix);
1064
+ return USE_SYSTEM_GIT ? diffSystem(dir, sha, prefix) : diffIso(dir, sha, prefix);
1065
+ }
1066
+
1067
+ async function diffIso(dir: string, sha: string, prefix: string): Promise<GitDiffEntry[]> {
1068
+ try {
1069
+ const ref = await git.resolveRef({ fs, dir, ref: sha }).catch(() => sha);
1070
+ const changed = await git.walk({
1071
+ fs,
1072
+ dir,
1073
+ trees: [git.TREE({ ref }), git.WORKDIR()],
1074
+ map: async (filepath, entries) => {
1075
+ if (filepath === '.') return;
1076
+ if (prefix && !underPrefix(filepath, prefix)) return;
1077
+ if (isMaudeRuntimeState(filepath)) return;
1078
+ const [tree, work] = entries;
1079
+ const treeOid = tree ? await tree.oid().catch(() => undefined) : undefined;
1080
+ const workOid = work ? await work.oid().catch(() => undefined) : undefined;
1081
+ // Different content (or added/removed) → it's a diff entry.
1082
+ if (treeOid === workOid) return;
1083
+ // Skip directories.
1084
+ const tType = tree ? await tree.type().catch(() => 'blob') : 'blob';
1085
+ const wType = work ? await work.type().catch(() => 'blob') : 'blob';
1086
+ if (tType === 'tree' || wType === 'tree') return;
1087
+ return filepath;
1088
+ },
1089
+ });
1090
+ return (changed as string[])
1091
+ .filter(Boolean)
1092
+ .sort((a, b) => a.localeCompare(b))
1093
+ .map((file) => ({ file, before: sha, after: 'workdir' as const }));
1094
+ } catch {
1095
+ return [];
1096
+ }
1097
+ }
1098
+
1099
+ async function diffSystem(dir: string, sha: string, prefix: string): Promise<GitDiffEntry[]> {
1100
+ // Second guard (the endpoint already anchors the sha regex): a dash-led rev
1101
+ // would be parsed as a `git diff` option even with the trailing `--` (which
1102
+ // only protects the pathspec). A real rev never starts with `-`.
1103
+ if (!isSafeGitPositional(sha)) return [];
1104
+ const r = await runGit(dir, ['diff', '--name-only', '-z', sha, '--']);
1105
+ if (r.code !== 0) return [];
1106
+ return r.stdout
1107
+ .split('\0')
1108
+ .map((f) => f.replace(/\\/g, '/'))
1109
+ .filter((f) => f && underPrefix(f, prefix) && !isMaudeRuntimeState(f))
1110
+ .sort((a, b) => a.localeCompare(b))
1111
+ .map((file) => ({ file, before: sha, after: 'workdir' as const }));
1112
+ }
1113
+
1114
+ // ── show a file at a past version (DiffView "before" render) ────────────────
1115
+
1116
+ /** The text content of `repoRelPath` as it was at commit/ref `sha`, or null if
1117
+ * unavailable. Drives the DiffView "before" pane (build the canvas from this
1118
+ * source). `sha` is positional-guarded (no argument injection) and the path is
1119
+ * containment-checked. Read-only; same exposure class as reading the current
1120
+ * design file. */
1121
+ export async function gitShowFile(
1122
+ dir: string,
1123
+ sha: string,
1124
+ repoRelPath: string
1125
+ ): Promise<string | null> {
1126
+ if (!isRepo(dir)) return null;
1127
+ if (!isSafeGitPositional(sha)) return null;
1128
+ const rel = repoRelPath.replace(/\\/g, '/');
1129
+ if (!isContainedRepoPath(dir, rel)) return null;
1130
+ try {
1131
+ if (USE_SYSTEM_GIT) {
1132
+ const r = await runGit(dir, ['show', `${sha}:${rel}`]);
1133
+ return r.code === 0 ? r.stdout : null;
1134
+ }
1135
+ const oid = await git.resolveRef({ fs, dir, ref: sha }).catch(() => sha);
1136
+ const { blob } = await git.readBlob({ fs, dir, oid, filepath: rel });
1137
+ return new TextDecoder().decode(blob);
1138
+ } catch {
1139
+ return null;
1140
+ }
1141
+ }
1142
+
1143
+ // ── local "unpushed" (saved-but-not-published) — NO network ─────────────────
1144
+
1145
+ /** Count local commits not yet on the remote-tracking ref `<remote>/<branch>`,
1146
+ * using only on-disk refs (no fetch). When no tracking ref exists but a remote
1147
+ * is configured, every commit counts as unpushed (never published). 0 when
1148
+ * there's no remote or no branch. Best-effort; callers swallow errors → 0. */
1149
+ async function localUnpushed(dir: string, branch: string | null, remote: string): Promise<number> {
1150
+ if (!branch) return 0;
1151
+ // Guard parity with the other system-git callsites — `branch`/`remote` are
1152
+ // server-derived today, but never interpolate an unguarded positional into git
1153
+ // argv (defense-in-depth so a future caller can't re-open the injection class).
1154
+ if (!isSafeGitPositional(branch) || !isSafeGitPositional(remote)) return 0;
1155
+ if (USE_SYSTEM_GIT) {
1156
+ const r = await runGit(dir, ['rev-list', '--count', `${remote}/${branch}..HEAD`]);
1157
+ if (r.code === 0) return Number(r.stdout.trim()) || 0;
1158
+ // No tracking ref — count all commits if a remote exists, else 0.
1159
+ const remotes = await runGit(dir, ['remote']);
1160
+ if (!remotes.stdout.trim()) return 0;
1161
+ const all = await runGit(dir, ['rev-list', '--count', 'HEAD']);
1162
+ return all.code === 0 ? Number(all.stdout.trim()) || 0 : 0;
1163
+ }
1164
+ const trackingOid = await git
1165
+ .resolveRef({ fs, dir, ref: `refs/remotes/${remote}/${branch}` })
1166
+ .catch(() => null);
1167
+ const localLog = await git.log({ fs, dir, ref: 'HEAD', depth: 500 }).catch(() => []);
1168
+ if (!trackingOid) {
1169
+ const remotes = await git.listRemotes({ fs, dir }).catch(() => []);
1170
+ return remotes.length ? localLog.length : 0;
1171
+ }
1172
+ const remoteSet = new Set(
1173
+ (await git.log({ fs, dir, ref: trackingOid, depth: 500 }).catch(() => [])).map((c) => c.oid)
1174
+ );
1175
+ let n = 0;
1176
+ for (const c of localLog) {
1177
+ if (remoteSet.has(c.oid)) break; // shared history → the rest is published
1178
+ n++;
1179
+ }
1180
+ return n;
1181
+ }
1182
+
1183
+ // ── remote ahead/behind (Get latest nudge) ─────────────────────────────────
1184
+
1185
+ /** Fetch the tracking remote and count commits ahead (local-only) / behind
1186
+ * (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. */
1188
+ export async function remoteAheadBehind(
1189
+ dir: string,
1190
+ token: string | undefined,
1191
+ remote = 'origin'
1192
+ ): Promise<{ ahead: number; behind: number }> {
1193
+ if (USE_SYSTEM_GIT) return remoteAheadBehindSystem(dir, token, remote);
1194
+ const branch = (await git.currentBranch({ fs, dir, fullname: false })) || 'main';
1195
+ await git.fetch({
1196
+ fs,
1197
+ http,
1198
+ dir,
1199
+ remote,
1200
+ ref: branch,
1201
+ singleBranch: true,
1202
+ tags: false,
1203
+ onAuth: () => ({ username: token ?? '', password: '' }),
1204
+ });
1205
+ const localOid = await git.resolveRef({ fs, dir, ref: branch });
1206
+ const remoteOid = await git
1207
+ .resolveRef({ fs, dir, ref: `refs/remotes/${remote}/${branch}` })
1208
+ .catch(() => null);
1209
+ if (!remoteOid || remoteOid === localOid) return { ahead: 0, behind: 0 };
1210
+
1211
+ const localSet = new Set(
1212
+ (await git.log({ fs, dir, ref: localOid, depth: 500 })).map((c) => c.oid)
1213
+ );
1214
+ const remoteSet = new Set(
1215
+ (await git.log({ fs, dir, ref: remoteOid, depth: 500 })).map((c) => c.oid)
1216
+ );
1217
+ let ahead = 0;
1218
+ let behind = 0;
1219
+ for (const oid of localSet) if (!remoteSet.has(oid)) ahead++;
1220
+ for (const oid of remoteSet) if (!localSet.has(oid)) behind++;
1221
+ return { ahead, behind };
1222
+ }
1223
+
1224
+ async function remoteAheadBehindSystem(
1225
+ dir: string,
1226
+ token: string | undefined,
1227
+ remote: string
1228
+ ): Promise<{ ahead: number; behind: number }> {
1229
+ const branch = (await runGit(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim() || 'main';
1230
+ if (!isSafeGitPositional(remote) || !isSafeGitPositional(branch)) {
1231
+ throw new Error('invalid remote or branch');
1232
+ }
1233
+ const args = tokenHeaderArgs(token);
1234
+ args.push('fetch', remote, branch);
1235
+ const f = await runGit(dir, args);
1236
+ if (f.code !== 0) throw new Error(f.stderr.trim() || 'fetch failed');
1237
+ const counts = await runGit(dir, [
1238
+ 'rev-list',
1239
+ '--left-right',
1240
+ '--count',
1241
+ `${branch}...${remote}/${branch}`,
1242
+ ]);
1243
+ if (counts.code !== 0) return { ahead: 0, behind: 0 };
1244
+ const [ahead, behind] = counts.stdout.trim().split(/\s+/).map(Number);
1245
+ return { ahead: ahead || 0, behind: behind || 0 };
1246
+ }
1247
+
1248
+ // ── shared ───────────────────────────────────────────────────────────────────
1249
+
1250
+ function errMsg(e: unknown): string {
1251
+ if (e instanceof Error) return e.message;
1252
+ return String(e);
1253
+ }
1254
+
1255
+ /** Guard: a repo-relative path that stays inside `dir` (no traversal, no abs).
1256
+ * The endpoint layer validates user-supplied `files[]` with this before they
1257
+ * reach gitCommit so a poisoned path can't stage outside the repo. */
1258
+ export function isContainedRepoPath(dir: string, repoRelative: string): boolean {
1259
+ if (typeof repoRelative !== 'string' || !repoRelative) return false;
1260
+ if (repoRelative.includes('\0')) return false;
1261
+ if (isAbsolute(repoRelative)) return false;
1262
+ const resolved = join(dir, repoRelative);
1263
+ const rel = relative(dir, resolved);
1264
+ return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel) && !rel.startsWith(`..${sep}`);
1265
+ }
1266
+
1267
+ export interface GitCloneResult {
1268
+ ok: boolean;
1269
+ dir?: string;
1270
+ authRequired?: boolean;
1271
+ error?: string;
1272
+ }
1273
+
1274
+ /** Clone a repo into `dir` (a fresh, non-existent path). Full clone of the default
1275
+ * branch (no shallow) so the working copy can commit + Publish. The optional token
1276
+ * authenticates private repos via iso-git's `onAuth` (token-as-username, never
1277
+ * logged) — phase-28's "pull a local copy". */
1278
+ export async function gitClone(url: string, dir: string, token?: string): Promise<GitCloneResult> {
1279
+ // SECURITY (phase-28 audit D-1/F-2, defense-in-depth): only ever offer the
1280
+ // keychain token to a github.com host. Callers already rebuild a canonical
1281
+ // URL, but this guarantees a crafted/redirected URL can never receive the PAT
1282
+ // as Basic auth — the bug class the whole keychain/bridge design exists to
1283
+ // prevent. A non-github host clones tokenless (public) or fails auth (private).
1284
+ let tokenHost = false;
1285
+ try {
1286
+ tokenHost = new URL(url).hostname.toLowerCase() === 'github.com';
1287
+ } catch {
1288
+ tokenHost = false;
1289
+ }
1290
+ const auth = token && tokenHost ? { onAuth: () => ({ username: token, password: '' }) } : {};
1291
+ try {
1292
+ await git.clone({
1293
+ fs,
1294
+ http,
1295
+ dir,
1296
+ url,
1297
+ singleBranch: true,
1298
+ ...auth,
1299
+ });
1300
+ // An EMPTY remote (a freshly-created repo) clones to a repo with NO commits and
1301
+ // an odd/unborn HEAD — the first Save version then fails to land on a resolvable
1302
+ // `main`, so Publish errors with "Could not find main" and unpushed can't be
1303
+ // computed. Normalize HEAD to an unborn `main` so the first commit creates it and
1304
+ // Publish (first push) works. Only touch the empty case; a populated clone is left
1305
+ // exactly as cloned.
1306
+ const hasCommits = await git
1307
+ .log({ fs, dir, depth: 1 })
1308
+ .then((l) => l.length > 0)
1309
+ .catch(() => false);
1310
+ if (!hasCommits) {
1311
+ fs.writeFileSync(join(dir, '.git', 'HEAD'), 'ref: refs/heads/main\n');
1312
+ }
1313
+ return { ok: true, dir };
1314
+ } catch (e) {
1315
+ const msg = errMsg(e);
1316
+ if (/401|403|auth|credential|unauthor/i.test(msg)) {
1317
+ return {
1318
+ ok: false,
1319
+ authRequired: true,
1320
+ error: 'GitHub sign-in is needed to download this project.',
1321
+ };
1322
+ }
1323
+ return { ok: false, error: 'Could not download the project. Check the link and try again.' };
1324
+ }
1325
+ }
1326
+
1327
+ export const __testing = {
1328
+ classify,
1329
+ classifyPorcelain,
1330
+ isNonFastForward,
1331
+ normPrefix,
1332
+ isMaudeRuntimeState,
1333
+ isSafeGitPositional,
1334
+ };