308 lines · 11.7 KB · Rust
RawDownload
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<gix::command::Prepare>,    pub document: Vec<u8>,    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<Prepared> {    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<ObjectId> {    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<rebase::Outcome> {    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<gix::Repository> {        Ok(crate::test_repository::open_with(            path,            ["user.name=editor", "user.email=editor@example.com"],        )?)    }     fn git(path: &Path, args: &[&str]) -> gix_testtools::Result<Vec<u8>> {        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(())    }}