rebase.py 39 KB

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