Skip to main content

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}