use std::path::Path;
use anyhow::{Context, Result, bail};
use git2::Repository;
use serde::{Deserialize, Serialize};
use crate::core::graph;
use crate::core::msg;
use crate::core::repo;
use crate::core::staging;
use crate::core::transaction::{self, LoomState, Rollback};
use crate::core::weave::{self, RebaseOutcome, Weave};
use crate::git;
#[derive(Serialize, Deserialize)]
struct CommitContext {
branch_name: String,
}
pub fn run(
branch: Option<String>,
message: Option<String>,
patch: bool,
files: Vec<String>,
theme: &graph::Theme,
) -> Result<()> {
let repo = repo::open_repo()?;
let workdir = repo::require_workdir(&repo, "commit")?.to_path_buf();
let git_dir = repo.path().to_path_buf();
let info = repo::gather_repo_info(&repo, false, 1).context(
"Must be on an integration branch to use commit\n\
Use `git commit` directly on feature branches",
)?;
let saved_staged = if patch {
resolve_staging_patch(&repo, &workdir, &files, theme)?
} else {
resolve_staging(&repo, &workdir, &files)?
};
if let Err(e) = repo::verify_has_staged_changes(&repo) {
git::restore_staged_patch(&workdir, &saved_staged)?;
return Err(e);
}
let do_commit = || {
if let Some(msg) = &message {
git::commit(&workdir, msg)
} else {
git::commit_with_editor(&workdir)
}
};
if branch.is_none() && info.branch_name == repo::upstream_local_branch(&info.upstream.label) {
let result = do_commit();
git::restore_staged_patch(&workdir, &saved_staged)?;
result?;
let new_head = repo::head_oid(&repo)?;
msg::success(&format!(
"Created commit `{}`",
git::short_hash(&new_head.to_string())
));
return Ok(());
}
let saved_head = repo::head_oid(&repo)?.to_string();
let (branch_name, branch_is_new) =
resolve_branch_target(&repo, &info, &workdir, branch.as_deref())?;
let branch_is_empty =
is_branch_at_merge_base(&repo, &branch_name, info.upstream.merge_base_oid)?;
if let Err(e) = do_commit() {
git::restore_staged_patch(&workdir, &saved_staged)?;
return Err(e);
}
let head_oid = repo::head_oid(&repo)?;
let mut graph = Weave::from_repo_with_info(&repo, &info)?;
if branch_is_empty {
graph.add_branch_section(
branch_name.clone(),
vec![branch_name.clone()],
vec![],
"onto".to_string(),
);
graph.add_merge(branch_name.clone(), None, None);
}
graph.move_commit(head_oid, &branch_name)?;
let todo = graph.to_todo();
let mut delete_branches = vec![];
if branch_is_new {
delete_branches.push(branch_name.clone());
}
let ctx = CommitContext {
branch_name: branch_name.clone(),
};
let state = LoomState {
command: "commit".to_string(),
rollback: Rollback {
reset_mixed_to: saved_head.clone(),
delete_branches,
saved_staged_patch: saved_staged.clone(),
..Default::default()
},
context: serde_json::to_value(&ctx)?,
};
transaction::save(&git_dir, &state)?;
match weave::run_rebase(&workdir, Some(&graph.base_oid.to_string()), &todo)? {
RebaseOutcome::Completed => {
transaction::delete(&git_dir)?;
post_commit(&workdir, &branch_name, &saved_staged)?;
}
RebaseOutcome::Conflicted => {
transaction::warn_conflict_paused("commit");
}
}
Ok(())
}
pub fn after_continue(
workdir: &Path,
rollback: &crate::core::transaction::Rollback,
context: &serde_json::Value,
) -> Result<()> {
let ctx: CommitContext =
serde_json::from_value(context.clone()).context("Failed to parse commit resume context")?;
post_commit(workdir, &ctx.branch_name, &rollback.saved_staged_patch)
}
fn post_commit(workdir: &Path, branch_name: &str, saved_staged: &str) -> Result<()> {
git::restore_staged_patch(workdir, saved_staged)?;
let new_hash = git::rev_parse(workdir, branch_name)?;
msg::success(&format!(
"Created commit `{}` on branch `{}`",
git::short_hash(&new_hash),
branch_name
));
Ok(())
}
fn resolve_staging_patch(
repo: &Repository,
workdir: &std::path::Path,
files: &[String],
theme: &graph::Theme,
) -> Result<String> {
let saved_staged = if !files.is_empty() && !files.iter().any(|f| f == "zz") {
let resolved_paths = resolve_file_args(repo, files)?;
let path_refs: Vec<&str> = resolved_paths.iter().map(|s| s.as_str()).collect();
staging::save_and_unstage_other_staged(repo, workdir, &path_refs)?
} else {
String::new()
};
let confirmed = staging::run_hunk_picker(repo, workdir, files, theme)?;
if !confirmed {
git::restore_staged_patch(workdir, &saved_staged)?;
anyhow::bail!("Cancelled");
}
Ok(saved_staged)
}
fn resolve_staging(
repo: &Repository,
workdir: &std::path::Path,
files: &[String],
) -> Result<String> {
if files.is_empty() {
return Ok(String::new());
}
if files.iter().any(|f| f == "zz") {
git::stage_all(workdir)?;
return Ok(String::new());
}
let resolved_paths = resolve_file_args(repo, files)?;
let path_refs: Vec<&str> = resolved_paths.iter().map(|s| s.as_str()).collect();
let saved_staged = staging::save_and_unstage_other_staged(repo, workdir, &path_refs)?;
git::stage_files(workdir, &path_refs)?;
Ok(saved_staged)
}
fn resolve_file_args(repo: &Repository, files: &[String]) -> Result<Vec<String>> {
files
.iter()
.map(|arg| repo::resolve_file_arg(repo, arg))
.collect()
}
fn resolve_branch_target(
repo: &Repository,
info: &repo::RepoInfo,
workdir: &std::path::Path,
branch: Option<&str>,
) -> Result<(String, bool)> {
match branch {
Some(b) => resolve_explicit_branch(repo, info, workdir, b),
None => pick_branch(repo, info, workdir),
}
}
fn resolve_explicit_branch(
repo: &Repository,
info: &repo::RepoInfo,
workdir: &std::path::Path,
branch: &str,
) -> Result<(String, bool)> {
match repo::resolve_arg(repo, branch, &[repo::TargetKind::Branch]) {
Ok(target) => {
let name = target.expect_branch()?;
if info.branches.iter().any(|b| b.name == name) {
Ok((name, false))
} else {
bail!("Branch '{}' is not woven into the integration branch", name)
}
}
Err(_) => {
let name = branch.trim().to_string();
if name.is_empty() {
bail!("Branch name cannot be empty");
}
git::branch_validate_name(&name)?;
if repo.find_branch(&name, git2::BranchType::Local).is_ok() {
bail!(
"Branch '{}' exists but is not woven into the integration branch",
name
);
}
create_branch_at_merge_base(workdir, &name, info.upstream.merge_base_oid)?;
Ok((name, true))
}
}
}
fn pick_branch(
repo: &Repository,
info: &repo::RepoInfo,
workdir: &std::path::Path,
) -> Result<(String, bool)> {
let branch_names: Vec<String> = info.branches.iter().map(|b| b.name.clone()).collect();
let not_empty = |s: &str| {
if s.trim().is_empty() {
Err("Branch name cannot be empty")
} else {
Ok(())
}
};
let name = if branch_names.is_empty() {
msg::input("Branch name", not_empty)?
} else {
msg::select_or_input("Select target branch", branch_names.clone(), not_empty)?
};
let name = name.trim().to_string();
if !branch_names.contains(&name) {
git::branch_validate_name(&name)?;
repo::ensure_branch_not_exists(repo, &name)?;
create_branch_at_merge_base(workdir, &name, info.upstream.merge_base_oid)?;
return Ok((name, true));
}
Ok((name, false))
}
fn is_branch_at_merge_base(
repo: &Repository,
branch_name: &str,
merge_base_oid: git2::Oid,
) -> Result<bool> {
let branch = repo.find_branch(branch_name, git2::BranchType::Local)?;
let branch_oid = branch.get().target().context("Branch has no target")?;
Ok(branch_oid == merge_base_oid)
}
fn create_branch_at_merge_base(
workdir: &std::path::Path,
name: &str,
merge_base_oid: git2::Oid,
) -> Result<()> {
let merge_base_hash = merge_base_oid.to_string();
git::branch_create(workdir, name, &merge_base_hash)?;
msg::success(&format!(
"Created branch `{}` at `{}`",
name,
git::short_hash(&merge_base_hash)
));
Ok(())
}
#[cfg(test)]
#[path = "commit_test.rs"]
mod tests;