From d46c1f848b26c87c33f6139af0cf516ec04ff963 Mon Sep 17 00:00:00 2001 From: Tamika Nomara Date: Tue, 28 Jul 2026 04:21:06 +0400 Subject: [PATCH 1/4] feat(code-review, test-planning): detect the diff base across multiple remotes and git-flow The code-review and test-planning git-context detectors chose the branch to diff changes against from origin/HEAD alone. On a fork whose origin default is stale, on a git-flow branch cut from an integration branch, or on a branch that merged another branch in, that base was wrong, so the review or test plan was scoped to an inflated, empty, or mis-attributed diff. Port review-skill-or-agent's base selection to both detectors: the base is now the candidate whose fork point is nearest the current commit, measured along the branch's own first-parent line, chosen across a multi-remote candidate pool. - Candidate pool: every remote's declared default branch plus well-known trunk/integration names (main, master, trunk, mainline, next, develop, devel, development, default, dev), looked up as local and per-remote branches; nearest own-line coincidence wins, declared defaults break ties. - A candidate the branch merely merged in cannot win on its absorbed commits. - Output contract shape unchanged; fail-open to `default-branch: none` preserved. - Adds test/detect-review-context.bats and test/detect-test-context.bats (16 tests each) under test/, which these detectors previously lacked. Completes the OI-1 follow-up flagged in review-skill-or-agent (PR #119), which intentionally ran ahead of these copies. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NfTexc8iv7XAqt3ksH9zNP --- .../scripts/detect-test-context.sh | 58 +++- .../scripts/detect-review-context.sh | 58 +++- test/detect-review-context.bats | 285 ++++++++++++++++++ test/detect-test-context.bats | 285 ++++++++++++++++++ 4 files changed, 676 insertions(+), 10 deletions(-) create mode 100755 test/detect-review-context.bats create mode 100755 test/detect-test-context.bats diff --git a/han-coding/skills/automated-test-planning/scripts/detect-test-context.sh b/han-coding/skills/automated-test-planning/scripts/detect-test-context.sh index 97d47d38..2da4c410 100755 --- a/han-coding/skills/automated-test-planning/scripts/detect-test-context.sh +++ b/han-coding/skills/automated-test-planning/scripts/detect-test-context.sh @@ -24,11 +24,60 @@ echo "git-available: true" BRANCH=$(git branch --show-current) echo "branch: ${BRANCH:-none}" -# Check for remote and default branch -if git symbolic-ref --short refs/remotes/origin/HEAD &>/dev/null; then - DEFAULT=$(git symbolic-ref --short refs/remotes/origin/HEAD) - echo "default-branch: $DEFAULT" +# Select the base branch to diff against: the candidate whose fork point is +# nearest the current commit, measured along the branch's own line of work so a +# candidate the branch merely merged in cannot win on absorbed commits. +# The branch's own line of work (first-parent history), newest first: a candidate +# reachable only through a merge's second parent is not on it. +OWNLINE=$(git rev-list --first-parent HEAD 2>/dev/null) + +DEFAULT=none +best_distance="" +seen=" " + +# Fold one candidate ref into the running nearest-wins selection. Its distance is +# the number of own-line commits above the newest own-line commit the candidate +# contains; a ref already seen, or one sharing no history with the own line, is +# skipped. +consider() { # candidate-ref + case "$seen" in *" $1 "*) return ;; esac + seen="$seen$1 " + local commit count=0 distance="" + while IFS= read -r commit; do + if git merge-base --is-ancestor "$commit" "$1" 2>/dev/null; then + distance=$count + break + fi + count=$((count + 1)) + done <<<"$OWNLINE" + [ -n "$distance" ] || return + if [ -z "$best_distance" ] || [ "$distance" -lt "$best_distance" ]; then + best_distance=$distance + DEFAULT=$1 + fi +} + +# Candidate pool, enumerated declared-defaults-first so that under the +# nearest-wins rule a declared default beats a name-guessed candidate at an +# equal-distance tie. First, every remote's declared default branch (HEAD). +while IFS= read -r headref; do + ref=$(git symbolic-ref --short "$headref" 2>/dev/null) || continue + consider "$ref" +done < <(git for-each-ref --sort=refname --format='%(refname)' refs/remotes/ 2>/dev/null | grep -E '/HEAD$') + +# Then well-known trunk/integration names as local branches, then as each +# remote's tracking branch. +for n in main master trunk mainline next develop devel development default dev; do + git show-ref --verify -q "refs/heads/$n" && consider "$n" + while IFS= read -r ref; do + [ -n "$ref" ] && consider "$ref" + done < <(git for-each-ref --sort=refname --format='%(refname:short)' "refs/remotes/*/$n" 2>/dev/null) +done + +echo "default-branch: $DEFAULT" + +if [ "$DEFAULT" != none ]; then CHANGED=$(git diff --name-only "$DEFAULT...HEAD" 2>/dev/null) if [ -n "$CHANGED" ]; then echo "changed-files-start" @@ -38,6 +87,5 @@ if git symbolic-ref --short refs/remotes/origin/HEAD &>/dev/null; then echo "changed-files: none" fi else - echo "default-branch: none" echo "changed-files: none" fi diff --git a/han-coding/skills/code-review/scripts/detect-review-context.sh b/han-coding/skills/code-review/scripts/detect-review-context.sh index e9bd7086..d89e3fff 100755 --- a/han-coding/skills/code-review/scripts/detect-review-context.sh +++ b/han-coding/skills/code-review/scripts/detect-review-context.sh @@ -24,11 +24,60 @@ echo "git-available: true" BRANCH=$(git branch --show-current) echo "branch: ${BRANCH:-none}" -# Check for remote and default branch -if git symbolic-ref --short refs/remotes/origin/HEAD &>/dev/null; then - DEFAULT=$(git symbolic-ref --short refs/remotes/origin/HEAD) - echo "default-branch: $DEFAULT" +# Select the base branch to diff against: the candidate whose fork point is +# nearest the current commit, measured along the branch's own line of work so a +# candidate the branch merely merged in cannot win on absorbed commits. +# The branch's own line of work (first-parent history), newest first: a candidate +# reachable only through a merge's second parent is not on it. +OWNLINE=$(git rev-list --first-parent HEAD 2>/dev/null) + +DEFAULT=none +best_distance="" +seen=" " + +# Fold one candidate ref into the running nearest-wins selection. Its distance is +# the number of own-line commits above the newest own-line commit the candidate +# contains; a ref already seen, or one sharing no history with the own line, is +# skipped. +consider() { # candidate-ref + case "$seen" in *" $1 "*) return ;; esac + seen="$seen$1 " + local commit count=0 distance="" + while IFS= read -r commit; do + if git merge-base --is-ancestor "$commit" "$1" 2>/dev/null; then + distance=$count + break + fi + count=$((count + 1)) + done <<<"$OWNLINE" + [ -n "$distance" ] || return + if [ -z "$best_distance" ] || [ "$distance" -lt "$best_distance" ]; then + best_distance=$distance + DEFAULT=$1 + fi +} + +# Candidate pool, enumerated declared-defaults-first so that under the +# nearest-wins rule a declared default beats a name-guessed candidate at an +# equal-distance tie. First, every remote's declared default branch (HEAD). +while IFS= read -r headref; do + ref=$(git symbolic-ref --short "$headref" 2>/dev/null) || continue + consider "$ref" +done < <(git for-each-ref --sort=refname --format='%(refname)' refs/remotes/ 2>/dev/null | grep -E '/HEAD$') + +# Then well-known trunk/integration names as local branches, then as each +# remote's tracking branch. +for n in main master trunk mainline next develop devel development default dev; do + git show-ref --verify -q "refs/heads/$n" && consider "$n" + while IFS= read -r ref; do + [ -n "$ref" ] && consider "$ref" + done < <(git for-each-ref --sort=refname --format='%(refname:short)' "refs/remotes/*/$n" 2>/dev/null) +done + +echo "default-branch: $DEFAULT" + +if [ "$DEFAULT" != none ]; then CHANGED=$(git diff --name-only "$DEFAULT...HEAD" 2>/dev/null) if [ -n "$CHANGED" ]; then echo "changed-files-start" @@ -38,6 +87,5 @@ if git symbolic-ref --short refs/remotes/origin/HEAD &>/dev/null; then echo "changed-files: none" fi else - echo "default-branch: none" echo "changed-files: none" fi diff --git a/test/detect-review-context.bats b/test/detect-review-context.bats new file mode 100755 index 00000000..9ac5a795 --- /dev/null +++ b/test/detect-review-context.bats @@ -0,0 +1,285 @@ +#!/usr/bin/env bats +# +# Tests for code-review's detect-review-context.sh: the git-context contract +# (git availability, current branch, base-branch selection across remotes and +# well-known trunk/integration names by the branch's own line of work, and the +# committed-changes-vs-base listing or its `changed-files: none` fallback). Kept +# in sync with the test-planning and review-skill-or-agent copies of this +# detector's tests. + +setup() { + SRC="$BATS_TEST_DIRNAME/../han-coding/skills/code-review/scripts/detect-review-context.sh" + TMP="$(mktemp -d)" +} + +teardown() { + rm -rf "$TMP" +} + +# Extract the value of the first `key: value` line matching $2 from output $1. +get() { + printf '%s\n' "$1" | awk -F': ' -v k="$2" '$1==k{print $2; exit}' +} + +# Initialize a git repo at $1 with a deterministic identity and one base commit. +git_init() { + git -C "$1" init -q + git -C "$1" config user.email a@b.c + git -C "$1" config user.name tester + git -C "$1" commit -q --allow-empty -m base +} + +# Point a fake origin/HEAD at a ref recording the current commit, so the script's +# `git symbolic-ref refs/remotes/origin/HEAD` resolves without a real remote. +set_origin_default() { + git -C "$1" update-ref "refs/remotes/origin/$2" "$(git -C "$1" rev-parse HEAD)" + git -C "$1" symbolic-ref refs/remotes/origin/HEAD "refs/remotes/origin/$2" +} + +# Point / at and make it that remote's declared default HEAD. +set_remote_head() { # repo remote branch sha + git -C "$1" update-ref "refs/remotes/$2/$3" "$4" + git -C "$1" symbolic-ref "refs/remotes/$2/HEAD" "refs/remotes/$2/$3" +} + +# Point / at as a plain remote-tracking branch (no HEAD). +set_remote_tracking() { # repo remote branch sha + git -C "$1" update-ref "refs/remotes/$2/$3" "$4" +} + +@test "lists committed changes against the default branch when the branch is ahead" { + git_init "$TMP" + set_origin_default "$TMP" main + git -C "$TMP" checkout -q -b feature + echo x >"$TMP/newfile.txt" + git -C "$TMP" add newfile.txt + git -C "$TMP" commit -q -m add + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + [ "$(get "$output" git-available)" = true ] + [ "$(get "$output" default-branch)" = origin/main ] + [[ "$output" == *"changed-files-start"* ]] + [[ "$output" == *"newfile.txt"* ]] + [[ "$output" == *"changed-files-end"* ]] +} + +@test "reports git unavailable when git is not on PATH" { + bash_bin="$(command -v bash)" + cd "$TMP" + run env PATH= "$bash_bin" "$SRC" + [ "$(get "$output" git-available)" = false ] + [ "$(get "$output" branch)" = none ] + [ "$(get "$output" default-branch)" = none ] + [ "$(get "$output" changed-files)" = none ] +} + +@test "reports git unavailable when run outside a work tree" { + cd "$TMP" + run "$SRC" + [ "$(get "$output" git-available)" = false ] +} + +@test "reports no default branch and no changed files when no candidate branch exists" { + git_init "$TMP" + git -C "$TMP" branch -m scratch # no remote, and no well-known-named branch + cd "$TMP" + run "$SRC" + [ "$(get "$output" git-available)" = true ] + [ "$(get "$output" default-branch)" = none ] + [ "$(get "$output" changed-files)" = none ] +} + +@test "reports no changed files when the branch has no committed diff against the default" { + git_init "$TMP" + set_origin_default "$TMP" main + cd "$TMP" + run "$SRC" + [ "$(get "$output" git-available)" = true ] + [ "$(get "$output" default-branch)" = origin/main ] + [ "$(get "$output" changed-files)" = none ] +} + +@test "reports no current branch name in detached HEAD" { + git_init "$TMP" + git -C "$TMP" commit -q --allow-empty -m second + git -C "$TMP" checkout -q --detach HEAD + cd "$TMP" + run "$SRC" + [ "$(get "$output" git-available)" = true ] + [ "$(get "$output" branch)" = none ] +} + +@test "reports a fresher other-remote default over a stale origin default" { + git_init "$TMP" # C0 on the init default branch (main) + set_origin_default "$TMP" main # origin/main is stale, pinned at C0 + echo up >"$TMP/upstream-only.txt" # upstream advances past C0 + git -C "$TMP" add -A + git -C "$TMP" commit -q -m U1 + set_remote_head "$TMP" upstream main "$(git -C "$TMP" rev-parse HEAD)" + git -C "$TMP" checkout -q -b feature # cut from upstream/main's tip + echo f >"$TMP/feature.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m F1 + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + [ "$(get "$output" default-branch)" = upstream/main ] + [[ "$output" == *"feature.txt"* ]] + [[ "$output" != *"upstream-only.txt"* ]] +} + +@test "reports a remote integration branch the current branch was cut from" { + git_init "$TMP" # C0 on main + set_origin_default "$TMP" main # origin/main is the declared default, at C0 + git -C "$TMP" checkout -q -b devwork + echo d >"$TMP/dev.txt" # develop advances past C0 + git -C "$TMP" add -A + git -C "$TMP" commit -q -m D1 + set_remote_tracking "$TMP" origin develop "$(git -C "$TMP" rev-parse HEAD)" + git -C "$TMP" checkout -q -b feature # cut from develop's tip + echo f >"$TMP/feature.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m F1 + git -C "$TMP" branch -D devwork # leave only origin/develop + origin/main + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + [ "$(get "$output" default-branch)" = origin/develop ] + [[ "$output" == *"feature.txt"* ]] +} + +@test "reports a local trunk the current branch was cut from" { + git_init "$TMP" # C0 on main + set_origin_default "$TMP" main # a remote default that must lose to the nearer local trunk + git -C "$TMP" checkout -q -b develop + echo d >"$TMP/dev.txt" # local develop advances past C0 + git -C "$TMP" add -A + git -C "$TMP" commit -q -m D1 + git -C "$TMP" checkout -q -b feature # cut from local develop + echo f >"$TMP/feature.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m F1 + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + [ "$(get "$output" default-branch)" = develop ] + [[ "$output" == *"feature.txt"* ]] +} + +@test "does not let a merged-in candidate win over the branch's own base" { + git_init "$TMP" # C0 on the initial branch + base=$(git -C "$TMP" rev-parse HEAD) + set_origin_default "$TMP" main # origin/main declared default at C0 + git -C "$TMP" checkout -q -b develop + echo helper >"$TMP/helper.txt" # develop advances past C0 + git -C "$TMP" add -A + git -C "$TMP" commit -q -m D1 + git -C "$TMP" checkout -q -b feature "$base" # cut from C0, not develop + echo f >"$TMP/feature.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m F1 + git -C "$TMP" merge -q --no-ff develop -m "merge develop" # absorb develop + echo more >>"$TMP/feature.txt" + git -C "$TMP" commit -qam F2 + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + # The branch came from main; develop was only merged in. Plain merge-base + # would wrongly pick develop (its tip is a nearer ancestor). + [ "$(get "$output" default-branch)" = origin/main ] + # The delta against main still includes develop's absorbed changes. + [[ "$output" == *"feature.txt"* ]] + [[ "$output" == *"helper.txt"* ]] +} + +@test "excludes an unrelated candidate without aborting and still resolves a valid base" { + git_init "$TMP" # C0 on main + set_origin_default "$TMP" main + git -C "$TMP" checkout -q -b feature + echo f >"$TMP/feature.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m F1 + # A declared-default candidate on an orphan history that shares nothing with HEAD: + git -C "$TMP" checkout -q --orphan alien + git -C "$TMP" commit -q --allow-empty -m orphan + set_remote_head "$TMP" other main "$(git -C "$TMP" rev-parse HEAD)" + git -C "$TMP" checkout -q feature + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + [ "$(get "$output" default-branch)" = origin/main ] + [[ "$output" == *"feature.txt"* ]] +} + +@test "reports none when the only candidate shares no history with HEAD" { + git_init "$TMP" # C0 on main + set_origin_default "$TMP" main + git -C "$TMP" checkout -q --orphan feature # feature shares no history with main + git -C "$TMP" commit -q --allow-empty -m orphan + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + [ "$(get "$output" default-branch)" = none ] + [ "$(get "$output" changed-files)" = none ] +} + +@test "reports none on an unborn HEAD without failing" { + git -C "$TMP" init -q + git -C "$TMP" config user.email a@b.c + git -C "$TMP" config user.name tester + cd "$TMP" # a fresh repo with no commit yet + run "$SRC" + [ "$status" -eq 0 ] + [ "$(get "$output" git-available)" = true ] + [ "$(get "$output" default-branch)" = none ] + [ "$(get "$output" changed-files)" = none ] +} + +@test "resolves an equal-distance tie to the same base across runs" { + git_init "$TMP" # C0 + set_remote_head "$TMP" origin main "$(git -C "$TMP" rev-parse HEAD)" + set_remote_head "$TMP" upstream main "$(git -C "$TMP" rev-parse HEAD)" # tied fork commit + git -C "$TMP" checkout -q -b feature + echo f >"$TMP/feature.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m F1 + cd "$TMP" + run "$SRC" + first=$(get "$output" default-branch) + run "$SRC" + second=$(get "$output" default-branch) + [ "$status" -eq 0 ] + [ -n "$first" ] && [ "$first" = "$second" ] # stable across runs + [ "$first" = origin/main ] || [ "$first" = upstream/main ] # one of the tied candidates +} + +@test "resolves a base from HEAD in a detached checkout" { + git_init "$TMP" # C0 + set_origin_default "$TMP" main + echo x >"$TMP/extra.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m C1 + git -C "$TMP" checkout -q --detach HEAD + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + [ "$(get "$output" branch)" = none ] + [ "$(get "$output" default-branch)" != none ] +} + +@test "prefers a declared default over a well-known-name candidate at an equal-distance tie" { + git_init "$TMP" # C0 on main + set_origin_default "$TMP" main # origin/main (declared default) at C0 + git -C "$TMP" branch develop # local develop at C0: a name-guessed candidate at the same distance + git -C "$TMP" checkout -q -b feature + echo f >"$TMP/feature.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m F1 + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + # origin/main and local develop sit at the same own-line distance; the declared + # default is enumerated first and must win the tie over the name-guessed branch. + [ "$(get "$output" default-branch)" = origin/main ] +} diff --git a/test/detect-test-context.bats b/test/detect-test-context.bats new file mode 100755 index 00000000..3669cb97 --- /dev/null +++ b/test/detect-test-context.bats @@ -0,0 +1,285 @@ +#!/usr/bin/env bats +# +# Tests for test-planning's detect-test-context.sh: the git-context contract +# (git availability, current branch, base-branch selection across remotes and +# well-known trunk/integration names by the branch's own line of work, and the +# committed-changes-vs-base listing or its `changed-files: none` fallback). Kept +# in sync with the code-review and review-skill-or-agent copies of this +# detector's tests. + +setup() { + SRC="$BATS_TEST_DIRNAME/../han-coding/skills/test-planning/scripts/detect-test-context.sh" + TMP="$(mktemp -d)" +} + +teardown() { + rm -rf "$TMP" +} + +# Extract the value of the first `key: value` line matching $2 from output $1. +get() { + printf '%s\n' "$1" | awk -F': ' -v k="$2" '$1==k{print $2; exit}' +} + +# Initialize a git repo at $1 with a deterministic identity and one base commit. +git_init() { + git -C "$1" init -q + git -C "$1" config user.email a@b.c + git -C "$1" config user.name tester + git -C "$1" commit -q --allow-empty -m base +} + +# Point a fake origin/HEAD at a ref recording the current commit, so the script's +# `git symbolic-ref refs/remotes/origin/HEAD` resolves without a real remote. +set_origin_default() { + git -C "$1" update-ref "refs/remotes/origin/$2" "$(git -C "$1" rev-parse HEAD)" + git -C "$1" symbolic-ref refs/remotes/origin/HEAD "refs/remotes/origin/$2" +} + +# Point / at and make it that remote's declared default HEAD. +set_remote_head() { # repo remote branch sha + git -C "$1" update-ref "refs/remotes/$2/$3" "$4" + git -C "$1" symbolic-ref "refs/remotes/$2/HEAD" "refs/remotes/$2/$3" +} + +# Point / at as a plain remote-tracking branch (no HEAD). +set_remote_tracking() { # repo remote branch sha + git -C "$1" update-ref "refs/remotes/$2/$3" "$4" +} + +@test "lists committed changes against the default branch when the branch is ahead" { + git_init "$TMP" + set_origin_default "$TMP" main + git -C "$TMP" checkout -q -b feature + echo x >"$TMP/newfile.txt" + git -C "$TMP" add newfile.txt + git -C "$TMP" commit -q -m add + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + [ "$(get "$output" git-available)" = true ] + [ "$(get "$output" default-branch)" = origin/main ] + [[ "$output" == *"changed-files-start"* ]] + [[ "$output" == *"newfile.txt"* ]] + [[ "$output" == *"changed-files-end"* ]] +} + +@test "reports git unavailable when git is not on PATH" { + bash_bin="$(command -v bash)" + cd "$TMP" + run env PATH= "$bash_bin" "$SRC" + [ "$(get "$output" git-available)" = false ] + [ "$(get "$output" branch)" = none ] + [ "$(get "$output" default-branch)" = none ] + [ "$(get "$output" changed-files)" = none ] +} + +@test "reports git unavailable when run outside a work tree" { + cd "$TMP" + run "$SRC" + [ "$(get "$output" git-available)" = false ] +} + +@test "reports no default branch and no changed files when no candidate branch exists" { + git_init "$TMP" + git -C "$TMP" branch -m scratch # no remote, and no well-known-named branch + cd "$TMP" + run "$SRC" + [ "$(get "$output" git-available)" = true ] + [ "$(get "$output" default-branch)" = none ] + [ "$(get "$output" changed-files)" = none ] +} + +@test "reports no changed files when the branch has no committed diff against the default" { + git_init "$TMP" + set_origin_default "$TMP" main + cd "$TMP" + run "$SRC" + [ "$(get "$output" git-available)" = true ] + [ "$(get "$output" default-branch)" = origin/main ] + [ "$(get "$output" changed-files)" = none ] +} + +@test "reports no current branch name in detached HEAD" { + git_init "$TMP" + git -C "$TMP" commit -q --allow-empty -m second + git -C "$TMP" checkout -q --detach HEAD + cd "$TMP" + run "$SRC" + [ "$(get "$output" git-available)" = true ] + [ "$(get "$output" branch)" = none ] +} + +@test "reports a fresher other-remote default over a stale origin default" { + git_init "$TMP" # C0 on the init default branch (main) + set_origin_default "$TMP" main # origin/main is stale, pinned at C0 + echo up >"$TMP/upstream-only.txt" # upstream advances past C0 + git -C "$TMP" add -A + git -C "$TMP" commit -q -m U1 + set_remote_head "$TMP" upstream main "$(git -C "$TMP" rev-parse HEAD)" + git -C "$TMP" checkout -q -b feature # cut from upstream/main's tip + echo f >"$TMP/feature.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m F1 + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + [ "$(get "$output" default-branch)" = upstream/main ] + [[ "$output" == *"feature.txt"* ]] + [[ "$output" != *"upstream-only.txt"* ]] +} + +@test "reports a remote integration branch the current branch was cut from" { + git_init "$TMP" # C0 on main + set_origin_default "$TMP" main # origin/main is the declared default, at C0 + git -C "$TMP" checkout -q -b devwork + echo d >"$TMP/dev.txt" # develop advances past C0 + git -C "$TMP" add -A + git -C "$TMP" commit -q -m D1 + set_remote_tracking "$TMP" origin develop "$(git -C "$TMP" rev-parse HEAD)" + git -C "$TMP" checkout -q -b feature # cut from develop's tip + echo f >"$TMP/feature.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m F1 + git -C "$TMP" branch -D devwork # leave only origin/develop + origin/main + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + [ "$(get "$output" default-branch)" = origin/develop ] + [[ "$output" == *"feature.txt"* ]] +} + +@test "reports a local trunk the current branch was cut from" { + git_init "$TMP" # C0 on main + set_origin_default "$TMP" main # a remote default that must lose to the nearer local trunk + git -C "$TMP" checkout -q -b develop + echo d >"$TMP/dev.txt" # local develop advances past C0 + git -C "$TMP" add -A + git -C "$TMP" commit -q -m D1 + git -C "$TMP" checkout -q -b feature # cut from local develop + echo f >"$TMP/feature.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m F1 + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + [ "$(get "$output" default-branch)" = develop ] + [[ "$output" == *"feature.txt"* ]] +} + +@test "does not let a merged-in candidate win over the branch's own base" { + git_init "$TMP" # C0 on the initial branch + base=$(git -C "$TMP" rev-parse HEAD) + set_origin_default "$TMP" main # origin/main declared default at C0 + git -C "$TMP" checkout -q -b develop + echo helper >"$TMP/helper.txt" # develop advances past C0 + git -C "$TMP" add -A + git -C "$TMP" commit -q -m D1 + git -C "$TMP" checkout -q -b feature "$base" # cut from C0, not develop + echo f >"$TMP/feature.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m F1 + git -C "$TMP" merge -q --no-ff develop -m "merge develop" # absorb develop + echo more >>"$TMP/feature.txt" + git -C "$TMP" commit -qam F2 + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + # The branch came from main; develop was only merged in. Plain merge-base + # would wrongly pick develop (its tip is a nearer ancestor). + [ "$(get "$output" default-branch)" = origin/main ] + # The delta against main still includes develop's absorbed changes. + [[ "$output" == *"feature.txt"* ]] + [[ "$output" == *"helper.txt"* ]] +} + +@test "excludes an unrelated candidate without aborting and still resolves a valid base" { + git_init "$TMP" # C0 on main + set_origin_default "$TMP" main + git -C "$TMP" checkout -q -b feature + echo f >"$TMP/feature.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m F1 + # A declared-default candidate on an orphan history that shares nothing with HEAD: + git -C "$TMP" checkout -q --orphan alien + git -C "$TMP" commit -q --allow-empty -m orphan + set_remote_head "$TMP" other main "$(git -C "$TMP" rev-parse HEAD)" + git -C "$TMP" checkout -q feature + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + [ "$(get "$output" default-branch)" = origin/main ] + [[ "$output" == *"feature.txt"* ]] +} + +@test "reports none when the only candidate shares no history with HEAD" { + git_init "$TMP" # C0 on main + set_origin_default "$TMP" main + git -C "$TMP" checkout -q --orphan feature # feature shares no history with main + git -C "$TMP" commit -q --allow-empty -m orphan + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + [ "$(get "$output" default-branch)" = none ] + [ "$(get "$output" changed-files)" = none ] +} + +@test "reports none on an unborn HEAD without failing" { + git -C "$TMP" init -q + git -C "$TMP" config user.email a@b.c + git -C "$TMP" config user.name tester + cd "$TMP" # a fresh repo with no commit yet + run "$SRC" + [ "$status" -eq 0 ] + [ "$(get "$output" git-available)" = true ] + [ "$(get "$output" default-branch)" = none ] + [ "$(get "$output" changed-files)" = none ] +} + +@test "resolves an equal-distance tie to the same base across runs" { + git_init "$TMP" # C0 + set_remote_head "$TMP" origin main "$(git -C "$TMP" rev-parse HEAD)" + set_remote_head "$TMP" upstream main "$(git -C "$TMP" rev-parse HEAD)" # tied fork commit + git -C "$TMP" checkout -q -b feature + echo f >"$TMP/feature.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m F1 + cd "$TMP" + run "$SRC" + first=$(get "$output" default-branch) + run "$SRC" + second=$(get "$output" default-branch) + [ "$status" -eq 0 ] + [ -n "$first" ] && [ "$first" = "$second" ] # stable across runs + [ "$first" = origin/main ] || [ "$first" = upstream/main ] # one of the tied candidates +} + +@test "resolves a base from HEAD in a detached checkout" { + git_init "$TMP" # C0 + set_origin_default "$TMP" main + echo x >"$TMP/extra.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m C1 + git -C "$TMP" checkout -q --detach HEAD + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + [ "$(get "$output" branch)" = none ] + [ "$(get "$output" default-branch)" != none ] +} + +@test "prefers a declared default over a well-known-name candidate at an equal-distance tie" { + git_init "$TMP" # C0 on main + set_origin_default "$TMP" main # origin/main (declared default) at C0 + git -C "$TMP" branch develop # local develop at C0: a name-guessed candidate at the same distance + git -C "$TMP" checkout -q -b feature + echo f >"$TMP/feature.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m F1 + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + # origin/main and local develop sit at the same own-line distance; the declared + # default is enumerated first and must win the tie over the name-guessed branch. + [ "$(get "$output" default-branch)" = origin/main ] +} From b948d92990f4c835c7ebfe34ab36ad88475e3961 Mon Sep 17 00:00:00 2001 From: Tamika Nomara Date: Tue, 28 Jul 2026 17:23:29 +0400 Subject: [PATCH 2/4] refactor(code-review, test-planning): one rev-list per candidate for base detection Replace the per-own-line-commit `git merge-base --is-ancestor` probe in base-branch selection with a single `git rev-list --count --first-parent HEAD ^candidate` per candidate. This cuts subprocess spawns across the candidate pool from O(C*H) to O(C), so a long first-parent history no longer forces a subprocess per own-line commit. Behavior-preserving: the base chosen and the changed-files listing are unchanged, and the detector test suites pass untouched. Verified equivalent across topologies including a candidate reachable only via a merge's second parent and off-spine-only shared history in a multi-root repo. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NfTexc8iv7XAqt3ksH9zNP --- .../scripts/detect-test-context.sh | 27 +++++++++---------- .../scripts/detect-review-context.sh | 27 +++++++++---------- 2 files changed, 24 insertions(+), 30 deletions(-) diff --git a/han-coding/skills/automated-test-planning/scripts/detect-test-context.sh b/han-coding/skills/automated-test-planning/scripts/detect-test-context.sh index 2da4c410..4fc5873c 100755 --- a/han-coding/skills/automated-test-planning/scripts/detect-test-context.sh +++ b/han-coding/skills/automated-test-planning/scripts/detect-test-context.sh @@ -28,30 +28,27 @@ echo "branch: ${BRANCH:-none}" # nearest the current commit, measured along the branch's own line of work so a # candidate the branch merely merged in cannot win on absorbed commits. -# The branch's own line of work (first-parent history), newest first: a candidate -# reachable only through a merge's second parent is not on it. -OWNLINE=$(git rev-list --first-parent HEAD 2>/dev/null) +# The length of the branch's own line of work (first-parent history). A candidate +# that contains none of these commits is reachable only through a merge's second +# parent, or shares no history, and is skipped. +OWNLINE_COUNT=$(git rev-list --count --first-parent HEAD 2>/dev/null) DEFAULT=none best_distance="" seen=" " # Fold one candidate ref into the running nearest-wins selection. Its distance is -# the number of own-line commits above the newest own-line commit the candidate -# contains; a ref already seen, or one sharing no history with the own line, is -# skipped. +# the count of own-line commits not reachable from the candidate, which equals +# the number sitting above the newest own-line commit it contains. That is one +# rev-list per candidate, rather than an is-ancestor probe per own-line commit. A +# ref already seen, or one containing no own-line commit (distance equals the full +# own-line length, so nothing was excluded), is skipped. consider() { # candidate-ref case "$seen" in *" $1 "*) return ;; esac seen="$seen$1 " - local commit count=0 distance="" - while IFS= read -r commit; do - if git merge-base --is-ancestor "$commit" "$1" 2>/dev/null; then - distance=$count - break - fi - count=$((count + 1)) - done <<<"$OWNLINE" - [ -n "$distance" ] || return + local distance + distance=$(git rev-list --count --first-parent HEAD "^$1" 2>/dev/null) + { [ -n "$distance" ] && [ -n "$OWNLINE_COUNT" ] && [ "$distance" -lt "$OWNLINE_COUNT" ]; } || return if [ -z "$best_distance" ] || [ "$distance" -lt "$best_distance" ]; then best_distance=$distance DEFAULT=$1 diff --git a/han-coding/skills/code-review/scripts/detect-review-context.sh b/han-coding/skills/code-review/scripts/detect-review-context.sh index d89e3fff..17d21985 100755 --- a/han-coding/skills/code-review/scripts/detect-review-context.sh +++ b/han-coding/skills/code-review/scripts/detect-review-context.sh @@ -28,30 +28,27 @@ echo "branch: ${BRANCH:-none}" # nearest the current commit, measured along the branch's own line of work so a # candidate the branch merely merged in cannot win on absorbed commits. -# The branch's own line of work (first-parent history), newest first: a candidate -# reachable only through a merge's second parent is not on it. -OWNLINE=$(git rev-list --first-parent HEAD 2>/dev/null) +# The length of the branch's own line of work (first-parent history). A candidate +# that contains none of these commits is reachable only through a merge's second +# parent, or shares no history, and is skipped. +OWNLINE_COUNT=$(git rev-list --count --first-parent HEAD 2>/dev/null) DEFAULT=none best_distance="" seen=" " # Fold one candidate ref into the running nearest-wins selection. Its distance is -# the number of own-line commits above the newest own-line commit the candidate -# contains; a ref already seen, or one sharing no history with the own line, is -# skipped. +# the count of own-line commits not reachable from the candidate, which equals +# the number sitting above the newest own-line commit it contains. That is one +# rev-list per candidate, rather than an is-ancestor probe per own-line commit. A +# ref already seen, or one containing no own-line commit (distance equals the full +# own-line length, so nothing was excluded), is skipped. consider() { # candidate-ref case "$seen" in *" $1 "*) return ;; esac seen="$seen$1 " - local commit count=0 distance="" - while IFS= read -r commit; do - if git merge-base --is-ancestor "$commit" "$1" 2>/dev/null; then - distance=$count - break - fi - count=$((count + 1)) - done <<<"$OWNLINE" - [ -n "$distance" ] || return + local distance + distance=$(git rev-list --count --first-parent HEAD "^$1" 2>/dev/null) + { [ -n "$distance" ] && [ -n "$OWNLINE_COUNT" ] && [ "$distance" -lt "$OWNLINE_COUNT" ]; } || return if [ -z "$best_distance" ] || [ "$distance" -lt "$best_distance" ]; then best_distance=$distance DEFAULT=$1 From 37afaaab7a86bce10e539b2601e9c70f34b7919b Mon Sep 17 00:00:00 2001 From: Tamika Nomara Date: Tue, 28 Jul 2026 18:41:56 +0400 Subject: [PATCH 3/4] chore(rebase): point detectors at automated-test-planning after the v5 rename The v5 line split test-planning into automated-test-planning, so the moved detect-test-context.sh and its Bats test now live under han-coding/skills/automated-test-planning/. Update the test's SRC path and the detector sync-note comments to match the new location. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_017NEQjJUi7ZHVXGfVGXKUVJ --- .../skills/code-review/scripts/detect-review-context.sh | 2 +- test/detect-review-context.bats | 2 +- test/detect-test-context.bats | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/han-coding/skills/code-review/scripts/detect-review-context.sh b/han-coding/skills/code-review/scripts/detect-review-context.sh index 17d21985..ee72cdec 100755 --- a/han-coding/skills/code-review/scripts/detect-review-context.sh +++ b/han-coding/skills/code-review/scripts/detect-review-context.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Detect git availability and review context -# NOTE: Kept in sync with test-planning/scripts/detect-test-context.sh +# NOTE: Kept in sync with automated-test-planning/scripts/detect-test-context.sh # Check if git is installed if ! command -v git &>/dev/null; then diff --git a/test/detect-review-context.bats b/test/detect-review-context.bats index 9ac5a795..9292b028 100755 --- a/test/detect-review-context.bats +++ b/test/detect-review-context.bats @@ -4,7 +4,7 @@ # (git availability, current branch, base-branch selection across remotes and # well-known trunk/integration names by the branch's own line of work, and the # committed-changes-vs-base listing or its `changed-files: none` fallback). Kept -# in sync with the test-planning and review-skill-or-agent copies of this +# in sync with the automated-test-planning and review-skill-or-agent copies of this # detector's tests. setup() { diff --git a/test/detect-test-context.bats b/test/detect-test-context.bats index 3669cb97..8af9ca88 100755 --- a/test/detect-test-context.bats +++ b/test/detect-test-context.bats @@ -1,6 +1,6 @@ #!/usr/bin/env bats # -# Tests for test-planning's detect-test-context.sh: the git-context contract +# Tests for automated-test-planning's detect-test-context.sh: the git-context contract # (git availability, current branch, base-branch selection across remotes and # well-known trunk/integration names by the branch's own line of work, and the # committed-changes-vs-base listing or its `changed-files: none` fallback). Kept @@ -8,7 +8,7 @@ # detector's tests. setup() { - SRC="$BATS_TEST_DIRNAME/../han-coding/skills/test-planning/scripts/detect-test-context.sh" + SRC="$BATS_TEST_DIRNAME/../han-coding/skills/automated-test-planning/scripts/detect-test-context.sh" TMP="$(mktemp -d)" } From d69b3d5bfa869a985154e2b2ced549da8319c753 Mon Sep 17 00:00:00 2001 From: Tamika Nomara Date: Tue, 28 Jul 2026 20:20:17 +0400 Subject: [PATCH 4/4] fix(code-review, automated-test-planning): freeze HEAD and cover second-parent base detection Address code-review findings on the base-branch detectors: - Freeze HEAD to a commit up front (HEAD_SHA) so every candidate and the changed-files diff measure against the same point, not a live ref re-read per candidate (SUGG-002). - Expand the selection comment with the reasoning the count rests on: first-parent monotonicity, and that "^candidate" excludes by the candidate's full reachability while "--first-parent" limits only the walk from HEAD (SUGG-001). - Add a regression test for a candidate that reaches an own-line commit only through its own merge second parent, the topology that distinguishes full-reachability exclusion from a first-parent-limited one (WARN-001). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01NfTexc8iv7XAqt3ksH9zNP --- .../scripts/detect-test-context.sh | 30 ++++++++++++------- .../scripts/detect-review-context.sh | 30 ++++++++++++------- test/detect-review-context.bats | 26 ++++++++++++++++ test/detect-test-context.bats | 26 ++++++++++++++++ 4 files changed, 90 insertions(+), 22 deletions(-) diff --git a/han-coding/skills/automated-test-planning/scripts/detect-test-context.sh b/han-coding/skills/automated-test-planning/scripts/detect-test-context.sh index 4fc5873c..c6965853 100755 --- a/han-coding/skills/automated-test-planning/scripts/detect-test-context.sh +++ b/han-coding/skills/automated-test-planning/scripts/detect-test-context.sh @@ -28,26 +28,34 @@ echo "branch: ${BRANCH:-none}" # nearest the current commit, measured along the branch's own line of work so a # candidate the branch merely merged in cannot win on absorbed commits. -# The length of the branch's own line of work (first-parent history). A candidate -# that contains none of these commits is reachable only through a merge's second -# parent, or shares no history, and is skipped. -OWNLINE_COUNT=$(git rev-list --count --first-parent HEAD 2>/dev/null) +# HEAD's own line of work is its first-parent history. Resolve HEAD to a commit +# once so every candidate is measured against the same point even if the ref moves +# mid-run, then record the line's length. A candidate that contains none of these +# commits shares no history with the own line, or touches it only through a merge's +# second parent, and is skipped below. +HEAD_SHA=$(git rev-parse --verify HEAD 2>/dev/null) +OWNLINE_COUNT=$(git rev-list --count --first-parent "$HEAD_SHA" 2>/dev/null) DEFAULT=none best_distance="" seen=" " # Fold one candidate ref into the running nearest-wins selection. Its distance is -# the count of own-line commits not reachable from the candidate, which equals -# the number sitting above the newest own-line commit it contains. That is one -# rev-list per candidate, rather than an is-ancestor probe per own-line commit. A -# ref already seen, or one containing no own-line commit (distance equals the full -# own-line length, so nothing was excluded), is skipped. +# the count of own-line commits not reachable from the candidate. First-parent +# history is monotone, so the own-line commits a candidate contains form an older +# run at the bottom of the line, and the count above them is the distance to the +# candidate's fork point. Note that "--first-parent" limits only the walk from +# HEAD; the "^$1" exclusion still uses the candidate's full reachability, so a +# commit the candidate reaches through a merge's second parent counts here just as +# the old "merge-base --is-ancestor" probe counted it. This runs one rev-list per +# candidate, not an is-ancestor probe per own-line commit. A ref already seen, or +# one containing no own-line commit (distance equals the full own-line length, so +# nothing was excluded), is skipped. consider() { # candidate-ref case "$seen" in *" $1 "*) return ;; esac seen="$seen$1 " local distance - distance=$(git rev-list --count --first-parent HEAD "^$1" 2>/dev/null) + distance=$(git rev-list --count --first-parent "$HEAD_SHA" "^$1" 2>/dev/null) { [ -n "$distance" ] && [ -n "$OWNLINE_COUNT" ] && [ "$distance" -lt "$OWNLINE_COUNT" ]; } || return if [ -z "$best_distance" ] || [ "$distance" -lt "$best_distance" ]; then best_distance=$distance @@ -75,7 +83,7 @@ done echo "default-branch: $DEFAULT" if [ "$DEFAULT" != none ]; then - CHANGED=$(git diff --name-only "$DEFAULT...HEAD" 2>/dev/null) + CHANGED=$(git diff --name-only "$DEFAULT...$HEAD_SHA" 2>/dev/null) if [ -n "$CHANGED" ]; then echo "changed-files-start" echo "$CHANGED" diff --git a/han-coding/skills/code-review/scripts/detect-review-context.sh b/han-coding/skills/code-review/scripts/detect-review-context.sh index ee72cdec..d51197dc 100755 --- a/han-coding/skills/code-review/scripts/detect-review-context.sh +++ b/han-coding/skills/code-review/scripts/detect-review-context.sh @@ -28,26 +28,34 @@ echo "branch: ${BRANCH:-none}" # nearest the current commit, measured along the branch's own line of work so a # candidate the branch merely merged in cannot win on absorbed commits. -# The length of the branch's own line of work (first-parent history). A candidate -# that contains none of these commits is reachable only through a merge's second -# parent, or shares no history, and is skipped. -OWNLINE_COUNT=$(git rev-list --count --first-parent HEAD 2>/dev/null) +# HEAD's own line of work is its first-parent history. Resolve HEAD to a commit +# once so every candidate is measured against the same point even if the ref moves +# mid-run, then record the line's length. A candidate that contains none of these +# commits shares no history with the own line, or touches it only through a merge's +# second parent, and is skipped below. +HEAD_SHA=$(git rev-parse --verify HEAD 2>/dev/null) +OWNLINE_COUNT=$(git rev-list --count --first-parent "$HEAD_SHA" 2>/dev/null) DEFAULT=none best_distance="" seen=" " # Fold one candidate ref into the running nearest-wins selection. Its distance is -# the count of own-line commits not reachable from the candidate, which equals -# the number sitting above the newest own-line commit it contains. That is one -# rev-list per candidate, rather than an is-ancestor probe per own-line commit. A -# ref already seen, or one containing no own-line commit (distance equals the full -# own-line length, so nothing was excluded), is skipped. +# the count of own-line commits not reachable from the candidate. First-parent +# history is monotone, so the own-line commits a candidate contains form an older +# run at the bottom of the line, and the count above them is the distance to the +# candidate's fork point. Note that "--first-parent" limits only the walk from +# HEAD; the "^$1" exclusion still uses the candidate's full reachability, so a +# commit the candidate reaches through a merge's second parent counts here just as +# the old "merge-base --is-ancestor" probe counted it. This runs one rev-list per +# candidate, not an is-ancestor probe per own-line commit. A ref already seen, or +# one containing no own-line commit (distance equals the full own-line length, so +# nothing was excluded), is skipped. consider() { # candidate-ref case "$seen" in *" $1 "*) return ;; esac seen="$seen$1 " local distance - distance=$(git rev-list --count --first-parent HEAD "^$1" 2>/dev/null) + distance=$(git rev-list --count --first-parent "$HEAD_SHA" "^$1" 2>/dev/null) { [ -n "$distance" ] && [ -n "$OWNLINE_COUNT" ] && [ "$distance" -lt "$OWNLINE_COUNT" ]; } || return if [ -z "$best_distance" ] || [ "$distance" -lt "$best_distance" ]; then best_distance=$distance @@ -75,7 +83,7 @@ done echo "default-branch: $DEFAULT" if [ "$DEFAULT" != none ]; then - CHANGED=$(git diff --name-only "$DEFAULT...HEAD" 2>/dev/null) + CHANGED=$(git diff --name-only "$DEFAULT...$HEAD_SHA" 2>/dev/null) if [ -n "$CHANGED" ]; then echo "changed-files-start" echo "$CHANGED" diff --git a/test/detect-review-context.bats b/test/detect-review-context.bats index 9292b028..8df77568 100755 --- a/test/detect-review-context.bats +++ b/test/detect-review-context.bats @@ -283,3 +283,29 @@ set_remote_tracking() { # repo remote branch sha # default is enumerated first and must win the tie over the name-guessed branch. [ "$(get "$output" default-branch)" = origin/main ] } + +@test "recognizes a candidate that reaches the own line only via its own second parent" { + git_init "$TMP" # R0 on the initial branch + git -C "$TMP" checkout -q -b spine + echo x >"$TMP/x.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m X # X: an own-line commit + xsha=$(git -C "$TMP" rev-parse HEAD) + echo h >"$TMP/head.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m HEADc # own line: R0 -> X -> HEADc + git -C "$TMP" checkout -q --orphan zline # a candidate on a second root + git -C "$TMP" rm -rfq . + git -C "$TMP" commit -q --allow-empty -m Z1 + git -C "$TMP" merge -q --no-ff --allow-unrelated-histories "$xsha" -m "absorb X as a second parent" + set_remote_head "$TMP" origin cand "$(git -C "$TMP" rev-parse HEAD)" + git -C "$TMP" checkout -q spine + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + # cand reaches own-line commit X only through the merge's second parent. Full + # reachability exclusion (correct) counts X as contained and selects cand at the + # nearer fork; a first-parent-limited exclusion would treat cand as unrelated and + # fall back to the initial branch at the older fork. + [ "$(get "$output" default-branch)" = origin/cand ] +} diff --git a/test/detect-test-context.bats b/test/detect-test-context.bats index 8af9ca88..d81c98b8 100755 --- a/test/detect-test-context.bats +++ b/test/detect-test-context.bats @@ -283,3 +283,29 @@ set_remote_tracking() { # repo remote branch sha # default is enumerated first and must win the tie over the name-guessed branch. [ "$(get "$output" default-branch)" = origin/main ] } + +@test "recognizes a candidate that reaches the own line only via its own second parent" { + git_init "$TMP" # R0 on the initial branch + git -C "$TMP" checkout -q -b spine + echo x >"$TMP/x.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m X # X: an own-line commit + xsha=$(git -C "$TMP" rev-parse HEAD) + echo h >"$TMP/head.txt" + git -C "$TMP" add -A + git -C "$TMP" commit -q -m HEADc # own line: R0 -> X -> HEADc + git -C "$TMP" checkout -q --orphan zline # a candidate on a second root + git -C "$TMP" rm -rfq . + git -C "$TMP" commit -q --allow-empty -m Z1 + git -C "$TMP" merge -q --no-ff --allow-unrelated-histories "$xsha" -m "absorb X as a second parent" + set_remote_head "$TMP" origin cand "$(git -C "$TMP" rev-parse HEAD)" + git -C "$TMP" checkout -q spine + cd "$TMP" + run "$SRC" + [ "$status" -eq 0 ] + # cand reaches own-line commit X only through the merge's second parent. Full + # reachability exclusion (correct) counts X as contained and selects cand at the + # nearer fork; a first-parent-limited exclusion would treat cand as unrelated and + # fall back to the initial branch at the older fork. + [ "$(get "$output" default-branch)" = origin/cand ] +}