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