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 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}