rebase.py 39 KB

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