pi-hashline-edit-pro 0.16.15 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -94,9 +94,11 @@ Replaces using the `HASH│content` anchors from `read` output to target lines p
94
94
 
95
95
  ### Stable hashing across edits
96
96
 
97
- Hashes are now computed with a persistent store (`~/.config/pi-hashline-edit-pro/hash-store.json`) that preserves hashes for unchanged lines across edits. When you replace lines in a file, the runtime maps the old content against the new content and copies hashes for unchanged lines to their new positions. This means editing one part of a file does not change the hashes of unrelated lines elsewhere — the model can keep using previously seen anchors for untouched regions.
97
+ Hashes are now computed with a persistent store (`~/.config/pi-hashline-edit-pro/hash-store.sqlite`) that preserves hashes for unchanged lines across edits. When you replace lines in a file, the runtime maps the old content against the new content and copies hashes for unchanged lines to their new positions. This means editing one part of a file does not change the hashes of unrelated lines elsewhere — the model can keep using previously seen anchors for untouched regions.
98
98
 
99
- The store contains per-file snapshots: the last known content and hashes for each file. On read, if the file content matches the snapshot, the saved hashes are returned immediately. Stale snapshots (for files that no longer exist) are pruned on session start.
99
+ The store is a SQLite database (WAL journal mode) keyed by canonical file path. Each snapshot stores a 64-bit content checksum (`xxhash64`) plus the per-line hashes, not the full text, so a cache hit is a single keyed lookup and a one-row write. Reads, replaces, undo, and pruning all share one transactional store, so concurrent Pi sessions editing different files never silently clobber each other's snapshots (per-path writers serialize via `BEGIN IMMEDIATE`; same-path concurrent edits still fail safe — stale anchors are rejected by content matching). Stale snapshots (for files that no longer exist) are pruned on session start.
100
+
101
+ On first run after upgrading, a one-time migration imports the previous `hash-store.json` into the database and renames the old file to `hash-store.json.bak`; the old JSON store is otherwise discarded.
100
102
 
101
103
  ### Chained edits
102
104
 
@@ -145,7 +147,7 @@ The file is created automatically when any setting is toggled. Both fields are i
145
147
  - **Per-file mutation queue.** Edits queue by the canonical write target, so concurrent edits through different symlink paths still serialize onto the same underlying file.
146
148
  - **Boundary duplication auto-fix.** When the last line of a replacement matches the next surviving line (or the first line matches the preceding one), the runtime automatically strips the duplicate from `content_lines` before applying the edit. This catches a common LLM pattern where closing delimiters like `}`, `});`, or `} else {` are accidentally duplicated. The auto-fix is completely silent — the model sees a normal successful edit. The duplicate never reaches the file. Raw line comparison (not trimmed) avoids false positives when indentation differs.
147
149
  - **Flat mode normalization.** When flat mode is active, the tool's `execute` function wraps the top-level `hash_range_inclusive` and `content_lines` into a single-element `changes` array internally, then runs the same pipeline as bulk mode. The `normReq` function in `replace-normalize.ts` also handles flat format directly, so any code path that normalizes input (e.g. `compPreview`) works with both formats.
148
- - **Persistent hash store.** `lineHashes` is async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is at `~/.config/pi-hashline-edit-pro/hash-store.json` and is auto-created on first use. It contains per-file snapshots (last known content+hashes). When called from the replace pipeline, it maps old vs new content and copies hashes for unchanged lines. When called from read, it returns saved hashes if the content matches, otherwise computes fresh hashes via `_lineHashesPure`. Stale snapshots are pruned on session start. This ensures that editing one part of a file does not cascade to change hashes of unrelated lines.
150
+ - **Persistent hash store.** `lineHashes` is async and uses a persistent store to preserve hashes for unchanged lines across edits. The store is a SQLite database at `~/.config/pi-hashline-edit-pro/hash-store.sqlite` (per-path snapshots keyed by resolved path storing a 64-bit content checksum + line hashes; auto-created on first use). When called from the replace pipeline, it maps old vs new content and copies hashes for unchanged lines. When called from read, it returns saved hashes if the content's checksum matches, otherwise computes fresh hashes via `_lineHashesPure`. Stale snapshots are pruned on session start. This ensures that editing one part of a file does not cascade to change hashes of unrelated lines. Per-operation work scales with the target file, not cumulative history.
149
151
  ## Hashing
150
152
 
151
153
  Hashes are computed with [xxhash-wasm](https://github.com/jungomi/xxhash-wasm) (xxHash32 via WebAssembly), then mapped to a 3-character string from the URL-safe base64 alphabet `A-Za-z0-9-_`. That's 64 distinct characters, 6 bits per position, 18 bits of entropy per anchor.
package/index.ts CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  toggleReplaceMode,
11
11
  toggleAutoRead,
12
12
  } from "./src/config";
13
- import { loadHashStore, pruneHashStore } from "./src/hash-store";
13
+ import { loadHashStore, pruneMissing } from "./src/hash-store";
14
14
  import { readNormFile } from "./src/file-reader";
15
15
 
16
16
  export default function (pi: ExtensionAPI): void {
@@ -36,7 +36,7 @@ function registerReplaceTool(pi: ExtensionAPI, mode: string, autoRead?: boolean)
36
36
  await initHasher();
37
37
  try {
38
38
  const store = await loadHashStore();
39
- await pruneHashStore(store);
39
+ await pruneMissing(store);
40
40
  } catch (err) {
41
41
  console.error("Failed to load or prune hash store:", err);
42
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.16.15",
3
+ "version": "0.17.0",
4
4
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
5
5
  "main": "index.ts",
6
6
  "repository": {
@@ -31,6 +31,7 @@
31
31
  ]
32
32
  },
33
33
  "dependencies": {
34
+ "better-sqlite3": "^13.0.1",
34
35
  "diff": "^8.0.2",
35
36
  "file-type": "^21.3.0",
36
37
  "xxhash-wasm": "^1.1.0"
@@ -46,8 +47,12 @@
46
47
  },
47
48
  "devDependencies": {
48
49
  "@earendil-works/pi-coding-agent": "^0.74.0",
50
+ "@types/better-sqlite3": "^7.6.13",
49
51
  "@types/node": "^22.0.0",
50
- "vitest": "^4.1.8",
51
- "typescript": "^5.8.0"
52
+ "typescript": "^5.8.0",
53
+ "vitest": "^4.1.8"
54
+ },
55
+ "allowScripts": {
56
+ "better-sqlite3@13.0.1": true
52
57
  }
53
58
  }
package/src/constants.ts CHANGED
@@ -5,6 +5,9 @@ export const MAX_BYTES = 100 * 1024 * 1024;
5
5
  export const MAX_HASH_LINES = 1_000_000;
6
6
  export const MAX_HASH_RETRIES = 262_144;
7
7
 
8
+ export const HASH_STORE_VERSION = 2;
9
+ export const HASH_STORE_BUSY_TIMEOUT = 1000;
10
+
8
11
  export const CONTENT_LINES_NOT_STRING_MSG =
9
12
  `[E_BAD_SHAPE] "content_lines" must be a native JSON array of strings, not a JSON string.`
10
13
  + ` Do not serialize the array (e.g. '["line1", "line2"]') — pass it as a proper JSON array: ["line1", "line2"].`;
package/src/hash-store.ts CHANGED
@@ -1,72 +1,191 @@
1
- import { readFile, mkdir } from "fs/promises";
2
- import { stat } from "fs/promises";
3
- import { hashStorePath, hashStoreDir } from "./paths";
1
+ import Database from "better-sqlite3";
2
+ import type { Database as DatabaseType, Statement } from "better-sqlite3";
3
+ import { existsSync } from "fs";
4
+ import { readFile, rename, mkdir, stat } from "fs/promises";
5
+ import { hashStorePath, hashStoreDir, legacyHashStorePath } from "./paths";
4
6
  import { errCode } from "./utils";
5
- import { writeAtomic } from "./fs-write";
6
- function isValidSnapshot(value: unknown): value is FileSnapshot {
7
- if (typeof value !== "object" || value === null) return false;
8
- const v = value as Record<string, unknown>;
9
- if (typeof v.content !== "string") return false;
10
- if (!Array.isArray(v.hashes)) return false;
11
- for (const h of v.hashes) {
12
- if (typeof h !== "string") return false;
13
- }
14
- return true;
15
- }
16
- export interface FileSnapshot {
17
- content: string;
18
- hashes: string[];
7
+ import { initHasher, contentChecksum } from "./hashline/hasher";
8
+ import { HASH_STORE_VERSION, HASH_STORE_BUSY_TIMEOUT } from "./constants";
9
+
10
+ interface Prepared {
11
+ get: Statement<[string, string, number], { hashes: string }>;
12
+ allPaths: Statement<[], { path: string }>;
13
+ deleteOne: Statement<[string]>;
14
+ upsert: Statement<[string, string, number, string, number]>;
19
15
  }
20
16
 
21
17
  export interface HashStore {
22
- version: 1;
23
- snapshots: Record<string, FileSnapshot>;
18
+ readonly db: DatabaseType;
19
+ readonly stmts: Prepared;
20
+ }
21
+
22
+ interface LegacySnapshot {
23
+ content: string;
24
+ hashes: string[];
25
+ }
26
+
27
+ function isValidSnapshot(value: unknown): value is LegacySnapshot {
28
+ if (typeof value !== "object" || value === null) return false;
29
+ const v = value as Record<string, unknown>;
30
+ if (typeof v.content !== "string") return false;
31
+ if (!Array.isArray(v.hashes)) return false;
32
+ for (const h of v.hashes) {
33
+ if (typeof h !== "string") return false;
34
+ }
35
+ return true;
36
+ }
37
+
38
+ let cachedStore: { path: string; store: HashStore } | null = null;
39
+
40
+ function openDatabase(storePath: string): HashStore {
41
+ const db = new Database(storePath);
42
+ db.pragma("journal_mode = WAL");
43
+ db.pragma("synchronous = NORMAL");
44
+ db.pragma(`busy_timeout = ${HASH_STORE_BUSY_TIMEOUT}`);
45
+ db.exec(`
46
+ CREATE TABLE IF NOT EXISTS snapshots (
47
+ path TEXT PRIMARY KEY,
48
+ checksum TEXT NOT NULL,
49
+ line_count INTEGER NOT NULL,
50
+ hashes TEXT NOT NULL,
51
+ updated_at INTEGER NOT NULL
52
+ );
53
+ `);
54
+ const stmts: Prepared = {
55
+ get: db.prepare<[string, string, number], { hashes: string }>(
56
+ "SELECT hashes FROM snapshots WHERE path = ? AND checksum = ? AND line_count = ?",
57
+ ),
58
+ allPaths: db.prepare<[], { path: string }>("SELECT path FROM snapshots"),
59
+ deleteOne: db.prepare<[string]>("DELETE FROM snapshots WHERE path = ?"),
60
+ upsert: db.prepare<[string, string, number, string, number]>(
61
+ "INSERT INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?) " +
62
+ "ON CONFLICT(path) DO UPDATE SET checksum = excluded.checksum, line_count = excluded.line_count, hashes = excluded.hashes, updated_at = excluded.updated_at",
63
+ ),
64
+ };
65
+ return { db, stmts };
66
+ }
67
+
68
+ async function migrateLegacy(store: HashStore): Promise<void> {
69
+ const legacyPath = legacyHashStorePath();
70
+ let content: string;
71
+ try {
72
+ content = await readFile(legacyPath, "utf-8");
73
+ } catch (error: unknown) {
74
+ if (errCode(error) === "ENOENT") return;
75
+ console.error("Failed to read legacy hash store for migration:", error);
76
+ return;
77
+ }
78
+
79
+ let parsed: { snapshots?: Record<string, unknown> };
80
+ try {
81
+ parsed = JSON.parse(content) as typeof parsed;
82
+ } catch (error) {
83
+ console.error("Failed to parse legacy hash store, skipping migration:", error);
84
+ return;
85
+ }
86
+
87
+ const raw = parsed.snapshots;
88
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return;
89
+
90
+ const insert = store.db.prepare(
91
+ "INSERT OR REPLACE INTO snapshots (path, checksum, line_count, hashes, updated_at) VALUES (?, ?, ?, ?, ?)",
92
+ );
93
+ const rows: [string, string, number, string, number][] = [];
94
+ for (const [key, value] of Object.entries(raw)) {
95
+ if (!isValidSnapshot(value)) continue;
96
+ rows.push([
97
+ key,
98
+ contentChecksum(value.content),
99
+ value.content.split("\n").length,
100
+ JSON.stringify(value.hashes),
101
+ Date.now(),
102
+ ]);
103
+ }
104
+
105
+ if (rows.length > 0) {
106
+ store.db.transaction(() => {
107
+ for (const row of rows) insert.run(...row);
108
+ }).immediate();
109
+ }
110
+
111
+ try {
112
+ await rename(legacyPath, `${legacyPath}.bak`);
113
+ } catch (error) {
114
+ console.error("Failed to rename legacy hash store after migration:", error);
115
+ }
24
116
  }
25
117
 
26
118
  export async function loadHashStore(): Promise<HashStore> {
27
- try {
28
- const content = await readFile(hashStorePath(), "utf-8");
29
- const parsed = JSON.parse(content) as Partial<HashStore>;
30
- const raw = parsed.snapshots;
31
- const snapshots: Record<string, FileSnapshot> = {};
32
- if (raw && typeof raw === "object" && !Array.isArray(raw)) {
33
- for (const [key, value] of Object.entries(raw)) {
34
- if (isValidSnapshot(value)) {
35
- snapshots[key] = value;
36
- }
37
- }
38
- }
39
- return { version: 1, snapshots };
40
- } catch (error: unknown) {
41
- if (errCode(error) !== "ENOENT") {
42
- console.error("Hash store corrupted, creating fresh store:", error);
43
- }
44
- await mkdir(hashStoreDir(), { recursive: true });
45
- const defaultStore: HashStore = {
46
- version: 1,
47
- snapshots: {},
48
- };
49
- await writeAtomic(hashStorePath(), JSON.stringify(defaultStore));
50
- return defaultStore;
51
- }
119
+ const storePath = hashStorePath();
120
+ if (cachedStore && cachedStore.path === storePath) {
121
+ return cachedStore.store;
122
+ }
123
+
124
+ shutdownHashStore();
125
+
126
+ await initHasher();
127
+ await mkdir(hashStoreDir(), { recursive: true });
128
+
129
+ const existed = existsSync(storePath);
130
+ const store = openDatabase(storePath);
131
+ if (!existed) {
132
+ await migrateLegacy(store);
133
+ }
134
+ cachedStore = { path: storePath, store };
135
+ return store;
52
136
  }
53
137
 
54
- export async function saveHashStore(store: HashStore): Promise<void> {
55
- await mkdir(hashStoreDir(), { recursive: true });
56
- await writeAtomic(hashStorePath(), JSON.stringify(store, null, 2));
138
+ export function shutdownHashStore(): void {
139
+ if (cachedStore) {
140
+ cachedStore.store.db.close();
141
+ cachedStore = null;
142
+ }
57
143
  }
58
144
 
59
- export async function pruneHashStore(store: HashStore): Promise<void> {
60
- let changed = false;
61
- for (const filePath of Object.keys(store.snapshots)) {
62
- try {
63
- await stat(filePath);
64
- } catch {
65
- delete store.snapshots[filePath];
66
- changed = true;
67
- }
68
- }
69
- if (changed) {
70
- await saveHashStore(store);
71
- }
145
+ export function getSnapshot(
146
+ store: HashStore,
147
+ path: string,
148
+ content: string,
149
+ ): string[] | undefined {
150
+ const checksum = contentChecksum(content);
151
+ const lineCount = content.split("\n").length;
152
+ const row = store.stmts.get.get(path, checksum, lineCount);
153
+ return row ? (JSON.parse(row.hashes) as string[]) : undefined;
72
154
  }
155
+
156
+ export function upsertSnapshot(
157
+ store: HashStore,
158
+ path: string,
159
+ checksum: string,
160
+ lineCount: number,
161
+ hashes: string[],
162
+ ): void {
163
+ const hashesJson = JSON.stringify(hashes);
164
+ store.db.transaction(() => {
165
+ store.stmts.upsert.run(path, checksum, lineCount, hashesJson, Date.now());
166
+ }).immediate();
167
+ }
168
+
169
+ export function deleteSnapshot(store: HashStore, path: string): void {
170
+ store.db.transaction(() => {
171
+ store.stmts.deleteOne.run(path);
172
+ }).immediate();
173
+ }
174
+
175
+ export async function pruneMissing(store: HashStore): Promise<void> {
176
+ const rows = store.stmts.allPaths.all();
177
+ const missing: string[] = [];
178
+ for (const row of rows) {
179
+ try {
180
+ await stat(row.path);
181
+ } catch {
182
+ missing.push(row.path);
183
+ }
184
+ }
185
+ if (missing.length === 0) return;
186
+ store.db.transaction(() => {
187
+ for (const path of missing) store.stmts.deleteOne.run(path);
188
+ }).immediate();
189
+ }
190
+
191
+ export { HASH_STORE_VERSION };
@@ -51,7 +51,7 @@ type RESpan = {
51
51
  function assertNotEmpty(originalContent: string, result: string): void {
52
52
  if (originalContent.length > 0 && result.length === 0) {
53
53
  throw new Error(
54
- "[E_WOULD_EMPTY] Cannot empty a non-empty file via edit."
54
+ "[E_WOULD_EMPTY] Cannot empty a non-empty file via edit. Use `write` if you need to clear the file."
55
55
  );
56
56
  }
57
57
  }
@@ -1,7 +1,13 @@
1
- import xxhash from "xxhash-wasm";
2
- import * as Diff from "diff";
3
- import { loadHashStore, saveHashStore, type HashStore } from "../hash-store";
4
1
  import { MAX_HASH_RETRIES } from "../constants";
2
+ import {
3
+ loadHashStore,
4
+ type HashStore,
5
+ getSnapshot,
6
+ upsertSnapshot,
7
+ } from "../hash-store";
8
+ import { xxh32, contentChecksum, initHasher } from "./hasher";
9
+
10
+ export { initHasher };
5
11
 
6
12
  export const HASH_LEN = 3;
7
13
  export const ANCHOR_LEN = HASH_LEN;
@@ -41,30 +47,6 @@ export const DIFF_MINUS_RE = /^-\s*\d+\s{4}/;
41
47
 
42
48
  export const HL_BARE_PREFIX_RE = new RegExp(`^\\s*(${HASH_CLASS})│`);
43
49
 
44
- type Hasher = { h32(input: string, seed?: number): number };
45
- let hasherP: Promise<Hasher> | null = null;
46
- let hasher: Hasher | null = null;
47
-
48
- function getH(): Hasher {
49
- if (hasher) return hasher;
50
- throw new Error("xxhash-wasm not initialized yet. This should not happen.");
51
- }
52
-
53
- hasherP = xxhash().then((h) => {
54
- hasher = h;
55
- return h;
56
- }).catch((err) => {
57
- console.error("xxhash-wasm initialization failed:", err);
58
- throw err;
59
- });
60
-
61
- export function initHasher(): Promise<Hasher> {
62
- return hasherP!
63
- }
64
-
65
- function xxh32(input: string, seed = 0): number {
66
- return getH().h32(input, seed) >>> 0;
67
- }
68
50
 
69
51
  function canon(line: string): string {
70
52
  return line.replace(/\r/g, "").trimEnd();
@@ -99,6 +81,7 @@ export async function lineHashes(
99
81
  path?: string,
100
82
  previous?: { content: string; hashes: string[]; removedHashes?: Set<string> },
101
83
  store?: HashStore,
84
+ persist?: boolean,
102
85
  ): Promise<string[]> {
103
86
  if (!path) {
104
87
  return _lineHashesPure(content);
@@ -112,19 +95,21 @@ export async function lineHashes(
112
95
  content,
113
96
  previous.removedHashes,
114
97
  );
115
- hashStore.snapshots[path] = { content, hashes: newHashes };
116
- await saveHashStore(hashStore);
98
+ if (persist !== false) {
99
+ upsertSnapshot(hashStore, path, contentChecksum(content), content.split("\n").length, newHashes);
100
+ }
117
101
  return newHashes;
118
102
  }
119
103
 
120
- const snapshot = hashStore.snapshots[path];
121
- if (snapshot && snapshot.content === content) {
122
- return snapshot.hashes;
104
+ const cached = getSnapshot(hashStore, path, content);
105
+ if (cached) {
106
+ return cached;
123
107
  }
124
108
 
125
109
  const newHashes = _lineHashesPure(content);
126
- hashStore.snapshots[path] = { content, hashes: newHashes };
127
- await saveHashStore(hashStore);
110
+ if (persist !== false) {
111
+ upsertSnapshot(hashStore, path, contentChecksum(content), content.split("\n").length, newHashes);
112
+ }
128
113
  return newHashes;
129
114
  }
130
115
 
@@ -0,0 +1,33 @@
1
+ import xxhash from "xxhash-wasm";
2
+
3
+ export type Hasher = {
4
+ h32(input: string, seed?: number): number;
5
+ h64ToString(input: string, seed?: bigint): string;
6
+ };
7
+
8
+ let hasher: Hasher | null = null;
9
+
10
+ export function getH(): Hasher {
11
+ if (hasher) return hasher;
12
+ throw new Error("xxhash-wasm not initialized yet. This should not happen.");
13
+ }
14
+
15
+ const hasherP: Promise<Hasher> = xxhash().then((h) => {
16
+ hasher = h as unknown as Hasher;
17
+ return hasher;
18
+ }).catch((err: unknown) => {
19
+ console.error("xxhash-wasm initialization failed:", err);
20
+ throw err;
21
+ });
22
+
23
+ export function initHasher(): Promise<Hasher> {
24
+ return hasherP;
25
+ }
26
+
27
+ export function xxh32(input: string, seed = 0): number {
28
+ return getH().h32(input, seed) >>> 0;
29
+ }
30
+
31
+ export function contentChecksum(content: string): string {
32
+ return getH().h64ToString(content);
33
+ }
package/src/paths.ts CHANGED
@@ -2,8 +2,13 @@ import { homedir } from "os";
2
2
  import { isAbsolute, resolve as resolvePath, join, dirname } from "path";
3
3
 
4
4
 
5
+ function homeBase(): string {
6
+ const envHome = process.env.HOME;
7
+ return envHome && envHome.length > 0 ? envHome : homedir();
8
+ }
9
+
5
10
  export function configDir(): string {
6
- return join(homedir(), ".config", "pi-hashline-edit-pro");
11
+ return join(homeBase(), ".config", "pi-hashline-edit-pro");
7
12
  }
8
13
 
9
14
  export function configPath(): string {
@@ -11,6 +16,10 @@ export function configPath(): string {
11
16
  }
12
17
 
13
18
  export function hashStorePath(): string {
19
+ return join(configDir(), "hash-store.sqlite");
20
+ }
21
+
22
+ export function legacyHashStorePath(): string {
14
23
  return join(configDir(), "hash-store.json");
15
24
  }
16
25
 
@@ -19,8 +28,9 @@ export function hashStoreDir(): string {
19
28
  }
20
29
 
21
30
  function expand(filePath: string): string {
22
- if (filePath === "~") return homedir();
23
- if (filePath.startsWith("~/")) return homedir() + filePath.slice(1);
31
+ const home = homeBase();
32
+ if (filePath === "~") return home;
33
+ if (filePath.startsWith("~/")) return home + filePath.slice(1);
24
34
  return filePath;
25
35
  }
26
36
 
@@ -2,7 +2,8 @@ import { readFile } from "fs/promises";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
4
  import { Type } from "typebox";
5
- import { loadHashStore, saveHashStore } from "./hash-store";
5
+ import { loadHashStore, upsertSnapshot } from "./hash-store";
6
+ import { contentChecksum } from "./hashline/hasher";
6
7
  import { resolveTarget, writeAtomic } from "./fs-write";
7
8
  import { toCwd } from "./paths";
8
9
  import { toLF, stripBOM, genDiff, restoreEndings } from "./replace-diff";
@@ -82,11 +83,7 @@ export function regReplaceUndo(pi: ExtensionAPI): void {
82
83
  );
83
84
 
84
85
  const store = await loadHashStore();
85
- store.snapshots[mutationTargetPath] = {
86
- content: undo.content,
87
- hashes: undo.hashes,
88
- };
89
- await saveHashStore(store);
86
+ upsertSnapshot(store, mutationTargetPath, contentChecksum(undo.content), undo.content.split("\n").length, undo.hashes);
90
87
 
91
88
  clearUndo(mutationTargetPath);
92
89
 
package/src/replace.ts CHANGED
@@ -154,6 +154,7 @@ export async function execPipeline(
154
154
  accessMode: number,
155
155
  signal?: AbortSignal,
156
156
  store?: HashStore,
157
+ noPersist?: boolean,
157
158
  ): Promise<PipelineResult> {
158
159
 
159
160
  const path = params.path;
@@ -199,7 +200,7 @@ export async function execPipeline(
199
200
  content: originalNormalized,
200
201
  hashes: originalHashes,
201
202
  removedHashes,
202
- }, hashStore);
203
+ }, hashStore, noPersist !== true);
203
204
 
204
205
  const warnings = [...(anchorResult.warnings ?? [])];
205
206
 
@@ -248,6 +249,9 @@ export async function compPreview(
248
249
  normalized,
249
250
  cwd,
250
251
  constants.R_OK,
252
+ undefined,
253
+ undefined,
254
+ true,
251
255
  );
252
256
 
253
257
  if (originalNormalized === result) {