1use anyhow::{Context, Result};
16use std::collections::{HashMap, HashSet};
17
18use crate::{CommitDiff, CommitInfo, DiffLine, DiffLineKind, FileDiff, Hunk, fragmap};
19
20use super::GitRepo;
21
22pub struct Git2Repo {
26 inner: git2::Repository,
27}
28
29impl Git2Repo {
30 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 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 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 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 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 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 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 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 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(¤t_tree),
366 Some(&commit_tree),
367 Some(&mut diff_opts),
368 )?;
369 apply_single_hunk_to_tree(repo, ¤t_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 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 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 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 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 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 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; 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 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 let descendants = self.collect_descendants(commit_git_oid, head_git_oid)?;
663
664 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 let mut index = repo.index()?;
713 index.read(true)?;
714 if index.has_conflicts() {
715 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 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 let base_oid = repo.merge_base(insert_after_git_oid, source_parent_oid)?;
848
849 let all_descendants = self.collect_descendants(base_oid, head_git_oid)?;
851
852 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 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 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 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 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
1173fn 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 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
1198fn 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 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
1278fn 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; 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 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
1326fn 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
1342fn apply_multiple_hunks_to_content(
1349 content: &[u8],
1350 patch: &mut git2::Patch,
1351 hunk_indices: &[usize],
1352) -> Result<Vec<u8>> {
1353 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; 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
1400fn 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 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 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 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 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 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 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 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 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 self.advance_branch_ref(onto_commit.id(), "git-tailor: drop commit (conflict)")?;
1678
1679 let mut repo_index = repo.index()?;
1682 for entry in cherry_index.iter() {
1683 repo_index.add(&entry)?;
1686 }
1687 repo_index.write()?;
1688
1689 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 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
1708enum CherryPickResult {
1710 Complete(git2::Oid),
1711 Conflict {
1712 tip: git2::Oid,
1713 conflicting_idx: usize,
1714 },
1715}
1716
1717pub(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 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 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}