pi-hashline-edit-pro 2.8.0 → 2.8.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/README.md +9 -4
- package/index.ts +14 -8
- package/package.json +9 -9
- package/prompts/grep-guidelines.md +3 -3
- package/prompts/grep-snippet.md +1 -1
- package/prompts/grep.md +1 -1
- package/src/commit.ts +8 -3
- package/src/constants.ts +1 -1
- package/src/edit-common.ts +45 -0
- package/src/file-kind.ts +6 -2
- package/src/file-reader.ts +9 -2
- package/src/fs-write.ts +42 -11
- package/src/grep.ts +284 -98
- package/src/hash-store/cache.ts +18 -0
- package/src/hash-store/retry.ts +48 -0
- package/src/hash-store/validation.ts +93 -0
- package/src/hash-store.ts +90 -147
- package/src/hashline/apply.ts +3 -3
- package/src/hashline/resolve.ts +52 -36
- package/src/insert.ts +7 -23
- package/src/normalize.ts +27 -0
- package/src/read.ts +5 -3
- package/src/replace-diff.ts +8 -30
- package/src/replace-undo.ts +22 -7
- package/src/replace.ts +14 -30
- package/src/served.ts +56 -27
- package/src/validation.ts +2 -2
- package/src/write-hook.ts +4 -4
package/README.md
CHANGED
|
@@ -133,7 +133,7 @@ Notes:
|
|
|
133
133
|
|
|
134
134
|
## The grep tool
|
|
135
135
|
|
|
136
|
-
`grep` replaces the built-in grep with an anchored search. Every matching line (and each requested context line) is returned as
|
|
136
|
+
`grep` replaces the built-in grep with an anchored search backed by ripgrep. Every matching line (and each requested context line) is returned as `lineNumber │ anchor│content` — the `anchor│content` part is served exactly like `read` output, so you can target it with `replace`/`insert` without a separate `read`, while the line-number gutter and `=== path ===` header give filename and line for navigation (press Return to jump).
|
|
137
137
|
|
|
138
138
|
| Field | Description |
|
|
139
139
|
| --- | --- |
|
|
@@ -146,9 +146,10 @@ Notes:
|
|
|
146
146
|
| `limit` | Maximum number of matched lines to return (default: 100). |
|
|
147
147
|
|
|
148
148
|
Notes:
|
|
149
|
-
- Results are grouped per file under a `=== path ===` header; every shown row
|
|
150
|
-
- Directory searches
|
|
151
|
-
-
|
|
149
|
+
- Results are grouped per file under a `=== path ===` header; every shown row is `lineNumber │ anchor│content` where `anchor│content` is the anchor it would have in `read` output.
|
|
150
|
+
- Directory searches use ripgrep, respecting `.gitignore`; `node_modules`, `.git`, `.tmp`, and `coverage` are always skipped. Binary, image, and oversized files are skipped silently.
|
|
151
|
+
- Regex patterns with backreferences, nested quantifiers, quantified alternation, or multiple variable quantifiers are rejected with `[E_UNSAFE_REGEX]` before any files are scanned; use `literal: true` when regex behavior is unnecessary.
|
|
152
|
+
- Output is capped at `limit` matched lines, 2000 rows, and 50KB of text (whichever comes first), with a hint naming the cap that cut results. A matched line longer than 500 bytes is shown as a fragment around the match with `...` marking the truncated sides, so the relevant part of the hit stays visible; a context line over 500 bytes is shown as its head with a trailing `...`. Fragments keep the line's anchor (long lines are hashed from their first 500 bytes) and are served like full rows, so a fragmented match is still editable with `replace` (which always replaces the whole line).
|
|
152
153
|
- `file_path` works as an alias for `path`.
|
|
153
154
|
- Line endings and BOMs survive every edit. The file's line ending is detected from its first newline and restored on write; a file that mixes LF and CRLF (for example a WSL-edited file) is normalized to the first-seen ending.
|
|
154
155
|
- Files with multiple hard links (`nlink > 1`) are rewritten in place rather than via a temp-file rename, so every link keeps seeing the same content; that write is direct rather than atomic.
|
|
@@ -206,6 +207,8 @@ Anchors are unique by construction. If a line's base hash collides with an alrea
|
|
|
206
207
|
|
|
207
208
|
Hashes live in a persistent per-file store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that keeps the hashes of unchanged lines across edits. When a range is replaced, the runtime maps the old content onto the new content and copies hashes for lines that survived; only genuinely new lines get fresh hashes.
|
|
208
209
|
|
|
210
|
+
On POSIX systems, the state directory is restricted to mode `0700` and the SQLite database plus its WAL/SHM sidecars to `0600`. The undo table contains the complete pre-edit and post-edit text for the latest edit to each file, so the store should still be treated as sensitive data.
|
|
211
|
+
|
|
209
212
|
The store also keeps a per-file record of the hashes the model was last served (`read` rows, auto-read blocks, post-edit diff rows), pruned to the file's current hashes on every update so removed lines' hashes do not accumulate. `replace` verifies every line of the resolved range against that record before writing; a line whose hash is missing from the record means it either changed on disk after it was shown or was never shown, and the edit is refused with `[E_RANGE_STALE]`. A `write` clears the record, so edits after a write are verified against whatever the next `read` or auto-read block serves.
|
|
210
213
|
|
|
211
214
|
Two guarantees make this safe even with duplicated content:
|
|
@@ -236,6 +239,8 @@ A no-op replace never changes the file, so anchors remain valid. On first run af
|
|
|
236
239
|
| `[E_BOUNDARY_BYPASS]` | The boundary anti-duplication was turned off for one replace call (an identical replacement had previously been cut to a noop); the duplicate lines were applied literally. The dedup is restored for the next call. |
|
|
237
240
|
| `[E_FILE_TOO_LARGE]` | The file exceeds the 238,328-line hashline limit or the 100MB size limit. |
|
|
238
241
|
| `[E_WRITE_HASH_ECHO]` | A `write` `content` line begins with the exact `anchor│` served for this file at the same line. The write is refused, file byte-identical; retry with bare content (remove the copied anchors). |
|
|
242
|
+
| `[E_PATH_CHANGED]` | A write target changed identity after it was read; the write was refused to avoid following a swapped symlink or overwriting a replacement file. |
|
|
243
|
+
| `[E_UNSAFE_REGEX]` | A grep regex can trigger excessive backtracking; simplify it or search with `literal: true`. |
|
|
239
244
|
|
|
240
245
|
## Troubleshooting
|
|
241
246
|
|
package/index.ts
CHANGED
|
@@ -14,13 +14,14 @@ import {
|
|
|
14
14
|
toggleAutoRead,
|
|
15
15
|
} from "./src/config";
|
|
16
16
|
import { loadHashStore, pruneMissing } from "./src/hash-store";
|
|
17
|
-
import { recordServedSafe, clearServed } from "./src/served";
|
|
17
|
+
import { recordServedSafe, clearServed, buildServedMap } from "./src/served";
|
|
18
18
|
import { clearBoundaryBypass } from "./src/boundary-bypass";
|
|
19
19
|
import { registerWriteHook } from "./src/write-hook";
|
|
20
20
|
import { readNormFile } from "./src/file-reader";
|
|
21
21
|
import { loadFileKindAndText } from "./src/file-kind";
|
|
22
22
|
import { resolveInCwd } from "./src/fs-write";
|
|
23
23
|
import { valAccess } from "./src/validation";
|
|
24
|
+
import { splitLines } from "./src/utils";
|
|
24
25
|
|
|
25
26
|
export default function (pi: ExtensionAPI): void {
|
|
26
27
|
regRead(pi);
|
|
@@ -37,12 +38,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
37
38
|
const active = pi.getActiveTools();
|
|
38
39
|
pi.setActiveTools(active.filter((t) => t !== "edit"));
|
|
39
40
|
await initHasher();
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
41
|
+
loadHashStore()
|
|
42
|
+
.then(store =>
|
|
43
|
+
pruneMissing(store).catch(err => {
|
|
44
|
+
console.error("Failed to prune hash store:", err);
|
|
45
|
+
}),
|
|
46
|
+
)
|
|
47
|
+
.catch(err => {
|
|
48
|
+
console.error("Failed to load hash store:", err);
|
|
49
|
+
});
|
|
46
50
|
const config = await readConfig();
|
|
47
51
|
autoRead = config.autoRead;
|
|
48
52
|
const debugValue = process.env.PI_HASHLINE_DEBUG;
|
|
@@ -95,7 +99,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
95
99
|
DEFAULT_MAX_BYTES,
|
|
96
100
|
DEFAULT_MAX_LINES,
|
|
97
101
|
);
|
|
98
|
-
|
|
102
|
+
const fileLines = splitLines(normalized);
|
|
103
|
+
const servedMap = buildServedMap(fileHashes, fileLines, preview.servedHashes);
|
|
104
|
+
await recordServedSafe(absolutePath, servedMap, "auto-read", new Set(fileHashes));
|
|
99
105
|
return {
|
|
100
106
|
content: [
|
|
101
107
|
...(event.content ?? []),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-hashline-edit-pro",
|
|
3
|
-
"version": "2.8.
|
|
3
|
+
"version": "2.8.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Hash-anchored read/replace/insert/grep tools for pi-coding-agent. Every line gets a unique 3-char hash (A-Za-z0-9) that stays stable across edits; stale or ambiguous anchors are rejected, never fuzzy-matched. Undo persists across restarts.",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -39,8 +39,8 @@
|
|
|
39
39
|
]
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"diff": "^
|
|
43
|
-
"file-type": "^
|
|
42
|
+
"diff": "^9.0.0",
|
|
43
|
+
"file-type": "^22.0.2",
|
|
44
44
|
"typebox": "^1.3.7",
|
|
45
45
|
"xxhash-wasm": "^1.1.0"
|
|
46
46
|
},
|
|
@@ -63,12 +63,12 @@
|
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@earendil-works/pi-coding-agent": "^0.84.0",
|
|
65
65
|
"@eslint/js": "^10.0.1",
|
|
66
|
-
"@types/node": "^24
|
|
67
|
-
"@vitest/coverage-v8": "^4.1.
|
|
68
|
-
"eslint": "^10.
|
|
69
|
-
"typescript": "^5.
|
|
70
|
-
"typescript-eslint": "^8.
|
|
71
|
-
"vitest": "^4.1.
|
|
66
|
+
"@types/node": "^24",
|
|
67
|
+
"@vitest/coverage-v8": "^4.1.11",
|
|
68
|
+
"eslint": "^10.9.1",
|
|
69
|
+
"typescript": "^5.9.3",
|
|
70
|
+
"typescript-eslint": "^8.68.0",
|
|
71
|
+
"vitest": "^4.1.11"
|
|
72
72
|
},
|
|
73
73
|
"allowScripts": {
|
|
74
74
|
"@google/genai@1.52.0": true,
|
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
- `grep`: every hit and `context` line comes back as `anchor│content` —
|
|
2
|
-
- `grep`: use `path` for file or folder (default cwd), `glob` like `*.ts` to filter, `literal:true` for literal text, `context:N` for surrounding lines.
|
|
3
|
-
- `grep`: search skips `node_modules/.git/.tmp/coverage` and skips binary/image files.
|
|
1
|
+
- `grep`: every hit and `context` line comes back as `lineNumber │ anchor│content` — the `anchor│content` part is usable directly for `replace`/`insert` without a new `read`, while `lineNumber` enables jump-to-line.
|
|
2
|
+
- `grep`: uses ripgrep, respects `.gitignore`; use `path` for file or folder (default cwd), `glob` like `*.ts` to filter, `literal:true` for literal text, `context:N` for surrounding lines.
|
|
3
|
+
- `grep`: search skips `node_modules/.git/.tmp/coverage` and skips binary/image files.
|
package/prompts/grep-snippet.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Search with `anchor│content` hits usable directly for `replace`/`insert`; use `literal`/`glob`/`context`
|
|
1
|
+
Search with `lineNumber │ anchor│content` hits usable directly for `replace`/`insert`; use `literal`/`glob`/`context`
|
package/prompts/grep.md
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
Search text files for a pattern. Every hit and each `context` line is returned as `anchor│content`
|
|
1
|
+
Search text files for a pattern using ripgrep. Every hit and each `context` line is returned as `lineNumber │ anchor│content` — the `anchor│content` part is usable directly for `replace`/`insert` without a new `read`, while `lineNumber` and the `=== path ===` header give file and line for navigation. Respects `.gitignore` via ripgrep; directory search skips `node_modules/.git/.tmp/coverage` and skips binary/image files. Matching lines longer than 500 bytes are shown as a fragment around the match with `...`, but the anchor is still valid for the whole line. If output says truncated, refine `pattern` or raise `limit` as hinted.
|
package/src/commit.ts
CHANGED
|
@@ -4,8 +4,9 @@ import { buildChanged, buildNoop, type RMeta, type TResult } from "./replace-res
|
|
|
4
4
|
import { saveUndo } from "./replace-undo";
|
|
5
5
|
import { safeSnapId } from "./file-reader";
|
|
6
6
|
import { writeAtomic } from "./fs-write";
|
|
7
|
-
import {
|
|
8
|
-
import { restoreEndings } from "./
|
|
7
|
+
import { recordServedSafe, buildServedMap, servedHashesFromDiff } from "./served";
|
|
8
|
+
import { restoreEndings } from "./normalize";
|
|
9
|
+
import { splitLines } from "./utils";
|
|
9
10
|
|
|
10
11
|
export interface CommitMeta {
|
|
11
12
|
path: string;
|
|
@@ -72,6 +73,7 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
72
73
|
await writeAtomic(
|
|
73
74
|
absolutePath,
|
|
74
75
|
pipe.bom + restoreEndings(pipe.result, pipe.originalEnding),
|
|
76
|
+
pipe.identity,
|
|
75
77
|
);
|
|
76
78
|
} catch (error) {
|
|
77
79
|
await undo.restore();
|
|
@@ -101,7 +103,10 @@ export async function commitEdit(pipe: PipelineResult, meta: CommitMeta): Promis
|
|
|
101
103
|
};
|
|
102
104
|
const changed = buildChanged(successInput, meta.verb);
|
|
103
105
|
if (changed.details.diff) {
|
|
104
|
-
|
|
106
|
+
const diffHashes = servedHashesFromDiff(changed.details.diff);
|
|
107
|
+
const resultLines = splitLines(pipe.result);
|
|
108
|
+
const servedMap = buildServedMap(pipe.resultHashes, resultLines, diffHashes);
|
|
109
|
+
await recordServedSafe(mutationTargetPath, servedMap, "post-edit diff", new Set(pipe.resultHashes));
|
|
105
110
|
}
|
|
106
111
|
return changed;
|
|
107
112
|
}
|
package/src/constants.ts
CHANGED
|
@@ -7,6 +7,6 @@ export const MAX_HASH_SOURCE_BYTES = 500;
|
|
|
7
7
|
export const MAX_GREP_LINE_BYTES = 500;
|
|
8
8
|
|
|
9
9
|
export const HASH_STORE_BUSY_TIMEOUT = 1000;
|
|
10
|
-
export const HASH_STORE_VERSION =
|
|
10
|
+
export const HASH_STORE_VERSION = 6;
|
|
11
11
|
export const NEW_CONTENT_NOT_ARRAY_MSG =
|
|
12
12
|
`[E_BAD_SHAPE] "replacement_lines" must be an array of strings, one per line (use [] to delete).`;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { resolveInCwd } from "./fs-write";
|
|
3
|
+
import { abortIf, makePrepareArguments } from "./utils";
|
|
4
|
+
import { makeRenderCall, renderEditResult, type RPreview, type FgT } from "./replace-render";
|
|
5
|
+
import type { ReplaceDetails } from "./replace";
|
|
6
|
+
|
|
7
|
+
export const editPrepare = makePrepareArguments();
|
|
8
|
+
|
|
9
|
+
export function editRenderResultWrapper(
|
|
10
|
+
result: { content?: Array<{ type: string; text?: string }>; details?: ReplaceDetails },
|
|
11
|
+
opts: { isPartial: boolean; expanded?: boolean } | boolean,
|
|
12
|
+
theme: FgT,
|
|
13
|
+
context: any,
|
|
14
|
+
) {
|
|
15
|
+
return renderEditResult(result, opts, theme, context);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function editRenderCallWrapper(
|
|
19
|
+
preview: (args: unknown, cwd: string) => Promise<RPreview>,
|
|
20
|
+
getInput?: (args: unknown) => { path?: string } | null,
|
|
21
|
+
toolName?: string,
|
|
22
|
+
) {
|
|
23
|
+
return makeRenderCall(preview, { getInput, toolName });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const editToolBase = {
|
|
27
|
+
prepareArguments: editPrepare,
|
|
28
|
+
executionMode: "sequential" as const,
|
|
29
|
+
renderShell: "default" as const,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export async function queuedEdit<T>(
|
|
33
|
+
path: string,
|
|
34
|
+
cwd: string,
|
|
35
|
+
signal: AbortSignal | undefined,
|
|
36
|
+
work: (absolute: string, resolved: string) => Promise<T>,
|
|
37
|
+
): Promise<T> {
|
|
38
|
+
abortIf(signal);
|
|
39
|
+
const { absolute, resolved } = await resolveInCwd(path, cwd);
|
|
40
|
+
return withFileMutationQueue(resolved, async () => {
|
|
41
|
+
abortIf(signal);
|
|
42
|
+
return work(absolute, resolved);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
package/src/file-kind.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { open as fsOpen, stat as fsStat } from "fs/promises";
|
|
|
2
2
|
import { fileTypeFromBuffer } from "file-type";
|
|
3
3
|
import { SNIFF_BYTES, MAX_BYTES } from "./constants";
|
|
4
4
|
import { assertLineLimit, lineLimitMoreThanMessage } from "./utils";
|
|
5
|
+
import type { FileIdentity } from "./fs-write";
|
|
5
6
|
|
|
6
7
|
const IMG_TYPES = new Set<string>([
|
|
7
8
|
"image/bmp",
|
|
@@ -54,7 +55,7 @@ function looksLikeText(sample: Uint8Array): boolean {
|
|
|
54
55
|
export type LFile =
|
|
55
56
|
| { kind: "directory" }
|
|
56
57
|
| { kind: "image"; mimeType: string }
|
|
57
|
-
| { kind: "text"; text: string; hadUtf8DecodeErrors?: true }
|
|
58
|
+
| { kind: "text"; text: string; identity?: FileIdentity; hadUtf8DecodeErrors?: true }
|
|
58
59
|
| { kind: "binary"; description: string }
|
|
59
60
|
| { kind: "too_large"; description: string };
|
|
60
61
|
|
|
@@ -87,6 +88,8 @@ export async function loadFileKindAndText(
|
|
|
87
88
|
|
|
88
89
|
const fileHandle = await fsOpen(filePath, "r");
|
|
89
90
|
try {
|
|
91
|
+
const openedStats = await fileHandle.stat();
|
|
92
|
+
const identity = { dev: openedStats.dev, ino: openedStats.ino };
|
|
90
93
|
const buffer = Buffer.alloc(SNIFF_BYTES);
|
|
91
94
|
const { bytesRead } = await fileHandle.read(
|
|
92
95
|
buffer,
|
|
@@ -95,7 +98,7 @@ export async function loadFileKindAndText(
|
|
|
95
98
|
0,
|
|
96
99
|
);
|
|
97
100
|
if (bytesRead === 0) {
|
|
98
|
-
return { kind: "text", text: "" };
|
|
101
|
+
return { kind: "text", text: "", identity };
|
|
99
102
|
}
|
|
100
103
|
|
|
101
104
|
const sample = buffer.subarray(0, bytesRead);
|
|
@@ -179,6 +182,7 @@ export async function loadFileKindAndText(
|
|
|
179
182
|
return {
|
|
180
183
|
kind: "text",
|
|
181
184
|
text,
|
|
185
|
+
identity,
|
|
182
186
|
...(hadUtf8DecodeErrors ? { hadUtf8DecodeErrors: true as const } : {}),
|
|
183
187
|
};
|
|
184
188
|
} finally {
|
package/src/file-reader.ts
CHANGED
|
@@ -3,9 +3,9 @@ import { stat } from "fs/promises";
|
|
|
3
3
|
import { relative } from "path";
|
|
4
4
|
import { lineHashes } from "./hashline";
|
|
5
5
|
import { loadFileKindAndText, type LFile } from "./file-kind";
|
|
6
|
-
import { resolveTarget } from "./fs-write";
|
|
6
|
+
import { resolveTarget, type FileIdentity } from "./fs-write";
|
|
7
7
|
import { toCwd } from "./paths";
|
|
8
|
-
import { detectEnding, toLF, stripBOM, type LineEnding } from "./
|
|
8
|
+
import { detectEnding, toLF, stripBOM, type LineEnding } from "./normalize";
|
|
9
9
|
import { abortIf, errCode, assertLineLimit } from "./utils";
|
|
10
10
|
import { valKind, valAccess } from "./validation";
|
|
11
11
|
import type { HashStore } from "./hash-store";
|
|
@@ -16,6 +16,7 @@ export interface NormFile {
|
|
|
16
16
|
originalEnding: LineEnding;
|
|
17
17
|
fileHashes: string[];
|
|
18
18
|
hadUtf8DecodeErrors: boolean;
|
|
19
|
+
identity: FileIdentity;
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
export type SnapInfo = {
|
|
@@ -94,12 +95,18 @@ export async function readNormFile(
|
|
|
94
95
|
if (options?.maxLines !== undefined) assertLineLimit(normalized, path, options.maxLines);
|
|
95
96
|
|
|
96
97
|
const fileHashes = await lineHashes(normalized, resolvedPath, undefined, options?.store, options?.noPersist !== true);
|
|
98
|
+
let identity = file.identity;
|
|
99
|
+
if (!identity) {
|
|
100
|
+
const { dev, ino } = await stat(resolvedPath);
|
|
101
|
+
identity = { dev, ino };
|
|
102
|
+
}
|
|
97
103
|
return {
|
|
98
104
|
absolutePath: resolvedPath,
|
|
99
105
|
normalized,
|
|
100
106
|
bom,
|
|
101
107
|
originalEnding,
|
|
102
108
|
fileHashes,
|
|
109
|
+
identity,
|
|
103
110
|
hadUtf8DecodeErrors: file.hadUtf8DecodeErrors === true,
|
|
104
111
|
};
|
|
105
112
|
}
|
package/src/fs-write.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "crypto";
|
|
2
|
+
import { constants } from "fs";
|
|
2
3
|
import {
|
|
3
|
-
chmod,
|
|
4
4
|
lstat,
|
|
5
5
|
mkdir,
|
|
6
6
|
open,
|
|
@@ -15,6 +15,22 @@ import { dirname, join, parse, resolve, sep } from "path";
|
|
|
15
15
|
import { toCwd } from "./paths";
|
|
16
16
|
import { errCode } from "./utils";
|
|
17
17
|
|
|
18
|
+
export interface FileIdentity {
|
|
19
|
+
dev: number;
|
|
20
|
+
ino: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sameIdentity(
|
|
24
|
+
actual: Pick<Awaited<ReturnType<typeof stat>>, "dev" | "ino">,
|
|
25
|
+
expected: FileIdentity,
|
|
26
|
+
): boolean {
|
|
27
|
+
return actual.dev === expected.dev && actual.ino === expected.ino;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pathChanged(path: string): Error {
|
|
31
|
+
return new Error(`[E_PATH_CHANGED] Refusing to write ${path}: the target changed after it was read.`);
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
export async function resolveTarget(path: string): Promise<string> {
|
|
19
35
|
const absolutePath = resolve(path);
|
|
20
36
|
const { root } = parse(absolutePath);
|
|
@@ -128,6 +144,7 @@ export async function resolveInCwd(path: string, cwd: string): Promise<{ absolut
|
|
|
128
144
|
export async function writeAtomic(
|
|
129
145
|
path: string,
|
|
130
146
|
content: string,
|
|
147
|
+
expectedIdentity?: FileIdentity,
|
|
131
148
|
): Promise<void> {
|
|
132
149
|
const targetPath = await resolveTarget(path);
|
|
133
150
|
|
|
@@ -140,19 +157,25 @@ export async function writeAtomic(
|
|
|
140
157
|
}
|
|
141
158
|
}
|
|
142
159
|
|
|
160
|
+
if (expectedIdentity && (!existingStats || !sameIdentity(existingStats, expectedIdentity))) {
|
|
161
|
+
throw pathChanged(path);
|
|
162
|
+
}
|
|
163
|
+
|
|
143
164
|
if (existingStats && existingStats.nlink > 1) {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
await chmod(targetPath, existingStats.mode & 0o7777);
|
|
147
|
-
} catch {}
|
|
165
|
+
const noFollow = process.platform === "win32" ? 0 : constants.O_NOFOLLOW;
|
|
166
|
+
const handle = await open(targetPath, constants.O_WRONLY | noFollow);
|
|
148
167
|
try {
|
|
149
|
-
const
|
|
168
|
+
const openedStats = await handle.stat();
|
|
169
|
+
if (!sameIdentity(openedStats, existingStats)) throw pathChanged(path);
|
|
170
|
+
await handle.writeFile(content, "utf-8");
|
|
171
|
+
await handle.truncate(Buffer.byteLength(content, "utf-8"));
|
|
150
172
|
try {
|
|
151
|
-
await handle.
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
173
|
+
await handle.chmod(existingStats.mode & 0o7777);
|
|
174
|
+
} catch {}
|
|
175
|
+
await handle.sync();
|
|
176
|
+
} finally {
|
|
177
|
+
await handle.close();
|
|
178
|
+
}
|
|
156
179
|
return;
|
|
157
180
|
}
|
|
158
181
|
|
|
@@ -174,6 +197,14 @@ export async function writeAtomic(
|
|
|
174
197
|
}
|
|
175
198
|
try {
|
|
176
199
|
await tempHandle.close();
|
|
200
|
+
try {
|
|
201
|
+
const finalStats = await lstat(targetPath);
|
|
202
|
+
if (!existingStats || finalStats.isSymbolicLink() || !sameIdentity(finalStats, existingStats)) {
|
|
203
|
+
throw pathChanged(path);
|
|
204
|
+
}
|
|
205
|
+
} catch (error) {
|
|
206
|
+
if (errCode(error) !== "ENOENT" || existingStats) throw error;
|
|
207
|
+
}
|
|
177
208
|
await rename(tempPath, targetPath);
|
|
178
209
|
await syncDir(dir);
|
|
179
210
|
} catch (error: unknown) {
|