Claude Skill

git-squash

Squash a feature branch into one commit via soft reset to the merge base, ready for a clean PR.

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download paulrberg-agent-skills-skills_git-squash-913232a.zip · 5 KB
Part of paulrberg/agent-skills — 42 skills

Install

skills CLI npx skills add https://github.com/PaulRBerg/agent-skills/tree/main/skills/git-squash
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install paulrberg-agent-skills@llmmart
Git git clone https://github.com/PaulRBerg/agent-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole paulrberg/agent-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Git Squash

If these instructions are already present in the conversation from a slash or dollar invocation, follow them directly; do not invoke this skill again through a skill tool.

Squash the current feature branch into one commit representing its net change relative to the resolved default branch.

Arguments

  • --subject <line>: require this exact first line in the replacement commit message.
  • --base <branch>: override default-branch detection.

Without --subject, the agent writes a subject from the surviving net diff in the repository's message format. Without --base, the helper resolves origin/HEAD, then local/remote main, master, or trunk.

Plan Interface

Resolve the helper from this SKILL.md. Write its JSON to a scratch path outside the repository so preflight remains clean:

uv run "<skill-dir>/scripts/git-squash.py" plan \
  [--cwd <repo>] [--base <branch>] [--subject <line>] > <plan.json>

plan is read-only. It verifies the Git worktree, attached branch, clean tree/index, resolved base, non-default current branch, merge base, positive ahead count, and remote facts. It returns schemaVersion: 1, the immutable original HEAD, merge base, base ref, commits in chronological order, unique authors, tree/remote state, and rollback facts. A failed precondition exits without changing history.

Show the branch, base ref, merge base, commits replaced, tree state, remote state, and rollback HEAD in a compact plain table before mutation.

Agent-Owned Commit Message

Inspect the plan's commits and the net diff from mergeBase..originalHead. The net diff is authoritative; intermediate commits supply intent and attribution only. Inspect targeted hunks when the summary is ambiguous.

Use --subject exactly when supplied. Otherwise read format under [message] in <git-root>/.agents/commit.toml; its value is natural or conventional, and an absent file or key means conventional.

  • natural: write a natural-language imperative subject with no type prefix, such as Add retry to webhook delivery.
  • conventional: choose the type from the surviving outcome: feat, fix, refactor, docs, test, build, ci, chore(deps), style, perf, revert, ai, or chore. Do not call the change chore merely because it is a squash. Keep the subject lowercase after the prefix.

Keep the subject imperative, specific, and without a trailing period. Add at most five body bullets for distinct surviving outcomes; omit the body when the subject is sufficient. Do not dump paths or statistics. Append one Co-authored-by: Name <email> trailer for each plan author other than the current Git user. The agent owns all semantic wording and must ensure every statement is supported by the net diff.

Write the final message to a scratch file outside the repository.

Apply Interface

uv run "<skill-dir>/scripts/git-squash.py" apply \
  --plan <plan.json> --message-file <message.txt>

apply binds the rewrite to the plan's original HEAD, branch, merge base, base ref, clean state, rollback index, and optional subject. It revalidates them immediately before mutation; a stale plan fails without changing history. It soft-resets to the merge base, verifies the staged net diff is non-empty, and commits from the message file.

If any operation fails after mutation but before the replacement commit completes, the helper restores the original HEAD and exact index. It leaves working-tree files untouched. Do not reproduce the reset/rollback sequence manually or continue after a helper failure without inspecting its diagnostic and current Git state.

Report

On success, report the plan's replaced count, resolved base ref, new hash, and subject. If the branch exists on origin, state the exact next action git push --force-with-lease; do not run it unless explicitly requested.

Lead with ### ✅ Squashed — <old count> commits → 1. Keep preflight facts, hashes, commands, errors, and rollback wording plain and exact.

Files (agent-skills)
  • agents
    • openai.yaml 43 B
      policy:
        allow_implicit_invocation: false
      
  • scripts
    • git-squash.py 9.8 KB
      #!/usr/bin/env python3
      """Plan or atomically apply a one-commit Git branch squash."""
      
      from __future__ import annotations
      
      import argparse
      import json
      import os
      import subprocess
      import sys
      from pathlib import Path
      from typing import Any
      
      
      class GitError(RuntimeError):
          pass
      
      
      def git(cwd: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess[str]:
          result = subprocess.run(["git", *args], cwd=cwd, text=True, capture_output=True)
          if check and result.returncode:
              raise GitError((result.stderr or result.stdout).strip() or f"git {' '.join(args)} failed")
          return result
      
      
      def ref_exists(cwd: Path, ref: str) -> bool:
          return git(cwd, "show-ref", "--verify", "--quiet", ref, check=False).returncode == 0
      
      
      def current_branch(cwd: Path) -> str:
          result = git(cwd, "symbolic-ref", "--quiet", "--short", "HEAD", check=False)
          if result.returncode:
              raise GitError("HEAD is detached")
          return result.stdout.strip()
      
      
      def resolve_base(cwd: Path, requested: str | None) -> tuple[str, str]:
          branch = requested
          if not branch:
              symbolic = git(cwd, "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD", check=False)
              if symbolic.returncode == 0 and symbolic.stdout.strip().startswith("origin/"):
                  branch = symbolic.stdout.strip().removeprefix("origin/")
          if not branch:
              branch = next((candidate for candidate in ("main", "master", "trunk") if ref_exists(cwd, f"refs/heads/{candidate}") or ref_exists(cwd, f"refs/remotes/origin/{candidate}")), None)
          if not branch:
              raise GitError("cannot resolve a default branch; pass --base")
          branch = branch.removeprefix("refs/heads/").removeprefix("refs/remotes/origin/").removeprefix("origin/")
          if ref_exists(cwd, f"refs/heads/{branch}"):
              return branch, f"refs/heads/{branch}"
          if ref_exists(cwd, f"refs/remotes/origin/{branch}"):
              return branch, f"refs/remotes/origin/{branch}"
          raise GitError(f"base branch does not exist locally or on origin: {branch}")
      
      
      def remote_facts(cwd: Path, branch: str) -> dict[str, Any]:
          upstream = git(cwd, "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}", check=False)
          return {
              "originConfigured": git(cwd, "remote", "get-url", "origin", check=False).returncode == 0,
              "upstream": upstream.stdout.strip() if upstream.returncode == 0 else None,
              "originBranchExists": ref_exists(cwd, f"refs/remotes/origin/{branch}"),
          }
      
      
      def preflight(cwd: Path, requested_base: str | None, subject: str | None) -> dict[str, Any]:
          inside = git(cwd, "rev-parse", "--is-inside-work-tree", check=False)
          if inside.returncode or inside.stdout.strip() != "true":
              raise GitError("not inside a Git worktree")
          root = Path(git(cwd, "rev-parse", "--show-toplevel").stdout.strip())
          branch = current_branch(root)
          status = git(root, "status", "--porcelain=v1").stdout.splitlines()
          if status:
              raise GitError("working tree or index is not clean")
          base_branch, base_ref = resolve_base(root, requested_base)
          if branch == base_branch:
              raise GitError("current branch is the default branch")
          merge_base_result = git(root, "merge-base", "HEAD", base_ref, check=False)
          if merge_base_result.returncode:
              raise GitError(f"HEAD and {base_ref} have no merge base")
          merge_base = merge_base_result.stdout.strip()
          original_head = git(root, "rev-parse", "HEAD").stdout.strip()
          ahead_count = int(git(root, "rev-list", "--count", f"{merge_base}..HEAD").stdout.strip())
          if ahead_count == 0:
              raise GitError("branch has no commits ahead of the merge base")
          commits = []
          raw_commits = git(root, "log", "--reverse", "--format=%H%x09%aN%x09%aE%x09%s", f"{merge_base}..HEAD").stdout
          for line in raw_commits.splitlines():
              commit, author_name, author_email, commit_subject = line.split("\t", 3)
              commits.append({"commit": commit, "author": {"name": author_name, "email": author_email}, "subject": commit_subject})
          authors = sorted({f"{item['author']['name']} <{item['author']['email']}>" for item in commits})
          return {
              "schemaVersion": 1,
              "repoRoot": str(root),
              "branch": branch,
              "baseBranch": base_branch,
              "baseRef": base_ref,
              "mergeBase": merge_base,
              "originalHead": original_head,
              "aheadCount": ahead_count,
              "commits": commits,
              "authors": authors,
              "subjectOverride": subject,
              "treeState": {"clean": True, "status": []},
              "remote": remote_facts(root, branch),
              "rollback": {"head": original_head, "indexTree": git(root, "write-tree").stdout.strip()},
          }
      
      
      def restore(cwd: Path, original_head: str, index_tree: str) -> None:
          errors: list[str] = []
          if git(cwd, "update-ref", "HEAD", original_head, check=False).returncode:
              errors.append("failed to restore HEAD")
          if git(cwd, "read-tree", index_tree, check=False).returncode:
              errors.append("failed to restore index")
          if errors:
              raise GitError("; ".join(errors))
      
      
      def load_apply_facts(args: argparse.Namespace) -> dict[str, Any]:
          if args.plan:
              try:
                  facts = json.loads(args.plan.read_text(encoding="utf-8"))
              except (OSError, json.JSONDecodeError) as exc:
                  raise GitError(f"cannot read plan: {exc}") from exc
              if facts.get("schemaVersion") != 1:
                  raise GitError("plan schemaVersion must be 1")
              return facts
          required = {"expectedHead": args.expected_head, "mergeBase": args.merge_base, "baseRef": args.base_ref}
          missing = [name for name, value in required.items() if not value]
          if missing:
              raise GitError(f"apply requires --plan or {', '.join('--' + name.replace('Ref', '-ref').replace('Base', '-base').replace('Head', '-head').lower() for name in missing)}")
          return {
              "schemaVersion": 1,
              "repoRoot": str(args.cwd.resolve()),
              "branch": args.branch,
              "baseRef": args.base_ref,
              "baseBranch": args.base_ref.removeprefix("refs/heads/").removeprefix("refs/remotes/origin/"),
              "mergeBase": args.merge_base,
              "originalHead": args.expected_head,
              "rollback": {"head": args.expected_head, "indexTree": args.index_tree},
              "subjectOverride": args.subject,
          }
      
      
      def apply_squash(args: argparse.Namespace) -> dict[str, Any]:
          facts = load_apply_facts(args)
          root = Path(facts["repoRoot"])
          if not root.is_dir():
              raise GitError(f"repository root does not exist: {root}")
          original_head = facts["originalHead"]
          expected_branch = facts.get("branch")
          if git(root, "rev-parse", "HEAD").stdout.strip() != original_head:
              raise GitError("stale plan: HEAD changed after planning")
          if expected_branch and current_branch(root) != expected_branch:
              raise GitError("stale plan: branch changed after planning")
          if git(root, "status", "--porcelain=v1").stdout:
              raise GitError("stale plan: working tree or index is not clean")
          current_merge_base = git(root, "merge-base", "HEAD", facts["baseRef"]).stdout.strip()
          if current_merge_base != facts["mergeBase"]:
              raise GitError("stale plan: merge base changed after planning")
          message = args.message_file.read_text(encoding="utf-8")
          if not message.strip():
              raise GitError("message file is empty")
          subject_override = args.subject or facts.get("subjectOverride")
          if subject_override and message.splitlines()[0] != subject_override:
              raise GitError("message subject does not match --subject")
          index_tree = facts.get("rollback", {}).get("indexTree") or git(root, "write-tree").stdout.strip()
          mutated = False
          committed = False
          try:
              mutated = True
              git(root, "reset", "--soft", facts["mergeBase"])
              if git(root, "diff", "--cached", "--quiet", check=False).returncode == 0:
                  raise GitError("squash would produce an empty commit")
              commit = git(root, "commit", "-F", str(args.message_file), check=False)
              if commit.returncode:
                  raise GitError((commit.stderr or commit.stdout).strip() or "replacement commit failed")
              committed = True
          finally:
              if mutated and not committed:
                  restore(root, original_head, index_tree)
          new_head = git(root, "rev-parse", "HEAD").stdout.strip()
          return {
              "schemaVersion": 1,
              "status": "squashed",
              "originalHead": original_head,
              "newHead": new_head,
              "mergeBase": facts["mergeBase"],
              "commitsReplaced": facts.get("aheadCount"),
              "subject": git(root, "show", "-s", "--format=%s", "HEAD").stdout.strip(),
              "remote": remote_facts(root, current_branch(root)),
          }
      
      
      def build_parser() -> argparse.ArgumentParser:
          parser = argparse.ArgumentParser(description=__doc__)
          subparsers = parser.add_subparsers(dest="command", required=True)
          plan = subparsers.add_parser("plan")
          plan.add_argument("--cwd", type=Path, default=Path.cwd())
          plan.add_argument("--base")
          plan.add_argument("--subject")
      
          apply = subparsers.add_parser("apply")
          apply.add_argument("--cwd", type=Path, default=Path.cwd())
          apply.add_argument("--plan", type=Path)
          apply.add_argument("--expected-head")
          apply.add_argument("--merge-base")
          apply.add_argument("--base-ref")
          apply.add_argument("--branch")
          apply.add_argument("--index-tree")
          apply.add_argument("--message-file", required=True, type=Path)
          apply.add_argument("--subject")
          return parser
      
      
      def main() -> int:
          args = build_parser().parse_args()
          try:
              result = preflight(args.cwd, args.base, args.subject) if args.command == "plan" else apply_squash(args)
          except (OSError, GitError) as exc:
              print(f"ERROR: {exc}", file=sys.stderr)
              return 1
          print(json.dumps(result, indent=2, ensure_ascii=False))
          return 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
  • SKILL.md 4.2 KB
    ---
    argument-hint: "[--subject <line>] [--base <branch>]"
    disable-model-invocation: true
    effort: high
    name: git-squash
    description: "Squash a feature branch into one commit via soft reset to the merge base, ready for a clean PR."
    ---
    
    # Git Squash
    
    If these instructions are already present in the conversation from a slash or dollar invocation, follow them directly;
    do not invoke this skill again through a skill tool.
    
    Squash the current feature branch into one commit representing its net change relative to the resolved default branch.
    
    ## Arguments
    
    - `--subject <line>`: require this exact first line in the replacement commit message.
    - `--base <branch>`: override default-branch detection.
    
    Without `--subject`, the agent writes a subject from the surviving net diff in the repository's message format. Without
    `--base`, the helper resolves `origin/HEAD`, then local/remote `main`, `master`, or `trunk`.
    
    ## Plan Interface
    
    Resolve the helper from this `SKILL.md`. Write its JSON to a scratch path outside the repository so preflight remains
    clean:
    
    ```sh
    uv run "<skill-dir>/scripts/git-squash.py" plan \
      [--cwd <repo>] [--base <branch>] [--subject <line>] > <plan.json>
    ```
    
    `plan` is read-only. It verifies the Git worktree, attached branch, clean tree/index, resolved base, non-default current
    branch, merge base, positive ahead count, and remote facts. It returns `schemaVersion: 1`, the immutable original HEAD,
    merge base, base ref, commits in chronological order, unique authors, tree/remote state, and rollback facts. A failed
    precondition exits without changing history.
    
    Show the branch, base ref, merge base, commits replaced, tree state, remote state, and rollback HEAD in a compact plain
    table before mutation.
    
    ## Agent-Owned Commit Message
    
    Inspect the plan's commits and the net diff from `mergeBase..originalHead`. The net diff is authoritative; intermediate
    commits supply intent and attribution only. Inspect targeted hunks when the summary is ambiguous.
    
    Use `--subject` exactly when supplied. Otherwise read `format` under `[message]` in `<git-root>/.agents/commit.toml`;
    its value is `natural` or `conventional`, and an absent file or key means `conventional`.
    
    - `natural`: write a natural-language imperative subject with no type prefix, such as `Add retry to webhook delivery`.
    - `conventional`: choose the type from the surviving outcome: `feat`, `fix`, `refactor`, `docs`, `test`, `build`, `ci`,
      `chore(deps)`, `style`, `perf`, `revert`, `ai`, or `chore`. Do not call the change `chore` merely because it is a
      squash. Keep the subject lowercase after the prefix.
    
    Keep the subject imperative, specific, and without a trailing period. Add at most five body bullets for distinct
    surviving outcomes; omit the body when the subject is sufficient. Do not dump paths or statistics. Append one
    `Co-authored-by: Name <email>` trailer for each plan author other than the current Git user. The agent owns all semantic
    wording and must ensure every statement is supported by the net diff.
    
    Write the final message to a scratch file outside the repository.
    
    ## Apply Interface
    
    ```sh
    uv run "<skill-dir>/scripts/git-squash.py" apply \
      --plan <plan.json> --message-file <message.txt>
    ```
    
    `apply` binds the rewrite to the plan's original HEAD, branch, merge base, base ref, clean state, rollback index, and
    optional subject. It revalidates them immediately before mutation; a stale plan fails without changing history. It
    soft-resets to the merge base, verifies the staged net diff is non-empty, and commits from the message file.
    
    If any operation fails after mutation but before the replacement commit completes, the helper restores the original HEAD
    and exact index. It leaves working-tree files untouched. Do not reproduce the reset/rollback sequence manually or
    continue after a helper failure without inspecting its diagnostic and current Git state.
    
    ## Report
    
    On success, report the plan's replaced count, resolved base ref, new hash, and subject. If the branch exists on origin,
    state the exact next action `git push --force-with-lease`; do not run it unless explicitly requested.
    
    Lead with `### ✅ Squashed — <old count> commits → 1`. Keep preflight facts, hashes, commands, errors, and rollback
    wording plain and exact.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related