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: 2 additions & 1 deletion .c8rc.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
"scripts/cli-args.mjs",
"scripts/build-diff-data.mjs",
"scripts/generate-summaries.mjs",
"scripts/present.mjs"
"scripts/present.mjs",
"scripts/serve-built.mjs"
],
"reporter": [
"text",
Expand Down
6 changes: 4 additions & 2 deletions .github/workflows/product-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Enable the pinned pnpm version
run: corepack enable
- name: Set up Node
uses: actions/setup-node@v6
Comment on lines +20 to 23

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Install pnpm before setup-node switches runtimes

On a clean runner where the preinstalled Node differs from the matrix version, this enables Corepack in the old Node installation and then replaces it on PATH; as corepack enable --help states, the shims are installed next to the currently resolved corepack binary. The cache: pnpm setup subsequently needs to invoke pnpm while actions/setup-node is running, so it can fail before the install step, and the same ordering appears in all five jobs in test-lanes.yml. Install pnpm independently before setup-node (for example with pnpm/action-setup), or otherwise ensure the selected Node toolchain exposes the shim before enabling pnpm caching.

AGENTS.md reference: AGENTS.md:L10-L17

Useful? React with 👍 / 👎.

with:
node-version: ${{ matrix.node-version }}
- name: Enable the pinned pnpm version
run: corepack enable
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install lockfile dependencies
run: pnpm install --frozen-lockfile
- name: Run product gate
Expand Down
40 changes: 30 additions & 10 deletions .github/workflows/test-lanes.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,44 +20,56 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Enable the pinned pnpm version
run: corepack enable
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: 22.13.0
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: corepack pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Run fast unit tests
run: corepack pnpm run test:unit
run: pnpm run test:unit

integration:
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Enable the pinned pnpm version
run: corepack enable
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: 22.13.0
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: corepack pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Run Git and server tests
run: corepack pnpm run test:integration
run: pnpm run test:integration

coverage:
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Enable the pinned pnpm version
run: corepack enable
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: 22.13.0
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: corepack pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Check core coverage
run: corepack pnpm run test:coverage
run: pnpm run test:coverage
- name: Upload coverage report
if: always()
uses: actions/upload-artifact@v6
Expand All @@ -72,16 +84,20 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Enable the pinned pnpm version
run: corepack enable
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: 22.13.0
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: corepack pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Install browser system packages
run: npx playwright install --with-deps chromium
- name: Run the browser journey
run: corepack pnpm run test:browser
run: pnpm run test:browser

platform:
if: github.event_name != 'pull_request'
Expand All @@ -95,11 +111,15 @@ jobs:
steps:
- name: Check out repository
uses: actions/checkout@v6
- name: Enable the pinned pnpm version
run: corepack enable
- name: Set up Node
uses: actions/setup-node@v6
with:
node-version: 22.13.0
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: corepack pnpm install --frozen-lockfile
run: pnpm install --frozen-lockfile
- name: Check host shell assumptions
run: corepack pnpm run test:platform
run: pnpm run test:platform
5 changes: 4 additions & 1 deletion PRODUCT.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@ Developers run `npx diffsplain` in a Git checkout. With no arguments, it compare
- Compare the current checkout with its default branch when no target is passed.
- Accept local paths, Git URLs, and GitHub `owner/name` repo names.
- Report local dependency paths, versions, and readiness with `diffsplain doctor`.
- Try Codex, Claude, Copilot, Cursor, then OpenCode when no agent is chosen.
- Try Codex, Claude, Copilot, then OpenCode when no agent is chosen. Cursor
detection does not mean support: Diffsplain detects it only so it can report
that Cursor is unsupported. It has no supported read-only, no-network,
no-tool mode, so it cannot generate notes.
- Show tracked and untracked worktree changes, exact local ranges, and remote branches as secondary targets.
- Present full or shortened unified diffs, including binary-file metadata.
- Pair the whole change and each file with agent-written summaries, reasons, details, and risks.
Expand Down
10 changes: 6 additions & 4 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -480,8 +480,11 @@ export default function Home() {

const visibleFiles = useMemo(() => {
const cleanQuery = query.trim().toLowerCase();
if (!cleanQuery) return files;
return files.filter((file) => file.path.toLowerCase().includes(cleanQuery));
return files.flatMap((file, index) =>
!cleanQuery || file.path.toLowerCase().includes(cleanQuery)
? [{ file, index }]
: [],
);
}, [files, query]);

if (!snapshot) {
Expand Down Expand Up @@ -918,8 +921,7 @@ export default function Home() {
className="picker-list"
id="file-picker-list"
>
{visibleFiles.map((file) => {
const index = files.findIndex((item) => item.path === file.path);
{visibleFiles.map(({ file, index }) => {
const active = file.path === currentFile.path;
return (
<button
Expand Down
7 changes: 5 additions & 2 deletions docs/content/agent-notes.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ sidebar:
---

A coding agent writes short notes for each changed file by default. Diffsplain
tries Codex, Claude, Copilot, Cursor, then OpenCode, and uses the first installed
CLI. Each CLI uses its current login.
tries Codex, Claude, Copilot, then OpenCode, and uses the first installed CLI.
Each CLI uses its current login. Cursor detection does not mean support:
Diffsplain detects it only to give a clear unsupported-provider result. Cursor
stays disabled for note generation because it has no supported read-only,
no-network, no-tool mode.

Use a plain diff when you do not want notes:

Expand Down
9 changes: 6 additions & 3 deletions docs/content/development.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -165,9 +165,12 @@ corepack pnpm run cloud:check

`cloud:check` runs the clean-checkout gate and the provider/browser tests.
Those tests use fake coding providers and a fake browser command. Real Codex,
Claude, Copilot, Cursor, OpenCode, GitHub, or browser login is optional and is
needed only for a live integration task. Keep credentials in Codex environment
settings, not checked-in rules or scripts.
Claude, Copilot, OpenCode, GitHub, or browser login is optional and is needed
only for a live integration task. Diffsplain tries Codex, Claude, Copilot, then
OpenCode for notes. Cursor detection does not mean support: it is detected only
to report that Cursor is unsupported. Cursor stays disabled because it has no
supported read-only, no-network, no-tool mode. Keep credentials in Codex
environment settings, not checked-in rules or scripts.

To test the linked-worktree path itself, run:

Expand Down
7 changes: 5 additions & 2 deletions docs/content/index.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@ patch on the left and a short coding agent note on the right.

## Start a review

You need Node.js 22.13 or newer and a signed-in Codex, Claude, Copilot, Cursor,
or OpenCode CLI.
You need Node.js 22.13 or newer and a signed-in Codex, Claude, Copilot, or
OpenCode CLI. Diffsplain tries Codex, Claude, Copilot, then OpenCode when you
do not choose one. Cursor detection does not mean support: it is detected only
to report that Cursor is unsupported. Cursor has no supported read-only,
no-network, no-tool mode required for note generation, so it stays disabled.

```sh
npx diffsplain
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
"test": "pnpm run test:unit && pnpm run test:integration && pnpm run test:coverage && pnpm run test:browser && pnpm run test:platform",
"test:unit": "node --test tests/access-token.test.mjs tests/automation-trust.test.mjs tests/cache.test.mjs tests/cli-args.test.mjs tests/cli-docs.test.mjs tests/coding-agents.test.mjs tests/data-contract.test.mjs tests/doctor.test.mjs tests/landing-demo.test.mjs tests/package-manifest.test.mjs tests/plan-ledger.test.mjs tests/presenter-runtime.test.mjs tests/product-gate.test.mjs tests/release.test.mjs tests/summary-path.test.mjs tests/support-record.test.mjs tests/test-lanes.test.mjs tests/tool-profiles.test.mjs",
"test:integration": "pnpm run build && node --test --test-concurrency=1 tests/dev.test.mjs tests/generate-summaries.test.mjs tests/live-update-speed.test.mjs tests/performance-gate.test.mjs tests/present-agent.test.mjs tests/present-help.test.mjs tests/present-instances.test.mjs tests/presenter-recovery.test.mjs tests/remote-targets.test.mjs tests/rendered-html.test.mjs tests/serve-built.test.mjs tests/setup-environments.test.mjs",
"test:coverage": "pnpm run build && c8 node --test --test-concurrency=1 tests/cli-args.test.mjs tests/generate-summaries.test.mjs tests/present-agent.test.mjs tests/present-help.test.mjs tests/present-instances.test.mjs tests/remote-targets.test.mjs",
"test:coverage": "pnpm run build && c8 node --test --test-concurrency=1 tests/cli-args.test.mjs tests/generate-summaries.test.mjs tests/present-agent.test.mjs tests/present-help.test.mjs tests/present-instances.test.mjs tests/remote-targets.test.mjs tests/serve-built.test.mjs",
"test:browser": "pnpm run build && node --test tests/browser/*.test.mjs",
"test:browser:install": "playwright install chromium",
"test:cloud": "node --test tests/generate-summaries.test.mjs tests/present-agent.test.mjs",
Expand Down
76 changes: 59 additions & 17 deletions scripts/build-diff-data.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import { execFileSync, spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readlinkSync,
renameSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, relative, resolve } from 'node:path';
import { basename, dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { summaryPath } from './summary-path.mjs';

Expand Down Expand Up @@ -1088,7 +1090,7 @@ function build() {
const change = changeSummary(sourceSummaries.change, target.changeDefaults);
const content = {
repo: {
name: remoteRepository?.name || repo.split('/').pop(),
name: remoteRepository?.name || basename(repo),
root: localWorkspace ? repo : target.remote?.url || repo,
base: target.base,
head: target.head,
Expand Down Expand Up @@ -1146,6 +1148,29 @@ function build() {
return true;
}

function untrackedFileKind(stat) {
if (stat.isFile()) return 'file';
if (stat.isSymbolicLink()) return 'symlink';
if (stat.isDirectory()) return 'directory';
return 'other';
}

function fingerprintUntrackedPath(content, path) {
const stat = lstatSync(resolve(repo, path), { bigint: true });
content.update(path);
content.update('\0');
content.update(untrackedFileKind(stat));
content.update('\0');
content.update(String(stat.size));
content.update('\0');
content.update(String(stat.mtimeNs));
Comment on lines +1164 to +1166

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include regular-file contents in watch fingerprints

When an untracked regular file is rewritten with the same byte length while its mtime is preserved—such as by a timestamp-preserving generator, rsync-like tool, or on a coarse-timestamp filesystem—its kind, size, mtime, path, and Git status all remain unchanged. The watcher therefore never calls refresh(), leaving the displayed patch stale until some unrelated change occurs; the previous implementation detected this by hashing the file bytes. Continue avoiding symlink targets, but hash regular-file content or another reliably content-sensitive value.

Useful? React with 👍 / 👎.

content.update('\0');
if (stat.isSymbolicLink()) {
content.update(readlinkSync(resolve(repo, path)));
content.update('\0');
}
}

function fingerprint() {
let summariesTime = '';
if (!ignoreSummaryWatch && !noSummaries) {
Expand All @@ -1163,7 +1188,7 @@ function fingerprint() {
}
const content = createHash('sha256');
content.update(
tryRepo(['diff', '--no-ext-diff', '--binary', 'HEAD', '--']),
tryRepo(['diff', '--no-ext-diff', '--no-textconv', '--binary', 'HEAD', '--']),
);
const untracked = tryRepo([
'ls-files',
Expand All @@ -1175,10 +1200,7 @@ function fingerprint() {
.filter((path) => path && !excludedPaths.has(path))
.sort();
for (const path of untracked) {
content.update('\0');
content.update(path);
content.update('\0');
content.update(readFileSync(resolve(repo, path)));
fingerprintUntrackedPath(content, path);
}
return [
tryRepo(['rev-parse', 'HEAD']),
Expand All @@ -1202,18 +1224,38 @@ const refresh = () => {

const started = refresh();
if (watching && started) {
let last = fingerprint();
let last;
let remoteWait = 0;
const watcher = setInterval(() => {
const next = fingerprint();
remoteWait += watchInterval;
const remoteDue = remoteMode && remoteWait >= remoteRefreshInterval;
if (next !== last || remoteDue || watchContent) {
last = next;
remoteWait = 0;
if (!refresh()) clearInterval(watcher);
let watcher;
const stopWatching = (error) => {
console.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
if (watcher) clearInterval(watcher);
};
const poll = () => {
try {
const next = fingerprint();
if (last === undefined) {
last = next;
return true;
}
remoteWait += watchInterval;
const remoteDue = remoteMode && remoteWait >= remoteRefreshInterval;
if (next !== last || remoteDue || watchContent) {
last = next;
remoteWait = 0;
if (!refresh()) {
clearInterval(watcher);
return false;
}
}
return true;
} catch (error) {
stopWatching(error);
return false;
}
}, watchInterval);
};
if (poll()) watcher = setInterval(poll, watchInterval);
} else if (watching) {
process.exitCode = 1;
}
19 changes: 0 additions & 19 deletions scripts/cache.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,32 +40,14 @@ function leaseRecord(path) {
}
}

function leaseOwnerIsActive(record) {
return (
record?.hostname === hostname() &&
Number.isSafeInteger(record.pid) &&
processIsAlive(record.pid)
);
}

function leaseIsActive(path, now = Date.now(), duration = leaseDurationMs) {
try {
if (leaseOwnerIsActive(leaseRecord(path))) return true;
return now - statSync(path).mtimeMs < duration;
} catch {
return false;
}
}

function processIsAlive(pid) {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error?.code === 'EPERM';
}
}

function createLease(path, record, duration) {
const descriptor = openSync(path, 'wx', 0o600);
writeFileSync(descriptor, `${JSON.stringify(record)}\n`);
Expand Down Expand Up @@ -116,7 +98,6 @@ function rejectActiveLease(active, path) {
function handleLeaseConflict(error, path, now, duration) {
rejectNonConflict(error);
const observed = leaseRecord(path);
rejectActiveLease(leaseOwnerIsActive(observed), path);
rejectActiveLease(leaseIsActive(path, now, duration), path);
removeStaleLease(path, observed?.token);
}
Expand Down
Loading
Loading