use super::{App, Sel};
use crate::git::load::{self, RemoteTags, TagState};
use crate::git::{git_capture, git_run};
use crate::tui::widgets::{Modal, Query};
use ratatui::style::Color;
pub(super) use crate::git::git_words;
pub(super) struct Undo {
pub(super) branch: bool,
name: String,
refname: String,
sha: String,
upstream: Option<(String, String)>,
}
pub(super) struct Confirm {
pub(super) target: Target,
pub(super) yes: bool,
pub(super) typed: Query,
}
impl Confirm {
pub(super) fn new(target: Target) -> Confirm {
Confirm {
yes: matches!(target, Target::Track(_)),
typed: Query::default(),
target,
}
}
}
pub(super) enum Target {
Branch {
name: String,
lost: usize,
landed: bool,
trunk: String,
},
Tag {
name: String,
differs: bool,
},
RemoteTag {
remote: String,
name: String,
here: bool,
},
RemoteBranch {
remote: String,
branch: String,
},
Track(String),
Remote {
name: String,
url: String,
},
Stash {
name: String,
subject: String,
hash: String,
},
}
impl Target {
pub(super) fn typed(&self) -> bool {
match self {
Target::Branch { lost, landed, .. } => *lost > 0 && !landed,
Target::RemoteTag { .. } | Target::RemoteBranch { .. } | Target::Stash { .. } => true,
Target::Tag { .. } | Target::Track(_) | Target::Remote { .. } => false,
}
}
pub(super) fn title(&self) -> String {
match self {
Target::Branch {
lost,
landed: false,
name,
..
} if *lost > 0 => format!("delete {name} here, with its commits?"),
Target::Branch { .. } => "delete this branch?".to_string(),
Target::Tag { .. } => "delete this tag?".to_string(),
Target::RemoteTag {
remote,
name,
here: true,
} => format!("delete {name} on {remote} and here?"),
Target::RemoteTag { remote, name, .. } => format!("delete {name} on {remote}?"),
Target::RemoteBranch { remote, branch } => format!("delete {branch} on {remote}?"),
Target::Track(_) => "show push marks instantly?".to_string(),
Target::Remote { .. } => "remove this remote?".to_string(),
Target::Stash { name, .. } => format!("drop {name}?"),
}
}
pub(super) fn name(&self) -> String {
match self {
Target::Branch { name, .. }
| Target::Tag { name, .. }
| Target::RemoteTag { name, .. }
| Target::Stash { name, .. }
| Target::Remote { name, .. } => name.clone(),
Target::RemoteBranch { branch, .. } => branch.clone(),
Target::Track(remote) => {
format!("git keeps a copy of {remote}'s tags, like it does its branches")
}
}
}
pub(super) fn note(&self) -> Option<String> {
match self {
Target::Branch { lost: 0, .. } => {
Some(
"no commits of its own: everything in it is already on the remote, so only the name goes"
.to_string(),
)
}
Target::Branch {
landed: true,
trunk,
..
} => Some(format!(
"its changes are all in {trunk} already, so only the name goes"
)),
Target::Branch { lost, .. } => Some(if *lost == 1 {
"about to remove the branch and the commit only it holds".to_string()
} else {
format!("about to remove the branch and the {lost} commits only it holds")
}),
Target::Tag { differs: true, .. } => {
Some("only yours goes: the remote's comes back with `git fetch --tags`".to_string())
}
Target::Tag { .. } => Some("the commit stays; only the tag goes".to_string()),
Target::RemoteTag { remote, .. } => Some(format!(
"everyone loses it on {remote}; clones that fetched it keep a copy"
)),
Target::RemoteBranch { remote, .. } => Some(format!(
"everyone loses it on {remote}; your own branches are untouched"
)),
Target::Track(remote) => Some(format!(
"adds to .git/config: fetch = {}\nundo: git config --unset remote.{remote}.fetch remote-tags",
load::tag_rule(remote)
)),
Target::Stash { subject, .. } => Some(format!(
"{subject}\nits changes are kept nowhere else, so they go with it"
)),
Target::Remote { url, .. } => Some(format!(
"only this clone forgets it, with its tracking refs; nothing on {url} is touched\nbranches that track it stop tracking anything"
)),
}
}
pub(super) fn verb(&self) -> &'static str {
match self {
Target::Stash { .. } => "drop",
_ => "del",
}
}
pub(super) fn colour(&self) -> Color {
match self {
Target::Track(_) => Color::Cyan,
_ => Color::Red,
}
}
}
impl App {
pub(super) fn ask_delete_branch(&mut self) {
let Some(i) = self.bsel.idx() else {
return;
};
let b = &self.branches[i];
let name = b.name.clone();
let (remote, branch) = if b.remote {
b.name.split_once('/').unwrap_or(("origin", &b.name))
} else {
("", b.name.as_str())
};
let target = if is_trunk(&self.repo, remote, branch) {
let body = if matches!(branch, "main" | "master") {
"You cannot delete main or master from here."
} else {
"You cannot delete the remote's default branch from here."
};
Err((
format!("`{name}` is not something `d` deletes"),
body.to_string(),
))
} else if b.remote {
Ok(Target::RemoteBranch {
remote: remote.to_string(),
branch: branch.to_string(),
})
} else if b.is_head {
Err((
format!("you are on `{name}`"),
"git will not delete the branch that is checked out. Switch to another one, then `d` again."
.to_string(),
))
} else if b.track.contains("gone") || !b.has_upstream || b.track.contains("ahead") {
let lost = load::unique_commits(&self.repo, &name);
let origin = b.upstream.split_once('/').map_or("origin", |(r, _)| r);
let trunk = load::trunk(&self.repo, origin);
let landed = lost > 0
&& trunk
.as_deref()
.is_some_and(|t| load::landed(&self.repo, &name, t));
let trunk = trunk
.map(|t| t.trim_start_matches("refs/remotes/").to_string())
.unwrap_or_default();
Ok(Target::Branch {
name,
lost,
landed,
trunk,
})
} else {
let (remote, branch) = b.upstream.split_once('/').unwrap_or(("origin", &b.name));
Err((
format!("`{name}` is still on {remote}"),
format!(
"Its upstream is alive, so it is not finished. Delete it on the remote first - \
`d` on `{remote}/{branch}` in the remote tab - then `s` to sync, and it shows as gone."
),
))
};
self.gate(target);
}
pub(super) fn ask_delete_tag(&mut self) {
let Some(i) = self.tsel.idx() else {
return;
};
let t = &self.tags[i];
let name = t.name.clone();
let remote = match &self.remote {
Some(RemoteTags::Answered { remote, .. } | RemoteTags::Unreachable(remote)) => {
remote.clone()
}
_ => "origin".to_string(),
};
let target = match (t.state, &self.remote) {
(TagState::Local | TagState::Differs, _) => Ok(Target::Tag {
differs: t.state == TagState::Differs,
name,
}),
(TagState::Pushed | TagState::Remote, _) => Ok(Target::RemoteTag {
here: t.state == TagState::Pushed,
remote,
name,
}),
(TagState::Unknown, Some(RemoteTags::Unreachable(_))) => Err((
format!("{remote} could not be reached"),
format!(
"There is no telling whether `{name}` exists anywhere else, so deleting it \
here could lose it. If you are sure: `git tag -d {name}`"
),
)),
(TagState::Unknown, _) => Err((
format!("{remote} has not answered yet"),
format!(
"Whether `{name}` is pushed is not known until it does. Try again in a moment."
),
)),
};
self.gate(target);
}
fn gate(&mut self, target: Result<Target, (String, String)>) {
match target {
Ok(target) => self.confirm = Some(Confirm::new(target)),
Err((title, body)) => self.modal = Some(Modal::new(title, body)),
}
}
pub(super) fn delete(&mut self, target: Target) {
match target {
Target::Track(remote) => self.start_tracking(&remote),
Target::RemoteTag { .. } | Target::RemoteBranch { .. } => {
self.set_status(format!("deleting {} on the remote…", target.name()));
self.delete_next = Some(target);
}
Target::Branch { .. } | Target::Tag { .. } => self.delete_here(target),
Target::Stash { name, hash, .. } => self.drop_stash(&name, &hash),
Target::Remote { .. } => self.delete_remote(&target),
}
}
fn delete_here(&mut self, target: Target) {
let (branch, name) = match &target {
Target::Branch { name, .. } => (true, name.clone()),
Target::Tag { name, .. } => (false, name.clone()),
_ => return,
};
let refname = if branch {
format!("refs/heads/{name}")
} else {
format!("refs/tags/{name}")
};
let Some(sha) = git_capture(&self.repo, &["rev-parse", &refname]) else {
self.fail(&target, &format!("`{refname}` does not resolve any more"));
return;
};
let upstream = branch.then(|| upstream_of(&self.repo, &name)).flatten();
let args = if branch {
["branch", "-D", name.as_str()]
} else {
["tag", "-d", name.as_str()]
};
let (ok, out) = git_run(&self.repo, &args);
if !ok {
self.fail(&target, &out);
return;
}
let short: String = sha.chars().take(7).collect();
self.set_status(format!("deleted {name} (was {short})"));
self.undo = Some(Undo {
branch,
name,
refname,
sha,
upstream,
});
self.reread(&target);
}
pub(super) fn undo(&mut self) {
let Some(undo) = self.undo.take() else {
return;
};
let (ok, out) = git_run(&self.repo, &["update-ref", &undo.refname, &undo.sha, ""]);
if !ok {
let words = git_words(&out);
self.modal = Some(Modal::new(
format!("could not put back `{}`", undo.name),
words,
));
return;
}
if let Some((remote, merge)) = &undo.upstream {
let key = |k: &str| format!("branch.{}.{k}", undo.name);
git_run(&self.repo, &["config", &key("remote"), remote]);
git_run(&self.repo, &["config", &key("merge"), merge]);
}
self.set_status(format!("put back {}", undo.name));
if undo.branch {
self.bsel.restore = Some(undo.name);
self.request_branches();
} else {
self.tsel.restore = Some(undo.name);
self.load_tags();
self.mark_tags();
}
self.pending = true;
}
pub(super) fn delete_on_remote(&mut self, target: Target) {
let (remote, refname) = match &target {
Target::RemoteTag { remote, name, .. } => (remote, format!("refs/tags/{name}")),
Target::RemoteBranch { remote, branch } => (remote, format!("refs/heads/{branch}")),
_ => return,
};
if let Err(words) = load::push_delete(&self.repo, remote, &refname) {
self.note = None;
self.fail(&target, &words);
return;
}
if let Target::RemoteTag {
name, here: true, ..
} = &target
{
let (ok, out) = git_run(&self.repo, &["tag", "-d", name]);
if !ok {
self.fail(&target, &out);
return;
}
}
self.set_status(format!("deleted {} on {remote}", target.name()));
self.reread(&target);
}
fn fail(&mut self, target: &Target, words: &str) {
let words = git_words(words);
self.modal = Some(Modal::new(
format!("could not delete `{}`", target.name()),
words,
));
}
fn reread(&mut self, target: &Target) {
match target {
Target::Branch { .. } | Target::RemoteBranch { .. } => {
self.bsel.restore = neighbour(&self.bsel, |i| self.branches[i].name.clone());
self.request_branches();
}
Target::Tag { .. } => {
self.tsel.restore = neighbour(&self.tsel, |i| self.tags[i].name.clone());
self.load_tags();
self.mark_tags();
}
Target::RemoteTag { name, .. } => {
if let Some(RemoteTags::Answered { tags, .. }) = &mut self.remote {
tags.remove(name);
}
self.tsel.restore = neighbour(&self.tsel, |i| self.tags[i].name.clone());
self.load_tags();
self.mark_tags();
}
Target::Track(_) | Target::Stash { .. } | Target::Remote { .. } => {}
}
self.pending = true;
}
pub(super) fn sync(&mut self, pull: bool) {
if let Err(words) = load::fetch_prune(&self.repo) {
self.note = None;
let words = git_words(&words);
self.modal = Some(Modal::new("sync failed", words));
return;
}
let note = if pull {
match load::fast_forward(&self.repo) {
Ok(Some(0)) => Ok("synced; already up to date".to_string()),
Ok(Some(1)) => Ok("synced; pulled 1 commit".to_string()),
Ok(Some(n)) => Ok(format!("synced; pulled {n} commits")),
Ok(None) => Ok("synced; this branch tracks nothing to pull".to_string()),
Err(words) => Err(words),
}
} else {
Ok("synced".to_string())
};
match note {
Ok(text) => self.set_status(text),
Err(words) => self.set_failed(format!(
"fetched, but not pulled: {}",
crate::git::first_line(&words)
)),
}
self.unpushed_for.clear();
self.bsel.restore = self.bsel.idx().map(|i| self.branches[i].name.clone());
self.request_branches();
self.pending = true;
}
}
fn upstream_of(repo: &str, name: &str) -> Option<(String, String)> {
let get = |k: &str| git_capture(repo, &["config", "--get", &format!("branch.{name}.{k}")]);
Some((get("remote")?, get("merge")?))
}
fn is_trunk(repo: &str, remote: &str, branch: &str) -> bool {
if matches!(branch, "main" | "master") {
return true;
}
let remote = if remote.is_empty() { "origin" } else { remote };
load::default_branch(repo, remote).is_some_and(|d| d == branch)
}
fn neighbour(sel: &Sel, name: impl Fn(usize) -> String) -> Option<String> {
let at = sel.cur;
sel.visible
.get(at + 1)
.or_else(|| at.checked_sub(1).and_then(|b| sel.visible.get(b)))
.map(|&i| name(i))
}