project-tiny-context-harness 0.8.13 → 0.8.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -16
- package/assets/README.md +25 -13
- package/assets/README.zh-CN.md +28 -14
- package/assets/agents/AGENTS_CORE.md +2 -2
- package/assets/agents/long-task-implementation.toml +1 -1
- package/assets/skills/long-task-workflow/SKILL.md +9 -7
- package/assets/skills/long-task-workflow/agents/openai.yaml +1 -1
- package/assets/skills/long-task-workflow/references/authority-lifecycle.md +1 -1
- package/dist/commands/long-task-revision.js +4 -4
- package/dist/lib/long-task-authority-revision-brief.js +3 -1
- package/dist/lib/long-task-authority-revision-summary.js +6 -2
- package/dist/lib/long-task-authority-revision-types.d.ts +2 -0
- package/dist/lib/long-task-design-resource-method-binding.js +8 -2
- package/dist/lib/long-task-git.d.ts +26 -0
- package/dist/lib/long-task-git.js +130 -0
- package/dist/lib/long-task-paths.js +1 -1
- package/dist/lib/long-task-process-runtime-closure.js +74 -42
- package/dist/lib/long-task-verifier-authority.js +2 -0
- package/dist/lib/long-task-workspace-manifest.d.ts +8 -0
- package/dist/lib/long-task-workspace-manifest.js +173 -0
- package/dist/lib/long-task-workspace-snapshot.d.ts +8 -0
- package/dist/lib/long-task-workspace-snapshot.js +176 -0
- package/dist/lib/long-task-workspace.d.ts +4 -25
- package/dist/lib/long-task-workspace.js +3 -432
- package/dist/long-task-hook.js +12 -3
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import path from "node:path";
|
|
|
2
2
|
import { resolveInsideRepository } from "./long-task-workspace.js";
|
|
3
3
|
export function parseRepositoryPattern(value, label = "repository_pattern") {
|
|
4
4
|
const normalized = canonicalRepositoryPath(value, label, "pattern");
|
|
5
|
-
if (/[\[\]{}
|
|
5
|
+
if (/[\[\]{}]/u.test(normalized))
|
|
6
6
|
throw new Error(`unsupported_repository_pattern_syntax:${label}:${value}`);
|
|
7
7
|
const segments = normalized.split("/").map((segment) => {
|
|
8
8
|
if (segment === "**")
|
|
@@ -110,62 +110,94 @@ function selectBinding(selected, scoped) {
|
|
|
110
110
|
function argvAttributedRepositoryFiles(argv, cwd, bindings, targetRef) {
|
|
111
111
|
const result = new Set();
|
|
112
112
|
for (const argument of argv) {
|
|
113
|
-
const
|
|
114
|
-
if (
|
|
113
|
+
const classification = classifyRootArgvToken(argument);
|
|
114
|
+
if (classification.kind === "label")
|
|
115
115
|
continue;
|
|
116
|
-
if (
|
|
117
|
-
fail("process_root_argv_unsafe", `${targetRef}:${argument}`);
|
|
118
|
-
const candidate = path.posix.join(cwd === "." ? "" : cwd, value);
|
|
116
|
+
if (classification.kind === "external_reference")
|
|
117
|
+
fail("process_root_argv_unsafe", `${targetRef}:${argument}:${classification.reason}`);
|
|
118
|
+
const candidate = path.posix.join(cwd === "." ? "" : cwd, classification.value);
|
|
119
119
|
let normalized;
|
|
120
120
|
try {
|
|
121
121
|
normalized = normalizeRepositoryFile(candidate, "process_root_argv");
|
|
122
122
|
}
|
|
123
123
|
catch {
|
|
124
|
-
fail("process_root_argv_unsafe", `${targetRef}:${argument}`);
|
|
124
|
+
fail("process_root_argv_unsafe", `${targetRef}:${argument}:parent_escape`);
|
|
125
125
|
}
|
|
126
126
|
if (bindingsContaining(bindings, normalized).length)
|
|
127
127
|
result.add(normalized);
|
|
128
128
|
}
|
|
129
129
|
return [...result].sort();
|
|
130
130
|
}
|
|
131
|
-
function
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
return
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
131
|
+
function classifyRootArgvToken(argument) {
|
|
132
|
+
if (!argument)
|
|
133
|
+
return { kind: "label" };
|
|
134
|
+
if (hasUnsafeArgvControl(argument))
|
|
135
|
+
return externalArgvReference("unsupported_compound");
|
|
136
|
+
if (argument === "--")
|
|
137
|
+
return { kind: "label" };
|
|
138
|
+
if (argument.startsWith("--")) {
|
|
139
|
+
const assignment = /^--[A-Za-z0-9][A-Za-z0-9_-]*=(.*)$/u.exec(argument);
|
|
140
|
+
if (assignment) {
|
|
141
|
+
const value = assignment[1];
|
|
142
|
+
if (!value)
|
|
143
|
+
return { kind: "label" };
|
|
144
|
+
if (value.includes("="))
|
|
145
|
+
return externalArgvReference("unsupported_compound");
|
|
146
|
+
return classifyRootArgvValue(value);
|
|
147
|
+
}
|
|
148
|
+
if (/^--[A-Za-z0-9][A-Za-z0-9_-]*$/u.test(argument))
|
|
149
|
+
return { kind: "label" };
|
|
150
|
+
return externalArgvReference("unsupported_compound");
|
|
151
|
+
}
|
|
152
|
+
if (/^-[A-Za-z0-9?]$/u.test(argument))
|
|
153
|
+
return { kind: "label" };
|
|
154
|
+
if (/^-\d+(?:\.\d+)?$/u.test(argument))
|
|
155
|
+
return { kind: "label" };
|
|
156
|
+
if (argument.startsWith("-") || argument.startsWith("@"))
|
|
157
|
+
return externalArgvReference("unsupported_compound");
|
|
158
|
+
if (argument.includes("="))
|
|
159
|
+
return externalArgvReference("unsupported_compound");
|
|
160
|
+
return classifyRootArgvValue(argument);
|
|
161
|
+
}
|
|
162
|
+
function classifyRootArgvValue(value) {
|
|
163
|
+
if (!value)
|
|
164
|
+
return { kind: "label" };
|
|
165
|
+
if (hasUnsafeArgvControl(value))
|
|
166
|
+
return externalArgvReference("unsupported_compound");
|
|
167
|
+
if (/["']/u.test(value))
|
|
168
|
+
return externalArgvReference("quote_ambiguous");
|
|
169
|
+
if (value.includes("\\"))
|
|
170
|
+
return externalArgvReference("platform_ambiguous");
|
|
171
|
+
if (/^\/[A-Za-z?]$/u.test(value))
|
|
172
|
+
return externalArgvReference("platform_ambiguous");
|
|
173
|
+
if (value.startsWith("/"))
|
|
174
|
+
return externalArgvReference("absolute");
|
|
175
|
+
if (/^[A-Za-z]:/u.test(value))
|
|
176
|
+
return externalArgvReference("drive_prefixed");
|
|
177
|
+
if (value.startsWith("@"))
|
|
178
|
+
return externalArgvReference("unsupported_compound");
|
|
179
|
+
if (hasUnsupportedArgvCompound(value))
|
|
180
|
+
return externalArgvReference("unsupported_compound");
|
|
181
|
+
if (/^node:\d+(?:\.\d+)?$/u.test(value))
|
|
182
|
+
return { kind: "label" };
|
|
183
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:/u.test(value))
|
|
184
|
+
return externalArgvReference("protocol_prefixed");
|
|
185
|
+
if (value.includes(":"))
|
|
186
|
+
return /^\d{1,4}:\d{1,4}$/u.test(value)
|
|
187
|
+
? { kind: "label" }
|
|
188
|
+
: externalArgvReference("unsupported_compound");
|
|
189
|
+
return { kind: "repository_candidate", value };
|
|
190
|
+
}
|
|
191
|
+
function hasUnsafeArgvControl(value) {
|
|
192
|
+
return /[\u0000-\u001f\u007f]/u.test(value);
|
|
156
193
|
}
|
|
157
|
-
function
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return { value, quoted: false };
|
|
194
|
+
function hasUnsupportedArgvCompound(value) {
|
|
195
|
+
return (value.trim() !== value ||
|
|
196
|
+
value.startsWith("~") ||
|
|
197
|
+
!/^[\p{L}\p{N}._+,: /-]+$/u.test(value));
|
|
162
198
|
}
|
|
163
|
-
function
|
|
164
|
-
return
|
|
165
|
-
value.startsWith("/") ||
|
|
166
|
-
/^[A-Za-z]:\//u.test(value) ||
|
|
167
|
-
/^file:/iu.test(value) ||
|
|
168
|
-
/^[A-Za-z][A-Za-z0-9+.-]*:\/\//u.test(value));
|
|
199
|
+
function externalArgvReference(reason) {
|
|
200
|
+
return { kind: "external_reference", reason };
|
|
169
201
|
}
|
|
170
202
|
function firstMatchingPattern(file, patterns) {
|
|
171
203
|
return patterns.find((pattern) => matchesRepoPattern(file, pattern)) ?? null;
|
|
@@ -37,6 +37,8 @@ function changedVerifierFiles(previous, next) {
|
|
|
37
37
|
...Object.keys(next.bundle_files),
|
|
38
38
|
]);
|
|
39
39
|
const changed = [...files].filter((file) => previous.bundle_files[file] !== next.bundle_files[file]);
|
|
40
|
+
if (previous.bundle_sha256 !== next.bundle_sha256)
|
|
41
|
+
changed.push("<bundle>");
|
|
40
42
|
if (previous.schema_sha256 !== next.schema_sha256)
|
|
41
43
|
changed.push("<schema>");
|
|
42
44
|
if (previous.hook_sha256 !== next.hook_sha256)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { WorkspaceFingerprintV2, WorkspaceManifestV2 } from "./long-task-delivery-types.js";
|
|
2
|
+
export declare function captureWorkspaceFingerprint(rootInput: string, excludedPrefixes?: string[]): Promise<WorkspaceFingerprintV2>;
|
|
3
|
+
export declare function captureWorkspaceManifest(rootInput: string, workdirInput: string, _copyRoot?: string, additionalExcludedWorkdirs?: string[]): Promise<WorkspaceManifestV2>;
|
|
4
|
+
export declare function changedWorkspacePaths(baseline: WorkspaceManifestV2, current: WorkspaceManifestV2): string[];
|
|
5
|
+
export declare function changedWorkspacePathsFromHead(rootInput: string, workdirInput: string, additionalExcludedWorkdirs?: string[]): Promise<string[]>;
|
|
6
|
+
export declare function workspaceFingerprintExcludedPrefixes(root: string, workdirs: string[]): string[];
|
|
7
|
+
export declare function workspaceSnapshotExcludedPrefixes(root: string, workdirs: string[]): string[];
|
|
8
|
+
export declare function workspacePathExcluded(relative: string, excluded: string[]): boolean;
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { gitBuffer, gitBufferInput, gitOutput, repoRelative, splitGitZero, } from "./long-task-git.js";
|
|
4
|
+
import { canonicalValueJson, sha256Hex } from "./strict-codec.js";
|
|
5
|
+
export async function captureWorkspaceFingerprint(rootInput, excludedPrefixes = []) {
|
|
6
|
+
const root = path.resolve(rootInput);
|
|
7
|
+
const indexTree = await gitOutput(root, ["write-tree"]);
|
|
8
|
+
const [head, headTree, staged, unstaged, statusBytes, untracked] = await Promise.all([
|
|
9
|
+
gitOutput(root, ["rev-parse", "HEAD"]),
|
|
10
|
+
gitOutput(root, ["rev-parse", "HEAD^{tree}"]),
|
|
11
|
+
gitBuffer(root, scopedDiffArgs(["diff", "--cached", "--binary", "--no-ext-diff"], excludedPrefixes)),
|
|
12
|
+
gitBuffer(root, scopedDiffArgs(["diff", "--binary", "--no-ext-diff"], excludedPrefixes)),
|
|
13
|
+
Promise.all([
|
|
14
|
+
gitBuffer(root, scopedDiffArgs(["diff", "--cached", "--raw", "-z", "-M"], excludedPrefixes)),
|
|
15
|
+
gitBuffer(root, scopedDiffArgs(["diff", "--raw", "-z", "-M"], excludedPrefixes)),
|
|
16
|
+
]).then((rows) => Buffer.concat(rows)),
|
|
17
|
+
untrackedIdentity(root, excludedPrefixes),
|
|
18
|
+
]);
|
|
19
|
+
const unsigned = {
|
|
20
|
+
head,
|
|
21
|
+
head_tree: headTree,
|
|
22
|
+
index_tree: indexTree,
|
|
23
|
+
staged_diff_sha256: sha256Hex(staged),
|
|
24
|
+
unstaged_diff_sha256: sha256Hex(unstaged),
|
|
25
|
+
untracked_sha256: untracked,
|
|
26
|
+
status_sha256: sha256Hex(statusBytes),
|
|
27
|
+
};
|
|
28
|
+
return {
|
|
29
|
+
...unsigned,
|
|
30
|
+
identity: sha256Hex(canonicalValueJson(unsigned)),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
export async function captureWorkspaceManifest(rootInput, workdirInput, _copyRoot, additionalExcludedWorkdirs = []) {
|
|
34
|
+
const root = path.resolve(rootInput);
|
|
35
|
+
const workdir = path.resolve(workdirInput);
|
|
36
|
+
const workdirRelative = repoRelative(root, workdir);
|
|
37
|
+
if (!workdirRelative)
|
|
38
|
+
throw new Error("long_task_workdir_must_not_be_repository_root");
|
|
39
|
+
const excluded = workspaceFingerprintExcludedPrefixes(root, [
|
|
40
|
+
workdir,
|
|
41
|
+
...additionalExcludedWorkdirs,
|
|
42
|
+
]);
|
|
43
|
+
const fingerprint = await captureWorkspaceFingerprint(root, excluded);
|
|
44
|
+
const [indexBytes, modifiedBytes, untrackedBytes] = await Promise.all([
|
|
45
|
+
gitBuffer(root, ["ls-files", "--stage", "-z"]),
|
|
46
|
+
gitBuffer(root, ["diff", "--name-only", "-z"]),
|
|
47
|
+
gitBuffer(root, ["ls-files", "--others", "--exclude-standard", "-z"]),
|
|
48
|
+
]);
|
|
49
|
+
const files = new Map();
|
|
50
|
+
for (const record of splitGitZero(indexBytes)) {
|
|
51
|
+
const tab = record.indexOf("\t");
|
|
52
|
+
if (tab < 0)
|
|
53
|
+
continue;
|
|
54
|
+
const [modeText, objectId, stage] = record.slice(0, tab).split(" ");
|
|
55
|
+
const relative = record.slice(tab + 1).replace(/\\/gu, "/");
|
|
56
|
+
if (stage !== "0" || workspacePathExcluded(relative, excluded))
|
|
57
|
+
continue;
|
|
58
|
+
files.set(relative, {
|
|
59
|
+
path: relative,
|
|
60
|
+
mode: Number.parseInt(modeText, 8),
|
|
61
|
+
size: 0,
|
|
62
|
+
sha256: `git:${objectId}`,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
const overlays = new Set([
|
|
66
|
+
...splitGitZero(modifiedBytes),
|
|
67
|
+
...splitGitZero(untrackedBytes),
|
|
68
|
+
]);
|
|
69
|
+
const overlayNames = [...overlays]
|
|
70
|
+
.map((raw) => raw.replace(/\\/gu, "/"))
|
|
71
|
+
.filter((relative) => !workspacePathExcluded(relative, excluded));
|
|
72
|
+
const overlayInfo = new Map(await Promise.all(overlayNames.map(async (relative) => [
|
|
73
|
+
relative,
|
|
74
|
+
await stat(path.join(root, ...relative.split("/")), {
|
|
75
|
+
bigint: true,
|
|
76
|
+
}).catch(() => null),
|
|
77
|
+
])));
|
|
78
|
+
const overlayHashes = await gitObjectIds(root, overlayNames.filter((relative) => overlayInfo.get(relative)?.isFile()));
|
|
79
|
+
for (const relative of overlayNames) {
|
|
80
|
+
const absolute = path.join(root, ...relative.split("/"));
|
|
81
|
+
const info = overlayInfo.get(relative);
|
|
82
|
+
if (!info?.isFile()) {
|
|
83
|
+
files.delete(relative);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const bytes = await readFile(absolute);
|
|
87
|
+
files.set(relative, {
|
|
88
|
+
path: relative,
|
|
89
|
+
mode: gitFileMode(Number(info.mode)),
|
|
90
|
+
size: bytes.length,
|
|
91
|
+
sha256: `git:${overlayHashes.get(relative)}`,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
repository_root: root,
|
|
96
|
+
git_head: fingerprint.head,
|
|
97
|
+
files: [...files.values()].sort((a, b) => a.path.localeCompare(b.path)),
|
|
98
|
+
fingerprint,
|
|
99
|
+
snapshot_sha256: fingerprint.identity,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export function changedWorkspacePaths(baseline, current) {
|
|
103
|
+
const before = new Map(baseline.files.map((file) => [file.path, `${file.mode}:${file.sha256}`]));
|
|
104
|
+
const after = new Map(current.files.map((file) => [file.path, `${file.mode}:${file.sha256}`]));
|
|
105
|
+
return [...new Set([...before.keys(), ...after.keys()])]
|
|
106
|
+
.filter((file) => before.get(file) !== after.get(file))
|
|
107
|
+
.sort();
|
|
108
|
+
}
|
|
109
|
+
export async function changedWorkspacePathsFromHead(rootInput, workdirInput, additionalExcludedWorkdirs = []) {
|
|
110
|
+
const root = path.resolve(rootInput);
|
|
111
|
+
const workdir = path.resolve(workdirInput);
|
|
112
|
+
const excluded = workspaceFingerprintExcludedPrefixes(root, [
|
|
113
|
+
workdir,
|
|
114
|
+
...additionalExcludedWorkdirs,
|
|
115
|
+
]);
|
|
116
|
+
const [trackedBytes, untrackedBytes] = await Promise.all([
|
|
117
|
+
gitBuffer(root, scopedDiffArgs(["diff", "--name-only", "--no-renames", "--no-ext-diff", "-z", "HEAD"], excluded)),
|
|
118
|
+
gitBuffer(root, ["ls-files", "--others", "--exclude-standard", "-z"]),
|
|
119
|
+
]);
|
|
120
|
+
return [
|
|
121
|
+
...new Set([...splitGitZero(trackedBytes), ...splitGitZero(untrackedBytes)]
|
|
122
|
+
.map((raw) => raw.replace(/\\/gu, "/"))
|
|
123
|
+
.filter((relative) => !workspacePathExcluded(relative, excluded))),
|
|
124
|
+
].sort();
|
|
125
|
+
}
|
|
126
|
+
export function workspaceFingerprintExcludedPrefixes(root, workdirs) {
|
|
127
|
+
return [
|
|
128
|
+
...workspaceSnapshotExcludedPrefixes(root, workdirs),
|
|
129
|
+
"project_context",
|
|
130
|
+
].filter(Boolean);
|
|
131
|
+
}
|
|
132
|
+
export function workspaceSnapshotExcludedPrefixes(root, workdirs) {
|
|
133
|
+
return [
|
|
134
|
+
...workdirs.map((workdir) => repoRelative(root, path.resolve(workdir))),
|
|
135
|
+
"tmp/ty-context/long-task-runs",
|
|
136
|
+
].filter(Boolean);
|
|
137
|
+
}
|
|
138
|
+
export function workspacePathExcluded(relative, excluded) {
|
|
139
|
+
const normalized = relative.replace(/\\/gu, "/");
|
|
140
|
+
return (normalized.split("/").includes("node_modules") ||
|
|
141
|
+
excluded.some((prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`)));
|
|
142
|
+
}
|
|
143
|
+
async function untrackedIdentity(root, excluded) {
|
|
144
|
+
const names = splitGitZero(await gitBuffer(root, ["ls-files", "--others", "--exclude-standard", "-z"]))
|
|
145
|
+
.map((name) => name.replace(/\\/gu, "/"))
|
|
146
|
+
.filter((name) => !workspacePathExcluded(name, excluded))
|
|
147
|
+
.sort();
|
|
148
|
+
const hashes = await gitObjectIds(root, names);
|
|
149
|
+
const rows = names.map((name) => [
|
|
150
|
+
name,
|
|
151
|
+
hashes.get(name) ?? "missing",
|
|
152
|
+
]);
|
|
153
|
+
return sha256Hex(canonicalValueJson(rows));
|
|
154
|
+
}
|
|
155
|
+
async function gitObjectIds(root, names) {
|
|
156
|
+
if (!names.length)
|
|
157
|
+
return new Map();
|
|
158
|
+
const output = await gitBufferInput(root, ["hash-object", "--stdin-paths"], Buffer.from(`${names.join("\n")}\n`, "utf8"));
|
|
159
|
+
const ids = output.toString("utf8").trim().split(/\r?\n/u);
|
|
160
|
+
return new Map(names.map((name, index) => [name, ids[index]]));
|
|
161
|
+
}
|
|
162
|
+
function scopedDiffArgs(base, excluded) {
|
|
163
|
+
return [
|
|
164
|
+
...base,
|
|
165
|
+
"--",
|
|
166
|
+
".",
|
|
167
|
+
...excluded.map((prefix) => `:(exclude)${prefix}/**`),
|
|
168
|
+
":(exclude)**/node_modules/**",
|
|
169
|
+
];
|
|
170
|
+
}
|
|
171
|
+
function gitFileMode(mode) {
|
|
172
|
+
return mode & 0o111 ? 0o100755 : 0o100644;
|
|
173
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { WorkspaceManifestV2 } from "./long-task-delivery-types.js";
|
|
2
|
+
export interface WorkspaceSnapshotV2 {
|
|
3
|
+
root: string;
|
|
4
|
+
manifest: WorkspaceManifestV2;
|
|
5
|
+
preparation_ms: number;
|
|
6
|
+
dispose(): Promise<void>;
|
|
7
|
+
}
|
|
8
|
+
export declare function createWorkspaceSnapshot(rootInput: string, workdirInput: string, label: string, additionalExcludedWorkdirs?: string[]): Promise<WorkspaceSnapshotV2>;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { copyFile, mkdir, mkdtemp, readdir, rm, stat, symlink, } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { GitCommandError, gitBuffer, gitEffectiveConfigGet, gitVoid, repoRelative, splitGitZero, } from "./long-task-git.js";
|
|
5
|
+
import { captureWorkspaceFingerprint, captureWorkspaceManifest, workspaceFingerprintExcludedPrefixes, workspacePathExcluded, workspaceSnapshotExcludedPrefixes, } from "./long-task-workspace-manifest.js";
|
|
6
|
+
const MAX_CHECKOUT_INDEX_ATTEMPTS = 2;
|
|
7
|
+
export async function createWorkspaceSnapshot(rootInput, workdirInput, label, additionalExcludedWorkdirs = []) {
|
|
8
|
+
const started = performance.now();
|
|
9
|
+
const root = path.resolve(rootInput);
|
|
10
|
+
const workdir = path.resolve(workdirInput);
|
|
11
|
+
const fingerprintExcluded = workspaceFingerprintExcludedPrefixes(root, [
|
|
12
|
+
workdir,
|
|
13
|
+
...additionalExcludedWorkdirs,
|
|
14
|
+
]);
|
|
15
|
+
const snapshotExcluded = workspaceSnapshotExcludedPrefixes(root, [
|
|
16
|
+
workdir,
|
|
17
|
+
...additionalExcludedWorkdirs,
|
|
18
|
+
]);
|
|
19
|
+
const manifest = await captureWorkspaceManifest(root, workdir, undefined, additionalExcludedWorkdirs);
|
|
20
|
+
const before = manifest.fingerprint;
|
|
21
|
+
const temporary = await checkoutIndexIntoFreshRoot(root, label, before.identity, fingerprintExcluded);
|
|
22
|
+
try {
|
|
23
|
+
await overlayTrackedEolDifferences(root, temporary);
|
|
24
|
+
const [modified, untracked] = await Promise.all([
|
|
25
|
+
gitBuffer(root, ["diff", "--name-only", "-z"]),
|
|
26
|
+
gitBuffer(root, ["ls-files", "--others", "--exclude-standard", "-z"]),
|
|
27
|
+
]);
|
|
28
|
+
for (const raw of new Set([
|
|
29
|
+
...splitGitZero(modified),
|
|
30
|
+
...splitGitZero(untracked),
|
|
31
|
+
])) {
|
|
32
|
+
const relative = raw.replace(/\\/gu, "/");
|
|
33
|
+
if (workspacePathExcluded(relative, snapshotExcluded))
|
|
34
|
+
continue;
|
|
35
|
+
const source = path.join(root, ...relative.split("/"));
|
|
36
|
+
const target = path.join(temporary, ...relative.split("/"));
|
|
37
|
+
const info = await stat(source).catch(() => null);
|
|
38
|
+
if (!info?.isFile()) {
|
|
39
|
+
await rm(target, { recursive: true, force: true });
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
43
|
+
await copyFile(source, target);
|
|
44
|
+
}
|
|
45
|
+
await removeExcludedSnapshotPaths(temporary, snapshotExcluded);
|
|
46
|
+
await linkDependencyTrees(root, temporary, [
|
|
47
|
+
workdir,
|
|
48
|
+
...additionalExcludedWorkdirs,
|
|
49
|
+
]);
|
|
50
|
+
const after = await captureWorkspaceFingerprint(root, fingerprintExcluded);
|
|
51
|
+
if (after.identity !== before.identity)
|
|
52
|
+
throw new Error("workspace_changed_during_snapshot");
|
|
53
|
+
return {
|
|
54
|
+
root: temporary,
|
|
55
|
+
manifest,
|
|
56
|
+
preparation_ms: performance.now() - started,
|
|
57
|
+
dispose: () => rm(temporary, { recursive: true, force: true }),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
await rm(temporary, { recursive: true, force: true });
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
async function checkoutIndexIntoFreshRoot(root, label, expectedFingerprint, fingerprintExcluded) {
|
|
66
|
+
for (let attempt = 1; attempt <= MAX_CHECKOUT_INDEX_ATTEMPTS; attempt += 1) {
|
|
67
|
+
const temporary = await mkdtemp(path.join(os.tmpdir(), `ty-context-${safe(label)}-`));
|
|
68
|
+
try {
|
|
69
|
+
await gitVoid(root, [
|
|
70
|
+
"checkout-index",
|
|
71
|
+
"--all",
|
|
72
|
+
"--force",
|
|
73
|
+
"--ignore-skip-worktree-bits",
|
|
74
|
+
`--prefix=${temporary.replace(/\\/gu, "/")}/`,
|
|
75
|
+
]);
|
|
76
|
+
return temporary;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
await rm(temporary, { recursive: true, force: true });
|
|
80
|
+
if (!retryableUnclassifiedCheckout(error) ||
|
|
81
|
+
attempt === MAX_CHECKOUT_INDEX_ATTEMPTS)
|
|
82
|
+
throw error;
|
|
83
|
+
const current = await captureWorkspaceFingerprint(root, fingerprintExcluded);
|
|
84
|
+
if (current.identity !== expectedFingerprint)
|
|
85
|
+
throw new Error("workspace_changed_during_snapshot");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
throw new Error("workspace_snapshot_checkout_attempts_exhausted");
|
|
89
|
+
}
|
|
90
|
+
function retryableUnclassifiedCheckout(error) {
|
|
91
|
+
return (error instanceof GitCommandError &&
|
|
92
|
+
error.exitCode === 1 &&
|
|
93
|
+
error.signal === null &&
|
|
94
|
+
error.stdoutBytes === 0 &&
|
|
95
|
+
error.stderrBytes === 0);
|
|
96
|
+
}
|
|
97
|
+
async function removeExcludedSnapshotPaths(snapshotRoot, excluded) {
|
|
98
|
+
for (const relative of excluded)
|
|
99
|
+
await rm(path.join(snapshotRoot, ...relative.split("/")), {
|
|
100
|
+
recursive: true,
|
|
101
|
+
force: true,
|
|
102
|
+
});
|
|
103
|
+
async function visit(directory) {
|
|
104
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
105
|
+
const target = path.join(directory, entry.name);
|
|
106
|
+
if (entry.isDirectory() && entry.name === "node_modules") {
|
|
107
|
+
await rm(target, { recursive: true, force: true });
|
|
108
|
+
}
|
|
109
|
+
else if (entry.isDirectory())
|
|
110
|
+
await visit(target);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
await visit(snapshotRoot);
|
|
114
|
+
}
|
|
115
|
+
async function overlayTrackedEolDifferences(sourceRoot, snapshotRoot) {
|
|
116
|
+
const [raw, autocrlf] = await Promise.all([
|
|
117
|
+
gitBuffer(sourceRoot, ["ls-files", "--eol", "-z"]),
|
|
118
|
+
gitEffectiveConfigGet(sourceRoot, "core.autocrlf"),
|
|
119
|
+
]);
|
|
120
|
+
for (const record of splitGitZero(raw)) {
|
|
121
|
+
const tab = record.indexOf("\t");
|
|
122
|
+
if (tab < 0)
|
|
123
|
+
continue;
|
|
124
|
+
const metadata = record.slice(0, tab);
|
|
125
|
+
const relative = record.slice(tab + 1).replace(/\\/gu, "/");
|
|
126
|
+
const match = metadata.match(/^i\/(\S+)\s+w\/(\S+)\s+attr\/(.*)$/u);
|
|
127
|
+
if (!match)
|
|
128
|
+
continue;
|
|
129
|
+
const [, indexEol, worktreeEol, attributesRaw] = match;
|
|
130
|
+
const expected = checkoutEol(indexEol, attributesRaw.trim(), autocrlf);
|
|
131
|
+
if (worktreeEol === expected)
|
|
132
|
+
continue;
|
|
133
|
+
const source = path.join(sourceRoot, ...relative.split("/"));
|
|
134
|
+
const target = path.join(snapshotRoot, ...relative.split("/"));
|
|
135
|
+
const info = await stat(source).catch(() => null);
|
|
136
|
+
if (!info?.isFile())
|
|
137
|
+
continue;
|
|
138
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
139
|
+
await copyFile(source, target);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function checkoutEol(indexEol, attributes, autocrlf) {
|
|
143
|
+
const explicit = attributes.match(/(?:^|\s)eol=(lf|crlf)(?:\s|$)/u)?.[1];
|
|
144
|
+
if (explicit)
|
|
145
|
+
return explicit;
|
|
146
|
+
if (/(?:^|\s)-text(?:\s|$)/u.test(attributes))
|
|
147
|
+
return indexEol;
|
|
148
|
+
if (indexEol === "-text" || indexEol === "none")
|
|
149
|
+
return indexEol;
|
|
150
|
+
return autocrlf?.toLowerCase() === "true" ? "crlf" : indexEol;
|
|
151
|
+
}
|
|
152
|
+
async function linkDependencyTrees(sourceRoot, snapshotRoot, workdirs) {
|
|
153
|
+
const protectedWorkdirs = workdirs.map((workdir) => repoRelative(sourceRoot, workdir));
|
|
154
|
+
async function visit(directory, relative = "") {
|
|
155
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
156
|
+
const next = relative ? `${relative}/${entry.name}` : entry.name;
|
|
157
|
+
if (protectedWorkdirs.some((protectedWorkdir) => next === protectedWorkdir ||
|
|
158
|
+
next.startsWith(`${protectedWorkdir}/`)) ||
|
|
159
|
+
entry.name === ".git")
|
|
160
|
+
continue;
|
|
161
|
+
const source = path.join(directory, entry.name);
|
|
162
|
+
if (entry.isDirectory() && entry.name === "node_modules") {
|
|
163
|
+
const target = path.join(snapshotRoot, ...next.split("/"));
|
|
164
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
165
|
+
await rm(target, { recursive: true, force: true });
|
|
166
|
+
await symlink(source, target, process.platform === "win32" ? "junction" : "dir");
|
|
167
|
+
}
|
|
168
|
+
else if (entry.isDirectory())
|
|
169
|
+
await visit(source, next);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
await visit(sourceRoot);
|
|
173
|
+
}
|
|
174
|
+
function safe(value) {
|
|
175
|
+
return value.replace(/[^A-Za-z0-9._-]/gu, "-").slice(0, 80);
|
|
176
|
+
}
|
|
@@ -1,26 +1,5 @@
|
|
|
1
1
|
export { resolveInsideRepository } from "./repository-path-safety.js";
|
|
2
|
-
|
|
3
|
-
export
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
preparation_ms: number;
|
|
7
|
-
dispose(): Promise<void>;
|
|
8
|
-
}
|
|
9
|
-
export declare function repositoryRoot(start: string): Promise<string>;
|
|
10
|
-
export declare function gitCommonDir(root: string): Promise<string>;
|
|
11
|
-
export declare function captureWorkspaceFingerprint(rootInput: string, excludedPrefixes?: string[]): Promise<WorkspaceFingerprintV2>;
|
|
12
|
-
export declare function captureWorkspaceManifest(rootInput: string, workdirInput: string, _copyRoot?: string, additionalExcludedWorkdirs?: string[]): Promise<WorkspaceManifestV2>;
|
|
13
|
-
export declare function createWorkspaceSnapshot(rootInput: string, workdirInput: string, label: string, additionalExcludedWorkdirs?: string[]): Promise<WorkspaceSnapshotV2>;
|
|
14
|
-
export declare function changedWorkspacePaths(baseline: WorkspaceManifestV2, current: WorkspaceManifestV2): string[];
|
|
15
|
-
export declare function changedWorkspacePathsFromHead(rootInput: string, workdirInput: string, additionalExcludedWorkdirs?: string[]): Promise<string[]>;
|
|
16
|
-
export declare function currentGitState(root: string): Promise<{
|
|
17
|
-
head: string;
|
|
18
|
-
tree: string;
|
|
19
|
-
dirty: string[];
|
|
20
|
-
}>;
|
|
21
|
-
export declare function currentGitTree(root: string): Promise<string>;
|
|
22
|
-
export declare function gitPath(root: string, pathSpec: string): Promise<string>;
|
|
23
|
-
export declare function gitConfigGet(root: string, name: string): Promise<string | null>;
|
|
24
|
-
export declare function gitConfigSet(root: string, name: string, value: string): Promise<void>;
|
|
25
|
-
export declare function gitConfigUnset(root: string, name: string): Promise<void>;
|
|
26
|
-
export declare function repoRelative(rootInput: string, fileInput: string): string;
|
|
2
|
+
export { currentGitState, currentGitTree, gitCommonDir, gitConfigGet, gitConfigSet, gitConfigUnset, gitPath, repoRelative, repositoryRoot, } from "./long-task-git.js";
|
|
3
|
+
export { captureWorkspaceFingerprint, captureWorkspaceManifest, changedWorkspacePaths, changedWorkspacePathsFromHead, } from "./long-task-workspace-manifest.js";
|
|
4
|
+
export { createWorkspaceSnapshot } from "./long-task-workspace-snapshot.js";
|
|
5
|
+
export type { WorkspaceSnapshotV2 } from "./long-task-workspace-snapshot.js";
|