diff --git a/package-lock.json b/package-lock.json index 8d6c902a..36771017 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8457,7 +8457,7 @@ }, "packages/api": { "name": "@diffity/api", - "version": "0.10.7", + "version": "0.10.8", "dependencies": { "@diffity/parser": "*" }, @@ -8468,7 +8468,7 @@ }, "packages/cli": { "name": "@naturalcycles/diffity", - "version": "0.10.7", + "version": "0.10.8", "license": "MIT", "dependencies": { "commander": "^14.0.3", @@ -8492,7 +8492,7 @@ }, "packages/git": { "name": "@diffity/git", - "version": "0.10.7", + "version": "0.10.8", "devDependencies": { "@types/node": "^25.5.0", "typescript": "^5.9.3", @@ -8501,7 +8501,7 @@ }, "packages/github": { "name": "@diffity/github", - "version": "0.10.7", + "version": "0.10.8", "dependencies": { "@diffity/api": "*", "@diffity/parser": "*" @@ -8514,7 +8514,7 @@ }, "packages/parser": { "name": "@diffity/parser", - "version": "0.10.7", + "version": "0.10.8", "devDependencies": { "typescript": "^5.9.3", "vitest": "^4.1.0" @@ -8522,7 +8522,7 @@ }, "packages/ui": { "name": "@diffity/ui", - "version": "0.10.7", + "version": "0.10.8", "dependencies": { "@diffity/api": "*", "@diffity/parser": "*", diff --git a/packages/api/package.json b/packages/api/package.json index fb7c85e8..35f655fa 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/api", - "version": "0.10.7", + "version": "0.10.8", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/api/src/bundle.ts b/packages/api/src/bundle.ts new file mode 100644 index 00000000..a139332a --- /dev/null +++ b/packages/api/src/bundle.ts @@ -0,0 +1,164 @@ +import { + COMMENT_KINDS, + COMMENT_SIDES, + THREAD_STATUSES, + type CommentAuthor, + type CommentKind, + type CommentSide, + type ThreadStatus, +} from './threads.js'; +import { TOUR_STATUSES, type TourStatus } from './tours.js'; +import type { GitHubRemote } from './github.js'; +import { + FieldError, + author, + int, + lineRange, + list, + member, + optInt, + optStr, + parseWith, + record, + str, + timestamp, + type ParseResult, +} from './parse.js'; + +/** + * A prepared review in portable form: the threads and walkthrough tours of one session, pinned to + * the commit whose working tree their line numbers mean. Everything machine- and forge-local — + * ids, live state, what was submitted where — stays behind; an import mints its own. + */ +export interface ReviewBundle { + formatVersion: number; + /** The commit the anchors mean: importing onto any other HEAD would misplace every line. */ + headSha: string; + /** The session ref the bundle was exported from, and the base it resolved to at export time. */ + ref: string; + baseSha: string | null; + repo: GitHubRemote | null; + prNumber: number | null; + createdAt: string; + /** Who or what prepared this, free text — shown so a reader knows the findings' provenance. */ + generator: string; + threads: BundleThread[]; + tours: BundleTour[]; +} + +export interface BundleThread { + filePath: string; + side: CommentSide; + startLine: number; + endLine: number; + status: ThreadStatus; + anchorContent: string | null; + comments: BundleComment[]; +} + +export interface BundleComment { + author: CommentAuthor; + body: string; + kind: CommentKind; + createdAt: string; +} + +export interface BundleTour { + topic: string; + body: string; + status: TourStatus; + steps: BundleTourStep[]; +} + +export interface BundleTourStep { + filePath: string; + startLine: number; + endLine: number; + body: string; + annotation: string; +} + +export const BUNDLE_FORMAT_VERSION = 1; + +export function parseReviewBundle(value: unknown): ParseResult { + return parseWith(value, obj => { + const formatVersion = int(obj.formatVersion, 'formatVersion', 1); + if (formatVersion > BUNDLE_FORMAT_VERSION) { + throw new FieldError( + `formatVersion ${formatVersion} is newer than this diffity understands (${BUNDLE_FORMAT_VERSION})`, + ); + } + return { + formatVersion, + headSha: str(obj.headSha, 'headSha'), + ref: str(obj.ref, 'ref'), + baseSha: obj.baseSha == null ? null : str(obj.baseSha, 'baseSha'), + repo: bundleRepo(obj.repo), + prNumber: optInt(obj.prNumber, 'prNumber', 1) ?? null, + createdAt: timestamp(obj.createdAt, 'createdAt'), + generator: str(obj.generator, 'generator'), + threads: list(obj.threads, 'threads').map((item, index) => bundleThread(item, `threads[${index}]`)), + tours: list(obj.tours, 'tours').map((item, index) => bundleTour(item, `tours[${index}]`)), + }; + }, 'The bundle'); +} + +function bundleRepo(value: unknown): GitHubRemote | null { + if (value == null) { + return null; + } + const obj = record(value, 'repo'); + return { + owner: str(obj.owner, 'repo.owner'), + repo: str(obj.repo, 'repo.repo'), + }; +} + +function bundleThread(value: unknown, label: string): BundleThread { + const obj = record(value, label); + const comments = list(obj.comments, `${label}.comments`) + .map((item, index) => bundleComment(item, `${label}.comments[${index}]`)); + if (comments.length === 0) { + throw new FieldError(`${label}.comments must not be empty`); + } + return { + filePath: str(obj.filePath, `${label}.filePath`), + side: member(obj.side, `${label}.side`, COMMENT_SIDES), + // Line 0 is real: a general comment is about the whole diff and sits on no line. + ...lineRange(obj, 0, label), + status: member(obj.status, `${label}.status`, THREAD_STATUSES), + anchorContent: optStr(obj.anchorContent, `${label}.anchorContent`) ?? null, + comments, + }; +} + +function bundleComment(value: unknown, label: string): BundleComment { + const obj = record(value, label); + return { + author: author(obj.author, `${label}.author`), + body: str(obj.body, `${label}.body`), + kind: member(obj.kind, `${label}.kind`, COMMENT_KINDS), + createdAt: timestamp(obj.createdAt, `${label}.createdAt`), + }; +} + +function bundleTour(value: unknown, label: string): BundleTour { + const obj = record(value, label); + return { + topic: str(obj.topic, `${label}.topic`), + body: optStr(obj.body, `${label}.body`) ?? '', + status: member(obj.status, `${label}.status`, TOUR_STATUSES), + steps: list(obj.steps, `${label}.steps`) + .map((item, index) => bundleTourStep(item, `${label}.steps[${index}]`)), + }; +} + +function bundleTourStep(value: unknown, label: string): BundleTourStep { + const obj = record(value, label); + return { + filePath: str(obj.filePath, `${label}.filePath`), + ...lineRange(obj, 0, label), + body: optStr(obj.body, `${label}.body`) ?? '', + annotation: optStr(obj.annotation, `${label}.annotation`) ?? '', + }; +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index d5a9026e..89722fd9 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -7,3 +7,4 @@ export * from './sessions.js'; export * from './diff.js'; export * from './tree.js'; export * from './requests.js'; +export * from './bundle.js'; diff --git a/packages/api/src/parse.ts b/packages/api/src/parse.ts new file mode 100644 index 00000000..da5a4768 --- /dev/null +++ b/packages/api/src/parse.ts @@ -0,0 +1,130 @@ +import { AUTHOR_TYPES, type CommentAuthor } from './threads.js'; + +/** + * What a parser answers: the typed value, or what is wrong with the input. Parsing happens at a + * boundary — a request body, a bundle file — so the message is written for whoever sent it. + */ +export type ParseResult = { ok: true; value: T } | { ok: false; error: string }; + +// Hand-rolled rather than a schema library: the wire has one small shape per route, and a field +// reader that throws keeps each parser a flat object literal. + +export class FieldError extends Error {} + +export function record(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new FieldError(`${label} must be an object`); + } + return value as Record; +} + +export function parseWith( + body: unknown, + build: (obj: Record) => T, + rootLabel = 'Request body', +): ParseResult { + try { + return { ok: true, value: build(record(body, rootLabel)) }; + } catch (error) { + if (error instanceof FieldError) { + return { ok: false, error: error.message }; + } + throw error; + } +} + +export function str(value: unknown, label: string): string { + if (typeof value !== 'string' || value === '') { + throw new FieldError(`${label} must be a non-empty string`); + } + return value; +} + +/** For the few fields where empty means something, like the editor path that means the repo root. */ +export function anyStr(value: unknown, label: string): string { + if (typeof value !== 'string') { + throw new FieldError(`${label} must be a string`); + } + return value; +} + +export function optStr(value: unknown, label: string): string | undefined { + return value == null ? undefined : anyStr(value, label); +} + +export function int(value: unknown, label: string, min: number): number { + if (typeof value !== 'number' || !Number.isInteger(value) || value < min) { + throw new FieldError(`${label} must be an integer >= ${min}`); + } + return value; +} + +export function optInt(value: unknown, label: string, min: number): number | undefined { + return value == null ? undefined : int(value, label, min); +} + +export function optBool(value: unknown, label: string): boolean | undefined { + if (value == null) { + return undefined; + } + if (typeof value !== 'boolean') { + throw new FieldError(`${label} must be a boolean`); + } + return value; +} + +export function member(value: unknown, label: string, values: readonly T[]): T { + if (typeof value !== 'string' || !(values as readonly string[]).includes(value)) { + throw new FieldError(`${label} must be one of: ${values.join(', ')}`); + } + return value as T; +} + +export function optMember( + value: unknown, + label: string, + values: readonly T[], +): T | undefined { + return value == null ? undefined : member(value, label, values); +} + +export function list(value: unknown, label: string): unknown[] { + if (!Array.isArray(value)) { + throw new FieldError(`${label} must be an array`); + } + return value; +} + +/** + * Normalised to `toISOString()` form, so stored timestamps sort as time regardless of who wrote + * them. A zone is required: without one, `Date.parse` answers in the reader's zone, and the same + * bundle would import with different times on different machines. + */ +export function timestamp(value: unknown, label: string): string { + const raw = str(value, label); + const ms = Date.parse(raw); + if (Number.isNaN(ms) || !/(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw)) { + throw new FieldError(`${label} must be an ISO 8601 timestamp with a time zone`); + } + return new Date(ms).toISOString(); +} + +export function lineRange(obj: Record, min: number, label = ''): { startLine: number; endLine: number } { + const prefix = label ? `${label}.` : ''; + const startLine = int(obj.startLine, `${prefix}startLine`, min); + const endLine = int(obj.endLine, `${prefix}endLine`, min); + if (endLine < startLine) { + throw new FieldError(`${prefix}endLine must not be before ${prefix}startLine`); + } + return { startLine, endLine }; +} + +/** Built from the fields it names, so nothing else in the input object reaches storage. */ +export function author(value: unknown, label: string): CommentAuthor { + const obj = record(value, label); + return { + name: str(obj.name, `${label}.name`), + type: member(obj.type, `${label}.type`, AUTHOR_TYPES), + avatarUrl: optStr(obj.avatarUrl, `${label}.avatarUrl`), + }; +} diff --git a/packages/api/src/requests.ts b/packages/api/src/requests.ts index aa32abc0..8338ec8b 100644 --- a/packages/api/src/requests.ts +++ b/packages/api/src/requests.ts @@ -1,5 +1,4 @@ import { - AUTHOR_TYPES, COMMENT_KINDS, COMMENT_SIDES, LIVE_INTENTS, @@ -17,12 +16,24 @@ import { type PrComment, type ReviewSubmission, } from './github.js'; - -/** - * What a parser answers: the typed request, or what is wrong with the body. Parsing happens at - * the server boundary, so the message is written for whoever sent the request. - */ -export type ParseResult = { ok: true; value: T } | { ok: false; error: string }; +import { + FieldError, + anyStr, + author, + int, + lineRange, + member, + optBool, + optInt, + optMember, + optStr, + parseWith, + record, + str, + type ParseResult, +} from './parse.js'; + +export type { ParseResult } from './parse.js'; /** What `POST /api/threads` accepts. */ export interface CreateThreadRequest { @@ -110,103 +121,6 @@ export interface PullCommentsRequest { sessionId: string; } -// Hand-rolled rather than a schema library: the wire has one small shape per route, and a field -// reader that throws keeps each parser a flat object literal. - -class FieldError extends Error {} - -function record(value: unknown, label: string): Record { - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - throw new FieldError(`${label} must be an object`); - } - return value as Record; -} - -function parseWith(body: unknown, build: (obj: Record) => T): ParseResult { - try { - return { ok: true, value: build(record(body, 'Request body')) }; - } catch (error) { - if (error instanceof FieldError) { - return { ok: false, error: error.message }; - } - throw error; - } -} - -function str(value: unknown, label: string): string { - if (typeof value !== 'string' || value === '') { - throw new FieldError(`${label} must be a non-empty string`); - } - return value; -} - -/** For the few fields where empty means something, like the editor path that means the repo root. */ -function anyStr(value: unknown, label: string): string { - if (typeof value !== 'string') { - throw new FieldError(`${label} must be a string`); - } - return value; -} - -function optStr(value: unknown, label: string): string | undefined { - return value == null ? undefined : anyStr(value, label); -} - -function int(value: unknown, label: string, min: number): number { - if (typeof value !== 'number' || !Number.isInteger(value) || value < min) { - throw new FieldError(`${label} must be an integer >= ${min}`); - } - return value; -} - -function optInt(value: unknown, label: string, min: number): number | undefined { - return value == null ? undefined : int(value, label, min); -} - -function optBool(value: unknown, label: string): boolean | undefined { - if (value == null) { - return undefined; - } - if (typeof value !== 'boolean') { - throw new FieldError(`${label} must be a boolean`); - } - return value; -} - -function member(value: unknown, label: string, values: readonly T[]): T { - if (typeof value !== 'string' || !(values as readonly string[]).includes(value)) { - throw new FieldError(`${label} must be one of: ${values.join(', ')}`); - } - return value as T; -} - -function optMember( - value: unknown, - label: string, - values: readonly T[], -): T | undefined { - return value == null ? undefined : member(value, label, values); -} - -function lineRange(obj: Record, min: number): { startLine: number; endLine: number } { - const startLine = int(obj.startLine, 'startLine', min); - const endLine = int(obj.endLine, 'endLine', min); - if (endLine < startLine) { - throw new FieldError('endLine must not be before startLine'); - } - return { startLine, endLine }; -} - -/** Built from the fields it names, so nothing else in the request object reaches storage. */ -function author(value: unknown, label: string): CommentAuthor { - const obj = record(value, label); - return { - name: str(obj.name, `${label}.name`), - type: member(obj.type, `${label}.type`, AUTHOR_TYPES), - avatarUrl: optStr(obj.avatarUrl, `${label}.avatarUrl`), - }; -} - export function parseCreateThreadRequest(body: unknown): ParseResult { return parseWith(body, obj => ({ sessionId: str(obj.sessionId, 'sessionId'), diff --git a/packages/api/tests/bundle.test.ts b/packages/api/tests/bundle.test.ts new file mode 100644 index 00000000..91a52a9e --- /dev/null +++ b/packages/api/tests/bundle.test.ts @@ -0,0 +1,168 @@ +import { describe, it, expect } from 'vitest'; +import { BUNDLE_FORMAT_VERSION, parseReviewBundle } from '../src/bundle.js'; + +function validBundle(): Record { + return { + formatVersion: BUNDLE_FORMAT_VERSION, + headSha: 'a'.repeat(40), + ref: 'main', + baseSha: 'b'.repeat(40), + repo: { owner: 'o', repo: 'r' }, + prNumber: 12, + createdAt: '2026-09-02T10:00:00.000Z', + generator: 'diffity 0.10.7', + threads: [ + { + filePath: 'src/a.ts', + side: 'new', + startLine: 4, + endLine: 6, + status: 'open', + anchorContent: 'const a = 1;', + comments: [ + { author: { name: 'Agent', type: 'agent' }, body: 'P2: name this', kind: 'review', createdAt: '2026-09-02T10:00:00.000Z' }, + { author: { name: 'You', type: 'user' }, body: 'agreed', kind: 'aside', createdAt: '2026-09-02T10:01:00.000Z' }, + ], + }, + { + filePath: '__general__', + side: 'new', + startLine: 0, + endLine: 0, + status: 'resolved', + anchorContent: null, + comments: [ + { author: { name: 'Agent', type: 'agent' }, body: 'Looks fine overall', kind: 'review', createdAt: '2026-09-02T10:00:00.000Z' }, + ], + }, + ], + tours: [ + { + topic: 'Reading order', + body: 'Start where the data enters', + status: 'ready', + steps: [ + { filePath: 'src/a.ts', startLine: 1, endLine: 3, body: 'The entry point', annotation: 'read first' }, + ], + }, + ], + }; +} + +function errorOf(input: unknown): string { + const result = parseReviewBundle(input); + if (result.ok) { + throw new Error('expected the bundle to be rejected'); + } + return result.error; +} + +describe('parseReviewBundle', () => { + it('accepts a complete bundle and keeps every field', () => { + const input = validBundle(); + const result = parseReviewBundle(input); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value).toEqual(input); + }); + + it('fills in the optional context fields as null and empty text', () => { + const input = validBundle(); + delete input.baseSha; + delete input.repo; + delete input.prNumber; + (input.tours as Record[])[0].body = undefined; + ((input.tours as Record[])[0].steps as Record[])[0].annotation = undefined; + + const result = parseReviewBundle(input); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.baseSha).toBeNull(); + expect(result.value.repo).toBeNull(); + expect(result.value.prNumber).toBeNull(); + expect(result.value.tours[0].body).toBe(''); + expect(result.value.tours[0].steps[0].annotation).toBe(''); + }); + + it('rejects a bundle from a newer format', () => { + expect(errorOf({ ...validBundle(), formatVersion: BUNDLE_FORMAT_VERSION + 1 })) + .toContain('newer than this diffity understands'); + }); + + it('rejects anything that is not an object, in words about a bundle', () => { + expect(errorOf('a string')).toBe('The bundle must be an object'); + expect(errorOf(null)).toBe('The bundle must be an object'); + expect(errorOf([])).toBe('The bundle must be an object'); + }); + + it('names the missing head', () => { + const input = validBundle(); + delete input.headSha; + expect(errorOf(input)).toBe('headSha must be a non-empty string'); + }); + + it('refuses a thread without an opening comment', () => { + const input = validBundle(); + (input.threads as Record[])[0].comments = []; + expect(errorOf(input)).toBe('threads[0].comments must not be empty'); + }); + + it('holds every enum to its members, naming the field', () => { + const sideways = validBundle(); + (sideways.threads as Record[])[1].side = 'sideways'; + expect(errorOf(sideways)).toBe('threads[1].side must be one of: old, new'); + + const zapped = validBundle(); + (zapped.threads as Record[])[0].status = 'zapped'; + expect(errorOf(zapped)).toBe('threads[0].status must be one of: open, resolved, dismissed'); + + const robot = validBundle(); + ((robot.threads as Record[])[0].comments as Record[])[0].author = { name: 'X', type: 'robot' }; + expect(errorOf(robot)).toBe('threads[0].comments[0].author.type must be one of: user, agent'); + + const halfBuilt = validBundle(); + (halfBuilt.tours as Record[])[0].status = 'half'; + expect(errorOf(halfBuilt)).toBe('tours[0].status must be one of: building, ready'); + }); + + it('names where a reversed line range sits', () => { + const input = validBundle(); + const step = ((input.tours as Record[])[0].steps as Record[])[0]; + step.startLine = 5; + step.endLine = 2; + expect(errorOf(input)).toBe('tours[0].steps[0].endLine must not be before tours[0].steps[0].startLine'); + + const thread = validBundle(); + (thread.threads as Record[])[1].endLine = 'nine'; + expect(errorOf(thread)).toBe('threads[1].endLine must be an integer >= 0'); + }); + + it('normalises timestamps to one form and refuses what is not a time', () => { + const spaced = validBundle(); + ((spaced.threads as Record[])[0].comments as Record[])[0].createdAt = '2026-09-02 12:00:00Z'; + const result = parseReviewBundle(spaced); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.threads[0].comments[0].createdAt).toBe('2026-09-02T12:00:00.000Z'); + + const vague = validBundle(); + ((vague.threads as Record[])[0].comments as Record[])[0].createdAt = 'yesterday'; + expect(errorOf(vague)).toBe('threads[0].comments[0].createdAt must be an ISO 8601 timestamp with a time zone'); + + // Valid ISO 8601, but it would mean a different instant on every machine that reads it. + const zoneless = validBundle(); + ((zoneless.threads as Record[])[0].comments as Record[])[0].createdAt = '2026-09-02T12:00:00'; + expect(errorOf(zoneless)).toBe('threads[0].comments[0].createdAt must be an ISO 8601 timestamp with a time zone'); + + expect(errorOf({ ...validBundle(), createdAt: 'soon' })).toBe('createdAt must be an ISO 8601 timestamp with a time zone'); + }); + + it('takes an absent base as unknown, but not an empty one', () => { + expect(errorOf({ ...validBundle(), baseSha: '' })).toBe('baseSha must be a non-empty string'); + }); + + it('requires the collections to be arrays', () => { + expect(errorOf({ ...validBundle(), threads: 'none' })).toBe('threads must be an array'); + expect(errorOf({ ...validBundle(), tours: {} })).toBe('tours must be an array'); + }); +}); diff --git a/packages/cli/package.json b/packages/cli/package.json index 0d113006..906eb73e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@naturalcycles/diffity", - "version": "0.10.7", + "version": "0.10.8", "description": "Agent-agnostic, GitHub-style diff viewer and code review tool with a live agent loop", "type": "module", "bin": { diff --git a/packages/cli/src/agent.ts b/packages/cli/src/agent.ts index 1ec33428..28d371a9 100644 --- a/packages/cli/src/agent.ts +++ b/packages/cli/src/agent.ts @@ -2,7 +2,8 @@ import { existsSync, statSync } from 'node:fs'; import { join, isAbsolute } from 'node:path'; import { InvalidArgumentError, type Command } from 'commander'; import pc from 'picocolors'; -import { isGitRepo, getDiffFiles, resolveRef, getRepoRoot } from '@diffity/git'; +import { isGitRepo, getDiffFiles, resolveRef, getRepoRoot, getHeadHash, getDirtyPaths } from '@diffity/git'; +import { detectRemote } from '@diffity/github'; import { createThread, getThreadsForSession, @@ -15,11 +16,14 @@ import { import { GENERAL_THREAD_FILE_PATH, isThreadStatus, + parseReviewBundle, THREAD_STATUSES, type ClaimResponse, type GitHubDetails, type LiveStatusResponse, + type ReviewBundle, } from '@diffity/api'; +import { baseShaOf, buildBundle, exportMismatch, importBundle, importMismatch, scopeWarning } from './bundle.js'; import { answerLiveRequest } from './live.js'; import { clampClientWait, CLIENT_WAIT_CAP_SECONDS } from './live-wait.js'; import { directiveFor } from './live-intent.js'; @@ -31,7 +35,7 @@ import { describeSince } from './live-events.js'; import { readAnchor, clampToFile, countWorkingTreeLines } from './anchor.js'; import { startReviewRun, finishReviewRun } from './review-run.js'; import { readRepoConfig, DEFAULT_SEVERITIES, resolveInRepo, REPO_CONFIG_FILE } from '@diffity/git'; -import { readFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; async function requireSession(explicitId?: string): Promise { if (!isGitRepo()) { @@ -699,4 +703,101 @@ Examples: } console.log(pc.green('Tour marked as ready')); }); + + agent + .command('export-bundle') + .description('Write the session\'s threads and tours as a portable review bundle (JSON)') + .option('--out ', 'Write to this file instead of stdout') + .option('--pr ', 'Pull request number to record in the bundle', positiveInteger) + .action(async (opts: { out?: string; pr?: number }) => { + const session = await requireSession(agent.opts().session); + const mismatch = exportMismatch(session, getHeadHash()); + if (mismatch) { + console.error(pc.red(`Error: ${mismatch}`)); + process.exit(1); + } + const dirty = getDirtyPaths(); + if (dirty.length > 0) { + console.error(pc.yellow(`Warning: ${dirty.length} uncommitted change(s) in the working tree; the bundle's lines mean this tree, not the commit alone.`)); + } + const bundle = buildBundle(session, { + prNumber: opts.pr ?? null, + generator: `diffity ${program.version() ?? ''}`.trim(), + }); + const json = JSON.stringify(bundle, null, 2); + if (opts.out) { + try { + writeFileSync(opts.out, json + '\n'); + } catch (err) { + console.error(pc.red(`Error: Could not write "${opts.out}": ${err instanceof Error ? err.message : err}`)); + process.exit(1); + } + console.log(pc.green(`Wrote ${bundle.threads.length} thread(s) and ${bundle.tours.length} tour(s) to ${opts.out}`)); + return; + } + console.log(json); + }); + + agent + .command('import-bundle') + .description('Add a review bundle\'s threads and tours to the session; "-" reads stdin') + .argument('', 'Bundle file, or "-" for stdin') + .option('--force', 'Import even though HEAD or the repository differs from the bundle\'s') + .action(async (file: string, opts: { force?: boolean }) => { + const bundle = readBundleOrExit(file); + const session = await requireSession(agent.opts().session); + + const mismatch = importMismatch(bundle, getHeadHash(), detectRemote()); + if (mismatch) { + if (!opts.force) { + console.error(pc.red(`Error: ${mismatch}`)); + console.error(pc.dim('Pass --force to import anyway.')); + process.exit(1); + } + console.error(pc.yellow(`Warning: ${mismatch} Importing anyway.`)); + } + const caution = scopeWarning(bundle, session, baseShaOf(session.ref)); + if (caution) { + console.error(pc.yellow(`Warning: ${caution}`)); + } + + const outcome = importBundle(session, bundle); + console.log(pc.green( + `Imported ${outcome.threadsCreated} thread(s) and ${outcome.toursCreated} tour(s)` + + (outcome.threadsSkipped || outcome.toursSkipped + ? pc.dim(` (already present: ${outcome.threadsSkipped} thread(s), ${outcome.toursSkipped} tour(s))`) + : ''), + )); + }); +} + +function positiveInteger(value: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new InvalidArgumentError('Expected a positive integer.'); + } + return parsed; +} + +function readBundleOrExit(file: string): ReviewBundle { + let raw: string; + try { + raw = readFileSync(file === '-' ? 0 : file, 'utf-8'); + } catch (err) { + console.error(pc.red(`Error: Could not read ${file === '-' ? 'stdin' : `"${file}"`}: ${err instanceof Error ? err.message : err}`)); + process.exit(1); + } + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + console.error(pc.red('Error: The bundle is not valid JSON')); + process.exit(1); + } + const parsed = parseReviewBundle(json); + if (!parsed.ok) { + console.error(pc.red(`Error: Not a review bundle: ${parsed.error}`)); + process.exit(1); + } + return parsed.value; } diff --git a/packages/cli/src/bundle.ts b/packages/cli/src/bundle.ts new file mode 100644 index 00000000..23ad9d72 --- /dev/null +++ b/packages/cli/src/bundle.ts @@ -0,0 +1,216 @@ +import { + BUNDLE_FORMAT_VERSION, + type BundleThread, + type BundleTour, + type GitHubRemote, + type ReviewBundle, +} from '@diffity/api'; +import { getCommitHash, resolveBaseRef } from '@diffity/git'; +import { detectRemote } from '@diffity/github'; +import { getDb } from './db.js'; +import type { Session } from './session.js'; +import { addReply, createThread, getThreadsForSession, updateThreadStatus, type Thread } from './threads.js'; +import { addTourStep, createTour, getToursForSession, updateTourStatus, type Tour } from './tours.js'; + +export interface BundleOrigin { + prNumber: number | null; + generator: string; +} + +/** + * Pinned to the session's own commit rather than HEAD: the threads were last anchored against + * that tree, and only reopening the review moves them along with a newer commit. + */ +export function buildBundle(session: Session, origin: BundleOrigin): ReviewBundle { + return { + formatVersion: BUNDLE_FORMAT_VERSION, + headSha: session.headHash, + ref: session.ref, + baseSha: baseShaOf(session.ref), + repo: detectRemote(), + prNumber: origin.prNumber, + createdAt: new Date().toISOString(), + generator: origin.generator, + threads: getThreadsForSession(session.id).map(thread => ({ + filePath: thread.filePath, + side: thread.side, + startLine: thread.startLine, + endLine: thread.endLine, + status: thread.status, + anchorContent: thread.anchorContent, + comments: thread.comments.map(comment => ({ + author: comment.author, + body: comment.body, + kind: comment.kind, + createdAt: comment.createdAt, + })), + })), + tours: getToursForSession(session.id).map(tour => ({ + topic: tour.topic, + body: tour.body, + status: tour.status, + steps: tour.steps.map(step => ({ + filePath: step.filePath, + startLine: step.startLine, + endLine: step.endLine, + body: step.body, + annotation: step.annotation, + })), + })), + }; +} + +/** Sessions without a diff base — the tree browser, an unborn ref — carry no base. */ +export function baseShaOf(ref: string): string | null { + try { + return getCommitHash(resolveBaseRef(ref)); + } catch { + return null; + } +} + +/** + * Why the session must not be exported right now, or null when it may. A session left behind by a + * commit still holds its findings at the old lines; reopening the review carries them across. + */ +export function exportMismatch(session: Session, head: string): string | null { + if (session.headHash !== head) { + return `The session was anchored at ${session.headHash.slice(0, 12)}, but HEAD is ${head.slice(0, 12)}. Open the review once so the findings follow the commit, then export.`; + } + return null; +} + +/** + * Why the bundle must not be imported here, or null when it may. Anchors are line numbers in the + * bundle's HEAD: on any other commit they may point at the wrong lines, in another repository at + * nothing at all. + */ +export function importMismatch(bundle: ReviewBundle, head: string, remote: GitHubRemote | null): string | null { + if (bundle.headSha !== head) { + return `The bundle was made at ${bundle.headSha.slice(0, 12)}, but HEAD is ${head.slice(0, 12)}.`; + } + if (bundle.repo && remote && !sameRepo(bundle.repo, remote)) { + return `The bundle is for ${bundle.repo.owner}/${bundle.repo.repo}, but this repository is ${remote.owner}/${remote.repo}.`; + } + return null; +} + +function sameRepo(a: GitHubRemote, b: GitHubRemote): boolean { + return a.owner.toLowerCase() === b.owner.toLowerCase() && a.repo.toLowerCase() === b.repo.toLowerCase(); +} + +/** + * A caution worth printing before an import that will succeed: a thread on a file outside the + * session's diff is stored but never shown, and the base decides which files that is. Compared + * as commits where both are known — a pull request session's ref is its base branch's tip, which + * moves with every merge while the diff stays put — and by ref name only when one side has none. + */ +export function scopeWarning(bundle: ReviewBundle, session: Session, sessionBaseSha: string | null): string | null { + const sameScope = bundle.baseSha && sessionBaseSha + ? bundle.baseSha === sessionBaseSha + : bundle.ref === session.ref; + if (sameScope) { + return null; + } + const exported = bundle.baseSha ? `against ${bundle.baseSha.slice(0, 12)}` : `on "${bundle.ref}"`; + const here = sessionBaseSha ? `against ${sessionBaseSha.slice(0, 12)}` : `on "${session.ref}"`; + return `The bundle was exported from a review ${exported}; this session reviews ${here}. Findings on files outside this diff will not be shown.`; +} + +export interface ImportOutcome { + threadsCreated: number; + threadsSkipped: number; + toursCreated: number; + toursSkipped: number; +} + +/** + * Adds the bundle's threads and tours to the session, skipping what is already there: a thread at + * the same position opening with the same comment, a tour on the same topic. Importing twice is + * therefore the same as importing once. All or nothing: a failure midway would otherwise leave a + * thread row without its comments, or with its opener but not its replies — the first a state + * nothing else can produce, the second one the retry would skip as "already present". + * + * The write lock is taken up front: the session's server polls this database every few seconds, + * and a deferred transaction that had already read would lose to that poll at its first write. + */ +export function importBundle(session: Session, bundle: ReviewBundle): ImportOutcome { + const db = getDb(); + db.exec('BEGIN IMMEDIATE'); + try { + const outcome = addBundle(session, bundle); + db.exec('COMMIT'); + return outcome; + } catch (err) { + // SQLite may already have rolled back on its own (a full disk, an I/O error); the original + // error is the one worth surfacing, not "no transaction is active". + try { + db.exec('ROLLBACK'); + } catch { + // nothing left to undo + } + throw err; + } +} + +function addBundle(session: Session, bundle: ReviewBundle): ImportOutcome { + const outcome: ImportOutcome = { threadsCreated: 0, threadsSkipped: 0, toursCreated: 0, toursSkipped: 0 }; + + const existingThreads = getThreadsForSession(session.id); + for (const incoming of bundle.threads) { + if (existingThreads.some(thread => holdsThread(thread, incoming))) { + outcome.threadsSkipped++; + continue; + } + const [first, ...replies] = incoming.comments; + const thread = createThread( + session.id, + incoming.filePath, + incoming.side, + incoming.startLine, + incoming.endLine, + first.body, + first.author, + incoming.anchorContent ?? undefined, + first.kind, + first.createdAt, + ); + for (const reply of replies) { + addReply(thread.id, reply.body, reply.author, reply.kind, reply.createdAt); + } + if (incoming.status !== 'open') { + updateThreadStatus(thread.id, incoming.status); + } + outcome.threadsCreated++; + } + + const existingTours = getToursForSession(session.id); + for (const incoming of bundle.tours) { + if (existingTours.some(tour => holdsTour(tour, incoming))) { + outcome.toursSkipped++; + continue; + } + const tour = createTour(session.id, incoming.topic, incoming.body); + for (const step of incoming.steps) { + addTourStep(tour.id, step.filePath, step.startLine, step.endLine, step.body, step.annotation); + } + if (incoming.status !== 'building') { + updateTourStatus(tour.id, incoming.status); + } + outcome.toursCreated++; + } + + return outcome; +} + +function holdsThread(thread: Thread, incoming: BundleThread): boolean { + return thread.filePath === incoming.filePath + && thread.side === incoming.side + && thread.startLine === incoming.startLine + && thread.endLine === incoming.endLine + && thread.comments[0]?.body === incoming.comments[0].body; +} + +function holdsTour(tour: Tour, incoming: BundleTour): boolean { + return tour.topic === incoming.topic; +} diff --git a/packages/cli/src/threads.ts b/packages/cli/src/threads.ts index 2fdf43e4..bca68b9b 100644 --- a/packages/cli/src/threads.ts +++ b/packages/cli/src/threads.ts @@ -205,11 +205,12 @@ export function createThread( author: ThreadAuthor, anchorContent?: string, kind: CommentKind = 'review', + createdAt?: string, ): Thread { const db = getDb(); const threadId = randomUUID(); const commentId = randomUUID(); - const now = new Date().toISOString(); + const now = createdAt ?? new Date().toISOString(); const cleanBody = body; @@ -326,10 +327,11 @@ export function addReply( body: string, author: ThreadAuthor, kind: CommentKind = 'review', + createdAt?: string, ): ThreadComment { const db = getDb(); const commentId = randomUUID(); - const now = new Date().toISOString(); + const now = createdAt ?? new Date().toISOString(); const cleanBody = body; db.prepare( diff --git a/packages/cli/tests/bundle-roundtrip.test.ts b/packages/cli/tests/bundle-roundtrip.test.ts new file mode 100644 index 00000000..bdd8001a --- /dev/null +++ b/packages/cli/tests/bundle-roundtrip.test.ts @@ -0,0 +1,265 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import type { ReviewBundle } from '@diffity/api'; + +let root: string; +let origCwd: string; +let repoDir: string; +let headSha: string; +let repoCount = 0; + +function git(cwd: string, args: string[]): string { + return execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf-8' }).trim(); +} + +// Sessions in one repository share their open work across commits, so each test gets a +// repository of its own and the tests cannot see each other's threads. +function freshRepo(): string { + const dir = join(root, `repo-${repoCount++}`); + execFileSync('git', ['init', '-b', 'main', dir], { stdio: 'pipe' }); + git(dir, ['config', 'user.email', 't@t']); + git(dir, ['config', 'user.name', 'T']); + writeFileSync(join(dir, 'a.ts'), 'const a = 1;\nconst b = 2;\nconst c = 3;\n'); + git(dir, ['add', '.']); + git(dir, ['commit', '-m', 'init']); + git(dir, ['remote', 'add', 'origin', 'https://github.com/o/r.git']); + return dir; +} + +beforeAll(() => { + origCwd = process.cwd(); + root = mkdtempSync(join(tmpdir(), 'diffity-bundle-')); + process.env.DIFFITY_DATA_DIR = join(root, 'notes'); +}); + +beforeEach(() => { + repoDir = freshRepo(); + headSha = git(repoDir, ['rev-parse', 'HEAD']); + process.chdir(repoDir); +}); + +afterAll(() => { + process.chdir(origCwd); + delete process.env.DIFFITY_DATA_DIR; + rmSync(root, { recursive: true, force: true }); +}); + +const agent = { name: 'Agent', type: 'agent' as const }; +const you = { name: 'You', type: 'user' as const }; + +function emptyBundle(): ReviewBundle { + return { + formatVersion: 1, headSha, ref: 'work', baseSha: null, repo: null, prNumber: null, + createdAt: '2026-09-02T12:00:00.000Z', generator: 'test', threads: [], tours: [], + }; +} + +async function preparedSession() { + const { findOrCreateSession } = await import('../src/session.js'); + const { createThread, addReply, updateThreadStatus } = await import('../src/threads.js'); + const { createTour, addTourStep, updateTourStatus } = await import('../src/tours.js'); + + const session = findOrCreateSession('work'); + const finding = createThread(session.id, 'a.ts', 'new', 2, 2, 'P2: name this', agent, 'const b = 2;'); + addReply(finding.id, 'Will do', you, 'aside'); + const settled = createThread(session.id, 'a.ts', 'new', 3, 3, 'P3: trailing const', agent); + updateThreadStatus(settled.id, 'resolved'); + createThread(session.id, '__general__', 'new', 0, 0, 'Two small things', agent); + + const tour = createTour(session.id, 'Reading order', 'Top to bottom'); + addTourStep(tour.id, 'a.ts', 1, 1, 'Where a is born', 'the start'); + addTourStep(tour.id, 'a.ts', 3, 3, 'Where it ends', 'the end'); + updateTourStatus(tour.id, 'ready'); + return session; +} + +describe('a review bundle', () => { + it('carries the session pinned to HEAD, without ids or forge state', async () => { + const { buildBundle } = await import('../src/bundle.js'); + const session = await preparedSession(); + + const bundle = buildBundle(session, { prNumber: 7, generator: 'test' }); + + expect(bundle.formatVersion).toBe(1); + expect(bundle.headSha).toBe(headSha); + expect(bundle.baseSha).toBe(headSha); + expect(bundle.ref).toBe('work'); + expect(bundle.repo).toEqual({ owner: 'o', repo: 'r' }); + expect(bundle.prNumber).toBe(7); + expect(bundle.generator).toBe('test'); + + expect(bundle.threads).toHaveLength(3); + const finding = bundle.threads.find(thread => thread.startLine === 2)!; + expect(finding.anchorContent).toBe('const b = 2;'); + expect(finding.comments.map(comment => [comment.author.name, comment.kind, comment.body])).toEqual([ + ['Agent', 'review', 'P2: name this'], + ['You', 'aside', 'Will do'], + ]); + expect(bundle.threads.find(thread => thread.startLine === 3)!.status).toBe('resolved'); + expect(bundle.threads.find(thread => thread.filePath === '__general__')!.startLine).toBe(0); + expect(JSON.stringify(bundle)).not.toMatch(/"id"|sessionId|submitted|githubCommentId|live/); + + expect(bundle.tours).toHaveLength(1); + expect(bundle.tours[0].status).toBe('ready'); + expect(bundle.tours[0].steps.map(step => step.annotation)).toEqual(['the start', 'the end']); + }); + + it('survives the trip through JSON and the parser unchanged', async () => { + const { buildBundle } = await import('../src/bundle.js'); + const { parseReviewBundle } = await import('@diffity/api'); + const session = await preparedSession(); + + const bundle = buildBundle(session, { prNumber: null, generator: 'test' }); + const parsed = parseReviewBundle(JSON.parse(JSON.stringify(bundle))); + + expect(parsed).toEqual({ ok: true, value: bundle }); + }); + + it('imports into a clone at the same commit, and a second import adds nothing', async () => { + const { buildBundle, importBundle } = await import('../src/bundle.js'); + const { findOrCreateSession } = await import('../src/session.js'); + const { getThreadsForSession } = await import('../src/threads.js'); + const { getToursForSession } = await import('../src/tours.js'); + const bundle = buildBundle(await preparedSession(), { prNumber: null, generator: 'test' }); + + const cloneDir = join(root, `clone-${repoCount++}`); + execFileSync('git', ['clone', '--quiet', repoDir, cloneDir], { stdio: 'pipe' }); + process.chdir(cloneDir); + const target = findOrCreateSession('work'); + + const first = importBundle(target, bundle); + expect(first).toEqual({ threadsCreated: 3, threadsSkipped: 0, toursCreated: 1, toursSkipped: 0 }); + + const threads = getThreadsForSession(target.id); + const finding = threads.find(thread => thread.startLine === 2)!; + expect(finding.comments.map(comment => [comment.author.type, comment.kind, comment.body])).toEqual([ + ['agent', 'review', 'P2: name this'], + ['user', 'aside', 'Will do'], + ]); + expect(finding.anchorContent).toBe('const b = 2;'); + expect(threads.find(thread => thread.startLine === 3)!.status).toBe('resolved'); + const tour = getToursForSession(target.id)[0]; + expect(tour.status).toBe('ready'); + expect(tour.steps.map(step => [step.sortOrder, step.body])).toEqual([[1, 'Where a is born'], [2, 'Where it ends']]); + + const second = importBundle(target, bundle); + expect(second).toEqual({ threadsCreated: 0, threadsSkipped: 3, toursCreated: 0, toursSkipped: 1 }); + expect(getThreadsForSession(target.id)).toHaveLength(3); + expect(getToursForSession(target.id)).toHaveLength(1); + }); + + it('keeps the comments\' own timestamps, so replies read back in their original order', async () => { + const { buildBundle, importBundle } = await import('../src/bundle.js'); + const { findOrCreateSession } = await import('../src/session.js'); + const { getThreadsForSession } = await import('../src/threads.js'); + const bundle = buildBundle(await preparedSession(), { prNumber: null, generator: 'test' }); + const exported = bundle.threads.find(thread => thread.startLine === 2)!.comments.map(comment => comment.createdAt); + + const cloneDir = join(root, `clone-${repoCount++}`); + execFileSync('git', ['clone', '--quiet', repoDir, cloneDir], { stdio: 'pipe' }); + process.chdir(cloneDir); + const target = findOrCreateSession('work'); + importBundle(target, bundle); + + const imported = getThreadsForSession(target.id).find(thread => thread.startLine === 2)!; + expect(imported.comments.map(comment => comment.createdAt)).toEqual(exported); + expect(imported.createdAt).toBe(exported[0]); + }); + + it('does not mistake a reply for an opening comment when deciding what is already there', async () => { + const { importBundle } = await import('../src/bundle.js'); + const { getThreadsForSession } = await import('../src/threads.js'); + const session = await preparedSession(); + const opensLikeAReply = { + ...emptyBundle(), + threads: [{ + filePath: 'a.ts', side: 'new' as const, startLine: 2, endLine: 2, status: 'open' as const, anchorContent: null, + comments: [{ author: agent, body: 'Will do', kind: 'review' as const, createdAt: '2026-09-02T12:00:00.000Z' }], + }], + }; + + const outcome = importBundle(session, opensLikeAReply); + + expect(outcome.threadsCreated).toBe(1); + expect(getThreadsForSession(session.id).filter(thread => thread.startLine === 2)).toHaveLength(2); + }); + + it('imports all or nothing, so a failed import can be retried whole', async () => { + const { importBundle } = await import('../src/bundle.js'); + const { findOrCreateSession } = await import('../src/session.js'); + const { getThreadsForSession } = await import('../src/threads.js'); + const session = findOrCreateSession('work'); + const sound = { + filePath: 'a.ts', side: 'new' as const, startLine: 1, endLine: 1, status: 'open' as const, anchorContent: null, + comments: [{ author: agent, body: 'fine', kind: 'review' as const, createdAt: '2026-09-02T12:00:00.000Z' }], + }; + // Past the parser on purpose: a body the database refuses, as a stand-in for any mid-import failure. + const broken = { ...sound, startLine: 3, endLine: 3, comments: [{ ...sound.comments[0], body: null as unknown as string }] }; + + expect(() => importBundle(session, { ...emptyBundle(), threads: [sound, broken] })).toThrow(/NOT NULL/); + expect(getThreadsForSession(session.id)).toHaveLength(0); + + const retried = importBundle(session, { ...emptyBundle(), threads: [sound] }); + expect(retried.threadsCreated).toBe(1); + }); + + it('is not exported once a commit has left the session behind', async () => { + const { buildBundle, exportMismatch } = await import('../src/bundle.js'); + const session = await preparedSession(); + expect(exportMismatch(session, headSha)).toBeNull(); + + writeFileSync(join(repoDir, 'a.ts'), 'const a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;\n'); + git(repoDir, ['commit', '-qam', 'more']); + const movedHead = git(repoDir, ['rev-parse', 'HEAD']); + + expect(exportMismatch(session, movedHead)) + .toBe(`The session was anchored at ${headSha.slice(0, 12)}, but HEAD is ${movedHead.slice(0, 12)}. Open the review once so the findings follow the commit, then export.`); + expect(buildBundle(session, { prNumber: null, generator: 'test' }).headSha).toBe(headSha); + }); + + it('cautions when the bundle and the session review against different bases, judged as commits', async () => { + const { buildBundle, scopeWarning, baseShaOf } = await import('../src/bundle.js'); + const { findOrCreateSession } = await import('../src/session.js'); + const session = await preparedSession(); + const bundle = buildBundle(session, { prNumber: null, generator: 'test' }); + expect(bundle.baseSha).toBe(headSha); + + // Another name for the same base is the same review: a pull request's base ref is the branch + // tip, which moves with every merge while the diff does not. + const byAnotherName = findOrCreateSession('main'); + expect(byAnotherName.ref).not.toBe(session.ref); + expect(scopeWarning(bundle, byAnotherName, baseShaOf(byAnotherName.ref))).toBeNull(); + + const other = 'e'.repeat(40); + expect(scopeWarning(bundle, byAnotherName, other)) + .toBe(`The bundle was exported from a review against ${headSha.slice(0, 12)}; this session reviews against eeeeeeeeeeee. Findings on files outside this diff will not be shown.`); + + // With no base on one side, only the ref names are left to compare, and each side is named + // by what it has. + const baseless = { ...bundle, baseSha: null }; + expect(scopeWarning(baseless, session, headSha)).toBeNull(); + expect(scopeWarning(baseless, byAnotherName, null)) + .toBe('The bundle was exported from a review on "work"; this session reviews on "main". Findings on files outside this diff will not be shown.'); + expect(scopeWarning(baseless, byAnotherName, other)) + .toBe('The bundle was exported from a review on "work"; this session reviews against eeeeeeeeeeee. Findings on files outside this diff will not be shown.'); + expect(scopeWarning(bundle, byAnotherName, null)) + .toBe(`The bundle was exported from a review against ${headSha.slice(0, 12)}; this session reviews on "main". Findings on files outside this diff will not be shown.`); + }); + + it('is refused on another commit or another repository', async () => { + const { buildBundle, importMismatch } = await import('../src/bundle.js'); + const bundle = buildBundle(await preparedSession(), { prNumber: null, generator: 'test' }); + + expect(importMismatch(bundle, headSha, { owner: 'o', repo: 'r' })).toBeNull(); + expect(importMismatch(bundle, headSha, { owner: 'O', repo: 'R' })).toBeNull(); + expect(importMismatch(bundle, headSha, null)).toBeNull(); + + expect(importMismatch(bundle, 'f'.repeat(40), { owner: 'o', repo: 'r' })) + .toBe(`The bundle was made at ${headSha.slice(0, 12)}, but HEAD is ffffffffffff.`); + expect(importMismatch(bundle, headSha, { owner: 'o', repo: 'other' })) + .toBe('The bundle is for o/r, but this repository is o/other.'); + }); +}); diff --git a/packages/git/package.json b/packages/git/package.json index e39b3813..88f27b39 100644 --- a/packages/git/package.json +++ b/packages/git/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/git", - "version": "0.10.7", + "version": "0.10.8", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/git/src/index.ts b/packages/git/src/index.ts index 96ae3aeb..9918137a 100644 --- a/packages/git/src/index.ts +++ b/packages/git/src/index.ts @@ -1,6 +1,6 @@ export type { Commit, RepoInfo } from './types.js'; export type { RefCapabilities } from './repo.js'; -export { isGitRepo, getRepoRoot, getRepoName, getCurrentBranch, getRepoInfo, getHeadHash, getDiffityDir, getDiffityDirPath, isDataDirUntracked, getRefCapabilities, isValidGitRef } from './repo.js'; +export { isGitRepo, getRepoRoot, getRepoName, getCurrentBranch, getRepoInfo, getHeadHash, getCommitHash, getDiffityDir, getDiffityDirPath, isDataDirUntracked, getRefCapabilities, isValidGitRef } from './repo.js'; export { getDiff, getDiffFiles, getDiffStat, getDiffStatForRef, getRenameStatus, getUntrackedFiles, getUntrackedDiff, getFileContent, getFileLineCount, getMergeBase, normalizeRef, resolveBaseRef, resolveThroughUpstream, resolveDiffArgs, resolveRef, revertFile, revertHunk, WORKING_TREE_REFS } from './diff.js'; export type { RefDiffArgs } from './diff.js'; export { getDirtyPaths } from './status.js'; diff --git a/packages/git/src/repo.ts b/packages/git/src/repo.ts index eabeffa3..6a459db3 100644 --- a/packages/git/src/repo.ts +++ b/packages/git/src/repo.ts @@ -2,7 +2,7 @@ import { execFileSync, execSync } from 'node:child_process'; import { mkdirSync } from 'node:fs'; import { sep } from 'node:path'; import { homedir } from 'node:os'; -import { exec } from './exec.js'; +import { exec, git } from './exec.js'; import { readRepoConfig, resolveDataDir } from './config.js'; import { WORKING_TREE_REFS } from './diff.js'; import type { RepoInfo } from './types.js'; @@ -45,6 +45,11 @@ export function getHeadHash(): string { return exec('git rev-parse HEAD'); } +/** The commit a ref names, so a moving name like `HEAD` or `main` is pinned to what it meant. */ +export function getCommitHash(ref: string): string { + return git(['rev-parse', '--verify', `${ref}^{commit}`]); +} + export function getDiffityDirPath(): string { const repoRoot = getRepoRoot(); return resolveDataDir({ diff --git a/packages/github/package.json b/packages/github/package.json index 6e2a96d9..d643e027 100644 --- a/packages/github/package.json +++ b/packages/github/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/github", - "version": "0.10.7", + "version": "0.10.8", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/parser/package.json b/packages/parser/package.json index b9688ad8..e912b57f 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/parser", - "version": "0.10.7", + "version": "0.10.8", "private": true, "type": "module", "main": "./dist/index.js", diff --git a/packages/ui/package.json b/packages/ui/package.json index 882e5072..45b33709 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@diffity/ui", - "version": "0.10.7", + "version": "0.10.8", "type": "module", "private": true, "scripts": {