Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/cli/src/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
type Thread,
} from './threads.js';
import { createTour, addTourStep, updateTourStatus } from './tours.js';
import { readAnchor } from './anchor.js';

function requireSession() {
if (!isGitRepo()) {
Expand Down Expand Up @@ -164,6 +165,8 @@ Examples:
endLine,
opts.body,
{ name: 'Agent', type: 'agent' },
// Recorded so the finding can follow its code when a later commit moves it.
opts.side === 'new' ? readAnchor(opts.file, opts.line, endLine) : undefined,
);
console.log(pc.green(`Created thread ${thread.id.slice(0, 8)}`));
});
Expand Down
67 changes: 67 additions & 0 deletions packages/cli/src/anchor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { getWorkingTreeFileContent } from '@diffity/git';

export interface AnchorRange {
startLine: number;
endLine: number;
}

/**
* The source lines a comment is attached to, in the same shape the browser stores: the lines
* themselves, joined, with no line numbers.
*/
export function readAnchor(filePath: string, startLine: number, endLine: number): string | undefined {
try {
const lines = getWorkingTreeFileContent(filePath).split('\n');
const anchor = lines.slice(startLine - 1, endLine).join('\n');
return anchor || undefined;
} catch {
return undefined;
}
}

/**
* Finds where a comment's lines went after the file changed under it.
*
* The match is exact: a line that was edited is a different line, and guessing at similarity
* would move a comment onto code it was not written about. When the same lines appear more than
* once, the occurrence nearest to where the comment used to be wins.
*/
export function reanchor(
anchorContent: string,
fileLines: string[],
originalStartLine: number,
): AnchorRange | null {
const anchorLines = anchorContent.split('\n');
if (anchorContent === '' || anchorLines.length === 0) {
return null;
}

const matches: number[] = [];
for (let i = 0; i + anchorLines.length <= fileLines.length; i++) {
if (anchorLines.every((line, offset) => fileLines[i + offset] === line)) {
matches.push(i + 1);
}
}

if (matches.length === 0) {
return null;
}

const startLine = matches.reduce((best, candidate) =>
Math.abs(candidate - originalStartLine) < Math.abs(best - originalStartLine) ? candidate : best,
);

return { startLine, endLine: startLine + anchorLines.length - 1 };
}

export function reanchorInWorkingTree(
filePath: string,
anchorContent: string,
originalStartLine: number,
): AnchorRange | null {
try {
return reanchor(anchorContent, getWorkingTreeFileContent(filePath).split('\n'), originalStartLine);
} catch {
return null;
}
}
30 changes: 29 additions & 1 deletion packages/cli/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { randomUUID } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { getHeadHash, getDiffityDir } from '@diffity/git';
import { getDb, queryOne } from './db.js';
import { getDb, queryAll, queryOne } from './db.js';
import { reanchorInWorkingTree } from './anchor.js';
import { updateThreadLines } from './threads.js';

export interface Session {
id: string;
Expand Down Expand Up @@ -71,6 +73,32 @@ export function carryForward(fromSessionId: string, toSessionId: string): void {
toSessionId,
fromSessionId,
);

reanchorThreads(toSessionId);
}

/**
* A finding that outlives the commit it was written against points at a line that has since
* moved. Only the new side is re-anchored: a comment on a removed line has nothing to follow.
*/
function reanchorThreads(sessionId: string): void {
const threads = queryAll<{
id: string;
file_path: string;
side: string;
start_line: number;
anchor_content: string | null;
}>(
"SELECT id, file_path, side, start_line, anchor_content FROM comment_threads WHERE session_id = ? AND status = 'open' AND side = 'new' AND anchor_content IS NOT NULL",
sessionId,
);

for (const thread of threads) {
const moved = reanchorInWorkingTree(thread.file_path, thread.anchor_content!, thread.start_line);
if (moved && moved.startLine !== thread.start_line) {
updateThreadLines(thread.id, moved.startLine, moved.endLine);
}
}
}

export function getCurrentSession(): Session | null {
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,15 @@ function getCommentsForThread(threadId: string): ThreadComment[] {
return map.get(threadId) ?? [];
}

export function updateThreadLines(threadId: string, startLine: number, endLine: number): void {
const db = getDb();
db.prepare('UPDATE comment_threads SET start_line = ?, end_line = ? WHERE id = ?').run(
startLine,
endLine,
threadId,
);
}

export function createThread(
sessionId: string,
filePath: string,
Expand Down
50 changes: 50 additions & 0 deletions packages/cli/tests/anchor.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect } from 'vitest';
import { reanchor } from '../src/anchor.js';

const file = [
'import { a } from "a";',
'',
'function first() {',
' return 1;',
'}',
'',
'function second() {',
' return 2;',
'}',
];

describe('reanchor', () => {
it('finds the lines where they now are', () => {
const moved = reanchor('function second() {\n return 2;', ['// new header', ...file], 7);

expect(moved).toEqual({ startLine: 8, endLine: 9 });
});

it('leaves a line that has not moved alone', () => {
expect(reanchor(' return 1;', file, 4)).toEqual({ startLine: 4, endLine: 4 });
});

it('gives up when the line was edited rather than moved', () => {
expect(reanchor(' return 1; // changed', file, 4)).toBeNull();
});

it('gives up when the code is gone', () => {
expect(reanchor('function third() {', file, 4)).toBeNull();
});

it('takes the occurrence nearest to where the comment was', () => {
const duplicated = [' return 1;', 'x', ' return 1;', 'y', ' return 1;'];

expect(reanchor(' return 1;', duplicated, 3)?.startLine).toBe(3);
expect(reanchor(' return 1;', duplicated, 5)?.startLine).toBe(5);
expect(reanchor(' return 1;', duplicated, 1)?.startLine).toBe(1);
});

it('refuses an empty anchor rather than matching everywhere', () => {
expect(reanchor('', file, 1)).toBeNull();
});

it('handles an anchor longer than the file', () => {
expect(reanchor(file.join('\n') + '\nextra', file, 1)).toBeNull();
});
});
63 changes: 63 additions & 0 deletions packages/cli/tests/session-continuity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,69 @@ describe('a session when HEAD moves', () => {
expect(left.map(thread => thread.id)).toEqual([dealtWith.id]);
});

it('follows its code when a later commit moves it', async () => {
const { findOrCreateSession } = await import('../src/session.js');
const { createThread, getThreadsForSession } = await import('../src/threads.js');

writeFileSync(join(repoDir, 'moving.ts'), 'one\ntwo\nthree\n');
git(['add', '.']);
git(['commit', '-m', 'moving.ts']);

const before = findOrCreateSession('work');
const finding = createThread(
before.id,
'moving.ts',
'new',
2,
2,
'P2: about the second line',
{ name: 'Agent', type: 'agent' },
'two',
);

// Two lines land above it, so the code it points at is now on line 4.
writeFileSync(join(repoDir, 'moving.ts'), 'inserted\nalso inserted\none\ntwo\nthree\n');
git(['add', '.']);
git(['commit', '-m', 'insert above']);

const after = findOrCreateSession('work');
const carried = getThreadsForSession(after.id).find(thread => thread.id === finding.id);

expect(carried?.startLine).toBe(4);
expect(carried?.endLine).toBe(4);
});

it('leaves a finding where it is when its code was edited rather than moved', async () => {
const { findOrCreateSession } = await import('../src/session.js');
const { createThread, getThreadsForSession } = await import('../src/threads.js');

writeFileSync(join(repoDir, 'edited.ts'), 'keep\ntarget\nkeep\n');
git(['add', '.']);
git(['commit', '-m', 'edited.ts']);

const before = findOrCreateSession('work');
const finding = createThread(
before.id,
'edited.ts',
'new',
2,
2,
'P3: about the target',
{ name: 'Agent', type: 'agent' },
'target',
);

writeFileSync(join(repoDir, 'edited.ts'), 'keep\ntarget changed\nkeep\n');
git(['add', '.']);
git(['commit', '-m', 'edit the target']);

const after = findOrCreateSession('work');
const carried = getThreadsForSession(after.id).find(thread => thread.id === finding.id);

expect(carried).toBeDefined();
expect(carried?.startLine).toBe(2);
});

it('returns the same session while HEAD stays put', async () => {
const { findOrCreateSession } = await import('../src/session.js');

Expand Down