test_rebase.py 27 KB

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