diff --git a/README.md b/README.md index 216149b..d3cc656 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,9 @@ git fix-author \ --email "aipal@uklok.ai" ``` +When the intent is "the unique commits on this branch", pass +`--current-branch` instead of computing `--start`. + Every mutating operation follows the same model: ```text diff --git a/skills/git-fix-author/README.md b/skills/git-fix-author/README.md index 7f6cbac..4d3f793 100644 --- a/skills/git-fix-author/README.md +++ b/skills/git-fix-author/README.md @@ -47,8 +47,20 @@ git fix-author \ --yes ``` +To rewrite only the unique commits on the current branch versus the +integration line (`origin/HEAD`, `origin/main`, or local `main`), pass +`--current-branch` instead of `--start`. That range is the first unique +commit through `HEAD`. On the default branch it is unpushed commits only; +it never falls back to the repository root. The flag fails on detached +`HEAD` and when there are no unique commits. It is mutually exclusive +with `--start`. + +```console +git fix-author --current-branch --non-interactive --yes +``` + All primary values also have `GFA_*` environment equivalents documented by -`--help`. +`--help`. `GFA_CURRENT_BRANCH=1` selects `--current-branch`. ## Identity semantics diff --git a/skills/git-fix-author/SKILL.md b/skills/git-fix-author/SKILL.md index c4743dc..eb5fe9e 100644 --- a/skills/git-fix-author/SKILL.md +++ b/skills/git-fix-author/SKILL.md @@ -11,8 +11,11 @@ request. ## Workflow -1. Confirm that rewriting published history is acceptable and identify the - inclusive start commit and local finish branch. +1. Confirm that rewriting published history is acceptable. Prefer + `--current-branch` so the operation resolves the first unique commit + on the current branch versus the integration line. Pass `--start` + only when that range is the wrong boundary. The finish branch defaults + to the current branch. 2. Preserve unrelated user work. The executable rejects a dirty repository; do not silently stash, reset, or discard changes to bypass that precondition. 3. Inspect `scripts/git-fix-author --help` and run `--dry-run` with the intended diff --git a/skills/git-fix-author/references/git-semantics.md b/skills/git-fix-author/references/git-semantics.md index 75ac0fa..a469a1a 100644 --- a/skills/git-fix-author/references/git-semantics.md +++ b/skills/git-fix-author/references/git-semantics.md @@ -19,6 +19,14 @@ This includes the start, finish, and merge-side commits within that graph. For a root start, every commit reachable from the finish branch is rebuilt. +`--current-branch` selects the oldest commit reachable from `HEAD` but not +from the integration line (`origin/HEAD`, then `origin/main` / +`origin/master`, then a local `main` / `master` that is not the current +branch). On the default branch that is the unpushed range versus +`origin/main`. The operation refuses to run when that set is empty or +when no integration line exists, instead of widening to the repository +root. + A merge commit cannot be the start because selecting one parent as the excluded boundary would silently choose semantics the caller did not express. Merges inside the resolved graph remain supported. diff --git a/skills/git-fix-author/scripts/git-fix-author b/skills/git-fix-author/scripts/git-fix-author index 426f9bb..d1d5895 100755 --- a/skills/git-fix-author/scripts/git-fix-author +++ b/skills/git-fix-author/scripts/git-fix-author @@ -10,14 +10,20 @@ author_email=${GFA_EMAIL:-} committer_name=${GFA_COMMITTER_NAME:-} committer_email=${GFA_COMMITTER_EMAIL:-} assume_yes=${GFA_YES:-0} +current_branch_mode=0 dry_run=0 make_backup=1 update_refs=1 +case ${GFA_CURRENT_BRANCH:-0} in + 1 | true | TRUE | yes | YES | on | ON) current_branch_mode=1 ;; +esac + usage() { cat </dev/null); then + printf '%s\n' "$ref" + return 0 + fi + + for short in origin/main origin/master; do + if git rev-parse --verify "${short}^{commit}" >/dev/null 2>&1; then + printf '%s\n' "$short" + return 0 + fi + done + + for short in main master; do + if [[ $short != "$current" ]] && git show-ref --verify --quiet "refs/heads/$short"; then + printf '%s\n' "refs/heads/$short" + return 0 + fi + done + + return 1 +} + +resolve_current_branch_start() { + local current=$1 + local integration integration_oid start_from integration_name + + integration=$(resolve_integration_ref "$current") || + die "cannot resolve an integration branch for --current-branch; fetch origin or pass --start" + integration_oid=$(git rev-parse --verify "${integration}^{commit}" 2>/dev/null) || + die "cannot resolve an integration branch for --current-branch; fetch origin or pass --start" + + local -a unique_commits=() + mapfile -t unique_commits < <(git rev-list --reverse --topo-order "$integration_oid..HEAD") + start_from=${unique_commits[0]:-} + integration_name=${integration#refs/remotes/} + integration_name=${integration_name#refs/heads/} + [[ -n $start_from ]] || + die "no unique commits on the current branch relative to $integration_name" + printf '%s\n' "$start_from" +} + lookup_new_oid() { awk -v oid="$1" '$1 == oid { print $2; exit }' "$map_file" } @@ -100,6 +161,10 @@ while (($#)); do start=$2 shift 2 ;; + --current-branch) + current_branch_mode=1 + shift + ;; -f | --finish) [[ $# -ge 2 ]] || die "$1 requires a value" finish=$2 @@ -187,13 +252,26 @@ esac current_branch=$(git symbolic-ref --quiet --short HEAD || true) [[ -n $finish ]] || finish=${current_branch:-HEAD} +if [[ $current_branch_mode -eq 1 ]]; then + [[ -z $start ]] || die "--current-branch is mutually exclusive with --start (or GFA_START)" + [[ -n $current_branch ]] || die "HEAD is detached; --current-branch requires a checked-out branch" + case $finish in + HEAD | "$current_branch" | "refs/heads/$current_branch") ;; + *) + die "--current-branch rewrites the current branch; omit --finish" + ;; + esac + finish=$current_branch + start=$(resolve_current_branch_start "$current_branch") +fi + if [[ $prompt -eq 1 ]]; then [[ -n $start ]] || read -r -p 'Start ref (inclusive): ' start [[ -n $author_name ]] || read -r -p 'Author name: ' author_name [[ -n $author_email ]] || read -r -p 'Author email: ' author_email fi -[[ -n $start ]] || die "missing --start (or GFA_START)" +[[ -n $start ]] || die "missing --start, --current-branch, or GFA_START" [[ -n $author_name ]] || die "missing --name (or GFA_NAME)" [[ -n $author_email ]] || die "missing --email (or GFA_EMAIL)" diff --git a/tests/git-fix-author/test.sh b/tests/git-fix-author/test.sh index fbf81c3..9189203 100755 --- a/tests/git-fix-author/test.sh +++ b/tests/git-fix-author/test.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash set -euo pipefail +unset GFA_START GFA_FINISH GFA_NAME GFA_EMAIL \ + GFA_COMMITTER_NAME GFA_COMMITTER_EMAIL GFA_MODE GFA_YES GFA_CURRENT_BRANCH + repository_root=$(git rev-parse --show-toplevel) tool=$repository_root/skills/git-fix-author/scripts/git-fix-author test_root=$(mktemp -d "${TMPDIR:-/tmp}/git-fix-author-tests.XXXXXX") @@ -103,6 +106,15 @@ test_help() { assert_contains "$output" 'Usage:' 'help shows usage' assert_contains "$output" '--non-interactive' 'help documents automation' assert_contains "$output" '--no-update-refs' 'help documents ref scope' + assert_contains "$output" '--current-branch' 'help documents current-branch start' + assert_contains "$output" 'GFA_CURRENT_BRANCH' 'help documents current-branch env' +} + +publish_origin_main() { + repo=$1 + oid=$2 + git -C "$repo" update-ref refs/remotes/origin/main "$oid" + git -C "$repo" symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main } test_linear_rewrite_and_backup() { @@ -334,6 +346,143 @@ test_rejections() { assert_eq "$(cd "$repo" && backup_count)" '0' 'rejections create no backup' } +test_current_branch_feature_vs_origin_main() { + repo=$test_root/current-branch-feature + new_repo "$repo" + commit_file "$repo" file.txt base base '2025-01-01T10:00:00Z' + root_oid=$(git -C "$repo" rev-parse HEAD) + publish_origin_main "$repo" "$root_oid" + git -C "$repo" checkout -q -b feature + commit_file "$repo" file.txt one feature-one '2025-01-02T10:00:00Z' + commit_file "$repo" file.txt two feature-two '2025-01-03T10:00:00Z' + + output=$(cd "$repo" && "$tool" --current-branch --name Feature \ + --email feature@example.com --dry-run --non-interactive) + assert_contains "$output" 'Commits: 2' 'feature unique range excludes origin/main' + + (cd "$repo" && "$tool" --current-branch --name Feature \ + --email feature@example.com --non-interactive --yes >/dev/null) + + assert_eq "$(git -C "$repo" show -s --format='%an <%ae>' "$root_oid")" \ + 'Configured User ' 'integration tip is untouched' + assert_eq "$(git -C "$repo" log --reverse --format='%an <%ae>' origin/main..HEAD | sort -u)" \ + 'Feature ' 'unique feature commits are rewritten' + assert_eq "$(git -C "$repo" rev-list --count origin/main..HEAD)" '2' \ + 'rewrite stays bounded to unique commits' +} + +test_current_branch_main_rewrites_unpushed_only() { + repo=$test_root/current-branch-main + new_repo "$repo" + commit_file "$repo" file.txt root root '2025-01-01T10:00:00Z' + root_oid=$(git -C "$repo" rev-parse HEAD) + publish_origin_main "$repo" "$root_oid" + commit_file "$repo" file.txt one unpushed-one '2025-01-02T10:00:00Z' + commit_file "$repo" file.txt two unpushed-two '2025-01-03T10:00:00Z' + + output=$(cd "$repo" && "$tool" --current-branch --name Local \ + --email local@example.com --dry-run --non-interactive) + assert_contains "$output" 'Commits: 2' 'default branch unique range is unpushed only' + + (cd "$repo" && "$tool" --current-branch --name Local \ + --email local@example.com --non-interactive --yes >/dev/null) + + assert_eq "$(git -C "$repo" show -s --format='%an <%ae>' "$root_oid")" \ + 'Configured User ' 'published main is not rewritten from root' + assert_eq "$(git -C "$repo" log --reverse --format='%an <%ae>' origin/main..HEAD | sort -u)" \ + 'Local ' 'unpushed main commits are rewritten' +} + +test_current_branch_feature_vs_local_main() { + repo=$test_root/current-branch-local-main + new_repo "$repo" + commit_file "$repo" file.txt base base '2025-01-01T10:00:00Z' + root_oid=$(git -C "$repo" rev-parse HEAD) + git -C "$repo" checkout -q -b feature + commit_file "$repo" file.txt feat feature '2025-01-02T10:00:00Z' + + (cd "$repo" && "$tool" --current-branch --name Feature \ + --email feature@example.com --non-interactive --yes >/dev/null) + + assert_eq "$(git -C "$repo" show -s --format='%an <%ae>' "$root_oid")" \ + 'Configured User ' 'local main remains the integration boundary' + assert_eq "$(git -C "$repo" show -s --format='%an <%ae>' HEAD)" \ + 'Feature ' 'unique commit versus local main is rewritten' +} + +test_current_branch_env_flag() { + repo=$test_root/current-branch-env + new_repo "$repo" + commit_file "$repo" file.txt base base '2025-01-01T10:00:00Z' + root_oid=$(git -C "$repo" rev-parse HEAD) + publish_origin_main "$repo" "$root_oid" + git -C "$repo" checkout -q -b feature + commit_file "$repo" file.txt feat feature '2025-01-02T10:00:00Z' + + output=$(cd "$repo" && GFA_CURRENT_BRANCH=1 "$tool" --name Env \ + --email env@example.com --dry-run --non-interactive) + assert_contains "$output" 'Commits: 1' 'GFA_CURRENT_BRANCH selects unique range' +} + +test_current_branch_rejections() { + repo=$test_root/current-branch-rejections + new_repo "$repo" + commit_file "$repo" file.txt base base '2025-01-01T10:00:00Z' + root_oid=$(git -C "$repo" rev-parse HEAD) + + in_sync_output=$test_root/current-branch-in-sync + publish_origin_main "$repo" "$root_oid" + if (cd "$repo" && "$tool" --current-branch --name Author \ + --email author@example.com --dry-run --non-interactive \ + >"$in_sync_output" 2>&1); then + return 1 + fi + assert_contains "$(<"$in_sync_output")" 'no unique commits' \ + 'in-sync default branch does not rewrite from root' + + exclusive_output=$test_root/current-branch-exclusive + git -C "$repo" checkout -q -b feature + commit_file "$repo" file.txt feat feature '2025-01-02T10:00:00Z' + if (cd "$repo" && "$tool" --current-branch --start "$root_oid" --name Author \ + --email author@example.com --dry-run --non-interactive \ + >"$exclusive_output" 2>&1); then + return 1 + fi + assert_contains "$(<"$exclusive_output")" 'mutually exclusive' \ + '--current-branch rejects an explicit start' + + env_exclusive=$test_root/current-branch-env-exclusive + if (cd "$repo" && GFA_START=$root_oid GFA_CURRENT_BRANCH=1 "$tool" \ + --name Author --email author@example.com --dry-run --non-interactive \ + >"$env_exclusive" 2>&1); then + return 1 + fi + assert_contains "$(<"$env_exclusive")" 'mutually exclusive' \ + 'GFA_CURRENT_BRANCH rejects GFA_START' + + git -C "$repo" checkout -q --detach HEAD + detached_output=$test_root/current-branch-detached + if (cd "$repo" && "$tool" --current-branch --name Author \ + --email author@example.com --dry-run --non-interactive \ + >"$detached_output" 2>&1); then + return 1 + fi + assert_contains "$(<"$detached_output")" 'HEAD is detached' \ + 'detached HEAD is rejected' + + no_integration=$test_root/current-branch-no-integration + new_repo "$no_integration" + commit_file "$no_integration" file.txt only root '2025-01-01T10:00:00Z' + missing_output=$test_root/current-branch-missing-integration + if (cd "$no_integration" && "$tool" --current-branch --name Author \ + --email author@example.com --dry-run --non-interactive \ + >"$missing_output" 2>&1); then + return 1 + fi + assert_contains "$(<"$missing_output")" 'cannot resolve an integration branch' \ + 'main without origin does not rewrite from the repo root' +} + printf 'TAP version 13\n' run_test 'help documents the public interface' test_help run_test 'linear rewrite verifies history and creates recovery' test_linear_rewrite_and_backup @@ -345,6 +494,11 @@ run_test 'no-update-refs limits ref mutation' test_no_update_refs run_test 'bounded rewrite can target a non-current branch' test_bounded_range_on_another_branch run_test 'non-ancestor ranges are rejected without mutation' test_nonancestor_is_rejected run_test 'unsafe and incomplete requests are rejected' test_rejections +run_test 'current branch unique range versus origin/main' test_current_branch_feature_vs_origin_main +run_test 'current branch on main rewrites unpushed commits only' test_current_branch_main_rewrites_unpushed_only +run_test 'current branch can use local main as integration' test_current_branch_feature_vs_local_main +run_test 'GFA_CURRENT_BRANCH selects the unique range' test_current_branch_env_flag +run_test 'current-branch preconditions are rejected without mutation' test_current_branch_rejections total=$((passed + failed)) printf '1..%s\n' "$total"