Files
brave-core/tools/cr/rebase_v2.py
T
cdesouza-chromium 3213961ae5 [cr150][brockit] Fix fixup! block message parsing (#36610)
[cr150][brockit] Fix handling of `fixup!` block messages

This PR fixes how we are handling the block messages for `fixup!`, as
the parsing for them was always assuming that they would receive the
label `will be skipped:` for not having any message in them, but
actually that doesn't appear to be the case with every `fixup!`. With
this change, `fixup!` blocks that have no message are now discarded too.

Bug: https://github.com/brave/brave-browser/issues/55466
2026-05-21 17:48:00 +01:00

844 lines
34 KiB
Python

# Copyright (c) 2026 The Brave Authors. All rights reserved.
# This Source Code Form is subject to the terms of the Mozilla Public
# License, v. 2.0. If a copy of the MPL was not distributed with this file,
# You can obtain one at https://mozilla.org/MPL/2.0/.
"""Rebase v2 -- an alternative `brockit rebase` code path.
Wired behind `brockit.py rebase --v2`. The module exposes two
file-in / file-out transforms that brockit hooks into git via the
`GIT_SEQUENCE_EDITOR` and `GIT_EDITOR` env vars during a
`git rebase --interactive`:
* `rewrite_plan(todo_file, …)` -- reorders the rebase TODO produced by
`GIT_SEQUENCE_EDITOR`. Pinned commits get grouped to the top, recyclable
commits get optionally evicted, reassign commits get placed next to
their target.
* `MessageWriter.parse(todo_file)` + `rewrite_with_last_message()` --
composes the squash commit message produced by `GIT_EDITOR`, picking
whichever surviving message corresponds to the kind of squash being
performed (`PINNED` or `REASSIGNMENT`).
Whenever either step can't safely transform the file, it raises
`EditorRecoverableFailure`. The brockit dispatch catches it and routes
the file through `hand_off_to_editor`, which prepends the failure reason
as a `#`-comment and opens the user's editor.
Commit categories formalised here (see `EntryType`):
* PINNED -- commits that should be unique in the history of the branch.
Multiple occurrences of the same pinned change are auto-squashed by
`brockit.py rebase --squash-minor-bumps`, and the resulting squash is
moved to the top of the branch history. Priority order is the entry
order in `PINNED_GROUPS`. Subjects that fit this category:
- Update from Chromium * to Chromium *.
- Apply-fixed 🩹 patches from Chromium * to Chromium *.
- Conflict-resolved patches from Chromium * to Chromium *.
- [cr*] `gnrt` run for Chromium *.
- [cr*] IWYU fixes.
- [cr*] Bump resource_ids.
- [cr*] Update Lit mangler snapshot.
- [cr*] Disable failing upstream tests.
* RECYCLABLE -- commits we can always drop and regenerate with tooling.
They're discarded by `brockit.py rebase --discard-regen-changes`.
Subjects that fit this category:
- Update patches from Chromium * to Chromium *.
- Updated strings for Chromium *.
* REASSIGNMENT -- `reassign!<hash>!` commits authored by
`brockit reassign`. Repositioned next to their target when
`pinned_squashed=True`.
* DEFAULT -- everything else.
"""
from __future__ import annotations
import enum
import logging
import os
import re
import shlex
from dataclasses import dataclass
from pathlib import Path
from terminal import terminal
# Brockit-specific prefix for commit messages, similar to `fixup!`.
REASSIGN_COMMIT_MSG_PREFIX = 'reassign!'
# Strips one or more leading bracketed tags whose first token starts with
# `cr` followed by digits -- e.g. `[cr149] `, `[cr149][ios] `. Leaves
# unrelated bracketed tags alone. This means branch tags are irrelevant
# when determining the commit type.
_CR_TAG_RE = re.compile(r'^(?:\[cr\d+\](?:\[[^\]]+\])*\s+)?')
# Strips repeated leading autosquash prefixes like `fixup! `, `amend! `,
# `squash! ` -- these stack when commits fix up an already-fixup commit
# (e.g. `fixup! fixup! fixup! Conflict-resolved patches …`). Pre-stripping
# them lets the pinned/recyclable regexes match the underlying subject
# regardless of how many autosquash levels deep the user has gone.
_AUTOSQUASH_PREFIX_RE = re.compile(r'^(?:(?:fixup|amend|squash)!\s+)+')
# Chromium version shape: exactly four dot-separated numeric segments
# (e.g. 149.0.7827.5).
_CR_VERSION = r'\d+\.\d+\.\d+\.\d+'
# Matches the contiguous `!`-separated subcommand prefix of a comment, e.g.
# `reassign!hash!`, `amend!`, `fixup!`. Git already uses `amend!`, `fixup!`,
# but brockit uses this format to make it a `!`-separated list for subcommands
# (e.g `reassign!hash!` → `['reassign', 'hash']`). The first token must start
# with a letter (it's the subcommand name itself -- `reassign`, `amend`,
# `fixup`, `squash`); subsequent tokens are allowed to start with a digit so
# commit short-hashes like `859ab9caa74` are captured intact.
_SUBCOMMAND_RE = re.compile(r'^[A-Za-z]\w*!(?:\w+!)*')
# Matches a `# <word>! <subject>` autosquash marker line inside a squash
# editor block (e.g. `# amend! Update from Chromium …`). Group 1 captures
# the marker word with its trailing `!`; group 2 captures the rest of
# the line as the commit-subject suffix.
_AUTOSQUASH_NOTE_RE = re.compile(r'#\s*(\w+!)\s*(.+)')
def _strip_cr_tag(subject: str) -> str:
"""Returns `subject` with any leading `[crNNN]` (or `[crNNN][...]…`)
tag and the trailing whitespace removed. A subject that doesn't
start with such a tag is returned unchanged."""
return _CR_TAG_RE.sub('', subject, count=1)
def _strip_autosquash_prefixes(subject: str) -> str:
"""Returns `subject` with any leading stack of `fixup! ` / `amend! `
/ `squash! ` prefixes removed. Subjects without those prefixes are
returned unchanged."""
return _AUTOSQUASH_PREFIX_RE.sub('', subject)
def _match(subject: str, pattern: re.Pattern) -> bool:
"""True if `pattern` fully matches `subject` after stripping any
leading `fixup!` / `amend!` / `squash!` autosquash prefixes and any
`[crNNN]` tag. The stripped subject is also right-trimmed so a
trailing space in the original line doesn't break the fullmatch."""
cleaned = _strip_cr_tag(_strip_autosquash_prefixes(subject)).rstrip()
return pattern.fullmatch(cleaned) is not None
# Ordered list of `(pinned-group-id, regex)` entries. The order *is* the
# squash priority order: when multiple pinned groups are present in the
# same rebase plan, they're emitted top-to-bottom in this order
# (version → plaster_reruns → conflict → gnrt → IWYU →
# resource_ids → lit_mangler → disable_tests). The regexes are
# anchored fullmatches applied to the `[crNNN]`-stripped subject, so each
# pattern matches both the tagged and the untagged form of the same commit.
PINNED_GROUPS = [
('version',
re.compile(rf'Update from Chromium {_CR_VERSION} to '
rf'Chromium {_CR_VERSION}\.?')),
('plaster_reruns',
re.compile(rf'Apply-fixed 🩹 patches from Chromium {_CR_VERSION} to '
rf'Chromium {_CR_VERSION}\.?')),
('conflict',
re.compile(rf'Conflict-resolved patches from Chromium {_CR_VERSION} to '
rf'Chromium {_CR_VERSION}\.?')),
('gnrt', re.compile(rf'`gnrt` run for Chromium {_CR_VERSION}\.?')),
('iwyu', re.compile(r'IWYU fixes\.?')),
('resource_ids', re.compile(r'Bump resource_ids\.?')),
('lit_mangler', re.compile(r'Update Lit mangler snapshot\.?')),
('disable_tests', re.compile(r'Disable failing upstream tests\.?')),
]
# Subjects produced by `npm run update_patches` and string generation --
# evicted when `discard_recyclable=True`.
RECYCLABLE_PATTERNS = [
re.compile(rf'Update patches from Chromium {_CR_VERSION} to '
rf'Chromium {_CR_VERSION}\.?'),
re.compile(rf'Updated strings for Chromium {_CR_VERSION}\.?'),
]
def get_pinned_group_for_subject(subject: str) -> str | None:
"""Returns the `PINNED_GROUPS` group id that `subject` matches
(e.g. `'version'`, `'iwyu'`), or `None` when no pattern matches.
Matching strips a leading `[crNNN]`-style tag, so the tagged and
untagged forms of the same subject return the same group id."""
for pinned_group, pattern in PINNED_GROUPS:
if _match(subject, pattern):
return pinned_group
return None
def is_recyclable(subject: str) -> bool:
"""Returns True when `subject` matches any `RECYCLABLE_PATTERNS`
entry -- i.e. the commit is auto-generated and can be regenerated
from the current source tree."""
return any(_match(subject, p) for p in RECYCLABLE_PATTERNS)
def get_entry_type_for_subject(subject: str) -> 'EntryType':
"""Classifies a bare commit subject (no TODO command/hash prefix)
into one of the four `EntryType` variants."""
if subject.startswith(REASSIGN_COMMIT_MSG_PREFIX):
return EntryType.REASSIGNMENT
if get_pinned_group_for_subject(subject) is not None:
return EntryType.PINNED
if is_recyclable(subject):
return EntryType.RECYCLABLE
return EntryType.DEFAULT
def get_git_editor(primary_env: str = 'GIT_EDITOR') -> str:
"""Returns the editor brockit should fall back to when a v2 rebase
step can't transform the file it was handed. Resolution order:
Must be called BEFORE the caller overrides `GIT_SEQUENCE_EDITOR` /
`GIT_EDITOR` (e.g. before `Rebase.execute` sets its own dispatch as
the editor).
"""
fallbacks = [primary_env]
if primary_env != 'GIT_EDITOR':
fallbacks.append('GIT_EDITOR')
fallbacks.extend(['VISUAL', 'EDITOR'])
for env_name in fallbacks:
value = os.environ.get(env_name)
if not value:
continue
if 'brockit.py' in value and '--internal-rebase-' in value:
raise NotImplementedError(
f'${env_name} is already set to a brockit internal '
f'dispatch ({value!r}). `get_git_editor` must run '
f'BEFORE brockit overrides the env vars -- otherwise '
f'the editor fallback would loop on ourselves.')
return value
return 'vim'
def hand_off_to_editor(todo_file: Path, reason: str, editor: str) -> None:
"""Spawns `editor` on `todo_file` and waits for it.
`editor` is the caller-resolved editor command (typically the value
captured by `get_git_editor` and threaded through via
`--internal-rebase-crash-*-editor`). The caller is responsible for
making sure it's non-empty before getting here -- this function
has no env-var fallback of its own.
When `reason` is non-empty, each of its lines is prepended to the
file as a `#`-comment so the user sees why we couldn't rewrite the
message automatically. Lines starting with `#` already are passed
through unchanged.
"""
if reason:
intro = ('# B🚀 has been unable to handle this rebase. Investigate '
'why, and\n'
'# file a bug. Failure reason below.\n')
prefix = intro + ''.join(
(line if line.startswith('#') else f'# {line}') + '\n'
for line in reason.splitlines())
todo_file.write_text(prefix + todo_file.read_bytes().decode('utf-8'),
encoding='utf-8',
newline='')
# `editor` may be a multi-token command (e.g. `vim -X`,
# `code --wait`, `my_editor --feature="with space"`).
terminal.run(shlex.split(editor) + [str(todo_file)], interactive=True)
class EntryType(enum.Enum):
"""The type of a given change based on the subject line.
* `PINNED` -- subject matches one of `PINNED_GROUPS`.
* `RECYCLABLE` -- subject matches one of `RECYCLABLE_PATTERNS`.
* `REASSIGNMENT` -- subject (or autosquash subcommand) starts with
`reassign!`.
* `DEFAULT` -- none of the above; produced only by `EntryLine`.
`MessageWriter.parse` never returns this value
-- it raises `EditorRecoverableFailure` instead.
"""
PINNED = 'pinned'
RECYCLABLE = 'recyclable'
REASSIGNMENT = 'reassignment'
DEFAULT = 'default'
class EditorRecoverableFailure(Exception):
"""Parsing failures during rebase that should be investigated manually.
This is used for cases where some unrecognised pattern is found and the
code we have doesn't know how to handle it. This shouldn't happen, but if
it does, we want to be able to inspect things, take over the process
manually, and even correct the issue later on in brockit.
The expected handling is for the actual editor to be launched for manual
intervention, with the failure reason prepended as a comment to the file.
This exception is *not* for actual violation of invariants. For those use
more appropriate, non-recoverable exceptions. This is also not for natural
user prompting. If anything like this is needed in the future, we should
have proper flows that do not involve the use of exceptions for control
flow.
"""
class EntryLine:
"""A parsed line from a `git rebase --interactive` TODO file.
"""
def __init__(
self,
*,
out: str,
command: str,
# Disabling pylint here as `hash` is an appropriate word.
hash: str, # pylint: disable=redefined-builtin
message: str,
subcommand: list | None = None,
note: str | None = None):
# The text for the entry line in the file. This is loaded from the
# file, however it does get updated when fields in this class are
# updated.
self._out = out
# The rebase command word(s) before the hash: `pick`, `edit`,
# `squash`, `fixup`, `fixup -C`, etc. May contain spaces.
self._command = command
# The commit short-hash that follows the command.
self._hash = hash
# The `!`-separated autosquash prefix tokens, with the trailing
# empty segment dropped (e.g. `['reassign', 'c080270dd7e']`).
self._subcommand = subcommand
# The commit subject. The subcommand is already pilled off in this
# field, which means, no `fixup!`, `amend!`, `reassign!`, etc.
self._message = message
# The trailing ` # <note>` marker (e.g. `'empty'`), or `None`.
self._note = note
# Classification fields. `_entry_type` defaults to DEFAULT and
# is overridden below based on the subcommand/message; the
# result is read-only for the rest of this `EntryLine`'s
# lifetime.
self._pinned_group: str | None = None
self._entry_type: EntryType = EntryType.DEFAULT
if self._subcommand and self._subcommand[0] == 'reassign':
self._entry_type = EntryType.REASSIGNMENT
elif is_recyclable(self._message):
self._entry_type = EntryType.RECYCLABLE
else:
pinned_group = get_pinned_group_for_subject(self._message)
if pinned_group is not None:
self._pinned_group = pinned_group
self._entry_type = EntryType.PINNED
# ----- read-only properties ---------------------------------------------
@property
def out(self) -> str:
return self._out
@property
def hash(self) -> str:
return self._hash
@property
def message(self) -> str:
return self._message
@property
def subcommand(self) -> list | None:
return self._subcommand
@property
def note(self) -> str | None:
return self._note
@property
def pinned_group(self) -> str | None:
return self._pinned_group
@property
def entry_type(self) -> 'EntryType':
return self._entry_type
@property
def is_pinned(self) -> bool:
return self._entry_type is EntryType.PINNED
@property
def is_recyclable(self) -> bool:
return self._entry_type is EntryType.RECYCLABLE
@property
def is_reassignment(self) -> bool:
return self._entry_type is EntryType.REASSIGNMENT
@property
def reassign_target_hash(self) -> str | None:
"""The target hash carried in the `reassign!<hash>!` prefix --
or `None` if the prefix has no hash. Raises
`NotImplementedError` on a non-reassignment commit (callers
should gate on `is_reassignment` first)."""
if not self.is_reassignment:
raise NotImplementedError(
'reassign_target_hash is only defined for reassignment '
'commits')
if self._subcommand and len(self._subcommand) >= 2:
return self._subcommand[1]
return None
# ----- the one mutable property -----------------------------------------
@property
def command(self) -> str:
"""Assigning to this property also updates `out` so the emitted
line stays in sync."""
return self._command
@command.setter
def command(self, new_command: str) -> None:
self._command = new_command
self._refresh_out()
# ----- public methods ---------------------------------------------------
@staticmethod
def parse(line: str) -> 'EntryLine | None':
"""Parses a TODO line into a `EntryLine`.
Returns `None` for blank or pure-comment lines. Raises
`EditorRecoverableFailure` for non-empty lines that don't match
the expected `<command> <hash> # <comment>` shape -- the caller
is expected to surface the failure via `hand_off_to_editor`.
"""
if not line.strip() or line.lstrip().startswith('#'):
return None
# Split into at most three sections: command-part, comment, and an
# optional trailing ` # <note>` marker (e.g. ` # empty`). Capping
# the split keeps any `#` inside the comment itself intact.
parts = line.rstrip().split('#', maxsplit=2)
if len(parts) < 2:
raise EditorRecoverableFailure(
f'Cannot parse TODO line (no comment): {line!r}')
cmd_part = parts[0].rstrip()
comment = parts[1].strip()
note = (parts[2].strip() or None) if len(parts) == 3 else None
# The hash is the last whitespace-separated token of the command
# part; everything before it is the command (e.g. `pick`, `edit`,
# `fixup -C`).
comand_parts = cmd_part.rsplit(maxsplit=1)
if len(comand_parts) != 2:
raise EditorRecoverableFailure(
f'Cannot parse TODO line (no hash): {line!r}')
command, commit_hash = comand_parts
subcommand = None
sub_match = _SUBCOMMAND_RE.match(comment)
if sub_match:
subcommand = [tok for tok in sub_match.group(0).split('!') if tok]
message = comment[sub_match.end():].lstrip()
else:
message = comment
return EntryLine(out=line,
command=command,
hash=commit_hash,
subcommand=subcommand,
message=message,
note=note)
# ----- private helpers --------------------------------------------------
def _refresh_out(self) -> None:
"""Regenerates `_out` from the current field values in the
canonical `<command> <hash> # [<sub1>!<sub2>! ]<message>[ # <note>]`
form. Called automatically by the `command` setter."""
subcommand_prefix = (''.join(f'{tok}!' for tok in self._subcommand) +
' ' if self._subcommand else '')
line = (f'{self._command} {self._hash} # '
f'{subcommand_prefix}{self._message}')
if self._note:
line = f'{line} # {self._note}'
self._out = line
def rewrite_plan(*,
todo_file: Path,
pinned_squashed: bool = False,
discard_recyclable: bool = False) -> None:
"""Rewrites a `git rebase --interactive` TODO file in place.
The two operations are independent and may be used alone or
combined. When both flags are `False` this is a no-op: the file is
neither read nor written.
Args:
todo_file: Path to the `git-rebase-todo` file that
`GIT_SEQUENCE_EDITOR` was invoked with.
pinned_squashed: When `True`, group pinned commits at the top
of the plan, in `PINNED_GROUPS` priority order. The first
commit in each group stays `pick`; subsequent ones become
`squash`. Reassign commits are repositioned to sit
immediately above their target (with the target rewritten
to `squash`). Defaults to `False`.
discard_recyclable: When `True`, drop recyclable commits from
the plan entirely. Defaults to `False`.
Raises:
EditorRecoverableFailure: Any TODO line that doesn't parse
(malformed shape, missing hash, etc.) bubbles up so the
caller can punt to `hand_off_to_editor`.
"""
if not pinned_squashed and not discard_recyclable:
# Nothing to do -- use whatever plan git produced.
return
pinned_groups = {group: [] for group, _ in PINNED_GROUPS}
all_others = []
def add_reassign_before_target(reassign: EntryLine) -> None:
"""Looks for `reassign`'s target in `all_others` and, if found,
inserts `reassign` immediately above it while rewriting the
target to `squash`. Reassignment targets are required to be
regular commits -- pinned and recyclable commits aren't
supported, so a reassign whose target is pinned (or got dropped
as recyclable) ends up orphaned and silently discarded."""
def find_target(predicate) -> tuple[EntryLine | None, int | None]:
"""Walks `all_others` in reverse order and returns the first
entry that satisfies `predicate`, paired with its index. The
reverse walk is a small optimisation: a reassign's target is
usually the immediately-preceding entry_line, so the typical hit
is on the first iteration. Returns `(None, None)` when no
entry matches."""
return next(((c, i) for c, i in zip(
reversed(all_others), range(len(all_others) - 1, -1, -1))
if predicate(c)), (None, None))
target, target_idx = None, None
if reassign.reassign_target_hash is not None:
target, target_idx = find_target(
lambda c: c.hash == reassign.reassign_target_hash)
if target is None:
# Fallback search by commit message. This is allowed.
target, target_idx = find_target(
lambda c: c.message == reassign.message)
if target is None:
# Reassignment is orphaned -- drop it and do nothing.
logging.warning('Dropping orphaned reassignment: %s',
reassign.out.strip())
return
# The `target` becomes `squash` so its message/authorship is
# absorbed by the reassign; the reassign keeps its `pick` and
# slots in right above. The `command` setter on `EntryLine` keeps
# `out` in sync automatically.
target.command = 'squash'
all_others.insert(target_idx, reassign)
for line in todo_file.read_bytes().decode('utf-8').splitlines():
entry_line = EntryLine.parse(line)
if entry_line is None:
# Irrelevant line (blank or comment).
continue
# Dropping discardable commits if we don't need them.
if discard_recyclable and entry_line.is_recyclable:
continue
if pinned_squashed:
if entry_line.is_reassignment:
add_reassign_before_target(entry_line)
continue
if entry_line.pinned_group is not None:
# First commit in the group becomes `pick`; subsequent
# ones become `squash`. The setter keeps `out` in sync.
entry_line.command = (
'pick' if not pinned_groups[entry_line.pinned_group] else
'squash')
pinned_groups[entry_line.pinned_group].append(entry_line)
continue
all_others.append(entry_line)
new_plan = [
*(c.out for group in pinned_groups.values() for c in group),
*(c.out for c in all_others)
]
todo_file.write_text('\n'.join(new_plan) + '\n',
encoding='utf-8',
newline='')
_COMMIT_MSG_HEADER_GUARDS = re.compile(
r'^# (This is the (?:1st|\d+(?:st|nd|rd|th)) commit message:|'
r'This is the commit message #\d+:|'
r'The commit message #\d+ will be skipped:)$')
class MsgBlock:
"""One commit-message block carved out of a `GIT_EDITOR` squash file.
When git squashes N commits, it builds a single editor file whose
body is the concatenation of each original commit's message, each
preceded by a `# This is …` header line that git itself inserts.
`MsgBlock` is the parsed unit for one of those sections.
Example (one squash combining three commits):
# This is a combination of 3 commits.
# This is the 1st commit message:
Update from Chromium 1.0.0.0 to Chromium 1.0.0.1
# This is the commit message #2:
# amend! Update from Chromium 1.0.0.0 to Chromium 1.0.0.1
Tweaked: dropped an extraneous header bump.
# The commit message #3 will be skipped:
# fixup! Update from Chromium 1.0.0.0 to Chromium 1.0.0.1
# Please enter the commit message for your changes. …
`MsgBlock.parse` walks the file and produces:
* Block 1 -- `full_message='Update from Chromium 1.0.0.0 to
Chromium 1.0.0.1'`, `note=None` (no autosquash marker).
* Block 2 -- `full_message='Tweaked: dropped an extraneous header
bump.'`, `note=OriginalCommitNote(command='amend!', first_line=...)`.
* Block 3 (the `will be skipped` block) is skipped by the parser and not
supported.
"""
@dataclass(frozen=True)
class OriginalCommitNote:
"""The autosquash marker line that git inserts inside some
commit-message blocks.
"""
# The marker word including its trailing `!`. One of `'amend!'`,
# `'squash!'`, `'fixup!'`.
command: str
# The commit-subject suffix on the same line as the marker.
first_line: str
@staticmethod
def from_line(line: str) -> 'MsgBlock.OriginalCommitNote | None':
"""Parses `line` as an autosquash marker. Returns an
`OriginalCommitNote` when it matches `# <word>! <subject>`,
`None` otherwise (e.g. a plain content line)."""
match = _AUTOSQUASH_NOTE_RE.match(line)
if match:
return MsgBlock.OriginalCommitNote(command=match.group(1),
first_line=match.group(2))
return None
def __init__(self,
*,
full_message: str,
note: 'MsgBlock.OriginalCommitNote | None' = None):
# The commit body for this block with `#`-comment lines removed
# and leading / trailing blanks trimmed.
self._full_message = full_message
# The autosquash marker that opened this block, or `None` when
# the block had no marker.
self._note = note
@property
def full_message(self) -> str:
return self._full_message
@property
def note(self) -> 'MsgBlock.OriginalCommitNote | None':
return self._note
def _extend_message(self, more: str) -> None:
"""Appends `more` on a new line to `_full_message`. Used by
`parse` when merging a `# squash!` block into its predecessor;
kept private so external callers can't mutate a block's body
ad-hoc."""
self._full_message = f'{self._full_message}\n{more}'
@staticmethod
def parse(text: str) -> list['MsgBlock']:
"""Splits `text` into a list of `MsgBlock`s, in source order.
This function goes over each block in the produced editor file and
extracts the commit messages for each case. Blocks for autosquash
changes are skipped. Blocks for `squash!` are merged into the previous
block. The rest are emitted as-is.
Raises `EditorRecoverableFailure` for malformed input (too few
lines for a valid block, an empty body, or a `squash!` pointing
at an empty previous block).
"""
def get_message_blocks_ranges(lines: list[str]):
start_idx = None
for i, line in enumerate(lines):
# Stop at the Git boilerplate
if line.startswith("# Please enter the commit message"):
if start_idx is not None:
yield lines[start_idx:i]
return # Cleanly exit the generator
# When we hit a new header, yield the slice of the
# previous block.
if _COMMIT_MSG_HEADER_GUARDS.match(line):
if start_idx is not None:
yield lines[start_idx:i]
start_idx = i # Mark the start of the new block
# Yield the very last block if the loop finishes normally
if start_idx is not None:
yield lines[start_idx:]
blocks = []
for block_lines in get_message_blocks_ranges(text.splitlines()):
# A valid block is at minimum `header`, `<blank>`, `<subject>`
# -- three lines. Noted blocks (`# squash!`, `# amend!`,
# `# fixup!`) are longer; we detect the note at index 2 if
# enough lines are present.
if len(block_lines) < 3:
raise EditorRecoverableFailure(
f'Unexpected block format: {block_lines}')
# `will be skipped` blocks aren't relevant for commit
# messages -- git already commented out their content.
# Example:
# The commit message #4 will be skipped:
#
# fixup! [cr149][brockit] `@latest-{channel}` latest
# version for all platforms
#
if "will be skipped:" in block_lines[0]:
continue
# The commit note line only appears next to the header line,
# with an empty line in between. For example:
# This is the commit message #3:
#
# amend! [cr149][brockit] `@latest-{channel}` latest version for all
note = MsgBlock.OriginalCommitNote.from_line(block_lines[2])
if note:
# The note pushes the beginning of the message a few
# lines down.
message_lines = block_lines[4:]
else:
# With no note, the message starts immediately after the
# header and empty line.
message_lines = block_lines[2:]
msg = "\n".join(line.strip() for line in message_lines).strip()
if (note is not None and note.command == "squash!" and msg
and blocks):
if not blocks[-1].full_message:
raise EditorRecoverableFailure(
'Unexpected empty message in previous block when '
'processing a squash! block with a note. Previous '
f'block lines: {blocks[-1].full_message}')
# For `# squash!`, append to the previous block's body
# and discard this block.
blocks[-1]._extend_message(msg)
elif msg:
blocks.append(MsgBlock(full_message=msg, note=note))
elif note is not None and note.command == "fixup!":
# A bare `# fixup!` block carries no message of its own
# -- git is just echoing the marker for context (this
# also happens with stacked `# fixup! fixup! …` markers).
# Treat it the same as a `will be skipped:` block.
continue
else:
raise EditorRecoverableFailure(
f'Unexpected empty message in block. Block lines: '
f'{block_lines}')
return blocks
@dataclass
class MessageWriter:
"""Decides what to do with a `GIT_EDITOR` squash commit-message file.
Construct one via the `MessageWriter.parse` factory: it reads the
file, checks that the squash is recognisable (either a pinned
subject or a `reassign!`), and stores the parsed `MsgBlock`s.
Use `rewrite_with_last_message()` to write the surviving message back.
Anything we can't classify raises `EditorRecoverableFailure` and is left
for the user to edit.
This class only supports PINNED and REASSIGNMENT message editing, because
these are the changes we know what to do with. Changes of any other type
arriving in this class are a sign of something went wrong, and will result
in `EditorRecoverableFailure` being raised.
"""
# The squash editor file we'll rewrite.
todo_file: Path
# Every commit-message block parsed out of `todo_file`, in source
# order. The `reassign!` first block (in the reassignment case) is
# retained here -- it's the *last* block that gets written back,
# not the first.
blocks: list[MsgBlock]
# The set of first-block entry types we know how to rewrite. Other
# classifications (RECYCLABLE, DEFAULT) are punted to the editor.
_HANDLED_ENTRY_TYPES = frozenset(
{EntryType.PINNED, EntryType.REASSIGNMENT})
def rewrite_with_last_message(self) -> None:
"""Writes the trailing block's `full_message` (with a trailing
newline) back to `todo_file`. Always valid:
`MessageWriter.parse` only returns instances with at least one
block whose first-block content classifies as PINNED or
REASSIGNMENT."""
self.todo_file.write_text(self.blocks[-1].full_message + '\n',
encoding='utf-8',
newline='')
@staticmethod
def parse(todo_file: Path) -> 'MessageWriter':
"""Reads `todo_file` and returns a `MessageWriter` provided the
first commit-message block's subject classifies as one of
`MessageWriter._HANDLED_ENTRY_TYPES` (currently `PINNED` and
`REASSIGNMENT`).
Any other first-block classification (RECYCLABLE, DEFAULT) and
any file that fails to parse / yields no blocks raises
`EditorRecoverableFailure`. The exception message names the
rejected `EntryType` so the user sees, in the editor, why the
rewrite was punted.
"""
blocks = MsgBlock.parse(todo_file.read_bytes().decode('utf-8'))
if not blocks:
raise EditorRecoverableFailure(
'No commit-message blocks were parsed from the editor file.')
first_msg_line = blocks[0].full_message.splitlines()[0]
entry_type = get_entry_type_for_subject(first_msg_line)
if entry_type not in MessageWriter._HANDLED_ENTRY_TYPES:
raise EditorRecoverableFailure(
f'Cannot rewrite a squash commit message classified as '
f'{entry_type.value!r}. First content line: '
f'{first_msg_line!r}')
return MessageWriter(todo_file=todo_file, blocks=blocks)