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