test_rebase.py 27 KB

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