diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1242e0..fe65963 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -51,6 +51,7 @@ jobs: - confstash - fetch-api-client - find-and-require-package-json + - git-changed - http-errors - inflekt - jsonld-tools diff --git a/packages/git-changed/README.md b/packages/git-changed/README.md new file mode 100644 index 0000000..92c40c2 --- /dev/null +++ b/packages/git-changed/README.md @@ -0,0 +1,291 @@ +# git-changed + +

+ +
+ the files you changed, correctly +
+
+ Merge-base-scoped changed-file detection for lint gates, incremental checks, and CI scripts — working tree and untracked files included, renames and deletions handled, zero dependencies +
+
+ + + + + + + + + +

+ +## Why + +Every repo eventually grows a tool that should only look at what you changed — a linter, a formatter check, a "is this generated artifact stale?" test. The git plumbing for that is four commands long and easy to get subtly wrong, so it gets re-implemented per tool, slightly differently each time, and the differences are bugs: + +- `git diff base...HEAD` vs `git merge-base` + `git diff` — get this wrong and commits that landed on `main` after you forked are attributed to your branch. +- `git status --porcelain` without `-uall` reports a **new directory** as one entry, so every file inside a brand-new module is invisible. +- Deleted paths come back from `git diff` and then get handed to a tool that stats them and crashes. +- A rename shows up as an add plus a delete unless you ask for `-M`. +- On a shallow clone or a detached CI checkout there is no base at all, and a tool that throws there checks nothing. + +`git-changed` is that plumbing, once, with the sharp edges filed off — as a library for tools and as a CLI for shell and CI. + +## Installation + +```bash +npm install git-changed +``` + +## Usage + +```typescript +import { changedFiles } from 'git-changed'; + +const { files, paths, base, source } = changedFiles({ ext: '.sql' }); + +console.log(`${files.length} changed .sql files vs ${base ?? '(no base)'} [${source}]`); +for (const file of files) { + console.log(file.status, file.relative); +} +``` + +Only want the paths? + +```typescript +import { changedPaths } from 'git-changed'; + +const paths = changedPaths({ ext: ['.ts', '.tsx'], exclude: ['**/*.d.ts'] }); +``` + +Asking more than one question about the same repository? `GitChanged` resolves the repo root and base once and takes per-call overrides: + +```typescript +import { GitChanged } from 'git-changed'; + +const changed = new GitChanged({ cwd: repoRoot, exclude: ['dist/', '**/generated/**'] }); + +const sql = changed.paths({ ext: '.sql' }); +const ts = changed.paths({ ext: ['.ts', '.tsx'] }); +const base = changed.base(); // e.g. 'origin/main' +``` + +## CLI + +```bash +$ git-changed --ext .sql +pgpm-modules/app-scope/deploy/schemas/app_scope/procedures/membership_parent.sql +pgpm-modules/utils/deploy/schemas/utils/procedures/ensure_singleton.sql +``` + +``` +git-changed [options] + + --base Diff against (default: $GITHUB_BASE_REF, else the + repository default branch) + --no-base Working-tree changes only + --ext Keep only these extensions (repeatable, comma-separated) + --include Keep only paths matching these globs (repeatable) + --exclude Drop paths matching these globs (repeatable) + --within Restrict to these directories (repeatable) + --no-worktree Committed changes only + --no-untracked Skip untracked files + --deleted Include paths that no longer exist + --status Prefix each path with its status + --absolute Print absolute paths (default: relative to cwd) + --json Print the full result as JSON + -0, --null NUL-separate output, for `xargs -0` + --cwd Run as if in + -h, --help Show this help + -v, --version Show the version +``` + +**Exit code is `0` whether or not anything changed.** An empty list is an answer, not an error — so `git-changed && ...` does not mean what you might hope. Test for empty output, or use `xargs -r`. + +## API + +### `changedFiles(options?): ChangedResult` + +### `changedPaths(options?): string[]` + +Same options; returns `result.paths`. + +### Options + +| Option | Type | Default | Description | +|---|---|---|---| +| `cwd` | `string` | `process.cwd()` | Directory to run in. May be a subdirectory of the repo; results are reported relative to it. | +| `base` | `string \| false` | resolved (see below) | Ref to diff against. `false` means working tree only. | +| `ext` | `string \| string[]` | — | Extension filter. `'sql'`, `'.sql'`, `'.ts,.tsx'` and `['.ts', '.tsx']` all work; matching is case-insensitive. | +| `include` | `string[]` | — | Keep only paths matching these globs. | +| `exclude` | `string[]` | — | Drop paths matching these globs. Applied after `include`. | +| `within` | `string[]` | — | Restrict to these directories (cheaper and clearer than a glob when you mean "under here"). | +| `existingOnly` | `boolean` | `true` | Drop paths that no longer exist on disk. | +| `worktree` | `boolean` | `true` | Include uncommitted changes. | +| `untracked` | `boolean` | `true` | Include untracked files (uses `-uall`). | + +### `ChangedResult` + +| Field | Type | Description | +|---|---|---| +| `files` | `ChangedFile[]` | The changed files, sorted by path. | +| `paths` | `string[]` | Absolute paths, same order. | +| `base` | `string \| undefined` | The ref actually used, after resolution. | +| `mergeBase` | `string \| undefined` | The resolved merge-base commit, if one was found. | +| `source` | `'merge-base' \| 'base' \| 'worktree'` | How the answer was produced — see [Degradation](#degradation). | +| `repoRoot` | `string` | Absolute path to the repository root. | + +### `ChangedFile` + +| Field | Type | Description | +|---|---|---| +| `path` | `string` | Absolute path. | +| `relative` | `string` | Path relative to `cwd`, always `/`-separated. | +| `status` | `ChangeStatus` | `added`, `modified`, `deleted`, `renamed`, `untracked`, `unknown`. | +| `from` | `string \| undefined` | For a rename, the previous path. | +| `committed` | `boolean` | Changed in a commit since the merge base. | +| `worktree` | `boolean` | Changed in the working tree. Both can be true. | +| `exists` | `boolean` | Whether the path is on disk (only ever `false` with `existingOnly: false`). | + +### Helpers + +| Export | Description | +|---|---| +| `resolveBase(base?, cwd?)` | The base resolution below, on its own. | +| `defaultBranch(cwd)` | `origin/HEAD`, else the first of `origin/main`, `origin/master`, `main`, `master` that exists. | +| `repoRoot(cwd)` / `isRepo(cwd)` / `isShallow(cwd)` | Thin git queries. `isShallow` is useful for warning that a diff may be incomplete. | +| `makeMatcher(patterns, cwd)` | The glob matcher, if you want to filter something else the same way. | +| `GitChangedError` | Thrown when git is unusable or `cwd` is not a repository. | + +## Behavior + +### Base resolution + +In order, first hit wins: + +1. `options.base` / `--base ` — explicit always wins. +2. `$GITHUB_BASE_REF` as `origin/`, **if that ref exists locally**. On a pull request GitHub sets this to the target branch. The existence check matters: without it you hand back a ref that every subsequent git call rejects. +3. The repository default branch — `origin/HEAD` if set, else the first of `origin/main`, `origin/master`, `main`, `master` that resolves. + +Then the diff is taken from `git merge-base HEAD `, not from the base tip, so work that landed on the base after you forked is not attributed to you. + +### What counts as changed + +The union of: + +- committed changes since the merge base (`git diff --name-status -M`), and +- working-tree changes, staged or not, plus untracked files (`git status --porcelain -uall`). + +A file in both is one entry with both `committed` and `worktree` true. This union is what makes the same call correct locally (where your work is uncommitted) and in CI (where it is committed) — no branching on environment. + +### Degradation + +`source` reports how the answer was produced, so a caller can decide whether to trust it: + +| `source` | Meaning | +|---|---| +| `merge-base` | Normal: diffed from the merge base of `HEAD` and `base`. | +| `base` | A base was resolved but no merge base exists — unrelated histories, or a shallow clone that does not reach the fork point. Diffed against the base directly. | +| `worktree` | No base at all: no `--base`, no `$GITHUB_BASE_REF`, no default branch. Working tree only. | + +Nothing here throws — a gate that fails open on a detached checkout checks nothing, which is worse than checking a little. Only an unusable git or a non-repository `cwd` raises `GitChangedError`. + +### Globs + +Small deliberate subset, matched against the path relative to `cwd`: + +| Pattern | Matches | +|---|---| +| `dist/` | The `dist` directory and everything under it, at any depth | +| `/sql/` | Leading slash anchors to `cwd` — root `sql/` only | +| `*.sql` | Any `.sql` file, at any depth | +| `pkg/*/deploy` | Exactly one segment for `*` | +| `**/generated/**` | Any depth | +| `**/sql/*--*.sql` | e.g. the pgpm bundle artifacts | + +## Recipes + +### SQL lint gate + +```json +{ + "scripts": { + "lint:sql:changed": "git-changed --ext .sql --null | xargs -0 -r pgsql-lint" + } +} +``` + +Or from inside the tool, which is better — it can report *which* base it used: + +```typescript +const { paths, base, source } = changedFiles({ ext: '.sql' }); +if (!paths.length) { + console.log('no changed SQL'); + process.exit(0); +} +if (source === 'worktree') { + console.warn('no base ref; linting working-tree changes only'); +} +lint(paths); +``` + +### Is a generated artifact stale? + +```typescript +const changed = new GitChanged({ cwd: moduleDir }); +const deployChanged = changed.paths({ within: ['deploy'], ext: '.sql' }).length > 0; +const bundleChanged = changed.paths({ within: ['sql'] }).length > 0; + +if (deployChanged && !bundleChanged) { + throw new Error(`${moduleName}: deploy/ changed but sql/ was not rebuilt`); +} +``` + +### Shell + +```bash +# Format only what changed. -r keeps xargs quiet when nothing did. +git-changed --ext .ts,.tsx --null | xargs -0 -r prettier --check + +# Review the diff, one file at a time, filenames with spaces and all. +git-changed --null | xargs -0 -r -n1 git diff -- + +# Branch on emptiness — remember the exit code is always 0. +if [ -z "$(git-changed --ext .sql)" ]; then echo "no SQL touched"; fi + +# What kind of change was each one? +git-changed --status +``` + +### In CI + +```yaml +- uses: actions/checkout@v4 + with: + fetch-depth: 0 # without this there is no merge base to find +- run: npx git-changed --ext .sql --status +``` + +`fetch-depth: 0` is the one thing worth remembering. `actions/checkout` defaults to a depth-1 clone, which has no merge base with the target branch; `git-changed` then degrades to `source: 'worktree'` and — in CI, where your work is already committed — finds **nothing**. It won't fail; it will just quietly pass. Prefer failing loudly if that matters to you: + +```typescript +const { source } = changedFiles({ ext: '.sql' }); +if (process.env.CI && source === 'worktree') { + throw new Error('No base ref in CI — is fetch-depth: 0 set?'); +} +``` + +## Troubleshooting + +| Symptom | Cause | +|---|---| +| Empty result in CI, works locally | Shallow clone — set `fetch-depth: 0`. Check `source`. | +| Files from someone else's merge included | You diffed the base tip somewhere else in your pipeline; this package uses the merge base. Verify with `--json` and look at `mergeBase`. | +| A new module's files are missing | You're not using this package, or not `-uall`. Untracked files inside a new directory need it. | +| Tool crashes on a missing path | Something passed `existingOnly: false`. The default drops deleted paths. | +| `origin/main` not found | `$GITHUB_BASE_REF` was set but unfetched. This package falls through to the local default branch rather than returning a broken ref. | + +## License + +MIT diff --git a/packages/git-changed/__tests__/changed.test.ts b/packages/git-changed/__tests__/changed.test.ts new file mode 100644 index 0000000..2f0bf6a --- /dev/null +++ b/packages/git-changed/__tests__/changed.test.ts @@ -0,0 +1,296 @@ +import { execFileSync } from 'child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { changedFiles, changedPaths, GitChanged } from '../src/changed'; +import { GitChangedError } from '../src/git'; + +function git(args: string[], cwd: string): string { + return execFileSync('git', args, { cwd, encoding: 'utf8' }); +} + +function write(cwd: string, file: string, body = 'select 1;\n'): void { + const full = join(cwd, file); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, body); +} + +function commit(cwd: string, message: string): void { + git(['add', '-A'], cwd); + git(['commit', '-q', '-m', message], cwd); +} + +/** A repo with one commit on `branch` and an empty working tree. */ +function makeRepo(branch = 'main'): string { + const dir = mkdtempSync(join(tmpdir(), 'git-changed-')); + git(['init', '-q', '-b', branch], dir); + git(['config', 'user.email', 'test@example.com'], dir); + git(['config', 'user.name', 'Test'], dir); + git(['config', 'commit.gpgsign', 'false'], dir); + write(dir, 'base.sql'); + commit(dir, 'base'); + return dir; +} + +describe('changedFiles', () => { + const repos: string[] = []; + const track = (dir: string): string => { + repos.push(dir); + return dir; + }; + + afterAll(() => { + for (const dir of repos) rmSync(dir, { recursive: true, force: true }); + delete process.env.GITHUB_BASE_REF; + }); + + beforeEach(() => { + delete process.env.GITHUB_BASE_REF; + }); + + it('rejects a directory that is not a repository', () => { + const dir = track(mkdtempSync(join(tmpdir(), 'git-changed-bare-'))); + expect(() => changedFiles({ cwd: dir })).toThrow(GitChangedError); + }); + + it('finds committed changes against the merge base, not the base tip', () => { + const dir = track(makeRepo()); + git(['checkout', '-q', '-b', 'feature'], dir); + write(dir, 'feature.sql'); + commit(dir, 'feature'); + + // A commit landing on main after the fork must not be attributed to the + // branch — this is the whole reason for using the merge base. + git(['checkout', '-q', 'main'], dir); + write(dir, 'other.sql'); + commit(dir, 'other'); + git(['checkout', '-q', 'feature'], dir); + + const result = changedFiles({ cwd: dir, base: 'main' }); + expect(result.source).toBe('merge-base'); + expect(result.mergeBase).toBeTruthy(); + expect(result.files.map((f) => f.relative)).toEqual(['feature.sql']); + expect(result.files[0].committed).toBe(true); + expect(result.files[0].worktree).toBe(false); + }); + + it('unions working-tree and untracked changes with committed ones', () => { + const dir = track(makeRepo()); + git(['checkout', '-q', '-b', 'feature'], dir); + write(dir, 'committed.sql'); + commit(dir, 'committed'); + write(dir, 'dirty.sql'); + write(dir, 'base.sql', 'select 2;\n'); + + const result = changedFiles({ cwd: dir, base: 'main' }); + expect(result.files.map((f) => f.relative).sort()).toEqual([ + 'base.sql', + 'committed.sql', + 'dirty.sql' + ]); + const dirty = result.files.find((f) => f.relative === 'dirty.sql'); + expect(dirty).toMatchObject({ status: 'untracked', worktree: true, committed: false }); + }); + + it('lists untracked files inside a brand-new directory individually', () => { + const dir = track(makeRepo()); + write(dir, 'pkg/deploy/one.sql'); + write(dir, 'pkg/deploy/two.sql'); + + // Without `status -uall` git collapses this to the directory `pkg/`, and + // every file under it goes unseen. + expect(changedPaths({ cwd: dir, ext: '.sql' }).sort()).toEqual([ + join(dir, 'pkg/deploy/one.sql'), + join(dir, 'pkg/deploy/two.sql') + ]); + }); + + it('reports a rename at its destination and drops the vanished source', () => { + const dir = track(makeRepo()); + write(dir, 'old.sql', 'select 42;\n'); + commit(dir, 'add old'); + git(['checkout', '-q', '-b', 'feature'], dir); + git(['mv', 'old.sql', 'new.sql'], dir); + commit(dir, 'rename'); + + const result = changedFiles({ cwd: dir, base: 'main' }); + expect(result.files.map((f) => f.relative)).toEqual(['new.sql']); + expect(result.files[0]).toMatchObject({ status: 'renamed', from: 'old.sql' }); + }); + + it('omits deleted paths unless asked for them', () => { + const dir = track(makeRepo()); + git(['checkout', '-q', '-b', 'feature'], dir); + git(['rm', '-q', 'base.sql'], dir); + commit(dir, 'delete'); + + expect(changedFiles({ cwd: dir, base: 'main' }).files).toEqual([]); + + const withDeleted = changedFiles({ cwd: dir, base: 'main', existingOnly: false }); + expect(withDeleted.files.map((f) => f.relative)).toEqual(['base.sql']); + expect(withDeleted.files[0]).toMatchObject({ status: 'deleted', exists: false }); + }); + + it('falls back to the working tree when no base exists', () => { + // No remote, no origin/HEAD, no main/master, no $GITHUB_BASE_REF: there is + // nothing to diff against, as on a shallow or detached CI checkout. + const dir = track(makeRepo('work')); + write(dir, 'dirty.sql'); + + const result = changedFiles({ cwd: dir }); + expect(result.base).toBeUndefined(); + expect(result.source).toBe('worktree'); + expect(result.files.map((f) => f.relative)).toEqual(['dirty.sql']); + }); + + it('discovers the local default branch when no base is given', () => { + const dir = track(makeRepo()); + git(['checkout', '-q', '-b', 'feature'], dir); + write(dir, 'feature.sql'); + commit(dir, 'feature'); + + const result = changedFiles({ cwd: dir }); + expect(result.base).toBe('main'); + expect(result.source).toBe('merge-base'); + expect(result.files.map((f) => f.relative)).toEqual(['feature.sql']); + }); + + it('honours base: false even when a base could be resolved', () => { + const dir = track(makeRepo()); + git(['checkout', '-q', '-b', 'feature'], dir); + write(dir, 'committed.sql'); + commit(dir, 'committed'); + write(dir, 'dirty.sql'); + + const result = changedFiles({ cwd: dir, base: false }); + expect(result.source).toBe('worktree'); + expect(result.files.map((f) => f.relative)).toEqual(['dirty.sql']); + }); + + it('ignores $GITHUB_BASE_REF when the remote ref is missing', () => { + const dir = track(makeRepo()); + process.env.GITHUB_BASE_REF = 'main'; + write(dir, 'dirty.sql'); + + // `origin/main` does not exist here. A naive implementation hands back that + // ref anyway and every later git call rejects it; this falls through to the + // local default branch instead. + const result = changedFiles({ cwd: dir }); + expect(result.base).toBe('main'); + expect(result.files.map((f) => f.relative)).toEqual(['dirty.sql']); + }); + + it('uses $GITHUB_BASE_REF as origin/ when that ref exists', () => { + const origin = track(makeRepo()); + const dir = track(mkdtempSync(join(tmpdir(), 'git-changed-clone-'))); + git(['clone', '-q', origin, dir], process.cwd()); + git(['config', 'user.email', 'test@example.com'], dir); + git(['config', 'user.name', 'Test'], dir); + git(['checkout', '-q', '-b', 'feature'], dir); + write(dir, 'feature.sql'); + commit(dir, 'feature'); + + process.env.GITHUB_BASE_REF = 'main'; + const result = changedFiles({ cwd: dir }); + expect(result.base).toBe('origin/main'); + expect(result.files.map((f) => f.relative)).toEqual(['feature.sql']); + }); + + it('filters by extension, exclude, include and within', () => { + const dir = track(makeRepo()); + write(dir, 'pkg/a.sql'); + write(dir, 'pkg/b.ts'); + write(dir, 'generated/c.sql'); + write(dir, 'other/d.sql'); + + expect(changedFiles({ cwd: dir, ext: 'sql' }).files.map((f) => f.relative).sort()).toEqual( + ['generated/c.sql', 'other/d.sql', 'pkg/a.sql'] + ); + expect( + changedFiles({ cwd: dir, ext: '.sql', exclude: ['generated/'] }) + .files.map((f) => f.relative) + .sort() + ).toEqual(['other/d.sql', 'pkg/a.sql']); + expect( + changedFiles({ cwd: dir, include: ['pkg/**'] }).files.map((f) => f.relative).sort() + ).toEqual(['pkg/a.sql', 'pkg/b.ts']); + expect( + changedFiles({ cwd: dir, within: ['pkg'], ext: ['.sql', '.ts'] }) + .files.map((f) => f.relative) + .sort() + ).toEqual(['pkg/a.sql', 'pkg/b.ts']); + }); + + it('excludes the whole subtree for a bare directory pattern', () => { + const dir = track(makeRepo()); + write(dir, 'a/dist/x.sql'); + write(dir, 'a/src/y.sql'); + + expect( + changedFiles({ cwd: dir, exclude: ['dist/'] }).files.map((f) => f.relative) + ).toEqual(['a/src/y.sql']); + }); + + it('reports paths relative to cwd when run from a subdirectory', () => { + const dir = track(makeRepo()); + write(dir, 'pkg/deploy/one.sql'); + write(dir, 'outside.sql'); + + const result = changedFiles({ cwd: join(dir, 'pkg') }); + // git speaks in repository-root paths; the caller asked from `pkg/`. + expect(result.repoRoot).toBe(require('fs').realpathSync(dir)); + const byRel = result.files.map((f) => f.relative).sort(); + expect(byRel).toContain('deploy/one.sql'); + expect(byRel).toContain('../outside.sql'); + }); + + it('handles paths that git quotes', () => { + const dir = track(makeRepo()); + write(dir, 'we ird/na"me.sql'); + + expect(changedFiles({ cwd: dir, ext: '.sql' }).files.map((f) => f.relative)).toEqual([ + 'we ird/na"me.sql' + ]); + }); + + it('can skip untracked files and the working tree', () => { + const dir = track(makeRepo()); + git(['checkout', '-q', '-b', 'feature'], dir); + write(dir, 'committed.sql'); + commit(dir, 'committed'); + write(dir, 'untracked.sql'); + write(dir, 'base.sql', 'select 3;\n'); + + expect( + changedFiles({ cwd: dir, base: 'main', untracked: false }) + .files.map((f) => f.relative) + .sort() + ).toEqual(['base.sql', 'committed.sql']); + + expect( + changedFiles({ cwd: dir, base: 'main', worktree: false }).files.map((f) => f.relative) + ).toEqual(['committed.sql']); + }); +}); + +describe('GitChanged', () => { + const repos: string[] = []; + + afterAll(() => { + for (const dir of repos) rmSync(dir, { recursive: true, force: true }); + }); + + it('reuses constructor defaults and lets calls override them', () => { + const dir = makeRepo(); + repos.push(dir); + write(dir, 'pkg/a.sql'); + write(dir, 'pkg/a.ts'); + + const changed = new GitChanged({ cwd: dir, ext: '.sql' }); + expect(changed.isRepo()).toBe(true); + expect(changed.paths()).toEqual([join(dir, 'pkg/a.sql')]); + expect(changed.paths({ ext: '.ts' })).toEqual([join(dir, 'pkg/a.ts')]); + expect(changed.files({ ext: undefined }).length).toBe(2); + }); +}); diff --git a/packages/git-changed/__tests__/cli.test.ts b/packages/git-changed/__tests__/cli.test.ts new file mode 100644 index 0000000..97e3af9 --- /dev/null +++ b/packages/git-changed/__tests__/cli.test.ts @@ -0,0 +1,168 @@ +import { execFileSync } from 'child_process'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; + +import { parseArgs, run } from '../src/cli'; + +function git(args: string[], cwd: string): string { + return execFileSync('git', args, { cwd, encoding: 'utf8' }); +} + +function write(cwd: string, file: string, body = 'select 1;\n'): void { + const full = join(cwd, file); + mkdirSync(join(full, '..'), { recursive: true }); + writeFileSync(full, body); +} + +function makeRepo(): string { + const dir = mkdtempSync(join(tmpdir(), 'git-changed-cli-')); + git(['init', '-q', '-b', 'main'], dir); + git(['config', 'user.email', 'test@example.com'], dir); + git(['config', 'user.name', 'Test'], dir); + git(['config', 'commit.gpgsign', 'false'], dir); + write(dir, 'base.sql'); + git(['add', '-A'], dir); + git(['commit', '-q', '-m', 'base'], dir); + return dir; +} + +describe('parseArgs', () => { + it('collects repeatable and comma-separated list values', () => { + const { options } = parseArgs([ + '--ext', + '.sql,.psql', + '--ext', + '.ddl', + '--exclude', + 'dist/', + '--exclude=generated/' + ]); + expect(options.ext).toEqual(['.sql', '.psql', '.ddl']); + expect(options.exclude).toEqual(['dist/', 'generated/']); + }); + + it('maps the negative flags', () => { + const { options } = parseArgs(['--no-base', '--no-worktree', '--no-untracked', '--deleted']); + expect(options).toMatchObject({ + base: false, + worktree: false, + untracked: false, + existingOnly: false + }); + }); + + it('rejects an unknown option and a flag with no value', () => { + expect(() => parseArgs(['--nope'])).toThrow(/Unknown option: --nope/); + expect(() => parseArgs(['--base'])).toThrow(/--base requires a value/); + }); +}); + +describe('run', () => { + const repos: string[] = []; + let out: string; + let err: string; + let logSpy: jest.SpyInstance; + let errSpy: jest.SpyInstance; + let writeSpy: jest.SpyInstance; + + beforeEach(() => { + out = ''; + err = ''; + logSpy = jest.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { + out += `${args.join(' ')}\n`; + }); + errSpy = jest.spyOn(console, 'error').mockImplementation((...args: unknown[]) => { + err += `${args.join(' ')}\n`; + }); + writeSpy = jest + .spyOn(process.stdout, 'write') + .mockImplementation((chunk: string | Uint8Array) => { + out += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString(); + return true; + }); + delete process.env.GITHUB_BASE_REF; + }); + + afterEach(() => { + logSpy.mockRestore(); + errSpy.mockRestore(); + writeSpy.mockRestore(); + }); + + afterAll(() => { + for (const dir of repos) rmSync(dir, { recursive: true, force: true }); + }); + + it('prints relative paths, one per line', () => { + const dir = makeRepo(); + repos.push(dir); + write(dir, 'pkg/a.sql'); + write(dir, 'pkg/b.ts'); + + expect(run(['--cwd', dir, '--ext', '.sql'])).toBe(0); + expect(out).toBe('pkg/a.sql\n'); + }); + + it('exits 0 and prints nothing when nothing changed', () => { + const dir = makeRepo(); + repos.push(dir); + + expect(run(['--cwd', dir])).toBe(0); + expect(out).toBe(''); + }); + + it('NUL-separates for xargs -0 without a trailing newline', () => { + const dir = makeRepo(); + repos.push(dir); + write(dir, 'a.sql'); + write(dir, 'b.sql'); + + expect(run(['--cwd', dir, '--ext', '.sql', '--null'])).toBe(0); + expect(out).toBe('a.sql\0b.sql\0'); + }); + + it('prefixes the status and can print absolute paths', () => { + const dir = makeRepo(); + repos.push(dir); + write(dir, 'a.sql'); + + expect(run(['--cwd', dir, '--status'])).toBe(0); + expect(out).toBe('untracked\ta.sql\n'); + + out = ''; + expect(run(['--cwd', dir, '--absolute'])).toBe(0); + expect(out.trim()).toBe(join(dir, 'a.sql')); + }); + + it('emits the full result as JSON', () => { + const dir = makeRepo(); + repos.push(dir); + write(dir, 'a.sql'); + + expect(run(['--cwd', dir, '--json', '--no-base'])).toBe(0); + const parsed = JSON.parse(out); + expect(parsed).toMatchObject({ source: 'worktree' }); + expect(parsed.files[0]).toMatchObject({ relative: 'a.sql', status: 'untracked' }); + }); + + it('reports usage on a bad flag and fails on a non-repository', () => { + expect(run(['--nope'])).toBe(2); + expect(err).toMatch(/Unknown option/); + + const bare = mkdtempSync(join(tmpdir(), 'git-changed-bare-')); + repos.push(bare); + err = ''; + expect(run(['--cwd', bare])).toBe(1); + expect(err).toMatch(/Not a git repository/); + }); + + it('prints help and a version', () => { + expect(run(['--help'])).toBe(0); + expect(out).toMatch(/git-changed — list files changed/); + + out = ''; + expect(run(['--version'])).toBe(0); + expect(out.trim()).toMatch(/^\d+\.\d+\.\d+|unknown$/); + }); +}); diff --git a/packages/git-changed/__tests__/match.test.ts b/packages/git-changed/__tests__/match.test.ts new file mode 100644 index 0000000..2c84c61 --- /dev/null +++ b/packages/git-changed/__tests__/match.test.ts @@ -0,0 +1,75 @@ +import { makeMatcher, normalizeExts, withinAny } from '../src/match'; + +const cwd = '/repo'; + +describe('makeMatcher', () => { + it('matches nothing when no patterns are given', () => { + const match = makeMatcher([], cwd); + expect(match('/repo/a.sql')).toBe(false); + }); + + it('matches a directory and its whole subtree', () => { + const match = makeMatcher(['dist/'], cwd); + expect(match('/repo/dist')).toBe(true); + expect(match('/repo/dist/a.sql')).toBe(true); + expect(match('/repo/pkg/dist/deep/a.sql')).toBe(true); + expect(match('/repo/distinct/a.sql')).toBe(false); + }); + + it('anchors a pattern that starts with a slash', () => { + const match = makeMatcher(['/sql/'], cwd); + expect(match('/repo/sql/a.sql')).toBe(true); + expect(match('/repo/pkg/sql/a.sql')).toBe(false); + }); + + it('keeps * inside a single segment and ** across segments', () => { + const single = makeMatcher(['pkg/*/deploy'], cwd); + expect(single('/repo/pkg/one/deploy/a.sql')).toBe(true); + expect(single('/repo/pkg/one/two/deploy/a.sql')).toBe(false); + + const deep = makeMatcher(['**/generated/**'], cwd); + expect(deep('/repo/a/b/generated/c.sql')).toBe(true); + expect(deep('/repo/generated/c.sql')).toBe(true); + }); + + it('matches an extension glob and a single-character glob', () => { + expect(makeMatcher(['*.sql'], cwd)('/repo/a/b/c.sql')).toBe(true); + expect(makeMatcher(['v?.sql'], cwd)('/repo/v1.sql')).toBe(true); + expect(makeMatcher(['v?.sql'], cwd)('/repo/v12.sql')).toBe(false); + }); + + it('matches the pgpm bundle artifacts and not their siblings', () => { + const match = makeMatcher(['**/sql/*--*.sql'], cwd); + expect(match('/repo/application/app/sql/app--0.0.1.sql')).toBe(true); + expect(match('/repo/sql/app--0.0.1.sql')).toBe(true); + expect(match('/repo/application/app/sql/helper.sql')).toBe(false); + }); + + it('treats regex metacharacters in a pattern literally', () => { + const match = makeMatcher(['a+b/'], cwd); + expect(match('/repo/a+b/c.sql')).toBe(true); + expect(match('/repo/aab/c.sql')).toBe(false); + }); +}); + +describe('withinAny', () => { + it('accepts everything when no directories are given', () => { + expect(withinAny('/repo/a.sql', [], cwd)).toBe(true); + }); + + it('accepts the directory itself and its descendants only', () => { + expect(withinAny('/repo/pkg/a.sql', ['pkg'], cwd)).toBe(true); + expect(withinAny('/repo/pkg', ['pkg'], cwd)).toBe(true); + // A sibling whose name merely starts with the same characters is outside. + expect(withinAny('/repo/pkg-other/a.sql', ['pkg'], cwd)).toBe(false); + }); +}); + +describe('normalizeExts', () => { + it('normalizes a string, a list, commas and a missing dot', () => { + expect(normalizeExts('sql')).toEqual(['.sql']); + expect(normalizeExts('.SQL')).toEqual(['.sql']); + expect(normalizeExts(['.ts,tsx', ' .mts '])).toEqual(['.ts', '.tsx', '.mts']); + expect(normalizeExts(undefined)).toEqual([]); + }); +}); diff --git a/packages/git-changed/jest.config.js b/packages/git-changed/jest.config.js new file mode 100644 index 0000000..057a942 --- /dev/null +++ b/packages/git-changed/jest.config.js @@ -0,0 +1,18 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + transform: { + '^.+\\.tsx?$': [ + 'ts-jest', + { + babelConfig: false, + tsconfig: 'tsconfig.json', + }, + ], + }, + transformIgnorePatterns: [`/node_modules/*`], + testRegex: '(/__tests__/.*|(\\.|/)(test|spec))\\.(jsx?|tsx?)$', + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + modulePathIgnorePatterns: ['dist/*'] +}; diff --git a/packages/git-changed/package.json b/packages/git-changed/package.json new file mode 100644 index 0000000..22a80c5 --- /dev/null +++ b/packages/git-changed/package.json @@ -0,0 +1,46 @@ +{ + "name": "git-changed", + "version": "0.1.0", + "author": "Constructive ", + "description": "List the files changed against a merge base, including working-tree and untracked changes", + "main": "index.js", + "module": "esm/index.js", + "types": "index.d.ts", + "bin": { + "git-changed": "cli.js" + }, + "homepage": "https://github.com/constructive-io/dev-utils", + "license": "MIT", + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "repository": { + "type": "git", + "url": "https://github.com/constructive-io/dev-utils" + }, + "bugs": { + "url": "https://github.com/constructive-io/dev-utils/issues" + }, + "scripts": { + "copy": "makage assets", + "clean": "makage clean", + "prepublishOnly": "npm run build", + "build": "makage build", + "lint": "eslint . --fix", + "test": "jest", + "test:watch": "jest --watch" + }, + "devDependencies": { + "makage": "0.1.10" + }, + "keywords": [ + "git", + "changed", + "merge-base", + "diff", + "lint-staged", + "ci", + "incremental" + ] +} diff --git a/packages/git-changed/src/base.ts b/packages/git-changed/src/base.ts new file mode 100644 index 0000000..e0a9427 --- /dev/null +++ b/packages/git-changed/src/base.ts @@ -0,0 +1,49 @@ +import { tryGit } from './git'; + +/** + * Candidate default branches, in the order git itself would find them. Tried + * only when the remote HEAD symref is missing — which is the norm in CI, where + * `actions/checkout` never sets it. + */ +const FALLBACK_BASES = ['origin/main', 'origin/master', 'main', 'master']; + +/** The repository's default branch (`origin/main`, `origin/master`, …). */ +export function defaultBranch(cwd: string): string | undefined { + const head = tryGit(['symbolic-ref', 'refs/remotes/origin/HEAD'], cwd)?.trim(); + if (head) { + // refs/remotes/origin/HEAD → refs/remotes/origin/main → origin/main + const short = head.replace(/^refs\/remotes\//, ''); + if (short) return short; + } + return FALLBACK_BASES.find( + (ref) => tryGit(['rev-parse', '--verify', '--quiet', ref], cwd) !== undefined + ); +} + +/** + * Resolve the ref to diff against, in precedence order: + * + * 1. an explicit base (`--base`, or `base` in code) — always wins; + * 2. `$GITHUB_BASE_REF` — the PR's target branch, set by GitHub Actions on + * `pull_request` events, as `origin/` since only the remote ref is + * fetched; + * 3. the repository's default branch. + * + * `undefined` means "no base is available" — a detached checkout, a fresh repo + * with no remote, or a shallow clone whose base ref was never fetched. Callers + * fall back to the working tree rather than failing: a gate that cannot see the + * base should still catch what the author is editing right now. + */ +export function resolveBase(base?: string, cwd: string = process.cwd()): string | undefined { + if (base && base.trim()) return base.trim(); + + const prBase = process.env.GITHUB_BASE_REF; + if (prBase && prBase.trim()) { + const ref = `origin/${prBase.trim()}`; + if (tryGit(['rev-parse', '--verify', '--quiet', ref], cwd) !== undefined) return ref; + // The remote ref is missing (a single-branch or shallow fetch). Fall through + // rather than handing back a ref that every later git call will reject. + } + + return defaultBranch(cwd); +} diff --git a/packages/git-changed/src/changed.ts b/packages/git-changed/src/changed.ts new file mode 100644 index 0000000..44f8613 --- /dev/null +++ b/packages/git-changed/src/changed.ts @@ -0,0 +1,298 @@ +import { existsSync } from 'fs'; +import { isAbsolute, relative, resolve, sep } from 'path'; + +import { resolveBase } from './base'; +import { GitChangedError, isRepo, repoRoot, tryGit } from './git'; +import { makeMatcher, normalizeExts, withinAny } from './match'; +import type { + ChangedFile, + ChangedOptions, + ChangedResult, + ChangedSource, + ChangeStatus +} from './types'; + +/** Map a `git diff --name-status` letter to a status. */ +function diffStatus(code: string): ChangeStatus { + switch (code[0]) { + case 'A': + return 'added'; + case 'M': + case 'T': + return 'modified'; + case 'D': + return 'deleted'; + case 'R': + case 'C': + return 'renamed'; + default: + return 'unknown'; + } +} + +/** Map the two-letter `git status --porcelain` code to a status. */ +function porcelainStatus(code: string): ChangeStatus { + if (code === '??') return 'untracked'; + // Either column can carry the interesting letter: `M ` is staged, ` M` is + // unstaged, `MM` is both. Prefer the first non-space. + const letter = code.trim()[0] ?? ''; + switch (letter) { + case 'A': + return 'added'; + case 'D': + return 'deleted'; + case 'R': + case 'C': + return 'renamed'; + case 'M': + case 'T': + return 'modified'; + default: + return 'unknown'; + } +} + +/** Strip git's quoting of paths with unusual characters. */ +function unquote(p: string): string { + if (!p.startsWith('"')) return p; + const inner = p.slice(1, -1); + try { + return JSON.parse(`"${inner}"`) as string; + } catch { + return inner; + } +} + +interface Entry { + status: ChangeStatus; + from?: string; + committed: boolean; + worktree: boolean; +} + +/** + * Merge a path into the accumulator. A path can show up twice — committed + * against the base *and* modified again in the working tree — so keep the union + * of the flags and let the later, more specific status win. + */ +function record(map: Map, path: string, next: Entry): void { + const prev = map.get(path); + if (!prev) { + map.set(path, next); + return; + } + map.set(path, { + // A path deleted in the working tree is deleted, whatever the base said. + status: next.status === 'deleted' ? 'deleted' : prev.status, + from: prev.from ?? next.from, + committed: prev.committed || next.committed, + worktree: prev.worktree || next.worktree + }); +} + +/** Committed changes between the merge base (or the base ref) and `HEAD`. */ +function collectCommitted( + map: Map, + cwd: string, + from: string +): void { + // -M detects renames so the destination is reported instead of an + // add/delete pair. + const out = tryGit(['diff', '--name-status', '-M', from, 'HEAD'], cwd) ?? ''; + for (const line of out.split('\n')) { + if (!line.trim()) continue; + const parts = line.split('\t'); + const code = parts[0]; + const status = diffStatus(code); + if (status === 'renamed' && parts.length >= 3) { + // R100oldnew — the destination is what exists now. + record(map, unquote(parts[2]), { + status, + from: unquote(parts[1]), + committed: true, + worktree: false + }); + continue; + } + if (parts.length < 2) continue; + record(map, unquote(parts[1]), { status, committed: true, worktree: false }); + } +} + +/** Uncommitted and untracked changes in the working tree. */ +function collectWorktree( + map: Map, + cwd: string, + untracked: boolean +): void { + // -uall lists files inside a new directory individually. Without it git + // reports the directory as a single entry and every file under it is invisible + // to the caller — the bug this utility exists to stop re-implementing. + const args = ['status', '--porcelain', untracked ? '-uall' : '-uno']; + const out = tryGit(args, cwd) ?? ''; + for (const raw of out.split('\n')) { + const line = raw.replace(/\s+$/, ''); + if (!line) continue; + const code = line.slice(0, 2); + let path = line.slice(3); + let from: string | undefined; + const arrow = path.indexOf(' -> '); + if (arrow !== -1) { + from = unquote(path.slice(0, arrow)); + path = path.slice(arrow + 4); + } + path = unquote(path); + if (!path) continue; + record(map, path, { + status: porcelainStatus(code), + from, + committed: false, + worktree: true + }); + } +} + +/** + * Files changed relative to a base ref, unioned with working-tree changes. + * + * ```ts + * changedFiles({ ext: '.sql', exclude: ['**\/generated/**'] }); + * // → { files: [...], paths: [...], base: 'origin/main', source: 'merge-base' } + * ``` + * + * Resolution order for the base is `options.base` → `$GITHUB_BASE_REF` → the + * repository default branch; pass `base: false` to skip it entirely. When no + * base is available the result falls back to the working tree with + * `source: 'worktree'` rather than throwing, so a gate still checks what the + * author is editing on a shallow or detached checkout. + */ +export function changedFiles(options: ChangedOptions = {}): ChangedResult { + const cwd = resolve(options.cwd ?? process.cwd()); + if (!isRepo(cwd)) { + throw new GitChangedError(`Not a git repository: ${cwd}`); + } + const root = repoRoot(cwd) ?? cwd; + + const base = options.base === false ? undefined : resolveBase(options.base, cwd); + let mergeBase: string | undefined; + let source: ChangedSource = 'worktree'; + + if (base) { + mergeBase = tryGit(['merge-base', 'HEAD', base], cwd)?.trim() || undefined; + source = mergeBase ? 'merge-base' : 'base'; + } + + const map = new Map(); + // Diff from the merge base, not the branch tip: changes the base picked up + // since this branch forked are not this branch's changes. + if (base) collectCommitted(map, cwd, mergeBase ?? base); + if (options.worktree !== false) { + collectWorktree(map, cwd, options.untracked !== false); + } + + const exts = normalizeExts(options.ext); + const included = makeMatcher(options.include ?? [], cwd); + const hasInclude = (options.include ?? []).filter((p) => p && p.trim()).length > 0; + const excluded = makeMatcher(options.exclude ?? [], cwd); + const within = options.within ?? []; + const existingOnly = options.existingOnly !== false; + + const files: ChangedFile[] = []; + for (const [gitPath, entry] of map) { + // git reports paths from the repository root; everything else here is + // relative to `cwd`, which may be a subdirectory. + const abs = resolve(root, gitPath); + const rel = relative(cwd, abs).split(sep).join('/'); + const exists = existsSync(abs); + + if (existingOnly && !exists) continue; + if (exts.length > 0 && !exts.some((e) => abs.toLowerCase().endsWith(e))) continue; + if (!withinAny(abs, within, cwd)) continue; + if (hasInclude && !included(abs)) continue; + if (excluded(abs)) continue; + + files.push({ + path: abs, + relative: rel, + status: entry.status, + from: entry.from, + committed: entry.committed, + worktree: entry.worktree, + exists + }); + } + + files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0)); + + return { + files, + paths: files.map((f) => f.path), + base, + mergeBase, + source, + repoRoot: root + }; +} + +/** Just the absolute paths — the common case. */ +export function changedPaths(options: ChangedOptions = {}): string[] { + return changedFiles(options).paths; +} + +/** + * Bound to one repository, so a tool that asks several questions resolves the + * repository root and the base once. + * + * ```ts + * const changed = new GitChanged({ cwd, exclude: ['dist/'] }); + * changed.paths({ ext: '.sql' }); + * changed.paths({ ext: ['.ts', '.tsx'] }); + * ``` + * + * Options given to the constructor are defaults; options passed to a method + * override them key by key. Array options replace rather than merge — an + * `exclude` on a call means "these instead", which is easier to reason about + * than a growing union. + */ +export class GitChanged { + private readonly defaults: ChangedOptions; + readonly cwd: string; + + constructor(options: ChangedOptions = {}) { + this.cwd = resolve(options.cwd ?? process.cwd()); + this.defaults = { ...options, cwd: this.cwd }; + } + + /** Whether `cwd` is inside a git work tree. */ + isRepo(): boolean { + return isRepo(this.cwd); + } + + /** Absolute path to the repository root. */ + root(): string | undefined { + return repoRoot(this.cwd); + } + + /** The base ref this instance would diff against. */ + base(): string | undefined { + const base = this.defaults.base; + return base === false ? undefined : resolveBase(base, this.cwd); + } + + files(options: ChangedOptions = {}): ChangedFile[] { + return this.result(options).files; + } + + paths(options: ChangedOptions = {}): string[] { + return this.result(options).paths; + } + + result(options: ChangedOptions = {}): ChangedResult { + return changedFiles({ ...this.defaults, ...options, cwd: this.cwd }); + } +} + +/** Resolve a path against `cwd`, for callers holding relative paths. */ +export function toAbsolute(file: string, cwd: string = process.cwd()): string { + return isAbsolute(file) ? file : resolve(cwd, file); +} diff --git a/packages/git-changed/src/cli.ts b/packages/git-changed/src/cli.ts new file mode 100644 index 0000000..b89e956 --- /dev/null +++ b/packages/git-changed/src/cli.ts @@ -0,0 +1,212 @@ +#!/usr/bin/env node +import { changedFiles } from './changed'; +import { GitChangedError } from './git'; +import type { ChangedOptions } from './types'; + +const USAGE = `git-changed — list files changed against the merge base + +Usage: + git-changed [options] + +Options: + --base Diff against (default: $GITHUB_BASE_REF, else the + repository default branch) + --no-base Working-tree changes only + --ext Keep only these extensions (repeatable, comma-separated) + --include Keep only paths matching these globs (repeatable) + --exclude Drop paths matching these globs (repeatable) + --within Restrict to these directories (repeatable) + --no-worktree Committed changes only + --no-untracked Skip untracked files + --deleted Include paths that no longer exist + --status Prefix each path with its status + --absolute Print absolute paths (default: relative to cwd) + --json Print the full result as JSON + -0, --null NUL-separate output, for \`xargs -0\` + --cwd Run as if in + -h, --help Show this help + -v, --version Show the version + +Exit code is 0 whether or not anything changed; an empty list is an answer, not +an error. Use --json (or test for empty output) to branch on it. + +Examples: + git-changed --ext .sql --exclude 'dist/' '**/generated/**' + git-changed --ext .ts --null | xargs -0 -r prettier --check + git-changed --base origin/develop --status`; + +function packageVersion(): string { + // The CLI sits in dist/ in the repo but at the package root once published, + // so the manifest is one directory up in one layout and alongside in the other. + for (const candidate of ['../package.json', './package.json']) { + try { + return (require(candidate) as { version: string }).version; + } catch { + // Wrong layout; try the next candidate. + } + } + return 'unknown'; +} + +interface Parsed { + options: ChangedOptions; + status: boolean; + absolute: boolean; + json: boolean; + nul: boolean; + help: boolean; + version: boolean; +} + +/** Split repeatable, comma-separated list values: `--ext .ts,.tsx --ext .mts`. */ +function pushList(target: string[], value: string): void { + for (const part of value.split(',')) { + const trimmed = part.trim(); + if (trimmed) target.push(trimmed); + } +} + +export function parseArgs(argv: string[]): Parsed { + const ext: string[] = []; + const include: string[] = []; + const exclude: string[] = []; + const within: string[] = []; + const options: ChangedOptions = {}; + const parsed: Parsed = { + options, + status: false, + absolute: false, + json: false, + nul: false, + help: false, + version: false + }; + + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + // Accept both `--ext .sql` and `--ext=.sql`. + const eq = arg.indexOf('='); + const flag = arg.startsWith('--') && eq !== -1 ? arg.slice(0, eq) : arg; + const inline = arg.startsWith('--') && eq !== -1 ? arg.slice(eq + 1) : undefined; + const next = (): string => { + const value = inline ?? argv[++i]; + if (value === undefined) { + throw new GitChangedError(`${flag} requires a value`); + } + return value; + }; + + switch (flag) { + case '-h': + case '--help': + parsed.help = true; + break; + case '-v': + case '--version': + parsed.version = true; + break; + case '--base': + options.base = next(); + break; + case '--no-base': + options.base = false; + break; + case '--ext': + pushList(ext, next()); + break; + case '--include': + pushList(include, next()); + break; + case '--exclude': + pushList(exclude, next()); + break; + case '--within': + pushList(within, next()); + break; + case '--cwd': + options.cwd = next(); + break; + case '--no-worktree': + options.worktree = false; + break; + case '--no-untracked': + options.untracked = false; + break; + case '--deleted': + options.existingOnly = false; + break; + case '--status': + parsed.status = true; + break; + case '--absolute': + parsed.absolute = true; + break; + case '--json': + parsed.json = true; + break; + case '-0': + case '--null': + parsed.nul = true; + break; + default: + throw new GitChangedError(`Unknown option: ${arg}`); + } + } + + if (ext.length) options.ext = ext; + if (include.length) options.include = include; + if (exclude.length) options.exclude = exclude; + if (within.length) options.within = within; + return parsed; +} + +export function run(argv: string[] = process.argv.slice(2)): number { + let parsed: Parsed; + try { + parsed = parseArgs(argv); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + console.error(`\n${USAGE}`); + return 2; + } + + if (parsed.help) { + console.log(USAGE); + return 0; + } + if (parsed.version) { + console.log(packageVersion()); + return 0; + } + + let result; + try { + result = changedFiles(parsed.options); + } catch (err) { + console.error(err instanceof Error ? err.message : String(err)); + return 1; + } + + if (parsed.json) { + console.log(JSON.stringify(result, null, 2)); + return 0; + } + + const lines = result.files.map((f) => { + const path = parsed.absolute ? f.path : f.relative; + return parsed.status ? `${f.status}\t${path}` : path; + }); + + if (parsed.nul) { + // No trailing newline: xargs -0 splits on NUL, and a stray newline would + // become part of the last filename. + process.stdout.write(lines.map((l) => `${l}\0`).join('')); + } else if (lines.length) { + process.stdout.write(`${lines.join('\n')}\n`); + } + return 0; +} + +if (require.main === module) { + process.exit(run()); +} diff --git a/packages/git-changed/src/git.ts b/packages/git-changed/src/git.ts new file mode 100644 index 0000000..0f152df --- /dev/null +++ b/packages/git-changed/src/git.ts @@ -0,0 +1,63 @@ +import { spawnSync } from 'child_process'; + +/** Thrown when git itself is unusable: not installed, or `cwd` is not a repo. */ +export class GitChangedError extends Error { + constructor(message: string) { + super(message); + this.name = 'GitChangedError'; + } +} + +/** + * Run git and return stdout. Arguments are passed as an array — never a shell + * string — so a branch name like `feat/it's-fine` cannot become a shell quoting + * bug. + */ +export function git(args: string[], cwd: string): string { + const result = spawnSync('git', args, { + cwd, + encoding: 'utf8', + // A monorepo diff can be large, and the default 1 MB cap truncates it + // *silently* — which reads as "nothing changed" rather than an error. + maxBuffer: 64 * 1024 * 1024 + }); + + if (result.error) { + throw new GitChangedError(`git ${args[0]} failed: ${result.error.message}`); + } + if (result.status !== 0) { + const stderr = (result.stderr ?? '').trim(); + throw new GitChangedError( + `git ${args.join(' ')} exited ${result.status}${stderr ? `: ${stderr}` : ''}` + ); + } + return result.stdout ?? ''; +} + +/** + * Run git, returning `undefined` instead of throwing. For the probes where + * failure is a legitimate answer: a ref that does not exist, a shallow clone + * with no merge base, a directory that is not a repository. + */ +export function tryGit(args: string[], cwd: string): string | undefined { + try { + return git(args, cwd); + } catch { + return undefined; + } +} + +/** Absolute path to the repository root, or `undefined` outside a repository. */ +export function repoRoot(cwd: string): string | undefined { + return tryGit(['rev-parse', '--show-toplevel'], cwd)?.trim() || undefined; +} + +/** Whether `cwd` is inside a git work tree. */ +export function isRepo(cwd: string): boolean { + return tryGit(['rev-parse', '--is-inside-work-tree'], cwd)?.trim() === 'true'; +} + +/** Whether the repository is a shallow clone (no merge base to be had). */ +export function isShallow(cwd: string): boolean { + return tryGit(['rev-parse', '--is-shallow-repository'], cwd)?.trim() === 'true'; +} diff --git a/packages/git-changed/src/index.ts b/packages/git-changed/src/index.ts new file mode 100644 index 0000000..f0f9a95 --- /dev/null +++ b/packages/git-changed/src/index.ts @@ -0,0 +1,11 @@ +export { defaultBranch, resolveBase } from './base'; +export { changedFiles, changedPaths, GitChanged, toAbsolute } from './changed'; +export { GitChangedError, isRepo, isShallow, repoRoot } from './git'; +export { makeMatcher, normalizeExts, withinAny } from './match'; +export type { + ChangedFile, + ChangedOptions, + ChangedResult, + ChangedSource, + ChangeStatus +} from './types'; diff --git a/packages/git-changed/src/match.ts b/packages/git-changed/src/match.ts new file mode 100644 index 0000000..c52a8c2 --- /dev/null +++ b/packages/git-changed/src/match.ts @@ -0,0 +1,97 @@ +import { isAbsolute, relative, resolve, sep } from 'path'; + +/** + * Gitignore-flavoured glob matching, deliberately not a full glob engine — the + * patterns these tools carry are `dist/`, `**\/generated/**`, `*.sql`, and the + * occasional `packages/*\/deploy`. Supported: + * + * - `*` — any run of characters within one path segment + * - `**` — any run of segments + * - `?` — exactly one character + * - a plain path (`sql/`, `dist`) matches that path *and everything under it* + * - an unanchored pattern matches at any segment boundary (`generated/` + * matches `a/b/generated/c.sql`) + * - a leading `/` anchors the pattern to `cwd` + */ +function toRegExp(pattern: string): RegExp { + const anchored = pattern.startsWith('/'); + let body = anchored ? pattern.slice(1) : pattern; + + // A trailing slash is a directory marker; the subtree suffix below covers it. + body = body.replace(/\/+$/, ''); + + let source = ''; + for (let i = 0; i < body.length; i++) { + const ch = body[i]; + if (ch === '*') { + if (body[i + 1] === '*') { + // `**/` spans zero or more segments; a bare `**` spans anything. + if (body[i + 2] === '/') { + source += '(?:[^/]+/)*'; + i += 2; + } else { + source += '.*'; + i += 1; + } + } else { + source += '[^/]*'; + } + continue; + } + if (ch === '?') { + source += '[^/]'; + continue; + } + source += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&'); + } + + // Match the path itself or anything beneath it, so `sql/` excludes the tree. + const tail = '(?:/.*)?$'; + return new RegExp(anchored ? `^${source}${tail}` : `^(?:.*/)?${source}${tail}`); +} + +/** Normalize to a `/`-separated path relative to `cwd`, for matching. */ +function toRelative(file: string, cwd: string): string { + const abs = isAbsolute(file) ? file : resolve(cwd, file); + return relative(cwd, abs).split(sep).join('/'); +} + +/** + * Compile patterns into a predicate. An empty pattern list matches nothing, so + * `exclude: []` excludes nothing and `include: []` is treated as "no include + * filter" by the caller rather than "include nothing". + */ +export function makeMatcher( + patterns: string[] = [], + cwd: string = process.cwd() +): (file: string) => boolean { + const regexes = patterns.filter((p) => p && p.trim()).map((p) => toRegExp(p.trim())); + if (regexes.length === 0) return () => false; + + return (file: string) => { + const rel = toRelative(file, cwd); + return regexes.some((re) => re.test(rel)); + }; +} + +/** `true` when `file` is inside one of `dirs` (or `dirs` is empty). */ +export function withinAny(file: string, dirs: string[], cwd: string): boolean { + if (dirs.length === 0) return true; + const abs = isAbsolute(file) ? file : resolve(cwd, file); + return dirs.some((dir) => { + const root = isAbsolute(dir) ? dir : resolve(cwd, dir); + if (abs === root) return true; + return abs.startsWith(root.endsWith(sep) ? root : root + sep); + }); +} + +/** Normalize `.sql` / `sql` / `['.sql','.psql']` into a `.ext` list. */ +export function normalizeExts(ext?: string | string[]): string[] { + const list = Array.isArray(ext) ? ext : ext ? [ext] : []; + return list + .flatMap((e) => e.split(',')) + .map((e) => e.trim()) + .filter(Boolean) + .map((e) => (e.startsWith('.') ? e : `.${e}`)) + .map((e) => e.toLowerCase()); +} diff --git a/packages/git-changed/src/types.ts b/packages/git-changed/src/types.ts new file mode 100644 index 0000000..c819fa2 --- /dev/null +++ b/packages/git-changed/src/types.ts @@ -0,0 +1,79 @@ +/** + * How a file came to be in the changed set. `deleted` and `renamed` are the two + * a naive implementation gets wrong: linting a path that no longer exists is a + * crash, and a rename must be reported at its *destination*. + */ +export type ChangeStatus = + | 'added' + | 'modified' + | 'deleted' + | 'renamed' + | 'untracked' + | 'unknown'; + +export interface ChangedFile { + /** Absolute path. */ + path: string; + /** Path relative to `cwd`, `/`-separated — what you print. */ + relative: string; + status: ChangeStatus; + /** For a rename, the path it came from. */ + from?: string; + /** Present in the diff against the base. */ + committed: boolean; + /** Present in `git status` — uncommitted or untracked. */ + worktree: boolean; + /** Whether the path exists on disk right now. */ + exists: boolean; +} + +/** Where the committed half of the set came from. */ +export type ChangedSource = + /** `merge-base HEAD ` — the normal PR case. */ + | 'merge-base' + /** The base ref exists but has no merge base with HEAD (unrelated history). */ + | 'base' + /** No base at all: working-tree changes only (shallow, detached, no remote). */ + | 'worktree'; + +export interface ChangedOptions { + /** Directory to resolve from and report relative to. Default `process.cwd()`. */ + cwd?: string; + /** + * Ref to diff against. Omit to auto-resolve (`$GITHUB_BASE_REF` → default + * branch); pass `false` for working-tree changes only. + */ + base?: string | false; + /** Keep only these extensions, e.g. `'.sql'` or `['.sql', '.psql']`. */ + ext?: string | string[]; + /** Keep only paths matching these globs. */ + include?: string[]; + /** Drop paths matching these globs — generated trees, `dist/`, fixtures. */ + exclude?: string[]; + /** + * Restrict to these directories. Cheaper and more predictable than an + * `include` glob when you already know the subtree (a package dir, a CLI's + * positional paths). + */ + within?: string[]; + /** Drop paths that no longer exist. Default `true`. */ + existingOnly?: boolean; + /** Include uncommitted/untracked working-tree changes. Default `true`. */ + worktree?: boolean; + /** Include untracked files. Default `true`; ignored when `worktree` is false. */ + untracked?: boolean; +} + +export interface ChangedResult { + /** The changed files, sorted by path, after filtering. */ + files: ChangedFile[]; + /** Absolute paths — the common case, so you don't map every call site. */ + paths: string[]; + /** The base that was used, if any. */ + base?: string; + /** The resolved merge-base commit, if one was found. */ + mergeBase?: string; + source: ChangedSource; + /** Absolute path to the repository root. */ + repoRoot: string; +} diff --git a/packages/git-changed/tsconfig.esm.json b/packages/git-changed/tsconfig.esm.json new file mode 100644 index 0000000..783094b --- /dev/null +++ b/packages/git-changed/tsconfig.esm.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "dist/esm", + "module": "ES2015" + } +} diff --git a/packages/git-changed/tsconfig.json b/packages/git-changed/tsconfig.json new file mode 100644 index 0000000..1a9d569 --- /dev/null +++ b/packages/git-changed/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src/" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules", "**/*.spec.*", "**/*.test.*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2af4b5..aff5e3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,6 +244,13 @@ importers: version: 0.1.10 publishDirectory: dist + packages/git-changed: + devDependencies: + makage: + specifier: 0.1.10 + version: 0.1.10 + publishDirectory: dist + packages/http-errors: devDependencies: makage: