@flyingrobots/graft 0.3.5 → 0.5.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/ARCHITECTURE.md +386 -0
- package/CHANGELOG.md +69 -0
- package/CODE_OF_CONDUCT.md +65 -0
- package/README.md +153 -17
- package/bin/graft.js +4 -11
- package/docs/ADVANCED_GUIDE.md +49 -0
- package/docs/CLI.md +43 -0
- package/docs/GUIDE.md +321 -32
- package/docs/MCP.md +44 -0
- package/package.json +17 -4
- package/src/adapters/node-fs.ts +4 -0
- package/src/adapters/node-git.ts +47 -0
- package/src/adapters/node-process-runner.ts +27 -0
- package/src/cli/index-cmd.ts +86 -0
- package/src/cli/init.ts +808 -57
- package/src/cli/main.ts +437 -0
- package/src/contracts/capabilities.ts +341 -0
- package/src/contracts/causal-ontology.ts +622 -0
- package/src/contracts/causal-surface-next-action.ts +18 -0
- package/src/contracts/output-schemas.ts +1169 -0
- package/src/git/diff.ts +25 -21
- package/src/git/target-git-hook-bootstrap.ts +56 -0
- package/src/hooks/posttooluse-read.ts +21 -74
- package/src/hooks/pretooluse-read.ts +20 -56
- package/src/hooks/read-governor.ts +95 -0
- package/src/hooks/read-messages.ts +53 -0
- package/src/mcp/burden.ts +123 -0
- package/src/mcp/cache.ts +51 -0
- package/src/mcp/cached-file.ts +10 -8
- package/src/mcp/context.ts +67 -2
- package/src/mcp/daemon-control-plane.ts +554 -0
- package/src/mcp/daemon-job-scheduler.ts +279 -0
- package/src/mcp/daemon-repos.ts +216 -0
- package/src/mcp/daemon-server.ts +396 -0
- package/src/mcp/daemon-worker-pool.ts +310 -0
- package/src/mcp/daemon-worker-process.ts +52 -0
- package/src/mcp/metrics.ts +108 -1
- package/src/mcp/monitor-tick-job.ts +99 -0
- package/src/mcp/persisted-local-history.ts +1246 -0
- package/src/mcp/persistent-monitor-runtime.ts +549 -0
- package/src/mcp/policy.ts +84 -0
- package/src/mcp/receipt.ts +82 -12
- package/src/mcp/repo-concurrency.ts +318 -0
- package/src/mcp/repo-state.ts +777 -0
- package/src/mcp/repo-tool-job.ts +302 -0
- package/src/mcp/run-capture-config.ts +33 -0
- package/src/mcp/runtime-causal-context.ts +72 -0
- package/src/mcp/runtime-observability.ts +219 -0
- package/src/mcp/runtime-staged-target.ts +161 -0
- package/src/mcp/runtime-workspace-overlay.ts +255 -0
- package/src/mcp/semantic-transition-guidance.ts +60 -0
- package/src/mcp/semantic-transition-summary.ts +130 -0
- package/src/mcp/server.ts +704 -45
- package/src/mcp/stdio-server.ts +12 -0
- package/src/mcp/stdio.ts +2 -5
- package/src/mcp/tools/activity-view.ts +325 -0
- package/src/mcp/tools/causal-attach.ts +67 -0
- package/src/mcp/tools/causal-status.ts +58 -0
- package/src/mcp/tools/changed-since.ts +13 -11
- package/src/mcp/tools/code-find.ts +164 -0
- package/src/mcp/tools/code-refs.ts +466 -0
- package/src/mcp/tools/code-show.ts +252 -0
- package/src/mcp/tools/daemon-monitors.ts +14 -0
- package/src/mcp/tools/daemon-repos.ts +22 -0
- package/src/mcp/tools/daemon-sessions.ts +14 -0
- package/src/mcp/tools/daemon-status.ts +12 -0
- package/src/mcp/tools/doctor.ts +45 -2
- package/src/mcp/tools/explain.ts +4 -0
- package/src/mcp/tools/file-outline.ts +7 -3
- package/src/mcp/tools/git-files.ts +73 -0
- package/src/mcp/tools/graft-diff.ts +12 -4
- package/src/mcp/tools/map.ts +136 -0
- package/src/mcp/tools/monitor-pause.ts +18 -0
- package/src/mcp/tools/monitor-resume.ts +18 -0
- package/src/mcp/tools/monitor-start.ts +20 -0
- package/src/mcp/tools/monitor-stop.ts +18 -0
- package/src/mcp/tools/precision-match.ts +51 -0
- package/src/mcp/tools/precision-query.ts +127 -0
- package/src/mcp/tools/precision.ts +312 -0
- package/src/mcp/tools/run-capture.ts +126 -44
- package/src/mcp/tools/safe-read.ts +14 -12
- package/src/mcp/tools/since.ts +49 -0
- package/src/mcp/tools/state.ts +11 -3
- package/src/mcp/tools/stats.ts +5 -1
- package/src/mcp/tools/workspace-authorizations.ts +14 -0
- package/src/mcp/tools/workspace-authorize.ts +20 -0
- package/src/mcp/tools/workspace-bind.ts +25 -0
- package/src/mcp/tools/workspace-rebind.ts +25 -0
- package/src/mcp/tools/workspace-revoke.ts +18 -0
- package/src/mcp/tools/workspace-status.ts +12 -0
- package/src/mcp/warp-pool.ts +36 -0
- package/src/mcp/workspace-router.ts +984 -0
- package/src/operations/file-outline.ts +12 -2
- package/src/operations/graft-diff.ts +56 -10
- package/src/operations/safe-read.ts +27 -4
- package/src/operations/state.ts +6 -9
- package/src/parser/lang.ts +19 -3
- package/src/parser/outline.ts +191 -2
- package/src/parser/types.ts +9 -1
- package/src/policy/types.ts +4 -3
- package/src/ports/filesystem.ts +1 -0
- package/src/ports/git.ts +16 -0
- package/src/ports/process-runner.ts +22 -0
- package/src/release/security-gate.ts +102 -0
- package/src/session/tracker.ts +31 -0
- package/src/version.ts +3 -0
- package/src/warp/indexer.ts +513 -0
- package/src/warp/observers.ts +105 -0
- package/src/warp/open.ts +31 -0
- package/src/warp/plumbing.d.ts +15 -0
- package/src/warp/writer-id.ts +30 -0
|
@@ -0,0 +1,777 @@
|
|
|
1
|
+
import * as crypto from "node:crypto";
|
|
2
|
+
import type { GitClient } from "../ports/git.js";
|
|
3
|
+
import type { FileSystem } from "../ports/filesystem.js";
|
|
4
|
+
import { buildSemanticTransitionSummary } from "./semantic-transition-summary.js";
|
|
5
|
+
|
|
6
|
+
export type WorldlineLayer = "commit_worldline" | "ref_view" | "workspace_overlay";
|
|
7
|
+
export type RepoTransitionKind = "checkout" | "reset" | "merge" | "rebase";
|
|
8
|
+
export type RepoSemanticTransitionKind =
|
|
9
|
+
| "index_update"
|
|
10
|
+
| "conflict_resolution"
|
|
11
|
+
| "merge_phase"
|
|
12
|
+
| "rebase_phase"
|
|
13
|
+
| "bulk_transition"
|
|
14
|
+
| "unknown";
|
|
15
|
+
export type RepoSemanticTransitionAuthority =
|
|
16
|
+
| "authoritative_git_state"
|
|
17
|
+
| "repo_snapshot";
|
|
18
|
+
export type RepoSemanticTransitionPhase =
|
|
19
|
+
| "started"
|
|
20
|
+
| "conflicted"
|
|
21
|
+
| "resolved_waiting_commit"
|
|
22
|
+
| "continued"
|
|
23
|
+
| "completed_or_cleared";
|
|
24
|
+
|
|
25
|
+
export interface RepoTransition {
|
|
26
|
+
readonly kind: RepoTransitionKind;
|
|
27
|
+
readonly fromRef: string | null;
|
|
28
|
+
readonly toRef: string | null;
|
|
29
|
+
readonly fromCommit: string | null;
|
|
30
|
+
readonly toCommit: string | null;
|
|
31
|
+
readonly evidence: {
|
|
32
|
+
readonly reflogSubject: string | null;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface RepoSemanticTransition {
|
|
37
|
+
readonly kind: RepoSemanticTransitionKind;
|
|
38
|
+
readonly authority: RepoSemanticTransitionAuthority;
|
|
39
|
+
readonly phase: RepoSemanticTransitionPhase | null;
|
|
40
|
+
readonly summary: string;
|
|
41
|
+
readonly evidence: {
|
|
42
|
+
readonly totalPaths: number;
|
|
43
|
+
readonly stagedPaths: number;
|
|
44
|
+
readonly changedPaths: number;
|
|
45
|
+
readonly untrackedPaths: number;
|
|
46
|
+
readonly unmergedPaths: number;
|
|
47
|
+
readonly mergeInProgress: boolean;
|
|
48
|
+
readonly rebaseInProgress: boolean;
|
|
49
|
+
readonly rebaseStep: number | null;
|
|
50
|
+
readonly rebaseTotalSteps: number | null;
|
|
51
|
+
readonly lastTransitionKind: RepoTransitionKind | null;
|
|
52
|
+
readonly reflogSubject: string | null;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface WorkspaceOverlaySummary {
|
|
57
|
+
readonly dirty: true;
|
|
58
|
+
readonly totalPaths: number;
|
|
59
|
+
readonly stagedPaths: number;
|
|
60
|
+
readonly changedPaths: number;
|
|
61
|
+
readonly untrackedPaths: number;
|
|
62
|
+
readonly actorGuess: "unknown";
|
|
63
|
+
readonly confidence: "low";
|
|
64
|
+
readonly evidence: {
|
|
65
|
+
readonly source: "git status --porcelain";
|
|
66
|
+
readonly reflogSubject: string | null;
|
|
67
|
+
readonly sample: readonly string[];
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface RepoObservation {
|
|
72
|
+
readonly checkoutEpoch: number;
|
|
73
|
+
readonly headRef: string | null;
|
|
74
|
+
readonly headSha: string | null;
|
|
75
|
+
readonly dirty: boolean;
|
|
76
|
+
readonly observedAt: string;
|
|
77
|
+
readonly lastTransition: RepoTransition | null;
|
|
78
|
+
readonly semanticTransition: RepoSemanticTransition | null;
|
|
79
|
+
readonly workspaceOverlayId: string | null;
|
|
80
|
+
readonly workspaceOverlay: WorkspaceOverlaySummary | null;
|
|
81
|
+
readonly statusLines: readonly string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface RebaseProgressState {
|
|
85
|
+
readonly inProgress: boolean;
|
|
86
|
+
readonly step: number | null;
|
|
87
|
+
readonly total: number | null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface RepoSnapshot {
|
|
91
|
+
readonly headRef: string | null;
|
|
92
|
+
readonly headSha: string | null;
|
|
93
|
+
readonly parentShas: readonly string[];
|
|
94
|
+
readonly observedAt: string;
|
|
95
|
+
readonly statusLines: readonly string[];
|
|
96
|
+
readonly dirty: boolean;
|
|
97
|
+
readonly stagedPaths: number;
|
|
98
|
+
readonly changedPaths: number;
|
|
99
|
+
readonly untrackedPaths: number;
|
|
100
|
+
readonly unmergedPaths: number;
|
|
101
|
+
readonly mergeInProgress: boolean;
|
|
102
|
+
readonly rebase: RebaseProgressState;
|
|
103
|
+
readonly headReflog: HeadReflogEntry | null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface HeadReflogEntry {
|
|
107
|
+
readonly raw: string;
|
|
108
|
+
readonly previousSha: string | null;
|
|
109
|
+
readonly nextSha: string | null;
|
|
110
|
+
readonly timestampSec: number | null;
|
|
111
|
+
readonly subject: string;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function stableId(prefix: string, input: string): string {
|
|
115
|
+
return `${prefix}:${crypto.createHash("sha256").update(input).digest("hex").slice(0, 16)}`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function git(gitClient: GitClient, args: readonly string[], cwd: string): Promise<string> {
|
|
119
|
+
const result = await gitClient.run({ args, cwd });
|
|
120
|
+
if (result.error !== undefined || result.status !== 0) {
|
|
121
|
+
throw result.error ?? new Error(result.stderr.trim() || `git exited with status ${String(result.status)}`);
|
|
122
|
+
}
|
|
123
|
+
return result.stdout;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function readGit(gitClient: GitClient, args: readonly string[], cwd: string): Promise<string | null> {
|
|
127
|
+
try {
|
|
128
|
+
const value = (await git(gitClient, args, cwd)).trim();
|
|
129
|
+
return value.length > 0 ? value : null;
|
|
130
|
+
} catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function readGitPorcelain(gitClient: GitClient, args: readonly string[], cwd: string): Promise<string | null> {
|
|
136
|
+
try {
|
|
137
|
+
const value = (await git(gitClient, args, cwd)).replace(/\r?\n$/, "");
|
|
138
|
+
return value.length > 0 ? value : null;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function readGitLines(gitClient: GitClient, args: readonly string[], cwd: string): Promise<string[]> {
|
|
145
|
+
const value = await readGitPorcelain(gitClient, args, cwd);
|
|
146
|
+
return value === null ? [] : value.split("\n");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function readGitPathText(
|
|
150
|
+
fs: FileSystem,
|
|
151
|
+
gitClient: GitClient,
|
|
152
|
+
cwd: string,
|
|
153
|
+
gitPath: string,
|
|
154
|
+
): Promise<string | null> {
|
|
155
|
+
const resolvedPath = await readGit(gitClient, ["rev-parse", "--path-format=absolute", "--git-path", gitPath], cwd);
|
|
156
|
+
if (resolvedPath === null) return null;
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const content = await fs.readFile(resolvedPath, "utf-8");
|
|
160
|
+
const value = content.trim();
|
|
161
|
+
return value.length > 0 ? value : null;
|
|
162
|
+
} catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseOptionalInt(value: string | null): number | null {
|
|
168
|
+
if (value === null) return null;
|
|
169
|
+
const parsed = Number.parseInt(value, 10);
|
|
170
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function countStatusLines(statusLines: readonly string[]): {
|
|
174
|
+
stagedPaths: number;
|
|
175
|
+
changedPaths: number;
|
|
176
|
+
untrackedPaths: number;
|
|
177
|
+
} {
|
|
178
|
+
let stagedPaths = 0;
|
|
179
|
+
let changedPaths = 0;
|
|
180
|
+
let untrackedPaths = 0;
|
|
181
|
+
|
|
182
|
+
for (const statusLine of statusLines) {
|
|
183
|
+
const x = statusLine[0] ?? " ";
|
|
184
|
+
const y = statusLine[1] ?? " ";
|
|
185
|
+
if (x === "?" && y === "?") {
|
|
186
|
+
untrackedPaths++;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (x !== " ") stagedPaths++;
|
|
190
|
+
if (y !== " ") changedPaths++;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return { stagedPaths, changedPaths, untrackedPaths };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function countUnmergedPaths(gitClient: GitClient, cwd: string): Promise<number> {
|
|
197
|
+
const paths = await readGitLines(gitClient, ["diff", "--name-only", "--diff-filter=U", "--"], cwd);
|
|
198
|
+
return new Set(paths.filter((value) => value.length > 0)).size;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function mergeStatusLines(
|
|
202
|
+
stagedLines: readonly string[],
|
|
203
|
+
changedLines: readonly string[],
|
|
204
|
+
untrackedLines: readonly string[],
|
|
205
|
+
): readonly string[] {
|
|
206
|
+
const merged = new Map<string, { x: string; y: string }>();
|
|
207
|
+
|
|
208
|
+
for (const line of stagedLines) {
|
|
209
|
+
const parts = line.split("\t");
|
|
210
|
+
const rawStatus = parts[0] ?? "";
|
|
211
|
+
const filePath = parts.at(-1) ?? "";
|
|
212
|
+
if (filePath.length === 0) continue;
|
|
213
|
+
const existing = merged.get(filePath) ?? { x: " ", y: " " };
|
|
214
|
+
existing.x = rawStatus[0] ?? "M";
|
|
215
|
+
merged.set(filePath, existing);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const line of changedLines) {
|
|
219
|
+
const parts = line.split("\t");
|
|
220
|
+
const rawStatus = parts[0] ?? "";
|
|
221
|
+
const filePath = parts.at(-1) ?? "";
|
|
222
|
+
if (filePath.length === 0) continue;
|
|
223
|
+
const existing = merged.get(filePath) ?? { x: " ", y: " " };
|
|
224
|
+
existing.y = rawStatus[0] ?? "M";
|
|
225
|
+
merged.set(filePath, existing);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const filePath of untrackedLines) {
|
|
229
|
+
if (filePath.length === 0) continue;
|
|
230
|
+
merged.set(filePath, { x: "?", y: "?" });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
return [...merged.entries()]
|
|
234
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
235
|
+
.map(([filePath, status]) => `${status.x}${status.y} ${filePath}`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function readParentShas(gitClient: GitClient, cwd: string, headSha: string | null): Promise<readonly string[]> {
|
|
239
|
+
if (headSha === null) return [];
|
|
240
|
+
const raw = await readGit(gitClient, ["show", "-s", "--format=%P", headSha], cwd);
|
|
241
|
+
if (raw === null || raw.trim().length === 0) return [];
|
|
242
|
+
return raw.trim().split(/\s+/).filter((sha) => sha.length > 0);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function readMergeInProgress(
|
|
246
|
+
fs: FileSystem,
|
|
247
|
+
gitClient: GitClient,
|
|
248
|
+
cwd: string,
|
|
249
|
+
): Promise<boolean> {
|
|
250
|
+
return (await readGitPathText(fs, gitClient, cwd, "MERGE_HEAD")) !== null;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function readRebaseProgressState(
|
|
254
|
+
fs: FileSystem,
|
|
255
|
+
gitClient: GitClient,
|
|
256
|
+
cwd: string,
|
|
257
|
+
): Promise<RebaseProgressState> {
|
|
258
|
+
const mergeHead = await readGitPathText(fs, gitClient, cwd, "rebase-merge/head-name");
|
|
259
|
+
if (mergeHead !== null) {
|
|
260
|
+
return {
|
|
261
|
+
inProgress: true,
|
|
262
|
+
step: parseOptionalInt(await readGitPathText(fs, gitClient, cwd, "rebase-merge/msgnum")),
|
|
263
|
+
total: parseOptionalInt(await readGitPathText(fs, gitClient, cwd, "rebase-merge/end")),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const applyHead = await readGitPathText(fs, gitClient, cwd, "rebase-apply/head-name");
|
|
268
|
+
if (applyHead !== null) {
|
|
269
|
+
return {
|
|
270
|
+
inProgress: true,
|
|
271
|
+
step: parseOptionalInt(await readGitPathText(fs, gitClient, cwd, "rebase-apply/next")),
|
|
272
|
+
total: parseOptionalInt(await readGitPathText(fs, gitClient, cwd, "rebase-apply/last")),
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const rebaseHead = await readGitPathText(fs, gitClient, cwd, "REBASE_HEAD");
|
|
277
|
+
return {
|
|
278
|
+
inProgress: rebaseHead !== null,
|
|
279
|
+
step: null,
|
|
280
|
+
total: null,
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function readHeadReflog(fs: FileSystem, gitClient: GitClient, cwd: string): Promise<HeadReflogEntry | null> {
|
|
285
|
+
const reflogPath = await readGit(gitClient, ["rev-parse", "--path-format=absolute", "--git-path", "logs/HEAD"], cwd);
|
|
286
|
+
if (reflogPath === null) return null;
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
const content = await fs.readFile(reflogPath, "utf-8");
|
|
290
|
+
const lines = content.split(/\r?\n/).map((line) => line.trimEnd());
|
|
291
|
+
let raw: string | null = null;
|
|
292
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
293
|
+
const line = lines[index];
|
|
294
|
+
if (line !== undefined && line.length > 0) {
|
|
295
|
+
raw = line;
|
|
296
|
+
break;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (raw === null) return null;
|
|
300
|
+
const [meta = "", subject = ""] = raw.split("\t");
|
|
301
|
+
const tokens = meta.split(" ");
|
|
302
|
+
const previousSha = tokens[0] ?? null;
|
|
303
|
+
const nextSha = tokens[1] ?? null;
|
|
304
|
+
const timestampToken = tokens.length >= 2 ? tokens[tokens.length - 2] : undefined;
|
|
305
|
+
const timestampSec = timestampToken !== undefined ? Number.parseInt(timestampToken, 10) : Number.NaN;
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
raw,
|
|
309
|
+
previousSha: previousSha !== null && /^[0-9a-f]{40}$/i.test(previousSha) ? previousSha : null,
|
|
310
|
+
nextSha: nextSha !== null && /^[0-9a-f]{40}$/i.test(nextSha) ? nextSha : null,
|
|
311
|
+
timestampSec: Number.isFinite(timestampSec) ? timestampSec : null,
|
|
312
|
+
subject,
|
|
313
|
+
};
|
|
314
|
+
} catch {
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function isAncestor(
|
|
320
|
+
gitClient: GitClient,
|
|
321
|
+
cwd: string,
|
|
322
|
+
possibleAncestor: string | null,
|
|
323
|
+
possibleDescendant: string | null,
|
|
324
|
+
): Promise<boolean> {
|
|
325
|
+
if (possibleAncestor === null || possibleDescendant === null) {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
const result = await gitClient.run({
|
|
329
|
+
args: ["merge-base", "--is-ancestor", possibleAncestor, possibleDescendant],
|
|
330
|
+
cwd,
|
|
331
|
+
});
|
|
332
|
+
return result.status === 0;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function captureSnapshot(cwd: string, fs: FileSystem, gitClient: GitClient): Promise<RepoSnapshot> {
|
|
336
|
+
const headSha = await readGit(gitClient, ["rev-parse", "HEAD"], cwd);
|
|
337
|
+
const statusLines = mergeStatusLines(
|
|
338
|
+
headSha === null ? [] : await readGitLines(gitClient, ["diff-index", "--cached", "--find-renames", "--name-status", headSha, "--"], cwd),
|
|
339
|
+
await readGitLines(gitClient, ["diff-files", "--find-renames", "--name-status", "--"], cwd),
|
|
340
|
+
await readGitLines(gitClient, ["ls-files", "--others", "--exclude-standard"], cwd),
|
|
341
|
+
);
|
|
342
|
+
const counts = countStatusLines(statusLines);
|
|
343
|
+
const unmergedPaths = await countUnmergedPaths(gitClient, cwd);
|
|
344
|
+
const mergeInProgress = await readMergeInProgress(fs, gitClient, cwd);
|
|
345
|
+
const rebase = await readRebaseProgressState(fs, gitClient, cwd);
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
headRef: await readGit(gitClient, ["symbolic-ref", "--quiet", "--short", "HEAD"], cwd),
|
|
349
|
+
headSha,
|
|
350
|
+
parentShas: await readParentShas(gitClient, cwd, headSha),
|
|
351
|
+
observedAt: new Date().toISOString(),
|
|
352
|
+
statusLines,
|
|
353
|
+
dirty: statusLines.length > 0,
|
|
354
|
+
...counts,
|
|
355
|
+
unmergedPaths,
|
|
356
|
+
mergeInProgress,
|
|
357
|
+
rebase,
|
|
358
|
+
headReflog: await readHeadReflog(fs, gitClient, cwd),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function buildWorkspaceOverlayId(snapshot: RepoSnapshot, checkoutEpoch: number): string | null {
|
|
363
|
+
if (!snapshot.dirty) return null;
|
|
364
|
+
return stableId("overlay", `${String(checkoutEpoch)}\n${snapshot.statusLines.join("\n")}`);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function buildWorkspaceOverlay(snapshot: RepoSnapshot): WorkspaceOverlaySummary | null {
|
|
368
|
+
if (!snapshot.dirty) return null;
|
|
369
|
+
return {
|
|
370
|
+
dirty: true,
|
|
371
|
+
totalPaths: snapshot.statusLines.length,
|
|
372
|
+
stagedPaths: snapshot.stagedPaths,
|
|
373
|
+
changedPaths: snapshot.changedPaths,
|
|
374
|
+
untrackedPaths: snapshot.untrackedPaths,
|
|
375
|
+
actorGuess: "unknown",
|
|
376
|
+
confidence: "low",
|
|
377
|
+
evidence: {
|
|
378
|
+
source: "git status --porcelain",
|
|
379
|
+
reflogSubject: null,
|
|
380
|
+
sample: snapshot.statusLines.slice(0, 10),
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function buildSemanticTransitionEvidence(
|
|
386
|
+
snapshot: RepoSnapshot,
|
|
387
|
+
lastTransition: RepoTransition | null,
|
|
388
|
+
): RepoSemanticTransition["evidence"] {
|
|
389
|
+
return {
|
|
390
|
+
totalPaths: snapshot.statusLines.length,
|
|
391
|
+
stagedPaths: snapshot.stagedPaths,
|
|
392
|
+
changedPaths: snapshot.changedPaths,
|
|
393
|
+
untrackedPaths: snapshot.untrackedPaths,
|
|
394
|
+
unmergedPaths: snapshot.unmergedPaths,
|
|
395
|
+
mergeInProgress: snapshot.mergeInProgress,
|
|
396
|
+
rebaseInProgress: snapshot.rebase.inProgress,
|
|
397
|
+
rebaseStep: snapshot.rebase.step,
|
|
398
|
+
rebaseTotalSteps: snapshot.rebase.total,
|
|
399
|
+
lastTransitionKind: lastTransition?.kind ?? null,
|
|
400
|
+
reflogSubject: lastTransition?.evidence.reflogSubject ?? snapshot.headReflog?.subject ?? null,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function buildSemanticTransition(
|
|
405
|
+
previous: RepoSnapshot | null,
|
|
406
|
+
current: RepoSnapshot,
|
|
407
|
+
lastTransition: RepoTransition | null,
|
|
408
|
+
): RepoSemanticTransition | null {
|
|
409
|
+
const evidence = buildSemanticTransitionEvidence(current, lastTransition);
|
|
410
|
+
|
|
411
|
+
if (current.mergeInProgress) {
|
|
412
|
+
return {
|
|
413
|
+
kind: "merge_phase",
|
|
414
|
+
authority: "authoritative_git_state",
|
|
415
|
+
phase: current.unmergedPaths > 0 ? "conflicted" : "resolved_waiting_commit",
|
|
416
|
+
summary: buildSemanticTransitionSummary({
|
|
417
|
+
kind: "merge_phase",
|
|
418
|
+
evidence,
|
|
419
|
+
phase: current.unmergedPaths > 0 ? "conflicted" : "resolved_waiting_commit",
|
|
420
|
+
}),
|
|
421
|
+
evidence,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
if (current.rebase.inProgress) {
|
|
426
|
+
const phase: RepoSemanticTransitionPhase = current.unmergedPaths > 0
|
|
427
|
+
? "conflicted"
|
|
428
|
+
: ((current.rebase.step ?? 1) > 1 ? "continued" : "started");
|
|
429
|
+
return {
|
|
430
|
+
kind: "rebase_phase",
|
|
431
|
+
authority: "authoritative_git_state",
|
|
432
|
+
phase,
|
|
433
|
+
summary: buildSemanticTransitionSummary({
|
|
434
|
+
kind: "rebase_phase",
|
|
435
|
+
evidence,
|
|
436
|
+
phase,
|
|
437
|
+
}),
|
|
438
|
+
evidence,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const unmergedChanged = previous !== null && previous.unmergedPaths !== current.unmergedPaths;
|
|
443
|
+
if (current.unmergedPaths > 0 || unmergedChanged) {
|
|
444
|
+
return {
|
|
445
|
+
kind: "conflict_resolution",
|
|
446
|
+
authority: "authoritative_git_state",
|
|
447
|
+
phase: null,
|
|
448
|
+
summary: buildSemanticTransitionSummary({
|
|
449
|
+
kind: "conflict_resolution",
|
|
450
|
+
evidence,
|
|
451
|
+
phase: null,
|
|
452
|
+
previousUnmergedPaths: previous?.unmergedPaths ?? null,
|
|
453
|
+
}),
|
|
454
|
+
evidence,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if (lastTransition?.kind === "merge") {
|
|
459
|
+
return {
|
|
460
|
+
kind: "merge_phase",
|
|
461
|
+
authority: "repo_snapshot",
|
|
462
|
+
phase: "completed_or_cleared",
|
|
463
|
+
summary: buildSemanticTransitionSummary({
|
|
464
|
+
kind: "merge_phase",
|
|
465
|
+
evidence,
|
|
466
|
+
phase: "completed_or_cleared",
|
|
467
|
+
}),
|
|
468
|
+
evidence,
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (lastTransition?.kind === "rebase") {
|
|
473
|
+
return {
|
|
474
|
+
kind: "rebase_phase",
|
|
475
|
+
authority: "repo_snapshot",
|
|
476
|
+
phase: "completed_or_cleared",
|
|
477
|
+
summary: buildSemanticTransitionSummary({
|
|
478
|
+
kind: "rebase_phase",
|
|
479
|
+
evidence,
|
|
480
|
+
phase: "completed_or_cleared",
|
|
481
|
+
}),
|
|
482
|
+
evidence,
|
|
483
|
+
};
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (current.statusLines.length >= 8) {
|
|
487
|
+
return {
|
|
488
|
+
kind: "bulk_transition",
|
|
489
|
+
authority: "repo_snapshot",
|
|
490
|
+
phase: null,
|
|
491
|
+
summary: buildSemanticTransitionSummary({
|
|
492
|
+
kind: "bulk_transition",
|
|
493
|
+
evidence,
|
|
494
|
+
phase: null,
|
|
495
|
+
}),
|
|
496
|
+
evidence,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (current.stagedPaths > 0) {
|
|
501
|
+
return {
|
|
502
|
+
kind: "index_update",
|
|
503
|
+
authority: "repo_snapshot",
|
|
504
|
+
phase: null,
|
|
505
|
+
summary: buildSemanticTransitionSummary({
|
|
506
|
+
kind: "index_update",
|
|
507
|
+
evidence,
|
|
508
|
+
phase: null,
|
|
509
|
+
}),
|
|
510
|
+
evidence,
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (current.statusLines.length > 0) {
|
|
515
|
+
return {
|
|
516
|
+
kind: "unknown",
|
|
517
|
+
authority: "repo_snapshot",
|
|
518
|
+
phase: null,
|
|
519
|
+
summary: buildSemanticTransitionSummary({
|
|
520
|
+
kind: "unknown",
|
|
521
|
+
evidence,
|
|
522
|
+
phase: null,
|
|
523
|
+
}),
|
|
524
|
+
evidence,
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function buildObservation(
|
|
532
|
+
snapshot: RepoSnapshot,
|
|
533
|
+
checkoutEpoch: number,
|
|
534
|
+
lastTransition: RepoTransition | null,
|
|
535
|
+
semanticTransition: RepoSemanticTransition | null,
|
|
536
|
+
): RepoObservation {
|
|
537
|
+
return {
|
|
538
|
+
checkoutEpoch,
|
|
539
|
+
headRef: snapshot.headRef,
|
|
540
|
+
headSha: snapshot.headSha,
|
|
541
|
+
dirty: snapshot.dirty,
|
|
542
|
+
observedAt: snapshot.observedAt,
|
|
543
|
+
lastTransition,
|
|
544
|
+
semanticTransition,
|
|
545
|
+
workspaceOverlayId: buildWorkspaceOverlayId(snapshot, checkoutEpoch),
|
|
546
|
+
workspaceOverlay: buildWorkspaceOverlay(snapshot),
|
|
547
|
+
statusLines: snapshot.statusLines,
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function samePosition(a: RepoSnapshot, b: RepoSnapshot): boolean {
|
|
552
|
+
return a.headRef === b.headRef && a.headSha === b.headSha;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function detectTransition(
|
|
556
|
+
gitClient: GitClient,
|
|
557
|
+
cwd: string,
|
|
558
|
+
previous: RepoSnapshot,
|
|
559
|
+
current: RepoSnapshot,
|
|
560
|
+
bootstrapTimestampSec: number,
|
|
561
|
+
allowBootstrap: boolean,
|
|
562
|
+
): Promise<RepoTransition | null> {
|
|
563
|
+
const reflogTransition = parseTransitionFromReflog(previous, current, bootstrapTimestampSec, allowBootstrap);
|
|
564
|
+
if (reflogTransition !== null) {
|
|
565
|
+
return reflogTransition;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
if (samePosition(previous, current)) return null;
|
|
569
|
+
|
|
570
|
+
if (previous.headRef !== current.headRef) {
|
|
571
|
+
return {
|
|
572
|
+
kind: "checkout",
|
|
573
|
+
fromRef: previous.headRef,
|
|
574
|
+
toRef: current.headRef,
|
|
575
|
+
fromCommit: previous.headSha,
|
|
576
|
+
toCommit: current.headSha,
|
|
577
|
+
evidence: { reflogSubject: null },
|
|
578
|
+
};
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (current.parentShas.length > 1 && previous.headSha !== null && current.parentShas.includes(previous.headSha)) {
|
|
582
|
+
return {
|
|
583
|
+
kind: "merge",
|
|
584
|
+
fromRef: previous.headRef,
|
|
585
|
+
toRef: current.headRef,
|
|
586
|
+
fromCommit: previous.headSha,
|
|
587
|
+
toCommit: current.headSha,
|
|
588
|
+
evidence: { reflogSubject: null },
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
if (await isAncestor(gitClient, cwd, current.headSha, previous.headSha)) {
|
|
593
|
+
return {
|
|
594
|
+
kind: "reset",
|
|
595
|
+
fromRef: previous.headRef,
|
|
596
|
+
toRef: current.headRef,
|
|
597
|
+
fromCommit: previous.headSha,
|
|
598
|
+
toCommit: current.headSha,
|
|
599
|
+
evidence: { reflogSubject: null },
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (!(await isAncestor(gitClient, cwd, previous.headSha, current.headSha))) {
|
|
604
|
+
return {
|
|
605
|
+
kind: "rebase",
|
|
606
|
+
fromRef: previous.headRef,
|
|
607
|
+
toRef: current.headRef,
|
|
608
|
+
fromCommit: previous.headSha,
|
|
609
|
+
toCommit: current.headSha,
|
|
610
|
+
evidence: { reflogSubject: null },
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function parseCheckoutTarget(value: string): string | null {
|
|
618
|
+
if (/^[0-9a-f]{7,40}$/i.test(value)) {
|
|
619
|
+
return null;
|
|
620
|
+
}
|
|
621
|
+
return value;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function parseTransitionFromReflog(
|
|
625
|
+
previous: RepoSnapshot,
|
|
626
|
+
current: RepoSnapshot,
|
|
627
|
+
bootstrapTimestampSec: number,
|
|
628
|
+
allowBootstrap: boolean,
|
|
629
|
+
): RepoTransition | null {
|
|
630
|
+
const entry = current.headReflog;
|
|
631
|
+
if (entry === null) {
|
|
632
|
+
return null;
|
|
633
|
+
}
|
|
634
|
+
const hasFreshReflog = previous.headReflog?.raw !== entry.raw;
|
|
635
|
+
const bootstrapEligible = allowBootstrap && entry.timestampSec !== null && entry.timestampSec >= bootstrapTimestampSec;
|
|
636
|
+
if (!hasFreshReflog && !bootstrapEligible) {
|
|
637
|
+
return null;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
const checkout = /^checkout: moving from (.+) to (.+)$/.exec(entry.subject);
|
|
641
|
+
if (checkout !== null) {
|
|
642
|
+
const fromRef = checkout[1];
|
|
643
|
+
const toTarget = checkout[2];
|
|
644
|
+
if (fromRef === undefined || toTarget === undefined) {
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
const toRef = parseCheckoutTarget(toTarget);
|
|
648
|
+
return {
|
|
649
|
+
kind: "checkout",
|
|
650
|
+
fromRef,
|
|
651
|
+
toRef,
|
|
652
|
+
fromCommit: entry.previousSha,
|
|
653
|
+
toCommit: entry.nextSha ?? current.headSha,
|
|
654
|
+
evidence: { reflogSubject: entry.subject },
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
const merge = /^merge ([^:]+):/.exec(entry.subject);
|
|
659
|
+
if (merge !== null) {
|
|
660
|
+
const fromRef = merge[1];
|
|
661
|
+
if (fromRef === undefined) {
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
kind: "merge",
|
|
666
|
+
fromRef,
|
|
667
|
+
toRef: current.headRef,
|
|
668
|
+
fromCommit: entry.previousSha,
|
|
669
|
+
toCommit: entry.nextSha ?? current.headSha,
|
|
670
|
+
evidence: { reflogSubject: entry.subject },
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (entry.subject.startsWith("reset: ")) {
|
|
675
|
+
return {
|
|
676
|
+
kind: "reset",
|
|
677
|
+
fromRef: previous.headRef,
|
|
678
|
+
toRef: current.headRef,
|
|
679
|
+
fromCommit: entry.previousSha,
|
|
680
|
+
toCommit: entry.nextSha ?? current.headSha,
|
|
681
|
+
evidence: { reflogSubject: entry.subject },
|
|
682
|
+
};
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (entry.subject.startsWith("rebase (")) {
|
|
686
|
+
return {
|
|
687
|
+
kind: "rebase",
|
|
688
|
+
fromRef: previous.headRef,
|
|
689
|
+
toRef: current.headRef,
|
|
690
|
+
fromCommit: entry.previousSha,
|
|
691
|
+
toCommit: entry.nextSha ?? current.headSha,
|
|
692
|
+
evidence: { reflogSubject: entry.subject },
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
return null;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
export class RepoStateTracker {
|
|
700
|
+
private checkoutEpoch = 0;
|
|
701
|
+
private snapshot: RepoSnapshot | null = null;
|
|
702
|
+
private observation: RepoObservation = {
|
|
703
|
+
checkoutEpoch: 0,
|
|
704
|
+
headRef: null,
|
|
705
|
+
headSha: null,
|
|
706
|
+
dirty: false,
|
|
707
|
+
observedAt: new Date(0).toISOString(),
|
|
708
|
+
lastTransition: null,
|
|
709
|
+
semanticTransition: null,
|
|
710
|
+
workspaceOverlayId: null,
|
|
711
|
+
workspaceOverlay: null,
|
|
712
|
+
statusLines: [],
|
|
713
|
+
};
|
|
714
|
+
private initialization: Promise<void> | null = null;
|
|
715
|
+
private readonly startedAtSec = Math.floor(Date.now() / 1000);
|
|
716
|
+
private hasObservedTransition = false;
|
|
717
|
+
|
|
718
|
+
constructor(
|
|
719
|
+
private readonly cwd: string,
|
|
720
|
+
private readonly fs: FileSystem,
|
|
721
|
+
private readonly gitClient: GitClient,
|
|
722
|
+
) {}
|
|
723
|
+
|
|
724
|
+
async observe(): Promise<RepoObservation> {
|
|
725
|
+
await this.initialize();
|
|
726
|
+
const previousSnapshot = this.snapshot;
|
|
727
|
+
if (previousSnapshot === null) {
|
|
728
|
+
return this.observation;
|
|
729
|
+
}
|
|
730
|
+
const nextSnapshot = await captureSnapshot(this.cwd, this.fs, this.gitClient);
|
|
731
|
+
const transition = await detectTransition(
|
|
732
|
+
this.gitClient,
|
|
733
|
+
this.cwd,
|
|
734
|
+
previousSnapshot,
|
|
735
|
+
nextSnapshot,
|
|
736
|
+
this.startedAtSec,
|
|
737
|
+
!this.hasObservedTransition,
|
|
738
|
+
);
|
|
739
|
+
if (transition !== null) {
|
|
740
|
+
this.checkoutEpoch++;
|
|
741
|
+
this.hasObservedTransition = true;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
this.snapshot = nextSnapshot;
|
|
745
|
+
this.observation = buildObservation(
|
|
746
|
+
nextSnapshot,
|
|
747
|
+
this.checkoutEpoch,
|
|
748
|
+
transition ?? this.observation.lastTransition,
|
|
749
|
+
buildSemanticTransition(previousSnapshot, nextSnapshot, transition),
|
|
750
|
+
);
|
|
751
|
+
return this.observation;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
async initialize(): Promise<void> {
|
|
755
|
+
if (this.initialization !== null) {
|
|
756
|
+
await this.initialization;
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
this.initialization = (async () => {
|
|
761
|
+
const snapshot = await captureSnapshot(this.cwd, this.fs, this.gitClient);
|
|
762
|
+
this.snapshot = snapshot;
|
|
763
|
+
this.observation = buildObservation(
|
|
764
|
+
snapshot,
|
|
765
|
+
this.checkoutEpoch,
|
|
766
|
+
null,
|
|
767
|
+
buildSemanticTransition(null, snapshot, null),
|
|
768
|
+
);
|
|
769
|
+
})();
|
|
770
|
+
|
|
771
|
+
await this.initialization;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
getState(): RepoObservation {
|
|
775
|
+
return this.observation;
|
|
776
|
+
}
|
|
777
|
+
}
|