rebase.py 39 KB

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