@1agh/maude 0.29.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.
- package/README.md +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +58 -12
|
@@ -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
|
+
};
|