Skip to content

Commit 9b69a67

Browse files
feat: add --diff-branch flag to skip existing migrations
Allows consumers to only lint migration files that don't exist on a given branch. Makes --all-files safe for repos with older migrations that have pre-existing squawk violations.
1 parent a43ab35 commit 9b69a67

3 files changed

Lines changed: 120 additions & 4 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,21 @@ repos:
1818
1919
No additional configuration is required. The hook auto-detects your migrations directory by reading `script_location` from `alembic.ini`. The consumer's `alembic` must be available on PATH (the hook calls it via subprocess).
2020

21+
### Only lint new migrations
22+
23+
To skip migrations that already exist on a branch (useful for repos with existing violations you can't fix immediately), pass `--diff-branch`:
24+
25+
```yaml
26+
repos:
27+
- repo: https://github.com/kintsugi-tax/squawk-pre-commit
28+
rev: v0.3.0
29+
hooks:
30+
- id: squawk-alembic
31+
args: [--diff-branch, main]
32+
```
33+
34+
With this flag, the hook checks whether each migration file exists on the specified branch. Files that already exist are skipped. New files (not yet on the branch) are linted. This makes `pre-commit run --all-files` safe to run in repos where older migrations would fail linting.
35+
2136
## How It Works
2237

2338
When pre-commit runs, the hook:

squawk_alembic/hook.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Pre-commit hook that generates DDL via alembic upgrade --sql and lints with squawk."""
22

3+
import argparse
34
import ast
45
import configparser
56
import os
@@ -126,9 +127,26 @@ def generate_sql(filepath):
126127
return result.stdout
127128

128129

130+
def file_exists_on_branch(filepath, branch):
131+
"""Check if a file exists on the given git branch."""
132+
result = subprocess.run(
133+
["git", "cat-file", "-e", f"{branch}:{filepath}"],
134+
capture_output=True,
135+
)
136+
return result.returncode == 0
137+
138+
129139
def main():
130-
files = sys.argv[1:]
131-
if not files:
140+
parser = argparse.ArgumentParser()
141+
parser.add_argument(
142+
"--diff-branch",
143+
default=None,
144+
help="Only lint migration files that don't exist on this branch.",
145+
)
146+
parser.add_argument("files", nargs="*")
147+
args = parser.parse_args()
148+
149+
if not args.files:
132150
return 0
133151

134152
migrations_path = find_migrations_path()
@@ -141,12 +159,15 @@ def main():
141159

142160
exit_code = 0
143161

144-
for filepath in files:
162+
for filepath in args.files:
145163
try:
146164
Path(filepath).relative_to(migrations_path)
147165
except ValueError:
148166
continue
149167

168+
if args.diff_branch and file_exists_on_branch(filepath, args.diff_branch):
169+
continue
170+
150171
sql = generate_sql(filepath)
151172
if not sql:
152173
continue

tests/test_main.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,16 @@ def make_result(returncode=0, stdout="", stderr=""):
3030
)()
3131

3232

33-
def fake_subprocess(alembic_result=None, squawk_result=None):
33+
def fake_subprocess(
34+
alembic_result=None, squawk_result=None, git_exists_on_branch=False
35+
):
3436
"""Return a side_effect function that dispatches based on the command."""
3537
alembic_res = alembic_result or make_result(stdout="CREATE TABLE foo (id int);\n")
3638
squawk_res = squawk_result or make_result()
3739

3840
def side_effect(cmd, **kwargs):
41+
if cmd[0] == "git":
42+
return make_result(returncode=0 if git_exists_on_branch else 1)
3943
if cmd[0] == "alembic":
4044
return alembic_res
4145
if cmd[0] == "squawk":
@@ -212,3 +216,79 @@ def upgrade():
212216
assert main() == 0
213217
alembic_call = mock_run.call_args_list[0][0][0]
214218
assert "base:first001" in alembic_call
219+
220+
221+
def test_diff_branch_skips_existing_file(repo):
222+
path = write_migration(
223+
repo,
224+
"008_existing.py",
225+
"""
226+
revision = 'exists01'
227+
down_revision = 'prev001'
228+
229+
from alembic import op
230+
231+
def upgrade():
232+
op.execute("CREATE TABLE foo (id int)")
233+
""",
234+
)
235+
with (
236+
patch("sys.argv", ["squawk-alembic", "--diff-branch", "main", path]),
237+
patch(
238+
"subprocess.run",
239+
side_effect=fake_subprocess(git_exists_on_branch=True),
240+
) as mock_run,
241+
):
242+
assert main() == 0
243+
# Only the git cat-file call should happen, no alembic or squawk
244+
assert mock_run.call_count == 1
245+
assert mock_run.call_args_list[0][0][0][0] == "git"
246+
247+
248+
def test_diff_branch_lints_new_file(repo):
249+
path = write_migration(
250+
repo,
251+
"009_new.py",
252+
"""
253+
revision = 'new001'
254+
down_revision = 'prev001'
255+
256+
from alembic import op
257+
258+
def upgrade():
259+
op.execute("CREATE TABLE foo (id int)")
260+
""",
261+
)
262+
with (
263+
patch("sys.argv", ["squawk-alembic", "--diff-branch", "main", path]),
264+
patch(
265+
"subprocess.run",
266+
side_effect=fake_subprocess(git_exists_on_branch=False),
267+
) as mock_run,
268+
):
269+
assert main() == 0
270+
# git cat-file + alembic + squawk = 3 calls
271+
assert mock_run.call_count == 3
272+
273+
274+
def test_without_diff_branch_lints_all(repo):
275+
path = write_migration(
276+
repo,
277+
"010_all.py",
278+
"""
279+
revision = 'all001'
280+
down_revision = 'prev001'
281+
282+
from alembic import op
283+
284+
def upgrade():
285+
op.execute("CREATE TABLE foo (id int)")
286+
""",
287+
)
288+
with (
289+
patch("sys.argv", ["squawk-alembic", path]),
290+
patch("subprocess.run", side_effect=fake_subprocess()) as mock_run,
291+
):
292+
assert main() == 0
293+
# No git call, just alembic + squawk = 2 calls
294+
assert mock_run.call_count == 2

0 commit comments

Comments
 (0)