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
26 changes: 26 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: test

on:
pull_request:
push:
branches:
- main

jobs:
git-branch-linearity:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v6
- name: Run git-branch-linearity tests
run: ./tests/test_git_branch_linearity.sh

# Runs the hook on itself in a real PR context (actions/checkout default
# shallow clone + GITHUB_HEAD_REF). This is the exact setup that broke in
# v0.57.0.
git-branch-linearity-self:
runs-on: ubuntu-22.04
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v6
- name: Run hook against PR branch
run: ./git-branch-linearity.sh ${{ github.base_ref }}
23 changes: 16 additions & 7 deletions git-branch-linearity.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,30 @@
TARGET_BRANCH="${1:-main}"

echo "Target branch: $TARGET_BRANCH"
git fetch --no-tags --depth=1 origin $TARGET_BRANCH 2> /dev/null
target_sha=$(git rev-parse origin/${TARGET_BRANCH})
git fetch --no-tags --depth=1 origin "$TARGET_BRANCH"
target_sha=$(git rev-parse FETCH_HEAD)

# If in a github PR, base from tip of branch, not the merge commit
# If in a github PR, base from tip of branch, not the merge commit.
if [ -n "$GITHUB_HEAD_REF" ]; then
git fetch --no-tags --shallow-exclude="$target_sha" origin "$GITHUB_HEAD_REF" 2> /dev/null
tip=$(git rev-parse origin/$GITHUB_HEAD_REF)
# --shallow-exclude requires a ref name (branch/tag), not a SHA: the value
# is forwarded to the server as `deepen-not <ref>` in protocol v2, which
# rejects commit ids.
if ! git fetch --no-tags --shallow-exclude="$TARGET_BRANCH" origin "$GITHUB_HEAD_REF"; then
# Fallback for remotes that do not honor shallow-exclude.
git fetch --no-tags origin "$GITHUB_HEAD_REF"
fi
# Read from FETCH_HEAD rather than origin/$GITHUB_HEAD_REF: the origin
# remote in a CI clone (actions/checkout, clone --depth=1) has a narrow
# refspec that does not map arbitrary branches into refs/remotes/origin/*.
tip=$(git rev-parse FETCH_HEAD)
else
tip="HEAD"
fi

out=$(git log ${target_sha}..${tip} --merges --oneline)
out=$(git log "${target_sha}..${tip}" --merges --oneline)
exit_status=$?

if [ -n "$out" ]
if [ -n "$out" ]
then
echo "Please rebase your branch" >&2
echo "If your branch or its base branch is a release branch then ignore this error" >&2
Expand Down
157 changes: 157 additions & 0 deletions tests/test_git_branch_linearity.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/usr/bin/env bash
# End-to-end tests for git-branch-linearity.sh.
#
# These tests exercise the hook in a realistic GitHub Actions PR environment:
# a shallow clone (depth=1) with GITHUB_HEAD_REF set. This reproduces the
# failure mode seen in practice when --shallow-exclude was given a SHA
# instead of a ref name.

set -eu -o pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
HOOK="$SCRIPT_DIR/../git-branch-linearity.sh"

# --------------------------------------------------------------------------
# Helpers
# --------------------------------------------------------------------------

pass=0
fail=0

on_exit() {
rc=$?
if [ -n "${WORKDIR:-}" ] && [ -d "${WORKDIR:-}" ]; then
rm -rf "$WORKDIR"
fi
if [ "$fail" -gt 0 ]; then
echo ""
echo "FAIL: $fail test(s) failed, $pass passed"
exit 1
fi
if [ "$rc" -ne 0 ]; then
exit "$rc"
fi
echo ""
echo "OK: $pass test(s) passed"
}
trap on_exit EXIT

setup_origin_with_linear_branch() {
# Build an "origin" bare repo with:
# - main at commit M1
# - feature branched from M1, with one additional linear commit F1
WORKDIR=$(mktemp -d)
ORIGIN="$WORKDIR/origin.git"
UPSTREAM="$WORKDIR/upstream"

git init --bare -q -b main "$ORIGIN"
git init -q -b main "$UPSTREAM"
(
cd "$UPSTREAM"
git config user.email test@example.com
git config user.name test
git remote add origin "$ORIGIN"

echo initial > file.txt
git add file.txt
git -c commit.gpgsign=false commit -q -m "M1 initial"
git push -q origin main

git checkout -q -b feature/linear
echo change >> file.txt
git -c commit.gpgsign=false commit -qam "F1 feature change"
git push -q origin feature/linear
)
}

add_merge_commit_on_feature() {
# Add a merge commit onto the feature branch to trigger the linearity failure.
(
cd "$UPSTREAM"
git checkout -q -b other main
echo other > other.txt
git add other.txt
git -c commit.gpgsign=false commit -q -m "other branch commit"

git checkout -q feature/linear
git -c commit.gpgsign=false merge -q --no-ff other -m "Merge other into feature"
git push -q origin feature/linear
)
}

shallow_clone_like_actions_checkout() {
# Reproduce actions/checkout@v6 default: a real shallow clone (depth=1)
# with a narrow refspec pointing at the PR branch. file:// is required —
# local path clones silently ignore --depth.
CI_REPO="$WORKDIR/ci"
git clone -q --depth=1 --branch feature/linear "file://$ORIGIN" "$CI_REPO"
}

expect_exit() {
want=$1; shift
name=$1; shift
set +e
output=$("$@" 2>&1)
got=$?
set -e
if [ "$got" -eq "$want" ]; then
pass=$((pass + 1))
echo "PASS $name"
else
fail=$((fail + 1))
echo "FAIL $name: want exit $want, got $got"
echo "----- output -----"
echo "$output"
echo "------------------"
fi
}

# --------------------------------------------------------------------------
# Test 1: linear branch in a shallow CI clone with GITHUB_HEAD_REF should pass
# --------------------------------------------------------------------------
setup_origin_with_linear_branch
shallow_clone_like_actions_checkout

expect_exit 0 "shallow+GITHUB_HEAD_REF, linear branch" \
env -C "$CI_REPO" GITHUB_HEAD_REF=feature/linear "$HOOK" main

rm -rf "$WORKDIR"

# --------------------------------------------------------------------------
# Test 2: branch with a merge commit in shallow CI clone should fail
# --------------------------------------------------------------------------
setup_origin_with_linear_branch
add_merge_commit_on_feature
shallow_clone_like_actions_checkout

expect_exit 1 "shallow+GITHUB_HEAD_REF, branch has merge commit" \
env -C "$CI_REPO" GITHUB_HEAD_REF=feature/linear "$HOOK" main

rm -rf "$WORKDIR"

# --------------------------------------------------------------------------
# Test 3: no GITHUB_HEAD_REF (local run), linear branch, should pass
# --------------------------------------------------------------------------
setup_origin_with_linear_branch
LOCAL_REPO="$WORKDIR/local"
git clone -q --branch feature/linear "$ORIGIN" "$LOCAL_REPO"

expect_exit 0 "local (no GITHUB_HEAD_REF), linear branch" \
env -C "$LOCAL_REPO" -u GITHUB_HEAD_REF "$HOOK" main

rm -rf "$WORKDIR"

# --------------------------------------------------------------------------
# Test 4: no GITHUB_HEAD_REF (local run), branch with merge commit, should fail
# --------------------------------------------------------------------------
setup_origin_with_linear_branch
add_merge_commit_on_feature
LOCAL_REPO="$WORKDIR/local"
git clone -q --branch feature/linear "$ORIGIN" "$LOCAL_REPO"

expect_exit 1 "local (no GITHUB_HEAD_REF), branch has merge commit" \
env -C "$LOCAL_REPO" -u GITHUB_HEAD_REF "$HOOK" main

rm -rf "$WORKDIR"

unset WORKDIR