@nakedved/ano
Version:
Claude Code plugin for collaborative annotation and review
253 lines (252 loc) • 9.97 kB
JavaScript
/**
* Version Diff Command
*
* Compare annotations between two versions of a file.
*
* Usage:
* ano diff <file1.annotations.json> <file2.annotations.json>
* ano diff --git <file> Compare against last committed version
*/
import { Command } from 'commander';
import chalk from 'chalk';
import { resolve, basename } from 'node:path';
import { readFile } from 'node:fs/promises';
import { execSync } from 'node:child_process';
import { getAnnotationFilePath, readAnnotationFile } from '../../core/annotations.js';
/**
* Compare two annotation files and return the differences
*/
function compareAnnotations(oldFile, newFile) {
const oldAnnotations = oldFile?.annotations || [];
const newAnnotations = newFile?.annotations || [];
const oldApprovals = oldFile?.approvals || [];
const newApprovals = newFile?.approvals || [];
const oldById = new Map(oldAnnotations.map((a) => [a.id, a]));
const newById = new Map(newAnnotations.map((a) => [a.id, a]));
const result = {
added: [],
removed: [],
resolved: [],
reopened: [],
modified: [],
newApprovals: [],
removedApprovals: [],
};
// Find added and modified annotations
for (const ann of newAnnotations) {
const old = oldById.get(ann.id);
if (!old) {
result.added.push(ann);
}
else {
// Check if status changed
if (old.status === 'open' && ann.status === 'resolved') {
result.resolved.push(ann);
}
else if (old.status === 'resolved' && ann.status === 'open') {
result.reopened.push(ann);
}
// Check if content or other properties changed
else if (old.content !== ann.content ||
old.anchor.line !== ann.anchor.line ||
old.replies.length !== ann.replies.length) {
result.modified.push({ old, new: ann });
}
}
}
// Find removed annotations
for (const ann of oldAnnotations) {
if (!newById.has(ann.id)) {
result.removed.push(ann);
}
}
// Compare approvals
const oldApprovalsByAuthor = new Map(oldApprovals.map((a) => [a.author, a]));
const newApprovalsByAuthor = new Map(newApprovals.map((a) => [a.author, a]));
for (const approval of newApprovals) {
const old = oldApprovalsByAuthor.get(approval.author);
if (!old || old.status !== approval.status || old.timestamp !== approval.timestamp) {
result.newApprovals.push(approval);
}
}
for (const approval of oldApprovals) {
if (!newApprovalsByAuthor.has(approval.author)) {
result.removedApprovals.push(approval);
}
}
return result;
}
function formatAnnotation(ann, prefix = '') {
const shortId = ann.id.slice(0, 8);
return `${prefix}#${shortId} L${ann.anchor.line} ${ann.type}: "${ann.content.slice(0, 50)}${ann.content.length > 50 ? '...' : ''}"`;
}
export const diffCommand = new Command('diff')
.description('Compare annotations between versions')
.argument('[file1]', 'First annotation file (older version)')
.argument('[file2]', 'Second annotation file (newer version)')
.option('--git', 'Compare current annotations against last committed version')
.option('--stats', 'Show statistics only')
.action(async (file1, file2, options) => {
let oldFile = null;
let newFile = null;
let sourceLabel1 = 'old';
let sourceLabel2 = 'new';
if (options.git) {
// Compare against git HEAD
if (!file1) {
console.error(chalk.red('Please specify a file when using --git'));
process.exit(1);
}
const filePath = resolve(file1);
const annotationPath = filePath.endsWith('.annotations.json') ? filePath : getAnnotationFilePath(filePath);
// Read current file
newFile = await readAnnotationFile(filePath.replace('.annotations.json', ''));
if (!newFile) {
try {
const content = await readFile(annotationPath, 'utf-8');
newFile = JSON.parse(content);
}
catch {
console.log(chalk.dim('No current annotations found'));
}
}
// Get from git HEAD
try {
const gitContent = execSync(`git show HEAD:"${annotationPath}"`, {
encoding: 'utf-8',
cwd: process.cwd(),
stdio: ['pipe', 'pipe', 'pipe'],
});
oldFile = JSON.parse(gitContent);
}
catch {
// File doesn't exist in git or not a git repo
console.log(chalk.dim('No committed annotations found'));
}
sourceLabel1 = 'HEAD';
sourceLabel2 = 'working tree';
}
else {
// Compare two files
if (!file1 || !file2) {
console.error(chalk.red('Please specify two annotation files to compare'));
console.error(chalk.dim(' Usage: ano diff <old.annotations.json> <new.annotations.json>'));
console.error(chalk.dim(' Or: ano diff --git <file>'));
process.exit(1);
}
const path1 = resolve(file1);
const path2 = resolve(file2);
try {
const content1 = await readFile(path1, 'utf-8');
oldFile = JSON.parse(content1);
sourceLabel1 = basename(path1);
}
catch (error) {
console.error(chalk.red(`Failed to read ${path1}`));
process.exit(1);
}
try {
const content2 = await readFile(path2, 'utf-8');
newFile = JSON.parse(content2);
sourceLabel2 = basename(path2);
}
catch (error) {
console.error(chalk.red(`Failed to read ${path2}`));
process.exit(1);
}
}
// Compare
const diff = compareAnnotations(oldFile, newFile);
// Check if there are any changes
const hasChanges = diff.added.length > 0 ||
diff.removed.length > 0 ||
diff.resolved.length > 0 ||
diff.reopened.length > 0 ||
diff.modified.length > 0 ||
diff.newApprovals.length > 0 ||
diff.removedApprovals.length > 0;
if (!hasChanges) {
console.log(chalk.dim('No changes between versions'));
return;
}
// Print header
console.log();
console.log(chalk.bold(' Annotation Diff'));
console.log(chalk.dim(` ${sourceLabel1} → ${sourceLabel2}`));
console.log();
// Statistics mode
if (options.stats) {
console.log(` Added: ${chalk.green(`+${diff.added.length}`)}`);
console.log(` Removed: ${chalk.red(`-${diff.removed.length}`)}`);
console.log(` Resolved: ${chalk.cyan(`${diff.resolved.length}`)}`);
console.log(` Reopened: ${chalk.yellow(`${diff.reopened.length}`)}`);
console.log(` Modified: ${chalk.magenta(`${diff.modified.length}`)}`);
if (diff.newApprovals.length > 0 || diff.removedApprovals.length > 0) {
console.log();
console.log(` New approvals: ${chalk.green(`+${diff.newApprovals.length}`)}`);
console.log(` Removed approvals: ${chalk.red(`-${diff.removedApprovals.length}`)}`);
}
console.log();
return;
}
// Detailed output
if (diff.added.length > 0) {
console.log(chalk.green.bold(` + Added (${diff.added.length})`));
for (const ann of diff.added) {
console.log(chalk.green(formatAnnotation(ann, ' ')));
}
console.log();
}
if (diff.removed.length > 0) {
console.log(chalk.red.bold(` - Removed (${diff.removed.length})`));
for (const ann of diff.removed) {
console.log(chalk.red(formatAnnotation(ann, ' ')));
}
console.log();
}
if (diff.resolved.length > 0) {
console.log(chalk.cyan.bold(` ✓ Resolved (${diff.resolved.length})`));
for (const ann of diff.resolved) {
console.log(chalk.cyan(formatAnnotation(ann, ' ')));
}
console.log();
}
if (diff.reopened.length > 0) {
console.log(chalk.yellow.bold(` ↺ Reopened (${diff.reopened.length})`));
for (const ann of diff.reopened) {
console.log(chalk.yellow(formatAnnotation(ann, ' ')));
}
console.log();
}
if (diff.modified.length > 0) {
console.log(chalk.magenta.bold(` ~ Modified (${diff.modified.length})`));
for (const { old, new: newAnn } of diff.modified) {
console.log(chalk.dim(` #${old.id.slice(0, 8)}`));
if (old.anchor.line !== newAnn.anchor.line) {
console.log(chalk.dim(` Line: ${old.anchor.line} → ${newAnn.anchor.line}`));
}
if (old.content !== newAnn.content) {
console.log(chalk.dim(` Content changed`));
}
if (old.replies.length !== newAnn.replies.length) {
console.log(chalk.dim(` Replies: ${old.replies.length} → ${newAnn.replies.length}`));
}
}
console.log();
}
if (diff.newApprovals.length > 0) {
console.log(chalk.green.bold(` + New Approvals (${diff.newApprovals.length})`));
for (const approval of diff.newApprovals) {
const status = approval.status === 'approved' ? chalk.green('approved') : chalk.yellow('changes_requested');
console.log(` ${approval.author.split('<')[0].trim()}: ${status}`);
}
console.log();
}
if (diff.removedApprovals.length > 0) {
console.log(chalk.red.bold(` - Removed Approvals (${diff.removedApprovals.length})`));
for (const approval of diff.removedApprovals) {
console.log(chalk.red(` ${approval.author.split('<')[0].trim()}`));
}
console.log();
}
});