rebase.py 40 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297
  1. # rebase.py -- Git rebase implementation
  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. """Git rebase implementation."""
  22. __all__ = [
  23. "DiskRebaseStateManager",
  24. "MemoryRebaseStateManager",
  25. "RebaseAbort",
  26. "RebaseConflict",
  27. "RebaseError",
  28. "RebaseState",
  29. "RebaseStateManager",
  30. "RebaseTodo",
  31. "RebaseTodoCommand",
  32. "RebaseTodoEntry",
  33. "Rebaser",
  34. "edit_todo",
  35. "process_interactive_rebase",
  36. "rebase",
  37. "start_interactive",
  38. ]
  39. import os
  40. import shutil
  41. import subprocess
  42. from collections.abc import Callable, Sequence
  43. from dataclasses import dataclass
  44. from enum import Enum
  45. from typing import Protocol, TypedDict
  46. from dulwich.graph import find_merge_base
  47. from dulwich.merge import three_way_merge
  48. from dulwich.objects import Commit, ObjectID
  49. from dulwich.objectspec import parse_commit
  50. from dulwich.refs import HEADREF, Ref, local_branch_name, set_ref_from_raw
  51. from dulwich.repo import BaseRepo, Repo
  52. class RebaseError(Exception):
  53. """Base class for rebase errors."""
  54. class RebaseConflict(RebaseError):
  55. """Raised when a rebase conflict occurs."""
  56. def __init__(self, conflicted_files: list[bytes]):
  57. """Initialize RebaseConflict.
  58. Args:
  59. conflicted_files: List of conflicted file paths
  60. """
  61. self.conflicted_files = conflicted_files
  62. super().__init__(
  63. f"Conflicts in: {', '.join(f.decode('utf-8', 'replace') for f in conflicted_files)}"
  64. )
  65. class RebaseAbort(RebaseError):
  66. """Raised when rebase is aborted."""
  67. class RebaseTodoCommand(Enum):
  68. """Enum for rebase todo commands."""
  69. PICK = "pick"
  70. REWORD = "reword"
  71. EDIT = "edit"
  72. SQUASH = "squash"
  73. FIXUP = "fixup"
  74. EXEC = "exec"
  75. BREAK = "break"
  76. DROP = "drop"
  77. LABEL = "label"
  78. RESET = "reset"
  79. MERGE = "merge"
  80. @classmethod
  81. def from_string(cls, s: str) -> "RebaseTodoCommand":
  82. """Parse a command from its string representation.
  83. Args:
  84. s: Command string (can be abbreviated)
  85. Returns:
  86. RebaseTodoCommand enum value
  87. Raises:
  88. ValueError: If command is not recognized
  89. """
  90. s = s.lower()
  91. # Support abbreviations
  92. abbreviations = {
  93. "p": cls.PICK,
  94. "r": cls.REWORD,
  95. "e": cls.EDIT,
  96. "s": cls.SQUASH,
  97. "f": cls.FIXUP,
  98. "x": cls.EXEC,
  99. "b": cls.BREAK,
  100. "d": cls.DROP,
  101. "l": cls.LABEL,
  102. "t": cls.RESET,
  103. "m": cls.MERGE,
  104. }
  105. if s in abbreviations:
  106. return abbreviations[s]
  107. # Try full command name
  108. try:
  109. return cls(s)
  110. except ValueError:
  111. raise ValueError(f"Unknown rebase command: {s}")
  112. @dataclass
  113. class RebaseTodoEntry:
  114. """Represents a single entry in a rebase todo list."""
  115. command: RebaseTodoCommand
  116. commit_sha: ObjectID | None = None # Store as hex string encoded as bytes
  117. short_message: str | None = None
  118. arguments: str | None = None
  119. def to_string(self, abbreviate: bool = False) -> str:
  120. """Convert to git-rebase-todo format string.
  121. Args:
  122. abbreviate: Use abbreviated command names
  123. Returns:
  124. String representation for todo file
  125. """
  126. if abbreviate:
  127. cmd_map = {
  128. RebaseTodoCommand.PICK: "p",
  129. RebaseTodoCommand.REWORD: "r",
  130. RebaseTodoCommand.EDIT: "e",
  131. RebaseTodoCommand.SQUASH: "s",
  132. RebaseTodoCommand.FIXUP: "f",
  133. RebaseTodoCommand.EXEC: "x",
  134. RebaseTodoCommand.BREAK: "b",
  135. RebaseTodoCommand.DROP: "d",
  136. RebaseTodoCommand.LABEL: "l",
  137. RebaseTodoCommand.RESET: "t",
  138. RebaseTodoCommand.MERGE: "m",
  139. }
  140. cmd = cmd_map.get(self.command, self.command.value)
  141. else:
  142. cmd = self.command.value
  143. parts = [cmd]
  144. if self.commit_sha:
  145. # Use short SHA (first 7 chars) like Git does
  146. parts.append(self.commit_sha.decode()[:7])
  147. if self.arguments:
  148. parts.append(self.arguments)
  149. elif self.short_message:
  150. parts.append(self.short_message)
  151. return " ".join(parts)
  152. @classmethod
  153. def from_string(cls, line: str) -> "RebaseTodoEntry | None":
  154. """Parse a todo entry from a line.
  155. Args:
  156. line: Line from git-rebase-todo file
  157. Returns:
  158. RebaseTodoEntry or None if line is empty/comment
  159. """
  160. line = line.strip()
  161. # Skip empty lines and comments
  162. if not line or line.startswith("#"):
  163. return None
  164. parts = line.split(None, 2)
  165. if not parts:
  166. return None
  167. command_str = parts[0]
  168. try:
  169. command = RebaseTodoCommand.from_string(command_str)
  170. except ValueError:
  171. # Unknown command, skip
  172. return None
  173. commit_sha = None
  174. short_message = None
  175. arguments = None
  176. if command in (
  177. RebaseTodoCommand.EXEC,
  178. RebaseTodoCommand.LABEL,
  179. RebaseTodoCommand.RESET,
  180. ):
  181. # These commands take arguments instead of commit SHA
  182. if len(parts) > 1:
  183. arguments = " ".join(parts[1:])
  184. elif command == RebaseTodoCommand.BREAK:
  185. # Break has no arguments
  186. pass
  187. else:
  188. # Commands that operate on commits
  189. if len(parts) > 1:
  190. # Store SHA as hex string encoded as bytes
  191. commit_sha = ObjectID(parts[1].encode())
  192. # Parse commit message if present
  193. if len(parts) > 2:
  194. short_message = parts[2]
  195. return cls(
  196. command=command,
  197. commit_sha=commit_sha,
  198. short_message=short_message,
  199. arguments=arguments,
  200. )
  201. class RebaseTodo:
  202. """Manages the git-rebase-todo file for interactive rebase."""
  203. def __init__(self, entries: list[RebaseTodoEntry] | None = None):
  204. """Initialize RebaseTodo.
  205. Args:
  206. entries: List of todo entries
  207. """
  208. self.entries = entries or []
  209. self.current_index = 0
  210. def add_entry(self, entry: RebaseTodoEntry) -> None:
  211. """Add an entry to the todo list."""
  212. self.entries.append(entry)
  213. def get_current(self) -> RebaseTodoEntry | None:
  214. """Get the current todo entry."""
  215. if self.current_index < len(self.entries):
  216. return self.entries[self.current_index]
  217. return None
  218. def advance(self) -> None:
  219. """Move to the next todo entry."""
  220. self.current_index += 1
  221. def is_complete(self) -> bool:
  222. """Check if all entries have been processed."""
  223. return self.current_index >= len(self.entries)
  224. def to_string(self, include_comments: bool = True) -> str:
  225. """Convert to git-rebase-todo file format.
  226. Args:
  227. include_comments: Include helpful comments
  228. Returns:
  229. String content for todo file
  230. """
  231. lines = []
  232. # Add entries from current position onward
  233. for entry in self.entries[self.current_index :]:
  234. lines.append(entry.to_string())
  235. if include_comments:
  236. lines.append("")
  237. lines.append("# Rebase in progress")
  238. lines.append("#")
  239. lines.append("# Commands:")
  240. lines.append("# p, pick <commit> = use commit")
  241. lines.append(
  242. "# r, reword <commit> = use commit, but edit the commit message"
  243. )
  244. lines.append("# e, edit <commit> = use commit, but stop for amending")
  245. lines.append(
  246. "# s, squash <commit> = use commit, but meld into previous commit"
  247. )
  248. lines.append(
  249. "# f, fixup [-C | -c] <commit> = like 'squash' but keep only the previous"
  250. )
  251. lines.append(
  252. "# commit's log message, unless -C is used, in which case"
  253. )
  254. lines.append(
  255. "# keep only this commit's message; -c is same as -C but"
  256. )
  257. lines.append("# opens the editor")
  258. lines.append(
  259. "# x, exec <command> = run command (the rest of the line) using shell"
  260. )
  261. lines.append(
  262. "# b, break = stop here (continue rebase later with 'git rebase --continue')"
  263. )
  264. lines.append("# d, drop <commit> = remove commit")
  265. lines.append("# l, label <label> = label current HEAD with a name")
  266. lines.append("# t, reset <label> = reset HEAD to a label")
  267. lines.append("# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]")
  268. lines.append(
  269. "# . create a merge commit using the original merge commit's"
  270. )
  271. lines.append(
  272. "# . message (or the oneline, if no original merge commit was"
  273. )
  274. lines.append(
  275. "# . specified); use -c <commit> to reword the commit message"
  276. )
  277. lines.append("#")
  278. lines.append(
  279. "# These lines can be re-ordered; they are executed from top to bottom."
  280. )
  281. lines.append("#")
  282. lines.append("# If you remove a line here THAT COMMIT WILL BE LOST.")
  283. lines.append("#")
  284. lines.append(
  285. "# However, if you remove everything, the rebase will be aborted."
  286. )
  287. lines.append("#")
  288. return "\n".join(lines)
  289. @classmethod
  290. def from_string(cls, content: str) -> "RebaseTodo":
  291. """Parse a git-rebase-todo file.
  292. Args:
  293. content: Content of todo file
  294. Returns:
  295. RebaseTodo instance
  296. """
  297. entries = []
  298. for line in content.splitlines():
  299. entry = RebaseTodoEntry.from_string(line)
  300. if entry:
  301. entries.append(entry)
  302. return cls(entries)
  303. @classmethod
  304. def from_commits(cls, commits: Sequence[Commit]) -> "RebaseTodo":
  305. """Create a todo list from a list of commits.
  306. Args:
  307. commits: List of commits to rebase (in chronological order)
  308. Returns:
  309. RebaseTodo instance with pick commands for each commit
  310. """
  311. entries = []
  312. for commit in commits:
  313. # Extract first line of commit message
  314. message = commit.message.decode("utf-8", errors="replace")
  315. short_message = message.split("\n")[0][:50]
  316. entry = RebaseTodoEntry(
  317. command=RebaseTodoCommand.PICK,
  318. commit_sha=commit.id, # Already bytes
  319. short_message=short_message,
  320. )
  321. entries.append(entry)
  322. return cls(entries)
  323. class RebaseStateManager(Protocol):
  324. """Protocol for managing rebase state."""
  325. def save(
  326. self,
  327. original_head: bytes | None,
  328. rebasing_branch: Ref | None,
  329. onto: ObjectID | None,
  330. todo: list[Commit],
  331. done: list[Commit],
  332. ) -> None:
  333. """Save rebase state."""
  334. ...
  335. def load(
  336. self,
  337. ) -> tuple[
  338. bytes | None, # original_head
  339. Ref | None, # rebasing_branch
  340. ObjectID | None, # onto
  341. list[Commit], # todo
  342. list[Commit], # done
  343. ]:
  344. """Load rebase state."""
  345. ...
  346. def clean(self) -> None:
  347. """Clean up rebase state."""
  348. ...
  349. def exists(self) -> bool:
  350. """Check if rebase state exists."""
  351. ...
  352. def save_todo(self, todo: RebaseTodo) -> None:
  353. """Save interactive rebase todo list."""
  354. ...
  355. def load_todo(self) -> RebaseTodo | None:
  356. """Load interactive rebase todo list."""
  357. ...
  358. class DiskRebaseStateManager:
  359. """Manages rebase state on disk using same files as C Git."""
  360. def __init__(self, path: str) -> None:
  361. """Initialize disk rebase state manager.
  362. Args:
  363. path: Path to the rebase-merge directory
  364. """
  365. self.path = path
  366. def save(
  367. self,
  368. original_head: bytes | None,
  369. rebasing_branch: Ref | None,
  370. onto: ObjectID | None,
  371. todo: list[Commit],
  372. done: list[Commit],
  373. ) -> None:
  374. """Save rebase state to disk."""
  375. # Ensure the directory exists
  376. os.makedirs(self.path, exist_ok=True)
  377. # Store the original HEAD ref (e.g. "refs/heads/feature")
  378. if original_head:
  379. self._write_file("orig-head", original_head)
  380. # Store the branch name being rebased
  381. if rebasing_branch:
  382. self._write_file("head-name", rebasing_branch)
  383. # Store the commit we're rebasing onto
  384. if onto:
  385. self._write_file("onto", onto)
  386. # Track progress
  387. if todo:
  388. # Store the current commit being rebased (same as C Git)
  389. current_commit = todo[0]
  390. self._write_file("stopped-sha", current_commit.id)
  391. # Store progress counters
  392. msgnum = len(done) + 1 # Current commit number (1-based)
  393. end = len(done) + len(todo) # Total number of commits
  394. self._write_file("msgnum", str(msgnum).encode())
  395. self._write_file("end", str(end).encode())
  396. def _write_file(self, name: str, content: bytes) -> None:
  397. """Write content to a file in the rebase directory."""
  398. with open(os.path.join(self.path, name), "wb") as f:
  399. f.write(content)
  400. def load(
  401. self,
  402. ) -> tuple[
  403. bytes | None,
  404. Ref | None,
  405. ObjectID | None,
  406. list[Commit],
  407. list[Commit],
  408. ]:
  409. """Load rebase state from disk."""
  410. original_head = None
  411. rebasing_branch_bytes = None
  412. onto_bytes = None
  413. todo: list[Commit] = []
  414. done: list[Commit] = []
  415. # Load rebase state files
  416. original_head = self._read_file("orig-head")
  417. rebasing_branch_bytes = self._read_file("head-name")
  418. rebasing_branch = (
  419. Ref(rebasing_branch_bytes) if rebasing_branch_bytes is not None else None
  420. )
  421. onto_bytes = self._read_file("onto")
  422. onto = ObjectID(onto_bytes) if onto_bytes is not None else None
  423. return original_head, rebasing_branch, onto, todo, done
  424. def _read_file(self, name: str) -> bytes | None:
  425. """Read content from a file in the rebase directory."""
  426. try:
  427. with open(os.path.join(self.path, name), "rb") as f:
  428. return f.read().strip()
  429. except FileNotFoundError:
  430. return None
  431. def clean(self) -> None:
  432. """Clean up rebase state files."""
  433. try:
  434. shutil.rmtree(self.path)
  435. except FileNotFoundError:
  436. # Directory doesn't exist, that's ok
  437. pass
  438. def exists(self) -> bool:
  439. """Check if rebase state exists."""
  440. return os.path.exists(os.path.join(self.path, "orig-head"))
  441. def save_todo(self, todo: RebaseTodo) -> None:
  442. """Save the interactive rebase todo list.
  443. Args:
  444. todo: The RebaseTodo object to save
  445. """
  446. todo_content = todo.to_string()
  447. self._write_file("git-rebase-todo", todo_content.encode("utf-8"))
  448. def load_todo(self) -> RebaseTodo | None:
  449. """Load the interactive rebase todo list.
  450. Returns:
  451. RebaseTodo object or None if no todo file exists
  452. """
  453. todo_content = self._read_file("git-rebase-todo")
  454. if todo_content:
  455. todo_str = todo_content.decode("utf-8", errors="replace")
  456. return RebaseTodo.from_string(todo_str)
  457. return None
  458. class RebaseState(TypedDict):
  459. """Type definition for rebase state."""
  460. original_head: bytes | None
  461. rebasing_branch: Ref | None
  462. onto: ObjectID | None
  463. todo: list[Commit]
  464. done: list[Commit]
  465. class MemoryRebaseStateManager:
  466. """Manages rebase state in memory for MemoryRepo."""
  467. def __init__(self, repo: BaseRepo) -> None:
  468. """Initialize MemoryRebaseStateManager.
  469. Args:
  470. repo: Repository instance
  471. """
  472. self.repo = repo
  473. self._state: RebaseState | None = None
  474. self._todo: RebaseTodo | None = None
  475. def save(
  476. self,
  477. original_head: bytes | None,
  478. rebasing_branch: Ref | None,
  479. onto: ObjectID | None,
  480. todo: list[Commit],
  481. done: list[Commit],
  482. ) -> None:
  483. """Save rebase state in memory."""
  484. self._state = RebaseState(
  485. original_head=original_head,
  486. rebasing_branch=rebasing_branch,
  487. onto=onto,
  488. todo=todo[:], # Copy the lists
  489. done=done[:],
  490. )
  491. def load(
  492. self,
  493. ) -> tuple[
  494. bytes | None,
  495. Ref | None,
  496. ObjectID | None,
  497. list[Commit],
  498. list[Commit],
  499. ]:
  500. """Load rebase state from memory."""
  501. if self._state is None:
  502. return None, None, None, [], []
  503. return (
  504. self._state["original_head"],
  505. self._state["rebasing_branch"],
  506. self._state["onto"],
  507. self._state["todo"][:], # Return copies
  508. self._state["done"][:],
  509. )
  510. def clean(self) -> None:
  511. """Clean up rebase state."""
  512. self._state = None
  513. self._todo = None
  514. def exists(self) -> bool:
  515. """Check if rebase state exists."""
  516. return self._state is not None
  517. def save_todo(self, todo: RebaseTodo) -> None:
  518. """Save the interactive rebase todo list.
  519. Args:
  520. todo: The RebaseTodo object to save
  521. """
  522. self._todo = todo
  523. def load_todo(self) -> RebaseTodo | None:
  524. """Load the interactive rebase todo list.
  525. Returns:
  526. RebaseTodo object or None if no todo exists
  527. """
  528. return self._todo
  529. class Rebaser:
  530. """Handles git rebase operations."""
  531. def __init__(self, repo: Repo):
  532. """Initialize rebaser.
  533. Args:
  534. repo: Repository to perform rebase in
  535. """
  536. self.repo = repo
  537. self.object_store = repo.object_store
  538. self._state_manager = repo.get_rebase_state_manager()
  539. # Initialize state
  540. self._original_head: bytes | None = None
  541. self._onto: ObjectID | None = None
  542. self._todo: list[Commit] = []
  543. self._done: list[Commit] = []
  544. self._rebasing_branch: Ref | None = None
  545. # Load any existing rebase state
  546. self._load_rebase_state()
  547. def _get_commits_to_rebase(
  548. self, upstream: bytes, branch: bytes | None = None
  549. ) -> list[Commit]:
  550. """Get list of commits to rebase.
  551. Args:
  552. upstream: Upstream commit/branch to rebase onto
  553. branch: Branch to rebase (defaults to current branch)
  554. Returns:
  555. List of commits to rebase in chronological order
  556. """
  557. # Get the branch commit
  558. if branch is None:
  559. # Use current HEAD
  560. _head_ref, head_sha = self.repo.refs.follow(HEADREF)
  561. if head_sha is None:
  562. raise ValueError("HEAD does not point to a valid commit")
  563. branch_commit = self.repo[head_sha]
  564. else:
  565. # Parse the branch reference
  566. branch_commit = parse_commit(self.repo, branch)
  567. # Get upstream commit
  568. upstream_commit = parse_commit(self.repo, upstream)
  569. # If already up to date, return empty list
  570. if branch_commit.id == upstream_commit.id:
  571. return []
  572. merge_bases = find_merge_base(self.repo, [branch_commit.id, upstream_commit.id])
  573. if not merge_bases:
  574. raise RebaseError("No common ancestor found")
  575. merge_base = merge_bases[0]
  576. # Get commits between merge base and branch head
  577. commits = []
  578. current = branch_commit
  579. while current.id != merge_base:
  580. assert isinstance(current, Commit)
  581. commits.append(current)
  582. if not current.parents:
  583. break
  584. current = self.repo[current.parents[0]]
  585. # Return in chronological order (oldest first)
  586. return list(reversed(commits))
  587. def _cherry_pick(
  588. self, commit: Commit, onto: ObjectID
  589. ) -> tuple[ObjectID | None, list[bytes]]:
  590. """Cherry-pick a commit onto another commit.
  591. Args:
  592. commit: Commit to cherry-pick
  593. onto: SHA of commit to cherry-pick onto
  594. Returns:
  595. Tuple of (new_commit_sha, list_of_conflicted_files)
  596. """
  597. # Get the parent of the commit being cherry-picked
  598. if not commit.parents:
  599. raise RebaseError(f"Cannot cherry-pick root commit {commit.id!r}")
  600. parent = self.repo[commit.parents[0]]
  601. onto_commit = self.repo[onto]
  602. assert isinstance(parent, Commit)
  603. assert isinstance(onto_commit, Commit)
  604. # Perform three-way merge
  605. merged_tree, conflicts = three_way_merge(
  606. self.object_store, parent, onto_commit, commit
  607. )
  608. if conflicts:
  609. # Store merge state for conflict resolution
  610. self.repo._put_named_file("rebase-merge/stopped-sha", commit.id)
  611. return None, conflicts
  612. # Create new commit
  613. new_commit = Commit()
  614. new_commit.tree = merged_tree.id
  615. new_commit.parents = [onto]
  616. new_commit.author = commit.author
  617. new_commit.author_time = commit.author_time
  618. new_commit.author_timezone = commit.author_timezone
  619. new_commit.committer = commit.committer
  620. new_commit.commit_time = commit.commit_time
  621. new_commit.commit_timezone = commit.commit_timezone
  622. new_commit.message = commit.message
  623. new_commit.encoding = commit.encoding
  624. self.object_store.add_object(merged_tree)
  625. self.object_store.add_object(new_commit)
  626. return new_commit.id, []
  627. def start(
  628. self,
  629. upstream: bytes,
  630. onto: bytes | None = None,
  631. branch: bytes | None = None,
  632. ) -> list[Commit]:
  633. """Start a rebase.
  634. Args:
  635. upstream: Upstream branch/commit to rebase onto
  636. onto: Specific commit to rebase onto (defaults to upstream)
  637. branch: Branch to rebase (defaults to current branch)
  638. Returns:
  639. List of commits that will be rebased
  640. """
  641. # Save original HEAD
  642. self._original_head = self.repo.refs.read_ref(HEADREF)
  643. # Save which branch we're rebasing (for later update)
  644. if branch is not None:
  645. # Parse the branch ref
  646. if branch.startswith(b"refs/heads/"):
  647. self._rebasing_branch = Ref(branch)
  648. else:
  649. # Assume it's a branch name
  650. self._rebasing_branch = Ref(local_branch_name(branch))
  651. else:
  652. # Use current branch
  653. if self._original_head is not None and self._original_head.startswith(
  654. b"ref: "
  655. ):
  656. self._rebasing_branch = Ref(self._original_head[5:])
  657. else:
  658. self._rebasing_branch = None
  659. # Determine onto commit
  660. if onto is None:
  661. onto = upstream
  662. # Parse the onto commit
  663. onto_commit = parse_commit(self.repo, onto)
  664. self._onto = onto_commit.id
  665. # Get commits to rebase
  666. commits = self._get_commits_to_rebase(upstream, branch)
  667. self._todo = commits
  668. self._done = []
  669. # Store rebase state
  670. self._save_rebase_state()
  671. return commits
  672. def continue_(self) -> tuple[bytes, list[bytes]] | None:
  673. """Continue an in-progress rebase.
  674. Returns:
  675. None if rebase is complete, or tuple of (commit_sha, conflicts) for next commit
  676. """
  677. if not self._todo:
  678. self._finish_rebase()
  679. return None
  680. # Get next commit to rebase
  681. commit = self._todo.pop(0)
  682. # Determine what to rebase onto
  683. if self._done:
  684. onto = self._done[-1].id
  685. else:
  686. if self._onto is None:
  687. raise RebaseError("No onto commit set")
  688. onto = self._onto
  689. # Cherry-pick the commit
  690. new_sha, conflicts = self._cherry_pick(commit, onto)
  691. if new_sha:
  692. # Success - add to done list
  693. new_commit = self.repo[new_sha]
  694. assert isinstance(new_commit, Commit)
  695. self._done.append(new_commit)
  696. self._save_rebase_state()
  697. # Continue with next commit if any
  698. if self._todo:
  699. return self.continue_()
  700. else:
  701. self._finish_rebase()
  702. return None
  703. else:
  704. # Conflicts - save state and return
  705. self._save_rebase_state()
  706. return (commit.id, conflicts)
  707. def is_in_progress(self) -> bool:
  708. """Check if a rebase is currently in progress."""
  709. return bool(self._state_manager.exists())
  710. def abort(self) -> None:
  711. """Abort an in-progress rebase and restore original state."""
  712. if not self.is_in_progress():
  713. raise RebaseError("No rebase in progress")
  714. # Restore original HEAD
  715. if self._original_head is None:
  716. raise RebaseError("No original HEAD to restore")
  717. set_ref_from_raw(self.repo.refs, HEADREF, self._original_head)
  718. # Clean up rebase state
  719. self._clean_rebase_state()
  720. # Reset instance state
  721. self._original_head = None
  722. self._onto = None
  723. self._todo = []
  724. self._done = []
  725. def _finish_rebase(self) -> None:
  726. """Finish rebase by updating HEAD and cleaning up."""
  727. if not self._done:
  728. # No commits were rebased
  729. return
  730. # Update HEAD to point to last rebased commit
  731. last_commit = self._done[-1]
  732. # Update the branch we're rebasing
  733. if self._rebasing_branch:
  734. self.repo.refs[self._rebasing_branch] = last_commit.id
  735. # If HEAD was pointing to this branch, it will follow automatically
  736. else:
  737. # If we don't know which branch, check current HEAD
  738. head_ref = self.repo.refs[HEADREF]
  739. if head_ref.startswith(b"ref: "):
  740. branch_ref = Ref(head_ref[5:])
  741. self.repo.refs[branch_ref] = last_commit.id
  742. else:
  743. # Detached HEAD
  744. self.repo.refs[HEADREF] = last_commit.id
  745. # Clean up rebase state
  746. self._clean_rebase_state()
  747. # Reset instance state but keep _done for caller
  748. self._original_head = None
  749. self._onto = None
  750. self._todo = []
  751. def _save_rebase_state(self) -> None:
  752. """Save rebase state to allow resuming."""
  753. self._state_manager.save(
  754. self._original_head,
  755. self._rebasing_branch,
  756. self._onto,
  757. self._todo,
  758. self._done,
  759. )
  760. def _load_rebase_state(self) -> None:
  761. """Load existing rebase state if present."""
  762. (
  763. self._original_head,
  764. self._rebasing_branch,
  765. self._onto,
  766. self._todo,
  767. self._done,
  768. ) = self._state_manager.load()
  769. def _clean_rebase_state(self) -> None:
  770. """Clean up rebase state files."""
  771. self._state_manager.clean()
  772. def rebase(
  773. repo: Repo,
  774. upstream: bytes,
  775. onto: bytes | None = None,
  776. branch: bytes | None = None,
  777. ) -> list[bytes]:
  778. """Perform a git rebase operation.
  779. Args:
  780. repo: Repository to rebase in
  781. upstream: Upstream branch/commit to rebase onto
  782. onto: Specific commit to rebase onto (defaults to upstream)
  783. branch: Branch to rebase (defaults to current branch)
  784. Returns:
  785. List of new commit SHAs created by rebase
  786. Raises:
  787. RebaseConflict: If conflicts occur during rebase
  788. RebaseError: For other rebase errors
  789. """
  790. rebaser = Rebaser(repo)
  791. # Start rebase
  792. rebaser.start(upstream, onto, branch)
  793. # Continue rebase
  794. result = rebaser.continue_()
  795. if result is not None:
  796. # Conflicts
  797. raise RebaseConflict(result[1])
  798. # Return the SHAs of the rebased commits
  799. return [c.id for c in rebaser._done]
  800. def start_interactive(
  801. repo: Repo,
  802. upstream: bytes,
  803. onto: bytes | None = None,
  804. branch: bytes | None = None,
  805. editor_callback: Callable[[bytes], bytes] | None = None,
  806. ) -> RebaseTodo:
  807. """Start an interactive rebase.
  808. This function generates a todo list and optionally opens an editor for the user
  809. to modify it before starting the rebase.
  810. Args:
  811. repo: Repository to rebase in
  812. upstream: Upstream branch/commit to rebase onto
  813. onto: Specific commit to rebase onto (defaults to upstream)
  814. branch: Branch to rebase (defaults to current branch)
  815. editor_callback: Optional callback to edit todo content. If None, no editing.
  816. Should take bytes and return bytes.
  817. Returns:
  818. RebaseTodo object with the (possibly edited) todo list
  819. Raises:
  820. RebaseError: If rebase cannot be started
  821. """
  822. rebaser = Rebaser(repo)
  823. # Get commits to rebase
  824. commits = rebaser.start(upstream, onto, branch)
  825. if not commits:
  826. raise RebaseError("No commits to rebase")
  827. # Generate todo list
  828. todo = RebaseTodo.from_commits(commits)
  829. # Save initial todo to disk
  830. state_manager = repo.get_rebase_state_manager()
  831. state_manager.save_todo(todo)
  832. # Let user edit todo if callback provided
  833. if editor_callback:
  834. todo_content = todo.to_string().encode("utf-8")
  835. edited_content = editor_callback(todo_content)
  836. # Parse edited todo
  837. edited_todo = RebaseTodo.from_string(
  838. edited_content.decode("utf-8", errors="replace")
  839. )
  840. # Check if user removed all entries (abort)
  841. if not edited_todo.entries:
  842. # User removed everything, abort
  843. rebaser.abort()
  844. raise RebaseAbort("Rebase aborted - empty todo list")
  845. todo = edited_todo
  846. # Save edited todo
  847. state_manager.save_todo(todo)
  848. return todo
  849. def edit_todo(repo: Repo, editor_callback: Callable[[bytes], bytes]) -> RebaseTodo:
  850. """Edit the todo list of an in-progress interactive rebase.
  851. Args:
  852. repo: Repository with in-progress rebase
  853. editor_callback: Callback to edit todo content. Takes bytes, returns bytes.
  854. Returns:
  855. Updated RebaseTodo object
  856. Raises:
  857. RebaseError: If no rebase is in progress or todo cannot be loaded
  858. """
  859. state_manager = repo.get_rebase_state_manager()
  860. if not state_manager.exists():
  861. raise RebaseError("No rebase in progress")
  862. # Load current todo
  863. todo = state_manager.load_todo()
  864. if not todo:
  865. raise RebaseError("No interactive rebase in progress")
  866. # Edit todo
  867. todo_content = todo.to_string().encode("utf-8")
  868. edited_content = editor_callback(todo_content)
  869. # Parse edited todo
  870. edited_todo = RebaseTodo.from_string(
  871. edited_content.decode("utf-8", errors="replace")
  872. )
  873. # Save edited todo
  874. state_manager.save_todo(edited_todo)
  875. return edited_todo
  876. def process_interactive_rebase(
  877. repo: Repo,
  878. todo: RebaseTodo | None = None,
  879. editor_callback: Callable[[bytes], bytes] | None = None,
  880. ) -> tuple[bool, str | None]:
  881. """Process an interactive rebase.
  882. This function executes the commands in the todo list sequentially.
  883. Args:
  884. repo: Repository to rebase in
  885. todo: RebaseTodo object (if None, loads from state)
  886. editor_callback: Optional callback for reword operations
  887. Returns:
  888. tuple of (``is_complete``, ``pause_reason``):
  889. * ``is_complete``: True if rebase is complete, False if paused
  890. * ``pause_reason``: Reason for pause (e.g., "edit", "conflict", "break") or None
  891. Raises:
  892. RebaseError: If rebase fails
  893. """
  894. state_manager = repo.get_rebase_state_manager()
  895. rebaser = Rebaser(repo)
  896. # Load todo if not provided
  897. if todo is None:
  898. todo = state_manager.load_todo()
  899. if not todo:
  900. raise RebaseError("No interactive rebase in progress")
  901. # Process each todo entry
  902. while not todo.is_complete():
  903. entry = todo.get_current()
  904. if not entry:
  905. break
  906. # Handle each command type
  907. if entry.command == RebaseTodoCommand.PICK:
  908. # Regular cherry-pick
  909. result = rebaser.continue_()
  910. if result is not None:
  911. # Conflicts
  912. return False, "conflict"
  913. elif entry.command == RebaseTodoCommand.REWORD:
  914. # Cherry-pick then edit message
  915. result = rebaser.continue_()
  916. if result is not None:
  917. # Conflicts
  918. return False, "conflict"
  919. # Get the last commit and allow editing its message
  920. if rebaser._done and editor_callback:
  921. last_commit = rebaser._done[-1]
  922. new_message = editor_callback(last_commit.message)
  923. # Create new commit with edited message
  924. new_commit = Commit()
  925. new_commit.tree = last_commit.tree
  926. new_commit.parents = last_commit.parents
  927. new_commit.author = last_commit.author
  928. new_commit.author_time = last_commit.author_time
  929. new_commit.author_timezone = last_commit.author_timezone
  930. new_commit.committer = last_commit.committer
  931. new_commit.commit_time = last_commit.commit_time
  932. new_commit.commit_timezone = last_commit.commit_timezone
  933. new_commit.message = new_message
  934. new_commit.encoding = last_commit.encoding
  935. repo.object_store.add_object(new_commit)
  936. # Replace last commit in done list
  937. rebaser._done[-1] = new_commit
  938. elif entry.command == RebaseTodoCommand.EDIT:
  939. # Cherry-pick then pause
  940. result = rebaser.continue_()
  941. if result is not None:
  942. # Conflicts
  943. return False, "conflict"
  944. # Pause for user to amend
  945. todo.advance()
  946. state_manager.save_todo(todo)
  947. return False, "edit"
  948. elif entry.command == RebaseTodoCommand.SQUASH:
  949. # Combine with previous commit, keeping both messages
  950. if not rebaser._done:
  951. raise RebaseError("Cannot squash without a previous commit")
  952. conflict_result = _squash_commits(
  953. repo, rebaser, entry, keep_message=True, editor_callback=editor_callback
  954. )
  955. if conflict_result == "conflict":
  956. return False, "conflict"
  957. elif entry.command == RebaseTodoCommand.FIXUP:
  958. # Combine with previous commit, discarding this message
  959. if not rebaser._done:
  960. raise RebaseError("Cannot fixup without a previous commit")
  961. conflict_result = _squash_commits(
  962. repo, rebaser, entry, keep_message=False, editor_callback=None
  963. )
  964. if conflict_result == "conflict":
  965. return False, "conflict"
  966. elif entry.command == RebaseTodoCommand.DROP:
  967. # Skip this commit
  968. if rebaser._todo:
  969. rebaser._todo.pop(0)
  970. elif entry.command == RebaseTodoCommand.EXEC:
  971. # Execute shell command
  972. if entry.arguments:
  973. try:
  974. subprocess.run(entry.arguments, shell=True, check=True)
  975. except subprocess.CalledProcessError as e:
  976. # Command failed, pause rebase
  977. return False, f"exec failed: {e}"
  978. elif entry.command == RebaseTodoCommand.BREAK:
  979. # Pause rebase
  980. todo.advance()
  981. state_manager.save_todo(todo)
  982. return False, "break"
  983. else:
  984. # Unsupported command
  985. raise RebaseError(f"Unsupported rebase command: {entry.command.value}")
  986. # Move to next entry
  987. todo.advance()
  988. # Save progress
  989. state_manager.save_todo(todo)
  990. rebaser._save_rebase_state()
  991. # Rebase complete
  992. rebaser._finish_rebase()
  993. return True, None
  994. def _squash_commits(
  995. repo: Repo,
  996. rebaser: Rebaser,
  997. entry: RebaseTodoEntry,
  998. keep_message: bool,
  999. editor_callback: Callable[[bytes], bytes] | None = None,
  1000. ) -> str | None:
  1001. """Helper to squash/fixup commits.
  1002. Args:
  1003. repo: Repository
  1004. rebaser: Rebaser instance
  1005. entry: Todo entry for the commit to squash
  1006. keep_message: Whether to keep this commit's message (squash) or discard (fixup)
  1007. editor_callback: Optional callback to edit combined message (for squash)
  1008. Returns:
  1009. None on success, "conflict" on conflict
  1010. """
  1011. if not rebaser._done:
  1012. raise RebaseError("Cannot squash without a previous commit")
  1013. # Get the commit to squash
  1014. if not entry.commit_sha:
  1015. raise RebaseError("No commit SHA for squash/fixup operation")
  1016. commit_to_squash = repo[entry.commit_sha]
  1017. if not isinstance(commit_to_squash, Commit):
  1018. raise RebaseError(f"Expected commit, got {type(commit_to_squash).__name__}")
  1019. # Get the previous commit (target of squash)
  1020. previous_commit = rebaser._done[-1]
  1021. # Cherry-pick the changes onto the previous commit
  1022. parent = repo[commit_to_squash.parents[0]]
  1023. if not isinstance(parent, Commit):
  1024. raise RebaseError(f"Expected parent commit, got {type(parent).__name__}")
  1025. # Perform three-way merge for the tree
  1026. merged_tree, conflicts = three_way_merge(
  1027. repo.object_store, parent, previous_commit, commit_to_squash
  1028. )
  1029. if conflicts:
  1030. return "conflict"
  1031. # Combine messages if squashing (not fixup)
  1032. if keep_message:
  1033. combined_message = previous_commit.message + b"\n\n" + commit_to_squash.message
  1034. if editor_callback:
  1035. combined_message = editor_callback(combined_message)
  1036. else:
  1037. combined_message = previous_commit.message
  1038. # Create new combined commit
  1039. new_commit = Commit()
  1040. new_commit.tree = merged_tree.id
  1041. new_commit.parents = previous_commit.parents
  1042. new_commit.author = previous_commit.author
  1043. new_commit.author_time = previous_commit.author_time
  1044. new_commit.author_timezone = previous_commit.author_timezone
  1045. new_commit.committer = commit_to_squash.committer
  1046. new_commit.commit_time = commit_to_squash.commit_time
  1047. new_commit.commit_timezone = commit_to_squash.commit_timezone
  1048. new_commit.message = combined_message
  1049. new_commit.encoding = previous_commit.encoding
  1050. repo.object_store.add_object(merged_tree)
  1051. repo.object_store.add_object(new_commit)
  1052. # Replace the previous commit with the combined one
  1053. rebaser._done[-1] = new_commit
  1054. # Remove the squashed commit from todo
  1055. if rebaser._todo and rebaser._todo[0].id == commit_to_squash.id:
  1056. rebaser._todo.pop(0)
  1057. return None