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
17 changes: 12 additions & 5 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,13 +114,20 @@ exact corresponding diffs in a deliberate order.
- `src/format/schema.ts`: boundary-only Zod schemas for the machine-owned capture and the author-edited
explanations, plus the version 1 ExplainDocument and its optional attribution metadata.
- `src/authoring/git.ts`: captures staged, unstaged, deleted, renamed, and untracked text
and binary files from an immutable Git base commit, optionally reading the index or
limiting the capture to named paths. A text side keeps its UTF-8 content; a binary side
keeps only its byte size and SHA-256 content hash.
and binary files from an immutable Git base commit, optionally reading the index and
limiting the capture to literal selected paths or away from literal excluded paths.
Positive paths narrow the diff before Git pairs renames; exclusions drop paths from the
result, before any file is read, so a detected rename crossing an exclusion keeps both
paths while only the included side is read. A text side keeps its UTF-8 content; a binary
side keeps only its byte size and SHA-256 content hash. Git itself may read excluded
content while detecting renames.
- `src/format/status.ts`: names the direction of a rename that crosses an exclusion and
labels the omitted side.
- `src/authoring/capture.ts`: derives text change blocks, file-level binary change blocks,
and the content `captureId`, and materializes exact section patches from capture plus
explanations. Text-only files hash into `captureId` exactly as before binary support, so
existing captures and their explanations stay matched.
explanations. A rename crossing an exclusion becomes a file-level move block that keeps
the included side and names the excluded one. Text-only files hash into `captureId`
exactly as before binary support, so existing captures and their explanations stay matched.
- `src/cli/explanations.ts`: strict safe YAML 1.2 parsing into the explanations schema.
- `src/cli/commands/`: each command owns its option schema and validates inputs before
calling internal logic. `cli.ts` registers commands and forwards their arguments.
Expand Down
39 changes: 33 additions & 6 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,21 +73,48 @@ publication tokens:
Capture selected working-tree changes:

```bash
diffwalk inspect --staged # staged changes only
diffwalk inspect -- src/a.ts src/b.ts # selected paths
diffwalk inspect --staged -- src/a.ts # both
diffwalk inspect --base main # working tree relative to main
diffwalk inspect --staged # staged changes only
diffwalk inspect -- src/a.ts src/b.ts # selected paths
diffwalk inspect --staged -- src/a.ts # both
diffwalk inspect --base main # working tree relative to main
diffwalk inspect --exclude experiments # omit a file or directory
diffwalk inspect --exclude notes.md --exclude experiments
diffwalk inspect --exclude src/legacy -- src # exclusion wins inside a selected path
```

`--exclude` is repeatable and takes literal file or directory paths relative to the
repository root. A directory excludes everything under it on path boundaries, so
`--exclude experiments` also omits `experiments/old.ts` but not `experiments.ts`.
Positive paths after `--` and `--exclude` values combine: a change is captured when it
matches the `--` list (or that list is empty) and matches no `--exclude` value, so an
exclusion always wins. Both selections are literal, so `--exclude 'notes[1].md'` names
that exact file and never a Git pattern. Exclusions apply to working-tree captures
(including untracked files) and to `--staged` captures, and are applied before Diffwalk
reads or validates files, so an unsupported file outside the requested scope cannot block
the capture.
Selection happens in two stages. Diffwalk hands Git the literal `--` paths first, and
Git detects renames among the changes that survive that scope. Diffwalk then drops
`--exclude` paths from the result. Git may read excluded file contents while it looks
for similarities, so exclusions cannot hide content from Git's rename detection, but
Diffwalk never reads or validates an omitted side. Rename detection is a heuristic, and
a rename that crosses an initial `--` path boundary may lose its relationship and appear
as an ordinary addition or deletion; only `--exclude` keeps detected moves. When a
detected rename crosses an `--exclude` boundary, the review keeps both paths and shows
`Moved to excluded path` or `Moved from excluded path`, omits the excluded side's content
explicitly rather than as an empty file, and drops a rename whose both sides are excluded.
When the selection matches no changes, `inspect` stops with `Nothing to capture` instead
of writing an empty walk. Say which paths you excluded when you share the review, since
the review cannot show changes that were never captured.

Or capture committed changes without checking out either revision:

```bash
diffwalk inspect <commit> # commit relative to its first parent
diffwalk inspect --from main --to feature # compare two committed revisions
```

Revision captures ignore local changes and cannot be limited by path. Single-commit
inspection requires a parent, so it does not support root commits.
Revision captures ignore local changes and cannot be limited by path or `--exclude`.
Single-commit inspection requires a parent, so it does not support root commits.

`.diffwalk/current` selects the walk used by later commands. Capturing the same source
and contents reuses it; a different capture creates a new walk and preserves previous walks.
Expand Down
43 changes: 31 additions & 12 deletions skills/diffwalk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,24 @@ explained.
- Run `diffwalk inspect` for staged, unstaged, renamed, deleted, and untracked
working-tree changes relative to `HEAD`. Use `--base <revision>` when the user
names a different working-tree base.
- Add `--staged` to capture the index instead of the working tree, or list paths
after `--` to limit the capture to them, for example
`diffwalk inspect --staged -- src/a.ts`. Path limiting applies only to
working-tree captures. Paths are literal file or directory names relative to
the repository root; there is no `--exclude` option or Git pathspec magic.
To omit unrelated files, list the files or directories that belong in the review.
- Add `--staged` to capture the index instead of the working tree. Limit a
working-tree capture with literal paths after `--`, with repeatable
`--exclude <path>`, or both, for example `diffwalk inspect --staged -- src/a.ts`
or `diffwalk inspect --exclude experiments --exclude notes.md`. Paths are
literal file or directory names relative to the repository root: there is no
Git pathspec magic, `--exclude experiments` omits the whole directory, and an
exclusion always wins over a `--` path. Selection applies only to local captures
(the working tree or the index); commit and range captures reject it. When the
selection matches no changes, `inspect` stops with `Nothing to capture`. Prefer
`--exclude` over listing every included path.
- Selection runs in two stages: Git sees the literal `--` paths and detects renames
first, then Diffwalk drops `--exclude` paths. Git may read excluded content to detect
similarities, but Diffwalk never reads or validates the omitted side. A detected
rename crossing an exclusion keeps both paths and is reported as `Moved to excluded
path` or `Moved from excluded path`, with the excluded side marked `excluded` and no
content; explain that move instead of describing it as an addition or deletion. A
rename with both sides excluded is omitted. A rename crossing an initial `--` path
boundary can lose its relationship because Git filters before it detects renames.
- Run `diffwalk inspect <commit>` for one commit relative to its first parent. A
root commit has no first parent, so use an explicit range instead.
- Run `diffwalk inspect --from <revision> --to <revision>` for a committed range.
Expand Down Expand Up @@ -171,11 +183,13 @@ hide ownership or order; otherwise let Diffwalk's exact diff carry the code.
Diffwalk reports it. Symbolic links and non-file Git paths are still rejected at
capture time; do not bypass that boundary.
If the reported files are outside the requested review scope, rerun
`inspect -- <path>...` with the relevant files or directories and continue.
Use `--staged` only when the requested review scope is the index. State what was
omitted; do not change files, staging, or Git ignore rules to make capture succeed.
If unsupported files belong to the requested scope, report the limitation rather
than silently omitting them.
`inspect -- <path>...` to include only the relevant files, or rerun
`inspect --exclude <path>...` to drop the irrelevant ones, and continue. Use
`--staged` only when the requested review scope is the index. Always state which
paths you excluded or omitted: an omission is intentional, and reviewers cannot see
changes that were never captured. Do not change files, staging, or Git ignore rules
to make capture succeed. If unsupported files belong to the requested scope, report
the limitation rather than silently omitting them.
- Binary files are represented at file level, not by their bytes. The capture records
the path, status, modes, and each side's byte size and SHA-256 hash; the review shows
a metadata card instead of a patch. A side counts as binary when its bytes contain a
Expand All @@ -184,14 +198,19 @@ hide ownership or order; otherwise let Diffwalk's exact diff carry the code.
captured metadata. Diffwalk does not generate binary patches or image previews, so
explain a binary change from its identity rather than expecting its contents in the
diff. Never reconstruct the missing bytes or paste them into `text`.
- A rename crossing an `--exclude` boundary is also a file-level card: it keeps both
paths and one side, reports `Moved to excluded path` or `Moved from excluded path`, and
marks the omitted side `excluded`. The omitted content is not in the capture, so treat
the card as the move it is and disclose the omission in your explanation instead of
treating it as a deleted or added file.
- Treat a pure rename as a real assignable change. Diffwalk renders it as a move rather
than an empty textual diff.
- The capture contains full file contents. Treat it as potentially sensitive and do not publish or send it without the user's authorization.

## Commands

```bash
diffwalk inspect [revision] [--staged] [--base <revision>] [--from <revision> --to <revision>] [--output <capture-path>] [--explanations <yaml-path>] [-- <path>...]
diffwalk inspect [revision] [--staged] [--base <revision>] [--from <revision> --to <revision>] [--exclude <path>...] [--output <capture-path>] [--explanations <yaml-path>] [-- <path>...]
diffwalk walks
diffwalk use <walk-id>
diffwalk delete <walk-id>
Expand Down
22 changes: 18 additions & 4 deletions src/authoring/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,14 @@ import type {
Explanations,
TextChangeBlock,
} from '../format/types'
import { excludedSide, isMovedStatus } from '../format/status'

export function createExplainCapture(files: DraftFile[], source: CaptureSource): ExplainCapture {
let nextId = 1
const changes: ChangeBlock[] = []

for (const file of [...files].sort((left, right) => left.path.localeCompare(right.path))) {
if (file.oldBinary !== undefined || file.newBinary !== undefined) {
if (file.oldBinary !== undefined || file.newBinary !== undefined || isMovedStatus(file.status)) {
changes.push(binaryChangeBlock(file, changeId(nextId++)))
continue
}
Expand Down Expand Up @@ -65,6 +66,7 @@ function binaryChangeBlock(file: DraftFile, id: string): BinaryChangeBlock {
path: file.path,
status: file.status,
...(file.oldPath === undefined ? {} : { oldPath: file.oldPath }),
...(file.excludedPath === undefined ? {} : { excludedPath: file.excludedPath }),
oldMode: file.oldMode,
newMode: file.newMode,
...(before === undefined ? {} : { before }),
Expand All @@ -73,9 +75,14 @@ function binaryChangeBlock(file: DraftFile, id: string): BinaryChangeBlock {
}

// Every existing side has an identity, whether its bytes decode as text or stay binary, so a
// text-to-binary or binary-to-text transition keeps both sides instead of dropping one.
// text-to-binary or binary-to-text transition keeps both sides instead of dropping one. A
// side omitted by an exclusion is absent here too, but its status says why, so a renderer
// never mistakes it for an empty file.
function changeSide(file: DraftFile, side: 'old' | 'new'): ChangeSide | undefined {
const absent = side === 'old' ? file.status === 'added' : file.status === 'deleted'
const absent =
side === 'old'
? file.status === 'added' || excludedSide(file.status) === 'old'
: file.status === 'deleted' || excludedSide(file.status) === 'new'
if (absent) return undefined

const binary = side === 'old' ? file.oldBinary : file.newBinary
Expand All @@ -98,6 +105,12 @@ export function captureIdFor(files: DraftFile[], includeModes = true): string {
hash.update('\0')
hash.update(file.oldPath ?? '')
hash.update('\0')
// Only a rename crossing an exclusion has an excluded path, so files without one keep
// the capture ID they had before this field existed.
if (file.excludedPath !== undefined) {
hash.update(`excluded:${file.excludedPath}`)
hash.update('\0')
}
if (includeModes) {
hash.update(file.oldMode)
hash.update('\0')
Expand Down Expand Up @@ -216,13 +229,14 @@ function materializeStep(
// confirms the captured sizes, hashes, status, and modes still describe it.
function validateBinaryChange(change: BinaryChangeBlock, filesByPath: Map<string, DraftFile>): void {
const file = findFile(filesByPath, change.path)
if (file.oldBinary === undefined && file.newBinary === undefined) {
if (file.oldBinary === undefined && file.newBinary === undefined && !isMovedStatus(file.status)) {
throw new Error(`Change block no longer matches captured file content: ${change.id}`)
}
const expected = binaryChangeBlock(file, change.id)
if (
expected.status !== change.status ||
expected.oldPath !== change.oldPath ||
expected.excludedPath !== change.excludedPath ||
expected.oldMode !== change.oldMode ||
expected.newMode !== change.newMode ||
JSON.stringify(expected.before) !== JSON.stringify(change.before) ||
Expand Down
Loading