wsb 0.275.7622

Workspace tools for every project under $PRJ: git across submodules, template stamping, GitHub, and the workbench
Documentation
//! `wsb discard` — throw away local changes, everywhere in the fleet.
//!
//! Two halves, and the first one is the point: FIND every worktree that has
//! local changes and say exactly what is in them, then ask. A fleet is
//! twenty-odd repositories and nobody holds all of them in their head, so a
//! command that discarded first and reported afterwards would be one an
//! operator could only audit by reading what it had already destroyed.
//!
//! # What "discard" means here
//!
//! Changes to TRACKED files: staged, unstaged, and deletions. That is
//! `git reset --hard`, and it is irreversible — the content was never
//! committed, so git holds no copy to go back to.
//!
//! Untracked files are shown but NOT removed unless `--untracked` says so.
//! They are the ones git has no copy of at all, and the ones most likely to be
//! something nobody meant to be disposable — a scratch file, a key, a capture
//! from a run. Ignored files are never touched by either: build output and
//! `.wsb/` state are ignored, and emptying those would be a different and much
//! larger command.

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;

/// How the operator answered, or that they were never asked.
pub struct Options {
    /// Remove untracked files too.
    pub untracked: bool,
    /// Do not ask. For a script that has already decided.
    pub assume_yes: bool,
    /// Report what would go, and stop.
    pub dry_run: bool,
}

/// One worktree with something to lose.
struct Dirty {
    /// What to call it in the report: `.` for the repository this was run in.
    label: String,
    path: PathBuf,
    /// Tracked files with changes — what `--hard` throws away.
    tracked: Vec<String>,
    /// Files git is not tracking. Only removed with `--untracked`.
    untracked: Vec<String>,
    /// Submodules sitting at a commit other than the recorded one. Counted
    /// separately because a reset does not move them back, and reporting them
    /// as though it did would be a lie about what this command did.
    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)
}

/// Every worktree under `root` that has local changes.
///
/// The superproject and every initialized submodule at every depth. An
/// uninitialized one has no worktree and therefore nothing to discard; a repo
/// this walk cannot open is reported rather than skipped, because "I could not
/// look" and "there was nothing there" must not read the same.
fn find_dirty(root: &Path) -> Result<Vec<Dirty>> {
    let tree = SubmoduleTree::discover_allow_empty(root)?;
    // One spelling of every path. The tree carries absolute paths resolved by
    // discovery while `root` is wherever the command was run from, and a
    // symlinked temp directory or a `..` in the way makes the two disagree —
    // at which point no submodule matches its parent and a moved one is
    // reported as an ordinary edited file.
    let root = &resolved(root);

    // Which paths in each repository are submodules, so a moved one is not
    // reported as an ordinary edited file.
    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 {
                // Ignored is not a local change. It is the state this command
                // is explicitly not about.
                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)
}

/// A path as the filesystem finally spells it, or as given when it cannot say.
///
/// A path that cannot be resolved is one that is not there, and this is used
/// for comparison rather than for opening anything — the open that follows
/// reports its own failure, with the path the operator recognises.
fn resolved(path: &Path) -> PathBuf {
    crate::platform::canonicalize(&path).unwrap_or_else(|_| path.to_path_buf())
}

/// What is about to be lost, per worktree.
///
/// Every path, not a count and not a sample: the operator is being asked to
/// approve destroying this, and a summary they cannot check is not something
/// they can consent to. A tree with a thousand changed files is a tree where
/// that scroll is the honest answer.
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!();
}

/// Ask, and accept only one answer.
///
/// The whole word, typed. `y` is what someone presses to get past a prompt
/// they have stopped reading, and this one destroys uncommitted work across
/// every repository at once.
///
/// Where there is no terminal to ask at, the command REFUSES rather than
/// assuming either answer: a script that means it says `--yes`, and one that
/// did not mean it must not lose a fleet's worth of work to a prompt nobody
/// saw.
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);
    }
    // Only an explicit yes. Anything else — including end of input — is no.
    Ok(answer.trim() == "yes")
}

/// Do it, worktree by worktree.
///
/// A failure in one is reported and the rest continue: the operator has
/// already approved discarding all of them, and stopping half way would leave
/// a fleet in a state that is neither what they had nor what they asked for.
/// The command still fails at the end, so nothing downstream treats a partial
/// discard as a clean one.
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()) {
            // Nothing here but a moved submodule, which this does not touch.
            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;