use crate::fleet::error::Result;
use crate::fleet::git::{GitOps, SubmoduleTree};
use crate::fleet::output::Formatter;
use crate::git_backend::{self, FileStatusKind};
use crate::prelude::*;
use colored::Colorize;
pub struct Options {
pub untracked: bool,
pub assume_yes: bool,
pub dry_run: bool,
}
struct Dirty {
label: String,
path: PathBuf,
tracked: Vec<String>,
untracked: Vec<String>,
moved_submodules: Vec<String>,
}
impl Dirty {
fn has_anything(&self) -> bool {
!self.tracked.is_empty() || !self.untracked.is_empty() || !self.moved_submodules.is_empty()
}
}
pub fn execute<P: AsRef<Path>>(root: P, options: Options) -> Result<()> {
let root = root.as_ref();
let dirty = find_dirty(root)?;
if dirty.is_empty() {
Formatter::print_success("nothing to discard: every worktree is clean");
return Ok(());
}
report(&dirty, &options);
if options.dry_run {
Formatter::print_info("--dry-run: nothing was discarded");
return Ok(());
}
if !options.assume_yes && !confirmed(&dirty, &options)? {
Formatter::print_info("nothing was discarded");
return Ok(());
}
discard(&dirty, &options)
}
fn find_dirty(root: &Path) -> Result<Vec<Dirty>> {
let tree = SubmoduleTree::discover_allow_empty(root)?;
let root = &resolved(root);
let mut worktrees: Vec<(String, PathBuf)> = vec![(".".to_string(), root.clone())];
for node in tree.top_down() {
if node.initialized {
worktrees.push((node.display_path.clone(), resolved(&node.abs_path)));
}
}
let submodule_paths = |repository: &Path| -> Vec<String> {
tree.top_down()
.iter()
.filter(|node| resolved(&node.parent_path) == repository)
.map(|node| node.rel_path.to_string_lossy().replace('\\', "/"))
.collect()
};
let mut dirty = Vec::new();
for (label, path) in worktrees {
let repo = GitOps::open_repo(&path)?;
let entries = git_backend::status_entries(&repo).map_err(|error| {
crate::fleet::error::SuperGitError::Other(format!(
"cannot read the status of {}: {error}",
path.display()
))
})?;
let submodules = submodule_paths(&path);
let mut found = Dirty {
label,
path,
tracked: Vec::new(),
untracked: Vec::new(),
moved_submodules: Vec::new(),
};
for entry in entries {
match entry.status {
FileStatusKind::Ignored => {}
FileStatusKind::Untracked => found.untracked.push(entry.path),
_ if submodules.iter().any(|sub| *sub == entry.path) => {
found.moved_submodules.push(entry.path)
}
_ => found.tracked.push(entry.path),
}
}
if found.has_anything() {
dirty.push(found);
}
}
Ok(dirty)
}
fn resolved(path: &Path) -> PathBuf {
crate::platform::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf())
}
fn report(dirty: &[Dirty], options: &Options) {
crate::outln!("\n{}", "Local changes:".bold().underline());
crate::outln!();
for worktree in dirty {
crate::outln!(" {} {}", "●".yellow(), worktree.label.bold());
for path in &worktree.tracked {
crate::outln!(" {} {path}", "discard".red());
}
for path in &worktree.untracked {
if options.untracked {
crate::outln!(" {} {path}", "delete ".red());
} else {
crate::outln!(" {} {path}", "keep ".dimmed());
}
}
for path in &worktree.moved_submodules {
crate::outln!(" {} {path} (submodule at another commit)", "keep ".dimmed());
}
}
let tracked: usize = dirty.iter().map(|w| w.tracked.len()).sum();
let untracked: usize = dirty.iter().map(|w| w.untracked.len()).sum();
let moved: usize = dirty.iter().map(|w| w.moved_submodules.len()).sum();
crate::outln!();
if tracked > 0 {
crate::outln!(
" {tracked} tracked file(s) across {} worktree(s) will be restored to \
HEAD. This cannot be undone.",
dirty.len()
);
} else {
crate::outln!(" No tracked file has been changed.");
}
if untracked > 0 {
if options.untracked {
crate::outln!(" {untracked} untracked file(s) will be DELETED.");
} else {
crate::outln!(
" {untracked} untracked file(s) are left alone; pass {} to delete them too.",
"--untracked".bold()
);
}
}
if moved > 0 {
crate::outln!(
" {moved} submodule(s) are checked out at another commit. That is not \
a file change and this does not move them back; `git submodule update \
--recursive` restores the recorded commits."
);
}
crate::outln!();
}
fn confirmed(dirty: &[Dirty], options: &Options) -> Result<bool> {
use std::io::{BufRead, IsTerminal, Write};
if !std::io::stdin().is_terminal() {
return Err(crate::fleet::error::SuperGitError::Other(format!(
"there is no terminal to confirm at, and {} worktree(s) have local changes. \
Pass --yes to discard them without being asked, or --dry-run to see what \
would go.",
dirty.len()
)));
}
let what = if options.untracked {
"discard these changes and delete the untracked files"
} else {
"discard these changes"
};
crate::out!(" Type 'yes' to {what}: ");
let _ = std::io::stdout().flush();
let mut answer = String::new();
if std::io::stdin().lock().read_line(&mut answer).is_err() {
return Ok(false);
}
Ok(answer.trim() == "yes")
}
fn discard(dirty: &[Dirty], options: &Options) -> Result<()> {
let mut failed = Vec::new();
for worktree in dirty {
if worktree.tracked.is_empty() && !(options.untracked && !worktree.untracked.is_empty()) {
continue;
}
let outcome = (|| -> anyhow::Result<()> {
let repo = git_backend::open_repo(&worktree.path)?;
if !worktree.tracked.is_empty() {
git_backend::discard_tracked_changes(&repo)?;
}
if options.untracked && !worktree.untracked.is_empty() {
git_backend::remove_untracked_files(&repo)?;
}
Ok(())
})();
match outcome {
Ok(()) => Formatter::print_success(&format!("discarded: {}", worktree.label)),
Err(error) => {
Formatter::print_error(&format!("{}: {error}", worktree.label));
failed.push(worktree.label.clone());
}
}
}
if failed.is_empty() {
Ok(())
} else {
Err(crate::fleet::error::SuperGitError::Other(format!(
"could not discard in {} worktree(s): {}",
failed.len(),
failed.join(", ")
)))
}
}
#[cfg(test)]
#[path = "discard_tests.rs"]
mod tests;