test_rebase.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762
  1. # test_rebase.py -- rebase tests
  2. # Copyright (C) 2025 Dulwich contributors
  3. #
  4. # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
  5. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  6. # General Public License as published by the Free Software Foundation; version 2.0
  7. # or (at your option) any later version. You can redistribute it and/or
  8. # modify it under the terms of either of these two licenses.
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. #
  16. # You should have received a copy of the licenses; if not, see
  17. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  18. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  19. # License, Version 2.0.
  20. #
  21. """Tests for dulwich.rebase."""
  22. import importlib.util
  23. import os
  24. import tempfile
  25. from dulwich.objects import Blob, Commit, Tree
  26. from dulwich.rebase import (
  27. RebaseConflict,
  28. Rebaser,
  29. RebaseTodo,
  30. RebaseTodoCommand,
  31. RebaseTodoEntry,
  32. process_interactive_rebase,
  33. rebase,
  34. start_interactive,
  35. )
  36. from dulwich.repo import MemoryRepo, Repo
  37. from dulwich.tests.utils import make_commit
  38. from . import DependencyMissing, TestCase
  39. class RebaserTestCase(TestCase):
  40. """Tests for the Rebaser class."""
  41. def setUp(self):
  42. """Set up test repository."""
  43. super().setUp()
  44. self.repo = MemoryRepo()
  45. self.addCleanup(self.repo.close)
  46. def _setup_initial_commit(self):
  47. """Set up initial commit for tests."""
  48. # Create initial commit
  49. blob = Blob.from_string(b"Initial content\n")
  50. self.repo.object_store.add_object(blob)
  51. tree = Tree()
  52. tree.add(b"file.txt", 0o100644, blob.id)
  53. self.repo.object_store.add_object(tree)
  54. self.initial_commit = make_commit(
  55. tree=tree.id,
  56. parents=[],
  57. message=b"Initial commit",
  58. committer=b"Test User <test@example.com>",
  59. author=b"Test User <test@example.com>",
  60. commit_time=1000000,
  61. author_time=1000000,
  62. commit_timezone=0,
  63. author_timezone=0,
  64. )
  65. self.repo.object_store.add_object(self.initial_commit)
  66. # Set up branches
  67. self.repo.refs[b"refs/heads/master"] = self.initial_commit.id
  68. self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")
  69. def test_simple_rebase(self):
  70. """Test simple rebase with no conflicts."""
  71. self._setup_initial_commit()
  72. # Create feature branch with one commit
  73. feature_blob = Blob.from_string(b"Feature content\n")
  74. self.repo.object_store.add_object(feature_blob)
  75. feature_tree = Tree()
  76. feature_tree.add(b"feature.txt", 0o100644, feature_blob.id)
  77. feature_tree.add(
  78. b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
  79. )
  80. self.repo.object_store.add_object(feature_tree)
  81. feature_commit = Commit()
  82. feature_commit.tree = feature_tree.id
  83. feature_commit.parents = [self.initial_commit.id]
  84. feature_commit.message = b"Add feature"
  85. feature_commit.committer = b"Test User <test@example.com>"
  86. feature_commit.author = b"Test User <test@example.com>"
  87. feature_commit.commit_time = 1000100
  88. feature_commit.author_time = 1000100
  89. feature_commit.commit_timezone = 0
  90. feature_commit.author_timezone = 0
  91. self.repo.object_store.add_object(feature_commit)
  92. self.repo.refs[b"refs/heads/feature"] = feature_commit.id
  93. # Create main branch advancement
  94. main_blob = Blob.from_string(b"Main advancement\n")
  95. self.repo.object_store.add_object(main_blob)
  96. main_tree = Tree()
  97. main_tree.add(b"main.txt", 0o100644, main_blob.id)
  98. main_tree.add(
  99. b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
  100. )
  101. self.repo.object_store.add_object(main_tree)
  102. main_commit = Commit()
  103. main_commit.tree = main_tree.id
  104. main_commit.parents = [self.initial_commit.id]
  105. main_commit.message = b"Main advancement"
  106. main_commit.committer = b"Test User <test@example.com>"
  107. main_commit.author = b"Test User <test@example.com>"
  108. main_commit.commit_time = 1000200
  109. main_commit.author_time = 1000200
  110. main_commit.commit_timezone = 0
  111. main_commit.author_timezone = 0
  112. self.repo.object_store.add_object(main_commit)
  113. self.repo.refs[b"refs/heads/master"] = main_commit.id
  114. # Switch to feature branch
  115. self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/feature")
  116. # Check refs before rebase
  117. main_ref_before = self.repo.refs[b"refs/heads/master"]
  118. feature_ref_before = self.repo.refs[b"refs/heads/feature"]
  119. # Double check that refs are correctly set up
  120. self.assertEqual(main_ref_before, main_commit.id)
  121. self.assertEqual(feature_ref_before, feature_commit.id)
  122. # Perform rebase
  123. rebaser = Rebaser(self.repo)
  124. commits = rebaser.start(b"refs/heads/master", branch=b"refs/heads/feature")
  125. self.assertEqual(len(commits), 1)
  126. self.assertEqual(commits[0].id, feature_commit.id)
  127. # Continue rebase
  128. result = rebaser.continue_()
  129. self.assertIsNone(result) # Rebase complete
  130. # Check that feature branch was updated
  131. new_feature_head = self.repo.refs[b"refs/heads/feature"]
  132. new_commit = self.repo[new_feature_head]
  133. # Should have main commit as parent
  134. self.assertEqual(new_commit.parents, [main_commit.id])
  135. # Should have same tree as original (both files present)
  136. new_tree = self.repo[new_commit.tree]
  137. self.assertIn(b"feature.txt", new_tree)
  138. self.assertIn(b"main.txt", new_tree)
  139. self.assertIn(b"file.txt", new_tree)
  140. def test_rebase_with_conflicts(self):
  141. # Check if merge3 module is available
  142. if importlib.util.find_spec("merge3") is None:
  143. raise DependencyMissing("merge3")
  144. """Test rebase with merge conflicts."""
  145. self._setup_initial_commit()
  146. # Create feature branch with conflicting change
  147. feature_blob = Blob.from_string(b"Feature change to file\n")
  148. self.repo.object_store.add_object(feature_blob)
  149. feature_tree = Tree()
  150. feature_tree.add(b"file.txt", 0o100644, feature_blob.id)
  151. self.repo.object_store.add_object(feature_tree)
  152. feature_commit = Commit()
  153. feature_commit.tree = feature_tree.id
  154. feature_commit.parents = [self.initial_commit.id]
  155. feature_commit.message = b"Feature change"
  156. feature_commit.committer = b"Test User <test@example.com>"
  157. feature_commit.author = b"Test User <test@example.com>"
  158. feature_commit.commit_time = 1000100
  159. feature_commit.author_time = 1000100
  160. feature_commit.commit_timezone = 0
  161. feature_commit.author_timezone = 0
  162. self.repo.object_store.add_object(feature_commit)
  163. self.repo.refs[b"refs/heads/feature"] = feature_commit.id
  164. # Create main branch with conflicting change
  165. main_blob = Blob.from_string(b"Main change to file\n")
  166. self.repo.object_store.add_object(main_blob)
  167. main_tree = Tree()
  168. main_tree.add(b"file.txt", 0o100644, main_blob.id)
  169. self.repo.object_store.add_object(main_tree)
  170. main_commit = Commit()
  171. main_commit.tree = main_tree.id
  172. main_commit.parents = [self.initial_commit.id]
  173. main_commit.message = b"Main change"
  174. main_commit.committer = b"Test User <test@example.com>"
  175. main_commit.author = b"Test User <test@example.com>"
  176. main_commit.commit_time = 1000200
  177. main_commit.author_time = 1000200
  178. main_commit.commit_timezone = 0
  179. main_commit.author_timezone = 0
  180. self.repo.object_store.add_object(main_commit)
  181. self.repo.refs[b"refs/heads/master"] = main_commit.id
  182. # Attempt rebase - should fail with conflicts
  183. with self.assertRaises(RebaseConflict) as cm:
  184. rebase(self.repo, b"refs/heads/master", branch=b"refs/heads/feature")
  185. self.assertIn(b"file.txt", cm.exception.conflicted_files)
  186. def test_abort_rebase(self):
  187. """Test aborting a rebase."""
  188. self._setup_initial_commit()
  189. # Set up branches similar to simple rebase test
  190. feature_blob = Blob.from_string(b"Feature content\n")
  191. self.repo.object_store.add_object(feature_blob)
  192. feature_tree = Tree()
  193. feature_tree.add(b"feature.txt", 0o100644, feature_blob.id)
  194. feature_tree.add(
  195. b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
  196. )
  197. self.repo.object_store.add_object(feature_tree)
  198. feature_commit = Commit()
  199. feature_commit.tree = feature_tree.id
  200. feature_commit.parents = [self.initial_commit.id]
  201. feature_commit.message = b"Add feature"
  202. feature_commit.committer = b"Test User <test@example.com>"
  203. feature_commit.author = b"Test User <test@example.com>"
  204. feature_commit.commit_time = 1000100
  205. feature_commit.author_time = 1000100
  206. feature_commit.commit_timezone = 0
  207. feature_commit.author_timezone = 0
  208. self.repo.object_store.add_object(feature_commit)
  209. self.repo.refs[b"refs/heads/feature"] = feature_commit.id
  210. self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/feature")
  211. # Start rebase
  212. rebaser = Rebaser(self.repo)
  213. rebaser.start(b"refs/heads/master")
  214. # Abort rebase
  215. rebaser.abort()
  216. # Check that HEAD is restored
  217. self.assertEqual(self.repo.refs.read_ref(b"HEAD"), b"ref: refs/heads/feature")
  218. self.assertEqual(self.repo.refs[b"refs/heads/feature"], feature_commit.id)
  219. # Check that REBASE_HEAD is cleaned up
  220. self.assertNotIn(b"REBASE_HEAD", self.repo.refs)
  221. def test_rebase_no_commits(self):
  222. """Test rebase when already up to date."""
  223. self._setup_initial_commit()
  224. # Both branches point to same commit
  225. self.repo.refs[b"refs/heads/feature"] = self.initial_commit.id
  226. self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/feature")
  227. # Perform rebase
  228. result = rebase(self.repo, b"refs/heads/master")
  229. # Should return empty list (no new commits)
  230. self.assertEqual(result, [])
  231. def test_rebase_onto(self):
  232. """Test rebase with --onto option."""
  233. self._setup_initial_commit()
  234. # Create a chain of commits: initial -> A -> B -> C
  235. blob_a = Blob.from_string(b"Commit A\n")
  236. self.repo.object_store.add_object(blob_a)
  237. tree_a = Tree()
  238. tree_a.add(b"a.txt", 0o100644, blob_a.id)
  239. tree_a.add(
  240. b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
  241. )
  242. self.repo.object_store.add_object(tree_a)
  243. commit_a = make_commit(
  244. id=b"a" * 40,
  245. tree=tree_a.id,
  246. parents=[self.initial_commit.id],
  247. message=b"Commit A",
  248. committer=b"Test User <test@example.com>",
  249. author=b"Test User <test@example.com>",
  250. commit_time=1000100,
  251. author_time=1000100,
  252. )
  253. self.repo.object_store.add_object(commit_a)
  254. blob_b = Blob.from_string(b"Commit B\n")
  255. self.repo.object_store.add_object(blob_b)
  256. tree_b = Tree()
  257. tree_b.add(b"b.txt", 0o100644, blob_b.id)
  258. tree_b.add(b"a.txt", 0o100644, blob_a.id)
  259. tree_b.add(
  260. b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
  261. )
  262. self.repo.object_store.add_object(tree_b)
  263. commit_b = make_commit(
  264. id=b"b" * 40,
  265. tree=tree_b.id,
  266. parents=[commit_a.id],
  267. message=b"Commit B",
  268. committer=b"Test User <test@example.com>",
  269. author=b"Test User <test@example.com>",
  270. commit_time=1000200,
  271. author_time=1000200,
  272. )
  273. self.repo.object_store.add_object(commit_b)
  274. blob_c = Blob.from_string(b"Commit C\n")
  275. self.repo.object_store.add_object(blob_c)
  276. tree_c = Tree()
  277. tree_c.add(b"c.txt", 0o100644, blob_c.id)
  278. tree_c.add(b"b.txt", 0o100644, blob_b.id)
  279. tree_c.add(b"a.txt", 0o100644, blob_a.id)
  280. tree_c.add(
  281. b"file.txt", 0o100644, self.repo[self.initial_commit.tree][b"file.txt"][1]
  282. )
  283. self.repo.object_store.add_object(tree_c)
  284. commit_c = make_commit(
  285. id=b"c" * 40,
  286. tree=tree_c.id,
  287. parents=[commit_b.id],
  288. message=b"Commit C",
  289. committer=b"Test User <test@example.com>",
  290. author=b"Test User <test@example.com>",
  291. commit_time=1000300,
  292. author_time=1000300,
  293. )
  294. self.repo.object_store.add_object(commit_c)
  295. # Create separate branch at commit A
  296. self.repo.refs[b"refs/heads/topic"] = commit_c.id
  297. self.repo.refs[b"refs/heads/newbase"] = commit_a.id
  298. self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/topic")
  299. # Rebase B and C onto initial commit (skipping A)
  300. rebaser = Rebaser(self.repo)
  301. commits = rebaser.start(
  302. upstream=commit_a.id,
  303. onto=self.initial_commit.id,
  304. branch=b"refs/heads/topic",
  305. )
  306. # Should rebase commits B and C
  307. self.assertEqual(len(commits), 2)
  308. self.assertEqual(commits[0].id, commit_b.id)
  309. self.assertEqual(commits[1].id, commit_c.id)
  310. # Continue rebase
  311. result = rebaser.continue_()
  312. self.assertIsNone(result)
  313. # Check result
  314. new_head = self.repo.refs[b"refs/heads/topic"]
  315. new_c = self.repo[new_head]
  316. new_b = self.repo[new_c.parents[0]]
  317. # B should now have initial commit as parent (not A)
  318. self.assertEqual(new_b.parents, [self.initial_commit.id])
  319. # Trees should still have b.txt and c.txt but not a.txt
  320. new_b_tree = self.repo[new_b.tree]
  321. self.assertIn(b"b.txt", new_b_tree)
  322. self.assertNotIn(b"a.txt", new_b_tree)
  323. new_c_tree = self.repo[new_c.tree]
  324. self.assertIn(b"c.txt", new_c_tree)
  325. self.assertIn(b"b.txt", new_c_tree)
  326. self.assertNotIn(b"a.txt", new_c_tree)
  327. class RebasePorcelainTestCase(TestCase):
  328. """Tests for the porcelain rebase function."""
  329. def setUp(self):
  330. """Set up test repository."""
  331. super().setUp()
  332. self.test_dir = tempfile.mkdtemp()
  333. self.repo = Repo.init(self.test_dir)
  334. # Create initial commit
  335. with open(os.path.join(self.test_dir, "README.md"), "wb") as f:
  336. f.write(b"# Test Repository\n")
  337. self.repo.get_worktree().stage(["README.md"])
  338. self.initial_commit = self.repo.get_worktree().commit(
  339. message=b"Initial commit",
  340. committer=b"Test User <test@example.com>",
  341. author=b"Test User <test@example.com>",
  342. )
  343. def tearDown(self):
  344. """Clean up test directory."""
  345. import shutil
  346. shutil.rmtree(self.test_dir)
  347. def test_porcelain_rebase(self):
  348. """Test rebase through porcelain interface."""
  349. from dulwich import porcelain
  350. # Create and checkout feature branch
  351. self.repo.refs[b"refs/heads/feature"] = self.initial_commit
  352. porcelain.checkout(self.repo, "feature")
  353. # Add commit to feature branch
  354. with open(os.path.join(self.test_dir, "feature.txt"), "wb") as f:
  355. f.write(b"Feature file\n")
  356. porcelain.add(self.repo, ["feature.txt"])
  357. porcelain.commit(
  358. self.repo,
  359. message="Add feature",
  360. author="Test User <test@example.com>",
  361. committer="Test User <test@example.com>",
  362. )
  363. # Switch to main and add different commit
  364. porcelain.checkout(self.repo, "master")
  365. with open(os.path.join(self.test_dir, "main.txt"), "wb") as f:
  366. f.write(b"Main file\n")
  367. porcelain.add(self.repo, ["main.txt"])
  368. porcelain.commit(
  369. self.repo,
  370. message="Main update",
  371. author="Test User <test@example.com>",
  372. committer="Test User <test@example.com>",
  373. )
  374. # Switch back to feature and rebase
  375. porcelain.checkout(self.repo, "feature")
  376. # Perform rebase
  377. new_shas = porcelain.rebase(self.repo, "master")
  378. # Should have rebased one commit
  379. self.assertEqual(len(new_shas), 1)
  380. # Check that the rebased commit has the correct parent and tree
  381. feature_head = self.repo.refs[b"refs/heads/feature"]
  382. feature_commit_obj = self.repo[feature_head]
  383. # Should have master as parent
  384. master_head = self.repo.refs[b"refs/heads/master"]
  385. self.assertEqual(feature_commit_obj.parents, [master_head])
  386. # Tree should have both files
  387. tree = self.repo[feature_commit_obj.tree]
  388. self.assertIn(b"feature.txt", tree)
  389. self.assertIn(b"main.txt", tree)
  390. self.assertIn(b"README.md", tree)
  391. class InteractiveRebaseTestCase(TestCase):
  392. """Tests for interactive rebase functionality."""
  393. def setUp(self):
  394. """Set up test repository."""
  395. super().setUp()
  396. self.repo = MemoryRepo()
  397. self.addCleanup(self.repo.close)
  398. self._setup_initial_commit()
  399. def _setup_initial_commit(self):
  400. """Set up initial commit for tests."""
  401. # Create initial commit
  402. blob = Blob.from_string(b"Initial content\n")
  403. self.repo.object_store.add_object(blob)
  404. tree = Tree()
  405. tree.add(b"file.txt", 0o100644, blob.id)
  406. self.repo.object_store.add_object(tree)
  407. self.initial_commit = make_commit(
  408. tree=tree.id,
  409. parents=[],
  410. message=b"Initial commit",
  411. committer=b"Test User <test@example.com>",
  412. author=b"Test User <test@example.com>",
  413. commit_time=1000000,
  414. author_time=1000000,
  415. commit_timezone=0,
  416. author_timezone=0,
  417. )
  418. self.repo.object_store.add_object(self.initial_commit)
  419. # Set up branches
  420. self.repo.refs[b"refs/heads/master"] = self.initial_commit.id
  421. self.repo.refs.set_symbolic_ref(b"HEAD", b"refs/heads/master")
  422. def _create_test_commits(self):
  423. """Create a series of test commits for interactive rebase."""
  424. commits = []
  425. parent = self.initial_commit.id
  426. for i in range(3):
  427. blob = Blob.from_string(f"Content {i}\n".encode())
  428. self.repo.object_store.add_object(blob)
  429. tree = Tree()
  430. tree.add(f"file{i}.txt".encode(), 0o100644, blob.id)
  431. self.repo.object_store.add_object(tree)
  432. commit = Commit()
  433. commit.tree = tree.id
  434. commit.parents = [parent]
  435. commit.message = f"Commit {i}".encode()
  436. commit.committer = b"Test User <test@example.com>"
  437. commit.author = b"Test User <test@example.com>"
  438. commit.commit_time = 1000000 + i * 100
  439. commit.author_time = 1000000 + i * 100
  440. commit.commit_timezone = 0
  441. commit.author_timezone = 0
  442. self.repo.object_store.add_object(commit)
  443. commits.append(commit)
  444. parent = commit.id
  445. self.repo.refs[b"refs/heads/feature"] = commits[-1].id
  446. return commits
  447. def test_todo_parsing(self):
  448. """Test parsing of todo file format."""
  449. todo_content = """pick 1234567 First commit
  450. reword 2345678 Second commit
  451. edit 3456789 Third commit
  452. squash 4567890 Fourth commit
  453. fixup 5678901 Fifth commit
  454. drop 6789012 Sixth commit
  455. exec echo "Running test"
  456. break
  457. # This is a comment
  458. """
  459. todo = RebaseTodo.from_string(todo_content)
  460. self.assertEqual(len(todo.entries), 8)
  461. # Check first entry
  462. self.assertEqual(todo.entries[0].command, RebaseTodoCommand.PICK)
  463. self.assertEqual(todo.entries[0].commit_sha, b"1234567")
  464. self.assertEqual(todo.entries[0].short_message, "First commit")
  465. # Check reword
  466. self.assertEqual(todo.entries[1].command, RebaseTodoCommand.REWORD)
  467. # Check exec
  468. self.assertEqual(todo.entries[6].command, RebaseTodoCommand.EXEC)
  469. self.assertEqual(todo.entries[6].arguments, 'echo "Running test"')
  470. # Check break
  471. self.assertEqual(todo.entries[7].command, RebaseTodoCommand.BREAK)
  472. def test_todo_generation(self):
  473. """Test generation of todo list from commits."""
  474. commits = self._create_test_commits()
  475. todo = RebaseTodo.from_commits(commits)
  476. # Should have one pick entry per commit
  477. self.assertEqual(len(todo.entries), 3)
  478. for i, entry in enumerate(todo.entries):
  479. self.assertEqual(entry.command, RebaseTodoCommand.PICK)
  480. # commit_sha stores the full hex SHA as bytes
  481. self.assertEqual(entry.commit_sha, commits[i].id)
  482. self.assertIn(f"Commit {i}", entry.short_message)
  483. def test_todo_serialization(self):
  484. """Test serialization of todo list."""
  485. entries = [
  486. RebaseTodoEntry(
  487. command=RebaseTodoCommand.PICK,
  488. commit_sha=b"1234567890abcdef",
  489. short_message="First commit",
  490. ),
  491. RebaseTodoEntry(
  492. command=RebaseTodoCommand.SQUASH,
  493. commit_sha=b"fedcba0987654321",
  494. short_message="Second commit",
  495. ),
  496. RebaseTodoEntry(command=RebaseTodoCommand.EXEC, arguments="make test"),
  497. ]
  498. todo = RebaseTodo(entries)
  499. content = todo.to_string(include_comments=False)
  500. lines = content.strip().split("\n")
  501. self.assertEqual(len(lines), 3)
  502. self.assertIn("pick 1234567", lines[0])
  503. self.assertIn("squash fedcba0", lines[1])
  504. self.assertIn("exec make test", lines[2])
  505. def test_start_interactive_no_editor(self):
  506. """Test starting interactive rebase without editor."""
  507. self._create_test_commits()
  508. # Start interactive rebase
  509. todo = start_interactive(
  510. self.repo,
  511. b"refs/heads/master",
  512. branch=b"refs/heads/feature",
  513. editor_callback=None,
  514. )
  515. # Should have generated todo list
  516. self.assertEqual(len(todo.entries), 3)
  517. for entry in todo.entries:
  518. self.assertEqual(entry.command, RebaseTodoCommand.PICK)
  519. def test_start_interactive_with_editor(self):
  520. """Test starting interactive rebase with editor callback."""
  521. self._create_test_commits()
  522. def mock_editor(content):
  523. # Simulate user changing pick to squash for second commit
  524. lines = content.decode().splitlines()
  525. new_lines = []
  526. for i, line in enumerate(lines):
  527. if i == 1 and line.startswith("pick"):
  528. new_lines.append(line.replace("pick", "squash"))
  529. else:
  530. new_lines.append(line)
  531. return "\n".join(new_lines).encode()
  532. todo = start_interactive(
  533. self.repo,
  534. b"refs/heads/master",
  535. branch=b"refs/heads/feature",
  536. editor_callback=mock_editor,
  537. )
  538. # Second entry should be squash
  539. self.assertEqual(todo.entries[0].command, RebaseTodoCommand.PICK)
  540. self.assertEqual(todo.entries[1].command, RebaseTodoCommand.SQUASH)
  541. self.assertEqual(todo.entries[2].command, RebaseTodoCommand.PICK)
  542. def test_process_drop_command(self):
  543. """Test processing DROP command in interactive rebase."""
  544. commits = self._create_test_commits()
  545. # Create todo with drop command
  546. entries = [
  547. RebaseTodoEntry(
  548. command=RebaseTodoCommand.PICK,
  549. commit_sha=commits[0].id,
  550. short_message="Commit 0",
  551. ),
  552. RebaseTodoEntry(
  553. command=RebaseTodoCommand.DROP,
  554. commit_sha=commits[1].id,
  555. short_message="Commit 1",
  556. ),
  557. RebaseTodoEntry(
  558. command=RebaseTodoCommand.PICK,
  559. commit_sha=commits[2].id,
  560. short_message="Commit 2",
  561. ),
  562. ]
  563. todo = RebaseTodo(entries)
  564. is_complete, pause_reason = process_interactive_rebase(self.repo, todo)
  565. # Should complete successfully
  566. self.assertTrue(is_complete)
  567. self.assertIsNone(pause_reason)
  568. # Should have only picked 2 commits (dropped one)
  569. # Note: _done list would contain the rebased commits
  570. def test_process_break_command(self):
  571. """Test processing BREAK command in interactive rebase."""
  572. commits = self._create_test_commits()
  573. entries = [
  574. RebaseTodoEntry(
  575. command=RebaseTodoCommand.PICK,
  576. commit_sha=commits[0].id,
  577. short_message="Commit 0",
  578. ),
  579. RebaseTodoEntry(command=RebaseTodoCommand.BREAK),
  580. RebaseTodoEntry(
  581. command=RebaseTodoCommand.PICK,
  582. commit_sha=commits[1].id,
  583. short_message="Commit 1",
  584. ),
  585. ]
  586. todo = RebaseTodo(entries)
  587. is_complete, pause_reason = process_interactive_rebase(self.repo, todo)
  588. # Should pause at break
  589. self.assertFalse(is_complete)
  590. self.assertEqual(pause_reason, "break")
  591. # Todo should be at position after break
  592. self.assertEqual(todo.current_index, 2)
  593. def test_process_edit_command(self):
  594. """Test processing EDIT command in interactive rebase."""
  595. commits = self._create_test_commits()
  596. entries = [
  597. RebaseTodoEntry(
  598. command=RebaseTodoCommand.PICK,
  599. commit_sha=commits[0].id,
  600. short_message="Commit 0",
  601. ),
  602. RebaseTodoEntry(
  603. command=RebaseTodoCommand.EDIT,
  604. commit_sha=commits[1].id,
  605. short_message="Commit 1",
  606. ),
  607. ]
  608. todo = RebaseTodo(entries)
  609. is_complete, pause_reason = process_interactive_rebase(self.repo, todo)
  610. # Should pause for editing
  611. self.assertFalse(is_complete)
  612. self.assertEqual(pause_reason, "edit")
  613. def test_abbreviations(self):
  614. """Test parsing abbreviated commands."""
  615. todo_content = """p 1234567 Pick
  616. r 2345678 Reword
  617. e 3456789 Edit
  618. s 4567890 Squash
  619. f 5678901 Fixup
  620. d 6789012 Drop
  621. x echo test
  622. b
  623. """
  624. todo = RebaseTodo.from_string(todo_content)
  625. self.assertEqual(todo.entries[0].command, RebaseTodoCommand.PICK)
  626. self.assertEqual(todo.entries[1].command, RebaseTodoCommand.REWORD)
  627. self.assertEqual(todo.entries[2].command, RebaseTodoCommand.EDIT)
  628. self.assertEqual(todo.entries[3].command, RebaseTodoCommand.SQUASH)
  629. self.assertEqual(todo.entries[4].command, RebaseTodoCommand.FIXUP)
  630. self.assertEqual(todo.entries[5].command, RebaseTodoCommand.DROP)
  631. self.assertEqual(todo.entries[6].command, RebaseTodoCommand.EXEC)
  632. self.assertEqual(todo.entries[7].command, RebaseTodoCommand.BREAK)