From 3039ba8a85bf60a94c6cd953c9904902e0b09bea Mon Sep 17 00:00:00 2001 From: Art Pai Date: Thu, 17 Sep 2026 08:48:47 +1000 Subject: [PATCH] Select inspect changes with Git pathspecs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add repeatable `inspect --path ` for literal selection in place of the `-- ...` interface, and repeatable `--pathspec ` so Git glob and exclusion magic can scope a working-tree capture. Selection reaches Git before rename detection while literal `--exclude` still drops paths afterwards, and the old separator now fails with migration guidance instead of being read as a revision or a pattern. Co-Authored-By: ことね --- docs/architecture.md | 13 ++- docs/usage.md | 67 +++++++---- skills/diffwalk/SKILL.md | 50 +++++---- src/authoring/git.ts | 28 +++-- src/cli.ts | 63 ++++++----- src/cli/commands/inspect.ts | 73 ++++++++---- test/cli.test.ts | 204 +++++++++++++++++++++++++++++++--- test/git.test.ts | 215 +++++++++++++++++++++++++++++++++++- 8 files changed, 595 insertions(+), 118 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index bdddd83..13b3352 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -115,12 +115,13 @@ exact corresponding diffs in a deliberate order. 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 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. + limiting the capture with literal `--path` values or Git `--pathspec` expressions, and + dropping literal `--exclude` paths. Literal paths disable Git pathspec magic while + pathspec expressions keep it; both 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, diff --git a/docs/usage.md b/docs/usage.md index 8cfbae4..1ac9683 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -73,35 +73,53 @@ 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 --exclude experiments # omit a file or directory +diffwalk inspect --staged # staged changes only +diffwalk inspect --path src/a.ts --path src/b.ts # selected literal paths +diffwalk inspect --staged --path 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 +diffwalk inspect --exclude src/legacy --path src # exclusion wins inside a selected path +diffwalk inspect --pathspec ':(glob)src/**/*.ts' # Git glob pathspec +diffwalk inspect --pathspec ':(exclude)pnpm-lock.yaml' # Git exclusion pathspec +diffwalk inspect --pathspec ':(glob)src/**/*.ts' --exclude src/legacy ``` +`--path` is repeatable and takes literal file or directory paths relative to the +repository root. Diffwalk disables Git pathspec magic for these values, so `*`, `?`, +`[`, and a leading `:` name literal filenames; `--path 'notes[1].md'` selects that +exact file. A directory selects everything under it on path boundaries. + +`--pathspec` is repeatable and hands each expression to Git as a pathspec, so Git's +pathspec semantics apply. `:(glob)src/**/*.ts` selects matching files with a glob, and +`:(exclude)pnpm-lock.yaml` drops a file from the scope. Pass the expression directly +after the option and quote it so the shell does not expand it before Git receives it. +Because the expression reaches Git, an exclusion pathspec is part of the initial Git +scope. `--path` and `--pathspec` are mutually exclusive; choose one selection mode per +capture. + `--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 +repository root. It composes with either selection mode. A directory excludes everything +under it on path boundaries, so `--exclude experiments` also omits `experiments/old.ts` +but not `experiments.ts`. The scope and `--exclude` values combine: a change is captured +when it matches the scope (or the scope is empty) and matches no `--exclude` value, so an +exclusion always wins. `--exclude` values are always 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 `--path` or `--pathspec` scope +first, and Git detects renames among the changes that survive it. 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. +a rename that crosses an initial selection boundary may lose its relationship and appear +as an ordinary addition or deletion; only `--exclude` keeps detected moves, and only when +both sides survived the initial scope. This includes an exclusion pathspec, which Git +applies as part of the initial scope. 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. @@ -113,8 +131,9 @@ diffwalk inspect # 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 or `--exclude`. -Single-commit inspection requires a parent, so it does not support root commits. +Revision captures ignore local changes and cannot be limited by `--path`, `--pathspec`, +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. diff --git a/skills/diffwalk/SKILL.md b/skills/diffwalk/SKILL.md index f9dccaf..93d0373 100644 --- a/skills/diffwalk/SKILL.md +++ b/skills/diffwalk/SKILL.md @@ -16,23 +16,32 @@ explained. working-tree changes relative to `HEAD`. Use `--base ` when the user names a different working-tree base. - Add `--staged` to capture the index instead of the working tree. Limit a - working-tree capture with literal paths after `--`, with repeatable - `--exclude `, 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. + working-tree capture with repeatable `--path ` for literal paths, + repeatable `--pathspec ` for Git pathspecs, repeatable + `--exclude `, or a selection option plus `--exclude`. For example, + `diffwalk inspect --staged --path src/a.ts`, + `diffwalk inspect --pathspec ':(glob)src/**/*.ts'`, or + `diffwalk inspect --pathspec ':(glob)src/**/*.ts' --exclude src/legacy`. + `--path` and `--pathspec` are mutually exclusive. `--path` values are literal + file or directory names relative to the repository root with Git pathspec magic + disabled, so a leading `:` or `*`, `?`, `[` stays literal and + `--path 'notes[1].md'` names that exact file. `--pathspec` values go to Git + directly, so quote them and use Git pathspec syntax: `:(glob)src/**/*.ts` for a + glob and `:(exclude)pnpm-lock.yaml` to drop a file. 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 `--path` or `--pathspec` scope and + detects renames first, then Diffwalk drops literal `--exclude` paths. An exclusion + pathspec is part of the initial Git scope, not the later `--exclude` step. Git may + read excluded content to detect similarities, but Diffwalk never reads or validates + the omitted side. A detected rename crossing an `--exclude` 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` or `--pathspec` boundary, or whose + destination an exclusion pathspec drops, can lose its relationship because Git + filters before it detects renames. - Run `diffwalk inspect ` 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 --to ` for a committed range. @@ -183,8 +192,9 @@ 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 -- ...` to include only the relevant files, or rerun - `inspect --exclude ...` to drop the irrelevant ones, and continue. Use + `inspect --path ...` or `inspect --pathspec ...` to include only the + relevant files, or rerun `inspect --exclude ...` 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 @@ -210,7 +220,7 @@ hide ownership or order; otherwise let Diffwalk's exact diff carry the code. ## Commands ```bash -diffwalk inspect [revision] [--staged] [--base ] [--from --to ] [--exclude ...] [--output ] [--explanations ] [-- ...] +diffwalk inspect [revision] [--staged] [--base ] [--from --to ] [--path ...] [--pathspec ...] [--exclude ...] [--output ] [--explanations ] diffwalk walks diffwalk use diffwalk delete diff --git a/src/authoring/git.ts b/src/authoring/git.ts index c1d81c8..b68f35c 100644 --- a/src/authoring/git.ts +++ b/src/authoring/git.ts @@ -21,6 +21,7 @@ export interface GitCapture { export interface CaptureGitChangesOptions { staged?: boolean paths?: string[] + pathspecs?: string[] exclude?: string[] } @@ -46,18 +47,31 @@ export async function captureGitChanges( cwd = process.cwd(), options: CaptureGitChangesOptions = {}, ): Promise { - const { staged = false, paths = [], exclude = [] } = options + const { staged = false, paths = [], pathspecs = [], exclude = [] } = options const root = (await gitText(['rev-parse', '--show-toplevel'], cwd)).trim() const baseCommit = ( await gitText(['rev-parse', '--verify', '--end-of-options', `${base}^{commit}`], root) ).trim() - // Positive paths narrow the diff before Git pairs renames. Exclusions stay out of the - // pathspec: Git must still see both sides of a rename so the move can be preserved, and - // the excluded side is dropped after detection, before Diffwalk reads any content. - const pathEnvironment = paths.length > 0 ? { GIT_LITERAL_PATHSPECS: '1' } : {} + // Scope selection reaches Git before it pairs renames. Literal `--path` values disable Git + // pathspec magic so special characters stay literal; `--pathspec` expressions keep Git's + // pathspec semantics, including exclusion magic. Every global pathspec mode is pinned so an + // inherited `GIT_*PATHSPECS` value cannot change the selection. Diffwalk's literal + // `--exclude` stays out of the pathspec: Git must still see both sides of a rename so the + // move can be preserved, and the excluded side is dropped after detection, before Diffwalk + // reads any content. + const scope = pathspecs.length > 0 ? pathspecs : paths + const pathEnvironment = + scope.length === 0 + ? {} + : { + GIT_LITERAL_PATHSPECS: pathspecs.length > 0 ? '0' : '1', + GIT_GLOB_PATHSPECS: '0', + GIT_NOGLOB_PATHSPECS: '0', + GIT_ICASE_PATHSPECS: '0', + } const changes = parseGitChanges( await gitBytes( - ['diff', '--raw', '-z', '--find-renames', ...(staged ? ['--cached'] : []), baseCommit, '--', ...paths], + ['diff', '--raw', '-z', '--find-renames', ...(staged ? ['--cached'] : []), baseCommit, '--', ...scope], root, pathEnvironment, ), @@ -76,7 +90,7 @@ export async function captureGitChanges( if (!staged) { const untracked = splitNulls( await gitBytes( - ['ls-files', '--others', '--exclude-standard', '--exclude=.diffwalk/', '-z', '--', ...paths], + ['ls-files', '--others', '--exclude-standard', '--exclude=.diffwalk/', '-z', '--', ...scope], root, pathEnvironment, ), diff --git a/src/cli.ts b/src/cli.ts index 7985a7f..e20c6c8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -41,19 +41,16 @@ function createCli(): Command { .option('--base ', 'Git base to diff against') .option('--from ', 'Committed revision range start') .option('--to ', 'Committed revision range end') - .option('--exclude ', 'Exclude a literal file or directory; repeatable', collectExclude, []) + .option('--path ', 'Limit a working-tree capture to a literal file or directory; repeatable, not with --pathspec', collectOption, []) + .option('--pathspec ', 'Limit a working-tree capture with a Git pathspec expression; repeatable, not with --path', collectOption, []) + .option('--exclude ', 'Exclude a literal file or directory; repeatable', collectOption, []) .option('--output ', 'Write capture to an explicit path') .option('--explanations ', 'Write or preserve authoring YAML at an explicit path') .allowExcessArguments(true) - .addHelpText('after', '\nLimit a working-tree capture with --staged, a `-- ...` list, or repeatable `--exclude `. An exclusion always wins over an included path; a rename crossing an exclusion keeps both paths and omits the excluded side.') - .action((_revision: string | undefined, options: Record, command: Command) => { - const positionals = inspectPositionals(command) - return inspectChanges( - positionals.revision, - options, - positionals.paths, - ) - }) + .addHelpText('after', '\nLimit a working-tree capture with repeatable `--path ` for literal paths or repeatable `--pathspec ` for Git pathspecs; the two are mutually exclusive. Quote pathspec expressions so the shell does not expand them:\n diffwalk inspect --pathspec \':(glob)src/**/*.ts\'\n diffwalk inspect --pathspec \':(exclude)pnpm-lock.yaml\'\nRepeatable `--exclude ` drops literal paths after Git scope selection and composes with either mode. An exclusion always wins over an included path; a rename crossing an exclusion keeps both paths and omits the excluded side.') + .action((_revision: string | undefined, options: Record, command: Command) => + inspectChanges(inspectRevision(command), options), + ) cli .command('walks') @@ -132,28 +129,42 @@ function createCli(): Command { return cli } -function inspectPositionals(command: Command): { revision: string | undefined; paths: string[] } { - const operands = command.args - const separator = process.argv.indexOf('--', 2) - if (separator === -1) { - validateRevisionCount(operands.length, false) - return { revision: operands[0], paths: [] } +function inspectRevision(command: Command): string | undefined { + if (hasPathSeparator(process.argv)) { + throw new UsageError( + 'Path selection no longer uses `-- ...`; use repeatable `--path ` for literal paths or `--pathspec ` for Git pathspecs', + ) } - const paths = process.argv.slice(separator + 1) - validateRevisionCount(operands.length - paths.length, true) - return { revision: operands.length > paths.length ? operands[0] : undefined, paths } + if (command.args.length > 1) { + throw new UsageError('Pass at most one revision; select changes with `--path` or `--pathspec`') + } + return command.args[0] } -function validateRevisionCount(count: number, hasPathSeparator: boolean): void { - if (count > 1) { - throw new UsageError(hasPathSeparator - ? 'Pass at most one revision before `--`' - : 'Pass at most one revision; separate paths from options with `--`') +// The old `-- ...` selection is gone. Commander consumes a `--` that follows a +// value-taking option as that option's value, so only a bare `--` that is not a value is +// the removed path separator. +function hasPathSeparator(argv: string[]): boolean { + const valueOptions = new Set([ + '--base', + '--from', + '--to', + '--path', + '--pathspec', + '--exclude', + '--output', + '--explanations', + ]) + for (let index = 2; index < argv.length; index++) { + const token = argv[index]! + if (token === '--') return true + if (valueOptions.has(token)) index++ } + return false } -function collectExclude(path: string, paths: string[]): string[] { - return [...paths, path] +function collectOption(value: string, values: string[]): string[] { + return [...values, value] } function reportError(error: unknown): void { diff --git a/src/cli/commands/inspect.ts b/src/cli/commands/inspect.ts index f9b01de..c449513 100644 --- a/src/cli/commands/inspect.ts +++ b/src/cli/commands/inspect.ts @@ -19,6 +19,8 @@ const inspectOptionsSchema = z.object({ base: z.string().optional(), from: z.string().optional(), to: z.string().optional(), + path: z.array(z.string()).default([]), + pathspec: z.array(z.string()).default([]), exclude: z.array(z.string()).default([]), output: z.string().optional(), explanations: z.string().optional(), @@ -28,22 +30,35 @@ type InspectOptions = z.infer export async function inspectChanges( revision: string | undefined, options: z.input, - paths: string[] = [], ): Promise { - const { base, from, to, staged, exclude, output, explanations } = inspectOptionsSchema.parse(options) - validateCaptureOptions(revision, { base, from, to, staged, exclude }, paths) - validateSelectionPaths(paths, exclude) + const { base, from, to, staged, path, pathspec, exclude, output, explanations } = + inspectOptionsSchema.parse(options) + validateSelectionMode(path, pathspec) + validateCaptureOptions(revision, { base, from, to, staged, exclude }, path, pathspec) + validateSelectionValues(path, pathspec, exclude) const capturedAt = new Date().toISOString() - const capture = await captureChanges(revision, { base, from, to, staged, exclude }, paths, capturedAt) - validateSelection(capture, paths, exclude) + const capture = await captureChanges( + revision, + { base, from, to, staged, path, pathspec, exclude }, + capturedAt, + ) + validateSelection(capture, path, pathspec, exclude) await saveCapture(capture, { output, explanations }, capturedAt) } +function validateSelectionMode(paths: string[], pathspecs: string[]): void { + if (paths.length > 0 && pathspecs.length > 0) { + throw new UsageError('Do not combine --path with --pathspec; choose one selection mode') + } +} + function validateCaptureOptions( revision: string | undefined, { base, from, to, staged, exclude }: Pick, paths: string[], + pathspecs: string[], ): void { + const limitsSelection = paths.length > 0 || pathspecs.length > 0 if (from !== undefined || to !== undefined) { if (from === undefined || to === undefined) { throw new UsageError('Pass both --from and --to for a committed revision range') @@ -51,7 +66,7 @@ function validateCaptureOptions( if (base !== undefined || revision !== undefined) { throw new UsageError('Do not combine --from/--to with --base or a positional revision') } - if (paths.length > 0) { + if (limitsSelection) { throw new UsageError('Path limiting applies only to working-tree captures') } if (exclude.length > 0) { @@ -64,7 +79,7 @@ function validateCaptureOptions( if (base !== undefined) { throw new UsageError('Do not combine a positional commit revision with --base') } - if (paths.length > 0) { + if (limitsSelection) { throw new UsageError('Path limiting applies only to working-tree captures') } if (exclude.length > 0) { @@ -76,26 +91,41 @@ function validateCaptureOptions( } } -function validateSelection(capture: ExplainCapture, paths: string[], exclude: string[]): void { - if ((paths.length > 0 || exclude.length > 0) && capture.files.length === 0) { +function validateSelection( + capture: ExplainCapture, + paths: string[], + pathspecs: string[], + exclude: string[], +): void { + if ( + (paths.length > 0 || pathspecs.length > 0 || exclude.length > 0) && + capture.files.length === 0 + ) { throw new UsageError( - 'Nothing to capture: the selected paths and --exclude values match no working-tree changes', + 'Nothing to capture: the selected paths, pathspecs, and --exclude values match no working-tree changes', ) } } -// Git reads an empty literal pathspec as "match everything", so a missing shell variable -// would silently invert the selection instead of failing. Reject empty values at the boundary. -function validateSelectionPaths(paths: string[], exclude: string[]): void { - if ([...paths, ...exclude].some((path) => path === '')) { - throw new UsageError('Selection paths and --exclude values must not be empty') +// Git reads an empty pathspec as "match everything", so a missing shell variable would +// silently invert the selection instead of failing. Reject empty values at the boundary. +function validateSelectionValues(paths: string[], pathspecs: string[], exclude: string[]): void { + if ([...paths, ...pathspecs, ...exclude].some((value) => value === '')) { + throw new UsageError('Selection paths, pathspecs, and --exclude values must not be empty') } } async function captureChanges( revision: string | undefined, - { base, from, to, staged, exclude }: Pick, - paths: string[], + { + base, + from, + to, + staged, + path, + pathspec, + exclude, + }: Pick, capturedAt: string, ): Promise { if (from !== undefined && to !== undefined) { @@ -118,7 +148,12 @@ async function captureChanges( }) } else { const resolvedBase = base ?? 'HEAD' - const git = await captureGitChanges(resolvedBase, process.cwd(), { staged, paths, exclude }) + const git = await captureGitChanges(resolvedBase, process.cwd(), { + staged, + paths: path, + pathspecs: pathspec, + exclude, + }) return createExplainCapture(git.files, { kind: 'working-tree', from: { revision: resolvedBase, commit: git.baseCommit }, diff --git a/test/cli.test.ts b/test/cli.test.ts index b0db103..30933a7 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -594,10 +594,10 @@ describe('inspect', () => { ]) }) - test('limits a working-tree capture to the paths after --', async () => { + test('limits a working-tree capture to a literal --path', async () => { const repo = await fixtureRepo() - const result = await runCli(['inspect', '--', 'untracked.ts'], repo) + const result = await runCli(['inspect', '--path', 'untracked.ts'], repo) expect(result.exitCode).toBe(0) const capture = await readCapture(repo) @@ -608,7 +608,7 @@ describe('inspect', () => { test('captures multiple paths and treats an option-like path literally', async () => { const repo = await fixtureRepo() - const multiple = await runCli(['inspect', '--', 'greeting.ts', 'untracked.ts'], repo) + const multiple = await runCli(['inspect', '--path', 'greeting.ts', '--path', 'untracked.ts'], repo) expect(multiple.exitCode).toBe(0) expect((await readCapture(repo)).files.map((file) => file.path)).toEqual([ 'greeting.ts', @@ -616,7 +616,7 @@ describe('inspect', () => { ]) await writeFile(join(repo, '--staged'), 'content\n') - const optionLike = await runCli(['inspect', '--', '--staged'], repo) + const optionLike = await runCli(['inspect', '--path=--staged'], repo) expect(optionLike.exitCode).toBe(0) expect((await readCapture(repo)).files.map((file) => file.path)).toEqual(['--staged']) }) @@ -625,7 +625,7 @@ describe('inspect', () => { const repo = await fixtureRepo() await git(['add', 'greeting.ts'], repo) - const result = await runCli(['inspect', '--staged', '--', 'greeting.ts'], repo) + const result = await runCli(['inspect', '--staged', '--path', 'greeting.ts'], repo) expect(result.exitCode).toBe(0) const capture = await readCapture(repo) @@ -635,12 +635,12 @@ describe('inspect', () => { test('a path-limited capture keeps a deterministic identity and passes check', async () => { const repo = await fixtureRepo() - const first = await runCli(['inspect', '--', 'greeting.ts'], repo) + const first = await runCli(['inspect', '--path', 'greeting.ts'], repo) expect(first.exitCode).toBe(0) const walk = await readCurrentWalkId(repo) const capture = await readCapture(repo) - const second = await runCli(['inspect', '--', 'greeting.ts'], repo) + const second = await runCli(['inspect', '--path', 'greeting.ts'], repo) expect(second.exitCode).toBe(0) expect(second.stdout).toContain('Working tree is unchanged; kept current walk') expect(await readCurrentWalkId(repo)).toBe(walk) @@ -655,8 +655,10 @@ describe('inspect', () => { const repo = await committedFixtureRepo() for (const args of [ - ['inspect', 'HEAD', '--', 'committed.ts'], - ['inspect', '--from', 'HEAD^1', '--to', 'HEAD', '--', 'committed.ts'], + ['inspect', 'HEAD', '--path', 'committed.ts'], + ['inspect', 'HEAD', '--pathspec', 'committed.ts'], + ['inspect', '--from', 'HEAD^1', '--to', 'HEAD', '--path', 'committed.ts'], + ['inspect', '--from', 'HEAD^1', '--to', 'HEAD', '--pathspec', 'committed.ts'], ['inspect', 'HEAD', '--exclude', 'committed.ts'], ['inspect', '--from', 'HEAD^1', '--to', 'HEAD', '--exclude', 'committed.ts'], ['inspect', '--staged', 'HEAD'], @@ -691,13 +693,16 @@ describe('inspect', () => { expect((await readCapture(repo)).files.map((file) => file.path)).toEqual(['kept.ts']) }) - test('combines --exclude with paths after -- and lets the exclusion win', async () => { + test('combines --exclude with --path and lets the exclusion win', async () => { const repo = await fixtureRepo() await mkdir(join(repo, 'src')) await writeFile(join(repo, 'src', 'skip.ts'), 'skip\n') await writeFile(join(repo, 'src', 'kept.ts'), 'kept\n') - const combined = await runCli(['inspect', '--exclude', 'src/skip.ts', '--', 'src', 'greeting.ts'], repo) + const combined = await runCli( + ['inspect', '--exclude', 'src/skip.ts', '--path', 'src', '--path', 'greeting.ts'], + repo, + ) expect(combined.exitCode).toBe(0) expect((await readCapture(repo)).files.map((file) => file.path)).toEqual(['greeting.ts', 'src/kept.ts']) }) @@ -724,7 +729,7 @@ describe('inspect', () => { expect(excludedEverything.exitCode).not.toBe(0) expect(excludedEverything.stderr).toContain('Nothing to capture') - const noMatchingPath = await runCli(['inspect', '--', 'nonexistent.ts'], repo) + const noMatchingPath = await runCli(['inspect', '--path', 'nonexistent.ts'], repo) expect(noMatchingPath.exitCode).not.toBe(0) expect(noMatchingPath.stderr).toContain('Nothing to capture') @@ -737,7 +742,10 @@ describe('inspect', () => { for (const args of [ ['inspect', '--exclude', ''], ['inspect', '--exclude='], - ['inspect', '--', ''], + ['inspect', '--path', ''], + ['inspect', '--path='], + ['inspect', '--pathspec', ''], + ['inspect', '--pathspec='], ]) { const result = await runCli(args, repo) expect(result.exitCode).not.toBe(0) @@ -772,7 +780,7 @@ describe('inspect', () => { await writeFile(join(repo, 'kept.ts'), 'kept new\n') const result = await runCli( - ['inspect', '--exclude', 'experiments/link', '--', 'experiments', 'kept.ts'], + ['inspect', '--exclude', 'experiments/link', '--path', 'experiments', '--path', 'kept.ts'], repo, ) @@ -915,14 +923,180 @@ describe('inspect', () => { expect((await readCapture(repo)).files.map((file) => file.path)).toEqual(['kept.ts']) }) - test('exposes --exclude in inspect help', async () => { + test('exposes --path, --pathspec, and --exclude in inspect help', async () => { const repo = await fixtureRepo() const result = await runCli(['inspect', '--help'], repo) expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('--path ') + expect(result.stdout).toContain('--pathspec ') expect(result.stdout).toContain('--exclude ') expect(result.stdout).toContain('An exclusion always wins') + expect(result.stdout).toContain(":(glob)src/**/*.ts") + expect(result.stdout).toContain(':(exclude)pnpm-lock.yaml') + }) + + test('selects changes with a Git pathspec expression', async () => { + const repo = await fixtureRepo() + + const result = await runCli(['inspect', '--pathspec', ':(glob)greet*.ts'], repo) + + expect(result.exitCode).toBe(0) + expect((await readCapture(repo)).files.map((file) => file.path)).toEqual(['greeting.ts']) + }) + + test('selects every tracked and untracked change except a Git exclusion pathspec', async () => { + const repo = await fixtureRepo() + + const result = await runCli(['inspect', '--pathspec', ':(exclude)greeting.ts'], repo) + + expect(result.exitCode).toBe(0) + expect((await readCapture(repo)).files.map((file) => file.path)).toEqual(['untracked.ts']) + }) + + test('rejects an invalid pathspec expression with the Git error', async () => { + const repo = await fixtureRepo() + + const result = await runCli(['inspect', '--pathspec', ':(bogus)x'], repo) + + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toMatch(/pathspec magic/i) + expect(existsSync(diffwalkDir(repo))).toBe(false) + }) + + test('rejects an empty pathspec match instead of writing a walk', async () => { + const repo = await fixtureRepo() + + const result = await runCli(['inspect', '--pathspec', ':(glob)nothing/**'], repo) + + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain('Nothing to capture') + expect(existsSync(diffwalkDir(repo))).toBe(false) + }) + + test('rejects mixed --path and --pathspec before capture', async () => { + const repo = await fixtureRepo() + + for (const args of [ + ['inspect', '--path', 'greeting.ts', '--pathspec', ':(glob)*.ts'], + ['inspect', '--pathspec', ':(glob)*.ts', '--path', 'greeting.ts'], + ]) { + const result = await runCli(args, repo) + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain('Do not combine --path with --pathspec') + } + expect(existsSync(diffwalkDir(repo))).toBe(false) + }) + + test('pins every global pathspec mode regardless of the caller environment', async () => { + const repo = await fixtureRepo() + + const exclusion = await runCli( + ['inspect', '--pathspec', ':(exclude)greeting.ts'], + repo, + { GIT_LITERAL_PATHSPECS: '1', GIT_GLOB_PATHSPECS: '1' }, + ) + expect(exclusion.exitCode).toBe(0) + expect((await readCapture(repo)).files.map((file) => file.path)).toEqual(['untracked.ts']) + + const literal = await runCli(['inspect', '--path', 'greeting.ts'], repo, { + GIT_GLOB_PATHSPECS: '1', + GIT_LITERAL_PATHSPECS: '1', + }) + expect(literal.exitCode).toBe(0) + expect((await readCapture(repo)).files.map((file) => file.path)).toEqual(['greeting.ts']) + }) + + test('guides old -- path selection to --path instead of capturing it', async () => { + const repo = await fixtureRepo() + + for (const args of [ + ['inspect', '--', 'greeting.ts'], + ['inspect', 'HEAD', '--', 'committed.ts'], + ]) { + const result = await runCli(args, repo) + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain('--path') + expect(result.stderr).toContain('--pathspec') + } + expect(existsSync(diffwalkDir(repo))).toBe(false) + }) + + test('selects a literal filename with special characters using --path', async () => { + const repo = await fixtureRepo() + await writeFile(join(repo, 'notes[1].md'), 'notes old\n') + await writeFile(join(repo, 'notesA.md'), 'other old\n') + await git(['add', '.'], repo) + await git(['commit', '-q', '-m', 'add notes'], repo) + await writeFile(join(repo, 'notes[1].md'), 'notes new\n') + await writeFile(join(repo, 'notesA.md'), 'other new\n') + + const result = await runCli(['inspect', '--path', 'notes[1].md'], repo) + + expect(result.exitCode).toBe(0) + expect((await readCapture(repo)).files.map((file) => file.path)).toEqual(['notes[1].md']) + }) + + test('composes --exclude with --pathspec and keeps the exclusion literal', async () => { + const repo = await fixtureRepo() + await mkdir(join(repo, 'src', 'legacy'), { recursive: true }) + await writeFile(join(repo, 'src', 'a.ts'), 'a old\n') + await writeFile(join(repo, 'src', 'legacy', 'b.ts'), 'b old\n') + await git(['add', '.'], repo) + await git(['commit', '-q', '-m', 'src fixture'], repo) + await writeFile(join(repo, 'src', 'a.ts'), 'a new\n') + await writeFile(join(repo, 'src', 'legacy', 'b.ts'), 'b new\n') + + const result = await runCli( + ['inspect', '--pathspec', ':(glob)src/**/*.ts', '--exclude', 'src/legacy'], + repo, + ) + + expect(result.exitCode).toBe(0) + expect((await readCapture(repo)).files.map((file) => file.path)).toEqual(['src/a.ts']) + }) + + test('drops an untracked file with --exclude under a pathspec scope', async () => { + const repo = await fixtureRepo() + + const result = await runCli( + ['inspect', '--pathspec', ':(glob)*.ts', '--exclude', 'untracked.ts'], + repo, + ) + + expect(result.exitCode).toBe(0) + expect((await readCapture(repo)).files.map((file) => file.path)).toEqual(['greeting.ts']) + }) + + test('treats a bare -- value as a literal path instead of the old syntax', async () => { + const repo = await fixtureRepo() + + const result = await runCli(['inspect', '--path', '--'], repo) + + expect(result.exitCode).not.toBe(0) + expect(result.stderr).toContain('Nothing to capture') + expect(result.stderr).not.toContain('no longer uses') + }) + + test('--pathspec composes with --staged', async () => { + const repo = await fixtureRepo() + await mkdir(join(repo, 'src')) + await writeFile(join(repo, 'src', 'first.ts'), 'first old\n') + await writeFile(join(repo, 'src', 'second.ts'), 'second old\n') + await git(['add', '.'], repo) + await git(['commit', '-q', '-m', 'src fixture'], repo) + await writeFile(join(repo, 'src', 'first.ts'), 'first new\n') + await writeFile(join(repo, 'src', 'second.ts'), 'second new\n') + await git(['add', '.'], repo) + + const result = await runCli( + ['inspect', '--staged', '--pathspec', ':(glob)src/first.ts'], + repo, + ) + + expect(result.exitCode).toBe(0) + expect((await readCapture(repo)).files.map((file) => file.path)).toEqual(['src/first.ts']) }) test('--exclude composes with --staged', async () => { diff --git a/test/git.test.ts b/test/git.test.ts index b99466d..b5c8d95 100644 --- a/test/git.test.ts +++ b/test/git.test.ts @@ -672,7 +672,7 @@ describe('captureGitChanges selection', () => { ]) }) - test('treats -- paths literally instead of applying Git pathspec magic', async () => { + test('treats literal --path values literally instead of applying Git pathspec magic', async () => { const directory = await mkdtemp(join(tmpdir(), 'diffwalk-git-')) directories.push(directory) await initializeRepository(directory) @@ -1028,6 +1028,219 @@ describe('captureGitChanges selection', () => { }) }) +describe('captureGitChanges pathspec selection', () => { + test('selects tracked and untracked files with a Git glob pathspec', async () => { + const directory = await mkdtemp(join(tmpdir(), 'diffwalk-git-')) + directories.push(directory) + await initializeRepository(directory) + await mkdir(join(directory, 'src', 'nested'), { recursive: true }) + await writeFile(join(directory, 'src', 'a.ts'), 'a old\n') + await writeFile(join(directory, 'src', 'nested', 'deep.ts'), 'deep old\n') + await writeFile(join(directory, 'other.md'), 'other old\n') + await git(['add', '.'], directory) + await git(['commit', '-q', '-m', 'fixture'], directory) + + await writeFile(join(directory, 'src', 'a.ts'), 'a new\n') + await writeFile(join(directory, 'src', 'nested', 'deep.ts'), 'deep new\n') + await writeFile(join(directory, 'src', 'untracked.ts'), 'untracked\n') + await writeFile(join(directory, 'other.md'), 'other new\n') + + const capture = await captureGitChanges('HEAD', directory, { + pathspecs: [':(glob)src/**/*.ts'], + }) + + expect(capture.files.map((file) => file.path)).toEqual([ + 'src/a.ts', + 'src/nested/deep.ts', + 'src/untracked.ts', + ]) + }) + + test('selects everything except an excluded pathspec, tracked and untracked', async () => { + const directory = await mkdtemp(join(tmpdir(), 'diffwalk-git-')) + directories.push(directory) + await initializeRepository(directory) + await writeFile(join(directory, 'tracked.ts'), 'tracked old\n') + await writeFile(join(directory, 'notes.md'), 'notes old\n') + await writeFile(join(directory, 'pnpm-lock.yaml'), 'lock old\n') + await git(['add', '.'], directory) + await git(['commit', '-q', '-m', 'fixture'], directory) + + await writeFile(join(directory, 'tracked.ts'), 'tracked new\n') + await writeFile(join(directory, 'notes.md'), 'notes new\n') + await writeFile(join(directory, 'pnpm-lock.yaml'), 'lock new\n') + await writeFile(join(directory, 'untracked.ts'), 'untracked\n') + + const capture = await captureGitChanges('HEAD', directory, { + pathspecs: [':(exclude)pnpm-lock.yaml'], + }) + + expect(capture.files.map((file) => file.path)).toEqual([ + 'notes.md', + 'tracked.ts', + 'untracked.ts', + ]) + }) + + test('combines a positive glob with exclusion magic in one scope', async () => { + const directory = await mkdtemp(join(tmpdir(), 'diffwalk-git-')) + directories.push(directory) + await initializeRepository(directory) + await mkdir(join(directory, 'src', 'legacy'), { recursive: true }) + await writeFile(join(directory, 'src', 'a.ts'), 'a old\n') + await writeFile(join(directory, 'src', 'legacy', 'b.ts'), 'b old\n') + await git(['add', '.'], directory) + await git(['commit', '-q', '-m', 'fixture'], directory) + + await writeFile(join(directory, 'src', 'a.ts'), 'a new\n') + await writeFile(join(directory, 'src', 'legacy', 'b.ts'), 'b new\n') + + const capture = await captureGitChanges('HEAD', directory, { + pathspecs: [':(glob)src/**/*.ts', ':(exclude)src/legacy'], + }) + + expect(capture.files.map((file) => file.path)).toEqual(['src/a.ts']) + }) + + test('selects a whole directory with a plain pathspec, including untracked files', async () => { + const directory = await mkdtemp(join(tmpdir(), 'diffwalk-git-')) + directories.push(directory) + await initializeRepository(directory) + await mkdir(join(directory, 'src')) + await writeFile(join(directory, 'src', 'a.ts'), 'a old\n') + await writeFile(join(directory, 'other.ts'), 'other old\n') + await git(['add', '.'], directory) + await git(['commit', '-q', '-m', 'fixture'], directory) + + await writeFile(join(directory, 'src', 'a.ts'), 'a new\n') + await writeFile(join(directory, 'src', 'untracked.ts'), 'untracked\n') + await writeFile(join(directory, 'other.ts'), 'other new\n') + + const capture = await captureGitChanges('HEAD', directory, { pathspecs: ['src'] }) + + expect(capture.files.map((file) => file.path)).toEqual(['src/a.ts', 'src/untracked.ts']) + }) + + test('returns an empty capture when a pathspec matches nothing', async () => { + const directory = await mkdtemp(join(tmpdir(), 'diffwalk-git-')) + directories.push(directory) + await initializeRepository(directory) + await writeFile(join(directory, 'tracked.ts'), 'old\n') + await git(['add', '.'], directory) + await git(['commit', '-q', '-m', 'fixture'], directory) + + await writeFile(join(directory, 'tracked.ts'), 'new\n') + + const capture = await captureGitChanges('HEAD', directory, { + pathspecs: [':(glob)missing/**'], + }) + + expect(capture.files).toEqual([]) + }) + + test('rejects an invalid pathspec expression with the Git error', async () => { + const directory = await mkdtemp(join(tmpdir(), 'diffwalk-git-')) + directories.push(directory) + await initializeRepository(directory) + await writeFile(join(directory, 'tracked.ts'), 'old\n') + await git(['add', '.'], directory) + await git(['commit', '-q', '-m', 'fixture'], directory) + + await writeFile(join(directory, 'tracked.ts'), 'new\n') + + await expect( + captureGitChanges('HEAD', directory, { pathspecs: [':(bogus)x'] }), + ).rejects.toThrow(/pathspec magic/i) + }) + + test('loses a rename that leaves a positive pathspec scope', async () => { + const directory = await mkdtemp(join(tmpdir(), 'diffwalk-git-')) + directories.push(directory) + await initializeRepository(directory) + await mkdir(join(directory, 'src')) + await writeFile(join(directory, 'src', 'old.ts'), 'same\n') + await git(['add', '.'], directory) + await git(['commit', '-q', '-m', 'fixture'], directory) + + await mkdir(join(directory, 'dest')) + await git(['mv', 'src/old.ts', 'dest/new.ts'], directory) + + const capture = await captureGitChanges('HEAD', directory, { pathspecs: ['src'] }) + + // Git resolves the scope before it pairs renames, so the destination outside the + // pathspec leaves the in-scope source looking like a deletion. + expect(capture.files).toEqual([ + { + path: 'src/old.ts', + status: 'deleted', + oldMode: '100644', + newMode: '000000', + oldContent: 'same\n', + newContent: '', + }, + ]) + }) + + test('loses a rename whose destination an exclusion pathspec drops early', async () => { + const directory = await mkdtemp(join(tmpdir(), 'diffwalk-git-')) + directories.push(directory) + await initializeRepository(directory) + await writeFile(join(directory, 'moved.ts'), 'same\n') + await git(['add', '.'], directory) + await git(['commit', '-q', '-m', 'fixture'], directory) + + await mkdir(join(directory, 'experiments')) + await git(['mv', 'moved.ts', 'experiments/moved.ts'], directory) + + const capture = await captureGitChanges('HEAD', directory, { + pathspecs: [':(exclude)experiments'], + }) + + expect(capture.files).toEqual([ + { + path: 'moved.ts', + status: 'deleted', + oldMode: '100644', + newMode: '000000', + oldContent: 'same\n', + newContent: '', + }, + ]) + }) + + test('keeps a rename crossing a later literal --exclude when both sides pass the pathspec', async () => { + const directory = await mkdtemp(join(tmpdir(), 'diffwalk-git-')) + directories.push(directory) + await initializeRepository(directory) + await mkdir(join(directory, 'src')) + await writeFile(join(directory, 'src', 'moved.ts'), 'same\n') + await git(['add', '.'], directory) + await git(['commit', '-q', '-m', 'fixture'], directory) + + await mkdir(join(directory, 'src', 'experiments')) + await git(['mv', 'src/moved.ts', 'src/experiments/moved.ts'], directory) + + const capture = await captureGitChanges('HEAD', directory, { + pathspecs: [':(glob)src/**/*.ts'], + exclude: ['src/experiments'], + }) + + // Both sides survive the initial glob scope, so Git pairs the rename and the later + // literal exclusion preserves the move with both paths. + expect(capture.files).toEqual([ + { + path: 'src/moved.ts', + excludedPath: 'src/experiments/moved.ts', + status: 'moved-to-excluded', + oldMode: '100644', + newMode: '100644', + oldContent: 'same\n', + newContent: '', + }, + ]) + }) +}) + describe('binary capture', () => { test('captures binary additions, modifications, deletions, and renames in the working tree', async () => { const directory = await mkdtemp(join(tmpdir(), 'diffwalk-git-'))