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,
}
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!(),
}
}
fn drop_file(repo: &Repository, path: &str, skip_confirm: bool) -> Result<()> {
let workdir = repo::require_workdir(repo, "drop")?;
let full_path = workdir.join(path);
if full_path.is_dir() {
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() {
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() {
confirm_or_bail(skip_confirm, &format!("Delete `{}`?", path))?;
git::run_git(workdir, &["rm", "--force", path])?;
msg::success(&format!("Deleted `{}`", path));
} else {
confirm_or_bail(skip_confirm, &format!("Discard changes to `{}`?", path))?;
git::run_git(workdir, &["restore", "--staged", "--worktree", path])?;
msg::success(&format!("Restored `{}`", path));
}
Ok(())
}
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(())
}
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)?;
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 {
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(())
}
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(())
}
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)
}
fn drop_branch_with_info(
repo: &Repository,
info: &repo::RepoInfo,
branch_name: &str,
skip_confirm: bool,
) -> Result<()> {
let workdir = repo::require_workdir(repo, "drop")?;
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;
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(());
}
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)?;
let colocated_branch = info
.branches
.iter()
.find(|b| b.name != branch_name && b.tip_oid == branch_info.tip_oid);
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() {
git::branch_delete(workdir, branch_name)?;
msg::success(&format!("Dropped branch `{}`", branch_name));
return Ok(());
} else {
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)?;
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(())
}
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
}
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)?;
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)?;
if commit.parent_count() > 1 {
continue;
}
oids.push(oid);
}
Ok(oids)
}
#[cfg(test)]
#[path = "drop_test.rs"]
mod tests;