Skip to main content

git_tailor/repo/
git2_impl.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
15use anyhow::{Context, Result};
16use std::collections::{HashMap, HashSet};
17
18use crate::{CommitDiff, CommitInfo, DiffLine, DiffLineKind, FileDiff, Hunk, fragmap};
19
20use super::GitRepo;
21
22/// Concrete git repository backed by `libgit2` via the `git2` crate.
23///
24/// Construct with [`Git2Repo::open`]; then use through the [`GitRepo`] trait.
25pub struct Git2Repo {
26    inner: git2::Repository,
27}
28
29impl Git2Repo {
30    /// Try to open a git repository by iteratively trying the given path and
31    /// its parents until a repository root is found.
32    pub fn open(mut path: std::path::PathBuf) -> Result<Self> {
33        loop {
34            let result = git2::Repository::open(&path);
35            if let Ok(repo) = result {
36                return Ok(Git2Repo { inner: repo });
37            }
38            if !path.pop() {
39                anyhow::bail!("Could not find git repository root");
40            }
41        }
42    }
43}
44
45impl GitRepo for Git2Repo {
46    fn head_oid(&self) -> Result<String> {
47        Ok(self
48            .inner
49            .head()
50            .context("Failed to get HEAD")?
51            .target()
52            .context("HEAD is not a direct reference")?
53            .to_string())
54    }
55
56    fn find_reference_point(&self, commit_ish: &str) -> Result<String> {
57        let target_object = self
58            .inner
59            .revparse_single(commit_ish)
60            .context(format!("Failed to resolve '{}'", commit_ish))?;
61        let target_oid = target_object.id();
62
63        let head = self.inner.head().context("Failed to get HEAD")?;
64        let head_oid = head.target().context("HEAD is not a direct reference")?;
65
66        let reference_oid = self
67            .inner
68            .merge_base(head_oid, target_oid)
69            .context("Failed to find merge base")?;
70
71        Ok(reference_oid.to_string())
72    }
73
74    fn list_commits(&self, from_oid: &str, to_oid: &str) -> Result<Vec<CommitInfo>> {
75        let from_object = self
76            .inner
77            .revparse_single(from_oid)
78            .context(format!("Failed to resolve '{}'", from_oid))?;
79        let from_commit_oid = from_object.id();
80
81        let to_object = self
82            .inner
83            .revparse_single(to_oid)
84            .context(format!("Failed to resolve '{}'", to_oid))?;
85        let to_commit_oid = to_object.id();
86
87        let mut revwalk = self.inner.revwalk()?;
88        revwalk.push(from_commit_oid)?;
89
90        let mut commits = Vec::new();
91
92        for oid_result in revwalk {
93            let oid = oid_result?;
94            let commit = self.inner.find_commit(oid)?;
95            commits.push(commit_info_from(&commit));
96
97            if oid == to_commit_oid {
98                break;
99            }
100        }
101
102        commits.reverse();
103        Ok(commits)
104    }
105
106    fn commit_diff(&self, oid: &str) -> Result<CommitDiff> {
107        let object = self
108            .inner
109            .revparse_single(oid)
110            .context(format!("Failed to resolve '{}'", oid))?;
111        let commit = object
112            .peel_to_commit()
113            .context("Resolved object is not a commit")?;
114
115        let new_tree = commit.tree().context("Failed to get commit tree")?;
116
117        let parent_tree = if commit.parent_count() > 0 {
118            Some(commit.parent(0)?.tree()?)
119        } else {
120            None
121        };
122
123        let diff = self
124            .inner
125            .diff_tree_to_tree(parent_tree.as_ref(), Some(&new_tree), None)?;
126
127        extract_commit_diff(&diff, &commit)
128    }
129
130    fn commit_diff_for_fragmap(&self, oid: &str) -> Result<CommitDiff> {
131        let object = self
132            .inner
133            .revparse_single(oid)
134            .context(format!("Failed to resolve '{}'", oid))?;
135        let commit = object
136            .peel_to_commit()
137            .context("Resolved object is not a commit")?;
138
139        let new_tree = commit.tree().context("Failed to get commit tree")?;
140
141        let parent_tree = if commit.parent_count() > 0 {
142            Some(commit.parent(0)?.tree()?)
143        } else {
144            None
145        };
146
147        let mut opts = git2::DiffOptions::new();
148        opts.context_lines(0);
149        opts.interhunk_lines(0);
150
151        let mut diff =
152            self.inner
153                .diff_tree_to_tree(parent_tree.as_ref(), Some(&new_tree), Some(&mut opts))?;
154
155        diff.find_similar(None)?;
156
157        extract_commit_diff(&diff, &commit)
158    }
159
160    fn staged_diff(&self) -> Option<CommitDiff> {
161        let head = self.inner.head().ok()?.peel_to_tree().ok();
162
163        let mut opts = git2::DiffOptions::new();
164        opts.context_lines(0);
165        opts.interhunk_lines(0);
166
167        let diff = self
168            .inner
169            .diff_tree_to_index(head.as_ref(), None, Some(&mut opts))
170            .ok()?;
171
172        let files = extract_files_from_diff(&diff).ok()?;
173        if files.iter().all(|f| f.hunks.is_empty()) {
174            return None;
175        }
176
177        Some(CommitDiff {
178            commit: synthetic_commit_info("staged", "(staged changes)"),
179            files,
180        })
181    }
182
183    fn unstaged_diff(&self) -> Option<CommitDiff> {
184        let mut opts = git2::DiffOptions::new();
185        opts.context_lines(0);
186        opts.interhunk_lines(0);
187
188        let diff = self
189            .inner
190            .diff_index_to_workdir(None, Some(&mut opts))
191            .ok()?;
192
193        let files = extract_files_from_diff(&diff).ok()?;
194        if files.iter().all(|f| f.hunks.is_empty()) {
195            return None;
196        }
197
198        Some(CommitDiff {
199            commit: synthetic_commit_info("unstaged", "(unstaged changes)"),
200            files,
201        })
202    }
203
204    fn split_commit_per_file(&self, commit_oid: &str, head_oid: &str) -> Result<()> {
205        let repo = &self.inner;
206
207        let commit_git_oid =
208            git2::Oid::from_str(commit_oid).context("Invalid commit OID for split")?;
209        let commit = repo.find_commit(commit_git_oid)?;
210
211        if commit.parent_count() != 1 {
212            anyhow::bail!(
213                "Can only split a commit with exactly one parent (merge commits and root commits are not supported)"
214            );
215        }
216        let parent_commit = commit.parent(0)?;
217        let parent_tree = parent_commit.tree()?;
218        let commit_tree = commit.tree()?;
219
220        // Compute full diff to enumerate files and for the overlap check
221        let full_diff = repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)?;
222        let file_count = full_diff.deltas().len();
223
224        if file_count < 2 {
225            anyhow::bail!("Commit touches fewer than 2 files — nothing to split");
226        }
227
228        // Collect the file paths touched by this commit
229        let commit_paths: HashSet<String> = full_diff
230            .deltas()
231            .filter_map(|d| {
232                d.new_file()
233                    .path()
234                    .or_else(|| d.old_file().path())
235                    .map(|p| p.to_string_lossy().into_owned())
236            })
237            .collect();
238
239        self.check_dirty_overlap(&commit_paths)?;
240
241        // Create one commit per file, each building on the previous
242        let mut current_base_oid = parent_commit.id();
243        for delta_idx in 0..file_count {
244            let delta = full_diff.get_delta(delta_idx).expect("delta index valid");
245            let path = delta
246                .new_file()
247                .path()
248                .or_else(|| delta.old_file().path())
249                .expect("delta has a path")
250                .to_string_lossy()
251                .into_owned();
252
253            // Diff from parent→commit scoped to this specific file
254            let mut opts = git2::DiffOptions::new();
255            opts.pathspec(&path);
256            let file_diff =
257                repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), Some(&mut opts))?;
258
259            let base_commit = repo.find_commit(current_base_oid)?;
260            let base_tree = base_commit.tree()?;
261
262            let mut new_index = repo.apply_to_tree(&base_tree, &file_diff, None)?;
263            if new_index.has_conflicts() {
264                anyhow::bail!("Conflict applying changes for file: {}", path);
265            }
266            let new_tree_oid = new_index.write_tree_to(repo)?;
267            let new_tree = repo.find_tree(new_tree_oid)?;
268
269            let author = commit.author();
270            let committer = commit.committer();
271            let message = format!(
272                "{} ({}/{})",
273                commit.summary().unwrap_or("split"),
274                delta_idx + 1,
275                file_count
276            );
277
278            let new_oid = repo.commit(
279                None,
280                &author,
281                &committer,
282                &message,
283                &new_tree,
284                &[&base_commit],
285            )?;
286            current_base_oid = new_oid;
287        }
288
289        let head_git_oid = git2::Oid::from_str(head_oid).context("Invalid head OID")?;
290        current_base_oid =
291            self.rebase_descendants(commit_git_oid, head_git_oid, current_base_oid)?;
292        self.advance_branch_ref(current_base_oid, "git-tailor: split per-file")?;
293
294        Ok(())
295    }
296
297    fn split_commit_per_hunk(&self, commit_oid: &str, head_oid: &str) -> Result<()> {
298        let repo = &self.inner;
299
300        let commit_git_oid =
301            git2::Oid::from_str(commit_oid).context("Invalid commit OID for split")?;
302        let commit = repo.find_commit(commit_git_oid)?;
303
304        if commit.parent_count() != 1 {
305            anyhow::bail!(
306                "Can only split a commit with exactly one parent (merge commits and root commits are not supported)"
307            );
308        }
309        let parent_commit = commit.parent(0)?;
310        let parent_tree = parent_commit.tree()?;
311        let commit_tree = commit.tree()?;
312
313        // Compute diff with 0 context lines so adjacent hunks stay separate
314        let mut diff_opts = git2::DiffOptions::new();
315        diff_opts.context_lines(0);
316        diff_opts.interhunk_lines(0);
317        let full_diff =
318            repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), Some(&mut diff_opts))?;
319
320        // Count total hunks across all files
321        let mut hunk_count = 0usize;
322        full_diff.foreach(
323            &mut |_, _| true,
324            None,
325            Some(&mut |_, _| {
326                hunk_count += 1;
327                true
328            }),
329            None,
330        )?;
331
332        if hunk_count < 2 {
333            anyhow::bail!("Commit has fewer than 2 hunks — nothing to split per hunk");
334        }
335
336        // Collect the file paths touched by this commit for the dirty overlap check
337        let commit_paths: HashSet<String> = full_diff
338            .deltas()
339            .filter_map(|d| {
340                d.new_file()
341                    .path()
342                    .or_else(|| d.old_file().path())
343                    .map(|p| p.to_string_lossy().into_owned())
344            })
345            .collect();
346
347        self.check_dirty_overlap(&commit_paths)?;
348
349        // Build one commit per hunk using incremental blob manipulation.
350        //
351        // At each step, recompute diff(current_tree → commit_tree) with 0 context
352        // and apply exactly its first hunk directly to the blob — bypassing
353        // apply_to_tree entirely to avoid libgit2 validating rejected hunks against
354        // the modified output buffer (which shifts line positions and causes
355        // "hunk did not apply").
356        let mut current_base_oid = parent_commit.id();
357        let mut current_tree_oid = parent_tree.id();
358        for target_k in 0..hunk_count {
359            let next_tree_oid = if target_k == hunk_count - 1 {
360                commit_tree.id()
361            } else {
362                let current_tree = repo.find_tree(current_tree_oid)?;
363                let mut diff_opts = git2::DiffOptions::new();
364                diff_opts.context_lines(0);
365                diff_opts.interhunk_lines(0);
366                let incremental_diff = repo.diff_tree_to_tree(
367                    Some(&current_tree),
368                    Some(&commit_tree),
369                    Some(&mut diff_opts),
370                )?;
371                apply_single_hunk_to_tree(repo, &current_tree, &incremental_diff)
372                    .with_context(|| format!("applying hunk {}", target_k + 1))?
373            };
374
375            let next_tree = repo.find_tree(next_tree_oid)?;
376            let base_commit = repo.find_commit(current_base_oid)?;
377
378            let author = commit.author();
379            let committer = commit.committer();
380            let message = format!(
381                "{} ({}/{})",
382                commit.summary().unwrap_or("split"),
383                target_k + 1,
384                hunk_count
385            );
386
387            let new_oid = repo.commit(
388                None,
389                &author,
390                &committer,
391                &message,
392                &next_tree,
393                &[&base_commit],
394            )?;
395            current_base_oid = new_oid;
396            current_tree_oid = next_tree_oid;
397        }
398
399        let head_git_oid = git2::Oid::from_str(head_oid).context("Invalid head OID")?;
400        current_base_oid =
401            self.rebase_descendants(commit_git_oid, head_git_oid, current_base_oid)?;
402        self.advance_branch_ref(current_base_oid, "git-tailor: split per-hunk")?;
403
404        Ok(())
405    }
406
407    fn split_commit_per_hunk_group(
408        &self,
409        commit_oid: &str,
410        head_oid: &str,
411        reference_oid: &str,
412    ) -> Result<()> {
413        let repo = &self.inner;
414
415        let commit_git_oid =
416            git2::Oid::from_str(commit_oid).context("Invalid commit OID for split")?;
417        let commit = repo.find_commit(commit_git_oid)?;
418
419        if commit.parent_count() != 1 {
420            anyhow::bail!(
421                "Can only split a commit with exactly one parent (merge commits and root commits are not supported)"
422            );
423        }
424        let parent_commit = commit.parent(0)?;
425        let parent_tree = parent_commit.tree()?;
426        let commit_tree = commit.tree()?;
427
428        // Build the fragmap over all branch commits so hunk grouping reflects
429        // how this commit interacts with its neighbours in the branch.
430        let branch_commits = self.list_commits(head_oid, reference_oid)?;
431        let branch_diffs: Vec<crate::CommitDiff> = branch_commits
432            .iter()
433            .filter(|c| c.oid != reference_oid && !c.oid.starts_with("synthetic:"))
434            .filter_map(|c| self.commit_diff_for_fragmap(&c.oid).ok())
435            .collect();
436
437        let (_, assignment) =
438            fragmap::assign_hunk_groups(&branch_diffs, commit_oid).ok_or_else(|| {
439                anyhow::anyhow!("Commit {} not found in branch diff list", commit_oid)
440            })?;
441
442        // Build a 0-context full diff (parent_tree → commit_tree) for tree
443        // manipulation; hunk indices here correspond to those in `assignment`.
444        let mut diff_opts = git2::DiffOptions::new();
445        diff_opts.context_lines(0);
446        diff_opts.interhunk_lines(0);
447        let full_diff =
448            repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), Some(&mut diff_opts))?;
449
450        let commit_paths: HashSet<String> = full_diff
451            .deltas()
452            .filter_map(|d| {
453                d.new_file()
454                    .path()
455                    .or_else(|| d.old_file().path())
456                    .map(|p| p.to_string_lossy().into_owned())
457            })
458            .collect();
459        self.check_dirty_overlap(&commit_paths)?;
460
461        // Build delta_hunk_groups: for each (delta_idx, hunk_idx), its group index.
462        let mut delta_hunk_groups: Vec<Vec<usize>> = Vec::new();
463        let num_deltas = full_diff.deltas().len();
464        for delta_idx in 0..num_deltas {
465            let delta = full_diff.get_delta(delta_idx).context("delta index")?;
466            let path = delta
467                .new_file()
468                .path()
469                .or_else(|| delta.old_file().path())
470                .map(|p| p.to_string_lossy().into_owned())
471                .unwrap_or_default();
472            let patch = git2::Patch::from_diff(&full_diff, delta_idx)?;
473            let num_hunks = patch.as_ref().map(|p| p.num_hunks()).unwrap_or(0);
474            let file_groups = assignment.get(&path);
475            let groups_for_delta: Vec<usize> = (0..num_hunks)
476                .map(|h| file_groups.and_then(|fg| fg.get(h).copied()).unwrap_or(0))
477                .collect();
478            delta_hunk_groups.push(groups_for_delta);
479        }
480
481        // Determine which group indices K's hunks actually touch (sorted).
482        // Only these produce output commits — not every column the full fragmap has.
483        let mut touched: std::collections::BTreeSet<usize> = std::collections::BTreeSet::new();
484        for groups in &delta_hunk_groups {
485            touched.extend(groups);
486        }
487        let k_groups: Vec<usize> = touched.into_iter().collect();
488        let split_count = k_groups.len();
489
490        if split_count < 2 {
491            anyhow::bail!("Commit has fewer than 2 hunk groups — nothing to split per hunk group");
492        }
493
494        // For each touched group gk (in order), build the intermediate tree by
495        // applying all of K's hunks whose group index ≤ gk to parent_tree in one
496        // sweep (positions relative to the original, no cumulative offset issues).
497        let mut current_base_oid = parent_commit.id();
498        let mut current_tree_oid = parent_tree.id();
499        for (out_pos, &gk) in k_groups.iter().enumerate() {
500            let next_tree_oid = if out_pos == split_count - 1 {
501                commit_tree.id()
502            } else {
503                // Collect (delta_idx → hunk_indices) for hunks in groups 0..=gk.
504                let mut selected: HashMap<usize, Vec<usize>> = HashMap::new();
505                for (delta_idx, hunk_groups) in delta_hunk_groups.iter().enumerate() {
506                    let chosen: Vec<usize> = hunk_groups
507                        .iter()
508                        .enumerate()
509                        .filter_map(|(h, &g)| if g <= gk { Some(h) } else { None })
510                        .collect();
511                    if !chosen.is_empty() {
512                        selected.insert(delta_idx, chosen);
513                    }
514                }
515                apply_selected_hunks_to_tree(repo, &parent_tree, &full_diff, &selected)
516                    .with_context(|| format!("building tree for hunk group {}", out_pos + 1))?
517            };
518
519            let next_tree = repo.find_tree(next_tree_oid)?;
520            let base_commit = repo.find_commit(current_base_oid)?;
521
522            let author = commit.author();
523            let committer = commit.committer();
524            let message = format!(
525                "{} ({}/{})",
526                commit.summary().unwrap_or("split"),
527                out_pos + 1,
528                split_count
529            );
530            let new_oid = repo.commit(
531                None,
532                &author,
533                &committer,
534                &message,
535                &next_tree,
536                &[&base_commit],
537            )?;
538            current_base_oid = new_oid;
539            current_tree_oid = next_tree_oid;
540        }
541
542        let head_git_oid = git2::Oid::from_str(head_oid).context("Invalid head OID")?;
543        current_base_oid =
544            self.rebase_descendants(commit_git_oid, head_git_oid, current_base_oid)?;
545        self.advance_branch_ref(current_base_oid, "git-tailor: split per-hunk-group")?;
546
547        let _ = current_tree_oid; // suppress unused warning
548        Ok(())
549    }
550
551    fn count_split_per_file(&self, commit_oid: &str) -> Result<usize> {
552        let repo = &self.inner;
553        let oid = git2::Oid::from_str(commit_oid).context("Invalid commit OID")?;
554        let commit = repo.find_commit(oid)?;
555        if commit.parent_count() != 1 {
556            anyhow::bail!("Can only split a commit with exactly one parent");
557        }
558        let parent_tree = commit.parent(0)?.tree()?;
559        let commit_tree = commit.tree()?;
560        let diff = repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)?;
561        Ok(diff.deltas().len())
562    }
563
564    fn count_split_per_hunk(&self, commit_oid: &str) -> Result<usize> {
565        let repo = &self.inner;
566        let oid = git2::Oid::from_str(commit_oid).context("Invalid commit OID")?;
567        let commit = repo.find_commit(oid)?;
568        if commit.parent_count() != 1 {
569            anyhow::bail!("Can only split a commit with exactly one parent");
570        }
571        let parent_tree = commit.parent(0)?.tree()?;
572        let commit_tree = commit.tree()?;
573        let mut diff_opts = git2::DiffOptions::new();
574        diff_opts.context_lines(0);
575        diff_opts.interhunk_lines(0);
576        let diff =
577            repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), Some(&mut diff_opts))?;
578        let mut count = 0usize;
579        diff.foreach(
580            &mut |_, _| true,
581            None,
582            Some(&mut |_, _| {
583                count += 1;
584                true
585            }),
586            None,
587        )?;
588        Ok(count)
589    }
590
591    fn count_split_per_hunk_group(
592        &self,
593        commit_oid: &str,
594        head_oid: &str,
595        reference_oid: &str,
596    ) -> Result<usize> {
597        let branch_commits = self.list_commits(head_oid, reference_oid)?;
598        let branch_diffs: Vec<crate::CommitDiff> = branch_commits
599            .iter()
600            .filter(|c| c.oid != reference_oid && !c.oid.starts_with("synthetic:"))
601            .filter_map(|c| self.commit_diff_for_fragmap(&c.oid).ok())
602            .collect();
603
604        let (_, assignment) =
605            fragmap::assign_hunk_groups(&branch_diffs, commit_oid).ok_or_else(|| {
606                anyhow::anyhow!("Commit {} not found in branch diff list", commit_oid)
607            })?;
608
609        // Count only the group indices that K's own hunks touch.
610        let touched: std::collections::HashSet<usize> =
611            assignment.values().flatten().copied().collect();
612        Ok(touched.len())
613    }
614
615    fn reword_commit(&self, commit_oid: &str, new_message: &str, head_oid: &str) -> Result<()> {
616        let repo = &self.inner;
617
618        let commit_git_oid =
619            git2::Oid::from_str(commit_oid).context("Invalid commit OID for reword")?;
620        let head_git_oid = git2::Oid::from_str(head_oid).context("Invalid HEAD OID for reword")?;
621        let commit = repo.find_commit(commit_git_oid)?;
622
623        let parents: Vec<git2::Commit> = (0..commit.parent_count())
624            .map(|i| commit.parent(i))
625            .collect::<std::result::Result<_, _>>()?;
626        let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
627
628        let new_oid = repo.commit(
629            None,
630            &commit.author(),
631            &commit.committer(),
632            new_message,
633            &commit.tree()?,
634            &parent_refs,
635        )?;
636
637        let tip = self.rebase_descendants(commit_git_oid, head_git_oid, new_oid)?;
638        self.advance_branch_ref(tip, "reword: update branch ref")?;
639        Ok(())
640    }
641
642    fn get_config_string(&self, key: &str) -> Option<String> {
643        self.inner.config().ok()?.get_string(key).ok()
644    }
645
646    fn drop_commit(&self, commit_oid: &str, head_oid: &str) -> Result<super::RebaseOutcome> {
647        self.check_no_dirty_state()?;
648
649        let repo = &self.inner;
650
651        let commit_git_oid =
652            git2::Oid::from_str(commit_oid).context("Invalid commit OID for drop")?;
653        let head_git_oid = git2::Oid::from_str(head_oid).context("Invalid HEAD OID for drop")?;
654        let commit = repo.find_commit(commit_git_oid)?;
655
656        if commit.parent_count() != 1 {
657            anyhow::bail!("Cannot drop a merge or root commit");
658        }
659        let parent_oid = commit.parent_id(0)?;
660
661        let original_branch_oid = head_oid.to_string();
662
663        // Collect descendants: commits strictly between commit_oid and head_oid.
664        let descendants = self.collect_descendants(commit_git_oid, head_git_oid)?;
665
666        // Cherry-pick each descendant onto the new chain, starting from the
667        // dropped commit's parent.
668        let result = self.cherry_pick_chain(parent_oid, &descendants)?;
669        match result {
670            CherryPickResult::Complete(tip) => {
671                self.advance_branch_ref(tip, "git-tailor: drop commit")?;
672                self.checkout_head()?;
673                Ok(super::RebaseOutcome::Complete)
674            }
675            CherryPickResult::Conflict {
676                tip,
677                conflicting_idx,
678            } => {
679                let conflicting_oid = descendants[conflicting_idx];
680                let remaining: Vec<String> = descendants[conflicting_idx + 1..]
681                    .iter()
682                    .map(|oid| oid.to_string())
683                    .collect();
684
685                Ok(super::RebaseOutcome::Conflict(Box::new(
686                    super::ConflictState {
687                        operation_label: "Drop".to_string(),
688                        original_branch_oid,
689                        new_tip_oid: tip.to_string(),
690                        conflicting_commit_oid: conflicting_oid.to_string(),
691                        remaining_oids: remaining,
692                        conflicting_files: collect_conflict_files(repo),
693                        still_unresolved: false,
694                        moved_commit_oid: None,
695                        squash_context: None,
696                    },
697                )))
698            }
699        }
700    }
701
702    fn rebase_continue(&self, state: &super::ConflictState) -> Result<super::RebaseOutcome> {
703        let repo = &self.inner;
704
705        let tip_oid =
706            git2::Oid::from_str(&state.new_tip_oid).context("Invalid tip OID in conflict state")?;
707        let conflicting_oid = git2::Oid::from_str(&state.conflicting_commit_oid)
708            .context("Invalid conflicting OID in conflict state")?;
709        let conflicting_commit = repo.find_commit(conflicting_oid)?;
710        let onto_commit = repo.find_commit(tip_oid)?;
711
712        // Re-read index from disk — the user (or another process) resolved
713        // conflicts by editing the on-disk index.
714        let mut index = repo.index()?;
715        index.read(true)?;
716        if index.has_conflicts() {
717            // The user pressed Enter but some files still have conflict
718            // markers. Stay in RebaseConflict mode with a refreshed file
719            // list so the dialog keeps the user informed rather than bailing
720            // out and leaving the repo in a broken state.
721            return Ok(super::RebaseOutcome::Conflict(Box::new(
722                super::ConflictState {
723                    operation_label: state.operation_label.clone(),
724                    original_branch_oid: state.original_branch_oid.clone(),
725                    new_tip_oid: state.new_tip_oid.clone(),
726                    conflicting_commit_oid: state.conflicting_commit_oid.clone(),
727                    remaining_oids: state.remaining_oids.clone(),
728                    conflicting_files: collect_conflict_files(repo),
729                    still_unresolved: true,
730                    moved_commit_oid: state.moved_commit_oid.clone(),
731                    squash_context: state.squash_context.clone(),
732                },
733            )));
734        }
735
736        let new_tree_oid = index.write_tree()?;
737        let new_tree = repo.find_tree(new_tree_oid)?;
738
739        let new_tip = repo.commit(
740            None,
741            &conflicting_commit.author(),
742            &conflicting_commit.committer(),
743            conflicting_commit.message().unwrap_or(""),
744            &new_tree,
745            &[&onto_commit],
746        )?;
747
748        // Continue cherry-picking remaining descendants.
749        let remaining: Vec<git2::Oid> = state
750            .remaining_oids
751            .iter()
752            .map(|s| git2::Oid::from_str(s))
753            .collect::<std::result::Result<_, _>>()
754            .context("Invalid OID in remaining list")?;
755
756        let result = self.cherry_pick_chain(new_tip, &remaining)?;
757        match result {
758            CherryPickResult::Complete(final_tip) => {
759                let label = state.operation_label.to_lowercase();
760                self.advance_branch_ref(final_tip, &format!("git-tailor: {label} (continue)"))?;
761                self.checkout_head()?;
762                Ok(super::RebaseOutcome::Complete)
763            }
764            CherryPickResult::Conflict {
765                tip,
766                conflicting_idx,
767            } => {
768                let conflicting_oid = remaining[conflicting_idx];
769                let new_remaining: Vec<String> = remaining[conflicting_idx + 1..]
770                    .iter()
771                    .map(|oid| oid.to_string())
772                    .collect();
773
774                Ok(super::RebaseOutcome::Conflict(Box::new(
775                    super::ConflictState {
776                        operation_label: state.operation_label.clone(),
777                        original_branch_oid: state.original_branch_oid.clone(),
778                        new_tip_oid: tip.to_string(),
779                        conflicting_commit_oid: conflicting_oid.to_string(),
780                        remaining_oids: new_remaining,
781                        conflicting_files: collect_conflict_files(repo),
782                        still_unresolved: false,
783                        moved_commit_oid: state.moved_commit_oid.clone(),
784                        squash_context: None,
785                    },
786                )))
787            }
788        }
789    }
790
791    fn rebase_abort(&self, state: &super::ConflictState) -> Result<()> {
792        let original_oid = git2::Oid::from_str(&state.original_branch_oid)
793            .context("Invalid original branch OID in conflict state")?;
794        let label = state.operation_label.to_lowercase();
795        self.advance_branch_ref(original_oid, &format!("git-tailor: {label} (abort)"))?;
796        self.checkout_head()?;
797        Ok(())
798    }
799
800    fn workdir(&self) -> Option<std::path::PathBuf> {
801        self.inner.workdir().map(|p| p.to_path_buf())
802    }
803
804    fn read_index_stage(&self, path: &str, stage: i32) -> Result<Option<Vec<u8>>> {
805        let repo = &self.inner;
806        let mut index = repo.index().context("failed to read index")?;
807        index
808            .read(true)
809            .context("failed to refresh index from disk")?;
810        let Some(entry) = index.get_path(std::path::Path::new(path), stage) else {
811            return Ok(None);
812        };
813        let blob = repo
814            .find_blob(entry.id)
815            .context("failed to find blob for index stage")?;
816        Ok(Some(blob.content().to_vec()))
817    }
818
819    fn read_conflicting_files(&self) -> Vec<String> {
820        collect_conflict_files(&self.inner)
821    }
822
823    fn move_commit(
824        &self,
825        commit_oid: &str,
826        insert_after_oid: &str,
827        head_oid: &str,
828    ) -> Result<super::RebaseOutcome> {
829        self.check_no_dirty_state()?;
830
831        let repo = &self.inner;
832
833        let commit_git_oid =
834            git2::Oid::from_str(commit_oid).context("Invalid commit OID for move")?;
835        let insert_after_git_oid =
836            git2::Oid::from_str(insert_after_oid).context("Invalid insert-after OID for move")?;
837        let head_git_oid = git2::Oid::from_str(head_oid).context("Invalid HEAD OID for move")?;
838
839        let commit = repo.find_commit(commit_git_oid)?;
840        if commit.parent_count() != 1 {
841            anyhow::bail!("Cannot move a merge or root commit");
842        }
843        let source_parent_oid = commit.parent_id(0)?;
844
845        let original_branch_oid = head_oid.to_string();
846
847        // The rebase base is the earlier of insert_after and source's parent.
848        // On a linear branch merge_base returns the older commit.
849        let base_oid = repo.merge_base(insert_after_git_oid, source_parent_oid)?;
850
851        // Collect all commits between base (exclusive) and HEAD (inclusive).
852        let all_descendants = self.collect_descendants(base_oid, head_git_oid)?;
853
854        // Build reordered list: remove source, insert after the target.
855        let mut reordered: Vec<git2::Oid> = all_descendants
856            .iter()
857            .filter(|&&oid| oid != commit_git_oid)
858            .copied()
859            .collect();
860
861        let insert_pos = if insert_after_git_oid == base_oid {
862            0
863        } else {
864            reordered
865                .iter()
866                .position(|&oid| oid == insert_after_git_oid)
867                .context("insert_after_oid not found among branch commits")?
868                + 1
869        };
870        reordered.insert(insert_pos, commit_git_oid);
871
872        let result = self.cherry_pick_chain(base_oid, &reordered)?;
873        match result {
874            CherryPickResult::Complete(tip) => {
875                self.advance_branch_ref(tip, "git-tailor: move commit")?;
876                self.checkout_head()?;
877                Ok(super::RebaseOutcome::Complete)
878            }
879            CherryPickResult::Conflict {
880                tip,
881                conflicting_idx,
882            } => {
883                let conflicting_oid = reordered[conflicting_idx];
884                let remaining: Vec<String> = reordered[conflicting_idx + 1..]
885                    .iter()
886                    .map(|oid| oid.to_string())
887                    .collect();
888
889                Ok(super::RebaseOutcome::Conflict(Box::new(
890                    super::ConflictState {
891                        operation_label: "Move".to_string(),
892                        original_branch_oid,
893                        new_tip_oid: tip.to_string(),
894                        conflicting_commit_oid: conflicting_oid.to_string(),
895                        remaining_oids: remaining,
896                        conflicting_files: collect_conflict_files(repo),
897                        still_unresolved: false,
898                        moved_commit_oid: Some(commit_oid.to_string()),
899                        squash_context: None,
900                    },
901                )))
902            }
903        }
904    }
905
906    fn squash_commits(
907        &self,
908        source_oid: &str,
909        target_oid: &str,
910        message: &str,
911        head_oid: &str,
912    ) -> Result<super::RebaseOutcome> {
913        self.check_no_dirty_state()?;
914
915        let repo = &self.inner;
916
917        let source_git_oid =
918            git2::Oid::from_str(source_oid).context("Invalid source OID for squash")?;
919        let target_git_oid =
920            git2::Oid::from_str(target_oid).context("Invalid target OID for squash")?;
921        let head_git_oid = git2::Oid::from_str(head_oid).context("Invalid HEAD OID for squash")?;
922
923        let source_commit = repo.find_commit(source_git_oid)?;
924        let target_commit = repo.find_commit(target_git_oid)?;
925
926        if target_commit.parent_count() != 1 {
927            anyhow::bail!("Cannot squash into a merge or root commit");
928        }
929        let base_oid = target_commit.parent_id(0)?;
930        let base_commit = repo.find_commit(base_oid)?;
931
932        // Create the combined tree by applying source's diff onto target's tree.
933        let mut cherry_index = repo.cherrypick_commit(&source_commit, &target_commit, 0, None)?;
934        if cherry_index.has_conflicts() {
935            let original_branch_oid = head_oid.to_string();
936
937            // Collect descendants now so they're available after resolution.
938            let all_descendants = self.collect_descendants(target_git_oid, head_git_oid)?;
939            let descendants: Vec<String> = all_descendants
940                .into_iter()
941                .filter(|&oid| oid != source_git_oid)
942                .map(|oid| oid.to_string())
943                .collect();
944
945            // Write the conflicted index to the working tree so the user can
946            // resolve it with their editor or merge tool.
947            self.write_conflicts_to_workdir(&cherry_index, &target_commit)?;
948
949            return Ok(super::RebaseOutcome::Conflict(Box::new(
950                super::ConflictState {
951                    operation_label: "Squash".to_string(),
952                    original_branch_oid,
953                    new_tip_oid: target_git_oid.to_string(),
954                    conflicting_commit_oid: source_git_oid.to_string(),
955                    remaining_oids: vec![],
956                    conflicting_files: collect_conflict_files(repo),
957                    still_unresolved: false,
958                    moved_commit_oid: None,
959                    squash_context: Some(super::SquashContext {
960                        base_oid: base_oid.to_string(),
961                        source_oid: source_oid.to_string(),
962                        target_oid: target_oid.to_string(),
963                        combined_message: message.to_string(),
964                        descendant_oids: descendants,
965                        is_fixup: false,
966                    }),
967                },
968            )));
969        }
970
971        let combined_tree_oid = cherry_index.write_tree_to(repo)?;
972        let combined_tree = repo.find_tree(combined_tree_oid)?;
973
974        let squash_oid = repo.commit(
975            None,
976            &target_commit.author(),
977            &target_commit.committer(),
978            message,
979            &combined_tree,
980            &[&base_commit],
981        )?;
982
983        let original_branch_oid = head_oid.to_string();
984
985        // Collect descendants: everything between target and HEAD, minus source.
986        let all_descendants = self.collect_descendants(target_git_oid, head_git_oid)?;
987        let descendants: Vec<git2::Oid> = all_descendants
988            .into_iter()
989            .filter(|&oid| oid != source_git_oid)
990            .collect();
991
992        let result = self.cherry_pick_chain(squash_oid, &descendants)?;
993        match result {
994            CherryPickResult::Complete(tip) => {
995                self.advance_branch_ref(tip, "git-tailor: squash commits")?;
996                self.checkout_head()?;
997                Ok(super::RebaseOutcome::Complete)
998            }
999            CherryPickResult::Conflict {
1000                tip,
1001                conflicting_idx,
1002            } => {
1003                let conflicting_oid = descendants[conflicting_idx];
1004                let remaining: Vec<String> = descendants[conflicting_idx + 1..]
1005                    .iter()
1006                    .map(|oid| oid.to_string())
1007                    .collect();
1008
1009                Ok(super::RebaseOutcome::Conflict(Box::new(
1010                    super::ConflictState {
1011                        operation_label: "Squash".to_string(),
1012                        original_branch_oid,
1013                        new_tip_oid: tip.to_string(),
1014                        conflicting_commit_oid: conflicting_oid.to_string(),
1015                        remaining_oids: remaining,
1016                        conflicting_files: collect_conflict_files(repo),
1017                        still_unresolved: false,
1018                        moved_commit_oid: None,
1019                        squash_context: None,
1020                    },
1021                )))
1022            }
1023        }
1024    }
1025
1026    fn stage_file(&self, path: &str) -> Result<()> {
1027        let repo = &self.inner;
1028        let mut index = repo.index().context("failed to read index")?;
1029        index
1030            .read(true)
1031            .context("failed to refresh index from disk")?;
1032
1033        let workdir = repo
1034            .workdir()
1035            .ok_or_else(|| anyhow::anyhow!("repository has no working directory"))?;
1036
1037        if workdir.join(path).exists() {
1038            // File is present — add it to clear conflict stages and create a
1039            // normal stage-0 entry.
1040            index
1041                .add_path(std::path::Path::new(path))
1042                .with_context(|| format!("failed to stage '{path}'"))?;
1043        } else {
1044            // File was deleted — remove all index entries for this path
1045            // (stages 0, 1, 2, 3) so the deletion is staged and no phantom
1046            // conflict entries remain.
1047            index
1048                .remove_path(std::path::Path::new(path))
1049                .with_context(|| format!("failed to remove '{path}' from index"))?;
1050        }
1051
1052        index
1053            .write()
1054            .context("failed to write index after staging")?;
1055        Ok(())
1056    }
1057
1058    fn auto_stage_resolved_conflicts(&self, files: &[String]) -> Result<()> {
1059        let workdir = self
1060            .inner
1061            .workdir()
1062            .ok_or_else(|| anyhow::anyhow!("repository has no working directory"))?;
1063
1064        for path in files {
1065            let full_path = workdir.join(path);
1066            if !full_path.exists() {
1067                // File was deleted — stage the deletion to clear conflict entries.
1068                self.stage_file(path)?;
1069                continue;
1070            }
1071            let content = std::fs::read(&full_path)
1072                .with_context(|| format!("failed to read '{path}' from working tree"))?;
1073            if !content.windows(b"<<<<<<<".len()).any(|w| w == b"<<<<<<<") {
1074                self.stage_file(path)?;
1075            }
1076        }
1077        Ok(())
1078    }
1079
1080    fn default_branch(&self) -> Option<String> {
1081        let reference = self.inner.find_reference("refs/remotes/origin/HEAD").ok()?;
1082        let target = reference.symbolic_target()?;
1083        // Strip the "refs/remotes/" prefix so the caller can pass the result
1084        // directly to find_reference_point (e.g. "origin/main").
1085        target.strip_prefix("refs/remotes/").map(str::to_string)
1086    }
1087
1088    fn squash_try_combine(
1089        &self,
1090        source_oid: &str,
1091        target_oid: &str,
1092        combined_message: &str,
1093        is_fixup: bool,
1094        head_oid: &str,
1095    ) -> Result<Option<super::ConflictState>> {
1096        self.check_no_dirty_state()?;
1097
1098        let repo = &self.inner;
1099
1100        let source_git_oid =
1101            git2::Oid::from_str(source_oid).context("Invalid source OID for squash")?;
1102        let target_git_oid =
1103            git2::Oid::from_str(target_oid).context("Invalid target OID for squash")?;
1104        let head_git_oid = git2::Oid::from_str(head_oid).context("Invalid HEAD OID for squash")?;
1105
1106        let source_commit = repo.find_commit(source_git_oid)?;
1107        let target_commit = repo.find_commit(target_git_oid)?;
1108
1109        if target_commit.parent_count() != 1 {
1110            anyhow::bail!("Cannot squash into a merge or root commit");
1111        }
1112        let base_oid = target_commit.parent_id(0)?;
1113
1114        let cherry_index = repo.cherrypick_commit(&source_commit, &target_commit, 0, None)?;
1115        if !cherry_index.has_conflicts() {
1116            return Ok(None);
1117        }
1118
1119        let original_branch_oid = head_oid.to_string();
1120
1121        let all_descendants = self.collect_descendants(target_git_oid, head_git_oid)?;
1122        let descendants: Vec<String> = all_descendants
1123            .into_iter()
1124            .filter(|&oid| oid != source_git_oid)
1125            .map(|oid| oid.to_string())
1126            .collect();
1127
1128        self.write_conflicts_to_workdir(&cherry_index, &target_commit)?;
1129
1130        let operation_label = if is_fixup { "Fixup" } else { "Squash" }.to_string();
1131
1132        Ok(Some(super::ConflictState {
1133            operation_label,
1134            original_branch_oid,
1135            new_tip_oid: target_git_oid.to_string(),
1136            conflicting_commit_oid: source_git_oid.to_string(),
1137            remaining_oids: vec![],
1138            conflicting_files: collect_conflict_files(repo),
1139            still_unresolved: false,
1140            moved_commit_oid: None,
1141            squash_context: Some(super::SquashContext {
1142                base_oid: base_oid.to_string(),
1143                source_oid: source_oid.to_string(),
1144                target_oid: target_oid.to_string(),
1145                combined_message: combined_message.to_string(),
1146                descendant_oids: descendants,
1147                is_fixup,
1148            }),
1149        }))
1150    }
1151
1152    fn squash_finalize(
1153        &self,
1154        ctx: &super::SquashContext,
1155        message: &str,
1156        original_branch_oid: &str,
1157    ) -> Result<super::RebaseOutcome> {
1158        let repo = &self.inner;
1159
1160        let mut index = repo.index()?;
1161        index.read(true)?;
1162        if index.has_conflicts() {
1163            anyhow::bail!("Cannot finalize squash: index still has unresolved conflicts");
1164        }
1165
1166        let base_git_oid =
1167            git2::Oid::from_str(&ctx.base_oid).context("Invalid base OID in squash context")?;
1168        let target_git_oid =
1169            git2::Oid::from_str(&ctx.target_oid).context("Invalid target OID in squash context")?;
1170        let base_commit = repo.find_commit(base_git_oid)?;
1171        let target_commit = repo.find_commit(target_git_oid)?;
1172
1173        let combined_tree_oid = index.write_tree()?;
1174        let combined_tree = repo.find_tree(combined_tree_oid)?;
1175
1176        let squash_oid = repo.commit(
1177            None,
1178            &target_commit.author(),
1179            &target_commit.committer(),
1180            message,
1181            &combined_tree,
1182            &[&base_commit],
1183        )?;
1184
1185        let descendants: Vec<git2::Oid> = ctx
1186            .descendant_oids
1187            .iter()
1188            .map(|s| git2::Oid::from_str(s))
1189            .collect::<std::result::Result<_, _>>()
1190            .context("Invalid OID in descendant list")?;
1191
1192        let result = self.cherry_pick_chain(squash_oid, &descendants)?;
1193        match result {
1194            CherryPickResult::Complete(tip) => {
1195                self.advance_branch_ref(tip, "git-tailor: squash commits (finalize)")?;
1196                self.checkout_head()?;
1197                Ok(super::RebaseOutcome::Complete)
1198            }
1199            CherryPickResult::Conflict {
1200                tip,
1201                conflicting_idx,
1202            } => {
1203                let conflicting_oid = descendants[conflicting_idx];
1204                let remaining: Vec<String> = descendants[conflicting_idx + 1..]
1205                    .iter()
1206                    .map(|oid| oid.to_string())
1207                    .collect();
1208
1209                Ok(super::RebaseOutcome::Conflict(Box::new(
1210                    super::ConflictState {
1211                        operation_label: "Squash".to_string(),
1212                        original_branch_oid: original_branch_oid.to_string(),
1213                        new_tip_oid: tip.to_string(),
1214                        conflicting_commit_oid: conflicting_oid.to_string(),
1215                        remaining_oids: remaining,
1216                        conflicting_files: collect_conflict_files(repo),
1217                        still_unresolved: false,
1218                        moved_commit_oid: None,
1219                        squash_context: None,
1220                    },
1221                )))
1222            }
1223        }
1224    }
1225}
1226
1227// ---------------------------------------------------------------------------
1228// Private helpers for drop/conflict operations
1229// ---------------------------------------------------------------------------
1230
1231/// Collect paths of all files that have conflict entries (stage > 0) in the
1232/// repository's current index.  Returns them sorted for a stable display order.
1233fn collect_conflict_files(repo: &git2::Repository) -> Vec<String> {
1234    let mut index = match repo.index() {
1235        Ok(i) => i,
1236        Err(_) => return Vec::new(),
1237    };
1238    let _ = index.read(true);
1239    let mut paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
1240    for entry in index.iter() {
1241        // stage is encoded in the high bits of flags
1242        let stage = (entry.flags >> 12) & 0x3;
1243        if stage > 0
1244            && let Ok(p) = std::str::from_utf8(&entry.path)
1245        {
1246            paths.insert(p.to_string());
1247        }
1248    }
1249    paths.into_iter().collect()
1250}
1251
1252// ---------------------------------------------------------------------------
1253// Private helpers for split operations (not part of the GitRepo trait)
1254// ---------------------------------------------------------------------------
1255
1256/// Apply the first hunk of the first non-empty delta in `diff` to `base_tree`
1257/// and return the resulting tree OID.
1258///
1259/// The diff must have been computed from `base_tree`, so the hunk's old-side
1260/// content matches exactly.  This avoids `apply_to_tree` with `hunk_callback`
1261/// filtering, which fails because libgit2 validates rejected hunks against the
1262/// already-modified output buffer (whose line positions have shifted).
1263fn apply_single_hunk_to_tree(
1264    repo: &git2::Repository,
1265    base_tree: &git2::Tree,
1266    diff: &git2::Diff,
1267) -> Result<git2::Oid> {
1268    for delta_idx in 0..diff.deltas().len() {
1269        let mut patch = match git2::Patch::from_diff(diff, delta_idx)? {
1270            Some(p) => p,
1271            None => continue,
1272        };
1273        if patch.num_hunks() == 0 {
1274            continue;
1275        }
1276        let delta = diff.get_delta(delta_idx).context("delta index in range")?;
1277        let file_path = delta
1278            .new_file()
1279            .path()
1280            .or_else(|| delta.old_file().path())
1281            .context("delta has no file path")?
1282            .to_owned();
1283
1284        let (old_content, mode) = match delta.status() {
1285            git2::Delta::Added => {
1286                let m: u32 = delta.new_file().mode().into();
1287                (Vec::new(), m)
1288            }
1289            _ => {
1290                let entry = base_tree
1291                    .get_path(&file_path)
1292                    .with_context(|| format!("'{}' not in base tree", file_path.display()))?;
1293                let blob = repo.find_blob(entry.id())?;
1294                (blob.content().to_owned(), entry.filemode() as u32)
1295            }
1296        };
1297
1298        let new_content = apply_hunk_to_content(&old_content, &mut patch, 0)
1299            .with_context(|| format!("applying hunk to '{}'", file_path.display()))?;
1300
1301        let new_blob_oid = repo.blob(&new_content)?;
1302
1303        // Load base_tree into an in-memory index, update the one file, write tree.
1304        let mut idx = git2::Index::new()?;
1305        idx.read_tree(base_tree)?;
1306
1307        let path_bytes = file_path
1308            .to_str()
1309            .context("file path is not valid UTF-8")?
1310            .as_bytes()
1311            .to_vec();
1312        idx.add(&git2::IndexEntry {
1313            ctime: git2::IndexTime::new(0, 0),
1314            mtime: git2::IndexTime::new(0, 0),
1315            dev: 0,
1316            ino: 0,
1317            mode,
1318            uid: 0,
1319            gid: 0,
1320            file_size: new_content.len() as u32,
1321            id: new_blob_oid,
1322            flags: 0,
1323            flags_extended: 0,
1324            path: path_bytes,
1325        })?;
1326
1327        return idx.write_tree_to(repo).map_err(Into::into);
1328    }
1329    Ok(base_tree.id())
1330}
1331
1332/// Apply hunk `hunk_idx` from `patch` to `content`, returning the new bytes.
1333///
1334/// Replacement splices in context + added lines, dropping deleted lines.
1335fn apply_hunk_to_content(
1336    content: &[u8],
1337    patch: &mut git2::Patch,
1338    hunk_idx: usize,
1339) -> Result<Vec<u8>> {
1340    let (hunk_header, _) = patch.hunk(hunk_idx)?;
1341    let old_start = hunk_header.old_start() as usize; // 1-based
1342    let old_count = hunk_header.old_lines() as usize;
1343
1344    let lines = split_lines_keep_eol(content);
1345
1346    let num_lines = patch.num_lines_in_hunk(hunk_idx)?;
1347    let mut replacement: Vec<Vec<u8>> = Vec::new();
1348    for line_idx in 0..num_lines {
1349        let line = patch.line_in_hunk(hunk_idx, line_idx)?;
1350        match line.origin() {
1351            ' ' | '+' => replacement.push(line.content().to_owned()),
1352            _ => {}
1353        }
1354    }
1355
1356    // old_start is 1-based.  For a substitution or deletion (old_count > 0) it
1357    // is the first line to remove, so the 0-based index is old_start-1.
1358    // For a pure insertion (old_count == 0) git convention says "insert after
1359    // line old_start", so the splice point is old_start (0-based).
1360    let start = if old_count == 0 {
1361        old_start.min(lines.len())
1362    } else {
1363        old_start.saturating_sub(1).min(lines.len())
1364    };
1365    let end = (start + old_count).min(lines.len());
1366
1367    let mut result: Vec<u8> = Vec::new();
1368    for l in &lines[..start] {
1369        result.extend_from_slice(l);
1370    }
1371    for r in &replacement {
1372        result.extend_from_slice(r);
1373    }
1374    for l in &lines[end..] {
1375        result.extend_from_slice(l);
1376    }
1377    Ok(result)
1378}
1379
1380/// Split raw bytes into lines keeping each `\n` terminator attached.
1381fn split_lines_keep_eol(data: &[u8]) -> Vec<&[u8]> {
1382    let mut lines: Vec<&[u8]> = Vec::new();
1383    let mut start = 0;
1384    for (i, &b) in data.iter().enumerate() {
1385        if b == b'\n' {
1386            lines.push(&data[start..=i]);
1387            start = i + 1;
1388        }
1389    }
1390    if start < data.len() {
1391        lines.push(&data[start..]);
1392    }
1393    lines
1394}
1395
1396/// Apply the hunks at `hunk_indices` from `patch` to `content` in a single
1397/// sweep, using each hunk's original `old_start` position.  Hunks NOT listed
1398/// in `hunk_indices` are preserved unchanged.
1399///
1400/// `hunk_indices` need not be sorted on entry; the function sorts them by
1401/// `old_start` before processing.
1402fn apply_multiple_hunks_to_content(
1403    content: &[u8],
1404    patch: &mut git2::Patch,
1405    hunk_indices: &[usize],
1406) -> Result<Vec<u8>> {
1407    // Sort by old_start so we can sweep top-to-bottom through the original.
1408    let mut sorted: Vec<(usize, u32)> = hunk_indices
1409        .iter()
1410        .map(|&i| {
1411            patch
1412                .hunk(i)
1413                .map(|(h, _)| (i, h.old_start()))
1414                .map_err(anyhow::Error::from)
1415        })
1416        .collect::<Result<_>>()?;
1417    sorted.sort_by_key(|&(_, s)| s);
1418
1419    let lines = split_lines_keep_eol(content);
1420    let mut result: Vec<u8> = Vec::new();
1421    let mut src_pos: usize = 0;
1422
1423    for (hunk_idx, _) in &sorted {
1424        let (hunk_header, _) = patch.hunk(*hunk_idx)?;
1425        let old_start = hunk_header.old_start() as usize; // 1-based
1426        let old_count = hunk_header.old_lines() as usize;
1427
1428        let splice_start = if old_count == 0 {
1429            old_start.min(lines.len())
1430        } else {
1431            old_start.saturating_sub(1).min(lines.len())
1432        };
1433
1434        for line in &lines[src_pos..splice_start] {
1435            result.extend_from_slice(line);
1436        }
1437        src_pos = splice_start + old_count;
1438
1439        let num_lines = patch.num_lines_in_hunk(*hunk_idx)?;
1440        for line_idx in 0..num_lines {
1441            let line = patch.line_in_hunk(*hunk_idx, line_idx)?;
1442            if matches!(line.origin(), ' ' | '+') {
1443                result.extend_from_slice(line.content());
1444            }
1445        }
1446    }
1447
1448    for line in &lines[src_pos..] {
1449        result.extend_from_slice(line);
1450    }
1451    Ok(result)
1452}
1453
1454/// Apply a selected subset of hunks from `full_diff` to `parent_tree`,
1455/// returning the new tree OID.
1456///
1457/// `selected_hunks` maps each delta index to the list of hunk indices
1458/// (within that delta) to apply.  Files with no selected hunks keep their
1459/// original content from `parent_tree`.
1460fn apply_selected_hunks_to_tree(
1461    repo: &git2::Repository,
1462    parent_tree: &git2::Tree,
1463    full_diff: &git2::Diff,
1464    selected_hunks: &HashMap<usize, Vec<usize>>,
1465) -> Result<git2::Oid> {
1466    let mut idx = git2::Index::new()?;
1467    idx.read_tree(parent_tree)?;
1468
1469    for (&delta_idx, hunk_indices) in selected_hunks {
1470        if hunk_indices.is_empty() {
1471            continue;
1472        }
1473
1474        let mut patch = match git2::Patch::from_diff(full_diff, delta_idx)? {
1475            Some(p) => p,
1476            None => continue,
1477        };
1478
1479        let delta = full_diff
1480            .get_delta(delta_idx)
1481            .context("delta index in range")?;
1482        let file_path = delta
1483            .new_file()
1484            .path()
1485            .or_else(|| delta.old_file().path())
1486            .context("delta has no file path")?
1487            .to_owned();
1488
1489        let (old_content, mode) = match delta.status() {
1490            git2::Delta::Added => {
1491                let m: u32 = delta.new_file().mode().into();
1492                (Vec::new(), m)
1493            }
1494            _ => {
1495                let entry = parent_tree
1496                    .get_path(&file_path)
1497                    .with_context(|| format!("'{}' not in parent tree", file_path.display()))?;
1498                let blob = repo.find_blob(entry.id())?;
1499                (blob.content().to_owned(), entry.filemode() as u32)
1500            }
1501        };
1502
1503        let new_content =
1504            apply_multiple_hunks_to_content(&old_content, &mut patch, hunk_indices)
1505                .with_context(|| format!("applying selected hunks to '{}'", file_path.display()))?;
1506
1507        let path_bytes = file_path
1508            .to_str()
1509            .context("file path is not valid UTF-8")?
1510            .as_bytes()
1511            .to_vec();
1512
1513        if delta.status() == git2::Delta::Deleted && new_content.is_empty() {
1514            // All lines removed → delete the file from the intermediate tree.
1515            idx.remove(&file_path, 0)?;
1516        } else {
1517            let new_blob_oid = repo.blob(&new_content)?;
1518            idx.add(&git2::IndexEntry {
1519                ctime: git2::IndexTime::new(0, 0),
1520                mtime: git2::IndexTime::new(0, 0),
1521                dev: 0,
1522                ino: 0,
1523                mode,
1524                uid: 0,
1525                gid: 0,
1526                file_size: new_content.len() as u32,
1527                id: new_blob_oid,
1528                flags: 0,
1529                flags_extended: 0,
1530                path: path_bytes,
1531            })?;
1532        }
1533    }
1534
1535    idx.write_tree_to(repo).map_err(Into::into)
1536}
1537
1538impl Git2Repo {
1539    /// Refuse if the working tree or index has any staged or unstaged changes,
1540    /// ignoring submodule pointer updates (consistent with `git rebase`).
1541    ///
1542    /// Gitlink entries (mode `0o160000`) are skipped because libgit2's
1543    /// `checkout_head` does not recurse into submodule directories, so a dirty
1544    /// submodule reference cannot be silently discarded.
1545    ///
1546    /// Called before operations that end with `checkout_head(force)`, which
1547    /// would silently discard any dirty state.  The user should stash or
1548    /// commit their changes before running such operations.
1549    fn check_no_dirty_state(&self) -> Result<()> {
1550        let mut opts = git2::DiffOptions::new();
1551        opts.context_lines(0);
1552        opts.interhunk_lines(0);
1553
1554        let head_tree = self.inner.head().ok().and_then(|h| h.peel_to_tree().ok());
1555
1556        // Returns true only when the delta is a real file change, not a gitlink.
1557        let is_real = |delta: git2::DiffDelta| {
1558            delta.old_file().mode() != git2::FileMode::Commit
1559                && delta.new_file().mode() != git2::FileMode::Commit
1560        };
1561
1562        let has_staged = self
1563            .inner
1564            .diff_tree_to_index(head_tree.as_ref(), None, Some(&mut opts))
1565            .map(|d| d.deltas().any(is_real))
1566            .unwrap_or(false);
1567
1568        let has_unstaged = self
1569            .inner
1570            .diff_index_to_workdir(None, Some(&mut opts))
1571            .map(|d| d.deltas().any(is_real))
1572            .unwrap_or(false);
1573
1574        if has_staged || has_unstaged {
1575            anyhow::bail!(
1576                "You have staged or unstaged changes. \
1577                 Stash or commit them before running this operation."
1578            );
1579        }
1580        Ok(())
1581    }
1582
1583    /// Refuse if any staged or unstaged change touches a file in `commit_paths`.
1584    fn check_dirty_overlap(&self, commit_paths: &HashSet<String>) -> Result<()> {
1585        let mut overlapping: Vec<String> = Vec::new();
1586        for synthetic_diff in [self.staged_diff(), self.unstaged_diff()]
1587            .into_iter()
1588            .flatten()
1589        {
1590            for file in &synthetic_diff.files {
1591                let path = file
1592                    .new_path
1593                    .as_deref()
1594                    .or(file.old_path.as_deref())
1595                    .unwrap_or("");
1596                if commit_paths.contains(path) && !overlapping.contains(&path.to_string()) {
1597                    overlapping.push(path.to_string());
1598                }
1599            }
1600        }
1601        if !overlapping.is_empty() {
1602            overlapping.sort();
1603            anyhow::bail!(
1604                "Cannot split: staged/unstaged changes overlap with: {}",
1605                overlapping.join(", ")
1606            );
1607        }
1608        Ok(())
1609    }
1610
1611    /// Cherry-pick all commits strictly between `stop_oid` (exclusive) and
1612    /// `head_oid` (inclusive) onto `tip`, returning the new tip OID.
1613    fn rebase_descendants(
1614        &self,
1615        stop_oid: git2::Oid,
1616        head_oid: git2::Oid,
1617        mut tip: git2::Oid,
1618    ) -> Result<git2::Oid> {
1619        let repo = &self.inner;
1620        if head_oid == stop_oid {
1621            return Ok(tip);
1622        }
1623
1624        let mut revwalk = repo.revwalk()?;
1625        revwalk.push(head_oid)?;
1626
1627        let mut descendants: Vec<git2::Oid> = Vec::new();
1628        for oid_result in revwalk {
1629            let oid = oid_result?;
1630            if oid == stop_oid {
1631                break;
1632            }
1633            descendants.push(oid);
1634        }
1635        descendants.reverse();
1636
1637        for desc_oid in descendants {
1638            let desc_commit = repo.find_commit(desc_oid)?;
1639            let onto_commit = repo.find_commit(tip)?;
1640
1641            let mut cherry_index = repo.cherrypick_commit(&desc_commit, &onto_commit, 0, None)?;
1642            if cherry_index.has_conflicts() {
1643                anyhow::bail!(
1644                    "Conflict rebasing {} onto split result",
1645                    &desc_oid.to_string()[..10]
1646                );
1647            }
1648            let new_tree_oid = cherry_index.write_tree_to(repo)?;
1649            let new_tree = repo.find_tree(new_tree_oid)?;
1650
1651            let author = desc_commit.author();
1652            let committer = desc_commit.committer();
1653            tip = repo.commit(
1654                None,
1655                &author,
1656                &committer,
1657                desc_commit.message().unwrap_or(""),
1658                &new_tree,
1659                &[&onto_commit],
1660            )?;
1661        }
1662
1663        Ok(tip)
1664    }
1665
1666    /// Fast-forward the branch ref that HEAD currently points to.
1667    fn advance_branch_ref(&self, new_tip: git2::Oid, log_msg: &str) -> Result<()> {
1668        let repo = &self.inner;
1669        let head_ref = repo.head()?;
1670        let branch_refname = head_ref
1671            .resolve()
1672            .context("HEAD is not a symbolic ref")?
1673            .name()
1674            .context("Ref has no name")?
1675            .to_string();
1676        repo.reference(&branch_refname, new_tip, true, log_msg)?;
1677        Ok(())
1678    }
1679
1680    /// Collect OIDs strictly between `stop_oid` (exclusive) and `head_oid`
1681    /// (inclusive), returned oldest-first.
1682    fn collect_descendants(
1683        &self,
1684        stop_oid: git2::Oid,
1685        head_oid: git2::Oid,
1686    ) -> Result<Vec<git2::Oid>> {
1687        let repo = &self.inner;
1688        if head_oid == stop_oid {
1689            return Ok(Vec::new());
1690        }
1691
1692        let mut revwalk = repo.revwalk()?;
1693        revwalk.push(head_oid)?;
1694
1695        let mut descendants: Vec<git2::Oid> = Vec::new();
1696        for oid_result in revwalk {
1697            let oid = oid_result?;
1698            if oid == stop_oid {
1699                break;
1700            }
1701            descendants.push(oid);
1702        }
1703        descendants.reverse();
1704        Ok(descendants)
1705    }
1706
1707    /// Cherry-pick a sequence of commits onto `tip`, returning the final tip
1708    /// or the point at which a conflict was detected.
1709    ///
1710    /// On conflict the conflicted index is written to the working tree so the
1711    /// user can resolve it. The returned `conflicting_idx` identifies which
1712    /// element of `commits` conflicted.
1713    fn cherry_pick_chain(
1714        &self,
1715        mut tip: git2::Oid,
1716        commits: &[git2::Oid],
1717    ) -> Result<CherryPickResult> {
1718        let repo = &self.inner;
1719
1720        for (idx, &desc_oid) in commits.iter().enumerate() {
1721            let desc_commit = repo.find_commit(desc_oid)?;
1722            let onto_commit = repo.find_commit(tip)?;
1723
1724            let mut cherry_index = repo.cherrypick_commit(&desc_commit, &onto_commit, 0, None)?;
1725            if cherry_index.has_conflicts() {
1726                self.write_conflicts_to_workdir(&cherry_index, &onto_commit)?;
1727                return Ok(CherryPickResult::Conflict {
1728                    tip,
1729                    conflicting_idx: idx,
1730                });
1731            }
1732
1733            let new_tree_oid = cherry_index.write_tree_to(repo)?;
1734            let new_tree = repo.find_tree(new_tree_oid)?;
1735
1736            tip = repo.commit(
1737                None,
1738                &desc_commit.author(),
1739                &desc_commit.committer(),
1740                desc_commit.message().unwrap_or(""),
1741                &new_tree,
1742                &[&onto_commit],
1743            )?;
1744        }
1745
1746        Ok(CherryPickResult::Complete(tip))
1747    }
1748
1749    /// Write a conflicted merge index to the repo index and working tree so
1750    /// the user can resolve conflicts manually.
1751    fn write_conflicts_to_workdir(
1752        &self,
1753        cherry_index: &git2::Index,
1754        onto_commit: &git2::Commit,
1755    ) -> Result<()> {
1756        let repo = &self.inner;
1757
1758        // Point the branch at the onto commit so HEAD matches the partially
1759        // rebased chain.
1760        self.advance_branch_ref(onto_commit.id(), "git-tailor: drop commit (conflict)")?;
1761
1762        // Write the conflicted index entries (including conflict markers) into
1763        // the repo's index so `git status` and the user's editor see them.
1764        let mut repo_index = repo.index()?;
1765        for entry in cherry_index.iter() {
1766            // Stage 0 = normal, stages 1-3 = conflict (base/ours/theirs).
1767            // We need to preserve all stages so the user's tools can resolve.
1768            repo_index.add(&entry)?;
1769        }
1770        repo_index.write()?;
1771
1772        // Check out the index to the working tree. Force-checkout writes
1773        // conflict markers into the working-tree files.
1774        let mut checkout = git2::build::CheckoutBuilder::new();
1775        checkout.force();
1776        checkout.allow_conflicts(true);
1777        repo.checkout_index(Some(&mut repo_index), Some(&mut checkout))?;
1778
1779        Ok(())
1780    }
1781
1782    /// Reset the working tree and index to match HEAD.
1783    fn checkout_head(&self) -> Result<()> {
1784        let mut checkout = git2::build::CheckoutBuilder::new();
1785        checkout.force();
1786        self.inner.checkout_head(Some(&mut checkout))?;
1787        Ok(())
1788    }
1789}
1790
1791/// Internal result from `cherry_pick_chain`.
1792enum CherryPickResult {
1793    Complete(git2::Oid),
1794    Conflict {
1795        tip: git2::Oid,
1796        conflicting_idx: usize,
1797    },
1798}
1799
1800// ---------------------------------------------------------------------------
1801// Private helpers
1802// ---------------------------------------------------------------------------
1803
1804pub(crate) fn git_time_to_offset_datetime(git_time: git2::Time) -> time::OffsetDateTime {
1805    let offset_seconds = git_time.offset_minutes() * 60;
1806    let utc_offset =
1807        time::UtcOffset::from_whole_seconds(offset_seconds).unwrap_or(time::UtcOffset::UTC);
1808
1809    time::OffsetDateTime::from_unix_timestamp(git_time.seconds())
1810        .unwrap_or(time::OffsetDateTime::UNIX_EPOCH)
1811        .to_offset(utc_offset)
1812}
1813
1814fn commit_info_from(commit: &git2::Commit) -> CommitInfo {
1815    let author_time = commit.author().when();
1816    let commit_time = commit.time();
1817
1818    CommitInfo {
1819        oid: commit.id().to_string(),
1820        summary: commit.summary().unwrap_or("").to_string(),
1821        author: commit.author().name().map(|s| s.to_string()),
1822        date: Some(commit.time().seconds().to_string()),
1823        parent_oids: commit.parent_ids().map(|id| id.to_string()).collect(),
1824        message: commit.message().unwrap_or("").to_string(),
1825        author_email: commit.author().email().map(|s| s.to_string()),
1826        author_date: Some(git_time_to_offset_datetime(author_time)),
1827        committer: commit.committer().name().map(|s| s.to_string()),
1828        committer_email: commit.committer().email().map(|s| s.to_string()),
1829        commit_date: Some(git_time_to_offset_datetime(commit_time)),
1830    }
1831}
1832
1833fn extract_commit_diff(diff: &git2::Diff, commit: &git2::Commit) -> Result<CommitDiff> {
1834    Ok(CommitDiff {
1835        commit: commit_info_from(commit),
1836        files: extract_files_from_diff(diff)?,
1837    })
1838}
1839
1840fn extract_files_from_diff(diff: &git2::Diff) -> Result<Vec<FileDiff>> {
1841    let mut files: Vec<FileDiff> = Vec::new();
1842
1843    for delta_idx in 0..diff.deltas().len() {
1844        let delta = diff.get_delta(delta_idx).expect("delta index in range");
1845
1846        let old_path = delta
1847            .old_file()
1848            .path()
1849            .map(|p| p.to_string_lossy().into_owned());
1850        let new_path = delta
1851            .new_file()
1852            .path()
1853            .map(|p| p.to_string_lossy().into_owned());
1854
1855        let status = match delta.status() {
1856            git2::Delta::Unmodified => crate::DeltaStatus::Unmodified,
1857            git2::Delta::Added => crate::DeltaStatus::Added,
1858            git2::Delta::Deleted => crate::DeltaStatus::Deleted,
1859            git2::Delta::Modified => crate::DeltaStatus::Modified,
1860            git2::Delta::Renamed => crate::DeltaStatus::Renamed,
1861            git2::Delta::Copied => crate::DeltaStatus::Copied,
1862            git2::Delta::Ignored => crate::DeltaStatus::Ignored,
1863            git2::Delta::Untracked => crate::DeltaStatus::Untracked,
1864            git2::Delta::Typechange => crate::DeltaStatus::Typechange,
1865            git2::Delta::Unreadable => crate::DeltaStatus::Unreadable,
1866            git2::Delta::Conflicted => crate::DeltaStatus::Conflicted,
1867        };
1868
1869        let patch = git2::Patch::from_diff(diff, delta_idx)?
1870            .context("Failed to extract patch from diff")?;
1871
1872        let mut hunks = Vec::new();
1873        for hunk_idx in 0..patch.num_hunks() {
1874            let (hunk_header, _num_lines) = patch.hunk(hunk_idx)?;
1875
1876            let mut lines = Vec::new();
1877            for line_idx in 0..patch.num_lines_in_hunk(hunk_idx)? {
1878                let line = patch.line_in_hunk(hunk_idx, line_idx)?;
1879                let kind = match line.origin() {
1880                    '+' => DiffLineKind::Addition,
1881                    '-' => DiffLineKind::Deletion,
1882                    _ => DiffLineKind::Context,
1883                };
1884                let content = String::from_utf8_lossy(line.content()).to_string();
1885                lines.push(DiffLine { kind, content });
1886            }
1887
1888            hunks.push(Hunk {
1889                old_start: hunk_header.old_start(),
1890                old_lines: hunk_header.old_lines(),
1891                new_start: hunk_header.new_start(),
1892                new_lines: hunk_header.new_lines(),
1893                lines,
1894            });
1895        }
1896
1897        files.push(FileDiff {
1898            old_path,
1899            new_path,
1900            status,
1901            hunks,
1902        });
1903    }
1904
1905    Ok(files)
1906}
1907
1908fn synthetic_commit_info(oid: &str, summary: &str) -> CommitInfo {
1909    CommitInfo {
1910        oid: oid.to_string(),
1911        summary: summary.to_string(),
1912        author: None,
1913        date: None,
1914        parent_oids: vec![],
1915        message: summary.to_string(),
1916        author_email: None,
1917        author_date: None,
1918        committer: None,
1919        committer_email: None,
1920        commit_date: None,
1921    }
1922}
1923
1924#[cfg(test)]
1925mod tests {
1926    use super::git_time_to_offset_datetime;
1927
1928    #[test]
1929    fn utc_epoch_stays_at_zero() {
1930        let t = git2::Time::new(0, 0);
1931        let dt = git_time_to_offset_datetime(t);
1932        assert_eq!(dt.unix_timestamp(), 0);
1933        assert_eq!(dt.offset(), time::UtcOffset::UTC);
1934    }
1935
1936    #[test]
1937    fn positive_offset_applied_correctly() {
1938        // 60-minute (UTC+1) offset: same instant, but hour should read as 1.
1939        let t = git2::Time::new(0, 60);
1940        let dt = git_time_to_offset_datetime(t);
1941        assert_eq!(dt.unix_timestamp(), 0);
1942        let expected_offset = time::UtcOffset::from_whole_seconds(3600).unwrap();
1943        assert_eq!(dt.offset(), expected_offset);
1944        assert_eq!(dt.hour(), 1);
1945    }
1946
1947    #[test]
1948    fn negative_offset_applied_correctly() {
1949        // −300-minute (UTC−5) offset: same instant, hour reads as 19 on previous day.
1950        let t = git2::Time::new(0, -300);
1951        let dt = git_time_to_offset_datetime(t);
1952        assert_eq!(dt.unix_timestamp(), 0);
1953        let expected_offset = time::UtcOffset::from_whole_seconds(-18000).unwrap();
1954        assert_eq!(dt.offset(), expected_offset);
1955        assert_eq!(dt.hour(), 19);
1956    }
1957}