use std::path::{Path, PathBuf};
use anyhow::{Context, Result, bail};
use inquire::Confirm;
use owo_colors::OwoColorize;
use crate::actions::{VcsKind, detect_vcs};
use crate::cli::RmArgs;
use crate::commands::ShokaContext;
use crate::commands::cd::{choose_by_hint, fuzzy_pick};
use crate::config::ShokaConfig;
use crate::git_status;
use crate::state::{Repo, Shelf};
pub async fn run(ctx: &ShokaContext, args: RmArgs) -> Result<()> {
let cfg = ShokaConfig::load(&ctx.paths)?;
let resolved = cfg.resolve(ctx.profile_override.as_deref())?;
let mut shelf = Shelf::load(&ctx.paths)?;
if shelf.is_empty() {
bail!("shelf is empty — nothing to remove");
}
let tag_filtered = shelf.filter_by_tags(&args.tags);
if tag_filtered.is_empty() {
bail!(
"no repos matched the tag filter ({} on the shelf total)",
shelf.len()
);
}
let page_size = resolved.ui.cd_page_size;
let chosen = match args.repo.as_deref() {
Some(hint) => choose_by_hint(&tag_filtered, hint, page_size)?,
None => fuzzy_pick(&tag_filtered, "remove:", page_size)?,
};
let id = RepoId::of(chosen);
let path = resolved.clone_path_for_one(chosen)?;
let slug = id.slug();
if args.dry_run {
let fate = if args.keep_files {
"drop from shelf (keep files)"
} else {
"delete working tree + drop from shelf"
};
println!(
"{} would {} {} {} {}",
"rm:".bold(),
fate,
slug.bold(),
"→".dimmed(),
path.display().dimmed()
);
println!(
"{} dry run — re-run without `--dry-run` to remove",
"rm:".bold()
);
return Ok(());
}
if !args.keep_files && !args.force {
match cleanliness(&path).await {
Cleanliness::Dirty => {
println!(
"{} {} has uncommitted changes",
"rm:".bold().yellow(),
slug.bold()
);
if args.yes {
bail!(
"{slug} has uncommitted changes — commit or stash them, \
or re-run with --force to delete anyway"
);
}
}
Cleanliness::Clean => {}
Cleanliness::Unknown(why) => {
tracing::debug!(
target: "shoka",
"cleanliness check for {slug} inconclusive: {why}"
);
}
}
}
if !args.yes {
let question = if args.keep_files {
format!(
"Drop {slug} from the shelf (leaving {} on disk)?",
path.display()
)
} else {
format!(
"Delete {slug} at {} (working tree + shelf entry)?",
path.display()
)
};
let confirmed = Confirm::new(&question)
.with_default(false)
.prompt()
.context("rm confirmation cancelled")?;
if !confirmed {
println!("{} aborted — shelf unchanged", "rm:".bold().yellow());
return Ok(());
}
}
let outcome = remove_entry(&mut shelf, &id, &path, args.keep_files)?;
shelf.save(&ctx.paths)?;
let what = match outcome {
Outcome::Deleted => "removed (working tree deleted)",
Outcome::AlreadyGone => "removed (working tree was already missing)",
Outcome::KeptFiles => "removed from shelf (files kept on disk)",
};
println!(
"{} {} {} ({} on shelf now)",
"rm:".bold().green(),
slug.bold(),
what,
shelf.len()
);
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Outcome {
Deleted,
AlreadyGone,
KeptFiles,
}
struct RepoId {
host: String,
owner: String,
name: String,
path: Option<PathBuf>,
}
impl RepoId {
fn of(repo: &Repo) -> Self {
Self {
host: repo.host.clone(),
owner: repo.owner.clone(),
name: repo.name.clone(),
path: repo.path.clone(),
}
}
fn slug(&self) -> String {
format!("{}/{}/{}", self.host, self.owner, self.name)
}
}
fn remove_entry(shelf: &mut Shelf, id: &RepoId, path: &Path, keep_files: bool) -> Result<Outcome> {
if shelf
.remove_by_path(&id.host, &id.owner, &id.name, id.path.as_deref())
.is_none()
{
bail!(
"internal: {} vanished from the shelf before removal",
id.slug()
);
}
if keep_files {
Ok(Outcome::KeptFiles)
} else {
delete_tree(path)
}
}
fn delete_tree(path: &Path) -> Result<Outcome> {
match std::fs::symlink_metadata(path) {
Ok(meta) if meta.is_dir() => {
std::fs::remove_dir_all(path)
.with_context(|| format!("deleting working tree {}", path.display()))?;
Ok(Outcome::Deleted)
}
Ok(_) => {
std::fs::remove_file(path).with_context(|| format!("deleting {}", path.display()))?;
Ok(Outcome::Deleted)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::warn!(
target: "shoka",
"clone path {} already missing — removing the shelf entry only",
path.display()
);
Ok(Outcome::AlreadyGone)
}
Err(e) => Err(e).with_context(|| format!("inspecting clone path {}", path.display())),
}
}
#[derive(Debug)]
enum Cleanliness {
Clean,
Dirty,
Unknown(String),
}
async fn cleanliness(path: &Path) -> Cleanliness {
match detect_vcs(path) {
Some(VcsKind::Git) => match git_status::is_dirty_at(path) {
Ok(true) => Cleanliness::Dirty,
Ok(false) => Cleanliness::Clean,
Err(e) => Cleanliness::Unknown(format!("git status read failed: {e}")),
},
Some(VcsKind::Jj) => jj_cleanliness(path).await,
None => Cleanliness::Unknown("no .git or .jj marker at clone path".into()),
}
}
async fn jj_cleanliness(path: &Path) -> Cleanliness {
let mut cmd = tokio::process::Command::new("jj");
cmd.arg("diff")
.arg("--summary")
.current_dir(path)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
#[cfg(windows)]
cmd.creation_flags(crate::silent_creation_flags());
match cmd.output().await {
Ok(out) if out.status.success() => {
if out.stdout.iter().any(|b| !b.is_ascii_whitespace()) {
Cleanliness::Dirty
} else {
Cleanliness::Clean
}
}
Ok(out) => Cleanliness::Unknown(format!(
"`jj diff` exited {}: {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Cleanliness::Unknown("jj not found on PATH".into())
}
Err(e) => Cleanliness::Unknown(format!("spawning `jj diff` failed: {e}")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn seed(shelf: &mut Shelf, name: &str) -> RepoId {
let repo = Repo::new("github.com", "yukimemi", name);
let id = RepoId::of(&repo);
shelf.add(repo).unwrap();
id
}
#[test]
fn remove_entry_deletes_dir_and_drops_entry() {
let tmp = TempDir::new().unwrap();
let clone = tmp.path().join("github.com/yukimemi/shoka");
std::fs::create_dir_all(&clone).unwrap();
std::fs::write(clone.join("README.md"), "hi").unwrap();
let mut shelf = Shelf::default();
let id = seed(&mut shelf, "shoka");
let outcome = remove_entry(&mut shelf, &id, &clone, false).unwrap();
assert_eq!(outcome, Outcome::Deleted);
assert!(!clone.exists(), "working tree should be gone");
assert!(shelf.is_empty(), "shelf entry should be dropped");
}
#[test]
fn remove_entry_missing_dir_is_already_gone_not_error() {
let tmp = TempDir::new().unwrap();
let clone = tmp.path().join("never-existed");
let mut shelf = Shelf::default();
let id = seed(&mut shelf, "ghost");
let outcome = remove_entry(&mut shelf, &id, &clone, false).unwrap();
assert_eq!(outcome, Outcome::AlreadyGone);
assert!(shelf.is_empty(), "orphaned entry still reaped");
}
#[test]
fn remove_entry_keep_files_leaves_dir() {
let tmp = TempDir::new().unwrap();
let clone = tmp.path().join("github.com/yukimemi/renri");
std::fs::create_dir_all(&clone).unwrap();
let mut shelf = Shelf::default();
let id = seed(&mut shelf, "renri");
let outcome = remove_entry(&mut shelf, &id, &clone, true).unwrap();
assert_eq!(outcome, Outcome::KeptFiles);
assert!(clone.exists(), "--keep-files must not touch the directory");
assert!(shelf.is_empty(), "shelf entry still dropped");
}
#[test]
fn remove_entry_only_drops_the_targeted_checkout() {
let tmp = TempDir::new().unwrap();
let clone_a = tmp.path().join("a");
let clone_b = tmp.path().join("b");
std::fs::create_dir_all(&clone_a).unwrap();
std::fs::create_dir_all(&clone_b).unwrap();
let mut shelf = Shelf::default();
let a = Repo::new("github.com", "yukimemi", "dup").with_path(clone_a.clone());
let b = Repo::new("github.com", "yukimemi", "dup").with_path(clone_b.clone());
let id_a = RepoId::of(&a);
shelf.add(a).unwrap();
shelf.add(b).unwrap();
remove_entry(&mut shelf, &id_a, &clone_a, false).unwrap();
assert_eq!(shelf.len(), 1, "only the targeted checkout is removed");
assert_eq!(shelf.repos[0].path.as_deref(), Some(clone_b.as_path()));
assert!(!clone_a.exists());
assert!(clone_b.exists());
}
#[tokio::test]
async fn cleanliness_unknown_when_no_vcs_marker() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("plain");
std::fs::create_dir_all(&dir).unwrap();
assert!(matches!(cleanliness(&dir).await, Cleanliness::Unknown(_)));
}
fn git_repo_with_commit(dir: &Path) -> bool {
std::fs::create_dir_all(dir).unwrap();
let git = |args: &[&str]| {
std::process::Command::new("git")
.args(args)
.current_dir(dir)
.output()
};
if git(&["init", "-q"]).is_err() {
return false;
}
let _ = git(&["config", "user.email", "t@example.com"]);
let _ = git(&["config", "user.name", "tester"]);
let _ = git(&["config", "commit.gpgsign", "false"]);
std::fs::write(dir.join("tracked.txt"), "v1\n").unwrap();
let _ = git(&["add", "."]);
let out = git(&["commit", "-q", "--no-gpg-sign", "-m", "init"]).unwrap();
out.status.success()
}
#[tokio::test]
async fn cleanliness_flags_a_dirty_git_tree() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("repo");
if !git_repo_with_commit(&dir) {
return; }
std::fs::write(dir.join("tracked.txt"), "v2 — edited\n").unwrap();
assert!(
matches!(cleanliness(&dir).await, Cleanliness::Dirty),
"a modified tracked file should read as dirty"
);
}
#[tokio::test]
async fn cleanliness_clean_git_tree_is_clean() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path().join("repo");
if !git_repo_with_commit(&dir) {
return; }
assert!(
matches!(cleanliness(&dir).await, Cleanliness::Clean),
"an unmodified committed repo should read as clean"
);
}
#[test]
fn delete_tree_unlinks_a_file_at_the_path() {
let tmp = TempDir::new().unwrap();
let weird = tmp.path().join("not-a-repo");
std::fs::write(&weird, "i am a file").unwrap();
let outcome = delete_tree(&weird).unwrap();
assert_eq!(outcome, Outcome::Deleted);
assert!(!weird.exists());
}
}