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..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 @@ -24,12 +24,66 @@ 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. - CHANGED=$(git diff --name-only "$DEFAULT...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. 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_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 + 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_SHA" 2>/dev/null) if [ -n "$CHANGED" ]; then echo "changed-files-start" echo "$CHANGED" @@ -38,6 +92,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..d51197dc 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 @@ -24,12 +24,66 @@ 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. - CHANGED=$(git diff --name-only "$DEFAULT...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. 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_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 + 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_SHA" 2>/dev/null) if [ -n "$CHANGED" ]; then echo "changed-files-start" echo "$CHANGED" @@ -38,6 +92,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..8df77568 --- /dev/null +++ b/test/detect-review-context.bats @@ -0,0 +1,311 @@ +#!/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 automated-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 ] +} + +@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 new file mode 100755 index 00000000..d81c98b8 --- /dev/null +++ b/test/detect-test-context.bats @@ -0,0 +1,311 @@ +#!/usr/bin/env bats +# +# 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 +# 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/automated-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 ] +} + +@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 ] +}