Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 13 additions & 1 deletion skills/git-fix-author/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions skills/git-fix-author/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions skills/git-fix-author/references/git-semantics.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
80 changes: 79 additions & 1 deletion skills/git-fix-author/scripts/git-fix-author
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOF
Usage:
$program --start REF [--finish BRANCH] --name NAME --email EMAIL [options]
$program --current-branch --name NAME --email EMAIL [options]

Rewrite author and committer attribution from START (inclusive) through the
local FINISH branch. File trees, commit messages, author/committer timestamps,
Expand All @@ -28,6 +34,12 @@ signatures in the rewritten range cannot be preserved.

Options:
-s, --start REF First commit to rewrite (inclusive)
--current-branch Start at the first unique commit on the
current branch versus the integration line
(origin/HEAD, origin/main, or local main).
Mutually exclusive with --start. Fails on
detached HEAD or when there are no unique
commits (does not rewrite from the repo root)
-f, --finish BRANCH Local branch to rewrite through
(default: current branch)
-n, --name NAME Replacement author name
Expand All @@ -51,6 +63,7 @@ Environment:
GFA_COMMITTER_NAME, GFA_COMMITTER_EMAIL
GFA_MODE auto | interactive | non-interactive
GFA_YES 1 to skip confirmation
GFA_CURRENT_BRANCH 1 to select --current-branch

Examples:
$program --start fix/author --finish feature/foo \
Expand All @@ -62,6 +75,9 @@ Examples:

GFA_START=fix/author GFA_FINISH=feature/foo GFA_NAME='AIPAL' \
GFA_EMAIL=aipal@uklok.ai $program --non-interactive --yes

GFA_NAME='AIPAL' GFA_EMAIL=aipal@uklok.ai \
$program --current-branch --non-interactive --yes
EOF
}

Expand All @@ -75,6 +91,51 @@ fail() {
exit 1
}

resolve_integration_ref() {
local current=$1
local ref short

if ref=$(git symbolic-ref --quiet refs/remotes/origin/HEAD 2>/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"
}
Expand All @@ -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
Expand Down Expand Up @@ -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)"

Expand Down
154 changes: 154 additions & 0 deletions tests/git-fix-author/test.sh
Original file line number Diff line number Diff line change
@@ -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")
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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 <configured@example.com>' 'integration tip is untouched'
assert_eq "$(git -C "$repo" log --reverse --format='%an <%ae>' origin/main..HEAD | sort -u)" \
'Feature <feature@example.com>' '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 <configured@example.com>' 'published main is not rewritten from root'
assert_eq "$(git -C "$repo" log --reverse --format='%an <%ae>' origin/main..HEAD | sort -u)" \
'Local <local@example.com>' '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 <configured@example.com>' 'local main remains the integration boundary'
assert_eq "$(git -C "$repo" show -s --format='%an <%ae>' HEAD)" \
'Feature <feature@example.com>' '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
Expand All @@ -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"
Expand Down
Loading