@qilin-agent/workspace-changes 0.0.0-stage → 3.1.3
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.i18n.yaml +37 -0
- package/README.md +105 -2
- package/README.zh.md +106 -0
- package/lib/index.js +1155 -0
- package/lib/types/capture.d.ts +47 -0
- package/lib/types/capture.js +107 -0
- package/lib/types/compare.d.ts +19 -0
- package/lib/types/compare.js +56 -0
- package/lib/types/git.d.ts +140 -0
- package/lib/types/git.js +224 -0
- package/lib/types/index.d.ts +34 -0
- package/lib/types/index.js +146 -0
- package/lib/types/numstat.d.ts +19 -0
- package/lib/types/numstat.js +45 -0
- package/lib/types/paths.d.ts +65 -0
- package/lib/types/paths.js +106 -0
- package/lib/types/recorder.d.ts +126 -0
- package/lib/types/recorder.js +370 -0
- package/lib/types/types.d.ts +163 -0
- package/lib/types/types.js +2 -0
- package/package.json +56 -3
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** One captured side of a path. */
|
|
2
|
+
export type Capture =
|
|
3
|
+
/** No file at the path. */
|
|
4
|
+
{
|
|
5
|
+
kind: 'absent';
|
|
6
|
+
}
|
|
7
|
+
/** A regular file larger than the configured cap; its content is not stored. */
|
|
8
|
+
| {
|
|
9
|
+
kind: 'oversized';
|
|
10
|
+
}
|
|
11
|
+
/** A stored copy, named by the SHA-1 of its bytes. */
|
|
12
|
+
| {
|
|
13
|
+
kind: 'file';
|
|
14
|
+
file: string;
|
|
15
|
+
binary: boolean;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* Store the current content of one path. At most `maxBytes + 1` bytes are
|
|
19
|
+
* read, so a file that grows past the cap while it is read costs no more
|
|
20
|
+
* memory than the cap.
|
|
21
|
+
* @param absolute - canonical absolute path of the file.
|
|
22
|
+
* @param directory - directory holding content-addressed copies; created when missing.
|
|
23
|
+
* @param maxBytes - inclusive byte cap on a stored copy.
|
|
24
|
+
* @returns the capture, or undefined for a path that is neither absent nor a regular file.
|
|
25
|
+
* @throws when the path cannot be opened or read for a reason other than absence, or the copy cannot be written.
|
|
26
|
+
*/
|
|
27
|
+
export declare function captureFile(absolute: string, directory: string, maxBytes: number): Promise<Capture | undefined>;
|
|
28
|
+
/**
|
|
29
|
+
* Whether two captures are known to hold the same content. Two absent sides
|
|
30
|
+
* are the same; two stored copies are the same when their bytes hash alike;
|
|
31
|
+
* an oversized side is never known to match anything, since its content was
|
|
32
|
+
* not read.
|
|
33
|
+
* @param a - one side.
|
|
34
|
+
* @param b - the other side.
|
|
35
|
+
* @returns true only when both sides are known to match.
|
|
36
|
+
*/
|
|
37
|
+
export declare function sameCapture(a: Capture, b: Capture): boolean;
|
|
38
|
+
/**
|
|
39
|
+
* The path a first-party file-tool call is about to mutate: `write`, `edit`,
|
|
40
|
+
* and the mutating `str_replace_editor` commands. Other tools, reads, and
|
|
41
|
+
* incomplete arguments yield undefined.
|
|
42
|
+
* @param name - wire tool name.
|
|
43
|
+
* @param args - parsed call arguments.
|
|
44
|
+
* @returns the model-facing path, or undefined.
|
|
45
|
+
*/
|
|
46
|
+
export declare function mutationPath(name: string, args: unknown): string | undefined;
|
|
47
|
+
//# sourceMappingURL=capture.d.ts.map
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whole-file captures around file-tool edits: the content of a path before
|
|
3
|
+
* the turn's first mutation of it and at turn end, stored content-addressed
|
|
4
|
+
* under the Session's temporary directory so both sides of a comparison
|
|
5
|
+
* survive later edits without depending on git.
|
|
6
|
+
*/
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { mkdir, open, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
/** Bytes git inspects for a NUL byte before treating content as binary. */
|
|
11
|
+
const BINARY_PROBE_BYTES = 8000;
|
|
12
|
+
/** Whether a filesystem error names a missing path. */
|
|
13
|
+
function isMissing(error) {
|
|
14
|
+
return typeof error === 'object' && error !== null && error.code === 'ENOENT';
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Store the current content of one path. At most `maxBytes + 1` bytes are
|
|
18
|
+
* read, so a file that grows past the cap while it is read costs no more
|
|
19
|
+
* memory than the cap.
|
|
20
|
+
* @param absolute - canonical absolute path of the file.
|
|
21
|
+
* @param directory - directory holding content-addressed copies; created when missing.
|
|
22
|
+
* @param maxBytes - inclusive byte cap on a stored copy.
|
|
23
|
+
* @returns the capture, or undefined for a path that is neither absent nor a regular file.
|
|
24
|
+
* @throws when the path cannot be opened or read for a reason other than absence, or the copy cannot be written.
|
|
25
|
+
*/
|
|
26
|
+
export async function captureFile(absolute, directory, maxBytes) {
|
|
27
|
+
let handle;
|
|
28
|
+
try {
|
|
29
|
+
handle = await open(absolute, 'r');
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
/* v8 ignore next -- an unopenable present file needs permissions the tests cannot revoke on every host. */
|
|
33
|
+
if (!isMissing(error))
|
|
34
|
+
throw error;
|
|
35
|
+
return { kind: 'absent' };
|
|
36
|
+
}
|
|
37
|
+
let bytes;
|
|
38
|
+
try {
|
|
39
|
+
if (!(await handle.stat()).isFile())
|
|
40
|
+
return undefined;
|
|
41
|
+
const probe = Buffer.allocUnsafe(maxBytes + 1);
|
|
42
|
+
let length = 0;
|
|
43
|
+
while (length < probe.length) {
|
|
44
|
+
const { bytesRead } = await handle.read(probe, length, probe.length - length, length);
|
|
45
|
+
if (bytesRead === 0)
|
|
46
|
+
break;
|
|
47
|
+
length += bytesRead;
|
|
48
|
+
}
|
|
49
|
+
if (length > maxBytes)
|
|
50
|
+
return { kind: 'oversized' };
|
|
51
|
+
bytes = probe.subarray(0, length);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
await handle.close();
|
|
55
|
+
}
|
|
56
|
+
const file = join(directory, createHash('sha1').update(bytes).digest('hex'));
|
|
57
|
+
await mkdir(directory, { recursive: true });
|
|
58
|
+
// Identical content across paths and turns shares one copy; `wx` keeps an existing copy as is.
|
|
59
|
+
await writeFile(file, bytes, { flag: 'wx' }).catch((error) => {
|
|
60
|
+
/* v8 ignore next -- a copy that cannot be written needs a directory the tests cannot make unwritable on every host. */
|
|
61
|
+
if (error.code !== 'EEXIST')
|
|
62
|
+
throw error;
|
|
63
|
+
});
|
|
64
|
+
return { kind: 'file', file, binary: bytes.subarray(0, BINARY_PROBE_BYTES).includes(0) };
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Whether two captures are known to hold the same content. Two absent sides
|
|
68
|
+
* are the same; two stored copies are the same when their bytes hash alike;
|
|
69
|
+
* an oversized side is never known to match anything, since its content was
|
|
70
|
+
* not read.
|
|
71
|
+
* @param a - one side.
|
|
72
|
+
* @param b - the other side.
|
|
73
|
+
* @returns true only when both sides are known to match.
|
|
74
|
+
*/
|
|
75
|
+
export function sameCapture(a, b) {
|
|
76
|
+
if (a.kind === 'absent' || b.kind === 'absent')
|
|
77
|
+
return a.kind === b.kind;
|
|
78
|
+
return a.kind === 'file' && b.kind === 'file' && a.file === b.file;
|
|
79
|
+
}
|
|
80
|
+
/** A non-blank string, or undefined. */
|
|
81
|
+
function text(value) {
|
|
82
|
+
return typeof value === 'string' && value.trim() !== '' ? value : undefined;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* The path a first-party file-tool call is about to mutate: `write`, `edit`,
|
|
86
|
+
* and the mutating `str_replace_editor` commands. Other tools, reads, and
|
|
87
|
+
* incomplete arguments yield undefined.
|
|
88
|
+
* @param name - wire tool name.
|
|
89
|
+
* @param args - parsed call arguments.
|
|
90
|
+
* @returns the model-facing path, or undefined.
|
|
91
|
+
*/
|
|
92
|
+
export function mutationPath(name, args) {
|
|
93
|
+
if (typeof args !== 'object' || args === null || Array.isArray(args))
|
|
94
|
+
return undefined;
|
|
95
|
+
const record = args;
|
|
96
|
+
switch (name) {
|
|
97
|
+
case 'write':
|
|
98
|
+
return typeof record.content === 'string' ? text(record.file_path) : undefined;
|
|
99
|
+
case 'edit':
|
|
100
|
+
return typeof record.old_string === 'string' && typeof record.new_string === 'string' ? text(record.file_path) : undefined;
|
|
101
|
+
case 'str_replace_editor':
|
|
102
|
+
return record.command === 'create' || record.command === 'str_replace' || record.command === 'insert' ? text(record.path) : undefined;
|
|
103
|
+
default:
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=capture.js.map
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { WorkspaceDiffHunk } from './types.ts';
|
|
2
|
+
/** Hunks, whether the timeout degraded them, and the changed-line totals they carry. */
|
|
3
|
+
export interface Comparison {
|
|
4
|
+
hunks: WorkspaceDiffHunk[];
|
|
5
|
+
coarse: boolean;
|
|
6
|
+
added: number;
|
|
7
|
+
deleted: number;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Compare two texts line by line. A side that is null means the file did not
|
|
11
|
+
* exist. A comparison exceeding `timeoutMs` yields one hunk that deletes every
|
|
12
|
+
* old line and adds every new line.
|
|
13
|
+
* @param before - turn-start text, or null.
|
|
14
|
+
* @param after - turn-end text, or null.
|
|
15
|
+
* @param timeoutMs - milliseconds the line comparison may run.
|
|
16
|
+
* @returns hunks and totals; no hunks when both sides hold the same lines.
|
|
17
|
+
*/
|
|
18
|
+
export declare function compareText(before: string | null, after: string | null, timeoutMs: number): Comparison;
|
|
19
|
+
//# sourceMappingURL=compare.d.ts.map
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Line comparison of two whole-file texts, bounded by a timeout that degrades to whole-file replacement. */
|
|
2
|
+
import { structuredPatch } from 'diff';
|
|
3
|
+
/** Context lines around each change, the unified-diff default. */
|
|
4
|
+
const CONTEXT_LINES = 3;
|
|
5
|
+
/**
|
|
6
|
+
* A side's text with every line terminated, so the last line compares by
|
|
7
|
+
* content alone and empty text reads as no lines rather than one empty line.
|
|
8
|
+
*/
|
|
9
|
+
function terminated(text) {
|
|
10
|
+
return text === '' || text.endsWith('\n') ? text : `${text}\n`;
|
|
11
|
+
}
|
|
12
|
+
/** Content lines of a terminated text; empty text is zero lines. */
|
|
13
|
+
function lines(text) {
|
|
14
|
+
return text === '' ? [] : text.slice(0, -1).split('\n');
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Compare two texts line by line. A side that is null means the file did not
|
|
18
|
+
* exist. A comparison exceeding `timeoutMs` yields one hunk that deletes every
|
|
19
|
+
* old line and adds every new line.
|
|
20
|
+
* @param before - turn-start text, or null.
|
|
21
|
+
* @param after - turn-end text, or null.
|
|
22
|
+
* @param timeoutMs - milliseconds the line comparison may run.
|
|
23
|
+
* @returns hunks and totals; no hunks when both sides hold the same lines.
|
|
24
|
+
*/
|
|
25
|
+
export function compareText(before, after, timeoutMs) {
|
|
26
|
+
const oldText = terminated(before ?? '');
|
|
27
|
+
const newText = terminated(after ?? '');
|
|
28
|
+
const patch = structuredPatch('', '', oldText, newText, undefined, undefined, { context: CONTEXT_LINES, timeout: timeoutMs });
|
|
29
|
+
let hunks;
|
|
30
|
+
let coarse = false;
|
|
31
|
+
if (patch === undefined) {
|
|
32
|
+
coarse = true;
|
|
33
|
+
const oldLines = lines(oldText);
|
|
34
|
+
const newLines = lines(newText);
|
|
35
|
+
hunks = [{
|
|
36
|
+
oldStart: 1, oldLines: oldLines.length,
|
|
37
|
+
newStart: 1, newLines: newLines.length,
|
|
38
|
+
lines: [...oldLines.map(line => `-${line}`), ...newLines.map(line => `+${line}`)],
|
|
39
|
+
}];
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
hunks = patch.hunks.map(({ oldStart, oldLines, newStart, newLines, lines: body }) => ({ oldStart, oldLines, newStart, newLines, lines: body }));
|
|
43
|
+
}
|
|
44
|
+
let added = 0;
|
|
45
|
+
let deleted = 0;
|
|
46
|
+
for (const hunk of hunks) {
|
|
47
|
+
for (const line of hunk.lines) {
|
|
48
|
+
if (line.startsWith('+'))
|
|
49
|
+
added += 1;
|
|
50
|
+
else if (line.startsWith('-'))
|
|
51
|
+
deleted += 1;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return { hunks, coarse, added, deleted };
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=compare.js.map
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type { SubprocessRuntime } from '@qilin-agent/subprocess';
|
|
2
|
+
import { type NumstatEntry } from './numstat.ts';
|
|
3
|
+
/** Settled git command facts; a nonzero exit is a result, not an exception. */
|
|
4
|
+
export interface GitRunResult {
|
|
5
|
+
exitCode: number | null;
|
|
6
|
+
stdout: string;
|
|
7
|
+
stderr: string;
|
|
8
|
+
/** True when stdout exceeded the output cap and lost its head. */
|
|
9
|
+
truncated: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** Per-command spawn facts. */
|
|
12
|
+
export interface GitRunOptions {
|
|
13
|
+
cwd: string;
|
|
14
|
+
env?: Readonly<Record<string, string>> | undefined;
|
|
15
|
+
stdin?: string | undefined;
|
|
16
|
+
/** In-memory stdout cap for this command, replacing the runner's `outputMaxBytes`. */
|
|
17
|
+
maxBytes?: number | undefined;
|
|
18
|
+
signal: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
/** Bounds every git command runs under. */
|
|
21
|
+
export interface GitLimits {
|
|
22
|
+
/** Milliseconds before a command is terminated. */
|
|
23
|
+
timeoutMs: number;
|
|
24
|
+
/** In-memory stdout cap in bytes. */
|
|
25
|
+
outputMaxBytes: number;
|
|
26
|
+
}
|
|
27
|
+
/** Runs one resolved git executable with scrubbed environment, timeout, and bounded output. */
|
|
28
|
+
export declare class GitRunner {
|
|
29
|
+
private readonly subprocess;
|
|
30
|
+
private readonly executable;
|
|
31
|
+
private readonly limits;
|
|
32
|
+
constructor(subprocess: SubprocessRuntime, executable: string, limits: GitLimits);
|
|
33
|
+
/**
|
|
34
|
+
* Run `git <args>` to completion.
|
|
35
|
+
* @param args - git arguments; never shell-interpreted.
|
|
36
|
+
* @param options - working directory, extra environment, stdin data, and cancellation.
|
|
37
|
+
* @returns exit facts and collected output.
|
|
38
|
+
* @throws when the command times out, is aborted, or cannot spawn.
|
|
39
|
+
*/
|
|
40
|
+
run(args: readonly string[], options: GitRunOptions): Promise<GitRunResult>;
|
|
41
|
+
}
|
|
42
|
+
/** The repository enclosing a Session working directory and the private directory its snapshots write to. */
|
|
43
|
+
export interface GitWorkspace {
|
|
44
|
+
/** Repository top-level directory, the root every diff path is relative to. */
|
|
45
|
+
root: string;
|
|
46
|
+
/** Absolute git directory holding the repository's index. */
|
|
47
|
+
gitDir: string;
|
|
48
|
+
/** Private directory holding the snapshot object store and each snapshot's scratch index. */
|
|
49
|
+
scratch: string;
|
|
50
|
+
/** Environment that routes object writes to the private store and object reads through the repository's store. */
|
|
51
|
+
env: Readonly<Record<string, string>>;
|
|
52
|
+
/** Work-tree paths a snapshot must skip: the private directory when a temporary root lies inside the work tree. */
|
|
53
|
+
excludes: readonly string[];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Locate the repository enclosing a working directory and prepare the private
|
|
57
|
+
* directory its snapshots write to. The repository's own object store is
|
|
58
|
+
* attached read-only as an alternate, so snapshots read committed content from
|
|
59
|
+
* it and write nothing into it. A private directory that lies inside the work
|
|
60
|
+
* tree, as a temporary root under the workspace does, is excluded from every
|
|
61
|
+
* snapshot. A directory outside any repository yields null; any other git
|
|
62
|
+
* failure throws.
|
|
63
|
+
* @param git - command runner.
|
|
64
|
+
* @param cwd - absolute Session working directory.
|
|
65
|
+
* @param scratch - yields the private directory for snapshot objects and scratch indexes; called only for a located repository.
|
|
66
|
+
* @param signal - cancellation.
|
|
67
|
+
* @returns the repository, or null when the directory is not inside one.
|
|
68
|
+
*/
|
|
69
|
+
export declare function locateGitWorkspace(git: GitRunner, cwd: string, scratch: () => Promise<string>, signal: AbortSignal): Promise<GitWorkspace | null>;
|
|
70
|
+
/**
|
|
71
|
+
* Write the complete work tree, including untracked and modified files but
|
|
72
|
+
* not ignored ones, as a tree object through a private index seeded from the
|
|
73
|
+
* repository's index. New blobs and the tree land in the private object store;
|
|
74
|
+
* the repository's index, object store, work tree, and refs stay unchanged,
|
|
75
|
+
* and an in-progress merge keeps its unmerged entries.
|
|
76
|
+
* @param git - command runner.
|
|
77
|
+
* @param workspace - addressed repository.
|
|
78
|
+
* @param signal - cancellation.
|
|
79
|
+
* @returns the tree object id.
|
|
80
|
+
*/
|
|
81
|
+
export declare function snapshotTree(git: GitRunner, workspace: GitWorkspace, signal: AbortSignal): Promise<string>;
|
|
82
|
+
/** A blob one snapshot tree holds at a path. */
|
|
83
|
+
export interface TreeBlob {
|
|
84
|
+
oid: string;
|
|
85
|
+
/** Object size in bytes. */
|
|
86
|
+
size: number;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* The blob a snapshot tree holds at one path.
|
|
90
|
+
* @param git - command runner.
|
|
91
|
+
* @param workspace - addressed repository.
|
|
92
|
+
* @param tree - snapshot tree id.
|
|
93
|
+
* @param path - slash-separated path relative to the repository root.
|
|
94
|
+
* @param signal - cancellation.
|
|
95
|
+
* @returns the blob, or null when the tree holds nothing at the path or holds a gitlink or tree there.
|
|
96
|
+
*/
|
|
97
|
+
export declare function treeBlob(git: GitRunner, workspace: GitWorkspace, tree: string, path: string, signal: AbortSignal): Promise<TreeBlob | null>;
|
|
98
|
+
/**
|
|
99
|
+
* The text of one blob whose size {@link treeBlob} reported within the cap.
|
|
100
|
+
* @param git - command runner.
|
|
101
|
+
* @param workspace - addressed repository.
|
|
102
|
+
* @param oid - blob id.
|
|
103
|
+
* @param maxBytes - inclusive byte cap the caller checked the blob's size against.
|
|
104
|
+
* @param signal - cancellation.
|
|
105
|
+
* @returns the blob decoded as UTF-8.
|
|
106
|
+
*/
|
|
107
|
+
export declare function blobText(git: GitRunner, workspace: GitWorkspace, oid: string, maxBytes: number, signal: AbortSignal): Promise<string>;
|
|
108
|
+
/**
|
|
109
|
+
* Per-file line counts between two snapshot trees, with renames detected.
|
|
110
|
+
* @param git - command runner.
|
|
111
|
+
* @param workspace - addressed repository.
|
|
112
|
+
* @param before - turn-start tree id.
|
|
113
|
+
* @param after - turn-end tree id.
|
|
114
|
+
* @param signal - cancellation.
|
|
115
|
+
* @returns changed files relative to the repository root.
|
|
116
|
+
* @throws when git fails or the output exceeded the cap.
|
|
117
|
+
*/
|
|
118
|
+
export declare function diffTrees(git: GitRunner, workspace: GitWorkspace, before: string, after: string, signal: AbortSignal): Promise<NumstatEntry[]>;
|
|
119
|
+
/**
|
|
120
|
+
* Work-tree directories the index records as gitlinks: nested repositories and
|
|
121
|
+
* submodules, whose contents snapshots never descend into and `check-ignore`
|
|
122
|
+
* refuses to classify.
|
|
123
|
+
* @param git - command runner.
|
|
124
|
+
* @param workspace - addressed repository.
|
|
125
|
+
* @param signal - cancellation.
|
|
126
|
+
* @returns slash-separated gitlink paths relative to the repository root.
|
|
127
|
+
*/
|
|
128
|
+
export declare function gitlinkPaths(git: GitRunner, workspace: GitWorkspace, signal: AbortSignal): Promise<Set<string>>;
|
|
129
|
+
/**
|
|
130
|
+
* The subset of work-tree paths that the repository ignores. Tracked files
|
|
131
|
+
* are never reported, so a tracked file matching an ignore pattern still
|
|
132
|
+
* counts as covered by snapshots.
|
|
133
|
+
* @param git - command runner.
|
|
134
|
+
* @param workspace - addressed repository.
|
|
135
|
+
* @param paths - slash-separated paths relative to the repository root.
|
|
136
|
+
* @param signal - cancellation.
|
|
137
|
+
* @returns the ignored members of `paths`.
|
|
138
|
+
*/
|
|
139
|
+
export declare function ignoredPaths(git: GitRunner, workspace: GitWorkspace, paths: readonly string[], signal: AbortSignal): Promise<Set<string>>;
|
|
140
|
+
//# sourceMappingURL=git.d.ts.map
|
package/lib/types/git.js
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/** Git working-tree snapshots, tree diffs, and ignore checks through the subprocess capability. */
|
|
2
|
+
import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
3
|
+
import { join, relative, resolve } from 'node:path';
|
|
4
|
+
import { parseNumstat } from "./numstat.js";
|
|
5
|
+
import { canonicalPath, isInside, toPosix } from "./paths.js";
|
|
6
|
+
/** Milliseconds a git child gets to exit after termination starts; a fixed lifecycle constant. */
|
|
7
|
+
const TERMINATE_GRACE_MS = 2_000;
|
|
8
|
+
/** Retained stderr tail for diagnostics. */
|
|
9
|
+
const STDERR_TAIL_BYTES = 16 * 1024;
|
|
10
|
+
/** Runs one resolved git executable with scrubbed environment, timeout, and bounded output. */
|
|
11
|
+
export class GitRunner {
|
|
12
|
+
subprocess;
|
|
13
|
+
executable;
|
|
14
|
+
limits;
|
|
15
|
+
constructor(subprocess, executable, limits) {
|
|
16
|
+
this.subprocess = subprocess;
|
|
17
|
+
this.executable = executable;
|
|
18
|
+
this.limits = limits;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Run `git <args>` to completion.
|
|
22
|
+
* @param args - git arguments; never shell-interpreted.
|
|
23
|
+
* @param options - working directory, extra environment, stdin data, and cancellation.
|
|
24
|
+
* @returns exit facts and collected output.
|
|
25
|
+
* @throws when the command times out, is aborted, or cannot spawn.
|
|
26
|
+
*/
|
|
27
|
+
async run(args, options) {
|
|
28
|
+
const timeout = AbortSignal.timeout(this.limits.timeoutMs);
|
|
29
|
+
const signal = AbortSignal.any([options.signal, timeout]);
|
|
30
|
+
const handle = this.subprocess.spawn({
|
|
31
|
+
argv: [this.executable, ...args],
|
|
32
|
+
cwd: options.cwd,
|
|
33
|
+
stdio: {
|
|
34
|
+
stdin: options.stdin === undefined ? 'ignore' : { data: options.stdin },
|
|
35
|
+
stdout: { maxBytes: options.maxBytes ?? this.limits.outputMaxBytes },
|
|
36
|
+
stderr: { maxBytes: STDERR_TAIL_BYTES },
|
|
37
|
+
},
|
|
38
|
+
graceMs: TERMINATE_GRACE_MS,
|
|
39
|
+
signal,
|
|
40
|
+
// The subprocess credential scrub removes ambient GIT_CONFIG_KEY_n entries.
|
|
41
|
+
env: { GIT_CONFIG_COUNT: '0', GIT_TERMINAL_PROMPT: '0', GIT_OPTIONAL_LOCKS: '0', LC_ALL: 'C', ...options.env },
|
|
42
|
+
});
|
|
43
|
+
const outcome = await handle.done;
|
|
44
|
+
if (signal.aborted) {
|
|
45
|
+
throw new Error(`git ${args.join(' ')} ${timeout.aborted ? `timed out after ${this.limits.timeoutMs}ms` : 'was aborted'}`);
|
|
46
|
+
}
|
|
47
|
+
/* v8 ignore start -- collect-mode stdio always yields both readers. */
|
|
48
|
+
const stdout = handle.collected.stdout?.readFrom(0) ?? { text: '', lossy: false };
|
|
49
|
+
const stderr = handle.collected.stderr?.readFrom(0).text ?? '';
|
|
50
|
+
/* v8 ignore stop */
|
|
51
|
+
return { exitCode: outcome.exitCode, stdout: stdout.text, stderr, truncated: stdout.lossy };
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Reject a failed command with its stderr.
|
|
56
|
+
* @param result - settled command facts.
|
|
57
|
+
* @param what - command description for the error message.
|
|
58
|
+
* @returns the same result when it exited zero.
|
|
59
|
+
*/
|
|
60
|
+
function ok(result, what) {
|
|
61
|
+
if (result.exitCode !== 0)
|
|
62
|
+
throw new Error(`${what} failed: ${result.stderr.trim()}`);
|
|
63
|
+
return result;
|
|
64
|
+
}
|
|
65
|
+
/** Whether a filesystem error names a missing path. */
|
|
66
|
+
function isMissing(error) {
|
|
67
|
+
return typeof error === 'object' && error !== null && error.code === 'ENOENT';
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Locate the repository enclosing a working directory and prepare the private
|
|
71
|
+
* directory its snapshots write to. The repository's own object store is
|
|
72
|
+
* attached read-only as an alternate, so snapshots read committed content from
|
|
73
|
+
* it and write nothing into it. A private directory that lies inside the work
|
|
74
|
+
* tree, as a temporary root under the workspace does, is excluded from every
|
|
75
|
+
* snapshot. A directory outside any repository yields null; any other git
|
|
76
|
+
* failure throws.
|
|
77
|
+
* @param git - command runner.
|
|
78
|
+
* @param cwd - absolute Session working directory.
|
|
79
|
+
* @param scratch - yields the private directory for snapshot objects and scratch indexes; called only for a located repository.
|
|
80
|
+
* @param signal - cancellation.
|
|
81
|
+
* @returns the repository, or null when the directory is not inside one.
|
|
82
|
+
*/
|
|
83
|
+
export async function locateGitWorkspace(git, cwd, scratch, signal) {
|
|
84
|
+
const found = await git.run(['rev-parse', '--show-toplevel', '--absolute-git-dir', '--git-path', 'objects'], { cwd, signal });
|
|
85
|
+
if (found.exitCode === 128 && /not a git repository/i.test(found.stderr))
|
|
86
|
+
return null;
|
|
87
|
+
const lines = ok(found, 'git rev-parse').stdout.split('\n').map(line => resolve(cwd, line));
|
|
88
|
+
const [root, gitDir, repositoryObjects] = lines;
|
|
89
|
+
// git reports the canonical root; compare the private directory in the same spelling.
|
|
90
|
+
const directory = await canonicalPath(await scratch());
|
|
91
|
+
const objects = join(directory, 'objects');
|
|
92
|
+
await mkdir(objects, { recursive: true });
|
|
93
|
+
const excludes = isInside(root, directory) ? [toPosix(relative(root, directory))] : [];
|
|
94
|
+
const env = { GIT_OBJECT_DIRECTORY: objects, GIT_ALTERNATE_OBJECT_DIRECTORIES: repositoryObjects };
|
|
95
|
+
return { root, gitDir, scratch: directory, env, excludes };
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Write the complete work tree, including untracked and modified files but
|
|
99
|
+
* not ignored ones, as a tree object through a private index seeded from the
|
|
100
|
+
* repository's index. New blobs and the tree land in the private object store;
|
|
101
|
+
* the repository's index, object store, work tree, and refs stay unchanged,
|
|
102
|
+
* and an in-progress merge keeps its unmerged entries.
|
|
103
|
+
* @param git - command runner.
|
|
104
|
+
* @param workspace - addressed repository.
|
|
105
|
+
* @param signal - cancellation.
|
|
106
|
+
* @returns the tree object id.
|
|
107
|
+
*/
|
|
108
|
+
export async function snapshotTree(git, workspace, signal) {
|
|
109
|
+
const scratch = await mkdtemp(join(workspace.scratch, 'index-'));
|
|
110
|
+
try {
|
|
111
|
+
const index = join(scratch, 'index');
|
|
112
|
+
// A repository without an index yet (fresh `git init`) starts from scratch.
|
|
113
|
+
await copyFile(join(workspace.gitDir, 'index'), index).catch((error) => {
|
|
114
|
+
if (!isMissing(error))
|
|
115
|
+
throw error;
|
|
116
|
+
});
|
|
117
|
+
const env = { ...workspace.env, GIT_INDEX_FILE: index };
|
|
118
|
+
// `--ignore-errors` skips unreadable files and reports them through exit code 1; the index is still complete.
|
|
119
|
+
const pathspec = workspace.excludes.length === 0 ? [] : ['--', '.', ...workspace.excludes.map(path => `:(exclude)${path}`)];
|
|
120
|
+
const added = await git.run(['add', '--all', '--ignore-errors', ...pathspec], { cwd: workspace.root, env, signal });
|
|
121
|
+
/* v8 ignore next -- git reports a skipped unreadable file only on hosts whose permissions the tests can revoke. */
|
|
122
|
+
if (added.exitCode !== 1)
|
|
123
|
+
ok(added, `git add in ${workspace.root}`);
|
|
124
|
+
return ok(await git.run(['write-tree'], { cwd: workspace.root, env, signal }), 'git write-tree').stdout.trim();
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
await rm(scratch, { recursive: true, force: true });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* The blob a snapshot tree holds at one path.
|
|
132
|
+
* @param git - command runner.
|
|
133
|
+
* @param workspace - addressed repository.
|
|
134
|
+
* @param tree - snapshot tree id.
|
|
135
|
+
* @param path - slash-separated path relative to the repository root.
|
|
136
|
+
* @param signal - cancellation.
|
|
137
|
+
* @returns the blob, or null when the tree holds nothing at the path or holds a gitlink or tree there.
|
|
138
|
+
*/
|
|
139
|
+
export async function treeBlob(git, workspace, tree, path, signal) {
|
|
140
|
+
// The path is a pathspec; literal matching keeps `*`, `?`, and `[` in a file name from selecting another entry.
|
|
141
|
+
const result = ok(await git.run(['ls-tree', '-z', '-l', tree, '--', path], {
|
|
142
|
+
cwd: workspace.root, env: { ...workspace.env, GIT_LITERAL_PATHSPECS: '1' }, signal,
|
|
143
|
+
}), 'git ls-tree');
|
|
144
|
+
const entry = result.stdout.split('\0')[0];
|
|
145
|
+
const match = /^\d+ (\S+) ([0-9a-f]+) +(\d+)\t/.exec(entry);
|
|
146
|
+
if (match === null || match[1] !== 'blob')
|
|
147
|
+
return null;
|
|
148
|
+
return { oid: match[2], size: Number(match[3]) };
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The text of one blob whose size {@link treeBlob} reported within the cap.
|
|
152
|
+
* @param git - command runner.
|
|
153
|
+
* @param workspace - addressed repository.
|
|
154
|
+
* @param oid - blob id.
|
|
155
|
+
* @param maxBytes - inclusive byte cap the caller checked the blob's size against.
|
|
156
|
+
* @param signal - cancellation.
|
|
157
|
+
* @returns the blob decoded as UTF-8.
|
|
158
|
+
*/
|
|
159
|
+
export async function blobText(git, workspace, oid, maxBytes, signal) {
|
|
160
|
+
const result = ok(await git.run(['cat-file', 'blob', oid], { cwd: workspace.root, env: workspace.env, maxBytes, signal }), 'git cat-file');
|
|
161
|
+
/* v8 ignore next -- callers size the blob with treeBlob first; a blob is immutable, so the cap cannot be exceeded here. */
|
|
162
|
+
if (result.truncated)
|
|
163
|
+
throw new Error(`blob ${oid} exceeds ${maxBytes} bytes`);
|
|
164
|
+
return result.stdout;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Per-file line counts between two snapshot trees, with renames detected.
|
|
168
|
+
* @param git - command runner.
|
|
169
|
+
* @param workspace - addressed repository.
|
|
170
|
+
* @param before - turn-start tree id.
|
|
171
|
+
* @param after - turn-end tree id.
|
|
172
|
+
* @param signal - cancellation.
|
|
173
|
+
* @returns changed files relative to the repository root.
|
|
174
|
+
* @throws when git fails or the output exceeded the cap.
|
|
175
|
+
*/
|
|
176
|
+
export async function diffTrees(git, workspace, before, after, signal) {
|
|
177
|
+
if (before === after)
|
|
178
|
+
return [];
|
|
179
|
+
const result = ok(await git.run(['diff-tree', '-r', '-M', '-z', '--numstat', before, after], {
|
|
180
|
+
cwd: workspace.root, env: workspace.env, signal,
|
|
181
|
+
}), 'git diff-tree');
|
|
182
|
+
if (result.truncated)
|
|
183
|
+
throw new Error('git diff-tree output exceeded the configured cap');
|
|
184
|
+
return parseNumstat(result.stdout);
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Work-tree directories the index records as gitlinks: nested repositories and
|
|
188
|
+
* submodules, whose contents snapshots never descend into and `check-ignore`
|
|
189
|
+
* refuses to classify.
|
|
190
|
+
* @param git - command runner.
|
|
191
|
+
* @param workspace - addressed repository.
|
|
192
|
+
* @param signal - cancellation.
|
|
193
|
+
* @returns slash-separated gitlink paths relative to the repository root.
|
|
194
|
+
*/
|
|
195
|
+
export async function gitlinkPaths(git, workspace, signal) {
|
|
196
|
+
const result = ok(await git.run(['ls-files', '-z', '--stage'], { cwd: workspace.root, env: workspace.env, signal }), 'git ls-files');
|
|
197
|
+
const links = new Set();
|
|
198
|
+
for (const entry of result.stdout.split('\0')) {
|
|
199
|
+
if (entry.startsWith('160000 '))
|
|
200
|
+
links.add(entry.slice(entry.indexOf('\t') + 1));
|
|
201
|
+
}
|
|
202
|
+
return links;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* The subset of work-tree paths that the repository ignores. Tracked files
|
|
206
|
+
* are never reported, so a tracked file matching an ignore pattern still
|
|
207
|
+
* counts as covered by snapshots.
|
|
208
|
+
* @param git - command runner.
|
|
209
|
+
* @param workspace - addressed repository.
|
|
210
|
+
* @param paths - slash-separated paths relative to the repository root.
|
|
211
|
+
* @param signal - cancellation.
|
|
212
|
+
* @returns the ignored members of `paths`.
|
|
213
|
+
*/
|
|
214
|
+
export async function ignoredPaths(git, workspace, paths, signal) {
|
|
215
|
+
if (paths.length === 0)
|
|
216
|
+
return new Set();
|
|
217
|
+
const result = await git.run(['check-ignore', '-z', '--stdin'], {
|
|
218
|
+
cwd: workspace.root, env: workspace.env, stdin: `${paths.join('\0')}\0`, signal,
|
|
219
|
+
});
|
|
220
|
+
if (result.exitCode === 1)
|
|
221
|
+
return new Set();
|
|
222
|
+
return new Set(ok(result, 'git check-ignore').stdout.split('\0').filter(path => path !== ''));
|
|
223
|
+
}
|
|
224
|
+
//# sourceMappingURL=git.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Context } from '@qilin-agent/kylin';
|
|
2
|
+
import z from '@qilin-agent/schemastery';
|
|
3
|
+
export type { WorkspaceChangedFile, WorkspaceChanges, WorkspaceChangesSummary, WorkspaceDiffHunk, WorkspaceFileDiff, WorkspaceSessionChangedFile, WorkspaceSessionChanges, } from './types.ts';
|
|
4
|
+
/** Stable Loader identity. */
|
|
5
|
+
export declare const name = "workspace-changes";
|
|
6
|
+
/** Services used to run git and observe turns. */
|
|
7
|
+
export declare const inject: string[];
|
|
8
|
+
/** Snapshot, capture, and comparison bounds. Invalid values fail plugin load. */
|
|
9
|
+
export interface Config {
|
|
10
|
+
/** Milliseconds one git command may run before the turn's record is abandoned. */
|
|
11
|
+
timeoutMs: number;
|
|
12
|
+
/** Bytes of git output retained per command; a larger diff listing abandons the record. */
|
|
13
|
+
outputMaxBytes: number;
|
|
14
|
+
/** Maximum files carried by one summary; `total` still reports the complete count. */
|
|
15
|
+
maxFiles: number;
|
|
16
|
+
/**
|
|
17
|
+
* Bytes a file may hold to be captured around a file-tool edit or read from a snapshot for its comparison.
|
|
18
|
+
* A larger file gets no comparison; one captured around a file-tool edit is also listed without counts.
|
|
19
|
+
*/
|
|
20
|
+
maxFileBytes: number;
|
|
21
|
+
/** Milliseconds a line comparison may run before it degrades to whole-file replacement. */
|
|
22
|
+
diffTimeoutMs: number;
|
|
23
|
+
}
|
|
24
|
+
/** Schemastery validation for {@link Config}. */
|
|
25
|
+
export declare const Config: z<Config>;
|
|
26
|
+
/**
|
|
27
|
+
* Observe top-level turns of every Session with a working directory, capture
|
|
28
|
+
* file-tool edits, announce change summaries, and serve them with their
|
|
29
|
+
* comparisons as `workspaceChanges`.
|
|
30
|
+
* @param ctx - host context with `subprocess`.
|
|
31
|
+
* @param config - validated bounds.
|
|
32
|
+
*/
|
|
33
|
+
export declare function apply(ctx: Context, config: Config): void;
|
|
34
|
+
//# sourceMappingURL=index.d.ts.map
|