This pr introduces full test coverage for `Rebase`, including integration tests, in preparation for `rebase --continue`.
717 lines
32 KiB
Python
Executable File
717 lines
32 KiB
Python
Executable File
#!/usr/bin/env vpython3
|
|
# 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/.
|
|
"""Tests for `brockit.Rebase`.
|
|
|
|
This file is organised in two layers:
|
|
|
|
* `RebaseStaticHelpersTest` covers the four `@staticmethod` rebase-todo-file
|
|
transformers as pure file-in/file-out unit tests. No git involved.
|
|
|
|
* `RebaseExecuteTest` covers `Rebase.execute()` end-to-end against a real git
|
|
repository tree built by `FakeChromiumRepo`. The editor callbacks
|
|
(`GIT_SEQUENCE_EDITOR`, `GIT_EDITOR`) re-enter `brockit.py`'s `__main__`
|
|
dispatch via subprocess, exactly as in production -- nothing is mocked out.
|
|
"""
|
|
|
|
import tempfile
|
|
import unittest
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import brockit
|
|
from test.fake_chromium_repo import FakeChromiumRepo
|
|
|
|
|
|
class RebaseStaticHelpersTest(unittest.TestCase):
|
|
"""Tests for the four todo-file transformers on `Rebase`."""
|
|
|
|
def setUp(self):
|
|
tmp = tempfile.TemporaryDirectory()
|
|
self.addCleanup(tmp.cleanup)
|
|
self._tmp_root = Path(tmp.name)
|
|
|
|
def _todo(self, content: str) -> Path:
|
|
"""Writes `content` to a fresh todo file and returns its path."""
|
|
path = self._tmp_root / 'git-rebase-todo'
|
|
path.write_text(content)
|
|
return path
|
|
|
|
# ----- discard_regen_changes_from_rebase_plan ----------------------------
|
|
|
|
def test_discard_regen_removes_update_patches_line(self):
|
|
"""A line whose comment starts with `Update patches from Chromium ` is
|
|
dropped; everything else is kept verbatim."""
|
|
path = self._todo('pick aaa # [cr148] Real feature commit\n'
|
|
'pick bbb # Update patches from Chromium 1.0.0.0 '
|
|
'to Chromium 1.0.0.1.\n'
|
|
'pick ccc # [cr148] Another feature commit\n')
|
|
|
|
brockit.Rebase.discard_regen_changes_from_rebase_plan(path)
|
|
|
|
self.assertEqual(
|
|
path.read_text(), 'pick aaa # [cr148] Real feature commit\n'
|
|
'pick ccc # [cr148] Another feature commit\n')
|
|
|
|
def test_discard_regen_removes_updated_strings_line(self):
|
|
"""A line whose comment starts with `Updated strings for Chromium ` is
|
|
dropped; everything else is kept verbatim."""
|
|
path = self._todo('pick aaa # [cr148] Real feature commit\n'
|
|
'pick bbb # Updated strings for Chromium 1.0.0.0 '
|
|
'to Chromium 1.0.0.1.\n'
|
|
'pick ccc # [cr148] Another feature commit\n')
|
|
|
|
brockit.Rebase.discard_regen_changes_from_rebase_plan(path)
|
|
|
|
self.assertEqual(
|
|
path.read_text(), 'pick aaa # [cr148] Real feature commit\n'
|
|
'pick ccc # [cr148] Another feature commit\n')
|
|
|
|
def test_discard_regen_preserves_comments_blanks_and_other_picks(self):
|
|
"""Comment lines, blank lines, and unrelated picks survive."""
|
|
path = self._todo('# Rebase plan generated by git\n'
|
|
'\n'
|
|
'pick aaa # [cr148] Feature A\n'
|
|
'pick bbb # Update patches from Chromium 1.0.0.0 '
|
|
'to Chromium 1.0.0.1.\n'
|
|
'pick ccc # [cr148] Feature B\n'
|
|
'\n'
|
|
'# Commands:\n')
|
|
|
|
brockit.Rebase.discard_regen_changes_from_rebase_plan(path)
|
|
|
|
self.assertEqual(
|
|
path.read_text(), '# Rebase plan generated by git\n'
|
|
'\n'
|
|
'pick aaa # [cr148] Feature A\n'
|
|
'pick ccc # [cr148] Feature B\n'
|
|
'\n'
|
|
'# Commands:\n')
|
|
|
|
def test_discard_regen_empty_file_is_noop(self):
|
|
"""An empty todo file remains empty."""
|
|
path = self._todo('')
|
|
|
|
brockit.Rebase.discard_regen_changes_from_rebase_plan(path)
|
|
|
|
self.assertEqual(path.read_text(), '')
|
|
|
|
# ----- recommit_in_rebase_plan -------------------------------------------
|
|
|
|
def test_recommit_flips_only_first_pick(self):
|
|
"""The first occurrence of `pick` becomes `edit`; later `pick`s stay."""
|
|
path = self._todo('pick aaa # one\n'
|
|
'pick bbb # two\n'
|
|
'pick ccc # three\n')
|
|
|
|
brockit.Rebase.recommit_in_rebase_plan(path)
|
|
|
|
self.assertEqual(
|
|
path.read_text(), 'edit aaa # one\n'
|
|
'pick bbb # two\n'
|
|
'pick ccc # three\n')
|
|
|
|
def test_recommit_no_pick_leaves_file_unchanged(self):
|
|
"""A todo file with no `pick` lines is untouched."""
|
|
path = self._todo('# Just comments\n\n')
|
|
|
|
brockit.Rebase.recommit_in_rebase_plan(path)
|
|
|
|
self.assertEqual(path.read_text(), '# Just comments\n\n')
|
|
|
|
# ----- squash_minor_bumps_from_rebase_plan -------------------------------
|
|
|
|
def test_squash_collapses_consecutive_version_bumps(self):
|
|
"""Repeated `Update from Chromium` commits keep the first as pick and
|
|
become squash for the rest, preserving their order."""
|
|
path = self._todo(
|
|
'pick aaa # Update from Chromium 1.0.0.0 to Chromium 1.0.0.1\n'
|
|
'pick bbb # Update from Chromium 1.0.0.1 to Chromium 1.0.0.2\n'
|
|
'pick ccc # Update from Chromium 1.0.0.2 to Chromium 1.0.0.3\n')
|
|
|
|
brockit.Rebase.squash_minor_bumps_from_rebase_plan(path)
|
|
|
|
self.assertEqual(
|
|
path.read_text(),
|
|
'pick aaa # Update from Chromium 1.0.0.0 to Chromium 1.0.0.1\n'
|
|
'squash bbb # Update from Chromium 1.0.0.1 to Chromium 1.0.0.2\n'
|
|
'squash ccc # Update from Chromium 1.0.0.2 to Chromium 1.0.0.3\n')
|
|
|
|
def test_squash_reorders_categories(self):
|
|
"""The output order is version, plaster reruns, conflict, gnrt, iwyu,
|
|
then everything else -- regardless of input order."""
|
|
path = self._todo(
|
|
'pick aaa # [cr148] Some feature commit\n'
|
|
'pick bbb # Conflict-resolved patches from Chromium 1.0.0.0 '
|
|
'to Chromium 1.0.0.1.\n'
|
|
'pick ccc # Update from Chromium 1.0.0.0 to Chromium 1.0.0.1\n'
|
|
'pick ddd # [cr148] `gnrt` run for Chromium 1.0.0.1\n'
|
|
'pick eee # [cr148] IWYU fixes.\n'
|
|
'pick fff # Apply-fixed 🩹 patches from Chromium 1.0.0.0 '
|
|
'to Chromium 1.0.0.1.\n')
|
|
|
|
brockit.Rebase.squash_minor_bumps_from_rebase_plan(path)
|
|
|
|
self.assertEqual(
|
|
path.read_text(),
|
|
'pick ccc # Update from Chromium 1.0.0.0 to Chromium 1.0.0.1\n'
|
|
'pick fff # Apply-fixed 🩹 patches from Chromium 1.0.0.0 '
|
|
'to Chromium 1.0.0.1.\n'
|
|
'pick bbb # Conflict-resolved patches from Chromium 1.0.0.0 '
|
|
'to Chromium 1.0.0.1.\n'
|
|
'pick ddd # [cr148] `gnrt` run for Chromium 1.0.0.1\n'
|
|
'pick eee # [cr148] IWYU fixes.\n'
|
|
'pick aaa # [cr148] Some feature commit\n')
|
|
|
|
def test_squash_collapses_plaster_reruns(self):
|
|
"""First `Apply-fixed patches` stays pick; the rest become squash."""
|
|
path = self._todo(
|
|
'pick aaa # Apply-fixed 🩹 patches from Chromium 1.0.0.0 '
|
|
'to Chromium 1.0.0.1.\n'
|
|
'pick bbb # Apply-fixed 🩹 patches from Chromium 1.0.0.1 '
|
|
'to Chromium 1.0.0.2.\n')
|
|
|
|
brockit.Rebase.squash_minor_bumps_from_rebase_plan(path)
|
|
|
|
self.assertEqual(
|
|
path.read_text(),
|
|
'pick aaa # Apply-fixed 🩹 patches from Chromium 1.0.0.0 '
|
|
'to Chromium 1.0.0.1.\n'
|
|
'squash bbb # Apply-fixed 🩹 patches from Chromium 1.0.0.1 '
|
|
'to Chromium 1.0.0.2.\n')
|
|
|
|
def test_squash_collapses_conflict_resolved(self):
|
|
"""Repeated `Conflict-resolved patches` commits keep first as pick."""
|
|
path = self._todo(
|
|
'pick aaa # Conflict-resolved patches from Chromium 1.0.0.0 '
|
|
'to Chromium 1.0.0.1.\n'
|
|
'pick bbb # Conflict-resolved patches from Chromium 1.0.0.1 '
|
|
'to Chromium 1.0.0.2.\n')
|
|
|
|
brockit.Rebase.squash_minor_bumps_from_rebase_plan(path)
|
|
|
|
self.assertEqual(
|
|
path.read_text(),
|
|
'pick aaa # Conflict-resolved patches from Chromium 1.0.0.0 '
|
|
'to Chromium 1.0.0.1.\n'
|
|
'squash bbb # Conflict-resolved patches from Chromium 1.0.0.1 '
|
|
'to Chromium 1.0.0.2.\n')
|
|
|
|
def test_squash_collapses_gnrt_runs(self):
|
|
"""Repeated `gnrt` run commits keep first as pick."""
|
|
path = self._todo(
|
|
'pick aaa # [cr148] `gnrt` run for Chromium 1.0.0.1\n'
|
|
'pick bbb # [cr148] `gnrt` run for Chromium 1.0.0.2\n')
|
|
|
|
brockit.Rebase.squash_minor_bumps_from_rebase_plan(path)
|
|
|
|
self.assertEqual(
|
|
path.read_text(),
|
|
'pick aaa # [cr148] `gnrt` run for Chromium 1.0.0.1\n'
|
|
'squash bbb # [cr148] `gnrt` run for Chromium 1.0.0.2\n')
|
|
|
|
def test_squash_collapses_iwyu_fixes(self):
|
|
"""Repeated `IWYU fixes.` commits keep first as pick."""
|
|
path = self._todo('pick aaa # [cr148] IWYU fixes.\n'
|
|
'pick bbb # [cr148] IWYU fixes.\n')
|
|
|
|
brockit.Rebase.squash_minor_bumps_from_rebase_plan(path)
|
|
|
|
self.assertEqual(
|
|
path.read_text(), 'pick aaa # [cr148] IWYU fixes.\n'
|
|
'squash bbb # [cr148] IWYU fixes.\n')
|
|
|
|
def test_squash_reassign_matched_by_hash_moves_above_target(self):
|
|
"""A `reassign!<hash>!` commit is inserted directly above the `pick`
|
|
for that hash, and that target line becomes `squash`."""
|
|
path = self._todo('pick aaa # [cr148] Feature A\n'
|
|
'pick bbb # [cr148] Feature B\n'
|
|
'pick zzz # reassign!bbb! [cr148] Feature B\n')
|
|
|
|
brockit.Rebase.squash_minor_bumps_from_rebase_plan(path)
|
|
|
|
# `aaa` and `bbb` are both "others"; reassign for `bbb` moves above it
|
|
# and turns `bbb`'s pick into squash.
|
|
self.assertEqual(
|
|
path.read_text(), 'pick aaa # [cr148] Feature A\n'
|
|
'pick zzz # reassign!bbb! [cr148] Feature B\n'
|
|
'squash bbb # [cr148] Feature B\n')
|
|
|
|
def test_squash_reassign_matched_by_message_when_hash_mismatch(self):
|
|
"""If the reassign hash doesn't match any pick, fall back to matching
|
|
a line whose comment ends with the reassign message."""
|
|
path = self._todo(
|
|
'pick aaa # [cr148] Feature A\n'
|
|
'pick bbb # [cr148] Feature B\n'
|
|
# The hash referenced in the reassign (`xxx`) doesn't appear; the
|
|
# message `[cr148] Feature B` matches `bbb`'s comment.
|
|
'pick zzz # reassign!xxx! [cr148] Feature B\n')
|
|
|
|
brockit.Rebase.squash_minor_bumps_from_rebase_plan(path)
|
|
|
|
self.assertEqual(
|
|
path.read_text(), 'pick aaa # [cr148] Feature A\n'
|
|
'pick zzz # reassign!xxx! [cr148] Feature B\n'
|
|
'squash bbb # [cr148] Feature B\n')
|
|
|
|
def test_squash_orphaned_reassign_is_silently_dropped(self):
|
|
"""A reassign whose hash and message both fail to match is removed."""
|
|
path = self._todo('pick aaa # [cr148] Feature A\n'
|
|
'pick zzz # reassign!xxx! [cr148] Some completely '
|
|
'unrelated message\n')
|
|
|
|
brockit.Rebase.squash_minor_bumps_from_rebase_plan(path)
|
|
|
|
self.assertEqual(path.read_text(), 'pick aaa # [cr148] Feature A\n')
|
|
|
|
def test_squash_reassign_strips_empty_suffix_for_message_lookup(self):
|
|
"""An empty reassign carries a trailing ` # empty` marker which must be
|
|
stripped before the message-based lookup."""
|
|
path = self._todo(
|
|
'pick aaa # [cr148] Feature A\n'
|
|
'pick bbb # [cr148] Feature B\n'
|
|
'pick zzz # reassign!xxx! [cr148] Feature B # empty\n')
|
|
|
|
brockit.Rebase.squash_minor_bumps_from_rebase_plan(path)
|
|
|
|
# The ` # empty` suffix is stripped so `[cr148] Feature B` matches
|
|
# `bbb`'s comment.
|
|
self.assertEqual(
|
|
path.read_text(), 'pick aaa # [cr148] Feature A\n'
|
|
'pick zzz # reassign!xxx! [cr148] Feature B # empty\n'
|
|
'squash bbb # [cr148] Feature B\n')
|
|
|
|
def test_squash_drops_comments_and_blank_lines(self):
|
|
"""Comment lines and blank lines are not categorised and are absent
|
|
from the rewritten file."""
|
|
path = self._todo('# A comment\n'
|
|
'\n'
|
|
'pick aaa # [cr148] Real commit\n'
|
|
'\n'
|
|
'# Another comment\n')
|
|
|
|
brockit.Rebase.squash_minor_bumps_from_rebase_plan(path)
|
|
|
|
self.assertEqual(path.read_text(), 'pick aaa # [cr148] Real commit\n')
|
|
|
|
# ----- fix_squash_commit_messages ----------------------------------------
|
|
|
|
def test_fix_squash_standard_keeps_last_valid_line(self):
|
|
"""A standard squash commit-message file collapses to just the last
|
|
non-comment, non-blank line."""
|
|
path = self._todo('# This is the 1st commit message:\n'
|
|
'Update from Chromium 1.0.0.0 to Chromium 1.0.0.1\n'
|
|
'\n'
|
|
'# This is the commit message #2:\n'
|
|
'Update from Chromium 1.0.0.1 to Chromium 1.0.0.2\n')
|
|
|
|
brockit.Rebase.fix_squash_commit_messages(path)
|
|
|
|
self.assertEqual(path.read_text(),
|
|
'Update from Chromium 1.0.0.1 to Chromium 1.0.0.2\n')
|
|
|
|
def test_fix_squash_reassign_keeps_following_content(self):
|
|
"""When the first valid line is `reassign!...`, drop it and keep
|
|
everything from the next *valid* line's index onward in the file."""
|
|
path = self._todo('# This is the 1st commit message:\n'
|
|
'reassign!bbb! [cr148] Feature B\n'
|
|
'\n'
|
|
'# This is the commit message #2:\n'
|
|
'[cr148] Feature B\n'
|
|
'\n'
|
|
'Original body line.\n')
|
|
|
|
brockit.Rebase.fix_squash_commit_messages(path)
|
|
|
|
# The cut starts at the next valid line's *index*, not the line
|
|
# before it -- so the `# This is the commit message #2:` comment is
|
|
# dropped along with the reassign line. Blanks and content after the
|
|
# cut point survive.
|
|
self.assertEqual(path.read_text(), '[cr148] Feature B\n'
|
|
'\n'
|
|
'Original body line.\n')
|
|
|
|
def test_fix_squash_empty_file_exits(self):
|
|
"""A file with only comments and blanks calls `sys.exit`."""
|
|
path = self._todo('# Only a comment\n\n# And another\n')
|
|
|
|
with self.assertRaises(SystemExit):
|
|
brockit.Rebase.fix_squash_commit_messages(path)
|
|
|
|
def test_fix_squash_reassign_with_no_following_content_exits(self):
|
|
"""A reassign squash with no content after the `reassign!` line
|
|
cannot be collapsed and calls `sys.exit`."""
|
|
path = self._todo('# This is the 1st commit message:\n'
|
|
'reassign!bbb! [cr148] Feature B\n')
|
|
|
|
with self.assertRaises(SystemExit):
|
|
brockit.Rebase.fix_squash_commit_messages(path)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _Scenario:
|
|
"""Scenario data returned by `_seed_rebase_scenario` for assertions.
|
|
|
|
The three `vNNN` fields are commit hashes on master for the version-bump
|
|
commits (`Update from Chromium ... to Chromium 1.0.0.N`). `feature` is
|
|
the Brave-only commit on the feature branch.
|
|
"""
|
|
branch: str
|
|
v100: str
|
|
v101: str
|
|
v102: str
|
|
feature: str
|
|
|
|
|
|
class RebaseExecuteTest(unittest.TestCase):
|
|
"""End-to-end tests for `Rebase.execute()` against a real git tree."""
|
|
|
|
def setUp(self):
|
|
self.repo = FakeChromiumRepo()
|
|
self.repo.setup()
|
|
self.addCleanup(self.repo.cleanup)
|
|
self.repo.create_brave_remote()
|
|
self._commit_counter = 0
|
|
|
|
# ----- Seeding helpers ---------------------------------------------------
|
|
|
|
def _seed_rebase_scenario(self,
|
|
branch_name: str = 'feature-branch'
|
|
) -> _Scenario:
|
|
"""Builds a minor-version-bump scenario.
|
|
|
|
Layout produced (commits in order):
|
|
initial -> v100 -> v101 (master)
|
|
\\-> feature_commit (branch_name, on origin)
|
|
\\-> v102 (master continues)
|
|
|
|
After this returns, the working tree is on `branch_name`.
|
|
"""
|
|
brave = self.repo.brave
|
|
v100 = self.repo.update_brave_version('1.0.0.0')
|
|
v101 = self.repo.update_brave_version('1.0.0.1')
|
|
|
|
self.repo._run_git_command(['checkout', '-b', branch_name], brave)
|
|
self.repo.write_and_stage_file('feature.txt', 'feature content\n',
|
|
brave)
|
|
feature_commit = self.repo.commit('Add brave-only feature.txt', brave)
|
|
self.repo._run_git_command(
|
|
['push', '--set-upstream', 'origin', branch_name], brave)
|
|
|
|
self.repo._run_git_command(['checkout', 'master'], brave)
|
|
v102 = self.repo.update_brave_version('1.0.0.2')
|
|
self.repo._run_git_command(['checkout', branch_name], brave)
|
|
|
|
return _Scenario(branch=branch_name,
|
|
v100=v100,
|
|
v101=v101,
|
|
v102=v102,
|
|
feature=feature_commit)
|
|
|
|
# ----- Small assertion helpers -------------------------------------------
|
|
|
|
def _git_log_subjects(self, ref: str = 'HEAD') -> list:
|
|
"""Returns the commit subjects from oldest to newest on `ref`."""
|
|
out = self.repo._run_git_command(
|
|
['log', '--reverse', '--format=%s', ref], self.repo.brave)
|
|
return out.splitlines() if out else []
|
|
|
|
def _git_rev_list_count(self, ref: str = 'HEAD') -> int:
|
|
"""Returns the number of commits reachable from `ref`."""
|
|
return int(
|
|
self.repo._run_git_command(['rev-list', '--count', ref],
|
|
self.repo.brave))
|
|
|
|
def _commit_with_file(self, message: str) -> str:
|
|
"""Creates a commit with a small unique file change.
|
|
|
|
Using a real file change (rather than `--allow-empty`) keeps commits
|
|
from being dropped by `git rebase --empty=drop`, which would obscure
|
|
what the editor callbacks are actually doing.
|
|
"""
|
|
self._commit_counter += 1
|
|
counter = self._commit_counter
|
|
self.repo.write_and_stage_file(f'gen-{counter}.txt',
|
|
f'content {counter}\n', self.repo.brave)
|
|
return self.repo.commit(message, self.repo.brave)
|
|
|
|
# ----- Tests -------------------------------------------------------------
|
|
|
|
def test_execute_happy_path_explicit_refs(self):
|
|
"""Explicit `from_ref`/`to_ref` rebases the feature commit onto the
|
|
new target; the feature branch's subject is preserved."""
|
|
scenario = self._seed_rebase_scenario()
|
|
|
|
brockit.Rebase().execute(from_ref=scenario.v101,
|
|
to_ref=scenario.v102,
|
|
recommit=False,
|
|
discard_regen_changes=False,
|
|
squash_minor_bumps=False)
|
|
|
|
# The feature commit is now on top of v102. We can't compare hashes
|
|
# (rebase produces new ones) so we compare subjects and parent.
|
|
self.assertEqual(self._git_log_subjects()[-1],
|
|
'Add brave-only feature.txt')
|
|
parent = self.repo._run_git_command(['rev-parse', 'HEAD^'],
|
|
self.repo.brave)
|
|
self.assertEqual(parent, scenario.v102)
|
|
|
|
def test_execute_resolves_to_ref_upstream_label(self):
|
|
"""`to_ref=None` resolves to `@upstream` (`origin/<branch>`); since
|
|
local and origin agree, the rebase is a no-op and the branch HEAD
|
|
remains the feature commit."""
|
|
scenario = self._seed_rebase_scenario()
|
|
head_before = self.repo._run_git_command(['rev-parse', 'HEAD'],
|
|
self.repo.brave)
|
|
|
|
brockit.Rebase().execute(from_ref=scenario.v101,
|
|
to_ref=None,
|
|
recommit=False,
|
|
discard_regen_changes=False,
|
|
squash_minor_bumps=False)
|
|
|
|
head_after = self.repo._run_git_command(['rev-parse', 'HEAD'],
|
|
self.repo.brave)
|
|
self.assertEqual(head_after, head_before)
|
|
|
|
def test_execute_invalid_from_ref_raises(self):
|
|
"""A from_ref that resolves to nothing is rejected before git runs."""
|
|
self._seed_rebase_scenario()
|
|
|
|
with self.assertRaises(brockit.InvalidInputException):
|
|
brockit.Rebase().execute(from_ref='not-a-real-ref-name',
|
|
to_ref='HEAD',
|
|
recommit=False,
|
|
discard_regen_changes=False,
|
|
squash_minor_bumps=False)
|
|
|
|
def test_execute_no_upstream_raises(self):
|
|
"""A branch with no `origin/...` upstream cannot use `to_ref=None`."""
|
|
brave = self.repo.brave
|
|
self.repo.update_brave_version('1.0.0.0')
|
|
self.repo._run_git_command(['checkout', '-b', 'no-upstream'], brave)
|
|
self.repo.write_and_stage_file('x.txt', 'x', brave)
|
|
self.repo.commit('Add x', brave)
|
|
|
|
with self.assertRaises(brockit.InvalidInputException):
|
|
brockit.Rebase().execute(from_ref='HEAD~1',
|
|
to_ref=None,
|
|
recommit=False,
|
|
discard_regen_changes=False,
|
|
squash_minor_bumps=False)
|
|
|
|
# The two defaulting tests below intercept the `git rebase` invocation so
|
|
# the rebase itself doesn't execute -- they verify only the *decision* of
|
|
# which `@` label is resolved into `from_ref`. Running the rebase here
|
|
# would inevitably conflict on the version-bump commit (`@previous`
|
|
# includes the most recent package.json change in the replay range, and
|
|
# that commit's diff can never apply cleanly onto a target with a
|
|
# different tag). In production this requires a prior `lift` to
|
|
# cherry-pick the matching bump onto the branch first.
|
|
|
|
def _intercept_rebase(self):
|
|
"""Returns a `patch` context that stubs only the `git rebase` call.
|
|
|
|
Other `terminal.run` invocations (used pervasively for ref resolution,
|
|
version reads, etc.) pass through unchanged.
|
|
"""
|
|
real_run = brockit.terminal.run
|
|
rebase_calls = []
|
|
|
|
def fake_run(cmd, *args, **kwargs):
|
|
if cmd[:2] == ['git', 'rebase']:
|
|
rebase_calls.append(cmd)
|
|
return None # `Rebase.execute` ignores the return value.
|
|
return real_run(cmd, *args, **kwargs)
|
|
|
|
ctx = patch.object(brockit.terminal, 'run', side_effect=fake_run)
|
|
return ctx, rebase_calls
|
|
|
|
@staticmethod
|
|
def _parse_rebase_cmd(cmd):
|
|
"""Extracts (to_ref, from_ref, branch) from a captured rebase cmd."""
|
|
return cmd[-3], cmd[-2], cmd[-1]
|
|
|
|
def test_execute_defaults_from_ref_to_previous_for_same_major(self):
|
|
"""When the target shares HEAD's major version, `from_ref=None`
|
|
resolves via `@previous` -- which is the parent of HEAD's most
|
|
recent package.json change."""
|
|
scenario = self._seed_rebase_scenario()
|
|
|
|
ctx, rebase_calls = self._intercept_rebase()
|
|
with ctx:
|
|
brockit.Rebase().execute(from_ref=None,
|
|
to_ref=scenario.v102,
|
|
recommit=False,
|
|
discard_regen_changes=False,
|
|
squash_minor_bumps=False)
|
|
|
|
self.assertEqual(len(rebase_calls), 1)
|
|
to_ref, from_ref, branch = self._parse_rebase_cmd(rebase_calls[0])
|
|
# `_solve_brave_ref` returns a relative form like `<hash>~1`; resolve
|
|
# it back to a full hash for comparison.
|
|
resolved = self.repo._run_git_command(['rev-parse', from_ref],
|
|
self.repo.brave)
|
|
self.assertEqual(to_ref, scenario.v102)
|
|
self.assertEqual(resolved, scenario.v100)
|
|
self.assertEqual(branch, scenario.branch)
|
|
|
|
def test_execute_defaults_from_ref_to_previous_major_for_new_major(self):
|
|
"""When the target is on a different major version, `from_ref=None`
|
|
resolves via `@previous-major` -- which walks back over commits of
|
|
the same major and returns the parent of the major-bump commit."""
|
|
brave = self.repo.brave
|
|
self.repo.update_brave_version('1.0.0.0')
|
|
v200 = self.repo.update_brave_version('2.0.0.0')
|
|
|
|
# Feature branch off v200; the brave-only commit doesn't touch
|
|
# package.json, so HEAD's pjs is still at major 2.
|
|
self.repo._run_git_command(['checkout', '-b', 'feature-major'], brave)
|
|
self.repo.write_and_stage_file('feature.txt', 'feature\n', brave)
|
|
self.repo.commit('Add brave-only feature.txt', brave)
|
|
self.repo._run_git_command(
|
|
['push', '--set-upstream', 'origin', 'feature-major'], brave)
|
|
|
|
# Master advances to a new major.
|
|
self.repo._run_git_command(['checkout', 'master'], brave)
|
|
v300 = self.repo.update_brave_version('3.0.0.0')
|
|
self.repo._run_git_command(['checkout', 'feature-major'], brave)
|
|
|
|
# @previous-major walks v200 -> v100 (different major, break), and
|
|
# returns v200~1, which resolves to the v100 commit.
|
|
expected_resolved = self.repo._run_git_command(
|
|
['rev-parse', f'{v200}~1'], brave)
|
|
|
|
ctx, rebase_calls = self._intercept_rebase()
|
|
with ctx:
|
|
brockit.Rebase().execute(from_ref=None,
|
|
to_ref=v300,
|
|
recommit=False,
|
|
discard_regen_changes=False,
|
|
squash_minor_bumps=False)
|
|
|
|
self.assertEqual(len(rebase_calls), 1)
|
|
to_ref, from_ref, branch = self._parse_rebase_cmd(rebase_calls[0])
|
|
resolved = self.repo._run_git_command(['rev-parse', from_ref], brave)
|
|
self.assertEqual(to_ref, v300)
|
|
self.assertEqual(resolved, expected_resolved)
|
|
self.assertEqual(branch, 'feature-major')
|
|
|
|
def test_execute_recommit_amends_first_rebased_commit(self):
|
|
"""`recommit=True` flips the first `pick` to `edit`, then amends and
|
|
continues. The rebased commit gets a fresh hash but keeps its
|
|
subject."""
|
|
scenario = self._seed_rebase_scenario()
|
|
before_head = self.repo._run_git_command(['rev-parse', 'HEAD'],
|
|
self.repo.brave)
|
|
|
|
brockit.Rebase().execute(from_ref=scenario.v101,
|
|
to_ref=scenario.v102,
|
|
recommit=True,
|
|
discard_regen_changes=False,
|
|
squash_minor_bumps=False)
|
|
|
|
after_head = self.repo._run_git_command(['rev-parse', 'HEAD'],
|
|
self.repo.brave)
|
|
self.assertNotEqual(after_head, before_head)
|
|
self.assertEqual(self._git_log_subjects()[-1],
|
|
'Add brave-only feature.txt')
|
|
parent = self.repo._run_git_command(['rev-parse', 'HEAD^'],
|
|
self.repo.brave)
|
|
self.assertEqual(parent, scenario.v102)
|
|
|
|
def test_execute_discard_regen_changes_strips_regen_commits(self):
|
|
"""`discard_regen_changes=True` removes `Update patches ...` and
|
|
`Updated strings ...` commits from the rebase plan, end-to-end
|
|
through the real `GIT_SEQUENCE_EDITOR` subprocess callback."""
|
|
scenario = self._seed_rebase_scenario()
|
|
# On top of the feature commit, add two regen-like commits and one
|
|
# genuine feature commit so we can prove only the regen ones are
|
|
# dropped.
|
|
self._commit_with_file(
|
|
'Update patches from Chromium 1.0.0.1 to Chromium 1.0.0.2.')
|
|
self._commit_with_file(
|
|
'Updated strings for Chromium 1.0.0.1 to Chromium 1.0.0.2.')
|
|
self._commit_with_file('[cr148] Another brave-only feature.')
|
|
|
|
brockit.Rebase().execute(from_ref=scenario.v101,
|
|
to_ref=scenario.v102,
|
|
recommit=False,
|
|
discard_regen_changes=True,
|
|
squash_minor_bumps=False)
|
|
|
|
subjects = self._git_log_subjects(scenario.v102 + '..HEAD')
|
|
self.assertEqual(subjects, [
|
|
'Add brave-only feature.txt',
|
|
'[cr148] Another brave-only feature.',
|
|
])
|
|
|
|
def test_execute_squash_minor_bumps_collapses_version_bumps(self):
|
|
"""`squash_minor_bumps=True` reorders the plan so version bumps come
|
|
first and squashes all-but-the-first; the final commit message is the
|
|
latest version-bump message."""
|
|
scenario = self._seed_rebase_scenario()
|
|
# Add an interleaved sequence: another version bump, a feature commit,
|
|
# then a third version bump.
|
|
v102_to_v103 = self._commit_with_file(
|
|
'Update from Chromium 1.0.0.2 to Chromium 1.0.0.3')
|
|
self._commit_with_file('[cr148] Some unrelated feature commit')
|
|
self._commit_with_file(
|
|
'Update from Chromium 1.0.0.3 to Chromium 1.0.0.4')
|
|
|
|
# Rebase from v101 (so all the commits above are in scope) onto v102.
|
|
brockit.Rebase().execute(from_ref=scenario.v101,
|
|
to_ref=scenario.v102,
|
|
recommit=False,
|
|
discard_regen_changes=False,
|
|
squash_minor_bumps=True)
|
|
|
|
subjects = self._git_log_subjects(scenario.v102 + '..HEAD')
|
|
# Version bumps collapse into the first one, taking the LAST message
|
|
# (per `fix_squash_commit_messages`). The unrelated feature commit
|
|
# and the original feature commit are kept as "others".
|
|
self.assertEqual(subjects, [
|
|
'Update from Chromium 1.0.0.3 to Chromium 1.0.0.4',
|
|
'Add brave-only feature.txt',
|
|
'[cr148] Some unrelated feature commit',
|
|
])
|
|
# Defensive: v102_to_v103 is gone (squashed into the merged bump).
|
|
all_hashes = self.repo._run_git_command(
|
|
['rev-list', scenario.v102 + '..HEAD'],
|
|
self.repo.brave).splitlines()
|
|
self.assertNotIn(v102_to_v103, all_hashes)
|
|
|
|
def test_execute_rebase_failure_raises_invalid_input(self):
|
|
"""A genuine git rebase failure (merge conflict on a tracked file) is
|
|
translated into `InvalidInputException`."""
|
|
brave = self.repo.brave
|
|
# Common ancestor: data.txt with content "A\n"
|
|
self.repo.write_and_stage_file('data.txt', 'A\n', brave)
|
|
base = self.repo.commit('Add data.txt', brave)
|
|
|
|
# Branch B: modifies data.txt to "B\n"
|
|
self.repo._run_git_command(['checkout', '-b', 'branch-b'], brave)
|
|
(brave / 'data.txt').write_text('B\n')
|
|
self.repo._run_git_command(['add', 'data.txt'], brave)
|
|
target = self.repo.commit('Set data.txt to B', brave)
|
|
|
|
# Branch A (current): modifies data.txt to "C\n"
|
|
self.repo._run_git_command(['checkout', '-b', 'branch-a', base], brave)
|
|
(brave / 'data.txt').write_text('C\n')
|
|
self.repo._run_git_command(['add', 'data.txt'], brave)
|
|
self.repo.commit('Set data.txt to C', brave)
|
|
|
|
with self.assertRaises(brockit.InvalidInputException):
|
|
brockit.Rebase().execute(from_ref=base,
|
|
to_ref=target,
|
|
recommit=False,
|
|
discard_regen_changes=False,
|
|
squash_minor_bumps=False)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|