Skip to main content

Skill: Preview commit plan

Posted on · Updated

Superseded. Use Skill: Prepare commits instead. The skill has a new name, prepare-commits. Its commit script writer can overwrite a script that this old skill wrote.

Folder Structure

preview-commit-plan/
├── SKILL.md
├── reference.md
└── scripts/
    ├── render_plan.py
    └── write_commit_script.py

Content

SKILL.md

SKILL.mdmd
---
name: preview-commit-plan
description: Use when asked to preview, plan, or group working tree or staged changes into commits, split a large diff into reviewable commits, or produce a commit breakdown before committing. Also writes an executable commit script to the repo root.
---

# Preview Commit Plan

Group working tree changes into atomic conventional commits, present
the plan as ASCII tables, and write an executable bash script into the
repo root that makes those commits when the user runs it.

**Never run `git add`, `git commit`, `git reset`, `git stash`, or
`git checkout`.** The user runs the script. The only file you write into
the repo is the commit script. Leave the tree and index exactly as found.

Paths in this document are relative to this skill's directory (the
harness announces its absolute path when the skill loads).

**Never hand-draw the tables or hand-write the script.** Build a plan
JSON and run the bundled tools. The renderer enforces byte-alignment,
ASCII-only output, and the spec's formatting rules. The script writer
quotes every path and message and checks the bash syntax. Both exit
nonzero if anything is off.

## Workflow

1. **Discover** - `git status --porcelain`, `git diff --numstat` and
   `git diff --staged --numstat`, plus `git log --oneline -10` to match the
   repo's existing message style (scopes, types). Honor arguments limiting
   scope (paths, "staged only"). Leave untracked `commit-*.sh` files in
   the repo root out of the plan; they are commit scripts.
2. **Group** - one type per commit, never mix unrelated changes.
   Dependency order: each commit must be self-contained and buildable
   (scaffolding/deps before code that uses them, code before docs that
   describe it). Put each path in exactly one commit; the script cannot
   split one file across commits.
3. **Write messages** - Conventional Commits: `type(scope): description`.
   Types: feat, fix, docs, style, refactor, perf, test, deps, build, ci,
   chore. Imperative mood, lowercase after the colon, no trailing period,
   under 50 chars. Body explains what and why, not how. ASCII punctuation
   only (no em/en dashes, no emojis).
4. **Render** - write the plan JSON to a scratch file (never into the
   user's repo) and run:

   ```bash
   python3 scripts/render_plan.py plan.json
   ```

   Tables go to stdout; `ALIGNMENT: OK` and `LINT:` notes go to stderr.
   Fix any lint, re-render. Nonzero exit = invalid plan, not user error.

5. **Write the script** - from the same plan JSON:

   ```bash
   python3 scripts/write_commit_script.py plan.json --repo <repo root> --name <name>
   ```

   Name: `commit-vX.Y.Z.sh` when the release version is known from the
   user or the conversation, else `commit-plan.sh`. The tool prints the
   script path. It overwrites only a script that it wrote before. If it
   refuses because another file has that name, pick another name; use
   `--force` only when the user says so.

6. **Dry run** - run `./<name> --dry-run` from the repo root. It checks
   HEAD, the git state, and that the plan holds each changed file one
   time. It uses a temporary index and changes nothing. Fix the plan
   JSON and write the script again if it fails.
7. **Present** - print the tables raw (no code fence, no markdown table),
   then brief notes: staged vs unstaged status, ordering rationale, the
   script path, and how to run it (`./<name> --dry-run`, then
   `./<name>`). If the user reclassifies files, rebuild the JSON,
   re-render the affected tables, and write the script again.

## Plan JSON

```json
{
  "commits": [
    {
      "message": "feat(auth): add refresh token rotation",
      "details": "Body prose. Omit for (none).",
      "breaking": "Optional BREAKING CHANGE footer text.",
      "warn": "Optional. Auto-added when a commit exceeds 500 changed lines; set your own text to explain why splitting is not worthwhile.",
      "files": [
        { "status": "A", "path": "src/auth/rotate.ts", "add": 91, "del": 0 },
        { "status": "M", "path": "logo.png", "binary": true },
        {
          "status": "M",
          "path": "src/session.ts",
          "add": 42,
          "del": 8,
          "staged": false
        },
        {
          "status": "R",
          "path": "src/auth/new.ts",
          "from": "src/auth/old.ts",
          "add": 0,
          "del": 0
        }
      ]
    }
  ]
}
```

The renderer sorts files (A, M, D, R, then path), right-aligns counts,
left-truncates long paths keeping the filename, renders `(binary)` cells,
marks unstaged files with `~` after the status letter, and adds the
summary table, totals, WARN and BREAKING rows. The full schema is
documented at the top of `scripts/render_plan.py`.

The script writer needs `from` (the old path) on every `R` entry. It
wraps `details` at 72 columns (a blank line in `details` starts a new
paragraph) and adds `breaking` as a `BREAKING CHANGE:` footer.

For the full grouping taxonomy, message style rules, output contract,
how the commit script works, and edge-case rationale, read reference.md
in this skill directory.

## Edge cases

- **No changes** - say so and stop; render nothing, write no script.
- **Merge in progress** (`.git/MERGE_HEAD` exists) - warn; suggest
  resolving before planning commits. Write no script.
- **Staged + unstaged both present** - include both; set
  `"staged": false` on unstaged files so the `~` marker shows. The
  script adds the working tree content of those files.

reference.md

reference.mdmd
# Preview Commit Plan - full reference

Extended guidance for the preview-commit-plan skill. SKILL.md is the
recipe; read this when a grouping call is ambiguous, a message needs
more than the compressed rules, or you are modifying
scripts/render_plan.py.

## Change analysis

For each changed file, analyze the diff to understand:

- Type of change (feature, fix, refactor, docs, style, test, chore)
- Scope of change (which component/module)
- Breaking changes (if any)

Use `git diff` for unstaged changes and `git diff --staged` for staged
changes; `--numstat` variants capture the per-file counts the plan
JSON needs. Check recent commits (`git log --oneline -10`) to maintain
consistency with the repo's existing types and scopes.

## Intelligent grouping

Group changes based on these criteria, in priority order:

- **Feature additions** - new functionality or capabilities
- **Bug fixes** - corrections to existing functionality
- **Refactoring** - code restructuring without behavior changes
- **Documentation** - README, comments, docs updates
- **Tests** - new or updated test files
- **Dependencies** - package.json, requirements.txt, etc.
- **Configuration** - config files, environment settings
- **Styling/Formatting** - linting, prettier, whitespace changes
- **Build/CI** - build scripts, CI/CD configuration
- **Chores** - routine tasks, maintenance

Avoid:

- Mixing unrelated changes in one group
- Creating groups with mixed types (e.g., features + formatting)
- Grouping changes that span multiple unrelated features

Maintain chronological logic (dependencies before features), and
ensure each proposed commit is self-contained and buildable.

## Commit messages

Conventional Commits format:

```
<type>(<scope>): <description>

[optional body]
```

Types: feat, fix, docs, style, refactor, perf, test, deps, build, ci,
chore.

Best practices:

- Avoid emojis
- Avoid em dashes and en dashes; use a hyphen, comma, colon, or a
  separate sentence. ASCII punctuation only.
- Keep the description under 50 characters
- Use imperative mood ("add" not "added" or "adds")
- Don't end the subject line with a period
- Don't capitalize the first letter of the description
- Separate subject from body with a blank line
- Body should explain what and why, not how
- Breaking changes get a `BREAKING CHANGE:` footer (the plan JSON
  `breaking` field renders it)

## Output contract (enforced by render_plan.py - do not hand-draw)

The renderer produces two artifacts, byte-aligned and ASCII-only:

**Plan summary** - one row per proposed commit: number, subject line,
file count, insertions, deletions, plus a TOTAL row.

**Commit detail** - one table per commit, in commit order:

- `Commit` - position in the sequence (`N of M`)
- `Message` - the subject line, ready to be used verbatim
- `Details` - the body in plain prose; `(none)` if the commit needs
  no body
- `BREAKING` - after Details, carrying the `BREAKING CHANGE:` footer
  (only when present)
- `WARN` - auto-added when a commit exceeds 500 changed lines;
  override with your own `warn` text explaining why splitting isn't
  worthwhile
- `Files` - one row per file: status letter, path, `+insertions`
  `-deletions`, closed by a `--` totals line. Binary files show
  `(binary)`; unstaged files get a `~` after the status letter; long
  paths truncate from the left preserving the filename
  (`...uth/session.ts`). Files sort by status (A, M, D, R) then path.

Print the tables raw - no code fence, no markdown table. The renderer
exits nonzero on any alignment or ASCII violation; treat that as a bug
in the plan JSON or the renderer, never something to fix by editing
table text manually.

## Edge cases

- **No changes detected**: inform the user, exit gracefully
- **Merge conflicts / merge in progress**: warn, suggest resolving
  before planning commits
- **Uncommitted + staged changes**: include both; mark unstaged files
  with `"staged": false` in the plan JSON
- **Large commits**: the renderer warns over 500 lines; when the bulk
  is generated content (lockfiles, vendored CSS), say so in `warn`
  rather than force-splitting

## Commit script (written by write_commit_script.py - do not hand-write)

The script makes the commits of the plan when the user runs it. It
works in these steps:

1. It stops if HEAD is not the HEAD of the plan, or if a merge,
   cherry-pick, revert or rebase is in progress.
2. It copies the index to a temporary index and adds the unstaged
   files of the plan (`"staged": false`) to that copy. The tree of the
   copy is the snapshot. The real index does not change in this step.
3. It compares the files that the snapshot changes against HEAD with
   the files of the plan. A missing or extra file stops the script.
   An untracked file that is not in the plan is not a change, so it
   does not stop the script.
4. `--dry-run` stops here and lists the commits.
5. It resets the index to HEAD. For each commit, it copies the
   snapshot entry of each file into the index, or removes the entry
   for a deleted file and the old path of a rename. Then it commits
   with the message of the plan.
6. It checks that the tree of the last commit is the snapshot.

The script never changes the working tree. Unstaged changes to files
that are not in the plan stay unstaged. On a failure, the script
prints `git read-tree <tree>`, which puts back the index from before
the script.

The script stores each file as `'N STATUS PATH'`, where `N` is the
commit number, `~` after the status marks an unstaged file, and
`R-from` marks the old path of a rename. It sets
`GIT_LITERAL_PATHSPECS=1`, so paths with glob characters are safe.

## Interactive mode

Allow the user to reclassify files after seeing the plan. Rebuild the
plan JSON, re-render, reprint the affected tables, and write the
commit script again with the same name.

## Constraints

Never run `git add`, `git commit`, `git reset`, `git stash`,
`git checkout`, or any command that mutates the repository or the
index. Never run the commit script without `--dry-run`. The commit
script is the only file that you write into the repository. Leave the
working tree and index exactly as found. Respect .gitignore.

scripts/render_plan.py

scripts/render_plan.pypy
#!/usr/bin/env python3
"""Render a commit plan (JSON) as byte-aligned ASCII tables.

Usage:
    python3 render_plan.py plan.json
    python3 render_plan.py < plan.json

Input schema (JSON):
    {
      "commits": [
        {
          "message":  "feat(scope): subject",        # required
          "details":  "body prose",                  # optional -> "(none)"
          "breaking": "BREAKING CHANGE footer text", # optional
          "warn":     "why this commit is large",    # optional; auto-added
                                                     # when > 500 lines change
          "files": [                                 # required, >= 1
            {"status": "A", "path": "src/x.ts", "add": 10, "del": 2},
            {"status": "M", "path": "logo.png", "binary": true},
            {"status": "M", "path": "y.ts", "add": 5, "del": 1,
             "staged": false},                       # "~" marker after status
            {"status": "R", "path": "new.ts", "from": "old.ts",
             "add": 0, "del": 0}                     # "from": the old path;
                                                     # write_commit_script.py
                                                     # needs it
          ]
        }
      ]
    }

Tables print to stdout; lint notes and the alignment verdict print to
stderr. Exit is nonzero if the plan is invalid or any table fails the
byte-alignment / ASCII-only self-check.
"""

import json
import re
import sys
import textwrap

STATUS_ORDER = {"A": 0, "M": 1, "D": 2, "R": 3}
TYPES = "feat|fix|docs|style|refactor|perf|test|deps|build|ci|chore"
SUBJECT_RE = re.compile(r"^(" + TYPES + r")(\([a-z0-9./-]+\))?(!)?: [a-z0-9]")
MIN_PROSE_W = 65   # floor for the detail-table value column
PATH_CAP = 64      # longer paths are left-truncated, filename preserved
WARN_LINES = 500


def die(msg):
    """Print a fatal error to stderr and exit nonzero.

    Args:
        msg: The error message.
    """
    print(f"ERROR: {msg}", file=sys.stderr)
    sys.exit(1)


def load_plan(path=None):
    """Read and validate the plan JSON from path, argv[1] or stdin."""
    if path is None and len(sys.argv) > 1:
        path = sys.argv[1]
    try:
        if path:
            with open(path) as f:
                plan = json.load(f)
        else:
            plan = json.load(sys.stdin)
    except (OSError, json.JSONDecodeError) as e:
        die(f"cannot read plan JSON: {e}")
    return validate_plan(plan)


def validate_plan(plan):
    """Return the commits of the plan, or exit if the plan is invalid."""
    commits = plan.get("commits")
    if not isinstance(commits, list) or not commits:
        die('plan must have a non-empty "commits" array')
    for i, c in enumerate(commits, 1):
        if not c.get("message"):
            die(f'commit {i}: missing "message"')
        files = c.get("files")
        if not isinstance(files, list) or not files:
            die(f'commit {i}: missing or empty "files"')
        for f in files:
            st = f.get("status")
            if st not in STATUS_ORDER:
                die(f'commit {i}: file status must be one of A/M/D/R, got {st!r}')
            if not f.get("path"):
                die(f'commit {i}: file entry missing "path"')
            if not f.get("binary") and ("add" not in f or "del" not in f):
                die(f'commit {i}: {f["path"]}: needs "add" and "del" (or "binary": true)')
    return commits


def center_r(text, width):
    """Center text with right-biased padding (matches the spec examples)."""
    left = (width - len(text) + 1) // 2
    return (" " * left + text).ljust(width)


def fit_path(path, width):
    """Left-truncate a path to width, keeping the filename end visible."""
    if len(path) <= width:
        return path.ljust(width)
    return "..." + path[-(width - 3):]


def lint(commits):
    """Emit non-fatal style notes about commit messages to stderr."""
    for i, c in enumerate(commits, 1):
        subject = c["message"]
        if len(subject) > 50:
            print(f"LINT: commit {i} subject is {len(subject)} chars (keep under 50)",
                  file=sys.stderr)
        if not SUBJECT_RE.match(subject):
            print(f"LINT: commit {i} subject does not look like Conventional Commits: "
                  f"{subject!r}", file=sys.stderr)
        if subject.rstrip().endswith("."):
            print(f"LINT: commit {i} subject ends with a period", file=sys.stderr)


def summary_table(commits):
    """Build the one-row-per-commit summary table as a list of lines."""
    msg_w = max(len("Commit message"), max(len(c["message"]) for c in commits))
    files_w = 5
    plus_w = max(4, len(str(sum(c["adds"] for c in commits))))
    del_w = max(4, len(str(sum(c["dels"] for c in commits))))

    def row(cells):
        return "| " + " | ".join(cells) + " |"

    sep = "+" + "+".join("-" * (w + 2) for w in (3, msg_w, files_w, plus_w, del_w)) + "+"
    out = [sep,
           row(["#".ljust(3), "Commit message".ljust(msg_w), "Files".rjust(files_w),
                center_r("+", plus_w), center_r("-", del_w)]),
           sep]
    for i, c in enumerate(commits, 1):
        out.append(row([str(i).ljust(3), c["message"].ljust(msg_w),
                        str(len(c["files"])).rjust(files_w),
                        str(c["adds"]).rjust(plus_w), str(c["dels"]).rjust(del_w)]))
    out.append(sep)
    merged = "TOTAL".ljust(3 + 3 + msg_w)  # spans the "#" and message columns
    out.append(row([merged, str(sum(len(c["files"]) for c in commits)).rjust(files_w),
                    str(sum(c["adds"] for c in commits)).rjust(plus_w),
                    str(sum(c["dels"] for c in commits)).rjust(del_w)]))
    out.append(sep)
    return out


def detail_table(c, i, total, label_w):
    """Build one commit's detail table as a list of lines."""
    num_w = max(4, max((len(f"+{f.get('add', 0)}") for f in c["files"]), default=4),
                max((len(f"-{f.get('del', 0)}") for f in c["files"]), default=4))
    path_w = min(PATH_CAP, max(len(f["path"]) for f in c["files"]))
    val_w = max(MIN_PROSE_W, len(c["message"]), 3 + path_w + 2 + num_w + 2 + num_w)
    path_w = val_w - 3 - 2 - num_w - 2 - num_w  # widen paths into any slack

    sep = "+" + "-" * (label_w + 2) + "+" + "-" * (val_w + 2) + "+"
    out = [sep]

    def row(label, text):
        out.append("| " + label.ljust(label_w) + " | " + text.ljust(val_w) + " |")

    def prose(label, text):
        for j, line in enumerate(textwrap.wrap(text, val_w) or [text]):
            row(label if j == 0 else "", line)

    row("Commit", f"{i} of {total}")
    out.append(sep)
    prose("Message", c["message"])
    out.append(sep)
    prose("Details", c.get("details") or "(none)")
    out.append(sep)
    if c.get("breaking"):
        prose("BREAKING", "BREAKING CHANGE: " + c["breaking"])
        out.append(sep)
    warn = c.get("warn")
    total_lines = c["adds"] + c["dels"]
    if warn is None and total_lines > WARN_LINES:
        warn = (f"This commit changes {total_lines} lines (over the "
                f"{WARN_LINES}-line guideline) - consider splitting.")
    if warn:
        prose("WARN", warn)
        out.append(sep)
    for j, f in enumerate(c["files"]):
        marker = " " if f.get("staged", True) else "~"
        if f.get("binary"):
            cell = f["status"] + marker + " " + fit_path(f["path"], path_w) \
                + "  " + "(binary)".rjust(num_w * 2 + 2)
        else:
            cell = f["status"] + marker + " " + fit_path(f["path"], path_w) \
                + "  " + f"+{f['add']}".rjust(num_w) + "  " + f"-{f['del']}".rjust(num_w)
        row("Files" if j == 0 else "", cell)
    unit = "file" if len(c["files"]) == 1 else "files"
    row("", f"-- {len(c['files'])} {unit}, +{c['adds']} -{c['dels']}")
    out.append(sep)
    return out


def self_check(blocks):
    """Verify every table is ASCII-only and byte-aligned; exit 1 if not."""
    ok = True
    for b, block in enumerate(blocks, 1):
        widths = {len(line) for line in block}
        if len(widths) != 1:
            ok = False
            print(f"MISALIGNED: table {b} has row widths {sorted(widths)}", file=sys.stderr)
        bad = {ch for line in block for ch in line if ord(ch) > 127}
        if bad:
            ok = False
            print(f"NON-ASCII in table {b}: {sorted(bad)}", file=sys.stderr)
    print("ALIGNMENT:", "OK" if ok else "FAILED", file=sys.stderr)
    if not ok:
        sys.exit(1)


def main():
    """Load, lint, render, and self-check the plan."""
    commits = load_plan()
    for c in commits:
        c["files"].sort(key=lambda f: (STATUS_ORDER[f["status"]], f["path"]))
        c["adds"] = sum(f.get("add", 0) for f in c["files"])
        c["dels"] = sum(f.get("del", 0) for f in c["files"])
    lint(commits)
    label_w = 8 if any(c.get("breaking") for c in commits) else 7
    blocks = [summary_table(commits)]
    blocks += [detail_table(c, i, len(commits), label_w)
               for i, c in enumerate(commits, 1)]
    print("\n\n".join("\n".join(b) for b in blocks))
    self_check(blocks)


if __name__ == "__main__":
    main()

scripts/write_commit_script.py

scripts/write_commit_script.pypy
#!/usr/bin/env python3
"""Write an executable bash script that makes the commits of a plan.

Usage:
    python3 write_commit_script.py plan.json [--repo DIR] [--name NAME] [--force]

The plan JSON is the same file that render_plan.py reads. A rename entry
("status": "R") must also give the old path in "from".

The script goes into the root of the repo (--repo, default: the repo of
the current directory) as NAME (default: commit-plan.sh). The tool
overwrites a file only if an earlier run of this tool wrote it, or if
--force is given. It prints the path of the script to stdout.

The script itself makes the commits only when the user runs it. Its
--dry-run option does all the checks in a temporary index and changes
nothing.
"""

import argparse
import os
import shlex
import subprocess
import sys
import textwrap

from render_plan import die, load_plan

MARKER = "# Generated by the preview-commit-plan skill."
BODY_W = 72

TEMPLATE = r"""#!/usr/bin/env bash
@MARKER@
#
# Makes the @COUNT@ commits of the plan below, in order. The plan puts the
# commits in dependency order, so each commit builds.
#
# The script first takes a snapshot of the staged changes in a temporary
# index, and adds the unstaged files of the plan (status with "~") to it.
# Each commit gets the content of its files from that snapshot, so later
# edits in the working tree do not go into the commits. The script does
# not change the working tree.
#
# Usage: ./@NAME@ [--dry-run]
#   --dry-run  Do the checks and show the commits. Change nothing.
set -euo pipefail
export GIT_LITERAL_PATHSPECS=1

cd "$(dirname "$0")"

me=${0##*/}
# The plan is for this HEAD. Empty means a repo with no commits.
expected_head=@HEAD@
dry_run=false
case ${1:-} in
  "") ;;
  --dry-run) dry_run=true ;;
  *)
    echo "usage: $me [--dry-run]" >&2
    exit 2
    ;;
esac

die() {
  echo "$me: $*" >&2
  exit 1
}

head=$(git rev-parse -q --verify HEAD || true)
if [[ $head != "$expected_head" ]]; then
  die "HEAD is ${head:-unborn}, but the plan is for ${expected_head:-an unborn HEAD}. Make the plan again."
fi
for state in MERGE_HEAD CHERRY_PICK_HEAD REVERT_HEAD rebase-merge rebase-apply; do
  if [[ -e $(git rev-parse --git-path "$state") ]]; then
    die "a git operation is in progress ($state). Finish or abort it first."
  fi
done

# One entry for each file: the commit number, the status and the path.
# A "~" after the status marks an unstaged file. "R-from" is the old path
# of a rename.
files=(
@FILES@
)

# The message of each commit, in commit order.
messages=(
@MESSAGES@
)

paths=()
unstaged=()
for e in "${files[@]}"; do
  rest=${e#* }
  st=${rest%% *}
  paths+=("${rest#* }")
  if [[ $st == *"~" ]]; then
    unstaged+=("${rest#* }")
  fi
done

tmp_dir=$(mktemp -d)
trap 'rm -rf "$tmp_dir"' EXIT
tmp_index=$tmp_dir/index
msg_file=$tmp_dir/message

# Take the snapshot in a copy of the index. The real index does not change.
real_index=$(git rev-parse --git-path index)
if [[ -e $real_index ]]; then
  cp "$real_index" "$tmp_index"
fi
staged_tree=$(GIT_INDEX_FILE=$tmp_index git write-tree) ||
  die "the index has unmerged files. Resolve them first."
if ((${#unstaged[@]})); then
  GIT_INDEX_FILE=$tmp_index git add -A -- "${unstaged[@]}" ||
    die "cannot add the unstaged files of the plan. Make the plan again."
fi
full_tree=$(GIT_INDEX_FILE=$tmp_index git write-tree)

# Check that the plan holds each changed file one time, and no other file.
if [[ -n $expected_head ]]; then
  base=$expected_head
else
  base=$(git hash-object -t tree /dev/null)
fi
planned=$(printf '%s\n' "${paths[@]}" | LC_ALL=C sort)
changed=$(git diff-tree -r -z --no-renames --name-only "$base" "$full_tree" |
  tr '\0' '\n' | LC_ALL=C sort)
if [[ -n $(uniq -d <<<"$planned") ]]; then
  die "a file is in more than one commit. Stop."
fi
if [[ $planned != "$changed" ]]; then
  echo "Only in the plan:" >&2
  comm -23 <(echo "$planned") <(echo "$changed") >&2
  echo "Only in the snapshot:" >&2
  comm -13 <(echo "$planned") <(echo "$changed") >&2
  die "the changes are not the changes of the plan. Make the plan again."
fi

if $dry_run; then
  for i in "${!messages[@]}"; do
    n=$((i + 1))
    echo "== $n of ${#messages[@]}: ${messages[i]%%$'\n'*}"
    for e in "${files[@]}"; do
      if [[ ${e%% *} == "$n" ]]; then
        echo "   ${e#* }"
      fi
    done
  done
  echo "Checks passed. Nothing changed."
  exit 0
fi

echo "Snapshot: $full_tree"

# On a failure, tell the user how to get the old index back.
trap 'echo >&2; echo "$me: stopped. To get back the index from before the script, run:" >&2; echo "  git read-tree $staged_tree" >&2' ERR

# Empty the index to HEAD. The working tree does not change.
if [[ -n $expected_head ]]; then
  git reset -q
else
  git read-tree --empty
fi

for i in "${!messages[@]}"; do
  n=$((i + 1))
  for e in "${files[@]}"; do
    if [[ ${e%% *} != "$n" ]]; then
      continue
    fi
    rest=${e#* }
    path=${rest#* }
    if git cat-file -e "$full_tree:$path" 2>/dev/null; then
      git ls-tree -z "$full_tree" -- "$path" | git update-index -z --index-info
    else
      git update-index --force-remove -- "$path"
    fi
  done
  printf '%s\n' "${messages[i]}" >"$msg_file"
  git commit -q -F "$msg_file"
  echo "$n of ${#messages[@]}: $(git log -1 --format='%h %s')"
done

if [[ $(git rev-parse 'HEAD^{tree}') != "$full_tree" ]]; then
  die "the last commit does not match the snapshot $full_tree. Check git status."
fi
echo "Done. HEAD has all the changes of the plan."
"""


def git(repo, *args):
    """Return the stdout of a git command, or None if the command fails."""
    r = subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True)
    return r.stdout.strip() if r.returncode == 0 else None


def build_message(c):
    """Return the full commit message: subject, body and footer."""
    parts = [c["message"].strip()]
    for para in (c.get("details") or "").split("\n\n"):
        if para.strip():
            parts.append(textwrap.fill(" ".join(para.split()), BODY_W))
    if c.get("breaking"):
        parts.append(textwrap.fill("BREAKING CHANGE: " + " ".join(c["breaking"].split()),
                                   BODY_W))
    return "\n\n".join(parts)


def file_entries(commits):
    """Return the "N status path" entries of the script, in commit order."""
    owner = {}
    entries = []
    for n, c in enumerate(commits, 1):
        # A path can be in one commit two times: staged and unstaged parts.
        merged = {}
        for f in c["files"]:
            pairs = [(f["status"], f["path"])]
            if f["status"] == "R":
                if not f.get("from"):
                    die(f'commit {n}: rename {f["path"]} needs "from" (the old path)')
                pairs.append(("R-from", f["from"]))
            for st, path in pairs:
                if "\n" in path or path.startswith("/"):
                    die(f"commit {n}: cannot use the path {path!r}")
                if owner.get(path, n) != n:
                    die(f"{path} is in commit {owner[path]} and commit {n}. "
                        "Put each file in one commit.")
                owner[path] = n
                old = merged.get(path)
                unstaged = f.get("staged", True) is False or (old and old[1])
                merged[path] = (old[0] if old else st, unstaged)
        for path, (st, unstaged) in merged.items():
            entries.append(f"{n} {st}{'~' if unstaged else ''} {path}")
    return entries


def main():
    """Validate the plan, write the script and check its syntax."""
    ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
    ap.add_argument("plan")
    ap.add_argument("--repo", default=".")
    ap.add_argument("--name", default="commit-plan.sh")
    ap.add_argument("--force", action="store_true")
    args = ap.parse_args()

    if "/" in args.name or not args.name.endswith(".sh"):
        die(f"--name must be a file name that ends with .sh, got {args.name!r}")
    root = git(args.repo, "rev-parse", "--show-toplevel")
    if not root:
        die(f"{args.repo} is not in a git repo")
    commits = load_plan(args.plan)
    head = git(root, "rev-parse", "-q", "--verify", "HEAD") or ""

    out = os.path.join(root, args.name)
    if os.path.exists(out) and not args.force:
        with open(out, errors="replace") as f:
            if MARKER not in f.read(4096):
                die(f"{out} exists and this tool did not write it. "
                    "Use another --name, or --force to overwrite it.")

    script = TEMPLATE
    for key, value in {
        "@MARKER@": MARKER,
        "@COUNT@": str(len(commits)),
        "@NAME@": args.name,
        "@HEAD@": head,
        "@FILES@": "\n".join("  " + shlex.quote(e) for e in file_entries(commits)),
        "@MESSAGES@": "\n".join("  " + shlex.quote(build_message(c)) for c in commits),
    }.items():
        script = script.replace(key, value)

    with open(out, "w") as f:
        f.write(script)
    os.chmod(out, 0o755)
    r = subprocess.run(["bash", "-n", out], capture_output=True, text=True)
    if r.returncode != 0:
        die(f"{out} has a bash syntax error: {r.stderr.strip()}")
    print(out)


if __name__ == "__main__":
    main()