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 message to use for the squash commit. For squash this is the
84 /// combined (target + source) message shown in the editor; for fixup
85 /// this is just the target message, used as-is without opening an editor.
86 pub combined_message: String,
87 /// OIDs of descendants to rebase after the squash commit is created.
88 pub descendant_oids: Vec<String>,
89 /// When true the operation is a fixup: the editor is skipped and
90 /// `combined_message` (the target message) is used directly.
91 pub is_fixup: bool,
92}
93
94/// Abstraction over git repository operations.
95///
96/// Isolates the `git2` crate to the `repo::git2_impl` module. Callers work
97/// through this trait so that the real `Git2Repo` implementation can be
98/// swapped with a mock or fake in tests.
99pub trait GitRepo {
100 /// Returns the OID that HEAD currently points at.
101 ///
102 /// Fails if HEAD is detached or does not resolve to a direct commit
103 /// reference.
104 fn head_oid(&self) -> Result<String>;
105
106 /// Find the merge-base (reference point) between HEAD and a given commit-ish.
107 ///
108 /// The commit-ish can be:
109 /// - A branch name (e.g., "main", "feature")
110 /// - A tag name (e.g., "v1.0")
111 /// - A commit hash (short or long)
112 ///
113 /// Returns the OID of the common ancestor as a string.
114 fn find_reference_point(&self, commit_ish: &str) -> Result<String>;
115
116 /// List commits from one commit back to another (inclusive).
117 ///
118 /// Walks the commit graph from `from_oid` back to `to_oid`, collecting
119 /// commit metadata. Returns commits in oldest-to-newest order.
120 ///
121 /// Both `from_oid` and `to_oid` can be any commit-ish (branch, tag, hash).
122 /// The range includes both endpoints.
123 fn list_commits(&self, from_oid: &str, to_oid: &str) -> Result<Vec<CommitInfo>>;
124
125 /// Extract the full diff for a single commit compared to its first parent.
126 ///
127 /// For the root commit (no parents), diffs against an empty tree so all
128 /// files show as additions. Returns a `CommitDiff` containing the commit
129 /// metadata and every file/hunk/line changed.
130 fn commit_diff(&self, oid: &str) -> Result<CommitDiff>;
131
132 /// Extract commit diff with zero context lines, suitable for fragmap analysis.
133 ///
134 /// The fragmap algorithm needs each logical change as its own hunk. With
135 /// the default 3-line context, git merges adjacent hunks together which
136 /// produces fewer but larger hunks — breaking the SPG's fine-grained
137 /// span tracking.
138 fn commit_diff_for_fragmap(&self, oid: &str) -> Result<CommitDiff>;
139
140 /// Return a synthetic `CommitDiff` for changes staged in the index (index vs HEAD).
141 ///
142 /// Returns `None` when the index is clean (no staged changes).
143 fn staged_diff(&self) -> Option<CommitDiff>;
144
145 /// Return a synthetic `CommitDiff` for unstaged working-tree changes (workdir vs index).
146 ///
147 /// Returns `None` when the working tree is clean relative to the index.
148 fn unstaged_diff(&self) -> Option<CommitDiff>;
149
150 /// Split a commit into one commit per changed file.
151 ///
152 /// Creates N new commits (one per file touched by `commit_oid`), each applying
153 /// only that file's changes. Rebases all commits between `commit_oid` (exclusive)
154 /// and `head_oid` (inclusive) onto the resulting commits, then fast-forwards the
155 /// branch ref to the new tip.
156 ///
157 /// Fails if:
158 /// - the commit has fewer than 2 changed files (nothing to split)
159 /// - staged or unstaged changes share file paths with the commit being split
160 /// - a rebase conflict occurs while rebuilding descendants
161 fn split_commit_per_file(&self, commit_oid: &str, head_oid: &str) -> Result<()>;
162
163 /// Split a commit into one commit per hunk.
164 ///
165 /// Creates N new commits (one per hunk across all files), in file-then-hunk-index
166 /// order. Each intermediate tree is built by cumulatively applying the first k hunks
167 /// of the full diff (with 0 context lines) onto the original parent tree.
168 ///
169 /// Fails if:
170 /// - the commit has fewer than 2 hunks (nothing to split)
171 /// - staged or unstaged changes share file paths with the commit being split
172 /// - a rebase conflict occurs while rebuilding descendants
173 fn split_commit_per_hunk(&self, commit_oid: &str, head_oid: &str) -> Result<()>;
174
175 /// Split a commit into one commit per hunk group.
176 ///
177 /// Hunks are grouped using the same SPG-based fragmap algorithm shown in the
178 /// hunk group matrix: two hunks from the commit end up in the same group when
179 /// they share the same set of interacting commits on the branch (i.e. their
180 /// fragmap columns deduplicate to the same column). This yields fewer, more
181 /// cohesive commits than per-hunk splitting, and the groups match exactly what
182 /// the user sees in the TUI fragmap after deduplication.
183 ///
184 /// Fails if:
185 /// - the commit cannot be mapped to at least 2 fragmap groups (nothing to split)
186 /// - staged or unstaged changes share file paths with the commit being split
187 /// - a rebase conflict occurs while rebuilding descendants
188 fn split_commit_per_hunk_group(
189 &self,
190 commit_oid: &str,
191 head_oid: &str,
192 reference_oid: &str,
193 ) -> Result<()>;
194
195 /// Count how many commits `split_commit_per_file` would produce for this commit.
196 fn count_split_per_file(&self, commit_oid: &str) -> Result<usize>;
197
198 /// Count how many commits `split_commit_per_hunk` would produce for this commit.
199 fn count_split_per_hunk(&self, commit_oid: &str) -> Result<usize>;
200
201 /// Count how many fragmap groups `split_commit_per_hunk_group` would produce
202 /// for this commit, given the full branch context up to `head_oid` from
203 /// `reference_oid`.
204 fn count_split_per_hunk_group(
205 &self,
206 commit_oid: &str,
207 head_oid: &str,
208 reference_oid: &str,
209 ) -> Result<usize>;
210
211 /// Reword the message of an existing commit.
212 ///
213 /// Creates a new commit with the same tree and parents as `commit_oid` but
214 /// with `new_message` as the commit message, then cherry-picks all commits
215 /// strictly between `commit_oid` and `head_oid` (inclusive) onto the new
216 /// commit, and fast-forwards the branch ref to the resulting tip.
217 ///
218 /// Because only the message changes the diff at every step is identical, so
219 /// no conflicts can arise from staged or unstaged working-tree changes.
220 fn reword_commit(&self, commit_oid: &str, new_message: &str, head_oid: &str) -> Result<()>;
221
222 /// Read a string value from the repository's git configuration.
223 ///
224 /// Returns `None` when the key does not exist or is not valid UTF-8.
225 fn get_config_string(&self, key: &str) -> Option<String>;
226
227 /// Drop a commit from the branch by cherry-picking its descendants onto
228 /// its parent.
229 ///
230 /// Returns `RebaseOutcome::Complete` when all descendants are
231 /// successfully rebased, or `RebaseOutcome::Conflict` when a cherry-pick
232 /// step produces merge conflicts. In the conflict case the working tree
233 /// and index contain the partially merged state for the user to resolve.
234 fn drop_commit(&self, commit_oid: &str, head_oid: &str) -> Result<RebaseOutcome>;
235
236 /// Move a commit to a different position on the branch.
237 ///
238 /// Removes `commit_oid` from its current position and inserts it
239 /// immediately after `insert_after_oid`. All affected descendants are
240 /// cherry-picked in the new order.
241 ///
242 /// `insert_after_oid` may be the merge-base (reference point) to move
243 /// the commit to the very beginning of the branch.
244 ///
245 /// Returns `RebaseOutcome::Complete` on success or
246 /// `RebaseOutcome::Conflict` when a cherry-pick step conflicts.
247 fn move_commit(
248 &self,
249 commit_oid: &str,
250 insert_after_oid: &str,
251 head_oid: &str,
252 ) -> Result<RebaseOutcome>;
253
254 /// Resume a conflicted rebase after the user has resolved conflicts.
255 ///
256 /// Reads the current index (which the user resolved), creates a commit
257 /// for the conflicting cherry-pick, then continues cherry-picking the
258 /// remaining descendants. Returns a new `RebaseOutcome` — the next
259 /// cherry-pick may also conflict.
260 fn rebase_continue(&self, state: &ConflictState) -> Result<RebaseOutcome>;
261
262 /// Abort a conflicted rebase and restore the branch to its original state.
263 ///
264 /// Resets the branch ref to `state.original_branch_oid`, cleans up the
265 /// working tree and index.
266 fn rebase_abort(&self, state: &ConflictState) -> Result<()>;
267
268 /// Return the path of the repository's working directory, if any.
269 ///
270 /// Bare repositories have no working directory and return `None`.
271 fn workdir(&self) -> Option<std::path::PathBuf>;
272
273 /// Read the raw blob content of a specific index stage for a conflicted path.
274 ///
275 /// Stage 1 = base (common ancestor), 2 = ours, 3 = theirs.
276 /// Returns `None` when that stage entry does not exist for the path.
277 fn read_index_stage(&self, path: &str, stage: i32) -> Result<Option<Vec<u8>>>;
278
279 /// Return the list of paths that currently have conflict markers in the index
280 /// (entries with stage > 0), sorted alphabetically and deduplicated.
281 fn read_conflicting_files(&self) -> Vec<String>;
282
283 /// Squash two commits into one.
284 ///
285 /// Creates a single commit that combines `target_oid` (older) and
286 /// `source_oid` (newer) by cherry-picking source's diff onto target's
287 /// tree. The result replaces target's position in the history and source
288 /// is removed. All descendants between target and `head_oid` (excluding
289 /// source) are rebased onto the squash commit.
290 ///
291 /// Returns `RebaseOutcome::Complete` on success or
292 /// `RebaseOutcome::Conflict` when a cherry-pick conflicts.
293 ///
294 /// When the initial squash tree creation (source vs target) conflicts,
295 /// the returned `ConflictState` carries a `squash_context` so the TUI
296 /// can let the user resolve, then call `squash_finalize`.
297 fn squash_commits(
298 &self,
299 source_oid: &str,
300 target_oid: &str,
301 message: &str,
302 head_oid: &str,
303 ) -> Result<RebaseOutcome>;
304
305 /// Test whether combining source onto target produces a conflict.
306 ///
307 /// Returns `Ok(None)` when the trees merge cleanly (caller should proceed
308 /// to open the editor and then call `squash_commits`).
309 ///
310 /// Returns `Ok(Some(ConflictState))` when the cherry-pick conflicts. The
311 /// conflict is written to the working tree and index. The `ConflictState`
312 /// carries a `SquashContext` so the TUI can let the user resolve, then
313 /// (for squash) open the editor and call `squash_finalize`, or (for fixup)
314 /// call `squash_finalize` directly without opening the editor.
315 fn squash_try_combine(
316 &self,
317 source_oid: &str,
318 target_oid: &str,
319 combined_message: &str,
320 is_fixup: bool,
321 head_oid: &str,
322 ) -> Result<Option<ConflictState>>;
323
324 /// Finalize a squash after the user resolved a squash-time tree conflict.
325 ///
326 /// Reads the resolved index, creates the squash commit with `message`,
327 /// then cherry-picks the descendants listed in `ctx`. Returns
328 /// `RebaseOutcome::Complete` or `Conflict` for a descendant conflict.
329 fn squash_finalize(
330 &self,
331 ctx: &SquashContext,
332 message: &str,
333 original_branch_oid: &str,
334 ) -> Result<RebaseOutcome>;
335
336 /// Stage a working-tree file, clearing any conflict entries for that path.
337 ///
338 /// Equivalent to `git add <path>`. Reads the file from the working directory,
339 /// adds it to the index at stage 0 (which removes stages 1/2/3), and writes
340 /// the updated index to disk. Must be called after a merge tool resolves a
341 /// conflict so that subsequent `index.has_conflicts()` checks return false.
342 fn stage_file(&self, path: &str) -> Result<()>;
343
344 /// Auto-stage conflicting files whose working-tree content no longer
345 /// contains conflict markers.
346 ///
347 /// When a user resolves conflicts in an external editor (instead of the
348 /// built-in mergetool), the index still carries stage 1/2/3 entries.
349 /// This method reads each file from disk and stages it if the standard
350 /// `<<<<<<<` marker is absent, so that `index.has_conflicts()` reflects
351 /// the actual resolution state.
352 fn auto_stage_resolved_conflicts(&self, files: &[String]) -> Result<()>;
353
354 /// Return the name of the repository's default upstream branch.
355 ///
356 /// Looks up the symbolic target of `refs/remotes/origin/HEAD` (the pointer
357 /// that `git remote set-head origin --auto` sets) and strips the
358 /// `refs/remotes/` prefix so the returned value can be passed directly to
359 /// `find_reference_point`. For example when `origin/HEAD` points to
360 /// `refs/remotes/origin/main` this returns `Some("origin/main")`.
361 ///
362 /// Returns `None` when the remote tracking ref is absent or has no symbolic
363 /// target (e.g. the repo has no remote configured, or `origin/HEAD` was
364 /// never set).
365 fn default_branch(&self) -> Option<String>;
366}
367
368impl ConflictState {
369 /// Whether this conflict arose from a squash-time tree conflict
370 /// (as opposed to a descendant rebase conflict).
371 pub fn is_squash_tree_conflict(&self) -> bool {
372 self.squash_context.is_some()
373 }
374}