# Diffle's Text Anchors: Where Do Review Comments Go After a Coding Agent Rewrites the Code?

> Canonical: https://vxi.me/en/posts/diffle-git-diff-comment-anchoring/
> Locale: en-us
> Published: 2026-10-13
> Updated: 2026-10-13
> Markdown: https://vxi.me/en/posts/diffle-git-diff-comment-anchoring.md

# Diffle's Text Anchors: Where Do Review Comments Go After a Coding Agent Rewrites the Code?

I leave a line-level review comment for a coding agent: the condition here is wrong; please fix it. The agent fixes it, then also splits the function and adds a few checks. When I reopen the diff, is the comment that used to sit on line 18 still talking about the same code?

That question drew me to [Diffle](https://github.com/moritzwilksch/diffle). It's a local Git diff viewer where you can leave comments and hand them to an agent or put them into a GitHub Pending Review. But when the agent keeps editing, a comment can't rely on its old line number forever.

I read Diffle's source at pinned commit [`a69274d`](https://github.com/moritzwilksch/diffle/tree/a69274d512a588dd86714bef103c8dbc2cadb2cf) (version 0.2.4). Its approach is intentionally simple: **remember the quoted code, find an exact match, prefer the one nearest the old line, then mark a location stale if it no longer belongs to the visible diff.** That handles ordinary line shifts. What happens when two pieces of code are identical? I ran four cases against a small, independently written Node.js model to examine the trade-off. This is a source-code analysis and a synthetic model test, not a hands-on Diffle CLI or browser benchmark.

## A comment remembers the text, not just line 18

Diffle stores comments under `<git-dir>/diffle/` rather than writing them into your working-tree files. Its [`CommentStore.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/CommentStore.ts) saves threads and anchors in `comments.json`. An anchor contains the file path, diff side, old start and end lines, and the selected snippet as `quoted` text.

In [`anchor.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/anchor.ts), `relocate()` searches line by line for that exact quoted text. When there are several matches, this is the part that chooses between them:

```ts
const start = i + 1;
const dist = Math.abs(start - anchor.startLine);
if (dist < bestDist) {
  best = start;
  bestDist = dist;
  if (dist === 0) break;
}
```

If a block moves from line 18 to line 26 without changing its text, the comment may move with it. And because the comparison is `dist < bestDist`, a tie leaves the first candidate in place.

There is a second check. [`CommentStore.relocateAll()`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/CommentStore.ts) checks the comparison side and currently visible diff hunks. A deleted quote, missing side, or newly matched location outside those hunks can leave a line comment marked `stale`. A file-level comment instead depends on whether the file remains part of the review.

I like that safeguard. Merely finding the same string shouldn't automatically mean the same logical place was found.

## What if line 18 has a twin?

Back to the opening example. Real projects have plenty of repeated `return false;` lines, or branches that all log `logger.info('done');`. Imagine the comment originally belongs to the final fallback branch. A refactor moves that branch far away, but another identical line now sits close to the previous comment location. The matching algorithm cannot infer which one I meant.

To isolate the behavior, my model uses `repeat();`. This is the revealing case out of the four I actually ran. The complete runnable script is in the expandable section below; first, look at the key assertion:

```js
const contents = [
  'header();',   // line 1
  'repeat();',   // line 2: nearer duplicate
  'other();',    // line 3: old comment position
  'new();',
  'changed();',
  'gap();',
  'repeat();',   // line 7: hypothetical semantic target
].join('\n') + '\n';

const r = relocateModel(
  { startLine: 3, quoted: 'repeat();' }, contents
);
assert.deepEqual(r, { startLine: 2, endLine: 2 });
assert.notEqual(r.startLine, 7);
```

The old anchor is at line 3. After the edit, identical `repeat();` calls sit on lines 2 and 7. One is one line away; the other is four lines away. The model chooses line 2, even though I deliberately designated line 7 as the hypothetical original logical target.

**Nearest is not necessarily the same thing.** That logical target exists only in my test setup; the matching function cannot recognize semantic identity. The actual application also checks paths, sides, hunks and thread state, so this observation does not establish a Diffle UI bug.

I ran three other cases alongside it. Here is what all four independent tests actually returned:

| Synthetic case | Observed result | What it tells us |
| --- | --- | --- |
| A unique quote moves | Relocates to line 4 | Text matching can follow ordinary shifts |
| The quoted text is deleted | Returns `null` | No invented location when the text disappears |
| Identical text on lines 2 and 7 | Chooses line 2 | Proximity is not semantic identity |
| Text is on line 4 but the shown hunk spans 1–2 | Returns `false` | An exact text match can still be outside the diff |

I ran the test on October 12, 2026, with Node.js `v22.16.0`: **4/4 passed, exit code 0, empty stderr**. The script uses only the built-in `node:test` and `node:assert/strict`, not Diffle's npm package, so the package's Node ≥24 engine requirement does not apply to this fixture. The command was `TERM=dumb node --test --test-reporter=tap anchor-relocation-model.test.mjs`. The exact four-case source and unedited TAP output are available in the two expandable sections at the end.

You could try to disambiguate duplicates with neighboring lines, function boundaries or a context fingerprint. But refactors can also invalidate those signals. It's a trade-off, not a matter of adding a field and declaring the problem solved; the pinned Diffle 0.2.4 matcher does not implement those extra semantic signals.

## Sending a comment to an agent isn't the same as posting a GitHub review

After a local review, [`format.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/format.ts) can turn a thread into a prompt containing the file path, line, quoted code and comment body. A `suggestion` fence becomes an `ORIGINAL` / `SUGGESTED` pair, and stale comments are labeled. That's a better handoff than asking an agent to “fix that bit from last time”: at least there is specific code to check on the next iteration.

Sending those comments to a GitHub PR is a different operation. [`review.ts`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/github/review.ts) explicitly rejects a worktree-only diff as a GitHub comment anchor:

```ts
if (snap.newSha === 'worktree')
  throw new GithubError('GitHub cannot anchor comments to the worktree');
```

That makes sense. If the changed lines exist only in my uncommitted worktree, collaborators opening the PR don't have that diff. Attaching comments to those ghost line numbers would be a confusing review experience.

When the comparison matches a committed PR diff, Diffle skips `stale` threads and adds eligible comments to **Pending Review**. Submitting that review remains a human action; the [official README](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/README.md) says the tool never submits it on your behalf. I appreciate that separation: an agent can revise the code and a tool can organize my feedback, but the comments my colleagues actually see deserve one final read from me.

## Before I'd add it to my routine

I'd start with a disposable repository that contains no sensitive data. I'd inspect the working-tree diff and turn off unnecessary language servers. The following combines the documented command and option; it is **not** output from a Diffle run in this study:

```sh
diffle working --no-lsp
```

The pinned [`package.json`](https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/package.json) requires Node ≥24 for the npm package, while the project also offers standalone binaries that do not require Node. I would keep `--no-lsp` initially because auto-discovered language servers can read repository files under the current user's permissions. There's no need to start them just to inspect one diff.

When I previously looked at [OpenQodex's pre-push review gate](/posts/openqodex-prepush-review-fail-open/), the question was whether incomplete review could let unreviewed code through. With Diffle the question comes earlier: can human feedback still point at the intended code after another agent edit? Whether generated tests catch the right failures belongs to the separate discussion of [testing and Mutation Testing](/posts/ai-generated-tests-mutation-testing/).

The next time I leave a note saying “line 18 has a problem,” I'll also look at the whole updated diff. A tool can help a comment find somewhere to land. Deciding whether that is still the code I meant to review takes more than a line number.

<details>
<summary>Show the full runnable Node.js test script (four cases)</summary>

```js
/**
 * Independent source-adapted synthetic model; consult verification-results.json for actual execution status.
 * An independent stdlib model of the source-visible anchor decisions in:
 * https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/anchor.ts
 * https://github.com/moritzwilksch/diffle/blob/a69274d512a588dd86714bef103c8dbc2cadb2cf/src/server/comments/hunks.ts
 * This does NOT import or run Diffle and cannot establish CLI/UI behavior.
 */
import test from 'node:test';
import assert from 'node:assert/strict';

function lines(s) {
  const a = s.split('\n');
  if (a.at(-1) === '') a.pop();
  return a;
}
function relocateModel({ startLine, quoted }, contents) {
  const haystack = lines(contents);
  const needle = lines(quoted);
  if (needle.length === 0) return { startLine, endLine: startLine };
  const candidates = [];
  for (let i = 0; i + needle.length <= haystack.length; i++) {
    if (needle.every((l, j) => haystack[i + j] === l)) candidates.push(i + 1);
  }
  if (candidates.length === 0) return null;
  const winner = candidates.reduce((best, p) =>
    Math.abs(p - startLine) < Math.abs(best - startLine) ? p : best);
  return { startLine: winner, endLine: winner + needle.length - 1 };
}
function isShownModel(ranges, a) {
  return ranges.some(([start, end]) => a.startLine >= start && a.endLine <= end);
}
test('unique quote moves', () => {
  assert.deepEqual(relocateModel({startLine:2,quoted:'target();'},'a();\nb();\nc();\ntarget();\n'),{startLine:4,endLine:4});
});
test('deleted quote has no text match', () => {
  assert.equal(relocateModel({startLine:2,quoted:'target();'},'a();\nb();\nc();\n'),null);
});
test('duplicate quote can select a nearer occurrence than the intended one', () => {
  const contents = [
    'header();',       // line 1
    'repeat();',       // line 2: lexical duplicate, nearer
    'other();',        // line 3: original anchor position before edit
    'new();',
    'changed();',
    'gap();',
    'repeat();',       // line 7: hypothetical semantic target after refactor
  ].join('\n') + '\n';
  const r = relocateModel({startLine:3,quoted:'repeat();'}, contents);
  assert.deepEqual(r,{startLine:2,endLine:2});
  assert.notEqual(r.startLine,7); // heuristic ambiguity, NOT an observed Diffle UI defect
});
test('a matching line beyond shown hunks is not visible', () => {
  const r = relocateModel({startLine:2,quoted:'target();'},'a();\nb();\nc();\ntarget();\n');
  assert.equal(isShownModel([[1,2]],r),false);
});
```

</details>

<details>
<summary>Show the original TAP output from October 12, 2026</summary>

```text
TAP version 13
# Subtest: unique quote moves
ok 1 - unique quote moves
  ---
  duration_ms: 7.325889
  type: 'test'
  ...
# Subtest: deleted quote has no text match
ok 2 - deleted quote has no text match
  ---
  duration_ms: 0.916348
  type: 'test'
  ...
# Subtest: duplicate quote can select a nearer occurrence than the intended one
ok 3 - duplicate quote can select a nearer occurrence than the intended one
  ---
  duration_ms: 4.069714
  type: 'test'
  ...
# Subtest: a matching line beyond shown hunks is not visible
ok 4 - a matching line beyond shown hunks is not visible
  ---
  duration_ms: 2.261519
  type: 'test'
  ...
1..4
# tests 4
# suites 0
# pass 4
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 1124.334836
```

</details>
