git_tailor/repo.rs
1// Copyright 2026 Thomas Johannesson
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15pub mod git2_impl;
16
17pub use git2_impl::Git2Repo;
18
19use anyhow::Result;
20
21use crate::{CommitDiff, CommitInfo};
22
23/// Result of a rebase operation that may encounter merge conflicts.
24#[derive(Debug)]
25pub enum RebaseOutcome {
26 /// The rebase completed without conflicts.
27 Complete,
28 /// A cherry-pick step produced a merge conflict. The conflicted state has
29 /// been written to the working tree and index so the user can resolve it.
30 Conflict(Box<ConflictState>),
31}
32
33/// Enough state to resume or abort a conflicted rebase.
34///
35/// When a cherry-pick produces conflicts during a rebase, the partially
36/// merged index is written to the working tree. The user resolves the
37/// conflicts, then calls `rebase_continue` (which reads the resolved
38/// index and creates the commit) or `rebase_abort` (which restores
39/// the branch to `original_branch_oid`).
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct ConflictState {
42 /// Human-readable label for the operation that triggered this conflict
43 /// (e.g. "Drop", "Squash"). Used in dialog titles and messages.
44 pub operation_label: String,
45 /// The branch tip OID before the operation started, used to restore on
46 /// abort.
47 pub original_branch_oid: String,
48 /// The new tip OID built so far (all commits cherry-picked before the
49 /// conflicting one).
50 pub new_tip_oid: String,
51 /// The OID of the commit whose cherry-pick conflicted.
52 pub conflicting_commit_oid: String,
53 /// OIDs of commits that still need to be cherry-picked after the
54 /// conflicting commit is resolved, in order (oldest first).
55 pub remaining_oids: Vec<String>,
56 /// Paths of files that have conflict markers in the index (stage > 0).
57 /// Collected at the point of conflict so the dialog can list them.
58 pub conflicting_files: Vec<String>,
59 /// True when `rebase_continue` was called but the index still had
60 /// unresolved entries. The dialog uses this to show a warning to the user.
61 pub still_unresolved: bool,
62 /// When this conflict was triggered by a move operation, this holds the OID
63 /// of the commit being moved. The conflict view uses it to tell the user
64 /// whether the moved commit itself conflicted or a successor did.
65 pub moved_commit_oid: Option<String>,
66 /// When present, the conflict arose during the initial squash tree
67 /// creation (source vs target overlap). After the user resolves the
68 /// conflict the TUI should open the editor and then call
69 /// `squash_finalize` instead of `rebase_continue`.
70 pub squash_context: Option<SquashContext>,
71}
72
73/// Extra state carried through a squash-time conflict so that the squash
74/// can be finalized after the user resolves the conflicting tree.
75#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct SquashContext {
77 /// OID of the target commit's parent (the base for the squash commit).
78 pub base_oid: String,
79 /// OID of the source commit (removed after squash).
80 pub source_oid: String,
81 /// OID of the target commit (author/committer are taken from here).
82 pub target_oid: String,
83 /// The combined default message (target + source), shown in the editor.
84 pub combined_message: String,
85 /// OIDs of descendants to rebase after the squash commit is created.
86 pub descendant_oids: Vec<String>,
87}
88
89/// Abstraction over git repository operations.
90///
91/// Isolates the `git2` crate to the `repo::git2_impl` module. Callers work
92/// through this trait so that the real `Git2Repo` implementation can be
93/// swapped with a mock or fake in tests.
94pub trait GitRepo {
95 /// Returns the OID that HEAD currently points at.
96 ///
97 /// Fails if HEAD is detached or does not resolve to a direct commit
98 /// reference.
99 fn head_oid(&self) -> Result<String>;
100
101 /// Find the merge-base (reference point) between HEAD and a given commit-ish.
102 ///
103 /// The commit-ish can be:
104 /// - A branch name (e.g., "main", "feature")
105 /// - A tag name (e.g., "v1.0")
106 /// - A commit hash (short or long)
107 ///
108 /// Returns the OID of the common ancestor as a string.
109 fn find_reference_point(&self, commit_ish: &str) -> Result<String>;
110
111 /// List commits from one commit back to another (inclusive).
112 ///
113 /// Walks the commit graph from `from_oid` back to `to_oid`, collecting
114 /// commit metadata. Returns commits in oldest-to-newest order.
115 ///
116 /// Both `from_oid` and `to_oid` can be any commit-ish (branch, tag, hash).
117 /// The range includes both endpoints.
118 fn list_commits(&self, from_oid: &str, to_oid: &str) -> Result<Vec<CommitInfo>>;
119
120 /// Extract the full diff for a single commit compared to its first parent.
121 ///
122 /// For the root commit (no parents), diffs against an empty tree so all
123 /// files show as additions. Returns a `CommitDiff` containing the commit
124 /// metadata and every file/hunk/line changed.
125 fn commit_diff(&self, oid: &str) -> Result<CommitDiff>;
126
127 /// Extract commit diff with zero context lines, suitable for fragmap analysis.
128 ///
129 /// The fragmap algorithm needs each logical change as its own hunk. With
130 /// the default 3-line context, git merges adjacent hunks together which
131 /// produces fewer but larger hunks — breaking the SPG's fine-grained
132 /// span tracking.
133 fn commit_diff_for_fragmap(&self, oid: &str) -> Result<CommitDiff>;
134
135 /// Return a synthetic `CommitDiff` for changes staged in the index (index vs HEAD).
136 ///
137 /// Returns `None` when the index is clean (no staged changes).
138 fn staged_diff(&self) -> Option<CommitDiff>;
139
140 /// Return a synthetic `CommitDiff` for unstaged working-tree changes (workdir vs index).
141 ///
142 /// Returns `None` when the working tree is clean relative to the index.
143 fn unstaged_diff(&self) -> Option<CommitDiff>;
144
145 /// Split a commit into one commit per changed file.
146 ///
147 /// Creates N new commits (one per file touched by `commit_oid`), each applying
148 /// only that file's changes. Rebases all commits between `commit_oid` (exclusive)
149 /// and `head_oid` (inclusive) onto the resulting commits, then fast-forwards the
150 /// branch ref to the new tip.
151 ///
152 /// Fails if:
153 /// - the commit has fewer than 2 changed files (nothing to split)
154 /// - staged or unstaged changes share file paths with the commit being split
155 /// - a rebase conflict occurs while rebuilding descendants
156 fn split_commit_per_file(&self, commit_oid: &str, head_oid: &str) -> Result<()>;
157
158 /// Split a commit into one commit per hunk.
159 ///
160 /// Creates N new commits (one per hunk across all files), in file-then-hunk-index
161 /// order. Each intermediate tree is built by cumulatively applying the first k hunks
162 /// of the full diff (with 0 context lines) onto the original parent tree.
163 ///
164 /// Fails if:
165 /// - the commit has fewer than 2 hunks (nothing to split)
166 /// - staged or unstaged changes share file paths with the commit being split
167 /// - a rebase conflict occurs while rebuilding descendants
168 fn split_commit_per_hunk(&self, commit_oid: &str, head_oid: &str) -> Result<()>;
169
170 /// Split a commit into one commit per hunk group.
171 ///
172 /// Hunks are grouped using the same SPG-based fragmap algorithm shown in the
173 /// hunk group matrix: two hunks from the commit end up in the same group when
174 /// they share the same set of interacting commits on the branch (i.e. their
175 /// fragmap columns deduplicate to the same column). This yields fewer, more
176 /// cohesive commits than per-hunk splitting, and the groups match exactly what
177 /// the user sees in the TUI fragmap after deduplication.
178 ///
179 /// Fails if:
180 /// - the commit cannot be mapped to at least 2 fragmap groups (nothing to split)
181 /// - staged or unstaged changes share file paths with the commit being split
182 /// - a rebase conflict occurs while rebuilding descendants
183 fn split_commit_per_hunk_group(
184 &self,
185 commit_oid: &str,
186 head_oid: &str,
187 reference_oid: &str,
188 ) -> Result<()>;
189
190 /// Count how many commits `split_commit_per_file` would produce for this commit.
191 fn count_split_per_file(&self, commit_oid: &str) -> Result<usize>;
192
193 /// Count how many commits `split_commit_per_hunk` would produce for this commit.
194 fn count_split_per_hunk(&self, commit_oid: &str) -> Result<usize>;
195
196 /// Count how many fragmap groups `split_commit_per_hunk_group` would produce
197 /// for this commit, given the full branch context up to `head_oid` from
198 /// `reference_oid`.
199 fn count_split_per_hunk_group(
200 &self,
201 commit_oid: &str,
202 head_oid: &str,
203 reference_oid: &str,
204 ) -> Result<usize>;
205
206 /// Reword the message of an existing commit.
207 ///
208 /// Creates a new commit with the same tree and parents as `commit_oid` but
209 /// with `new_message` as the commit message, then cherry-picks all commits
210 /// strictly between `commit_oid` and `head_oid` (inclusive) onto the new
211 /// commit, and fast-forwards the branch ref to the resulting tip.
212 ///
213 /// Because only the message changes the diff at every step is identical, so
214 /// no conflicts can arise from staged or unstaged working-tree changes.
215 fn reword_commit(&self, commit_oid: &str, new_message: &str, head_oid: &str) -> Result<()>;
216
217 /// Read a string value from the repository's git configuration.
218 ///
219 /// Returns `None` when the key does not exist or is not valid UTF-8.
220 fn get_config_string(&self, key: &str) -> Option<String>;
221
222 /// Drop a commit from the branch by cherry-picking its descendants onto
223 /// its parent.
224 ///
225 /// Returns `RebaseOutcome::Complete` when all descendants are
226 /// successfully rebased, or `RebaseOutcome::Conflict` when a cherry-pick
227 /// step produces merge conflicts. In the conflict case the working tree
228 /// and index contain the partially merged state for the user to resolve.
229 fn drop_commit(&self, commit_oid: &str, head_oid: &str) -> Result<RebaseOutcome>;
230
231 /// Move a commit to a different position on the branch.
232 ///
233 /// Removes `commit_oid` from its current position and inserts it
234 /// immediately after `insert_after_oid`. All affected descendants are
235 /// cherry-picked in the new order.
236 ///
237 /// `insert_after_oid` may be the merge-base (reference point) to move
238 /// the commit to the very beginning of the branch.
239 ///
240 /// Returns `RebaseOutcome::Complete` on success or
241 /// `RebaseOutcome::Conflict` when a cherry-pick step conflicts.
242 fn move_commit(
243 &self,
244 commit_oid: &str,
245 insert_after_oid: &str,
246 head_oid: &str,
247 ) -> Result<RebaseOutcome>;
248
249 /// Resume a conflicted rebase after the user has resolved conflicts.
250 ///
251 /// Reads the current index (which the user resolved), creates a commit
252 /// for the conflicting cherry-pick, then continues cherry-picking the
253 /// remaining descendants. Returns a new `RebaseOutcome` — the next
254 /// cherry-pick may also conflict.
255 fn rebase_continue(&self, state: &ConflictState) -> Result<RebaseOutcome>;
256
257 /// Abort a conflicted rebase and restore the branch to its original state.
258 ///
259 /// Resets the branch ref to `state.original_branch_oid`, cleans up the
260 /// working tree and index.
261 fn rebase_abort(&self, state: &ConflictState) -> Result<()>;
262
263 /// Return the path of the repository's working directory, if any.
264 ///
265 /// Bare repositories have no working directory and return `None`.
266 fn workdir(&self) -> Option<std::path::PathBuf>;
267
268 /// Read the raw blob content of a specific index stage for a conflicted path.
269 ///
270 /// Stage 1 = base (common ancestor), 2 = ours, 3 = theirs.
271 /// Returns `None` when that stage entry does not exist for the path.
272 fn read_index_stage(&self, path: &str, stage: i32) -> Result<Option<Vec<u8>>>;
273
274 /// Return the list of paths that currently have conflict markers in the index
275 /// (entries with stage > 0), sorted alphabetically and deduplicated.
276 fn read_conflicting_files(&self) -> Vec<String>;
277
278 /// Squash two commits into one.
279 ///
280 /// Creates a single commit that combines `target_oid` (older) and
281 /// `source_oid` (newer) by cherry-picking source's diff onto target's
282 /// tree. The result replaces target's position in the history and source
283 /// is removed. All descendants between target and `head_oid` (excluding
284 /// source) are rebased onto the squash commit.
285 ///
286 /// Returns `RebaseOutcome::Complete` on success or
287 /// `RebaseOutcome::Conflict` when a cherry-pick conflicts.
288 ///
289 /// When the initial squash tree creation (source vs target) conflicts,
290 /// the returned `ConflictState` carries a `squash_context` so the TUI
291 /// can let the user resolve, then call `squash_finalize`.
292 fn squash_commits(
293 &self,
294 source_oid: &str,
295 target_oid: &str,
296 message: &str,
297 head_oid: &str,
298 ) -> Result<RebaseOutcome>;
299
300 /// Test whether combining source onto target produces a conflict.
301 ///
302 /// Returns `Ok(None)` when the trees merge cleanly (caller should proceed
303 /// to open the editor and then call `squash_commits`).
304 ///
305 /// Returns `Ok(Some(ConflictState))` when the cherry-pick conflicts. The
306 /// conflict is written to the working tree and index. The `ConflictState`
307 /// carries a `SquashContext` so the TUI can let the user resolve, then
308 /// open the editor, then call `squash_finalize`.
309 fn squash_try_combine(
310 &self,
311 source_oid: &str,
312 target_oid: &str,
313 combined_message: &str,
314 head_oid: &str,
315 ) -> Result<Option<ConflictState>>;
316
317 /// Finalize a squash after the user resolved a squash-time tree conflict.
318 ///
319 /// Reads the resolved index, creates the squash commit with `message`,
320 /// then cherry-picks the descendants listed in `ctx`. Returns
321 /// `RebaseOutcome::Complete` or `Conflict` for a descendant conflict.
322 fn squash_finalize(
323 &self,
324 ctx: &SquashContext,
325 message: &str,
326 original_branch_oid: &str,
327 ) -> Result<RebaseOutcome>;
328
329 /// Stage a working-tree file, clearing any conflict entries for that path.
330 ///
331 /// Equivalent to `git add <path>`. Reads the file from the working directory,
332 /// adds it to the index at stage 0 (which removes stages 1/2/3), and writes
333 /// the updated index to disk. Must be called after a merge tool resolves a
334 /// conflict so that subsequent `index.has_conflicts()` checks return false.
335 fn stage_file(&self, path: &str) -> Result<()>;
336}
337
338impl ConflictState {
339 /// Whether this conflict arose from a squash-time tree conflict
340 /// (as opposed to a descendant rebase conflict).
341 pub fn is_squash_tree_conflict(&self) -> bool {
342 self.squash_context.is_some()
343 }
344}