use colored::Colorize;
use dialoguer::{Confirm, Select};
use crate::git;
#[derive(Debug)]
enum Action {
Commit { message: String, is_amend: bool, is_merge: bool, is_initial: bool },
Pull,
Merge { branch: String },
Checkout { from: String, to: String },
Rebase,
CherryPick,
Reset,
StashApply,
Other { description: String },
}
fn detect_action(reflog_entry: &str) -> Action {
let e = reflog_entry.trim();
if let Some(rest) = e.strip_prefix("commit (amend): ") {
return Action::Commit { message: rest.to_string(), is_amend: true, is_merge: false, is_initial: false };
}
if let Some(rest) = e.strip_prefix("commit (merge): ") {
return Action::Commit { message: rest.to_string(), is_amend: false, is_merge: true, is_initial: false };
}
if let Some(rest) = e.strip_prefix("commit (initial): ") {
return Action::Commit { message: rest.to_string(), is_amend: false, is_merge: false, is_initial: true };
}
if let Some(rest) = e.strip_prefix("commit: ") {
return Action::Commit { message: rest.to_string(), is_amend: false, is_merge: false, is_initial: false };
}
if e.starts_with("pull") {
return Action::Pull;
}
if let Some(rest) = e.strip_prefix("merge ") {
return Action::Merge { branch: rest.to_string() };
}
if e.starts_with("checkout: moving from") {
let parts: Vec<&str> = e.splitn(2, " to ").collect();
let from = e
.strip_prefix("checkout: moving from ")
.unwrap_or("")
.split(" to ")
.next()
.unwrap_or("?")
.to_string();
let to = parts.get(1).unwrap_or(&"?").to_string();
return Action::Checkout { from, to };
}
if e.starts_with("rebase") {
return Action::Rebase;
}
if e.starts_with("cherry-pick") {
return Action::CherryPick;
}
if e.starts_with("reset") {
return Action::Reset;
}
if e.contains("stash") {
return Action::StashApply;
}
Action::Other { description: e.to_string() }
}
fn action_icon(action: &Action) -> &str {
match action {
Action::Commit { is_amend: true, .. } => "✏️",
Action::Commit { is_merge: true, .. } => "🔀",
Action::Commit { is_initial: true, .. } => "🌱",
Action::Commit { .. } => "💾",
Action::Pull => "⬇️",
Action::Merge { .. } => "🔀",
Action::Checkout { .. } => "🔄",
Action::Rebase => "📐",
Action::CherryPick => "🍒",
Action::Reset => "⏪",
Action::StashApply => "📦",
Action::Other { .. } => "❓",
}
}
fn action_description(action: &Action) -> String {
match action {
Action::Commit { message, is_amend: true, .. } =>
format!("Amended commit: \"{}\"", message),
Action::Commit { message, is_merge: true, .. } =>
format!("Merge commit: \"{}\"", message),
Action::Commit { message, is_initial: true, .. } =>
format!("Initial commit: \"{}\"", message),
Action::Commit { message, .. } =>
format!("Commit: \"{}\"", message),
Action::Pull =>
"Pulled from remote".to_string(),
Action::Merge { branch } =>
format!("Merged branch '{}'", branch),
Action::Checkout { from, to } =>
format!("Switched from '{}' to '{}'", from, to),
Action::Rebase =>
"Rebased branch".to_string(),
Action::CherryPick =>
"Cherry-picked a commit".to_string(),
Action::Reset =>
"Reset branch".to_string(),
Action::StashApply =>
"Applied stash".to_string(),
Action::Other { description } =>
description.clone(),
}
}
pub fn run(list: bool) {
if list {
run_list();
} else {
run_undo();
}
}
fn run_list() {
let entries = git::reflog_entries(20);
if entries.is_empty() {
println!();
println!(" {} No actions found in history.", "⚠".yellow());
println!();
return;
}
println!();
println!(" {}:", "Undo timeline".bold());
println!(" {}", "Pick any action to rewind to the state before it.".dimmed());
println!();
let mut items: Vec<(String, String, Action)> = vec![];
for (hash, desc, time) in &entries {
let action = detect_action(desc);
items.push((hash.clone(), time.clone(), action));
}
let display: Vec<String> = items
.iter()
.map(|(_, time, action)| {
format!(
" {} {} {}",
action_icon(action),
action_description(action),
format!("({})", time).dimmed()
)
})
.collect();
let refs: Vec<&str> = display.iter().map(|s| s.as_str()).collect();
let selection = Select::new()
.with_prompt(" Rewind to before which action?")
.items(&refs)
.default(0)
.interact();
let selection = match selection {
Ok(s) => s,
Err(_) => {
println!(" Cancelled.");
println!();
return;
}
};
let (_, _, action) = &items[selection];
let reflog_target = format!("HEAD@{{{}}}", selection);
println!();
show_rewind_preview(&reflog_target);
let confirm = Confirm::new()
.with_prompt(format!(
" {} Rewind to before: {}?",
"⚠".yellow(),
action_description(action).bold()
))
.default(false)
.interact();
match confirm {
Ok(true) => {}
_ => {
println!(" Cancelled.");
println!();
return;
}
}
println!();
let result = git::run(&["reset", "--hard", &reflog_target]);
if result.success {
println!(" {} Rewound successfully.", "✔".green().bold());
println!(" You're now at the state before that action.");
} else {
println!(" {} Failed: {}", "✖".red(), result.stderr);
}
println!();
}
fn run_undo() {
println!();
let files = git::parse_status();
let staged: Vec<&git::FileStatus> = files.iter().filter(|f| f.staged).collect();
if !staged.is_empty() {
println!(" {}:", "Staged changes detected".yellow().bold());
for f in &staged {
println!(" {} {}", f.kind.icon().green(), f.path);
}
println!();
let options = &[
"Unstage all files (keep changes in working directory)",
"Continue to undo last action instead",
"Cancel",
];
let selection = Select::new()
.with_prompt(" What would you like to undo?")
.items(options)
.default(0)
.interact();
match selection {
Ok(0) => {
let result = git::run(&["reset", "HEAD"]);
if result.success {
println!();
println!(" {} Unstaged {} file(s).", "✔".green().bold(), staged.len());
println!(" Your changes are still in your working directory.");
} else {
println!(" {} Failed: {}", "✖".red(), result.stderr);
}
println!();
return;
}
Ok(1) => {
}
_ => {
println!(" Cancelled.");
println!();
return;
}
}
println!();
}
let action_str = git::last_action_type();
if action_str.is_none() {
println!(" {} No recent actions found to undo.", "⚠".yellow());
println!();
return;
}
let action = detect_action(action_str.as_deref().unwrap());
println!(" {} Last action detected:", "⏪".to_string());
println!(
" {} {}",
action_icon(&action),
action_description(&action).bold()
);
println!();
match action {
Action::Commit { ref message, is_amend, is_merge, is_initial } => {
undo_commit(message, is_amend, is_merge, is_initial);
}
Action::Pull | Action::Merge { .. } => {
undo_merge_or_pull(&action);
}
Action::Checkout { ref from, ref to } => {
undo_checkout(from, to);
}
Action::Rebase => {
undo_generic("rebase", "ORIG_HEAD");
}
Action::CherryPick => {
undo_generic("cherry-pick", "HEAD~1");
}
Action::Reset => {
undo_via_reflog("reset");
}
Action::StashApply | Action::Other { .. } => {
undo_via_reflog("last action");
}
}
}
fn undo_commit(_message: &str, is_amend: bool, _is_merge: bool, is_initial: bool) {
show_commit_preview();
let verb = if is_amend { "amend" } else { "commit" };
let options = &[
format!("Undo {}, keep all changes staged", verb),
format!("Undo {}, keep changes unstaged", verb),
format!("Undo {} and discard all changes", verb),
"Cancel".to_string(),
];
let refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
let selection = Select::new()
.with_prompt(" What would you like to do?")
.items(&refs)
.default(0)
.interact();
let selection = match selection {
Ok(s) => s,
Err(_) => {
println!(" Cancelled.");
println!();
return;
}
};
println!();
match selection {
0 => {
let ok = if is_initial {
let r = git::run(&["update-ref", "-d", "HEAD"]);
r.success
} else {
git::run(&["reset", "--soft", "HEAD~1"]).success
};
if ok {
println!(" {} Commit undone.", "✔".green().bold());
println!(" Your changes are staged and ready to re-commit.");
} else {
println!(" {} Failed to undo commit.", "✖".red());
}
}
1 => {
let ok = if is_initial {
let r = git::run(&["update-ref", "-d", "HEAD"]);
if r.success { git::run(&["rm", "-r", "--cached", "."]).success } else { false }
} else {
git::run(&["reset", "--mixed", "HEAD~1"]).success
};
if ok {
println!(" {} Commit undone.", "✔".green().bold());
println!(" Your changes are in your working directory (unstaged).");
} else {
println!(" {} Failed to undo commit.", "✖".red());
}
}
2 => {
println!(" {} This will permanently discard these changes:", "⚠ WARNING".red().bold());
show_commit_preview();
let confirm = Confirm::new()
.with_prompt(" Are you sure? This cannot be undone")
.default(false)
.interact();
match confirm {
Ok(true) => {
let ok = if is_initial {
let r = git::run(&["update-ref", "-d", "HEAD"]);
if r.success {
git::run(&["rm", "-r", "--cached", "."]).success;
git::run(&["clean", "-fd"]).success
} else { false }
} else {
git::run(&["reset", "--hard", "HEAD~1"]).success
};
if ok {
println!(" {} Commit undone and changes discarded.", "✔".green().bold());
} else {
println!(" {} Failed to undo commit.", "✖".red());
}
}
_ => {
println!(" Cancelled. Nothing was changed.");
}
}
}
_ => {
println!(" Cancelled.");
}
}
println!();
}
fn undo_merge_or_pull(action: &Action) {
let diff = git::run(&["diff", "--stat", "HEAD@{1}", "HEAD"]);
if diff.success && !diff.stdout.is_empty() {
println!(" {}:", "Changes that came in".dimmed());
for line in diff.stdout.lines().take(10) {
println!(" {}", line.dimmed());
}
println!();
}
let label = match action {
Action::Pull => "pull",
Action::Merge { .. } => "merge",
_ => "action",
};
let options = &[
format!("Undo {} (reset to state before)", label),
"Cancel".to_string(),
];
let refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
let selection = Select::new()
.with_prompt(" What would you like to do?")
.items(&refs)
.default(0)
.interact();
match selection {
Ok(0) => {
println!();
let result = git::run(&["reset", "--hard", "HEAD@{1}"]);
if result.success {
println!(" {} {} undone.", "✔".green().bold(), label);
println!(" You're back to the state before the {}.", label);
} else {
println!(" {} Failed: {}", "✖".red(), result.stderr);
}
}
_ => {
println!(" Cancelled.");
}
}
println!();
}
fn undo_checkout(from: &str, to: &str) {
println!(" You switched from '{}' to '{}'.", from.dimmed(), to.cyan());
println!();
let options = &[
format!("Switch back to '{}'", from),
"Cancel".to_string(),
];
let refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
let selection = Select::new()
.with_prompt(" What would you like to do?")
.items(&refs)
.default(0)
.interact();
match selection {
Ok(0) => {
println!();
let result = git::run(&["checkout", from]);
if result.success {
println!(" {} Switched back to '{}'.", "✔".green().bold(), from.cyan());
} else {
println!(" {} Failed: {}", "✖".red(), result.stderr);
}
}
_ => {
println!(" Cancelled.");
}
}
println!();
}
fn undo_generic(action_name: &str, reset_target: &str) {
show_rewind_preview("HEAD@{1}");
let options = &[
format!("Undo {} (reset to before)", action_name),
"Cancel".to_string(),
];
let refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
let selection = Select::new()
.with_prompt(" What would you like to do?")
.items(&refs)
.default(0)
.interact();
match selection {
Ok(0) => {
println!();
let result = git::run(&["reset", "--hard", reset_target]);
if result.success {
println!(" {} {} undone.", "✔".green().bold(), action_name);
} else {
let result = git::run(&["reset", "--hard", "HEAD@{1}"]);
if result.success {
println!(" {} {} undone.", "✔".green().bold(), action_name);
} else {
println!(" {} Failed: {}", "✖".red(), result.stderr);
}
}
}
_ => {
println!(" Cancelled.");
}
}
println!();
}
fn undo_via_reflog(action_name: &str) {
show_rewind_preview("HEAD@{1}");
let options = &[
format!("Rewind to state before {}", action_name),
"Cancel".to_string(),
];
let refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
let selection = Select::new()
.with_prompt(" What would you like to do?")
.items(&refs)
.default(0)
.interact();
match selection {
Ok(0) => {
println!();
let confirm = Confirm::new()
.with_prompt(format!(
" {} This will hard reset. Continue?",
"⚠".yellow()
))
.default(false)
.interact();
match confirm {
Ok(true) => {
let result = git::run(&["reset", "--hard", "HEAD@{1}"]);
if result.success {
println!(" {} Rewound to previous state.", "✔".green().bold());
} else {
println!(" {} Failed: {}", "✖".red(), result.stderr);
}
}
_ => {
println!(" Cancelled.");
}
}
}
_ => {
println!(" Cancelled.");
}
}
println!();
}
fn show_commit_preview() {
let stat = git::run(&["diff", "--stat", "HEAD~1", "HEAD"]);
if stat.success && !stat.stdout.is_empty() {
println!(" {}:", "Files in this commit".dimmed());
for line in stat.stdout.lines() {
let trimmed = line.trim();
if trimmed.is_empty() { continue; }
if trimmed.contains('|') {
let parts: Vec<&str> = trimmed.splitn(2, '|').collect();
let filename = parts[0].trim();
let changes = parts.get(1).map(|s| s.trim()).unwrap_or("");
println!(" {} {}", filename, changes.dimmed());
} else {
println!(" {}", trimmed.dimmed());
}
}
println!();
}
}
fn show_rewind_preview(target: &str) {
let diff = git::run(&["diff", "--stat", target, "HEAD"]);
if diff.success && !diff.stdout.is_empty() {
println!(" {}:", "Changes that will be undone".yellow());
for line in diff.stdout.lines().take(10) {
let trimmed = line.trim();
if !trimmed.is_empty() {
println!(" {}", trimmed.dimmed());
}
}
let total_lines: Vec<&str> = diff.stdout.lines().collect();
if total_lines.len() > 10 {
println!(" {}", format!("(+{} more files)", total_lines.len() - 10).dimmed());
}
println!();
}
}