From e9540d5a9f5ae30424727e35d38272c1afa83058 Mon Sep 17 00:00:00 2001 From: Yonathan Morales Date: Tue, 1 Sep 2026 02:08:32 -0400 Subject: [PATCH 1/7] =?UTF-8?q?docs:=20=F0=9F=91=BB=20Add=20foundational?= =?UTF-8?q?=20project=20governance=20documents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CODE_OF_CONDUCT.md | 23 ++++++++++++++++++++ CONTRIBUTING.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++ LICENSE | 21 ++++++++++++++++++ SECURITY.md | 33 ++++++++++++++++++++++++++++ 4 files changed, 131 insertions(+) create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 SECURITY.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0441a28 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,23 @@ +# Code of Conduct + +Git Intent is built through careful technical disagreement and shared respect. + +Participants are expected to: + +- be considerate, precise, and constructive; +- critique ideas and evidence rather than people; +- respect different experience levels and communication styles; +- disclose relevant constraints and conflicts of interest; and +- protect private, security-sensitive, and personally identifying information. + +Harassment, threats, discriminatory conduct, deliberate intimidation, and +publication of another person's private information are not acceptable. + +Project maintainers may clarify, edit, or remove contributions and restrict +participation when needed to protect the community. Report conduct concerns +privately through a maintainer contact listed on the repository profile. Reports +will be reviewed with discretion, fairness, and attention to the safety of the +people involved. + +This code applies in project spaces and when someone is representing the +project in public. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5156263 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Contributing to Git Intent + +Thank you for helping make risky Git work more predictable. + +## Begin with the intent + +Before proposing code, describe the outcome without prescribing commands. An +operation belongs in Git Intent when its intent is common, its raw Git procedure +is non-trivial or risky, and it can have explicit safety and recovery semantics. + +Use the **Propose an operation** issue form. Identify existing tools before +designing a new engine. Building on maintained software is encouraged when it +improves correctness or safety. + +## Operation requirements + +New mutating operations must follow the +[operation contract](docs/operation-contract.md). A proposal should define: + +- its exact mutation boundary; +- preconditions and unsupported cases; +- a read-only preview when technically possible; +- a recovery model; +- verification invariants; +- behavior in non-interactive environments; and +- how local mutation remains separate from remote publication. + +Each operation should remain independently useful and have precise Agent Skill +discovery metadata. Avoid broad Git tutorials or catch-all skills. + +## Development + +Run the repository checks before opening a pull request: + +```console +scripts/test +scripts/validate-skills +``` + +If ShellCheck and shfmt are installed, also run the lint commands documented in +the [README](README.md#develop-and-verify). + +Tests for a mutating operation should create isolated disposable repositories +and verify behavior, not implementation wording. At minimum, cover dry-run +purity, rejection paths, promised history invariants, checkpoint reachability, +identity configuration isolation, and non-interactive behavior. + +## Pull requests + +Keep changes bounded to one intent. Explain the safety contract and evidence +from tests. Call out any engine-dependent limitation or changed recovery +behavior explicitly. + +By participating, you agree to follow the [Code of Conduct](CODE_OF_CONDUCT.md). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..0bfcaad --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Git Intent contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..9e7b107 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,33 @@ +# Security Policy + +Git Intent performs repository mutations, so safety defects can have an impact +beyond ordinary command-line failures. + +## Report a vulnerability + +Do not open a public issue for a vulnerability that could cause silent history +loss, unintended ref mutation, command injection, credential disclosure, or an +unsafe remote update. + +Use the repository host's private vulnerability-reporting feature. If that is +not enabled, contact the maintainers privately through an address listed on the +repository profile. Include: + +- the affected operation and version or commit; +- the smallest safe reproduction; +- the expected and observed mutation boundary; +- whether a recovery ref was created; and +- any known workaround. + +Do not include real credentials or sensitive repository contents. + +## Supported versions + +Until the first tagged release, only the current default branch receives +security fixes. This policy will be updated when versioned releases begin. + +## Safety is not recovery + +Keep independent backups of valuable repositories. Recovery refs reduce risk, +but they are stored inside the same object database and are not a substitute +for an external backup or remote copy. From 972b70e8898f988b36be7e29d19d86d3bd988789 Mon Sep 17 00:00:00 2001 From: Yonathan Morales Date: Tue, 1 Sep 2026 02:08:32 -0400 Subject: [PATCH 2/7] =?UTF-8?q?docs:=20=F0=9F=91=BB=20Establish=20core=20p?= =?UTF-8?q?roject=20philosophy,=20architecture,=20and=20operation=20contra?= =?UTF-8?q?ct?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/architecture.md | 63 +++++++++++++++++++++++++++++ docs/operation-contract.md | 82 ++++++++++++++++++++++++++++++++++++++ docs/philosophy.md | 56 ++++++++++++++++++++++++++ 3 files changed, 201 insertions(+) create mode 100644 docs/architecture.md create mode 100644 docs/operation-contract.md create mode 100644 docs/philosophy.md diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..0130301 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,63 @@ +# Architecture + +Git Intent is a thin intent and safety layer over proven repository engines. + +```text +human or agent intent + | + v + Git Intent +recognize -> inspect -> preview -> checkpoint -> execute -> verify + | + v +best available engine +Git / git-filter-repo / git-revise / libgit2 / ... + | + v + Git repository +``` + +## Operations + +Each operation is a self-contained Agent Skill under `skills/`. Its `SKILL.md` +provides selection and safety instructions, `scripts/` contains the canonical +executable, and `references/` holds conditional technical context. + +```text +skills// +|-- SKILL.md +|-- README.md +|-- scripts/ +| `-- git- +`-- references/ + `-- ... +``` + +The README is human-facing packaging documentation. The skill entry point stays +compact and routes to references only when their details affect a decision. + +The executable is the single implementation source. Human installation exposes +the same `git-` file on `PATH`, where Git discovers it as +`git `. + +## Stable contracts, replaceable engines + +An operation owns its CLI, preconditions, mutation boundary, recovery model, +and verified invariants. It does not promise a permanent internal engine. + +The first operation, `git-fix-author`, uses Git plumbing to reconstruct commit +objects without changing file trees, the working tree, or the index. A future +engine change is acceptable only if it preserves or explicitly improves the +documented contract. + +## No framework yet + +The repository intentionally has no plugin system, shared core, engine adapter, +or meta-command. The second and third real operations should demonstrate a +shared need before common machinery is extracted. + +## Distribution + +The repository is a collection of independently useful Agent Skills. Existing +skill ecosystems can distribute them; Git Intent does not operate a separate +marketplace. diff --git a/docs/operation-contract.md b/docs/operation-contract.md new file mode 100644 index 0000000..a1a0794 --- /dev/null +++ b/docs/operation-contract.md @@ -0,0 +1,82 @@ +# Operation Contract + +Every mutating Git Intent operation should implement these stages: + +1. preflight; +2. resolution; +3. preview; +4. checkpoint; +5. execution; +6. verification; and +7. recovery and publishing guidance. + +An operation's documentation must state which guarantees are unconditional, +which depend on the selected engine, and which cases are unsupported. + +## Safety principles + +A mutating operation must not silently: + +- discard working-tree or index changes; +- delete user branches; +- modify repository, worktree, or global Git identity configuration; +- force-push; +- remove recovery data; +- broaden its requested history range; or +- mutate unrelated refs. + +The operation must fail before mutation when it cannot establish its documented +preconditions. Error messages should identify the violated precondition and a +safe next action. + +## Dry runs + +When technically possible, a mutating operation must provide a read-only +preview. + +A dry run must not create backup refs, modify refs, rewrite reachable commits, +alter configuration, or change the working tree or index. It should resolve the +same intent and report the same mutation scope as execution. + +## Checkpoints + +History-rewriting operations should establish a native Git recovery point +before mutating a reachable ref. Native Git refs are preferred over external +bookkeeping where practical. + +The checkpoint name and target must be reported to the caller. Omitting a +checkpoint requires an explicit option; it must never be inferred from +non-interactive execution. + +## Verification + +Success is defined by the operation's promised invariants, not only by the exit +status of its underlying engine. + +Verification should compare the resolved pre-operation objects with the final +objects. If an invariant fails after mutation, the operation must report the +failure, retain the recovery point, and avoid publishing changes. + +## Recovery and publishing + +Local mutation and remote publication are separate actions. + +An operation must not publish or force-update remote history unless that is the +operation's explicit, separately authorized purpose. A history rewrite should +provide recovery and `--force-with-lease` guidance without performing either +action automatically. + +## Engines + +Git Intent does not require operations to reproduce capabilities already +provided reliably elsewhere. An operation may use: + +- Git; +- `git-filter-repo`; +- `git-revise`; +- libgit2; or +- another established tool. + +Interfaces should remain stable while engines remain replaceable. The selected +engine is an implementation detail unless it materially changes safety, +semantics, portability, or recovery. diff --git a/docs/philosophy.md b/docs/philosophy.md new file mode 100644 index 0000000..e37933a --- /dev/null +++ b/docs/philosophy.md @@ -0,0 +1,56 @@ +# Philosophy + +Git Intent exists for Git jobs whose desired outcome fits in a sentence while +their safe implementation takes a page. + +## Express the intent, not the surgery + +Git exposes excellent low-level primitives. Those primitives do not always form +a single safe command for higher-level maintenance tasks. Git Intent names that +outcome, resolves it to concrete objects, previews the scope, creates a recovery +point, performs the bounded mutation, and verifies its promises. + +The caller should need to understand the consequence of an operation, not +reconstruct its internal command sequence. + +## Scope is a safety feature + +An operation belongs in Git Intent when: + +1. its intent is common; +2. its raw Git procedure is non-trivial or risky; and +3. its behavior can be given a clear safety and recovery contract. + +That makes author repair, lost-commit recovery, or bounded history movement +plausible operations. It does not justify wrappers for `git status`, `git add`, +`git log`, or other ordinary Git commands. + +## Recovery is product surface + +Mistakes and changed minds are normal operational conditions. Recovery should +be visible, inspectable, and built into mutating workflows rather than hidden in +a warning or left to reflog expertise. + +## Agent-native, not agent-dependent + +Every capability should be useful from a human terminal. A narrow Agent Skill +helps an agent recognize the intent, gather the required inputs, preview the +operation, and invoke the reviewed executable. It should not teach the agent to +improvise equivalent history surgery. + +The agent chooses the operation. The operation owns the surgery. + +## Stand on mature abstractions + +We prefer climbing on mature abstractions to boiling the same water again. + +Dependencies are judged by correctness, maintenance, portability, and +safety—not by whether we could reproduce their behavior ourselves. When native +Git is the right engine, use it. When another maintained tool provides stronger +semantics, integrate it transparently. + +## Let real operations shape the project + +Git Intent is deliberately not beginning with a framework or an invented +catalogue of commands. The first operation establishes the contract. Later +operations should reveal which abstractions are genuinely shared. From a32de813b0211615a42719fe46b8db1473a845cd Mon Sep 17 00:00:00 2001 From: Yonathan Morales Date: Tue, 1 Sep 2026 02:08:32 -0400 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=F0=9F=91=BB=20Introduce=20generic?= =?UTF-8?q?=20validation=20and=20test=20runner=20scripts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/test | 5 +++++ scripts/validate-skills | 50 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) create mode 100755 scripts/test create mode 100755 scripts/validate-skills diff --git a/scripts/test b/scripts/test new file mode 100755 index 0000000..855f089 --- /dev/null +++ b/scripts/test @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root=$(git rev-parse --show-toplevel) +exec "$repository_root/tests/git-fix-author/test.sh" diff --git a/scripts/validate-skills b/scripts/validate-skills new file mode 100755 index 0000000..9d29223 --- /dev/null +++ b/scripts/validate-skills @@ -0,0 +1,50 @@ +#!/usr/bin/env bash +set -euo pipefail + +repository_root=$(git rev-parse --show-toplevel) +skills_root=$repository_root/skills +status=0 +count=0 + +while IFS= read -r skill_file; do + count=$((count + 1)) + skill_dir=${skill_file%/SKILL.md} + folder_name=${skill_dir##*/} + + first_line=$(sed -n '1p' "$skill_file") + closing_line=$(awk 'NR > 1 && $0 == "---" { print NR; exit }' "$skill_file") + declared_name=$(sed -n 's/^name:[[:space:]]*//p' "$skill_file" | head -1) + description=$(sed -n 's/^description:[[:space:]]*//p' "$skill_file" | head -1) + + if [[ $first_line != '---' || -z $closing_line ]]; then + printf 'error: %s has invalid YAML frontmatter boundaries\n' "$skill_file" >&2 + status=1 + fi + if [[ $declared_name != "$folder_name" ]]; then + printf 'error: %s declares name %s; expected %s\n' \ + "$skill_file" "$declared_name" "$folder_name" >&2 + status=1 + fi + if [[ -z $description ]]; then + printf 'error: %s has no single-line description\n' "$skill_file" >&2 + status=1 + fi + if grep -Eq 'TODO|FIXME|\[PLACEHOLDER\]' "$skill_file"; then + printf 'error: %s contains unfinished scaffold text\n' "$skill_file" >&2 + status=1 + fi + if [[ ! -x $skill_dir/scripts/git-${folder_name#git-} ]]; then + printf 'error: %s has no executable canonical operation\n' "$skill_dir" >&2 + status=1 + fi +done < <(find "$skills_root" -mindepth 2 -maxdepth 2 -name SKILL.md -print | sort) + +((count > 0)) || { + printf 'error: no skills found under %s\n' "$skills_root" >&2 + exit 1 +} + +if [[ $status -eq 0 ]]; then + printf 'Validated %s skill(s).\n' "$count" +fi +exit "$status" From 655aea91f78de6719d68096921809e65487ead06 Mon Sep 17 00:00:00 2001 From: Yonathan Morales Date: Tue, 1 Sep 2026 02:08:32 -0400 Subject: [PATCH 4/7] =?UTF-8?q?ci:=20=F0=9F=91=BB=20Configure=20GitHub=20A?= =?UTF-8?q?ctions=20workflow=20and=20issue=20templates?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/ISSUE_TEMPLATE/bug.yml | 56 ++++++++++++++++++++++ .github/ISSUE_TEMPLATE/new-operation.yml | 59 ++++++++++++++++++++++++ .github/workflows/ci.yml | 54 ++++++++++++++++++++++ 3 files changed, 169 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug.yml create mode 100644 .github/ISSUE_TEMPLATE/new-operation.yml create mode 100644 .github/workflows/ci.yml diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 0000000..b4726f6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -0,0 +1,56 @@ +name: Bug report +description: Report incorrect, unsafe, or confusing operation behavior. +title: "fix: " +labels: + - bug +body: + - type: markdown + attributes: + value: | + Do not disclose a security-sensitive history-loss, injection, credential, or unintended-publication issue here. Follow SECURITY.md instead. + - type: input + id: operation + attributes: + label: Operation and version + placeholder: git-fix-author at commit ... + validations: + required: true + - type: textarea + id: intent + attributes: + label: Intended outcome + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Minimal reproduction + description: Use a disposable repository and remove sensitive data. + validations: + required: true + - type: textarea + id: observed + attributes: + label: Observed behavior + validations: + required: true + - type: textarea + id: safety + attributes: + label: Repository and recovery state + description: Was anything mutated, and was a recovery ref created? + validations: + required: true + - type: input + id: git-version + attributes: + label: Git version + placeholder: git version 2.x.y + validations: + required: true + - type: input + id: platform + attributes: + label: Operating system and shell + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/new-operation.yml b/.github/ISSUE_TEMPLATE/new-operation.yml new file mode 100644 index 0000000..f2257bb --- /dev/null +++ b/.github/ISSUE_TEMPLATE/new-operation.yml @@ -0,0 +1,59 @@ +name: Propose an operation +description: Propose a reusable Git intent with explicit safety and recovery semantics. +title: "feat(operation): " +labels: + - enhancement +body: + - type: markdown + attributes: + value: | + An operation belongs in Git Intent when its intent is common, its raw Git procedure is non-trivial or risky, and its behavior can have a clear safety and recovery contract. + - type: textarea + id: intent + attributes: + label: What is the Git intent? + description: Describe the desired outcome without specifying commands. + validations: + required: true + - type: textarea + id: risk + attributes: + label: Why is native execution error-prone? + description: Explain what makes humans or agents likely to get this wrong. + validations: + required: true + - type: textarea + id: existing + attributes: + label: Existing solutions + description: Which Git commands, projects, or libraries already solve some or all of it? + validations: + required: true + - type: textarea + id: safety + attributes: + label: Proposed safety contract + description: What must never change, and what preconditions should be enforced? + validations: + required: true + - type: textarea + id: recovery + attributes: + label: Recovery model + description: How can the operation be inspected, reversed, or recovered? + validations: + required: true + - type: textarea + id: verification + attributes: + label: Verification invariants + description: How can the operation prove that only the requested change occurred? + validations: + required: true + - type: textarea + id: fit + attributes: + label: Why Git Intent? + description: Why should this be an executable operation rather than documentation or an ordinary Git command? + validations: + required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0931796 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,54 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + test: + name: Integration tests (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: + - ubuntu-latest + - macos-latest + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v7 + - run: scripts/validate-skills + - run: scripts/test + + lint: + name: Shell lint and format + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version: stable + cache: false + - name: Install linters + run: | + sudo apt-get update + sudo apt-get install --yes shellcheck + go install mvdan.cc/sh/v3/cmd/shfmt@v3.14.0 + - name: ShellCheck + run: | + shellcheck \ + scripts/test \ + scripts/validate-skills \ + skills/git-fix-author/scripts/git-fix-author \ + tests/git-fix-author/test.sh + - name: shfmt + run: | + "$(go env GOPATH)/bin/shfmt" -d -i 2 -ci \ + scripts/test \ + scripts/validate-skills \ + skills/git-fix-author/scripts/git-fix-author \ + tests/git-fix-author/test.sh From 46846a08f4bf64200ea75519a029b324c8fbceb3 Mon Sep 17 00:00:00 2001 From: Yonathan Morales Date: Tue, 1 Sep 2026 02:08:32 -0400 Subject: [PATCH 5/7] =?UTF-8?q?feat(git-fix-author):=20=F0=9F=91=BB=20Defi?= =?UTF-8?q?ne=20the=20first=20reference=20operation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/git-fix-author/README.md | 125 ++++++++++++++++++ skills/git-fix-author/SKILL.md | 42 ++++++ .../references/git-semantics.md | 49 +++++++ 3 files changed, 216 insertions(+) create mode 100644 skills/git-fix-author/README.md create mode 100644 skills/git-fix-author/SKILL.md create mode 100644 skills/git-fix-author/references/git-semantics.md diff --git a/skills/git-fix-author/README.md b/skills/git-fix-author/README.md new file mode 100644 index 0000000..7f6cbac --- /dev/null +++ b/skills/git-fix-author/README.md @@ -0,0 +1,125 @@ +# `git fix-author` + +Safely repair author and committer attribution across an inclusive commit range. + +## Install + +Copy the canonical executable to a directory on `PATH`: + +```console +install -m 0755 \ + skills/git-fix-author/scripts/git-fix-author \ + "$HOME/.local/bin/git-fix-author" +``` + +Git exposes `git-fix-author` as `git fix-author`. + +## Preview, then execute + +```console +git fix-author \ + --start fix/author \ + --finish feature/foo \ + --name "AIPAL" \ + --email "aipal@uklok.ai" \ + --dry-run + +git fix-author \ + --start fix/author \ + --finish feature/foo \ + --name "AIPAL" \ + --email "aipal@uklok.ai" +``` + +The finish value must be a local branch and defaults to the current branch. +Missing values are prompted for only when both stdin and stdout are terminals, +or when `--interactive` is explicit. + +For reviewed automation: + +```console +git fix-author \ + --start fix/author \ + --finish feature/foo \ + --name "AIPAL" \ + --email "aipal@uklok.ai" \ + --non-interactive \ + --yes +``` + +All primary values also have `GFA_*` environment equivalents documented by +`--help`. + +## Identity semantics + +By default, both the author and committer become the requested author. This +prevents `user.name` or `user.email` from the executing environment from +appearing in rewritten history. + +Set a separate committer only as an explicit pair: + +```console +git fix-author \ + --start fix/author \ + --name "Tony Stark" \ + --email "tony@stak.com" \ + --committer-name "Jarvis" \ + --committer-email "jarvis@stark.ai" +``` + +Neither repository-local nor global Git identity configuration is modified. +Commit-message `Co-authored-by:` trailers are unrelated and remain unchanged. + +## Preserved and changed data + +The operation reconstructs the resolved commit graph and verifies: + +- identical file-tree IDs; +- identical commit messages; +- identical author and committer timestamps; +- identical parent topology after mapping rewritten parents; and +- the requested author and committer identities. + +Empty and merge commits are preserved. Commit IDs necessarily change. Any +cryptographic commit signatures in the rewritten range are removed because +their signed objects no longer exist. + +The start commit itself must not be a merge, because its first-parent boundary +would otherwise be ambiguous. Merge commits later in the range are supported. + +## Local refs + +By default, local branch tips that point to rewritten commits move with the +rewritten graph. The preview lists every affected ref. Use `--no-update-refs` to +move only the finish branch. + +Tags and remote-tracking refs are never changed. + +## Recovery + +Before any reachable ref moves, the operation creates a ref like: + +```text +refs/backup/author-rewrite-20260901-014500 +``` + +Inspect recovery refs with: + +```console +git for-each-ref \ + --format='%(refname) -> %(objectname:short)' \ + refs/backup/ +``` + +`--no-backup` is available for callers that explicitly accept losing this +checkpoint. Dry runs never create one. + +The operation changes local refs only. Review the result before publishing. A +published branch normally requires: + +```console +git push --force-with-lease origin feature/foo +``` + +Backup listing, verification, restoration, and pruning are intentionally a +follow-up operation rather than hidden behavior in this rewrite path. diff --git a/skills/git-fix-author/SKILL.md b/skills/git-fix-author/SKILL.md new file mode 100644 index 0000000..c4743dc --- /dev/null +++ b/skills/git-fix-author/SKILL.md @@ -0,0 +1,42 @@ +--- +name: git-fix-author +description: Safely repair Git commit author or committer attribution across an existing history range. Use when commits have the wrong author name or email, a bot or operator identity was recorded incorrectly, or multiple existing commits need attribution corrected. Do not use for message edits, squashing, splitting, secret removal, or new commits. +--- + +# Git Fix Author + +Use the bundled `scripts/git-fix-author` executable for a bounded attribution +repair. Do not improvise an equivalent rebase while this operation satisfies the +request. + +## Workflow + +1. Confirm that rewriting published history is acceptable and identify the + inclusive start commit and local finish 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 + author and, only when explicitly requested, separate committer values. +4. Review the resolved objects, commit count, identities, and local refs that + will move. +5. Obtain authorization immediately before mutation. For deterministic + automation, add `--non-interactive --yes` only after reviewing the preview. +6. Report the new tip and recovery ref. Keep remote publication separate; if + needed, recommend `git push --force-with-lease`, never silently push. + +By default the committer identity matches the requested author so the executing +machine's configured identity cannot leak into rewritten commits. Use +`--committer-name` and `--committer-email` only when a distinct committer was +requested. + +Changing commit metadata necessarily changes commit IDs and therefore the IDs +of descendants. Signed commits in the range lose their invalidated signatures. + +Read [references/git-semantics.md](references/git-semantics.md) when explaining +object identity, range/topology behavior, local ref updates, signatures, or +recovery. Human installation and complete CLI examples are in +[README.md](README.md). + +Do not use this operation to change commit messages or contents, rewrite +trailers, reorder/squash/split commits, remove secrets, or repair ordinary new +commits. diff --git a/skills/git-fix-author/references/git-semantics.md b/skills/git-fix-author/references/git-semantics.md new file mode 100644 index 0000000..75ac0fa --- /dev/null +++ b/skills/git-fix-author/references/git-semantics.md @@ -0,0 +1,49 @@ +# Git semantics for attribution repair + +Read this reference when evaluating or explaining edge cases in +`git-fix-author`. + +## Why IDs change + +A commit ID hashes the commit's tree, parents, author, committer, timestamps, +message, and other headers. Changing an identity creates a different object. +Descendants then change because they name the replaced parent ID. + +This is object replacement, not evidence that file content changed. + +## Inclusive range + +For a non-root start, the boundary is the start commit's sole parent. Every +commit reachable from the finish branch but not from that parent is rebuilt. +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. + +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. + +## Metadata + +The operation changes author and committer names/emails. It preserves author and +committer timestamps, file-tree IDs, messages, empty commits, and mapped parent +topology. + +Commit signatures authenticate the exact original object. They cannot survive +an identity change and are intentionally not copied. Message trailers such as +`Co-authored-by:` are message content and remain untouched. + +## Ref scope + +The finish local branch always moves. With the default `--update-refs`, other +local branches whose tips are reconstructed move atomically in the same ref +transaction. Tags, custom refs, and remote-tracking refs do not move. + +Before that transaction, the original finish tip is stored under +`refs/backup/author-rewrite-*` unless `--no-backup` was explicit. A dry run does +not write any object or ref reachable from the repository. + +## Publication + +Local rewriting and remote publication are separate. A remote branch update may +require `--force-with-lease`; the operation prints guidance but never pushes. From 235b4cac86574f101b2f71043ed777025a6319c4 Mon Sep 17 00:00:00 2001 From: Yonathan Morales Date: Tue, 1 Sep 2026 02:08:32 -0400 Subject: [PATCH 6/7] =?UTF-8?q?feat(git-fix-author):=20=F0=9F=91=BB=20Impl?= =?UTF-8?q?ement=20and=20test=20the=20attribution=20repair=20operation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- skills/git-fix-author/scripts/git-fix-author | 462 +++++++++++++++++++ tests/git-fix-author/test.sh | 355 ++++++++++++++ 2 files changed, 817 insertions(+) create mode 100755 skills/git-fix-author/scripts/git-fix-author create mode 100755 tests/git-fix-author/test.sh diff --git a/skills/git-fix-author/scripts/git-fix-author b/skills/git-fix-author/scripts/git-fix-author new file mode 100755 index 0000000..426f9bb --- /dev/null +++ b/skills/git-fix-author/scripts/git-fix-author @@ -0,0 +1,462 @@ +#!/usr/bin/env bash +set -euo pipefail + +program=${0##*/} +mode=${GFA_MODE:-auto} +start=${GFA_START:-} +finish=${GFA_FINISH:-} +author_name=${GFA_NAME:-} +author_email=${GFA_EMAIL:-} +committer_name=${GFA_COMMITTER_NAME:-} +committer_email=${GFA_COMMITTER_EMAIL:-} +assume_yes=${GFA_YES:-0} +dry_run=0 +make_backup=1 +update_refs=1 + +usage() { + cat <&2 + exit 2 +} + +fail() { + printf 'error: %s\n' "$*" >&2 + exit 1 +} + +lookup_new_oid() { + awk -v oid="$1" '$1 == oid { print $2; exit }' "$map_file" +} + +contains_old_oid() { + grep -Fqx "$1" "$commit_file" +} + +extract_commit_message() { + git cat-file commit "$1" | + { + while IFS= read -r header_line; do + [[ -n $header_line ]] || break + done + cat + } >"$2" +} + +while (($#)); do + case $1 in + -s | --start) + [[ $# -ge 2 ]] || die "$1 requires a value" + start=$2 + shift 2 + ;; + -f | --finish) + [[ $# -ge 2 ]] || die "$1 requires a value" + finish=$2 + shift 2 + ;; + -n | --name) + [[ $# -ge 2 ]] || die "$1 requires a value" + author_name=$2 + shift 2 + ;; + -e | --email) + [[ $# -ge 2 ]] || die "$1 requires a value" + author_email=$2 + shift 2 + ;; + --committer-name) + [[ $# -ge 2 ]] || die "$1 requires a value" + committer_name=$2 + shift 2 + ;; + --committer-email) + [[ $# -ge 2 ]] || die "$1 requires a value" + committer_email=$2 + shift 2 + ;; + -i | --interactive) + mode=interactive + shift + ;; + --non-interactive) + mode=non-interactive + shift + ;; + -y | --yes) + assume_yes=1 + shift + ;; + --dry-run) + dry_run=1 + shift + ;; + --no-backup) + make_backup=0 + shift + ;; + --update-refs) + update_refs=1 + shift + ;; + --no-update-refs) + update_refs=0 + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) + die "unknown option: $1" + ;; + esac +done + +git rev-parse --git-dir >/dev/null 2>&1 || die "not inside a Git repository" + +case $mode in + interactive) + prompt=1 + ;; + non-interactive) + prompt=0 + ;; + auto) + if [[ -t 0 && -t 1 ]]; then + prompt=1 + else + prompt=0 + fi + ;; + *) + die "GFA_MODE must be auto, interactive, or non-interactive" + ;; +esac + +current_branch=$(git symbolic-ref --quiet --short HEAD || true) +[[ -n $finish ]] || finish=${current_branch:-HEAD} + +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 $author_name ]] || die "missing --name (or GFA_NAME)" +[[ -n $author_email ]] || die "missing --email (or GFA_EMAIL)" + +case $author_name$author_email$committer_name$committer_email in + *$'\n'* | *$'\r'*) die "identity values must not contain newlines" ;; +esac + +case $author_email in + *'<'* | *'>'*) die "author email must not contain angle brackets" ;; +esac + +if [[ -n $committer_name || -n $committer_email ]]; then + [[ -n $committer_name ]] || die "--committer-name is required with --committer-email" + [[ -n $committer_email ]] || die "--committer-email is required with --committer-name" +else + committer_name=$author_name + committer_email=$author_email +fi + +case $committer_email in + *'<'* | *'>'*) die "committer email must not contain angle brackets" ;; +esac + +case $finish in + HEAD) + [[ -n $current_branch ]] || die "HEAD is detached; pass --finish with a local branch" + finish_branch=$current_branch + ;; + refs/heads/*) + finish_branch=${finish#refs/heads/} + ;; + *) + finish_branch=$finish + ;; +esac + +finish_ref=refs/heads/$finish_branch +git show-ref --verify --quiet "$finish_ref" || die "--finish must name a local branch: $finish" + +start_oid=$(git rev-parse --verify "$start^{commit}" 2>/dev/null) || die "invalid start ref: $start" +finish_oid=$(git rev-parse --verify "$finish_ref^{commit}" 2>/dev/null) || die "invalid finish: $finish" + +git merge-base --is-ancestor "$start_oid" "$finish_oid" || + die "$start is not an ancestor of $finish_branch" + +parents_text=$(git show -s --format=%P "$start_oid") +parents=() +if [[ -n $parents_text ]]; then + read -r -a parents <<<"$parents_text" +fi +((${#parents[@]} <= 1)) || die "start commit is a merge; choose a non-merge start" + +[[ -z $(git status --porcelain) ]] || + die "working tree/index is dirty; commit or stash changes first" + +printf 'start\nabort\n' | git update-ref --stdin >/dev/null 2>&1 || + die "installed Git does not support transactional ref updates" + +temporary_root=${TMPDIR:-/tmp} +temporary_dir=$(mktemp -d "$temporary_root/git-fix-author.XXXXXX") || + fail "could not create temporary directory" +trap 'rm -rf "$temporary_dir"' EXIT HUP INT TERM + +commit_file=$temporary_dir/commits +map_file=$temporary_dir/map +ref_file=$temporary_dir/refs +transaction_file=$temporary_dir/transaction +: >"$map_file" + +if ((${#parents[@]} == 0)); then + git rev-list --reverse --topo-order "$finish_oid" >"$commit_file" +else + base_oid=${parents[0]} + git rev-list --reverse --topo-order "$base_oid..$finish_oid" >"$commit_file" +fi + +grep -Fqx "$start_oid" "$commit_file" || fail "resolved range does not contain the start commit" +commit_count=$(wc -l <"$commit_file" | tr -d ' ') +((commit_count > 0)) || die "no commits found in requested range" + +{ + printf '%s\t%s\n' "$finish_ref" "$finish_oid" + if [[ $update_refs -eq 1 ]]; then + git for-each-ref --format='%(refname)%09%(objectname)' refs/heads/ | + while IFS=$'\t' read -r ref oid; do + if contains_old_oid "$oid"; then + printf '%s\t%s\n' "$ref" "$oid" + fi + done + fi +} | sort -u >"$ref_file" + +printf '\nAuthor rewrite preview\n' +printf '%s\n' '----------------------' +printf 'Start: %s (%s)\n' "$start" "$(git rev-parse --short "$start_oid")" +printf 'Finish: %s (%s)\n' "$finish_branch" "$(git rev-parse --short "$finish_oid")" +printf 'Commits: %s\n' "$commit_count" +printf 'Author: %s <%s>\n' "$author_name" "$author_email" +printf 'Committer: %s <%s>\n' "$committer_name" "$committer_email" +printf 'Checkpoint: %s\n' "$([[ $make_backup -eq 1 ]] && printf 'enabled' || printf 'disabled')" +printf 'Local refs: %s\n\n' "$(wc -l <"$ref_file" | tr -d ' ')" + +while IFS= read -r preview_oid; do + git --no-pager show -s --format=' %h %an <%ae> %s' "$preview_oid" +done <"$commit_file" + +printf '\nRefs that will move:\n' +while IFS=$'\t' read -r ref oid; do + printf ' %s (%s)\n' "$ref" "$(git rev-parse --short "$oid")" +done <"$ref_file" +printf '\n' + +if [[ $dry_run -eq 1 ]]; then + printf 'Dry run only; nothing changed.\n' + exit 0 +fi + +if [[ $assume_yes != 1 ]]; then + [[ $prompt -eq 1 ]] || die "confirmation required; pass --yes in non-interactive mode" + read -r -p 'Rewrite these commits? [y/N] ' reply + case $reply in + y | Y | yes | YES) ;; + *) + printf 'Cancelled.\n' + exit 0 + ;; + esac +fi + +printf 'Reconstructing commits...\n' +while IFS= read -r old_oid; do + tree_oid=$(git show -s --format=%T "$old_oid") + old_parents=$(git show -s --format=%P "$old_oid") + parent_args=() + if [[ -n $old_parents ]]; then + read -r -a parent_oids <<<"$old_parents" + for parent_oid in "${parent_oids[@]}"; do + new_parent=$(lookup_new_oid "$parent_oid") + parent_args+=("-p" "${new_parent:-$parent_oid}") + done + fi + + message_file=$temporary_dir/message + extract_commit_message "$old_oid" "$message_file" + author_date=$(git show -s --format=%aI "$old_oid") + committer_date=$(git show -s --format=%cI "$old_oid") + + if ((${#parent_args[@]} == 0)); then + new_oid=$( + GIT_AUTHOR_NAME=$author_name \ + GIT_AUTHOR_EMAIL=$author_email \ + GIT_AUTHOR_DATE=$author_date \ + GIT_COMMITTER_NAME=$committer_name \ + GIT_COMMITTER_EMAIL=$committer_email \ + GIT_COMMITTER_DATE=$committer_date \ + git -c commit.gpgSign=false commit-tree "$tree_oid" -F "$message_file" + ) || fail "could not reconstruct commit $old_oid" + else + new_oid=$( + GIT_AUTHOR_NAME=$author_name \ + GIT_AUTHOR_EMAIL=$author_email \ + GIT_AUTHOR_DATE=$author_date \ + GIT_COMMITTER_NAME=$committer_name \ + GIT_COMMITTER_EMAIL=$committer_email \ + GIT_COMMITTER_DATE=$committer_date \ + git -c commit.gpgSign=false commit-tree "$tree_oid" \ + "${parent_args[@]}" -F "$message_file" + ) || fail "could not reconstruct commit $old_oid" + fi + + printf '%s %s\n' "$old_oid" "$new_oid" >>"$map_file" +done <"$commit_file" + +new_finish_oid=$(lookup_new_oid "$finish_oid") +[[ -n $new_finish_oid ]] || fail "could not resolve the reconstructed finish commit" + +backup_ref= +if [[ $make_backup -eq 1 ]]; then + backup_base=refs/backup/author-rewrite-$(date +%Y%m%d-%H%M%S) + backup_ref=$backup_base + suffix=0 + while git show-ref --verify --quiet "$backup_ref"; do + suffix=$((suffix + 1)) + backup_ref=$backup_base-$suffix + done + git update-ref "$backup_ref" "$finish_oid" || fail "could not create recovery ref" + printf 'Checkpoint: %s -> %s\n' "$backup_ref" "$(git rev-parse --short "$finish_oid")" +fi + +{ + printf 'start\n' + while IFS=$'\t' read -r ref old_ref_oid; do + new_ref_oid=$(lookup_new_oid "$old_ref_oid") + [[ -n $new_ref_oid ]] || fail "no reconstructed object for $ref" + printf 'update %s %s %s\n' "$ref" "$new_ref_oid" "$old_ref_oid" + done <"$ref_file" + printf 'prepare\ncommit\n' +} >"$transaction_file" + +if ! git update-ref --stdin <"$transaction_file"; then + if [[ -n $backup_ref ]]; then + fail "ref update failed; original history remains at $backup_ref" + fi + fail "ref update failed before verification" +fi + +printf 'Verifying invariants...\n' +verification_failed=0 +verified_count=0 +while read -r old_oid new_oid; do + old_tree=$(git show -s --format=%T "$old_oid") + new_tree=$(git show -s --format=%T "$new_oid") + [[ $old_tree == "$new_tree" ]] || verification_failed=1 + + extract_commit_message "$old_oid" "$temporary_dir/old-message" + extract_commit_message "$new_oid" "$temporary_dir/new-message" + cmp -s "$temporary_dir/old-message" "$temporary_dir/new-message" || verification_failed=1 + + [[ $(git show -s --format=%aI "$old_oid") == "$(git show -s --format=%aI "$new_oid")" ]] || + verification_failed=1 + [[ $(git show -s --format=%cI "$old_oid") == "$(git show -s --format=%cI "$new_oid")" ]] || + verification_failed=1 + [[ $(git show -s --format=%an "$new_oid") == "$author_name" ]] || verification_failed=1 + [[ $(git show -s --format=%ae "$new_oid") == "$author_email" ]] || verification_failed=1 + [[ $(git show -s --format=%cn "$new_oid") == "$committer_name" ]] || verification_failed=1 + [[ $(git show -s --format=%ce "$new_oid") == "$committer_email" ]] || verification_failed=1 + + expected_parents= + old_parents=$(git show -s --format=%P "$old_oid") + if [[ -n $old_parents ]]; then + read -r -a parent_oids <<<"$old_parents" + for parent_oid in "${parent_oids[@]}"; do + new_parent=$(lookup_new_oid "$parent_oid") + expected_parents=${expected_parents:+$expected_parents }${new_parent:-$parent_oid} + done + fi + [[ $(git show -s --format=%P "$new_oid") == "$expected_parents" ]] || verification_failed=1 + verified_count=$((verified_count + 1)) +done <"$map_file" + +[[ $verified_count -eq $commit_count ]] || verification_failed=1 +[[ $(git rev-parse "$finish_ref") == "$new_finish_oid" ]] || verification_failed=1 +if [[ -n $backup_ref ]]; then + [[ $(git rev-parse "$backup_ref") == "$finish_oid" ]] || verification_failed=1 +fi + +if [[ $verification_failed -ne 0 ]]; then + if [[ -n $backup_ref ]]; then + fail "verification failed; original history remains at $backup_ref" + fi + fail "verification failed and --no-backup was requested" +fi + +printf '\nDone.\n' +printf '[ok] %s commits rewritten\n' "$commit_count" +printf '[ok] trees, messages, timestamps, and topology preserved\n' +printf '[ok] requested author and committer identities applied\n' +printf 'New tip: %s (%s)\n' "$finish_branch" "$(git rev-parse --short "$new_finish_oid")" +if [[ -n $backup_ref ]]; then + printf 'Original history: %s\n' "$backup_ref" +fi +printf '\nThis operation changed local refs only.\n' +printf 'Inspect before publishing:\n' +printf " git --no-pager log --format='%%h %%an <%%ae> %%cn <%%ce> %%s'\n" +printf 'If the finish branch was already published:\n' +printf ' git push --force-with-lease origin %s\n' "$finish_branch" diff --git a/tests/git-fix-author/test.sh b/tests/git-fix-author/test.sh new file mode 100755 index 0000000..fbf81c3 --- /dev/null +++ b/tests/git-fix-author/test.sh @@ -0,0 +1,355 @@ +#!/usr/bin/env bash +set -euo pipefail + +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") +trap 'rm -rf "$test_root"' EXIT HUP INT TERM + +passed=0 +failed=0 +test_index=0 + +pass() { + passed=$((passed + 1)) + printf 'ok %s - %s\n' "$test_index" "$1" +} + +fail_test() { + failed=$((failed + 1)) + printf 'not ok %s - %s\n' "$test_index" "$1" >&2 +} + +assert_eq() { + actual=$1 + expected=$2 + message=$3 + if [[ $actual != "$expected" ]]; then + printf 'assertion failed: %s\nexpected: %s\nactual: %s\n' \ + "$message" "$expected" "$actual" >&2 + return 1 + fi +} + +assert_contains() { + value=$1 + expected=$2 + message=$3 + if [[ $value != *"$expected"* ]]; then + printf 'assertion failed: %s\nmissing: %s\noutput:\n%s\n' \ + "$message" "$expected" "$value" >&2 + return 1 + fi +} + +new_repo() { + repo=$1 + mkdir -p "$repo" + git -C "$repo" init -q -b main 2>/dev/null || { + git -C "$repo" init -q + git -C "$repo" checkout -q -b main + } + git -C "$repo" config user.name 'Configured User' + git -C "$repo" config user.email configured@example.com + git -C "$repo" config commit.gpgSign false +} + +commit_file() { + repo=$1 + path=$2 + content=$3 + message=$4 + timestamp=$5 + printf '%s\n' "$content" >"$repo/$path" + git -C "$repo" add "$path" + GIT_AUTHOR_DATE=$timestamp GIT_COMMITTER_DATE=$timestamp \ + git -C "$repo" commit -q -m "$message" +} + +backup_count() { + git for-each-ref --format='%(refname)' refs/backup/ | wc -l | tr -d ' ' +} + +extract_commit_message() { + git -C "$1" cat-file commit "$2" | + { + while IFS= read -r header_line; do + [[ -n $header_line ]] || break + done + cat + } +} + +run_test() { + name=$1 + shift + test_index=$((test_index + 1)) + set +e + ( + set -e + "$@" + ) + test_status=$? + set -e + if [[ $test_status -eq 0 ]]; then + pass "$name" + else + fail_test "$name" + fi +} + +test_help() { + output=$($tool --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' +} + +test_linear_rewrite_and_backup() { + repo=$test_root/linear + new_repo "$repo" + commit_file "$repo" story.txt one 'root message' '2025-01-01T10:00:00-04:00' + start_oid=$(git -C "$repo" rev-parse HEAD) + commit_file "$repo" story.txt two $'subject\n\nbody' '2025-02-02T11:30:00-04:00' + GIT_AUTHOR_DATE='2025-03-03T12:45:00-04:00' \ + GIT_COMMITTER_DATE='2025-03-03T12:45:00-04:00' \ + git -C "$repo" commit -q --allow-empty -m 'empty marker' + old_tip=$(git -C "$repo" rev-parse HEAD) + old_trees=$(git -C "$repo" log --reverse --format=%T "$old_tip") + old_messages=$(git -C "$repo" log --reverse --format='---%n%B' "$old_tip") + old_author_dates=$(git -C "$repo" log --reverse --format=%aI "$old_tip") + old_committer_dates=$(git -C "$repo" log --reverse --format=%cI "$old_tip") + configured_name=$(git -C "$repo" config user.name) + configured_email=$(git -C "$repo" config user.email) + isolated_home=$test_root/linear-home + mkdir -p "$isolated_home" + HOME=$isolated_home git config --global user.name 'Global User' + HOME=$isolated_home git config --global user.email global@example.com + + output=$(cd "$repo" && HOME=$isolated_home "$tool" --start "$start_oid" --name 'Correct Author' \ + --email correct@example.com --non-interactive --yes) + + new_tip=$(git -C "$repo" rev-parse HEAD) + [[ $new_tip != "$old_tip" ]] + assert_eq "$(git -C "$repo" log --reverse --format=%T HEAD)" "$old_trees" 'trees survive' + assert_eq "$(git -C "$repo" log --reverse --format='---%n%B' HEAD)" "$old_messages" 'messages survive' + assert_eq "$(git -C "$repo" log --reverse --format=%aI HEAD)" "$old_author_dates" 'author dates survive' + assert_eq "$(git -C "$repo" log --reverse --format=%cI HEAD)" "$old_committer_dates" 'committer dates survive' + assert_eq "$(git -C "$repo" log --format='%an <%ae>|%cn <%ce>' | sort -u)" \ + 'Correct Author |Correct Author ' 'identities applied' + assert_eq "$(git -C "$repo" config user.name)" "$configured_name" 'configured name unchanged' + assert_eq "$(git -C "$repo" config user.email)" "$configured_email" 'configured email unchanged' + assert_eq "$(HOME=$isolated_home git config --global user.name)" 'Global User' 'global name unchanged' + assert_eq "$(HOME=$isolated_home git config --global user.email)" \ + 'global@example.com' 'global email unchanged' + assert_eq "$(cd "$repo" && backup_count)" '1' 'one backup exists' + backup_ref=$(git -C "$repo" for-each-ref --format='%(refname)' refs/backup/) + assert_eq "$(git -C "$repo" rev-parse "$backup_ref")" "$old_tip" 'backup reaches old tip' + assert_contains "$output" '[ok] 3 commits rewritten' 'verification summary reported' +} + +test_dry_run_is_pure() { + repo=$test_root/dry-run + new_repo "$repo" + commit_file "$repo" file.txt one root '2025-01-01T10:00:00Z' + start_oid=$(git -C "$repo" rev-parse HEAD) + commit_file "$repo" file.txt two second '2025-01-02T10:00:00Z' + old_tip=$(git -C "$repo" rev-parse HEAD) + old_objects=$(git -C "$repo" count-objects -v) + + output=$(cd "$repo" && "$tool" --start "$start_oid" --name Preview \ + --email preview@example.com --dry-run --non-interactive) + + assert_eq "$(git -C "$repo" rev-parse HEAD)" "$old_tip" 'dry run keeps tip' + assert_eq "$(cd "$repo" && backup_count)" '0' 'dry run creates no backup' + assert_eq "$(git -C "$repo" count-objects -v)" "$old_objects" 'dry run writes no objects' + assert_contains "$output" 'Dry run only; nothing changed.' 'dry run result is explicit' +} + +test_raw_message_bytes_survive() { + repo=$test_root/raw-message + new_repo "$repo" + tree_oid=$(git -C "$repo" mktree "$test_root/old-raw-message" + + (cd "$repo" && "$tool" --start "$old_oid" --name Author \ + --email author@example.com --non-interactive --yes >/dev/null) + + new_oid=$(git -C "$repo" rev-parse main) + extract_commit_message "$repo" "$new_oid" >"$test_root/new-raw-message" + cmp -s "$test_root/old-raw-message" "$test_root/new-raw-message" + assert_eq "$(wc -c <"$test_root/new-raw-message" | tr -d ' ')" '29' \ + 'message byte length is exact' +} + +test_explicit_committer_and_no_backup() { + repo=$test_root/committer + new_repo "$repo" + commit_file "$repo" file.txt one root '2025-01-01T10:00:00Z' + start_oid=$(git -C "$repo" rev-parse HEAD) + + (cd "$repo" && "$tool" --start "$start_oid" --name Author \ + --email author@example.com --committer-name Operator \ + --committer-email operator@example.com --no-backup \ + --non-interactive --yes >/dev/null) + + assert_eq "$(git -C "$repo" show -s --format='%an <%ae>')" \ + 'Author ' 'explicit author applied' + assert_eq "$(git -C "$repo" show -s --format='%cn <%ce>')" \ + 'Operator ' 'explicit committer applied' + assert_eq "$(cd "$repo" && backup_count)" '0' 'no-backup is honored' +} + +test_merge_topology_and_update_refs() { + repo=$test_root/merge + new_repo "$repo" + commit_file "$repo" base.txt base base '2025-01-01T10:00:00Z' + git -C "$repo" checkout -q -b side + commit_file "$repo" side.txt side side '2025-01-02T10:00:00Z' + side_tip=$(git -C "$repo" rev-parse HEAD) + git -C "$repo" checkout -q main + commit_file "$repo" main.txt main start '2025-01-03T10:00:00Z' + start_oid=$(git -C "$repo" rev-parse HEAD) + GIT_AUTHOR_DATE='2025-01-04T10:00:00Z' GIT_COMMITTER_DATE='2025-01-04T10:00:00Z' \ + git -C "$repo" merge -q --no-ff side -m merge + old_tip=$(git -C "$repo" rev-parse HEAD) + old_parent_count=$(git -C "$repo" show -s --format=%P HEAD | wc -w | tr -d ' ') + + (cd "$repo" && "$tool" --start "$start_oid" --name Unified \ + --email unified@example.com --non-interactive --yes >/dev/null) + + new_tip=$(git -C "$repo" rev-parse main) + [[ $new_tip != "$old_tip" ]] + assert_eq "$(git -C "$repo" show -s --format=%P main | wc -w | tr -d ' ')" \ + "$old_parent_count" 'merge parent count survives' + [[ $(git -C "$repo" rev-parse side) != "$side_tip" ]] + assert_eq "$(git -C "$repo" show -s --format='%an <%ae>' side)" \ + 'Unified ' 'related local branch moves' + + merge_output=$test_root/merge-start-output + if (cd "$repo" && "$tool" --start main --name Again --email again@example.com \ + --dry-run --non-interactive >"$merge_output" 2>&1); then + return 1 + fi + assert_contains "$(<"$merge_output")" 'start commit is a merge' \ + 'ambiguous merge boundary is rejected' +} + +test_no_update_refs() { + repo=$test_root/no-update-refs + new_repo "$repo" + commit_file "$repo" file.txt one root '2025-01-01T10:00:00Z' + start_oid=$(git -C "$repo" rev-parse HEAD) + git -C "$repo" branch marker + marker_oid=$(git -C "$repo" rev-parse marker) + commit_file "$repo" file.txt two second '2025-01-02T10:00:00Z' + + (cd "$repo" && "$tool" --start "$start_oid" --name Author \ + --email author@example.com --no-update-refs --non-interactive --yes >/dev/null) + + assert_eq "$(git -C "$repo" rev-parse marker)" "$marker_oid" 'marker branch stays put' + [[ $(git -C "$repo" rev-parse main) != "$marker_oid" ]] +} + +test_bounded_range_on_another_branch() { + repo=$test_root/bounded + new_repo "$repo" + commit_file "$repo" file.txt base base '2025-01-01T10:00:00Z' + base_oid=$(git -C "$repo" rev-parse HEAD) + git -C "$repo" checkout -q -b feature + commit_file "$repo" file.txt feature feature '2025-01-02T10:00:00Z' + feature_oid=$(git -C "$repo" rev-parse HEAD) + git -C "$repo" tag retained-tag + git -C "$repo" checkout -q main + main_before=$(git -C "$repo" rev-parse main) + worktree_before=$(git -C "$repo" status --porcelain=v1) + + (cd "$repo" && "$tool" --start "$feature_oid" --finish feature \ + --name Feature --email feature@example.com --non-interactive --yes >/dev/null) + + assert_eq "$(git -C "$repo" symbolic-ref --short HEAD)" 'main' 'current branch does not change' + assert_eq "$(git -C "$repo" rev-parse main)" "$main_before" 'unrelated current branch does not move' + assert_eq "$(git -C "$repo" status --porcelain=v1)" "$worktree_before" 'working tree does not change' + assert_eq "$(git -C "$repo" show -s --format='%an <%ae>' feature)" \ + 'Feature ' 'bounded commit is rewritten' + assert_eq "$(git -C "$repo" show -s --format='%an <%ae>' "$base_oid")" \ + 'Configured User ' 'boundary parent is untouched' + assert_eq "$(git -C "$repo" rev-parse retained-tag)" "$feature_oid" 'tags are untouched' +} + +test_nonancestor_is_rejected() { + repo=$test_root/nonancestor + new_repo "$repo" + commit_file "$repo" file.txt base base '2025-01-01T10:00:00Z' + git -C "$repo" checkout -q -b unrelated + commit_file "$repo" unrelated.txt unrelated unrelated '2025-01-02T10:00:00Z' + unrelated_oid=$(git -C "$repo" rev-parse HEAD) + git -C "$repo" checkout -q main + commit_file "$repo" main.txt main main '2025-01-03T10:00:00Z' + main_before=$(git -C "$repo" rev-parse main) + + output_file=$test_root/nonancestor-output + if (cd "$repo" && "$tool" --start "$unrelated_oid" --finish main \ + --name Author --email author@example.com --dry-run \ + --non-interactive >"$output_file" 2>&1); then + return 1 + fi + output=$(<"$output_file") + assert_contains "$output" 'is not an ancestor' 'ancestry error is specific' + assert_eq "$(git -C "$repo" rev-parse main)" "$main_before" 'rejection keeps finish ref' + assert_eq "$(cd "$repo" && backup_count)" '0' 'rejection creates no backup' +} + +test_rejections() { + repo=$test_root/rejections + new_repo "$repo" + commit_file "$repo" file.txt one root '2025-01-01T10:00:00Z' + start_oid=$(git -C "$repo" rev-parse HEAD) + commit_file "$repo" file.txt two second '2025-01-02T10:00:00Z' + + printf 'dirty\n' >"$repo/untracked.txt" + if (cd "$repo" && "$tool" --start "$start_oid" --name Author \ + --email author@example.com --non-interactive --yes >/dev/null 2>&1); then + return 1 + fi + rm "$repo/untracked.txt" + + if (cd "$repo" && "$tool" --start missing --name Author \ + --email author@example.com --dry-run --non-interactive >/dev/null 2>&1); then + return 1 + fi + + if (cd "$repo" && "$tool" --start "$start_oid" --name Author \ + --email author@example.com --non-interactive >/dev/null 2>&1); then + return 1 + fi + + assert_eq "$(cd "$repo" && backup_count)" '0' 'rejections create no backup' +} + +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 +run_test 'dry run is read-only' test_dry_run_is_pure +run_test 'raw commit-message bytes survive exactly' test_raw_message_bytes_survive +run_test 'explicit committer and no-backup work' test_explicit_committer_and_no_backup +run_test 'merge topology and related local refs survive' test_merge_topology_and_update_refs +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 + +total=$((passed + failed)) +printf '1..%s\n' "$total" +if [[ $failed -ne 0 ]]; then + printf '%s test(s) failed\n' "$failed" >&2 + exit 1 +fi +printf 'All %s tests passed.\n' "$passed" From bbace5dcd8550265a84278b0e3704e48a9524f08 Mon Sep 17 00:00:00 2001 From: Yonathan Morales Date: Tue, 1 Sep 2026 02:08:32 -0400 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20=F0=9F=91=BB=20Update=20root=20READ?= =?UTF-8?q?ME=20with=20project=20overview=20and=20new=20sections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 66 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/README.md b/README.md index f43b86b..216149b 100644 --- a/README.md +++ b/README.md @@ -74,3 +74,69 @@ use them. > **The agent chooses the operation. The operation owns the surgery.** This reduces improvisation without creating an agent-only Git abstraction. + +## Install `git fix-author` + +Install the reference operation somewhere on `PATH`: + +```console +install -m 0755 \ + skills/git-fix-author/scripts/git-fix-author \ + "$HOME/.local/bin/git-fix-author" +``` + +Git discovers executables named `git-`, so the installed program is +available as either `git-fix-author` or `git fix-author`. + +Preview an inclusive rewrite range: + +```console +git fix-author \ + --start fix/author \ + --finish feature/foo \ + --name "AIPAL" \ + --email "aipal@uklok.ai" \ + --dry-run +``` + +Read [the operation guide](skills/git-fix-author/README.md) for behavior, +limitations, recovery, and automation examples. + +## Project documents + +- [Philosophy](docs/philosophy.md) +- [Architecture](docs/architecture.md) +- [Operation contract](docs/operation-contract.md) +- [Contributing](CONTRIBUTING.md) +- [Security policy](SECURITY.md) + +## Develop and verify + +The test suite creates disposable repositories and proves the promised history +invariants: + +```console +scripts/test +scripts/validate-skills +``` + +ShellCheck and shfmt are used in CI when available locally: + +```console +shellcheck scripts/test scripts/validate-skills \ + skills/git-fix-author/scripts/git-fix-author \ + tests/git-fix-author/test.sh + +shfmt -d -i 2 -ci \ + scripts/test scripts/validate-skills \ + skills/git-fix-author/scripts/git-fix-author \ + tests/git-fix-author/test.sh +``` + +## Status + +`git-fix-author` is the first reference implementation of the Git Intent +operation contract. New operations should start from a real, repeatable Git +problem whose intent is simple but whose safe execution requires careful work. + +Git Intent is licensed under the [MIT License](LICENSE).