pi-hashline-edit-pro 0.18.2 → 0.18.4

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,7 +94,7 @@ 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.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.
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. A replace that produces identical content (a no-op, reported as "No changes made") never rotates hashes: no file change means no anchor change, so previously read anchors remain valid after a no-op.
98
98
 
99
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
100
 
@@ -138,6 +138,22 @@ Settings are stored in `~/.config/pi-hashline-edit-pro/config.json`:
138
138
 
139
139
  The file is created automatically when any setting is toggled. Both fields are independent — toggling one never clobbers the other.
140
140
 
141
+ ### Error codes
142
+
143
+ | Code | Meaning |
144
+ | --- | --- |
145
+ | `[E_BAD_SHAPE]` | Request envelope or edit item has unknown, missing, or wrongly-typed fields. |
146
+ | `[E_BAD_REF]` | An anchor in `hash_range_inclusive` is not a bare 3-char hash. |
147
+ | `[E_STALE_ANCHOR]` | An anchor does not match any line in the current file; call `read` for fresh anchors. |
148
+ | `[E_AMBIGUOUS_ANCHOR]` | An anchor matches multiple lines; call `read` for fresh anchors. |
149
+ | `[E_INVALID_PATCH]` | `content_lines` contains diff-preview rows (`+HASH│`, `-HASH│`, `- │`, `-N `). |
150
+ | `[E_BARE_HASH_PREFIX]` | A `content_lines` entry starts with a hash-like `HASH│` prefix. |
151
+ | `[E_LEGACY_SHAPE]` | The request uses the unsupported `oldText`/`newText` dialect. |
152
+ | `[E_BAD_OP]` | Range start line is after range end line. |
153
+ | `[E_EDIT_CONFLICT]` | Two edits in one batch overlap the same original lines. |
154
+ | `[E_WOULD_EMPTY]` | An edit would empty a non-empty file; use `write` instead. |
155
+ | `[E_FILE_TOO_LARGE]` | The file exceeds the 262,144-line hashline limit. |
156
+
141
157
  ## Design Decisions
142
158
 
143
159
  - **Stale anchors fail (per-line).** A hash mismatch means that specific line's content changed since the last `read`; the error tells the model to call `read()` to get fresh anchors, then copy the 3-character HASH of the start and end of the range being replaced into `hash_range_inclusive` of the next replace call. Because staleness is per-line, editing or appending lines does **not** invalidate anchors for lines whose content is unchanged — anchors for untouched regions stay valid across edits to other regions.
@@ -149,7 +165,7 @@ The file is created automatically when any setting is toggled. Both fields are i
149
165
  - **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.
150
166
  - **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.
151
167
  - **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.
152
- - **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.
168
+ - **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. If the database is corrupt or unreadable it is quarantined (renamed to `hash-store.sqlite.corrupt-<timestamp>`) and rebuilt from content on the next session start — the store is a cache, never a source of truth.
153
169
  ## Hashing
154
170
 
155
171
  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.
@@ -162,6 +178,8 @@ Before hashing, each line is normalized: carriage returns are stripped and trail
162
178
  The runtime always precomputes the full per-line hash array for a file via `lineHashes(content, path)`, then looks up by line number during validation and during `read` / `replace` response formatting. There is no per-line recomputation that could disagree with what the model saw in its last read. When `path` is provided, `lineHashes` uses a persistent store to preserve hashes for unchanged lines across edits — see [Stable hashing across edits](#stable-hashing-across-edits).
163
179
  `HASH_LEN` in `src/hashline/hash.ts` sets the hash body length; bump it to 4 if you need even more entropy without collision resolution.
164
180
 
181
+ The 3-character space holds 262,144 unique anchors, so files are capped at 262,144 lines: `read` and `replace` reject larger files with `[E_FILE_TOO_LARGE]` (use `write` or a non-line-based approach for very large files).
182
+
165
183
  ### Bare-prefix detector
166
184
 
167
185
  With the `│` delimiter format, the bare-prefix detector regex `^\s*([A-Za-z0-9_\-]{3})│` is highly specific. It only matches lines starting with a hash-like prefix. This eliminates false positives from common code patterns like `init:`, `data:`, `else:`, etc. The detector rejects edit lines matching this pattern with `[E_BARE_HASH_PREFIX]` to prevent the model from accidentally pasting hash anchors into file content.
package/index.ts CHANGED
@@ -5,6 +5,7 @@ import { regReplaceUndo, clearUndo } from "./src/replace-undo";
5
5
  import { regRead, fmtReadPreview } from "./src/read";
6
6
  import { visLines } from "./src/utils";
7
7
  import { AUTO_READ_MAX } from "./src/constants";
8
+ import { MAX_HASH_LINES } from "./src/hashline";
8
9
  import {
9
10
  readConfig,
10
11
  toggleReplaceMode,
@@ -92,8 +93,9 @@ export default function (pi: ExtensionAPI): void {
92
93
  if (typeof filePath !== "string") return;
93
94
 
94
95
  try {
95
- const { normalized, fileHashes, absolutePath } = await readNormFile(filePath, ctx.cwd);
96
-
96
+ const { normalized, fileHashes, absolutePath } = await readNormFile(
97
+ filePath, ctx.cwd, { maxLines: MAX_HASH_LINES },
98
+ );
97
99
  if (visLines(normalized).length === 0) return;
98
100
 
99
101
  const preview = await fmtReadPreview(normalized, { limit: AUTO_READ_MAX }, fileHashes, absolutePath);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-hashline-edit-pro",
3
- "version": "0.18.2",
3
+ "version": "0.18.4",
4
4
  "type": "module",
5
5
  "description": "Strict hashline read/replace tool for pi-coding-agent with hash-anchored edits (3-char, 18-bit, perfect hashing)",
6
6
  "main": "index.ts",
package/src/config.ts CHANGED
@@ -1,6 +1,7 @@
1
- import { readFile, writeFile, mkdir } from "fs/promises";
2
- import { configDir, configPath } from "./paths";
1
+ import { readFile } from "fs/promises";
2
+ import { configPath } from "./paths";
3
3
  import { errCode } from "./utils";
4
+ import { writeAtomic } from "./fs-write";
4
5
 
5
6
  export type ReplaceMode = "bulk" | "flat";
6
7
  export interface Config {
@@ -32,8 +33,7 @@ export async function readConfig(): Promise<Config> {
32
33
  }
33
34
  }
34
35
  export async function writeConfig(config: Config): Promise<void> {
35
- await mkdir(configDir(), { recursive: true });
36
- await writeFile(configPath(), JSON.stringify(config, null, 2), "utf-8");
36
+ await writeAtomic(configPath(), JSON.stringify(config, null, 2));
37
37
  }
38
38
 
39
39
 
package/src/constants.ts CHANGED
@@ -2,9 +2,6 @@ export const AUTO_READ_MAX = 2000;
2
2
  export const SNIFF_BYTES = 8192;
3
3
  export const MAX_BYTES = 100 * 1024 * 1024;
4
4
 
5
- export const MAX_HASH_LINES = 1_000_000;
6
- export const MAX_HASH_RETRIES = 262_144;
7
-
8
5
  export const HASH_STORE_BUSY_TIMEOUT = 1000;
9
6
  export const HASH_STORE_VERSION = 3;
10
7
  export const CONTENT_LINES_NOT_STRING_MSG =
package/src/fs-write.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  lstat,
4
4
  mkdir,
5
5
  open,
6
+ readdir,
6
7
  readlink,
7
8
  rename,
8
9
  rm,
@@ -71,6 +72,44 @@ export async function resolveTarget(path: string): Promise<string> {
71
72
  return resParts(root, parts);
72
73
  }
73
74
 
75
+ const TEMP_PREFIX = ".tmp-";
76
+ const STALE_TEMP_MS = 60 * 60 * 1000;
77
+ const sweptDirs = new Set<string>();
78
+
79
+ async function sweepStaleTemps(dir: string): Promise<void> {
80
+ if (sweptDirs.has(dir)) return;
81
+ sweptDirs.add(dir);
82
+ try {
83
+ const entries = await readdir(dir, { withFileTypes: true });
84
+ const now = Date.now();
85
+ for (const entry of entries) {
86
+ if (!entry.isFile() || !entry.name.startsWith(TEMP_PREFIX)) continue;
87
+ const tempPath = join(dir, entry.name);
88
+ try {
89
+ const stats = await stat(tempPath);
90
+ if (now - stats.mtimeMs > STALE_TEMP_MS) {
91
+ await rm(tempPath, { force: true });
92
+ }
93
+ } catch {
94
+ }
95
+ }
96
+ } catch {
97
+ }
98
+ }
99
+
100
+ async function syncDir(dir: string): Promise<void> {
101
+ if (process.platform === "win32") return;
102
+ try {
103
+ const handle = await open(dir, "r");
104
+ try {
105
+ await handle.sync();
106
+ } finally {
107
+ await handle.close();
108
+ }
109
+ } catch {
110
+ }
111
+ }
112
+
74
113
  export async function writeAtomic(
75
114
  path: string,
76
115
  content: string,
@@ -92,7 +131,8 @@ export async function writeAtomic(
92
131
  }
93
132
 
94
133
  const dir = dirname(targetPath);
95
- const tempPath = join(dir, `.tmp-${randomUUID()}`);
134
+ await sweepStaleTemps(dir);
135
+ const tempPath = join(dir, `${TEMP_PREFIX}${randomUUID()}`);
96
136
  await mkdir(dir, { recursive: true });
97
137
  const tempHandle = await open(tempPath, "wx", 0o600);
98
138
  try {
@@ -100,6 +140,7 @@ export async function writeAtomic(
100
140
  if (existingStats) {
101
141
  await tempHandle.chmod(existingStats.mode & 0o7777);
102
142
  }
143
+ await tempHandle.sync();
103
144
  } catch (error: unknown) {
104
145
  await tempHandle.close();
105
146
  try { await rm(tempPath, { force: true }); } catch {}
@@ -108,12 +149,13 @@ export async function writeAtomic(
108
149
  try {
109
150
  await tempHandle.close();
110
151
  await rename(tempPath, targetPath);
152
+ await syncDir(dir);
111
153
  } catch (error: unknown) {
112
154
  if (errCode(error) === "EXDEV") {
113
155
  try {
114
- await tempHandle.close();
115
156
  await copyFile(tempPath, targetPath);
116
157
  await rm(tempPath, { force: true });
158
+ await syncDir(dir);
117
159
  return;
118
160
  } catch {
119
161
  try { await rm(tempPath, { force: true }); } catch {}
package/src/hash-store.ts CHANGED
@@ -40,8 +40,7 @@ let exitHandlerRegistered = false;
40
40
  function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
41
41
  const db = new DatabaseSync(storePath, {
42
42
  timeout: HASH_STORE_BUSY_TIMEOUT,
43
- defensive: false,
44
- } as any);
43
+ });
45
44
  db.exec("PRAGMA journal_mode = WAL");
46
45
  db.exec("PRAGMA synchronous = NORMAL");
47
46
  db.exec(
@@ -72,6 +71,36 @@ function openDb(storePath: string): { db: DatabaseSync; stmts: Prepared } {
72
71
  return { db, stmts };
73
72
  }
74
73
 
74
+ function isHealthy(db: DatabaseSync): boolean {
75
+ try {
76
+ const row = db.prepare("PRAGMA quick_check").get() as { quick_check?: string } | undefined;
77
+ return row?.quick_check === "ok";
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ async function quarantineStore(storePath: string): Promise<void> {
84
+ const suffix = `.corrupt-${Date.now()}`;
85
+ for (const candidate of [storePath, `${storePath}-wal`, `${storePath}-shm`]) {
86
+ try {
87
+ await rename(candidate, `${candidate}${suffix}`);
88
+ } catch (error) {
89
+ if (errCode(error) !== "ENOENT") {
90
+ console.error("Failed to quarantine corrupt hash store file:", error);
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ function shutdownDb(db: DatabaseSync): void {
97
+ try {
98
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
99
+ } catch {
100
+ }
101
+ db.close();
102
+ }
103
+
75
104
  export async function loadHashStore(): Promise<HashStore> {
76
105
  const storePath = hashStorePath();
77
106
  if (cachedDb && cachedDb.path === storePath && cachedDb.db.isOpen) {
@@ -83,13 +112,27 @@ export async function loadHashStore(): Promise<HashStore> {
83
112
  await initHasher();
84
113
  await mkdir(hashStoreDir(), { recursive: true });
85
114
 
86
- const existed = existsSync(storePath);
87
- const { db, stmts } = openDb(storePath);
115
+ let existed = existsSync(storePath);
116
+ let opened: { db: DatabaseSync; stmts: Prepared };
117
+ try {
118
+ opened = openDb(storePath);
119
+ } catch (error) {
120
+ console.error("Hash store failed to open, rebuilding:", error);
121
+ await quarantineStore(storePath);
122
+ existed = false;
123
+ opened = openDb(storePath);
124
+ }
125
+ if (!isHealthy(opened.db)) {
126
+ shutdownDb(opened.db);
127
+ await quarantineStore(storePath);
128
+ existed = false;
129
+ opened = openDb(storePath);
130
+ }
131
+ const { db, stmts } = opened;
88
132
 
89
133
  if (!existed) {
90
134
  await migrateLegacy(db);
91
135
  }
92
-
93
136
  cachedDb = { path: storePath, db, stmts };
94
137
 
95
138
  if (!exitHandlerRegistered) {
@@ -108,11 +151,7 @@ export async function loadHashStore(): Promise<HashStore> {
108
151
 
109
152
  export function shutdownHashStore(): void {
110
153
  if (cachedDb) {
111
- try {
112
- cachedDb.db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
113
- } catch {
114
- }
115
- cachedDb.db.close();
154
+ shutdownDb(cachedDb.db);
116
155
  cachedDb = null;
117
156
  }
118
157
  }
@@ -21,6 +21,9 @@ const ALPH_SAFE = ALPH.replace(/-/g, "\\-");
21
21
  const ALPH_RE = new RegExp(`^[${ALPH_SAFE}]+$`);
22
22
  export const HASH_CLASS = `[${ALPH_SAFE}]{${HASH_LEN}}`;
23
23
 
24
+ export const HASH_SPACE = ALPH.length ** HASH_LEN;
25
+ export const MAX_HASH_LINES = HASH_SPACE;
26
+
24
27
  function idxToHash(idx: number): string {
25
28
  let out = "";
26
29
  for (let j = 0; j < HASH_LEN; j++) {
@@ -30,7 +33,7 @@ function idxToHash(idx: number): string {
30
33
  }
31
34
 
32
35
  const HASH_TABLE: string[] = Array.from(
33
- { length: 262_144 },
36
+ { length: HASH_SPACE },
34
37
  (_, i) => idxToHash(i),
35
38
  );
36
39
 
@@ -51,7 +54,7 @@ function canon(line: string): string {
51
54
  return line.replace(/\r/g, "").trimEnd();
52
55
  }
53
56
 
54
- const BITSET_WORDS = 8192;
57
+ const BITSET_WORDS = Math.ceil(HASH_SPACE / 32);
55
58
 
56
59
  function getBit(bits: Uint32Array, idx: number): boolean {
57
60
  return (bits[idx >>> 5] >>> (idx & 31) & 1) !== 0;
@@ -98,7 +101,9 @@ function nextZeroBit(bits: Uint32Array, start: number): number {
98
101
  if ((word >>> b & 1) === 0) return wordIdx * 32 + b;
99
102
  }
100
103
 
101
- throw new Error("Hash space exhausted");
104
+ throw new Error(
105
+ `[E_FILE_TOO_LARGE] Cannot allocate a unique hash anchor: the file exceeds the ${HASH_SPACE}-line limit for ${HASH_LEN}-char hashline anchors. For very large files use write or a non-line-based approach.`,
106
+ );
102
107
  }
103
108
 
104
109
  function assignHash(used: Uint32Array, baseIdx: number, hint: { value: number }): string {
@@ -175,6 +180,47 @@ function hashToIndex(hash: string): number {
175
180
  return idx;
176
181
  }
177
182
 
183
+ function findNearestCandidate(
184
+ candidates: { index: number; hash: string }[],
185
+ target: number,
186
+ removedHashes?: Set<string>,
187
+ ): number {
188
+ let lo = 0;
189
+ let hi = candidates.length;
190
+ while (lo < hi) {
191
+ const mid = (lo + hi) >>> 1;
192
+ if (candidates[mid]!.index < target) lo = mid + 1;
193
+ else hi = mid;
194
+ }
195
+ let left = lo - 1;
196
+ let right = lo;
197
+ while (left >= 0 || right < candidates.length) {
198
+ let bestPos = -1;
199
+ let bestDist = Infinity;
200
+ if (left >= 0) {
201
+ const candidate = candidates[left]!;
202
+ if (!removedHashes?.has(candidate.hash)) {
203
+ bestPos = left;
204
+ bestDist = target - candidate.index;
205
+ }
206
+ }
207
+ if (right < candidates.length) {
208
+ const candidate = candidates[right]!;
209
+ if (!removedHashes?.has(candidate.hash)) {
210
+ const dist = candidate.index - target;
211
+ if (dist < bestDist) {
212
+ bestPos = right;
213
+ bestDist = dist;
214
+ }
215
+ }
216
+ }
217
+ if (bestPos >= 0) return bestPos;
218
+ left--;
219
+ right++;
220
+ }
221
+ return -1;
222
+ }
223
+
178
224
  function mapStableHashes(
179
225
  oldContent: string,
180
226
  oldHashes: string[],
@@ -211,18 +257,8 @@ function mapStableHashes(
211
257
  const candidates = contentMap.get(line);
212
258
  if (!candidates || candidates.length === 0) continue;
213
259
 
214
- let bestIdx = 0;
215
- let bestDist = Infinity;
216
- for (let j = 0; j < candidates.length; j++) {
217
- if (removedHashes?.has(candidates[j]!.hash)) continue;
218
- const dist = Math.abs(candidates[j]!.index - i);
219
- if (dist < bestDist) {
220
- bestDist = dist;
221
- bestIdx = j;
222
- }
223
- }
224
-
225
- if (removedHashes?.has(candidates[bestIdx]!.hash)) continue;
260
+ const bestIdx = findNearestCandidate(candidates, i, removedHashes);
261
+ if (bestIdx < 0) continue;
226
262
  const match = candidates.splice(bestIdx, 1)[0]!;
227
263
  newHashes[i] = match.hash;
228
264
  const matchIdx = hashToIndex(match.hash);
@@ -3,6 +3,8 @@ export {
3
3
  ANCHOR_LEN,
4
4
  HASH_SEP,
5
5
  HASH_CLASS,
6
+ HASH_SPACE,
7
+ MAX_HASH_LINES,
6
8
  HL_PREFIX_RE,
7
9
  HL_PREFIX_PLUS_RE,
8
10
  HL_PREFIX_MINUS_RE,
package/src/read.ts CHANGED
@@ -8,7 +8,7 @@ import {
8
8
  import { Type } from "typebox";
9
9
  import { loadFileKindAndText } from "./file-kind";
10
10
  import { readNormFile } from "./file-reader";
11
- import { lineHashes, fmtRegion, HASH_SEP } from "./hashline";
11
+ import { lineHashes, fmtRegion, HASH_SEP, MAX_HASH_LINES } from "./hashline";
12
12
  import { toCwd } from "./paths";
13
13
  import { abortIf } from "./utils";
14
14
  import { fileSnap } from "./file-reader";
@@ -159,7 +159,7 @@ export function regRead(pi: ExtensionAPI): void {
159
159
  return executeBuiltinRead(_toolCallId, params, signal, _onUpdate, ctx);
160
160
  }
161
161
  const { normalized, fileHashes, hadUtf8DecodeErrors } = await readNormFile(
162
- rawPath, ctx.cwd, { signal, preloadedFile: file },
162
+ rawPath, ctx.cwd, { signal, preloadedFile: file, maxLines: MAX_HASH_LINES },
163
163
  );
164
164
  const preview = await fmtReadPreview(
165
165
  normalized,
package/src/replace.ts CHANGED
@@ -13,12 +13,11 @@ import {
13
13
  import { readNormFile } from "./file-reader";
14
14
  import { normReq, normalizeFilePath, tryParseContentLines } from "./replace-normalize";
15
15
  import { isRec, has, rejectUnknownFields, abortIf } from "./utils";
16
- import { MAX_HASH_LINES } from "./constants";
17
16
  import { resolveTarget, writeAtomic } from "./fs-write";
18
- import {
19
- applyEdits,
17
+ import { applyEdits,
20
18
  lineHashes,
21
19
  resEdits,
20
+ MAX_HASH_LINES,
22
21
  type HTEdit,
23
22
  } from "./hashline";
24
23
  import { toCwd } from "./paths";
@@ -166,9 +165,11 @@ export interface ExecPipelineOptions {
166
165
  function collectRemovedHashes(
167
166
  resolved: { hash_range_inclusive: [{ hash: string }, { hash: string }] }[],
168
167
  originalHashes: string[],
168
+ skipIndices?: Set<number>,
169
169
  ): Set<string> {
170
170
  const removedHashes = new Set<string>();
171
- for (const edit of resolved) {
171
+ for (const [index, edit] of resolved.entries()) {
172
+ if (skipIndices?.has(index)) continue;
172
173
  const startHash = edit.hash_range_inclusive[0].hash;
173
174
  const endHash = edit.hash_range_inclusive[1].hash;
174
175
  const startLine = originalHashes.indexOf(startHash);
@@ -234,16 +235,20 @@ export async function execPipeline(
234
235
  );
235
236
 
236
237
  const result = anchorResult.content;
237
-
238
- const removedHashes = collectRemovedHashes(resolved, originalHashes);
238
+ const isNoop = result === originalNormalized;
239
239
 
240
240
  const noPersist = options?.noPersist;
241
- const resultHashes = await lineHashes(result, absolutePath, {
242
- content: originalNormalized,
243
- hashes: originalHashes,
244
- removedHashes,
245
- }, hashStore, noPersist !== true);
246
-
241
+ const noopIndices = new Set(anchorResult.noopEdits?.map((n) => n.editIndex) ?? []);
242
+ const removedHashes = isNoop
243
+ ? undefined
244
+ : collectRemovedHashes(resolved, originalHashes, noopIndices);
245
+ const resultHashes = isNoop
246
+ ? originalHashes
247
+ : await lineHashes(result, absolutePath, {
248
+ content: originalNormalized,
249
+ hashes: originalHashes,
250
+ removedHashes,
251
+ }, hashStore, noPersist !== true);
247
252
  const warnings = [...(anchorResult.warnings ?? [])];
248
253
 
249
254
  const { totalAddedLines, totalRemovedLines } = countLineChanges(