Skip to main content

Skill: Preview commit plan

Posted on · Updated

Folder Structure

preview-commit-plan/
├── SKILL.md
├── reference.md
└── scripts/
    └── render_plan.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.
---

# Preview Commit Plan

Group working tree changes into atomic conventional commits and present
the plan as ASCII tables. **Read-only** - never run `git add`, `git commit`,
`git reset`, `git stash`, or `git checkout`. The output is a plan for the
user to act on. 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.** Build a plan JSON and run the bundled
renderer - it enforces byte-alignment, ASCII-only output, and the spec's
formatting rules, and exits 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").
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).
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 /tmp/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. **Present** - print the tables raw (no code fence, no markdown table),
   then brief notes: staged vs unstaged status, ordering rationale, and an
   execution tip. If the user reclassifies files, rebuild the JSON and
   re-render the affected tables.

## 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
        }
      ]
    }
  ]
}
```

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`.

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

## Edge cases

- **No changes** - say so and stop; render nothing.
- **Merge in progress** (`.git/MERGE_HEAD` exists) - warn; suggest
  resolving before planning commits.
- **Staged + unstaged both present** - include both; set
  `"staged": false` on unstaged files so the `~` marker shows.

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

## Interactive mode

Allow the user to reclassify files after seeing the plan. Rebuild the
plan JSON, re-render, and reprint the affected tables and any
execution handoff notes.

## Constraints

Read-only. Never run `git add`, `git commit`, `git reset`,
`git stash`, `git checkout`, or any command that mutates the
repository or the index. 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
          ]
        }
      ]
    }

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():
    """Read and validate the plan JSON from argv[1] or stdin."""
    try:
        if len(sys.argv) > 1:
            with open(sys.argv[1]) 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}")
    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()