Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ jobs:
fi
echo "Versions match: $PYPROJECT_VERSION"

if [ "${{ github.event_name }}" = "pull_request" ]; then
git fetch origin main --depth=1
MAIN_VERSION=$(git show origin/main:pyproject.toml | grep '^version = ' | sed 's/version = "\(.*\)"/\1/' || true)
if [ -z "$MAIN_VERSION" ]; then
echo "::warning::Could not determine version on main, skipping version bump check"
elif [ "$PYPROJECT_VERSION" = "$MAIN_VERSION" ]; then
echo "::error::Version $PYPROJECT_VERSION is the same as on main. Please bump the version."
echo "Run 'make bump VERSION=x.y.z' to update both files."
exit 1
fi
fi

- name: Run tests
run: poetry run pytest tests/ -v

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"

[tool.poetry]
name = "squawk-alembic"
version = "0.3.0"
version = "0.3.1"
description = "Pre-commit hook to lint Alembic migration SQL with squawk"
packages = [{include = "squawk_alembic"}]
readme = "README.md"
Expand Down
2 changes: 1 addition & 1 deletion squawk_alembic/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.3.0"
__version__ = "0.3.1"
28 changes: 21 additions & 7 deletions squawk_alembic/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,11 @@ def generate_sql(filepath):


def validate_branch(branch):
"""Validate that a branch name is safe and exists in git."""
"""Validate that a branch name is safe and exists in git.

For remote refs (origin/...), attempts a shallow fetch when the ref is
missing locally — common in CI shallow clones.
"""
if not _BRANCH_RE.match(branch) or ".." in branch:
print(
f"squawk-alembic: invalid branch name: {branch!r}",
Expand All @@ -148,13 +152,23 @@ def validate_branch(branch):
except FileNotFoundError:
print("squawk-alembic: git not found", file=sys.stderr)
return False
if result.returncode != 0:
print(
f"squawk-alembic: branch '{branch}' not found in git",
file=sys.stderr,
if result.returncode == 0:
return True

if branch.startswith("origin/"):
remote_branch = branch.removeprefix("origin/")
fetch = subprocess.run(
["git", "fetch", "origin", remote_branch, "--depth=1"],
capture_output=True,
)
return False
return True
if fetch.returncode == 0:
return True
Comment on lines +158 to +165
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify git fetch behavior with and without explicit refspec
# Test in a temporary directory to check if origin/<branch> is created

echo "Testing git fetch behavior..."

# Create a bare repo to act as remote
mkdir -p /tmp/test_remote
cd /tmp/test_remote
git init --bare

# Create a local repo and push a commit
mkdir -p /tmp/test_local
cd /tmp/test_local
git init
git config user.email "test@example.com"
git config user.name "Test User"
git remote add origin /tmp/test_remote
echo "test" > file.txt
git add file.txt
git commit -m "initial"
git push origin main

# Create a shallow clone to simulate CI
mkdir -p /tmp/test_shallow
cd /tmp/test_shallow
git init
git config user.email "test@example.com"
git config user.name "Test User"
git remote add origin /tmp/test_remote

# Test: fetch without explicit refspec
git fetch origin main --depth=1
echo "After 'git fetch origin main --depth=1':"
git rev-parse --verify origin/main 2>&1 || echo "origin/main NOT created"

# Also test with explicit refspec for comparison
rm -rf /tmp/test_shallow2
mkdir -p /tmp/test_shallow2
cd /tmp/test_shallow2
git init
git config user.email "test@example.com"
git config user.name "Test User"
git remote add origin /tmp/test_remote
git fetch origin "main:refs/remotes/origin/main" --depth=1
echo "After 'git fetch origin main:refs/remotes/origin/main --depth=1':"
git rev-parse --verify origin/main 2>&1 || echo "origin/main NOT created"

# Cleanup
rm -rf /tmp/test_remote /tmp/test_local /tmp/test_shallow /tmp/test_shallow2

Repository: kintsugi-tax/squawk-pre-commit

Length of output: 2934


🏁 Script executed:

#!/bin/bash
# Corrected test: verify git fetch behavior with matching branch names

echo "Testing git fetch behavior with correct branch name..."

# Create a bare repo to act as remote
mkdir -p /tmp/test_remote
cd /tmp/test_remote
git init --bare

# Create a local repo and push a commit
mkdir -p /tmp/test_local
cd /tmp/test_local
git init
git config user.email "test@example.com"
git config user.name "Test User"
git remote add origin /tmp/test_remote
echo "test" > file.txt
git add file.txt
git commit -m "initial"
git push origin master

# Test 1: fetch without explicit refspec
echo "===== Test 1: git fetch origin master --depth=1 ====="
mkdir -p /tmp/test_shallow
cd /tmp/test_shallow
git init
git config user.email "test@example.com"
git config user.name "Test User"
git remote add origin /tmp/test_remote

git fetch origin master --depth=1 2>&1
echo "Checking if origin/master was created:"
git rev-parse --verify origin/master 2>&1 && echo "✓ origin/master EXISTS" || echo "✗ origin/master NOT created"
echo ""

# Test 2: fetch with explicit refspec
echo "===== Test 2: git fetch origin master:refs/remotes/origin/master --depth=1 ====="
mkdir -p /tmp/test_shallow2
cd /tmp/test_shallow2
git init
git config user.email "test@example.com"
git config user.name "Test User"
git remote add origin /tmp/test_remote

git fetch origin "master:refs/remotes/origin/master" --depth=1 2>&1
echo "Checking if origin/master was created:"
git rev-parse --verify origin/master 2>&1 && echo "✓ origin/master EXISTS" || echo "✗ origin/master NOT created"

# Cleanup
rm -rf /tmp/test_remote /tmp/test_local /tmp/test_shallow /tmp/test_shallow2

Repository: kintsugi-tax/squawk-pre-commit

Length of output: 3078


🏁 Script executed:

#!/bin/bash
# Test with explicit verification of push and remote state

echo "Testing git fetch behavior..."

# Create a bare repo to act as remote
mkdir -p /tmp/test_remote
cd /tmp/test_remote
git init --bare

# Create a local repo and push a commit
mkdir -p /tmp/test_local
cd /tmp/test_local
git init
git config user.email "test@example.com"
git config user.name "Test User"
git remote add origin /tmp/test_remote
echo "test" > file.txt
git add file.txt
git commit -m "initial"
git push origin master
push_status=$?
echo "Push exit code: $push_status"

# Verify push created ref on remote
echo "Refs on remote after push:"
cd /tmp/test_remote
git show-ref

# Test 1: fetch without explicit refspec
echo ""
echo "===== Test 1: git fetch origin master --depth=1 ====="
mkdir -p /tmp/test_shallow
cd /tmp/test_shallow
git init
git config user.email "test@example.com"
git config user.name "Test User"
git remote add origin /tmp/test_remote

git fetch origin master --depth=1
fetch_exit=$?
echo "Fetch exit code: $fetch_exit"
echo "Checking if origin/master was created:"
if git rev-parse --verify origin/master 2>/dev/null; then
  echo "✓ origin/master EXISTS"
else
  echo "✗ origin/master NOT created"
fi
echo "Refs in repository:"
git show-ref

# Test 2: fetch with explicit refspec
echo ""
echo "===== Test 2: git fetch origin master:refs/remotes/origin/master --depth=1 ====="
mkdir -p /tmp/test_shallow2
cd /tmp/test_shallow2
git init
git config user.email "test@example.com"
git config user.name "Test User"
git remote add origin /tmp/test_remote

git fetch origin "master:refs/remotes/origin/master" --depth=1
fetch_exit=$?
echo "Fetch exit code: $fetch_exit"
echo "Checking if origin/master was created:"
if git rev-parse --verify origin/master 2>/dev/null; then
  echo "✓ origin/master EXISTS"
else
  echo "✗ origin/master NOT created"
fi
echo "Refs in repository:"
git show-ref

# Cleanup
rm -rf /tmp/test_remote /tmp/test_local /tmp/test_shallow /tmp/test_shallow2

Repository: kintsugi-tax/squawk-pre-commit

Length of output: 3117


🏁 Script executed:

#!/bin/bash
# Examine the actual code and understand the context
cd /repo
cat -n squawk_alembic/hook.py | sed -n '130,175p'

Repository: kintsugi-tax/squawk-pre-commit

Length of output: 1753


🌐 Web query:

git fetch shallow clone remote tracking branch refs/remotes behavior

💡 Result:

In a shallow clone (git clone --depth N ...), Git typically behaves differently for remote-tracking branches (refs/remotes/<remote>/...) because --depth implies --single-branch unless you add --no-single-branch. As a result, the clone is configured to fetch/update only one remote-tracking branch by default. [1]

What actually happens to refs/remotes/...

Normal (non–single-branch) clone

Your remote fetch refspec is usually:
+refs/heads/*:refs/remotes/origin/*
So git fetch origin updates all refs/remotes/origin/* remote-tracking branches. [2]

Shallow clone (default: single-branch)

Git writes a narrow refspec (only the cloned branch), e.g.:
+refs/heads/main:refs/remotes/origin/main
So git fetch origin will keep updating only refs/remotes/origin/main, and you may not even have other refs/remotes/origin/<branch> names locally to update. [1]

That’s why people often observe “git fetch doesn’t bring in other remotes/origin/* branches” in shallow clones: it’s mostly the configured refspec / single-branch mode, not a special rule about refs/remotes themselves. [1]

How to fetch other branches (while staying shallow)

Option A (best upfront): clone shallow but not single-branch

git clone --depth 1 --no-single-branch <url>

This keeps history shallow but allows remote-tracking branches for multiple branches to be created/updated. [1]

Option B: fetch a specific extra branch by explicit refspec

git fetch --depth 1 origin otherbranch:refs/remotes/origin/otherbranch

This directly tells Git which refs/remotes/... name to update. [3]

Option C: change what the remote tracks (persistent)

Adjust the remote’s branch list / fetch refspec so future git fetch updates additional refs/remotes/origin/*. (Common approaches: git remote set-branches ... or editing remote.origin.fetch.) [4]

Shallow boundary caveat (.git/shallow)

When fetching in/into shallow history, some updates may require changing the shallow boundary; Git can refuse such updates unless you allow it (e.g. git fetch --update-shallow ...). [2]

Sources: git-clone docs on --depth implying --single-branch and how that limits future fetch updates [1]; git-fetch docs on refspecs, remote-tracking branches, and --update-shallow [2]; common refspec workaround examples [3]; discussion of changing tracked branches / refspec in shallow clones [4].


Use explicit refspec to ensure remote-tracking branch is created in shallow clones.

In shallow clones (common in CI), git fetch origin <branch> without an explicit refspec won't create refs/remotes/origin/<branch> because the narrow refspec only tracks the single cloned branch. The function returns True if fetch succeeds, but the ref may not exist locally afterward.

Use an explicit refspec to ensure the remote-tracking branch is created:

🔧 Proposed fix with explicit refspec
     if branch.startswith("origin/"):
         remote_branch = branch.removeprefix("origin/")
         fetch = subprocess.run(
-            ["git", "fetch", "origin", remote_branch, "--depth=1"],
+            ["git", "fetch", "origin", f"{remote_branch}:refs/remotes/origin/{remote_branch}", "--depth=1"],
             capture_output=True,
         )
🧰 Tools
🪛 Ruff (0.15.2)

[error] 160-160: subprocess call: check for execution of untrusted input

(S603)


[error] 161-161: Starting a process with a partial executable path

(S607)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@squawk_alembic/hook.py` around lines 158 - 165, The fetch call using
subprocess.run(["git", "fetch", "origin", remote_branch, "--depth=1"]) can
succeed in CI shallow clones yet not create refs/remotes/origin/<branch>; update
the fetch invocation (the subprocess.run call where remote_branch is used in
hook.py) to use an explicit refspec such as
"refs/heads/{remote_branch}:refs/remotes/origin/{remote_branch}" (or prefixed
with + if forcing) as the fetch argument so the remote-tracking branch is
written locally while keeping --depth=1; keep the existing return logic but
ensure the subprocess.run args include that refspec string instead of just
remote_branch.


print(
f"squawk-alembic: branch '{branch}' not found in git",
file=sys.stderr,
)
return False


def file_exists_on_branch(filepath, branch):
Expand Down
102 changes: 101 additions & 1 deletion tests/test_main.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ def fake_subprocess(
squawk_result=None,
git_exists_on_branch=False,
git_branch_valid=True,
git_fetch_succeeds=False,
):
"""Return a side_effect function that dispatches based on the command."""
alembic_res = alembic_result or make_result(stdout="CREATE TABLE foo (id int);\n")
Expand All @@ -44,7 +45,11 @@ def side_effect(cmd, **kwargs):
if cmd[0] == "git":
if "rev-parse" in cmd:
return make_result(returncode=0 if git_branch_valid else 1)
return make_result(returncode=0 if git_exists_on_branch else 1)
if "fetch" in cmd:
return make_result(returncode=0 if git_fetch_succeeds else 1)
if "cat-file" in cmd:
return make_result(returncode=0 if git_exists_on_branch else 1)
return make_result(returncode=1)
if cmd[0] == "alembic":
return alembic_res
if cmd[0] == "squawk":
Expand Down Expand Up @@ -381,3 +386,98 @@ def upgrade():
assert main() == 0
# No git call, just alembic + squawk = 2 calls
assert mock_run.call_count == 2


def test_origin_branch_shallow_fetch_succeeds(repo):
"""In CI shallow clones, origin/main may not exist locally; the hook should fetch it."""
path = write_migration(
repo,
"016_shallow.py",
"""
revision = 'sha001'
down_revision = 'prev001'

from alembic import op

def upgrade():
op.execute("CREATE TABLE foo (id int)")
""",
)
with (
patch("sys.argv", ["squawk-alembic", "--diff-branch", "origin/main", path]),
patch(
"subprocess.run",
side_effect=fake_subprocess(
git_branch_valid=False,
git_fetch_succeeds=True,
git_exists_on_branch=False,
),
) as mock_run,
):
assert main() == 0
# git rev-parse (fail) + git fetch + git cat-file + alembic + squawk = 5 calls
assert mock_run.call_count == 5
assert mock_run.call_args_list[0][0][0][0] == "git"
assert "fetch" in mock_run.call_args_list[1][0][0]
assert "cat-file" in mock_run.call_args_list[2][0][0]


def test_origin_branch_shallow_fetch_fails(repo, capsys):
"""When both rev-parse and fetch fail, the hook should error."""
path = write_migration(
repo,
"017_fetch_fail.py",
"""
revision = 'ff001'
down_revision = 'prev001'

from alembic import op

def upgrade():
op.execute("CREATE TABLE foo (id int)")
""",
)
with (
patch("sys.argv", ["squawk-alembic", "--diff-branch", "origin/main", path]),
patch(
"subprocess.run",
side_effect=fake_subprocess(
git_branch_valid=False,
git_fetch_succeeds=False,
),
) as mock_run,
):
assert main() == 1
# git rev-parse (fail) + git fetch (fail) = 2 calls
assert mock_run.call_count == 2
captured = capsys.readouterr()
assert "not found in git" in captured.err


def test_non_origin_branch_no_fetch_attempted(repo, capsys):
"""Non-origin branches should not trigger a fetch attempt."""
path = write_migration(
repo,
"018_no_fetch.py",
"""
revision = 'nf001'
down_revision = 'prev001'

from alembic import op

def upgrade():
op.execute("CREATE TABLE foo (id int)")
""",
)
with (
patch("sys.argv", ["squawk-alembic", "--diff-branch", "main", path]),
patch(
"subprocess.run",
side_effect=fake_subprocess(git_branch_valid=False),
) as mock_run,
):
assert main() == 1
# Only git rev-parse (fail), no fetch attempted
assert mock_run.call_count == 1
captured = capsys.readouterr()
assert "not found in git" in captured.err