git-loom 0.18.0

A Git CLI tool that weaves together multiple feature branches into integration branches
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,
}

/// Create a commit on a feature branch without leaving the integration branch.
///
/// Stages files, creates the commit at HEAD, then uses Weave to relocate
/// it to the target feature branch (creating merge topology if needed).
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();

    // Gather repo info once — also serves as verification that we're on an
    // integration branch (gather_repo_info requires an upstream).
    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",
    )?;

    // Stage files, saving aside any pre-existing staged files not in the
    // target list so they don't accidentally end up in this commit.
    let saved_staged = if patch {
        resolve_staging_patch(&repo, &workdir, &files, theme)?
    } else {
        resolve_staging(&repo, &workdir, &files)?
    };

    // Verify index has changes — restore saved staged on failure so pre-existing staged work is not lost.
    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)
        }
    };

    // Loose commit: when no -b flag and local branch name matches the
    // upstream's local counterpart (e.g. "main" tracking "origin/main"),
    // commit directly on the integration branch without targeting a feature
    // branch. This works regardless of whether local commits or woven
    // branches already exist.
    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();

    // Resolve branch target (may create a new branch at merge-base).
    // Returns whether the branch was newly created — only newly-created
    // branches are deleted on rollback (not pre-existing empty ones).
    let (branch_name, branch_is_new) =
        resolve_branch_target(&repo, &info, &workdir, branch.as_deref())?;

    // Empty branches (pointing at merge-base) need a branch section and
    // merge entry created in the Weave before moving the commit there.
    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 {
        // For empty branches, create a new branch section and merge topology
        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();

    // Save LoomState before the rebase so we can resume on conflict.
    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(())
}

/// Resume a `commit` operation after a conflict has been resolved.
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)
}

/// Post-rebase work: restore staged changes and print success message.
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(())
}

/// Resolve staging in patch mode: open the interactive hunk picker.
///
/// - If files specified: save and unstage other staged files first (so only
///   the selected files appear in the picker and don't leak into this commit).
/// - Opens the hunk picker (filtered to files if provided, or all changes).
/// - If the user cancels: restores saved staged patch and returns an error.
/// - Returns the saved staged patch for later restoration after the commit.
fn resolve_staging_patch(
    repo: &Repository,
    workdir: &std::path::Path,
    files: &[String],
    theme: &graph::Theme,
) -> Result<String> {
    // Save aside other staged files when specific files are targeted.
    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)
}

/// Resolve staging based on file arguments.
///
/// - Empty: use index as-is; returns an empty saved patch.
/// - Contains "zz": stage all changes; returns an empty saved patch.
/// - Otherwise: save and unstage any pre-existing staged files NOT in the
///   target list (so they don't leak into this commit), stage the target
///   files, and return the saved patch for later restoration.
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)
}

/// Resolve a slice of user file arguments to repo-relative paths.
fn resolve_file_args(repo: &Repository, files: &[String]) -> Result<Vec<String>> {
    files
        .iter()
        .map(|arg| repo::resolve_file_arg(repo, arg))
        .collect()
}

/// Resolve the target branch: explicit name/shortID, or interactive picker.
///
/// Returns `(branch_name, is_new)` — `is_new` is true when the branch was
/// created by this call (only newly-created branches are deleted on rollback).
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),
    }
}

/// Resolve an explicit branch argument.
///
/// - Known woven branch (by name or short ID): use it
/// - Known branch but not woven: error
/// - Unknown: treat as new branch name, validate, create at merge-base, weave
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(_) => {
            // Treat as new branch name
            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))
        }
    }
}

/// Interactive branch picker: select an existing woven branch or type a new name.
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 user typed a name that isn't an existing woven branch, create it
    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))
}

/// Check if a branch points to the merge-base commit (i.e., has no commits of its own).
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)
}

/// Create a new branch at the merge-base.
///
/// The branch is not yet woven — weaving happens after the commit is created,
/// in the main `run` flow via Weave.
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;