runlist 0.0.0 → 0.94.2
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/LICENSE +21 -0
- package/README.md +1112 -6
- package/assets/app/app.css +283 -0
- package/assets/app/app.mjs +381 -0
- package/assets/app/block-editor.mjs +182 -0
- package/assets/app/block-model.mjs +52 -0
- package/assets/app/document-create.mjs +86 -0
- package/assets/app/document-lifecycle.mjs +36 -0
- package/assets/app/document-stages.mjs +22 -0
- package/assets/app/document-views.mjs +33 -0
- package/assets/app/document-yardstick.mjs +258 -0
- package/assets/app/editor-navigation.mjs +51 -0
- package/assets/app/filing-navigation.mjs +138 -0
- package/assets/app/git-changes.mjs +80 -0
- package/assets/app/git-commit.mjs +46 -0
- package/assets/app/index.html +70 -0
- package/assets/app/library-navigation.mjs +93 -0
- package/assets/app/outline.mjs +18 -0
- package/assets/app/quick-navigation.mjs +52 -0
- package/assets/app/record-navigation.mjs +78 -0
- package/assets/app/recovery-center.mjs +53 -0
- package/assets/app/semantic-search.mjs +14 -0
- package/assets/app/settings.mjs +15 -0
- package/assets/app/shared.mjs +104 -0
- package/assets/app/stage-source.mjs +75 -0
- package/assets/app/template-editor.mjs +47 -0
- package/assets/app/theme.mjs +25 -0
- package/assets/app/transport.mjs +43 -0
- package/assets/contracts/source-editing-v1.md +232 -0
- package/assets/opencode/plugin.js +162 -0
- package/assets/schemas/runlist-record-v1.schema.json +912 -0
- package/bin/dotmd.mjs +5 -0
- package/bin/runlist.mjs +2714 -0
- package/package.json +53 -6
- package/plugins/runlist-codex/.codex-plugin/plugin.json +18 -0
- package/plugins/runlist-codex/bin/runlist-hook +86 -0
- package/plugins/runlist-codex/hooks/hooks.json +16 -0
- package/plugins/runlist-codex/skills/runlist/SKILL.md +25 -0
- package/runlist.config.example.mjs +370 -0
- package/scripts/postinstall.mjs +56 -0
- package/src/agent-context.mjs +201 -0
- package/src/ai.mjs +30 -0
- package/src/app-create.mjs +169 -0
- package/src/app-filing.mjs +139 -0
- package/src/app-git-commits.mjs +48 -0
- package/src/app-git.mjs +180 -0
- package/src/app-library.mjs +152 -0
- package/src/app-lifecycle-worker.mjs +12 -0
- package/src/app-lifecycle.mjs +79 -0
- package/src/app-records.mjs +134 -0
- package/src/app-search.mjs +80 -0
- package/src/app-semantic-search.mjs +105 -0
- package/src/app-service.mjs +208 -0
- package/src/app-yardstick.mjs +269 -0
- package/src/app.mjs +102 -0
- package/src/atomic-mutation.mjs +1736 -0
- package/src/baton.mjs +435 -0
- package/src/body-link.mjs +48 -0
- package/src/bulk-tag.mjs +174 -0
- package/src/check-collapse.mjs +75 -0
- package/src/claude-commands.mjs +75 -0
- package/src/cli-editor.mjs +8 -0
- package/src/code-refs.mjs +545 -0
- package/src/codex-integration.mjs +81 -0
- package/src/color.mjs +14 -0
- package/src/commands.mjs +374 -0
- package/src/completions.mjs +93 -0
- package/src/config-edit.mjs +606 -0
- package/src/config.mjs +705 -0
- package/src/decision-action.mjs +58 -0
- package/src/decision.mjs +220 -0
- package/src/decisions.mjs +787 -0
- package/src/deps.mjs +317 -0
- package/src/desktop-install.mjs +158 -0
- package/src/desktop-runtime.mjs +4 -0
- package/src/desktop.mjs +25 -0
- package/src/diff.mjs +101 -0
- package/src/doctor.mjs +591 -0
- package/src/durable-rename.mjs +55 -0
- package/src/errors-read.mjs +44 -0
- package/src/export.mjs +496 -0
- package/src/extractors.mjs +101 -0
- package/src/filing.mjs +255 -0
- package/src/fix-membership.mjs +134 -0
- package/src/fix-refs.mjs +174 -0
- package/src/flags.mjs +318 -0
- package/src/frontmatter-fix.mjs +195 -0
- package/src/frontmatter.mjs +252 -0
- package/src/git-commit-filters.mjs +32 -0
- package/src/git-commit-protocol.mjs +198 -0
- package/src/git-commit-signing.mjs +75 -0
- package/src/git-commit-store.mjs +26 -0
- package/src/git-commit-support.mjs +16 -0
- package/src/git-commit-worker.mjs +32 -0
- package/src/git.mjs +909 -0
- package/src/glossary-check.mjs +35 -0
- package/src/glossary.mjs +243 -0
- package/src/graph.mjs +298 -0
- package/src/guard.mjs +488 -0
- package/src/health.mjs +212 -0
- package/src/hints.mjs +129 -0
- package/src/host-integration.mjs +339 -0
- package/src/hub-membership.mjs +284 -0
- package/src/hub.mjs +296 -0
- package/src/hud.mjs +436 -0
- package/src/index-file.mjs +163 -0
- package/src/index.mjs +478 -0
- package/src/init.mjs +512 -0
- package/src/install.mjs +222 -0
- package/src/journal-read.mjs +116 -0
- package/src/journal.mjs +472 -0
- package/src/lifecycle.mjs +1444 -0
- package/src/lint.mjs +386 -0
- package/src/managed-path.mjs +192 -0
- package/src/markdown-code-spans.mjs +58 -0
- package/src/migrate-prompts.mjs +171 -0
- package/src/migrate-template.mjs +170 -0
- package/src/migrate.mjs +90 -0
- package/src/misuse-read.mjs +61 -0
- package/src/model-request.mjs +33 -0
- package/src/model.mjs +523 -0
- package/src/modules.mjs +355 -0
- package/src/naming.mjs +55 -0
- package/src/native-action.mjs +59 -0
- package/src/native-record.mjs +244 -0
- package/src/new.mjs +1321 -0
- package/src/output-identity.mjs +111 -0
- package/src/parent-plan.mjs +58 -0
- package/src/parse-cache.mjs +99 -0
- package/src/path-containment.mjs +15 -0
- package/src/pickup-card.mjs +293 -0
- package/src/pickup.mjs +671 -0
- package/src/prompt.mjs +25 -0
- package/src/prompts.mjs +585 -0
- package/src/public-inspection.mjs +19 -0
- package/src/query.mjs +977 -0
- package/src/record-action.mjs +11 -0
- package/src/record-create.mjs +82 -0
- package/src/reference-planner.mjs +388 -0
- package/src/reference-values.mjs +11 -0
- package/src/rename.mjs +107 -0
- package/src/render.mjs +678 -0
- package/src/roadmap.mjs +184 -0
- package/src/runlist.mjs +944 -0
- package/src/section.mjs +225 -0
- package/src/semantic-search-client.mjs +58 -0
- package/src/ship.mjs +166 -0
- package/src/show.mjs +101 -0
- package/src/skill-drift.mjs +98 -0
- package/src/source-editor.mjs +491 -0
- package/src/stages.mjs +73 -0
- package/src/state-migration.mjs +123 -0
- package/src/stats.mjs +200 -0
- package/src/status-metadata.mjs +87 -0
- package/src/statuses.mjs +721 -0
- package/src/summary.mjs +74 -0
- package/src/surfaces.mjs +28 -0
- package/src/sync-status.mjs +316 -0
- package/src/template-store.mjs +62 -0
- package/src/update.mjs +338 -0
- package/src/use.mjs +58 -0
- package/src/util.mjs +337 -0
- package/src/validate.mjs +795 -0
- package/src/watch.mjs +56 -0
- package/src/xref.mjs +406 -0
- package/src/yardstick-command.mjs +178 -0
- package/src/yardstick.mjs +112 -0
package/src/git.mjs
ADDED
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { chmodSync, closeSync, existsSync, fsyncSync, linkSync, lstatSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
5
|
+
import { commitRename } from './durable-rename.mjs';
|
|
6
|
+
import { ARTIFACT_PREFIX, isOwnedArtifact } from './naming.mjs';
|
|
7
|
+
|
|
8
|
+
// Best-effort `git check-ignore` for a path. Returns true only when git
|
|
9
|
+
// definitively reports the path is ignored; any failure (not a repo, git
|
|
10
|
+
// missing, path outside the tree) returns false so callers never block on a
|
|
11
|
+
// false positive. Used by the guard hook and `runlist new` to warn that a
|
|
12
|
+
// freshly-created doc lives under a gitignored path (the "agent tries to
|
|
13
|
+
// commit a session-local prompt" confusion).
|
|
14
|
+
export function isGitIgnored(absPath, repoRoot) {
|
|
15
|
+
try {
|
|
16
|
+
const result = spawnSync('git', ['check-ignore', '-q', '--', absPath], {
|
|
17
|
+
cwd: repoRoot || process.cwd(),
|
|
18
|
+
encoding: 'utf8',
|
|
19
|
+
});
|
|
20
|
+
// exit 0 → ignored, 1 → not ignored, 128 → not a git repo / other error.
|
|
21
|
+
return result.status === 0;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
let gitChecked = false;
|
|
28
|
+
function assertSafeGitPaths(paths) {
|
|
29
|
+
for (const filePath of paths) {
|
|
30
|
+
if (!filePath || /[\0\r\n]/.test(filePath) || path.isAbsolute(filePath) || filePath.split(/[\\/]/).includes('..')) {
|
|
31
|
+
throw new Error(`Unsafe repository-relative Git path: ${filePath}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function ensureGit() {
|
|
36
|
+
if (gitChecked) return;
|
|
37
|
+
const result = spawnSync('git', ['--version'], { encoding: 'utf8' });
|
|
38
|
+
if (result.error) {
|
|
39
|
+
throw new Error('git is not installed or not found in PATH. runlist requires git for this operation.');
|
|
40
|
+
}
|
|
41
|
+
gitChecked = true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function literalGitPathspec(filePath) {
|
|
45
|
+
return filePath === '.' ? ':(top,literal)' : `:(top,literal)${filePath}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function getGitLastModified(relPath, repoRoot) {
|
|
49
|
+
const result = spawnSync('git', ['log', '-1', '--format=%aI', '--', relPath], {
|
|
50
|
+
cwd: repoRoot,
|
|
51
|
+
encoding: 'utf8',
|
|
52
|
+
});
|
|
53
|
+
if (result.error || result.status !== 0 || !result.stdout.trim()) return null;
|
|
54
|
+
return result.stdout.trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function getGitFirstAdded(relPath, repoRoot) {
|
|
58
|
+
const result = spawnSync('git', ['log', '--diff-filter=A', '--follow', '--format=%aI', '--', relPath], {
|
|
59
|
+
cwd: repoRoot, encoding: 'utf8',
|
|
60
|
+
});
|
|
61
|
+
if (result.error || result.status !== 0 || !result.stdout.trim()) return null;
|
|
62
|
+
// `git log` returns newest-first; the file's add commit is the LAST entry.
|
|
63
|
+
const lines = result.stdout.trim().split('\n').filter(Boolean);
|
|
64
|
+
return lines[lines.length - 1] ?? null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const DEFAULT_GIT_METADATA_MAX_COMMITS = 10_000;
|
|
68
|
+
const DEFAULT_GIT_METADATA_MAX_BUFFER = 10 * 1024 * 1024;
|
|
69
|
+
const DEFAULT_GIT_METADATA_PATH_BATCH = 256;
|
|
70
|
+
const GIT_METADATA_HISTORY_PER_PATH = 16;
|
|
71
|
+
|
|
72
|
+
function boundedPositiveInteger(value, fallback, name) {
|
|
73
|
+
if (value == null) return fallback;
|
|
74
|
+
if (!Number.isSafeInteger(value) || value < 1) {
|
|
75
|
+
throw new TypeError(`${name} must be a positive integer`);
|
|
76
|
+
}
|
|
77
|
+
return value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function unavailableGitHistory(result) {
|
|
81
|
+
if (result.error?.code === 'ENOENT') return true;
|
|
82
|
+
const detail = `${result.stderr ?? ''}\n${result.error?.message ?? ''}`;
|
|
83
|
+
return /not a git repository|does not have any commits yet|bad revision|unknown revision|ambiguous argument/i.test(detail);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parseGitMetadataOutput(stdout, dates, commits, history, expectedPaths) {
|
|
87
|
+
const fields = String(stdout ?? '').split('\0');
|
|
88
|
+
let currentDate = null;
|
|
89
|
+
let currentCommit = null;
|
|
90
|
+
for (let i = 0; i < fields.length; i++) {
|
|
91
|
+
const field = fields[i];
|
|
92
|
+
if (field === 'runlist:git-metadata:commit'
|
|
93
|
+
&& /^[0-9a-f]{40,64}$/i.test(fields[i + 1] ?? '')
|
|
94
|
+
&& /^\d{4}-\d{2}-\d{2}T/.test(fields[i + 2] ?? '')) {
|
|
95
|
+
currentCommit = fields[++i];
|
|
96
|
+
currentDate = fields[++i];
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const filePath = field.startsWith('\n') ? field.slice(1) : field;
|
|
100
|
+
if (filePath && currentDate && expectedPaths.has(filePath)) {
|
|
101
|
+
if (!history.has(filePath)) history.set(filePath, []);
|
|
102
|
+
const entries = history.get(filePath);
|
|
103
|
+
if (entries.length < GIT_METADATA_HISTORY_PER_PATH && entries.at(-1)?.commit !== currentCommit) {
|
|
104
|
+
entries.push({ date: currentDate, commit: currentCommit });
|
|
105
|
+
}
|
|
106
|
+
if (!dates.has(filePath)) {
|
|
107
|
+
dates.set(filePath, currentDate);
|
|
108
|
+
commits.set(filePath, currentCommit);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Read latest Git dates only for the current managed paths. Every subprocess
|
|
115
|
+
// has explicit history, output, and path-count bounds. Callers must inspect
|
|
116
|
+
// `complete` before using missing dates as evidence that no history exists.
|
|
117
|
+
export function getGitLastModifiedBatch(repoRoot, relPaths, options = {}) {
|
|
118
|
+
const maxCommits = boundedPositiveInteger(options.maxCommits, DEFAULT_GIT_METADATA_MAX_COMMITS, 'maxCommits');
|
|
119
|
+
const maxBuffer = boundedPositiveInteger(options.maxBuffer, DEFAULT_GIT_METADATA_MAX_BUFFER, 'maxBuffer');
|
|
120
|
+
const maxPathsPerBatch = boundedPositiveInteger(options.maxPathsPerBatch, DEFAULT_GIT_METADATA_PATH_BATCH, 'maxPathsPerBatch');
|
|
121
|
+
const paths = [...new Set(relPaths ?? [])];
|
|
122
|
+
assertSafeGitPaths(paths);
|
|
123
|
+
if (paths.length === 0) return { dates: new Map(), commits: new Map(), history: new Map(), complete: true, reason: null };
|
|
124
|
+
// Full-tree callers can supply a small set of configured root pathspecs for
|
|
125
|
+
// diff extraction.
|
|
126
|
+
const scanPaths = options.pathspecs?.length ? [...new Set(options.pathspecs)] : paths;
|
|
127
|
+
assertSafeGitPaths(scanPaths);
|
|
128
|
+
const expectedPaths = new Set(paths);
|
|
129
|
+
const revision = options.revision ?? 'HEAD';
|
|
130
|
+
|
|
131
|
+
const dates = new Map();
|
|
132
|
+
const commitsByPath = new Map();
|
|
133
|
+
const history = new Map();
|
|
134
|
+
let reason = null;
|
|
135
|
+
// Revision selection uses the root pathspecs when the caller supplied them,
|
|
136
|
+
// not the full requested list: Git matches every pathspec against every
|
|
137
|
+
// commit's diff, so selecting by roots avoids repeatedly matching every
|
|
138
|
+
// document path. The window can only widen, never narrow, per requested path —
|
|
139
|
+
// every requested path lives under a root, so a commit touching one matches
|
|
140
|
+
// the root too, and the window stays a newest-first prefix of history. What
|
|
141
|
+
// it costs is depth: commits touching only non-requested docs now consume the
|
|
142
|
+
// bound, so a path whose latest commit falls outside the window resolves to
|
|
143
|
+
// no date at all rather than to a stale one. That case is already accounted
|
|
144
|
+
// for below — an unresolved TRACKED path keeps `commit-limit`, which callers
|
|
145
|
+
// surface as "Git metadata is incomplete".
|
|
146
|
+
const revisionPathspecs = scanPaths.length < paths.length ? scanPaths : paths;
|
|
147
|
+
const revisions = spawnSync('git', ['rev-list', '--stdin', `--max-count=${maxCommits + 1}`, revision], {
|
|
148
|
+
cwd: repoRoot,
|
|
149
|
+
encoding: 'utf8',
|
|
150
|
+
input: `--\n${revisionPathspecs.map(literalGitPathspec).join('\n')}\n`,
|
|
151
|
+
maxBuffer,
|
|
152
|
+
});
|
|
153
|
+
if (revisions.error?.code === 'ENOBUFS') {
|
|
154
|
+
reason = 'output-limit';
|
|
155
|
+
} else if (revisions.error || revisions.status !== 0) {
|
|
156
|
+
if (unavailableGitHistory(revisions)) return { dates, commits: commitsByPath, history, complete: true, reason: null };
|
|
157
|
+
return { dates, commits: commitsByPath, history, complete: false, reason: 'git-error' };
|
|
158
|
+
}
|
|
159
|
+
const revisionList = String(revisions.stdout ?? '')
|
|
160
|
+
.split('\n')
|
|
161
|
+
.filter(line => /^[0-9a-f]{40,64}$/i.test(line))
|
|
162
|
+
.slice(0, maxCommits);
|
|
163
|
+
if (revisionList.length === 0) return { dates, commits: commitsByPath, history, complete: reason === null, reason };
|
|
164
|
+
if (!reason && String(revisions.stdout ?? '').trim().split('\n').length > maxCommits) {
|
|
165
|
+
reason = 'commit-limit';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
for (let offset = 0; offset < scanPaths.length; offset += maxPathsPerBatch) {
|
|
169
|
+
const batch = scanPaths.slice(offset, offset + maxPathsPerBatch);
|
|
170
|
+
const result = spawnSync('git', [
|
|
171
|
+
'diff-tree', '--stdin', '--root', '-r', '-z', '--format=%x00runlist:git-metadata:commit%x00%H%x00%aI%x00', '--name-only', '--diff-filter=ACDMR', '--', ...batch.map(literalGitPathspec),
|
|
172
|
+
], { cwd: repoRoot, encoding: 'utf8', input: revisionList.join('\n') + '\n', maxBuffer });
|
|
173
|
+
parseGitMetadataOutput(result.stdout, dates, commitsByPath, history, expectedPaths);
|
|
174
|
+
|
|
175
|
+
if (result.error?.code === 'ENOBUFS') {
|
|
176
|
+
reason = 'output-limit';
|
|
177
|
+
} else if (result.error || result.status !== 0) {
|
|
178
|
+
reason = reason ?? 'git-error';
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Reaching the history window is harmless when every requested tracked path
|
|
183
|
+
// already received a latest date. Only unresolved tracked paths require
|
|
184
|
+
// older history; untracked files legitimately have no Git date.
|
|
185
|
+
if (reason === 'commit-limit') {
|
|
186
|
+
const missing = paths.filter(filePath => !dates.has(filePath));
|
|
187
|
+
let unresolvedTracked = false;
|
|
188
|
+
let trackingCheckFailed = false;
|
|
189
|
+
for (let offset = 0; offset < missing.length; offset += maxPathsPerBatch) {
|
|
190
|
+
const batch = missing.slice(offset, offset + maxPathsPerBatch);
|
|
191
|
+
const result = spawnSync('git', ['ls-files', '-z', '--', ...batch.map(literalGitPathspec)], {
|
|
192
|
+
cwd: repoRoot, encoding: 'utf8', maxBuffer,
|
|
193
|
+
});
|
|
194
|
+
if (result.error || result.status !== 0) {
|
|
195
|
+
trackingCheckFailed = true;
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
if (String(result.stdout ?? '').split('\0').some(Boolean)) {
|
|
199
|
+
unresolvedTracked = true;
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
if (!trackingCheckFailed && !unresolvedTracked) reason = null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { dates, commits: commitsByPath, history, complete: reason === null, reason };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function parseBatchObjects(stdout, count) {
|
|
210
|
+
const output = Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout ?? '');
|
|
211
|
+
const objects = [];
|
|
212
|
+
let offset = 0;
|
|
213
|
+
while (objects.length < count && offset < output.length) {
|
|
214
|
+
const newline = output.indexOf(0x0a, offset);
|
|
215
|
+
if (newline < 0) return null;
|
|
216
|
+
const header = output.subarray(offset, newline).toString('utf8');
|
|
217
|
+
offset = newline + 1;
|
|
218
|
+
if (header.endsWith(' missing')) {
|
|
219
|
+
objects.push(null);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const size = Number(header.match(/\s(\d+)$/)?.[1]);
|
|
223
|
+
if (!Number.isSafeInteger(size) || size < 0 || offset + size > output.length) return null;
|
|
224
|
+
objects.push(output.subarray(offset, offset + size).toString('utf8'));
|
|
225
|
+
offset += size;
|
|
226
|
+
if (output[offset] === 0x0a) offset++;
|
|
227
|
+
}
|
|
228
|
+
return objects.length === count ? objects : null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function withoutUpdatedLine(raw) {
|
|
232
|
+
if (raw == null) return null;
|
|
233
|
+
const eol = raw.startsWith('---\r\n') ? '\r\n' : raw.startsWith('---\n') ? '\n' : null;
|
|
234
|
+
if (!eol) return null;
|
|
235
|
+
const marker = `${eol}---${eol}`;
|
|
236
|
+
const end = raw.indexOf(marker, 3 + eol.length);
|
|
237
|
+
if (end < 0) return null;
|
|
238
|
+
const frontmatter = raw.slice(3 + eol.length, end).split(eol);
|
|
239
|
+
const index = frontmatter.findIndex(line => line.startsWith('updated:'));
|
|
240
|
+
if (index < 0) return { line: null, content: raw };
|
|
241
|
+
const [line] = frontmatter.splice(index, 1);
|
|
242
|
+
return { line, content: `---${eol}${frontmatter.join(eol)}${marker}${raw.slice(end + marker.length)}` };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function updatedOnlyPathsByCommit(repoRoot, byCommit, maxBuffer, maxPathsPerBatch) {
|
|
246
|
+
const entries = [...byCommit].flatMap(([commit, commitPaths]) => commitPaths.map(filePath => ({ commit, filePath })));
|
|
247
|
+
const paths = new Set();
|
|
248
|
+
// Blob contents are much larger than path metadata, so keep each bounded
|
|
249
|
+
// cat-file response comfortably below the shared output cap.
|
|
250
|
+
const batchSize = Math.min(maxPathsPerBatch, 32);
|
|
251
|
+
for (let offset = 0; offset < entries.length; offset += batchSize) {
|
|
252
|
+
const batch = entries.slice(offset, offset + batchSize);
|
|
253
|
+
if (batch.some(item => item.filePath.includes('\n'))) return { paths: new Set(), complete: false };
|
|
254
|
+
const specs = batch.flatMap(item => [`${item.commit}:${item.filePath}`, `${item.commit}^:${item.filePath}`]);
|
|
255
|
+
const result = spawnSync('git', ['cat-file', '--batch'], {
|
|
256
|
+
cwd: repoRoot, input: specs.join('\n') + '\n', maxBuffer,
|
|
257
|
+
});
|
|
258
|
+
if (result.error || result.status !== 0) {
|
|
259
|
+
return { paths: new Set(), complete: false };
|
|
260
|
+
}
|
|
261
|
+
const objects = parseBatchObjects(result.stdout, specs.length);
|
|
262
|
+
if (!objects) return { paths: new Set(), complete: false };
|
|
263
|
+
for (let i = 0; i < batch.length; i++) {
|
|
264
|
+
const current = withoutUpdatedLine(objects[i * 2]);
|
|
265
|
+
const parent = withoutUpdatedLine(objects[i * 2 + 1]);
|
|
266
|
+
if (!current || !parent || current.line === parent.line) continue;
|
|
267
|
+
if (current.content === parent.content) paths.add(`${batch[i].commit}\0${batch[i].filePath}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return { paths, complete: true };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Resolve the latest substantive date for paths whose latest commit may only
|
|
274
|
+
// have synchronized the top-level `updated:` line. Callers provide their first
|
|
275
|
+
// bounded history scan so normal paths pay no extra Git cost. Metadata-only
|
|
276
|
+
// paths are grouped by commit, then resolved from the retained history window
|
|
277
|
+
// (with a bounded parent fallback); consecutive sync-only commits are skipped.
|
|
278
|
+
export function getGitLastSubstantiveModifiedBatch(repoRoot, relPaths, initialMetadata, options = {}) {
|
|
279
|
+
const maxBuffer = boundedPositiveInteger(options.maxBuffer, DEFAULT_GIT_METADATA_MAX_BUFFER, 'maxBuffer');
|
|
280
|
+
const maxPathsPerBatch = boundedPositiveInteger(options.maxPathsPerBatch, DEFAULT_GIT_METADATA_PATH_BATCH, 'maxPathsPerBatch');
|
|
281
|
+
const paths = [...new Set(relPaths ?? [])];
|
|
282
|
+
assertSafeGitPaths(paths);
|
|
283
|
+
if (paths.length === 0) return { dates: new Map(), commits: new Map(), history: new Map(), complete: true, reason: null };
|
|
284
|
+
|
|
285
|
+
const initial = initialMetadata ?? getGitLastModifiedBatch(repoRoot, paths, options);
|
|
286
|
+
const dates = new Map(initial.dates);
|
|
287
|
+
const commits = new Map(initial.commits ?? []);
|
|
288
|
+
const history = new Map(initial.history ?? []);
|
|
289
|
+
let complete = initial.complete;
|
|
290
|
+
let reason = initial.reason;
|
|
291
|
+
const byCommit = new Map();
|
|
292
|
+
for (const filePath of paths) {
|
|
293
|
+
const commit = commits.get(filePath);
|
|
294
|
+
if (!commit) continue;
|
|
295
|
+
if (!byCommit.has(commit)) byCommit.set(commit, []);
|
|
296
|
+
byCommit.get(commit).push(filePath);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const classification = updatedOnlyPathsByCommit(repoRoot, byCommit, maxBuffer, maxPathsPerBatch);
|
|
300
|
+
if (!classification.complete) {
|
|
301
|
+
complete = false;
|
|
302
|
+
reason = reason ?? 'git-error';
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
for (const [commit, commitPaths] of byCommit) {
|
|
306
|
+
const metadataOnly = commitPaths.filter(filePath => classification.paths.has(`${commit}\0${filePath}`));
|
|
307
|
+
if (metadataOnly.length === 0) continue;
|
|
308
|
+
|
|
309
|
+
const older = {
|
|
310
|
+
dates: new Map(), commits: new Map(), history: new Map(),
|
|
311
|
+
complete: initial.complete, reason: initial.reason,
|
|
312
|
+
};
|
|
313
|
+
const unresolved = [];
|
|
314
|
+
for (const filePath of metadataOnly) {
|
|
315
|
+
const entries = history.get(filePath) ?? [];
|
|
316
|
+
const currentIndex = entries.findIndex(entry => entry.commit === commit);
|
|
317
|
+
const remaining = currentIndex >= 0 ? entries.slice(currentIndex + 1) : [];
|
|
318
|
+
if (remaining.length === 0) {
|
|
319
|
+
unresolved.push(filePath);
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
older.dates.set(filePath, remaining[0].date);
|
|
323
|
+
older.commits.set(filePath, remaining[0].commit);
|
|
324
|
+
older.history.set(filePath, remaining);
|
|
325
|
+
}
|
|
326
|
+
if (unresolved.length > 0) {
|
|
327
|
+
older.complete = false;
|
|
328
|
+
older.reason = older.reason ?? 'commit-limit';
|
|
329
|
+
}
|
|
330
|
+
const substantive = getGitLastSubstantiveModifiedBatch(repoRoot, metadataOnly, older, options);
|
|
331
|
+
complete = complete && substantive.complete;
|
|
332
|
+
reason = reason ?? substantive.reason;
|
|
333
|
+
for (const filePath of metadataOnly) {
|
|
334
|
+
if (substantive.dates.has(filePath)) {
|
|
335
|
+
dates.set(filePath, substantive.dates.get(filePath));
|
|
336
|
+
commits.set(filePath, substantive.commits.get(filePath));
|
|
337
|
+
if (substantive.history.has(filePath)) history.set(filePath, substantive.history.get(filePath));
|
|
338
|
+
} else {
|
|
339
|
+
dates.delete(filePath);
|
|
340
|
+
commits.delete(filePath);
|
|
341
|
+
history.delete(filePath);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
return { dates, commits, history, complete, reason };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function parseNullPaths(result) {
|
|
350
|
+
if (result.error || result.status !== 0) return [];
|
|
351
|
+
return result.stdout.split('\0').filter(Boolean);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function parsePorcelainRecords(result) {
|
|
355
|
+
if (result.error || result.status !== 0) return [];
|
|
356
|
+
const fields = result.stdout.split('\0').filter(Boolean);
|
|
357
|
+
const records = [];
|
|
358
|
+
for (let i = 0; i < fields.length; i++) {
|
|
359
|
+
const field = fields[i];
|
|
360
|
+
if (field.length < 4 || field[2] !== ' ') continue;
|
|
361
|
+
const status = field.slice(0, 2);
|
|
362
|
+
records.push({ status, path: field.slice(3) });
|
|
363
|
+
// In porcelain v1 -z, rename/copy records put the destination first and
|
|
364
|
+
// the old source path in the following NUL field. The command includes the
|
|
365
|
+
// destination state, not the now-missing live source.
|
|
366
|
+
if (/[RC]/.test(status)) i += 1;
|
|
367
|
+
}
|
|
368
|
+
return records;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function parseShortOptions(arg, valueOptions = new Set()) {
|
|
372
|
+
const parsed = { options: new Set(), consumesNext: false };
|
|
373
|
+
if (!/^-[^-]/.test(arg)) return parsed;
|
|
374
|
+
const chars = arg.slice(1);
|
|
375
|
+
for (let i = 0; i < chars.length; i++) {
|
|
376
|
+
const option = chars[i];
|
|
377
|
+
parsed.options.add(option);
|
|
378
|
+
if (valueOptions.has(option)) {
|
|
379
|
+
parsed.consumesNext = i === chars.length - 1;
|
|
380
|
+
break; // remaining characters are this option's attached value
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return parsed;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function hasShortOption(args, option, valueOptions = new Set()) {
|
|
387
|
+
return args.some(arg => parseShortOptions(arg, valueOptions).options.has(option));
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function gitPathspecs(args, subcommand) {
|
|
391
|
+
const separator = args.indexOf('--');
|
|
392
|
+
if (separator >= 0) return args.slice(separator + 1);
|
|
393
|
+
const valueFlags = subcommand === 'commit'
|
|
394
|
+
? new Set(['-m', '--message', '-F', '--file', '-C', '-c', '--reuse-message', '--reedit-message', '-t', '--template', '--author', '--date', '--cleanup', '--fixup', '--squash', '--trailer'])
|
|
395
|
+
: new Set(['--chmod', '--pathspec-from-file']);
|
|
396
|
+
const paths = [];
|
|
397
|
+
for (let i = 0; i < args.length; i++) {
|
|
398
|
+
const arg = args[i];
|
|
399
|
+
if (valueFlags.has(arg)) { i += 1; continue; }
|
|
400
|
+
if (subcommand === 'commit' && /^-[^-]/.test(arg)) {
|
|
401
|
+
const short = parseShortOptions(arg, new Set(['m', 'F', 'C', 'c', 't', 'S']));
|
|
402
|
+
if (short.consumesNext) i += 1;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (arg.startsWith('-')) continue;
|
|
406
|
+
paths.push(arg);
|
|
407
|
+
}
|
|
408
|
+
return paths;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Paths a broad `git add`/`git commit` would actually include. Read-only and
|
|
412
|
+
// fail-open: any Git error returns an empty list so the PreToolUse guard never
|
|
413
|
+
// blocks on an uncertain repository state.
|
|
414
|
+
export function inspectGitCommandPaths(subcommand, args, repoRoot) {
|
|
415
|
+
try {
|
|
416
|
+
if ((subcommand === 'add' || subcommand === 'stage')
|
|
417
|
+
&& (hasShortOption(args, 'n') || args.includes('--dry-run'))) return [];
|
|
418
|
+
if (subcommand === 'commit' && args.includes('--dry-run')) return [];
|
|
419
|
+
const pathspecs = gitPathspecs(args, subcommand);
|
|
420
|
+
if (subcommand === 'add' || subcommand === 'stage') {
|
|
421
|
+
const cmd = ['status', '--porcelain=v1', '-z', '--untracked-files=all'];
|
|
422
|
+
if (pathspecs.length > 0) cmd.push('--', ...pathspecs);
|
|
423
|
+
const updateOnly = hasShortOption(args, 'u') || args.includes('--update');
|
|
424
|
+
const records = parsePorcelainRecords(spawnSync('git', cmd, { cwd: repoRoot, encoding: 'utf8' }));
|
|
425
|
+
const paths = records
|
|
426
|
+
.filter(record => !updateOnly || record.status !== '??')
|
|
427
|
+
.map(record => record.path)
|
|
428
|
+
.filter(candidate => existsSync(path.resolve(repoRoot, candidate)));
|
|
429
|
+
const forceAll = hasShortOption(args, 'A') || args.includes('--all');
|
|
430
|
+
if ((hasShortOption(args, 'f') || args.includes('--force')) && (pathspecs.length > 0 || forceAll)) {
|
|
431
|
+
const ignoredCmd = ['ls-files', '--others', '--ignored', '--exclude-standard', '-z'];
|
|
432
|
+
if (pathspecs.length > 0) ignoredCmd.push('--', ...pathspecs);
|
|
433
|
+
paths.push(...parseNullPaths(spawnSync('git', ignoredCmd, { cwd: repoRoot, encoding: 'utf8' })));
|
|
434
|
+
}
|
|
435
|
+
return [...new Set(paths)];
|
|
436
|
+
}
|
|
437
|
+
if (subcommand !== 'commit') return [];
|
|
438
|
+
|
|
439
|
+
const commands = [];
|
|
440
|
+
const commitValueOptions = new Set(['m', 'F', 'C', 'c', 't', 'S']);
|
|
441
|
+
const commitAll = args.includes('--all') || hasShortOption(args, 'a', commitValueOptions);
|
|
442
|
+
if (pathspecs.length > 0) {
|
|
443
|
+
const hasHead = spawnSync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).status === 0;
|
|
444
|
+
if (hasHead) commands.push(['diff', 'HEAD', '--name-only', '--diff-filter=ACMR', '-z', '--', ...pathspecs]);
|
|
445
|
+
else commands.push(['diff', '--cached', '--name-only', '--diff-filter=ACMR', '-z', '--', ...pathspecs]);
|
|
446
|
+
if (args.includes('-i') || args.includes('--include')) {
|
|
447
|
+
commands.push(['diff', '--cached', '--name-only', '--diff-filter=ACMR', '-z']);
|
|
448
|
+
}
|
|
449
|
+
} else if (commitAll) {
|
|
450
|
+
// commit -a replaces tracked index entries with worktree state. Comparing
|
|
451
|
+
// HEAD directly to the worktree models that final commit and avoids a
|
|
452
|
+
// false deny when a staged prompt change was subsequently reverted.
|
|
453
|
+
commands.push(['diff', 'HEAD', '--name-only', '--diff-filter=ACMR', '-z']);
|
|
454
|
+
} else {
|
|
455
|
+
commands.push(['diff', '--cached', '--name-only', '--diff-filter=ACMR', '-z']);
|
|
456
|
+
}
|
|
457
|
+
return [...new Set(commands.flatMap(cmd => parseNullPaths(spawnSync('git', cmd, { cwd: repoRoot, encoding: 'utf8' }))))];
|
|
458
|
+
} catch {
|
|
459
|
+
return [];
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function listStagedPaths(repoRoot) {
|
|
464
|
+
const result = spawnSync('git', ['diff', '--cached', '--name-only', '--no-renames', '-z'], {
|
|
465
|
+
cwd: repoRoot,
|
|
466
|
+
encoding: 'utf8',
|
|
467
|
+
});
|
|
468
|
+
if (result.error || result.status !== 0) {
|
|
469
|
+
throw new Error(`Cannot inspect staged files: ${result.error?.message || result.stderr.trim() || 'git diff failed'}`);
|
|
470
|
+
}
|
|
471
|
+
return result.stdout.split('\0').filter(Boolean);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export function assertGitIndex(repoRoot, expectedPaths = []) {
|
|
475
|
+
const actual = [...new Set(listStagedPaths(repoRoot))].sort();
|
|
476
|
+
const expected = [...new Set(expectedPaths)].sort();
|
|
477
|
+
if (actual.length === expected.length && actual.every((item, index) => item === expected[index])) {
|
|
478
|
+
return actual;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const expectedLabel = expected.length > 0 ? expected.join(', ') : 'empty';
|
|
482
|
+
const actualLabel = actual.length > 0 ? actual.join(', ') : 'empty';
|
|
483
|
+
throw new Error(`Git index mismatch (expected: ${expectedLabel}; staged: ${actualLabel}). Commit or unstage existing work before releasing.`);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
export function gitMv(source, target, repoRoot) {
|
|
487
|
+
ensureGit();
|
|
488
|
+
// Source is untracked (scaffolded this session, never committed; or repoRoot
|
|
489
|
+
// is not a git repo at all): a plain rename is the only correct move. `git mv`
|
|
490
|
+
// would error with `fatal: not under version control` and the user can't act
|
|
491
|
+
// on that — the file is genuinely a doc, just not yet staged.
|
|
492
|
+
if (!isTracked(source, repoRoot)) {
|
|
493
|
+
const absSource = path.isAbsolute(source) ? source : path.join(repoRoot, source);
|
|
494
|
+
const absTarget = path.isAbsolute(target) ? target : path.join(repoRoot, target);
|
|
495
|
+
try {
|
|
496
|
+
renameSync(absSource, absTarget);
|
|
497
|
+
return { status: 0, stderr: '' };
|
|
498
|
+
} catch (err) {
|
|
499
|
+
return { status: 1, stderr: err.message };
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const result = spawnSync('git', ['mv', source, target], {
|
|
503
|
+
cwd: repoRoot,
|
|
504
|
+
encoding: 'utf8',
|
|
505
|
+
});
|
|
506
|
+
return { status: result.status, stderr: result.stderr };
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
export function isTracked(source, repoRoot) {
|
|
510
|
+
const relSource = path.isAbsolute(source) ? path.relative(repoRoot, source) : source;
|
|
511
|
+
const result = spawnSync('git', ['ls-files', '--error-unmatch', '--', relSource], {
|
|
512
|
+
cwd: repoRoot,
|
|
513
|
+
encoding: 'utf8',
|
|
514
|
+
});
|
|
515
|
+
return result.status === 0;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
export function gitIndexLocations(repoRoot) {
|
|
519
|
+
const result = spawnSync('git', ['rev-parse', '--git-dir'], { cwd: repoRoot, encoding: 'utf8' });
|
|
520
|
+
if (result.error || result.status !== 0) throw new Error(`Could not locate Git directory: ${result.error?.message || result.stderr.trim()}`);
|
|
521
|
+
const gitDir = path.resolve(repoRoot, result.stdout.trim());
|
|
522
|
+
const indexPath = process.env.GIT_INDEX_FILE ? path.resolve(repoRoot, process.env.GIT_INDEX_FILE) : path.join(gitDir, 'index');
|
|
523
|
+
return { gitDir, indexPath, indexDir: path.dirname(indexPath), lockPath: `${indexPath}.lock` };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function generationFromBytes(bytes, indexPath, mode) {
|
|
527
|
+
return { exists: true, hash: createHash('sha256').update(bytes).digest('hex'), size: bytes.length, content: bytes.toString('base64'), mode, indexPath };
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
function captureIndexPath(indexPath) {
|
|
531
|
+
if (!existsSync(indexPath)) return { exists: false, hash: null, size: 0, content: null, mode: null, indexPath };
|
|
532
|
+
const stat = lstatSync(indexPath);
|
|
533
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Selected Git index is not a regular file: ${indexPath}`);
|
|
534
|
+
return generationFromBytes(readFileSync(indexPath), indexPath, stat.mode & 0o7777);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export function captureGitIndexGeneration(repoRoot) {
|
|
538
|
+
return captureIndexPath(gitIndexLocations(repoRoot).indexPath);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export function sameGitIndexGeneration(left, right) {
|
|
542
|
+
return Boolean(left && right && left.indexPath === right.indexPath && left.exists === right.exists
|
|
543
|
+
&& left.hash === right.hash && left.size === right.size && left.mode === right.mode);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function fsyncIndexDirectory(directory, testHooks, reason) {
|
|
547
|
+
testHooks?.beforeGitIndexDirectoryFsync?.({ directory, reason });
|
|
548
|
+
const dirFd = openSync(directory, 'r');
|
|
549
|
+
try { fsyncSync(dirFd); } catch (err) { if (!['EINVAL', 'ENOTSUP', 'EBADF', 'EISDIR', 'EPERM'].includes(err?.code)) throw err; } finally { closeSync(dirFd); }
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function writeGeneration(filePath, generation, mode = generation.mode ?? (0o666 & ~process.umask())) {
|
|
553
|
+
const bytes = generation.exists ? Buffer.from(generation.content, 'base64') : Buffer.alloc(0);
|
|
554
|
+
if (generation.exists && (bytes.length !== generation.size || createHash('sha256').update(bytes).digest('hex') !== generation.hash)) throw new Error('Invalid captured Git index generation.');
|
|
555
|
+
const fd = openSync(filePath, 'wx', mode);
|
|
556
|
+
try { writeFileSync(fd, bytes); fsyncSync(fd); } finally { closeSync(fd); }
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function describePrepared(preparedPath, generation) {
|
|
560
|
+
const stat = lstatSync(preparedPath);
|
|
561
|
+
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Prepared Git index is unsafe: ${preparedPath}`);
|
|
562
|
+
const bytes = readFileSync(preparedPath);
|
|
563
|
+
return {
|
|
564
|
+
path: preparedPath,
|
|
565
|
+
dev: stat.dev,
|
|
566
|
+
ino: stat.ino,
|
|
567
|
+
mode: stat.mode & 0o7777,
|
|
568
|
+
size: bytes.length,
|
|
569
|
+
hash: createHash('sha256').update(bytes).digest('hex'),
|
|
570
|
+
generation,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function preparedMatches(prepared) {
|
|
575
|
+
try {
|
|
576
|
+
const stat = lstatSync(prepared.path);
|
|
577
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.dev !== prepared.dev || stat.ino !== prepared.ino || (stat.mode & 0o7777) !== prepared.mode || stat.size !== prepared.size) return false;
|
|
578
|
+
return createHash('sha256').update(readFileSync(prepared.path)).digest('hex') === prepared.hash;
|
|
579
|
+
} catch { return false; }
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function unlinkPrepared(prepared, testHooks = null, reason = 'prepared-delete') {
|
|
583
|
+
if (prepared && preparedMatches(prepared)) {
|
|
584
|
+
unlinkSync(prepared.path);
|
|
585
|
+
fsyncIndexDirectory(path.dirname(prepared.path), testHooks, reason);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function prepareMoveIndex(source, target, repoRoot, before, options = {}) {
|
|
590
|
+
const testHooks = options.testHooks ?? {};
|
|
591
|
+
const paths = [source, target].map(candidate => path.isAbsolute(candidate) ? path.relative(repoRoot, candidate) : candidate);
|
|
592
|
+
assertSafeGitPaths(paths);
|
|
593
|
+
const { indexDir } = gitIndexLocations(repoRoot);
|
|
594
|
+
const preparedPath = path.join(indexDir, `${ARTIFACT_PREFIX}index-${process.pid}-${randomUUID()}`);
|
|
595
|
+
const artifactPath = options.artifactPath ?? preparedPath;
|
|
596
|
+
writeGeneration(artifactPath, before);
|
|
597
|
+
let seed = { ...describePrepared(artifactPath, before), state: 'preparing', tempPath: preparedPath, work: null };
|
|
598
|
+
testHooks.afterGitIndexArtifact?.({ before, prepared: seed });
|
|
599
|
+
if (artifactPath !== preparedPath && before.exists) {
|
|
600
|
+
try { linkSync(artifactPath, preparedPath); }
|
|
601
|
+
catch (err) {
|
|
602
|
+
if (err?.code !== 'EXDEV') throw err;
|
|
603
|
+
writeGeneration(preparedPath, before);
|
|
604
|
+
seed = { ...seed, work: describePrepared(preparedPath, before) };
|
|
605
|
+
testHooks.afterGitIndexWorkSeed?.({ before, prepared: seed });
|
|
606
|
+
}
|
|
607
|
+
fsyncIndexDirectory(indexDir, testHooks, 'working-index-link');
|
|
608
|
+
}
|
|
609
|
+
testHooks.beforeGitIndexPrepare?.({ preparedPath, before, prepared: seed });
|
|
610
|
+
const env = { ...process.env, GIT_INDEX_FILE: preparedPath };
|
|
611
|
+
// A tracked source relocating into an ignored destination is exactly what
|
|
612
|
+
// `git mv` permits: ignore patterns govern NEW untracked paths, not the
|
|
613
|
+
// relocation of content git already tracks. Bare `git add` doesn't make that
|
|
614
|
+
// distinction and refuses ("paths are ignored by one of your .gitignore
|
|
615
|
+
// files"), which failed the whole transaction and rolled the move back — so
|
|
616
|
+
// `runlist archive` was unusable in any repo that gitignores its docs root
|
|
617
|
+
// while force-tracking the docs inside it. Force only when the source was
|
|
618
|
+
// tracked; an untracked source stays subject to the ignore.
|
|
619
|
+
const sourceTracked = isTracked(paths[0], repoRoot);
|
|
620
|
+
const addArgs = sourceTracked ? ['add', '-f', '-A', '--', paths[1]] : ['add', '-A', '--', paths[1]];
|
|
621
|
+
const result = spawnSync('git', addArgs, {
|
|
622
|
+
cwd: repoRoot,
|
|
623
|
+
encoding: 'utf8',
|
|
624
|
+
env,
|
|
625
|
+
});
|
|
626
|
+
let preCheckpointError = null;
|
|
627
|
+
try { testHooks.afterGitIndexSubprocessBeforeCheckpoint?.({ preparedPath, before, prepared: seed, step: 'target-added', result }); }
|
|
628
|
+
catch (err) { preCheckpointError = err; }
|
|
629
|
+
fsyncIndexDirectory(indexDir, testHooks, 'working-index-step');
|
|
630
|
+
const prepareStep = existsSync(preparedPath) ? { ...seed, work: describePrepared(preparedPath, captureIndexPath(preparedPath)) } : seed;
|
|
631
|
+
if (prepareStep.work) prepareStep.work.generation.indexPath = before.indexPath;
|
|
632
|
+
testHooks.afterGitIndexPrepareStep?.({ preparedPath, before, prepared: prepareStep, step: 'target-added' });
|
|
633
|
+
if (preCheckpointError) throw preCheckpointError;
|
|
634
|
+
if (result.error || result.status !== 0) {
|
|
635
|
+
throw new Error(`Could not stage moved document: ${result.error?.message || result.stderr.trim() || 'git add failed'}`);
|
|
636
|
+
}
|
|
637
|
+
testHooks.duringGitIndexPrepare?.({ preparedPath, before, prepared: prepareStep, step: 'target-added' });
|
|
638
|
+
const removed = spawnSync('git', ['update-index', '--remove', '--', paths[0]], { cwd: repoRoot, encoding: 'utf8', env });
|
|
639
|
+
preCheckpointError = null;
|
|
640
|
+
try { testHooks.afterGitIndexSubprocessBeforeCheckpoint?.({ preparedPath, before, prepared: prepareStep, step: 'source-removed', result: removed }); }
|
|
641
|
+
catch (err) { preCheckpointError = err; }
|
|
642
|
+
fsyncIndexDirectory(indexDir, testHooks, 'working-index-step');
|
|
643
|
+
const removedStep = existsSync(preparedPath) ? { ...seed, work: describePrepared(preparedPath, captureIndexPath(preparedPath)) } : prepareStep;
|
|
644
|
+
if (removedStep.work) removedStep.work.generation.indexPath = before.indexPath;
|
|
645
|
+
testHooks.afterGitIndexPrepareStep?.({ preparedPath, before, prepared: removedStep, step: 'source-removed' });
|
|
646
|
+
if (preCheckpointError) throw preCheckpointError;
|
|
647
|
+
if (removed.error || removed.status !== 0) {
|
|
648
|
+
throw new Error(`Could not stage moved document source removal: ${removed.error?.message || removed.stderr.trim() || 'git update-index failed'}`);
|
|
649
|
+
}
|
|
650
|
+
if (before.exists && (lstatSync(preparedPath).mode & 0o7777) !== before.mode) {
|
|
651
|
+
chmodSync(preparedPath, before.mode);
|
|
652
|
+
const fd = openSync(preparedPath, 'r+');
|
|
653
|
+
try { fsyncSync(fd); } finally { closeSync(fd); }
|
|
654
|
+
}
|
|
655
|
+
const generation = captureIndexPath(preparedPath);
|
|
656
|
+
generation.indexPath = before.indexPath;
|
|
657
|
+
const work = describePrepared(preparedPath, generation);
|
|
658
|
+
if (artifactPath !== preparedPath) {
|
|
659
|
+
writeFileSync(artifactPath, readFileSync(preparedPath));
|
|
660
|
+
chmodSync(artifactPath, generation.mode);
|
|
661
|
+
const artifactFd = openSync(artifactPath, 'r+');
|
|
662
|
+
try { fsyncSync(artifactFd); } finally { closeSync(artifactFd); }
|
|
663
|
+
}
|
|
664
|
+
const prepared = { ...describePrepared(artifactPath, generation), state: 'prepared', tempPath: preparedPath, work };
|
|
665
|
+
testHooks.afterGitIndexPrepared?.({ preparedPath, before, generation, prepared });
|
|
666
|
+
return prepared;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// A failure that provably happened before `.git/index` was replaced. Rollback
|
|
670
|
+
// has to tell this apart from "we may already have published": in the latter
|
|
671
|
+
// case it must restore the index and, failing that, retain the transaction for
|
|
672
|
+
// manual repair; in this one there is nothing of ours in the index to undo, so
|
|
673
|
+
// insisting on a restore turns someone else's concurrent `git add` into a
|
|
674
|
+
// wedged repo. `race` additionally means the failure was contention, not a
|
|
675
|
+
// verdict — the same call can succeed on a fresh generation.
|
|
676
|
+
function markUnpublishedIndexError(err, { race = false } = {}) {
|
|
677
|
+
if (err && typeof err === 'object') {
|
|
678
|
+
err.gitIndexPublished = false;
|
|
679
|
+
if (race) err.gitIndexRace = true;
|
|
680
|
+
}
|
|
681
|
+
return err;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
export function gitIndexProvablyUnpublished(err) {
|
|
685
|
+
return err?.gitIndexPublished === false;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
export function gitIndexPublicationRaced(err) {
|
|
689
|
+
return err?.gitIndexPublished === false && err?.gitIndexRace === true;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function publishIndexGeneration(repoRoot, expected, desired, prepared, testHooks = {}) {
|
|
693
|
+
let published = false;
|
|
694
|
+
try {
|
|
695
|
+
return publishIndexGenerationLocked(repoRoot, expected, desired, prepared, testHooks, state => { published = state; });
|
|
696
|
+
} catch (err) {
|
|
697
|
+
if (!published) markUnpublishedIndexError(err, { race: Boolean(err?.gitIndexRace) });
|
|
698
|
+
throw err;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function publishIndexGenerationLocked(repoRoot, expected, desired, prepared, testHooks, notePublished) {
|
|
703
|
+
const { indexPath, indexDir, lockPath } = gitIndexLocations(repoRoot);
|
|
704
|
+
if (expected.indexPath !== indexPath || desired.indexPath !== indexPath) throw new Error('Selected Git index changed since the transaction snapshot; recovery refused to target a different index.');
|
|
705
|
+
if (!prepared || prepared.state !== 'prepared' || path.dirname(prepared.tempPath) !== indexDir || !isOwnedArtifact(path.basename(prepared.tempPath), 'index') || !preparedMatches(prepared)) throw new Error('Prepared Git index ownership could not be verified.');
|
|
706
|
+
if ((desired.exists && (prepared.hash !== desired.hash || prepared.size !== desired.size || prepared.mode !== desired.mode)) || (!desired.exists && prepared.size !== 0)) throw new Error('Prepared Git index artifact does not match the desired generation.');
|
|
707
|
+
const publication = prepared.work ?? prepared;
|
|
708
|
+
if (!preparedMatches(publication) || (desired.exists && (publication.hash !== desired.hash || publication.size !== desired.size || publication.mode !== desired.mode)) || (!desired.exists && publication.size !== 0)) throw new Error('Selected-directory Git working index does not match the desired generation.');
|
|
709
|
+
let lockOwned = false;
|
|
710
|
+
try {
|
|
711
|
+
linkSync(publication.path, lockPath);
|
|
712
|
+
lockOwned = true;
|
|
713
|
+
fsyncIndexDirectory(indexDir, testHooks, 'lock-acquisition');
|
|
714
|
+
} catch (err) {
|
|
715
|
+
if (lockOwned) {
|
|
716
|
+
try {
|
|
717
|
+
const lock = lstatSync(lockPath);
|
|
718
|
+
if (lock.dev === publication.dev && lock.ino === publication.ino) {
|
|
719
|
+
unlinkSync(lockPath);
|
|
720
|
+
fsyncIndexDirectory(indexDir, null, 'lock-acquisition-rollback');
|
|
721
|
+
}
|
|
722
|
+
} catch { /* retain original durability error */ }
|
|
723
|
+
}
|
|
724
|
+
if (err?.code === 'EEXIST') throw markUnpublishedIndexError(new Error('Git index is locked by another process; transaction index publication was not attempted.'), { race: true });
|
|
725
|
+
throw err;
|
|
726
|
+
}
|
|
727
|
+
let published = false;
|
|
728
|
+
try {
|
|
729
|
+
testHooks.afterGitIndexLock?.({ lockPath, expected, desired });
|
|
730
|
+
const current = captureIndexPath(indexPath);
|
|
731
|
+
if (!sameGitIndexGeneration(current, expected)) throw markUnpublishedIndexError(new Error('Git index changed before transaction publication; current staging was preserved.'), { race: true });
|
|
732
|
+
testHooks.afterGitIndexCompare?.({ lockPath, current, desired });
|
|
733
|
+
if (desired.exists) {
|
|
734
|
+
// .git/index is routinely held open by concurrent git processes and IDE git
|
|
735
|
+
// extensions, so on Windows this publish needs the retry.
|
|
736
|
+
commitRename(lockPath, indexPath, testHooks);
|
|
737
|
+
lockOwned = false;
|
|
738
|
+
} else {
|
|
739
|
+
if (existsSync(indexPath)) unlinkSync(indexPath);
|
|
740
|
+
const lock = lstatSync(lockPath);
|
|
741
|
+
if (!lock.isFile() || lock.isSymbolicLink() || lock.dev !== publication.dev || lock.ino !== publication.ino) throw new Error('Transaction-owned Git index lock was replaced before deletion.');
|
|
742
|
+
unlinkSync(lockPath);
|
|
743
|
+
lockOwned = false;
|
|
744
|
+
}
|
|
745
|
+
published = true;
|
|
746
|
+
notePublished(true);
|
|
747
|
+
fsyncIndexDirectory(indexDir, testHooks, 'publication');
|
|
748
|
+
testHooks.afterGitIndexPublication?.({ indexPath, desired });
|
|
749
|
+
return desired;
|
|
750
|
+
} finally {
|
|
751
|
+
if (!published && lockOwned) {
|
|
752
|
+
try {
|
|
753
|
+
const lock = lstatSync(lockPath);
|
|
754
|
+
if (lock.dev === publication.dev && lock.ino === publication.ino) {
|
|
755
|
+
unlinkSync(lockPath);
|
|
756
|
+
fsyncIndexDirectory(indexDir, testHooks, 'failed-lock-delete');
|
|
757
|
+
}
|
|
758
|
+
} catch (err) {
|
|
759
|
+
if (err?.code !== 'ENOENT') throw err;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
export function stageMovePathsCas(source, target, repoRoot, before, options = {}) {
|
|
766
|
+
let prepared;
|
|
767
|
+
// Preparation writes only to transaction-owned scratch indexes, so anything
|
|
768
|
+
// that fails here — a refused `git add`, a failing clean filter — leaves the
|
|
769
|
+
// real index untouched by definition.
|
|
770
|
+
try { prepared = prepareMoveIndex(source, target, repoRoot, before, options); }
|
|
771
|
+
catch (err) { throw markUnpublishedIndexError(err); }
|
|
772
|
+
try { return publishIndexGeneration(repoRoot, before, prepared.generation, prepared, options.testHooks); }
|
|
773
|
+
finally {
|
|
774
|
+
if (prepared.work && prepared.work.path !== prepared.path) unlinkPrepared(prepared.work, options.testHooks, 'working-index-delete');
|
|
775
|
+
unlinkPrepared(prepared, options.testHooks);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
export function restoreGitIndexCas(before, ownedAfter, repoRoot, options = {}) {
|
|
780
|
+
const { indexDir } = gitIndexLocations(repoRoot);
|
|
781
|
+
const preparedPath = options.artifactPath ?? path.join(indexDir, `${ARTIFACT_PREFIX}index-restore-${process.pid}-${randomUUID()}`);
|
|
782
|
+
let artifact = null;
|
|
783
|
+
try {
|
|
784
|
+
writeGeneration(preparedPath, before);
|
|
785
|
+
const prepared = before;
|
|
786
|
+
const tempPath = path.join(indexDir, `${ARTIFACT_PREFIX}index-restore-${process.pid}-${randomUUID()}`);
|
|
787
|
+
artifact = { ...describePrepared(preparedPath, prepared), state: 'preparing', tempPath, work: null };
|
|
788
|
+
options.testHooks?.afterGitRestoreArtifact?.({ before, ownedAfter, prepared: artifact });
|
|
789
|
+
writeGeneration(tempPath, before);
|
|
790
|
+
const work = describePrepared(tempPath, prepared);
|
|
791
|
+
artifact = { ...artifact, state: 'prepared', work };
|
|
792
|
+
options.testHooks?.afterGitRestorePrepared?.({ before, ownedAfter, preparedPath, prepared: artifact });
|
|
793
|
+
return publishIndexGeneration(repoRoot, ownedAfter, prepared, artifact, options.testHooks);
|
|
794
|
+
} finally {
|
|
795
|
+
if (artifact?.work) unlinkPrepared(artifact.work, options.testHooks, 'restore-working-index-delete');
|
|
796
|
+
unlinkPrepared(artifact, options.testHooks, 'restore-prepared-delete');
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// `.git/index.lock` is only ever ours as a hard link to the prepared index, so
|
|
801
|
+
// the recorded publication inode identifies it even after the artifact itself
|
|
802
|
+
// is unlinked.
|
|
803
|
+
function lockIsOurs(lockPath, prepared) {
|
|
804
|
+
const publication = prepared.work ?? prepared;
|
|
805
|
+
try {
|
|
806
|
+
const lock = lstatSync(lockPath);
|
|
807
|
+
return lock.isFile() && !lock.isSymbolicLink() && lock.dev === publication.dev && lock.ino === publication.ino;
|
|
808
|
+
} catch { return false; }
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
export function reclaimPreparedGitIndex(manifestGitIndex, repoRoot, options = {}) {
|
|
812
|
+
const prepared = manifestGitIndex?.prepared;
|
|
813
|
+
if (!prepared) return { cleaned: false, retainedPaths: [] };
|
|
814
|
+
const retainedPaths = [];
|
|
815
|
+
const { indexPath, indexDir, lockPath } = gitIndexLocations(repoRoot);
|
|
816
|
+
if (manifestGitIndex.before?.indexPath !== indexPath || prepared.generation?.indexPath !== indexPath) throw new Error('Recovery environment selects a different Git index than the abandoned transaction.');
|
|
817
|
+
if (path.dirname(prepared.tempPath) !== indexDir || !isOwnedArtifact(path.basename(prepared.tempPath), 'index')) throw new Error('Abandoned prepared Git index path is unsafe.');
|
|
818
|
+
if (!existsSync(prepared.path)) {
|
|
819
|
+
// The lock is only ever ours as a hard link to the publication inode, so a
|
|
820
|
+
// lock that does not carry that inode cannot be ours — it belongs to a live
|
|
821
|
+
// `git` (a plain `git status` takes index.lock to rewrite its stat cache).
|
|
822
|
+
// Leaving it is right; treating it as damage was not. That throw travelled
|
|
823
|
+
// up as a failed rollback and retained the transaction, so a few hundred
|
|
824
|
+
// milliseconds of ordinary Git activity bricked every later mutation in the
|
|
825
|
+
// repo until `doctor --transactions --apply` ran.
|
|
826
|
+
if (existsSync(lockPath) && lockIsOurs(lockPath, prepared)) throw new Error('A transaction-owned Git index lock outlived its prepared artifact; it was preserved.');
|
|
827
|
+
if (existsSync(prepared.tempPath)) retainedPaths.push(prepared.tempPath);
|
|
828
|
+
return { cleaned: false, retainedPaths };
|
|
829
|
+
}
|
|
830
|
+
if (!preparedMatches(prepared)) throw new Error('Abandoned prepared Git index ownership could not be verified.');
|
|
831
|
+
if (existsSync(prepared.tempPath)) {
|
|
832
|
+
const expectedWork = prepared.work ?? { ...prepared, path: prepared.tempPath };
|
|
833
|
+
if (!preparedMatches(expectedWork)) {
|
|
834
|
+
if (prepared.state !== 'preparing') throw new Error('Abandoned Git working index is foreign or unverified; it was preserved.');
|
|
835
|
+
retainedPaths.push(prepared.tempPath);
|
|
836
|
+
} else {
|
|
837
|
+
unlinkPrepared(expectedWork, options.testHooks, 'recovery-working-index-delete');
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (existsSync(lockPath)) {
|
|
841
|
+
const lock = lstatSync(lockPath);
|
|
842
|
+
const publication = prepared.work ?? prepared;
|
|
843
|
+
if (!lock.isFile() || lock.isSymbolicLink() || lock.dev !== publication.dev || lock.ino !== publication.ino) throw new Error('Git index lock is foreign; it was preserved.');
|
|
844
|
+
unlinkSync(lockPath);
|
|
845
|
+
fsyncIndexDirectory(indexDir, options.testHooks, 'recovery-lock-delete');
|
|
846
|
+
}
|
|
847
|
+
unlinkPrepared(prepared, options.testHooks, 'recovery-prepared-delete');
|
|
848
|
+
return { cleaned: true, retainedPaths };
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
export function stageMovePaths(source, target, repoRoot) {
|
|
852
|
+
const before = captureGitIndexGeneration(repoRoot);
|
|
853
|
+
return stageMovePathsCas(source, target, repoRoot, before);
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// Legacy selected-path helpers remain read-only compatibility surfaces.
|
|
857
|
+
export function captureGitIndexPaths(paths, repoRoot) {
|
|
858
|
+
const relative = paths.map(candidate => path.isAbsolute(candidate) ? path.relative(repoRoot, candidate) : candidate);
|
|
859
|
+
assertSafeGitPaths(relative);
|
|
860
|
+
const result = spawnSync('git', ['ls-files', '--stage', '-z', '--', ...relative], { cwd: repoRoot, encoding: 'utf8' });
|
|
861
|
+
if (result.error || result.status !== 0) throw new Error(`Could not snapshot Git index: ${result.error?.message || result.stderr.trim()}`);
|
|
862
|
+
const records = result.stdout.split('\0').filter(Boolean);
|
|
863
|
+
return { paths: relative, records, identity: createHash('sha256').update(result.stdout).digest('hex') };
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
export function restoreGitIndexPathsCas(before, ownedAfter, repoRoot) {
|
|
867
|
+
const current = captureGitIndexPaths(before.paths, repoRoot);
|
|
868
|
+
if (!before || !ownedAfter || current.identity !== ownedAfter.identity) {
|
|
869
|
+
throw new Error(`Git index changed after transaction staging for: ${before.paths.join(', ')}; current staging was preserved.`);
|
|
870
|
+
}
|
|
871
|
+
restoreGitIndexPaths(before, repoRoot);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
export function restoreGitIndexPaths(snapshot, repoRoot) {
|
|
875
|
+
assertSafeGitPaths(snapshot.paths);
|
|
876
|
+
const firstHash = snapshot.records[0]?.match(/^\d+ ([0-9a-f]+) /)?.[1];
|
|
877
|
+
const removals = snapshot.paths.map(filePath => `0 ${'0'.repeat(firstHash?.length ?? 40)}\t${filePath}\n`);
|
|
878
|
+
const records = snapshot.records.map(record => {
|
|
879
|
+
const match = /^(\d+) ([0-9a-f]+) (\d+)\t([\s\S]+)$/.exec(record);
|
|
880
|
+
if (!match) throw new Error(`Unexpected Git index record: ${record}`);
|
|
881
|
+
return `${match[1]} ${match[2]} ${match[3]}\t${match[4]}\n`;
|
|
882
|
+
});
|
|
883
|
+
const restored = spawnSync('git', ['update-index', '--index-info'], {
|
|
884
|
+
cwd: repoRoot,
|
|
885
|
+
encoding: 'utf8',
|
|
886
|
+
input: [...removals, ...records].join(''),
|
|
887
|
+
});
|
|
888
|
+
if (restored.error || restored.status !== 0) {
|
|
889
|
+
throw new Error(`Could not restore Git index: ${restored.error?.message || restored.stderr.trim() || 'git update-index failed'}`);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
export function gitDiffSince(relPath, sinceDate, repoRoot, opts = {}) {
|
|
894
|
+
ensureGit();
|
|
895
|
+
// Find the last commit at or before sinceDate
|
|
896
|
+
const baseline = spawnSync('git', [
|
|
897
|
+
'log', '-1', '--before=' + sinceDate + 'T23:59:59', '--format=%H', '--', relPath
|
|
898
|
+
], { cwd: repoRoot, encoding: 'utf8' });
|
|
899
|
+
|
|
900
|
+
const baseRef = baseline.stdout.trim();
|
|
901
|
+
if (!baseRef) return null;
|
|
902
|
+
|
|
903
|
+
const diffArgs = ['diff', baseRef, 'HEAD'];
|
|
904
|
+
if (opts.stat) diffArgs.push('--stat');
|
|
905
|
+
diffArgs.push('--', relPath);
|
|
906
|
+
|
|
907
|
+
const result = spawnSync('git', diffArgs, { cwd: repoRoot, encoding: 'utf8' });
|
|
908
|
+
return result.stdout || null;
|
|
909
|
+
}
|