use anyhow::{Context, Result}; use gix::ObjectId; use super::{create, rebase}; use crate::{ChangeGroup, ChangeKind, load_worktree_changes_without_lines}; pub(crate) struct Prepared { pub editor: Option, pub document: Vec, create: create::Prepared, target: ObjectId, source: gix::objs::Commit, tree: ObjectId, } #[tracing::instrument(skip_all)] pub(crate) fn prepare(mut repo: gix::Repository, todo: bool) -> Result { let target = repo .head_id() .context("splitting requires an existing HEAD commit")? .detach(); let changes = load_worktree_changes_without_lines(&repo)?; if changes.paths.iter().any(|change| change.kind == ChangeKind::Unmerged) { anyhow::bail!("cannot split with unresolved conflicts"); } if !changes.paths.iter().any(|change| change.group == ChangeGroup::Staged) || !changes.paths.iter().any(|change| change.group == ChangeGroup::Unstaged) { anyhow::bail!("splitting requires both staged and worktree changes"); } let mut source = repo .find_commit(target) .context("could not find HEAD commit")? .decode() .context("could not decode HEAD commit")? .into_owned() .context("could not own HEAD commit")?; let mut create = create::prepare_from(repo.clone(), Some(target), create::Source::Default, None, todo)?; repo.objects.set_object_memory(std::mem::take(&mut create.objects)); let head_tree = source.tree; let index_tree = create.tree; let index = repo .find_tree(index_tree) .context("could not load the prepared index tree")?; let worktree_tree = create::worktree_tree_with_changes(&repo, &index, &changes)?; drop(index); let source_tree = rebase::cherry_pick_tree(&repo, index_tree, head_tree, worktree_tree) .context("worktree changes conflict with the source commit")?; let tree = rebase::cherry_pick_tree(&repo, head_tree, source_tree, index_tree) .context("staged changes conflict with the rewritten source commit")?; source.tree = source_tree; create.objects = repo .objects .take_object_memory() .context("candidate object memory was unavailable")?; Ok(Prepared { editor: create.editor.take(), document: create.document.clone(), create, target, source, tree, }) } #[cfg(test)] #[tracing::instrument(skip_all, fields(target = %prepared.target))] pub(crate) fn apply( repo: gix::Repository, graph: &crate::history::HistoryGraph, prepared: Prepared, edited: &[u8], ) -> Result { apply_reporting(repo, graph, prepared, edited, |_| {})? .selected .context("splitting HEAD did not produce a selection") } pub(crate) fn apply_reporting( mut repo: gix::Repository, graph: &crate::history::HistoryGraph, mut prepared: Prepared, edited: &[u8], report: impl FnMut(rebase::Progress), ) -> Result { let repository_path = repo.git_dir().to_owned(); let bare = repo.is_bare(); repo.objects .set_object_memory(std::mem::take(&mut prepared.create.objects)); let (mut upper, enrichment) = create::commit_from_edit(&prepared.create, edited)?; upper.tree = prepared.tree; let mut outcome = rebase::perform_with_progress( &repo, graph, rebase::Edit::Split { target: prepared.target, source: prepared.source, upper, }, rebase::Signature::InvalidateExisting, rebase::Tree::LeaveAsIsAndMark, report, )? .complete()?; let id = outcome.selected.context("splitting HEAD did not produce a selection")?; drop(repo); let repo = crate::open_repository(&repository_path, bare, false)?; let name: gix::refs::FullName = crate::enrich::REF_NAME.try_into().expect("valid enrich ref"); let before = super::undo::state(&repo, name.as_ref())?; crate::enrich::apply_headers(&repo, id, &enrichment) .context("the commit was split, but its enrichment could not be saved")?; let after = super::undo::state(&repo, name.as_ref())?; if before != after { outcome.ref_changes.push(super::undo::RefChange { name, before, after }); } Ok(outcome) } #[cfg(test)] mod tests { use std::{path::Path, process::Command}; use gix::bstr::ByteSlice; use super::*; fn open(path: &Path) -> gix_testtools::Result { Ok(crate::test_repository::open_with( path, ["user.name=editor", "user.email=editor@example.com"], )?) } fn git(path: &Path, args: &[&str]) -> gix_testtools::Result> { let output = Command::new("git").arg("-C").arg(path).args(args).output()?; if !output.status.success() { return Err(format!("git {} failed: {}", args.join(" "), output.stderr.to_str_lossy()).into()); } Ok(output.stdout) } #[test] fn splits_staged_and_worktree_changes_without_touching_files_during_preparation() -> gix_testtools::Result { let fixture = gix_testtools::scripted_fixture_writable("split_commit.sh")?; let repository = open(fixture.path())?; let old = repository.head_id()?.detach(); let old_parent = repository.find_commit(old)?.parent_ids().next().map(gix::Id::detach); for name in ["refs/patches/split", "refs/tags/keep", "refs/remotes/origin/keep"] { git(fixture.path(), &["update-ref", name, &old.to_string()])?; } let before = gix_testtools::repository::snapshot(fixture.path())?; let prepared = prepare(open(fixture.path())?, false)?; assert_eq!( prepared .document .split(|byte| *byte == b'\n') .filter(|line| line.strip_prefix(b";").unwrap_or(line).starts_with(b"Author: ")) .count(), 1, "split editors contain only the configured author" ); assert!( prepared .document .windows(b"staged".len()) .any(|window| window == b"staged"), "the upper commit editor describes staged paths" ); assert!( !prepared .document .windows(b"unstaged |".len()) .any(|window| window == b"unstaged |"), "the upper commit editor excludes worktree-only paths" ); assert_eq!( gix_testtools::repository::snapshot(fixture.path())?, before, "preparing both cherry-picks leaves the repository unchanged" ); let edited = prepared .document .replacen(b"what\n\nwhy", b"upper\n\nreason", 1) .replacen(b";Todo\n;Message:", b"Todo\nMessage: split enrichment", 1); let graph = super::super::loaded_graph(&open(fixture.path())?)?; let repository = open(fixture.path())?; let mut notes = repository.notes()?; let notes_ref = notes .default_ref() .expect("the test repository has a default notes ref") .to_owned(); notes.replace_at_ref(notes_ref.as_ref(), old, b"source Git note")?; let upper = apply(open(fixture.path())?, &graph, prepared, &edited)?; let repository = open(fixture.path())?; assert_eq!( crate::enrich::load( &mut crate::enrich::open(&repository)?, crate::change_id::for_commit(&repository, upper)? )?, crate::enrich::Enrichment { todo: true, note: Some("split enrichment".into()), } ); let source = repository .find_commit(upper)? .parent_ids() .next() .expect("the upper commit has the rewritten source") .detach(); assert_eq!( repository.find_commit(source)?.parent_ids().next().map(gix::Id::detach), old_parent, "the source commit retains its original ancestry" ); assert_eq!( git(fixture.path(), &["show", &format!("{source}:both")])?, b"one\nbase staged\nmiddle\nworktree\nend\n" ); assert_eq!( git(fixture.path(), &["show", &format!("{upper}:both")])?, b"one\nstaged\nmiddle\nworktree\nend\n" ); assert_eq!( git(fixture.path(), &["show", &format!("{source}:unstaged")])?, b"worktree\n" ); assert_eq!( git(fixture.path(), &["show", &format!("{source}:untracked")])?, b"untracked\n" ); assert_eq!(git(fixture.path(), &["show", &format!("{upper}:staged")])?, b"staged\n"); assert_eq!(repository.find_commit(source)?.message_raw()?, b"base\n".as_bstr()); assert_eq!( repository.find_commit(upper)?.message_raw()?, b"upper\n\nreason\n".as_bstr() ); let source_commit = repository.find_commit(source)?.decode()?.into_owned()?; assert_eq!( crate::change_id::effective(source, source_commit.extra_headers().find_all(crate::change_id::HEADER)), gix::hash::ChangeId::from(old), "the rewritten lower commit inherits the original identity" ); let mut notes = repository.notes()?.with_refs([notes_ref.as_bstr()])?; assert_eq!( notes.get(source)?.first().map(|note| note.blob.data.as_slice()), Some(b"source Git note".as_slice()), "the rewritten lower commit receives the source Git note" ); assert!( notes.get(upper)?.is_empty(), "the newly inserted upper commit receives no source Git note" ); assert_eq!( repository .find_commit(upper)? .decode()? .extra_headers() .find(crate::change_id::HEADER), None, "the newly created upper commit remains headerless" ); assert!( !super::super::rebase::is_pending(&source_commit), "the unsigned source already has its final tree and parent" ); assert!( !super::super::rebase::is_pending(&repository.find_commit(upper)?.decode()?.into_owned()?), "the new upper commit already has its final tree and parent" ); assert_eq!(git(fixture.path(), &["status", "--short"])?, b""); for name in ["refs/heads/main", "refs/patches/split"] { assert_eq!(repository.find_reference(name)?.id(), upper, "{name} follows the split"); } for name in ["refs/tags/keep", "refs/remotes/origin/keep"] { assert_eq!(repository.find_reference(name)?.id(), old, "{name} is not edited"); } Ok(()) } #[test] fn conflicting_staged_and_worktree_hunks_abort_before_the_editor() -> gix_testtools::Result { let fixture = gix_testtools::scripted_fixture_writable("split_commit.sh")?; std::fs::write( fixture.path().join("both"), b"one\nworktree conflict\nmiddle\nbase worktree\nend\n", )?; let before = gix_testtools::repository::snapshot(fixture.path())?; let err = match prepare(open(fixture.path())?, false) { Ok(_) => return Err("overlapping staged and worktree hunks should conflict".into()), Err(err) => err, }; assert!( format!("{err:#}").contains("worktree changes conflict with the source commit"), "the error identifies the conflicting half of the split: {err:#}" ); assert_eq!( gix_testtools::repository::snapshot(fixture.path())?, before, "a conflicting split writes no observable state" ); Ok(()) } }