git-loom 0.18.0

A Git CLI tool that weaves together multiple feature branches into integration branches
use std::collections::{HashMap, HashSet};
use std::path::Path;

use anyhow::{Context, Result, bail};
use git2::Repository;
use serde::{Deserialize, Serialize};

use crate::branch::is_on_first_parent_line;
use crate::core::msg;
use crate::core::repo::{self, Target, TargetKind};
use crate::core::transaction::{self, LoomState, Rollback};
use crate::core::weave::{self, RebaseOutcome, Weave};
use crate::git;

fn confirm_or_bail(skip: bool, prompt: &str) -> Result<()> {
    if !skip && !msg::confirm(prompt)? {
        bail!("Cancelled");
    }
    Ok(())
}

#[derive(Serialize, Deserialize)]
struct DropContext {
    commit_hash: String,
}

/// Drop a commit, branch, or file from history or the working tree.
///
/// Dispatches based on the resolved target type:
/// - Commit → remove the commit via interactive rebase
/// - Branch → remove all branch commits, unweave merge topology, delete the ref
/// - File → restore/delete the file (tracked: restore, new: delete)
/// - Unstaged (`zz`) → discard all local changes (restore + clean)
pub fn run(target: String, skip_confirm: bool) -> Result<()> {
    let repo = repo::open_repo()?;

    let resolved = repo::resolve_arg(
        &repo,
        &target,
        &[
            TargetKind::File,
            TargetKind::Branch,
            TargetKind::Commit,
            TargetKind::Unstaged,
        ],
    )?;

    match resolved {
        Target::Commit(hash) => drop_commit(&repo, &hash, skip_confirm),
        Target::Branch(name) => drop_branch(&repo, &name, skip_confirm),
        Target::File(path) => drop_file(&repo, &path, skip_confirm),
        Target::Unstaged => drop_all(&repo, skip_confirm),
        _ => unreachable!(),
    }
}

/// Drop a file or directory: restore tracked changes, or delete new/untracked entries.
fn drop_file(repo: &Repository, path: &str, skip_confirm: bool) -> Result<()> {
    let workdir = repo::require_workdir(repo, "drop")?;
    let full_path = workdir.join(path);

    // Directory — restore tracked changes and clean untracked files inside
    if full_path.is_dir() {
        // Check for tracked changes first to pick the right prompt and message
        let has_tracked = {
            let mut opts = git2::StatusOptions::new();
            opts.pathspec(path)
                .include_untracked(false)
                .recurse_untracked_dirs(false);
            let statuses = repo.statuses(Some(&mut opts))?;
            !statuses.is_empty()
        };
        if has_tracked {
            confirm_or_bail(skip_confirm, &format!("Discard all changes in `{}`?", path))?;
            git::run_git(workdir, &["restore", "--staged", "--worktree", path])?;
            git::run_git(workdir, &["clean", "-fd", "--", path])?;
            msg::success(&format!("Restored `{}`", path));
        } else {
            confirm_or_bail(skip_confirm, &format!("Delete `{}`?", path))?;
            git::run_git(workdir, &["clean", "-fd", "--", path])?;
            msg::success(&format!("Deleted `{}`", path));
        }
        return Ok(());
    }

    let status = repo
        .status_file(std::path::Path::new(path))
        .with_context(|| format!("'{}' is not tracked by git", path))?;

    if status.is_wt_new() {
        // Untracked file — delete it
        confirm_or_bail(skip_confirm, &format!("Delete `{}`?", path))?;
        std::fs::remove_file(workdir.join(path))
            .with_context(|| format!("Failed to delete '{}'", path))?;
        msg::success(&format!("Deleted `{}`", path));
    } else if status.is_index_new() {
        // Staged new file — remove from index and disk
        confirm_or_bail(skip_confirm, &format!("Delete `{}`?", path))?;
        git::run_git(workdir, &["rm", "--force", path])?;
        msg::success(&format!("Deleted `{}`", path));
    } else {
        // Tracked file with modifications — restore it
        confirm_or_bail(skip_confirm, &format!("Discard changes to `{}`?", path))?;
        git::run_git(workdir, &["restore", "--staged", "--worktree", path])?;
        msg::success(&format!("Restored `{}`", path));
    }

    Ok(())
}

/// Drop all local changes: restore tracked files and delete untracked files.
fn drop_all(repo: &Repository, skip_confirm: bool) -> Result<()> {
    let workdir = repo::require_workdir(repo, "drop")?;

    let mut opts = git2::StatusOptions::new();
    opts.include_untracked(true).recurse_untracked_dirs(false);
    let statuses = repo.statuses(Some(&mut opts))?;

    if statuses.is_empty() {
        bail!("No local changes to discard");
    }

    confirm_or_bail(skip_confirm, "Discard all local changes?")?;

    git::run_git(workdir, &["restore", "--staged", "--worktree", "."])?;
    git::run_git(workdir, &["clean", "-fd"])?;

    msg::success("Discarded all local changes");
    Ok(())
}

/// Drop a single commit from history via interactive rebase.
///
/// If the commit is the only commit on a branch, delegates to `drop_branch`
/// to properly remove the entire branch section and merge topology.
fn drop_commit(repo: &Repository, commit_hash: &str, skip_confirm: bool) -> Result<()> {
    let workdir = repo::require_workdir(repo, "drop")?;
    let git_dir = repo.path().to_path_buf();

    let commit_oid = git2::Oid::from_str(commit_hash)?;
    let info = repo::gather_repo_info(repo, false, 1)?;

    // Check if this commit is the only commit on a branch.
    // If so, delegate to drop_branch for clean section removal.
    let merge_base_oid = info.upstream.merge_base_oid;

    if let Some(branch_name) = find_branch_owning_commit_from_info(&info, commit_oid)
        && let Some(branch_info) = info.branches.iter().find(|b| b.name == branch_name)
    {
        let owned = find_owned_commits(
            repo,
            branch_info.tip_oid,
            merge_base_oid,
            &info.branches,
            &branch_name,
        )?;
        if owned.len() == 1 {
            // This is the only commit on the branch — drop the whole branch
            return drop_branch_with_info(repo, &info, &branch_name, skip_confirm);
        }
    }

    let short_hash = git::short_hash(commit_hash);
    let summary = repo::commit_subject(&repo.find_commit(commit_oid)?);
    confirm_or_bail(
        skip_confirm,
        &format!("Drop commit `{}` {}?", short_hash, summary),
    )?;

    let mut graph = Weave::from_repo_with_info(repo, &info)?;
    graph.drop_commit(commit_oid);

    let ctx = DropContext {
        commit_hash: commit_hash.to_string(),
    };
    let state = LoomState {
        command: "drop".to_string(),
        rollback: Rollback::default(),
        context: serde_json::to_value(&ctx)?,
    };
    transaction::save(&git_dir, &state)?;

    let todo = graph.to_todo();
    match weave::run_rebase(workdir, Some(&graph.base_oid.to_string()), &todo)? {
        RebaseOutcome::Completed => {
            transaction::delete(&git_dir)?;
            msg::success(&format!("Dropped commit `{}`", short_hash));
        }
        RebaseOutcome::Conflicted => {
            transaction::warn_conflict_paused("drop");
        }
    }

    Ok(())
}

/// Resume a `drop commit` operation after a conflict has been resolved.
pub fn after_continue(_workdir: &Path, context: &serde_json::Value) -> Result<()> {
    let ctx: DropContext =
        serde_json::from_value(context.clone()).context("Failed to parse drop resume context")?;
    msg::success(&format!(
        "Dropped commit `{}`",
        git::short_hash(&ctx.commit_hash)
    ));
    Ok(())
}

/// Drop a branch: remove all its commits, unweave merge topology, delete the ref.
fn drop_branch(repo: &Repository, branch_name: &str, skip_confirm: bool) -> Result<()> {
    let info = repo::gather_repo_info(repo, false, 1)?;
    drop_branch_with_info(repo, &info, branch_name, skip_confirm)
}

/// Drop a branch using pre-gathered `RepoInfo`.
fn drop_branch_with_info(
    repo: &Repository,
    info: &repo::RepoInfo,
    branch_name: &str,
    skip_confirm: bool,
) -> Result<()> {
    let workdir = repo::require_workdir(repo, "drop")?;

    // Verify the branch is in the integration range
    let branch_info = info
        .branches
        .iter()
        .find(|b| b.name == branch_name)
        .with_context(|| {
            format!(
                "Branch '{}' is not woven into the integration branch\n\
                 Use `git branch -d {}` to delete it directly",
                branch_name, branch_name
            )
        })?;

    let head_oid = repo::head_oid(repo)?;
    let merge_base_oid = info.upstream.merge_base_oid;

    // Check if branch is at the merge-base with no owned commits
    if branch_info.tip_oid == merge_base_oid {
        confirm_or_bail(
            skip_confirm,
            &format!("Drop empty branch `{}`?", branch_name),
        )?;
        git::branch_delete(workdir, branch_name)?;
        msg::success(&format!("Dropped branch `{}`", branch_name));
        return Ok(());
    }

    // Count owned commits for the confirmation message and for the non-woven drop path
    let owned = find_owned_commits(
        repo,
        branch_info.tip_oid,
        merge_base_oid,
        &info.branches,
        branch_name,
    )?;
    let commit_count = owned.len();
    let prompt = if commit_count == 1 {
        format!("Drop branch `{}` and its 1 commit?", branch_name)
    } else {
        format!(
            "Drop branch `{}` and its {} commits?",
            branch_name, commit_count
        )
    };
    confirm_or_bail(skip_confirm, &prompt)?;

    // Check if another branch shares the same tip (co-located branches)
    let colocated_branch = info
        .branches
        .iter()
        .find(|b| b.name != branch_name && b.tip_oid == branch_info.tip_oid);

    // Determine if the branch is woven (tip NOT on first-parent line)
    let is_woven = branch_info.tip_oid != head_oid
        && !is_on_first_parent_line(repo, head_oid, merge_base_oid, branch_info.tip_oid)?;

    let mut graph = Weave::from_repo_with_info(repo, info)?;

    if is_woven {
        if let Some(keep) = colocated_branch {
            graph.reassign_branch(branch_name, &keep.name);
        } else {
            graph.drop_branch(branch_name);
        }
    } else if owned.is_empty() {
        // Co-located non-woven: no commits to drop, just delete the ref
        git::branch_delete(workdir, branch_name)?;
        msg::success(&format!("Dropped branch `{}`", branch_name));
        return Ok(());
    } else {
        // Non-woven branch: drop each uniquely owned commit individually
        for oid in &owned {
            graph.drop_commit(*oid);
        }
    }

    let todo = graph.to_todo();
    weave::run_rebase_or_abort(workdir, Some(&graph.base_oid.to_string()), &todo)?;

    // Delete the branch ref (warn on failure — extremely unlikely)
    if let Err(e) = git::branch_delete(workdir, branch_name) {
        eprintln!(
            "warning: Could not delete branch ref '{}': {} (may have been cleaned up automatically)",
            branch_name, e
        );
    }

    msg::success(&format!("Dropped branch `{}`", branch_name));
    Ok(())
}

/// Determine which branch owns the given commit using pre-gathered repo info.
///
/// Walks from each branch tip along parent links, stopping at another branch's
/// tip or the edge of the commit range. Returns the branch name if found.
fn find_branch_owning_commit_from_info(
    info: &repo::RepoInfo,
    target_oid: git2::Oid,
) -> Option<String> {
    let parent_map: HashMap<git2::Oid, Option<git2::Oid>> =
        info.commits.iter().map(|c| (c.oid, c.parent_oid)).collect();

    let branch_tip_set: HashSet<git2::Oid> = info.branches.iter().map(|b| b.tip_oid).collect();

    for branch in &info.branches {
        let mut current = Some(branch.tip_oid);
        let mut is_tip = true;
        while let Some(oid) = current {
            if !parent_map.contains_key(&oid) {
                break;
            }
            if !is_tip && branch_tip_set.contains(&oid) {
                break;
            }
            is_tip = false;
            if oid == target_oid {
                return Some(branch.name.clone());
            }
            current = parent_map.get(&oid).and_then(|p| *p);
        }
    }

    None
}

/// Find all commits owned by a branch (from tip to next boundary or merge-base).
///
/// `dropping_branch_name` identifies the branch being dropped so that other
/// branches sharing the same tip are properly excluded. Without this, co-located
/// branches (same tip) would not be hidden, causing their shared commits to be
/// incorrectly reported as owned by the dropping branch.
fn find_owned_commits(
    repo: &Repository,
    branch_tip: git2::Oid,
    merge_base_oid: git2::Oid,
    all_branches: &[repo::BranchInfo],
    dropping_branch_name: &str,
) -> Result<Vec<git2::Oid>> {
    let mut revwalk = repo.revwalk()?;
    revwalk.push(branch_tip)?;
    revwalk.hide(merge_base_oid)?;

    // Hide other branch tips that are ancestors of (or co-located with) our tip.
    // Skip the branch being dropped (by name), so co-located branches with the
    // same tip_oid are still hidden — their shared commits are not "owned" by us.
    for other_branch in all_branches {
        if other_branch.name == dropping_branch_name {
            continue;
        }
        if other_branch.tip_oid == branch_tip
            || repo.graph_descendant_of(branch_tip, other_branch.tip_oid)?
        {
            revwalk.hide(other_branch.tip_oid)?;
        }
    }

    revwalk.set_sorting(git2::Sort::TOPOLOGICAL)?;

    let mut oids = Vec::new();
    for oid_result in revwalk {
        let oid = oid_result?;
        let commit = repo.find_commit(oid)?;
        // Skip merge commits (same as gather_repo_info)
        if commit.parent_count() > 1 {
            continue;
        }
        oids.push(oid);
    }

    Ok(oids)
}

#[cfg(test)]
#[path = "drop_test.rs"]
mod tests;