From 60d1b6d1e1c9f2cdbb65d6d7a506f42fa60549a4 Mon Sep 17 00:00:00 2001 From: Yonathan Morales Date: Sat, 12 Sep 2026 18:51:50 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(git-fix-author):=20=F0=9F=A6=89=20reso?= =?UTF-8?q?lve=20start=20from=20the=20current=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --current-branch (and GFA_CURRENT_BRANCH) rewrites unique commits versus the integration line instead of requiring a computed --start. Refuse detached HEAD, a missing integration ref, and an empty unique range so default-branch history is never rewritten from the root. Co-authored-by: Cursor Agent --- skills/git-fix-author/scripts/git-fix-author | 80 +++++++++- tests/git-fix-author/test.sh | 154 +++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) 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" From 7a9a34e7c417128bd862094878c5043f957f926b Mon Sep 17 00:00:00 2001 From: Yonathan Morales Date: Sat, 12 Sep 2026 18:51:56 +0000 Subject: [PATCH 2/2] =?UTF-8?q?docs(git-fix-author):=20=F0=9F=A6=89=20docu?= =?UTF-8?q?ment=20current-branch=20start=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Describe --current-branch as the unique range versus origin/HEAD, origin/main, or local main, including the empty-range refusal. Co-authored-by: Cursor Agent --- README.md | 3 +++ skills/git-fix-author/README.md | 14 +++++++++++++- skills/git-fix-author/SKILL.md | 7 +++++-- skills/git-fix-author/references/git-semantics.md | 8 ++++++++ 4 files changed, 29 insertions(+), 3 deletions(-) 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.