use anyhow::{Result, bail};
use git2::{Oid, Repository};
use crate::core::repo::{self, Target, TargetKind};
use crate::core::weave;
use crate::core::{diff, graph, msg, staging};
use crate::git;
use crate::tui::hunk_selector::FileEntry;
const COMMAND: &str = "split";
fn commit_or_editor(workdir: &std::path::Path, message: Option<&str>) -> Result<()> {
match message {
Some(m) => git::commit(workdir, m),
None => git::commit_with_editor(workdir),
}
}
pub fn run(
target: String,
message: Option<String>,
patch: bool,
files: Vec<String>,
theme: &graph::Theme,
) -> Result<()> {
let repo = repo::open_repo()?;
let resolved = repo::resolve_arg(&repo, &target, &[TargetKind::Commit])?;
match resolved {
Target::Commit(hash) => split_commit(&repo, &hash, message, patch, files, theme),
_ => unreachable!(),
}
}
fn split_commit(
repo: &Repository,
commit_hash: &str,
message: Option<String>,
patch: bool,
files: Vec<String>,
theme: &graph::Theme,
) -> Result<()> {
let workdir = repo::require_workdir(repo, COMMAND)?;
let commit = repo.revparse_single(commit_hash)?.peel_to_commit()?;
let commit_oid = commit.id();
if commit.parent_count() > 1 {
bail!("Cannot split a merge commit");
}
let original_msg = commit.message().unwrap_or("").trim().to_string();
if patch {
let oid_str = commit_oid.to_string();
let selections = staging::run_commit_hunk_picker(workdir, &oid_str, &files, theme)?
.ok_or_else(|| anyhow::anyhow!("Cancelled"))?;
let has_selected = selections
.iter()
.any(|f| f.hunks.iter().any(|h| h.selected));
let has_unselected = selections
.iter()
.any(|f| f.hunks.iter().any(|h| !h.selected));
if !has_selected {
bail!("Must select at least one hunk for the first commit");
}
if !has_unselected {
bail!("Must leave at least one hunk for the second commit");
}
return perform_split_by_hunks(
repo,
workdir,
commit_oid,
&selections,
message.as_deref(),
&original_msg,
);
}
let all_files = repo::commit_file_paths(repo, commit_oid)?;
if all_files.len() < 2 {
bail!("Cannot split a commit with only one file");
}
let selected = if files.is_empty() {
pick_files(&all_files)?
} else {
let remaining_count = all_files.iter().filter(|f| !files.contains(f)).count();
if remaining_count == 0 {
bail!("Must leave at least one file for the second commit");
}
files
};
let remaining: Vec<String> = all_files
.into_iter()
.filter(|f| !selected.contains(f))
.collect();
perform_split(
repo,
workdir,
commit_oid,
&selected,
&remaining,
message.as_deref(),
&original_msg,
)
}
#[cfg(test)]
pub fn split_commit_with_selection(
repo: &Repository,
commit_hash: &str,
selected: Vec<String>,
message: String,
) -> Result<()> {
let theme = graph::Theme::dark();
split_commit(repo, commit_hash, Some(message), false, selected, &theme)
}
fn pick_files(files: &[String]) -> Result<Vec<String>> {
let selected = inquire::MultiSelect::new("Select files for the first commit:", files.to_vec())
.with_validator(|selection: &[inquire::list_option::ListOption<&String>]| {
if selection.is_empty() {
return Ok(inquire::validator::Validation::Invalid(
"Must select at least one file".into(),
));
}
Ok(inquire::validator::Validation::Valid)
})
.prompt()?;
if selected.len() == files.len() {
bail!("Must leave at least one file for the second commit");
}
Ok(selected)
}
fn run_split(
repo: &Repository,
workdir: &std::path::Path,
commit_oid: Oid,
do_split: impl FnOnce(bool) -> Result<(String, String)>,
) -> Result<()> {
let is_head = repo::head_oid(repo)? == commit_oid;
let oid_str = commit_oid.to_string();
let short_hash = git::short_hash(&oid_str);
let saved_staged = staging::save_and_unstage_staged(repo, workdir)?;
let split_result = do_split(is_head);
git::restore_staged_patch(workdir, &saved_staged)?;
let (h1, h2) = split_result?;
msg::success(&format!(
"Split `{}` into `{}` and `{}`",
short_hash,
git::short_hash(&h1),
git::short_hash(&h2)
));
Ok(())
}
fn perform_non_head_with(
repo: &Repository,
workdir: &std::path::Path,
commit_oid: Oid,
do_head_split: impl FnOnce() -> Result<(String, String)>,
) -> Result<(String, String)> {
weave::start_edit_rebase(repo, workdir, commit_oid)?;
let result = match do_head_split() {
Ok(hashes) => hashes,
Err(e) => {
let _ = git::rebase_abort(workdir);
return Err(e);
}
};
git::continue_rebase_or_abort(workdir)?;
Ok(result)
}
fn perform_split(
repo: &Repository,
workdir: &std::path::Path,
commit_oid: Oid,
selected: &[String],
remaining: &[String],
msg1: Option<&str>,
msg2: &str,
) -> Result<()> {
run_split(repo, workdir, commit_oid, |is_head| {
if is_head {
perform_head_split(workdir, selected, remaining, msg1, msg2)
} else {
perform_non_head_with(repo, workdir, commit_oid, || {
perform_head_split(workdir, selected, remaining, msg1, msg2)
})
}
})
}
fn perform_head_split(
workdir: &std::path::Path,
selected: &[String],
remaining: &[String],
msg1: Option<&str>,
msg2: &str,
) -> Result<(String, String)> {
git::reset_mixed(workdir, "HEAD~1")?;
let selected_refs: Vec<&str> = selected.iter().map(|s| s.as_str()).collect();
git::stage_files(workdir, &selected_refs)?;
commit_or_editor(workdir, msg1)?;
let remaining_refs: Vec<&str> = remaining.iter().map(|s| s.as_str()).collect();
git::stage_files(workdir, &remaining_refs)?;
git::commit(workdir, msg2)?;
let hash2 = git::rev_parse(workdir, "HEAD")?;
let hash1 = git::rev_parse(workdir, "HEAD~1")?;
Ok((hash1, hash2))
}
fn perform_split_by_hunks(
repo: &Repository,
workdir: &std::path::Path,
commit_oid: Oid,
selections: &[FileEntry],
msg1: Option<&str>,
msg2: &str,
) -> Result<()> {
run_split(repo, workdir, commit_oid, |is_head| {
if is_head {
perform_head_split_by_hunks(workdir, selections, msg1, msg2)
} else {
perform_non_head_with(repo, workdir, commit_oid, || {
perform_head_split_by_hunks(workdir, selections, msg1, msg2)
})
}
})
}
fn perform_head_split_by_hunks(
workdir: &std::path::Path,
selections: &[FileEntry],
msg1: Option<&str>,
msg2: &str,
) -> Result<(String, String)> {
git::reset_mixed(workdir, "HEAD~1")?;
let mut selected_patch = String::new();
for file in selections {
let selected: Vec<_> = file
.hunks
.iter()
.filter(|h| h.selected)
.map(|h| &h.hunk)
.collect();
if selected.is_empty() {
continue;
}
if file.binary || file.index_status == 'D' {
git::stage_path(workdir, &file.path)?;
} else {
selected_patch.push_str(&diff::build_hunk_patch(&file.path, &selected));
}
}
if !selected_patch.is_empty() {
git::apply_cached_patch(workdir, &selected_patch)?;
}
commit_or_editor(workdir, msg1)?;
for file in selections {
if file.hunks.iter().any(|h| !h.selected) {
git::stage_path(workdir, &file.path)?;
}
}
git::commit(workdir, msg2)?;
let hash2 = git::rev_parse(workdir, "HEAD")?;
let hash1 = git::rev_parse(workdir, "HEAD~1")?;
Ok((hash1, hash2))
}
#[cfg(test)]
#[path = "split_test.rs"]
mod tests;