Files
ardupilot/Tools/gittools/git-subsystems-split
T
Peter BarkerandClaude Opus 5 db56aaff53 Tools: git-subsystems-split: do not label a subject twice
build_template() removes one prefix from a subject before substituting
the subsystem in, so a subject which already named the subsystem after
its prefix ended up labelled twice: relabelling

  AP_HAL: hwdef: avoid shlex when splitting simple hwdef lines

for the hwdef subsystem gave "hwdef: hwdef: avoid shlex ...".

Collapse a repeated label when filling the template in, in both the
place which predicts a new subject for the plan and the place which
writes the message of a commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 14:01:01 +10:00

456 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
'''
git subsystems-split [OPTIONS]
ArduPilot's git extension.
Make each commit touch a single subsystem (vehicles, libraries and tooling
directories), the way Tools/scripts/check_branch_conventions.py requires. The
subsystem a file belongs to is decided by Tools/scripts/allowed_subsystems.py.
Per commit the tool either:
- leaves it alone if it is already a single, correctly-labelled subsystem,
- relabels the subsystem prefix (keeping the rest of the message) if all its
files share one subsystem but the prefix is wrong or mistyped, or
- splits it into one commit per subsystem if its files genuinely span more
than one, reusing the original message as a "$subsystem: ..." template.
By default it operates on the HEAD commit. With --branch it processes the
whole branch.
Options:
-n, --dry-run Show what would happen; make no changes.
--branch [BASE] Process every commit in BASE..HEAD instead of just HEAD.
BASE defaults to the merge base with origin/master (then
master). This rewrites history, so the working tree must
be clean; the original HEAD is printed for recovery.
--copy (HEAD only) give every commit the HEAD message verbatim.
--edit (HEAD only) edit the HEAD message as a template first.
-h, --help Show this help.
AP_FLAKE8_CLEAN
'''
from __future__ import annotations
import os
import subprocess
import sys
class GitSubsystemsSplit(object):
USAGE = __doc__
def __init__(self, argv=None):
self.argv = sys.argv[1:] if argv is None else argv
self.option_copy = False
self.option_edit = False
self.option_dry_run = False
self.branch_mode = False
self.branch_base = None
@staticmethod
def progress(message):
print(f"***** {message}")
def usage(self, stream=sys.stdout):
print(self.USAGE, file=stream)
def git(self, args, capture=True, check=True):
'''run a git command; return stripped stdout when capturing'''
cmd = ["git"] + list(args)
if capture:
result = subprocess.run(cmd, check=check, text=True,
stdout=subprocess.PIPE)
return result.stdout.strip()
subprocess.run(cmd, check=check)
return None
def git_ok(self, args):
'''run a git command quietly; return (returncode, stdout)'''
result = subprocess.run(["git"] + list(args), text=True,
stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL)
return result.returncode, result.stdout.strip()
def parse_args(self):
args = self.argv
i = 0
while i < len(args):
opt = args[i]
if opt in ("-h", "--help"):
self.usage()
sys.exit(0)
elif opt == "--copy":
self.option_copy = True
elif opt == "--edit":
self.option_edit = True
elif opt in ("-n", "--dry-run"):
self.option_dry_run = True
elif opt == "--branch":
self.branch_mode = True
if i + 1 < len(args) and not args[i + 1].startswith("-"):
self.branch_base = args[i + 1]
i += 1
elif opt.startswith("--branch="):
self.branch_mode = True
self.branch_base = opt.split("=", 1)[1]
else:
self.usage(sys.stderr)
return False
i += 1
if self.option_copy and self.option_edit:
print("Options --copy and --edit can't be combined.",
file=sys.stderr)
return False
if self.branch_mode and (self.option_copy or self.option_edit):
print("--branch can't be combined with --copy/--edit.",
file=sys.stderr)
return False
return True
def make_subsystems(self, repo_root):
'''build an AllowedSubsystems from the Tools/scripts module'''
scripts_dir = os.path.join(repo_root, "Tools", "scripts")
if scripts_dir not in sys.path:
sys.path.insert(0, scripts_dir)
import allowed_subsystems
return allowed_subsystems.AllowedSubsystems(repo_root)
# -- message handling ---------------------------------------------------
def build_template(self, message):
'''turn a commit message into a $subsystem template'''
lines = message.splitlines()
if not lines:
return "$subsystem: \n"
first = lines[0]
head = first.split(":", 1)
if len(head) == 2 and head[0] and " " not in head[0].strip():
# existing "prefix: text" subject
lines[0] = f"$subsystem:{head[1]}"
else:
stripped = first.lstrip()
if stripped:
stripped = stripped[0].lower() + stripped[1:]
lines[0] = f"$subsystem: {stripped}"
return "\n".join(lines) + "\n"
def declared_prefix(self, subject):
'''the subsystem prefix declared in a subject, or None'''
if ":" in subject:
return subject.split(":", 1)[0].strip()
return None
def fill_template(self, template, subsystem):
'''substitute subsystem into a $subsystem template, labelling the
subject with it exactly once'''
message = template.replace("$subsystem", subsystem)
lines = message.splitlines()
prefix = subsystem + ":"
if lines and lines[0].startswith(prefix):
rest = lines[0][len(prefix):].lstrip()
while rest.startswith(prefix):
rest = rest[len(prefix):].lstrip()
lines[0] = f"{prefix} {rest}" if rest else prefix
return "\n".join(lines) + ("\n" if message.endswith("\n") else "")
def new_subject(self, subject, subsystem):
'''the subject a commit would get once labelled with subsystem'''
templated = self.build_template(subject)
return self.fill_template(templated, subsystem).splitlines()[0]
def make_message_file(self, git_dir, message):
'''write the (possibly templated) commit message to a file, honouring
--copy / --edit, and return its path'''
msg_file = os.path.join(git_dir, "SUBSYSTEMS_SPLIT_MSG")
if self.option_edit:
editor = os.environ.get("EDITOR")
if not editor:
print("Environment variable EDITOR is required for --edit.",
file=sys.stderr)
sys.exit(1)
with open(msg_file, "w") as fh:
fh.write(message if message.endswith("\n") else message + "\n")
subprocess.run([editor, msg_file], check=True)
elif self.option_copy:
with open(msg_file, "w") as fh:
fh.write(message if message.endswith("\n") else message + "\n")
else:
with open(msg_file, "w") as fh:
fh.write(self.build_template(message))
return msg_file
# -- classification -----------------------------------------------------
def commit_files(self, commit):
'''the list of paths changed by a commit'''
out = self.git(["diff-tree", "--no-commit-id", "--name-only", "-r",
commit])
return [line.strip() for line in out.splitlines() if line.strip()]
def classify_files(self, paths, subsystems, declared):
'''group paths into a list of (subsystem, [paths]), minimising splits.
If one subsystem is common to every path the whole commit stays as a
single group -- labelled with the declared prefix when that is already
valid, otherwise the most specific shared subsystem. Only when no
shared subsystem exists are the paths split by primary subsystem.
'hwdef' is always preferred over a HAL library name (e.g.
AP_HAL_ChibiOS) when both are shared candidates, even if the HAL name
is already the declared prefix -- the deepest directory name is the
canonical tag for any libraries/<HAL>/hwdef/... change.
returns (groups, unmatched).
'''
paths = [p for p in paths if p]
cand = {}
unmatched = []
for p in paths:
c = subsystems.subsystems_for_path(p)
if not c:
unmatched.append(p)
cand[p] = c
if unmatched:
return [], unmatched
if not paths:
return [], []
common = set(cand[paths[0]])
for p in paths[1:]:
common &= set(cand[p])
if common:
if 'hwdef' in common:
chosen = 'hwdef'
elif declared in common:
chosen = declared
else:
chosen = next(s for s in cand[paths[0]] if s in common)
return [(chosen, list(paths))], []
groups = {}
order = []
for p in paths:
s = cand[p][0]
if s not in groups:
groups[s] = []
order.append(s)
groups[s].append(p)
return [(s, groups[s]) for s in order], []
def describe(self, declared, groups):
'''one-line description of what would happen to a commit'''
if len(groups) == 1:
subsystem = groups[0][0]
if subsystem == declared:
return "keep"
return f"relabel '{declared}' -> '{subsystem}'"
return "split -> " + ", ".join(s for s, _ in groups)
def report_unmatched(self, unmatched):
print("These files map to no known subsystem:", file=sys.stderr)
for path in unmatched:
print(f" {path}", file=sys.stderr)
print("Add a rule to Tools/scripts/allowed_subsystems.py.",
file=sys.stderr)
# -- committing ---------------------------------------------------------
def commit_group(self, subsystem, paths, template, author, date, msg_file):
'''stage the given paths and create a single commit for them'''
message = self.fill_template(template, subsystem)
with open(msg_file, "w") as fh:
fh.write(message)
self.git(["reset", "--quiet"], capture=False)
self.git(["add", "--"] + paths, capture=False)
self.progress(f" {subsystem}: {len(paths)} file(s)")
cmd = ["commit", "-F", msg_file, f"--author={author}"]
if date:
cmd.append(f"--date={date}")
self.git(cmd, capture=False)
def commit_author(self, commit):
name = self.git(["log", "-n", "1", "--format=%an", commit])
email = self.git(["log", "-n", "1", "--format=%ae", commit])
return f"{name} <{email}>"
# -- HEAD mode ----------------------------------------------------------
def recover(self, original_head, all_paths):
'''restore the original HEAD and re-stage the files after a failure'''
self.progress("Error; restoring original HEAD...")
self.git(["reset", original_head], capture=False, check=False)
if all_paths:
self.git(["add", "--"] + all_paths, capture=False, check=False)
self.progress("Original state restored.")
def run_head(self, subsystems, git_dir):
message = self.git(["log", "-n", "1", "--format=%B"])
subject = message.splitlines()[0] if message else ""
declared = self.declared_prefix(subject)
files = self.commit_files("HEAD")
groups, unmatched = self.classify_files(files, subsystems, declared)
if unmatched:
self.report_unmatched(unmatched)
return 1
if not groups:
print("Nothing to commit.", file=sys.stderr)
return 1
if self.option_dry_run:
print(f"HEAD {self.describe(declared, groups)}: {subject}")
for subsystem, paths in groups:
print(f" -> {self.new_subject(subject, subsystem)} "
f"({len(paths)} file(s))")
return 0
author = self.commit_author("HEAD")
date = self.git(["log", "-n", "1", "--format=%aI"])
msg_file = self.make_message_file(git_dir, message)
with open(msg_file) as fh:
template = fh.read()
original_head = self.git(["rev-parse", "HEAD"])
self.git(["reset", "HEAD~1", "--soft"], capture=False)
all_paths = [p for _, paths in groups for p in paths]
try:
for subsystem, paths in groups:
self.commit_group(subsystem, paths, template, author, date,
msg_file)
except (subprocess.CalledProcessError, KeyboardInterrupt):
self.recover(original_head, all_paths)
return 1
self.progress(f"Produced {len(groups)} commit(s) from HEAD.")
return 0
# -- branch mode --------------------------------------------------------
def resolve_base(self):
if self.branch_base:
rc, out = self.git_ok(["rev-parse", "--verify",
f"{self.branch_base}^{{commit}}"])
if rc != 0:
print(f"Not a valid base ref: {self.branch_base}",
file=sys.stderr)
return None
return out
for upstream in ("origin/master", "master"):
rc, out = self.git_ok(["merge-base", "HEAD", upstream])
if rc == 0 and out:
return out
print("Could not determine a base; pass --branch <base>.",
file=sys.stderr)
return None
def working_tree_clean(self):
unstaged = subprocess.run(["git", "diff", "--quiet"]).returncode == 0
staged = subprocess.run(["git", "diff", "--cached",
"--quiet"]).returncode == 0
return unstaged and staged
def replay_commit(self, commit, groups, git_dir):
'''re-apply commit's changes as the given (already-computed) groups'''
message = self.git(["log", "-n", "1", "--format=%B", commit])
author = self.commit_author(commit)
date = self.git(["log", "-n", "1", "--format=%aI", commit])
template = self.build_template(message)
self.git(["cherry-pick", "--no-commit", commit], capture=False)
msg_file = os.path.join(git_dir, "SUBSYSTEMS_SPLIT_MSG")
for subsystem, paths in groups:
self.commit_group(subsystem, paths, template, author, date,
msg_file)
def run_branch(self, subsystems, git_dir):
base = self.resolve_base()
if base is None:
return 1
merges = self.git(["rev-list", "--merges", f"{base}..HEAD"])
if merges:
print("Cannot process a range containing merge commits.",
file=sys.stderr)
return 1
commits = self.git(["rev-list", "--reverse", f"{base}..HEAD"]).split()
if not commits:
print("No commits between base and HEAD.", file=sys.stderr)
return 1
plan = []
any_unmatched = False
for commit in commits:
subject = self.git(["log", "-n", "1", "--format=%s", commit])
declared = self.declared_prefix(subject)
files = self.commit_files(commit)
groups, unmatched = self.classify_files(files, subsystems,
declared)
plan.append((commit, subject, declared, groups, unmatched))
if unmatched:
any_unmatched = True
print(f"Plan for {len(commits)} commit(s) since {base[:12]}:")
total_out = 0
for commit, subject, declared, groups, unmatched in plan:
if unmatched:
print(f" {commit[:12]} UNMAPPED: {subject}")
for path in unmatched:
print(f" {path}")
continue
total_out += len(groups)
print(f" {commit[:12]} {self.describe(declared, groups)}: "
f"{subject}")
if len(groups) > 1 or (groups and groups[0][0] != declared):
for subsystem, paths in groups:
print(f" -> {self.new_subject(subject, subsystem)}")
if any_unmatched:
print("Aborting: some files map to no known subsystem; add rules "
"to Tools/scripts/allowed_subsystems.py.", file=sys.stderr)
return 1
if self.option_dry_run:
print(f"(dry run) {len(commits)} commit(s) -> {total_out} "
f"commit(s); no changes made.")
return 0
if not self.working_tree_clean():
print("Refusing to rewrite history with a dirty working tree; "
"commit or stash first.", file=sys.stderr)
return 1
original_head = self.git(["rev-parse", "HEAD"])
self.git(["reset", "--hard", base], capture=False)
try:
for commit, subject, declared, groups, unmatched in plan:
self.replay_commit(commit, groups, git_dir)
except (subprocess.CalledProcessError, KeyboardInterrupt) as e:
print(f"Error during rewrite ({e}); restoring...", file=sys.stderr)
self.git(["cherry-pick", "--abort"], capture=False, check=False)
self.git(["reset", "--hard", original_head], capture=False,
check=False)
print(f"Restored original HEAD {original_head[:12]}.",
file=sys.stderr)
return 1
print(f"Rewrote {len(commits)} commit(s) into {total_out} commit(s).")
print(f"Original HEAD was {original_head[:12]}; recover with: "
f"git reset --hard {original_head[:12]}")
return 0
def run(self):
if not self.parse_args():
return 1
repo_root = self.git(["rev-parse", "--show-toplevel"])
git_dir = self.git(["rev-parse", "--git-dir"])
subsystems = self.make_subsystems(repo_root)
if self.branch_mode:
return self.run_branch(subsystems, git_dir)
return self.run_head(subsystems, git_dir)
if __name__ == "__main__":
sys.exit(GitSubsystemsSplit().run())