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:
]