diff --git a/crates/tryke/src/cli.rs b/crates/tryke/src/cli.rs index c939884..197cec2 100644 --- a/crates/tryke/src/cli.rs +++ b/crates/tryke/src/cli.rs @@ -302,6 +302,17 @@ pub enum Commands { python: Option, }, + /// Remove tryke's persistent discovery cache. + /// + /// Deletes the default `/.tryke/cache` directory. When + /// `--cache-dir` or `[tool.tryke] cache_dir` points at a custom directory, + /// only tryke-owned cache files inside that directory are removed. + Clean { + /// Project root used to resolve the default cache directory. + #[arg(long)] + root: Option, + }, + /// Print the import dependency graph for the project. /// /// Renders the static import graph that drives discovery, change diff --git a/crates/tryke/src/main.rs b/crates/tryke/src/main.rs index 4801586..a01b982 100644 --- a/crates/tryke/src/main.rs +++ b/crates/tryke/src/main.rs @@ -284,6 +284,26 @@ fn main() -> Result<()> { ); rt.block_on(server.run()) } + Commands::Clean { root } => { + let cwd = env::current_dir()?; + let root_path = root.as_deref().unwrap_or(&cwd); + let config = tryke_config::load_effective_config(root_path); + let resolved_cache_dir = tryke_config::resolve_cache_dir(cache_dir.as_deref(), &config); + let report = + tryke_discovery::clean_project_cache(root_path, resolved_cache_dir.as_deref())?; + if report.removed_entries == 0 { + println!( + "No tryke discovery cache found at {}", + report.cache_dir.display() + ); + } else { + println!( + "Cleaned tryke discovery cache at {}", + report.cache_dir.display() + ); + } + Ok(()) + } Commands::Graph { root, exclude, @@ -928,6 +948,21 @@ mod tests { assert!(result.is_err(), "--now without --watch should error"); } + #[test] + fn clean_subcommand_parsed() { + let cli = Cli::try_parse_from(["tryke", "clean"]).unwrap(); + assert!(matches!(command(&cli), Commands::Clean { root: None })); + } + + #[test] + fn clean_root_flag_parsed() { + let cli = Cli::try_parse_from(["tryke", "clean", "--root", "/tmp/project"]).unwrap(); + assert!(matches!( + command(&cli), + Commands::Clean { root: Some(p) } if p == &PathBuf::from("/tmp/project") + )); + } + #[test] fn graph_subcommand_parsed() { let cli = Cli::try_parse_from(["tryke", "graph"]).unwrap(); diff --git a/crates/tryke_discovery/src/cache.rs b/crates/tryke_discovery/src/cache.rs index 318ff07..976b575 100644 --- a/crates/tryke_discovery/src/cache.rs +++ b/crates/tryke_discovery/src/cache.rs @@ -34,7 +34,7 @@ const CACHE_VERSION: u32 = 3; /// Name of the cache file within its directory. The stem is also reused /// (with a `.tmp` extension) for the atomic write in `save`. -const CACHE_FILE_NAME: &str = "discovery-v1.bin"; +pub const CACHE_FILE_NAME: &str = "discovery-v1.bin"; /// Identity of a source file derived from `stat`. Cheap to obtain /// without reading the file contents. @@ -91,6 +91,66 @@ struct GitignoreConfig { contents: &'static str, } +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct CleanCacheReport { + pub cache_dir: PathBuf, + pub removed_entries: usize, +} + +/// Remove tryke's persistent discovery cache for `start`. +/// +/// When `cache_dir` is `None`, the default cache lives under tryke's owned +/// state directory at `/.tryke/cache`, so the whole cache +/// directory can be removed. `start` is resolved the same way discovery finds a +/// project root: walk up to the nearest `pyproject.toml`, then fall back to +/// `start` when no project file exists. When a custom cache directory is +/// configured, only tryke-owned cache files are removed to avoid deleting +/// unrelated user data. +/// +/// # Errors +/// +/// Returns any filesystem error encountered while deleting the cache directory +/// or cache files, except missing cache paths which are treated as already +/// clean. +pub fn clean_project_cache(start: &Path, cache_dir: Option<&Path>) -> io::Result { + match cache_dir { + Some(cache_dir) => clean_custom_cache_dir(cache_dir), + None => clean_default_cache_dir(start), + } +} + +fn clean_default_cache_dir(start: &Path) -> io::Result { + let root = crate::find_project_root(start).unwrap_or_else(|| start.to_path_buf()); + let root = root.canonicalize().unwrap_or(root); + let cache_dir = root.join(".tryke").join("cache"); + let removed_entries = match fs::remove_dir_all(&cache_dir) { + Ok(()) => 1, + Err(err) if err.kind() == io::ErrorKind::NotFound => 0, + Err(err) => return Err(err), + }; + Ok(CleanCacheReport { + cache_dir, + removed_entries, + }) +} + +fn clean_custom_cache_dir(cache_dir: &Path) -> io::Result { + let cache_file = cache_dir.join(CACHE_FILE_NAME); + let tmp_file = cache_file.with_extension("tmp"); + let mut removed_entries = 0; + for path in [&cache_file, &tmp_file] { + match fs::remove_file(path) { + Ok(()) => removed_entries += 1, + Err(err) if err.kind() == io::ErrorKind::NotFound => {} + Err(err) => return Err(err), + } + } + Ok(CleanCacheReport { + cache_dir: cache_dir.to_path_buf(), + removed_entries, + }) +} + impl DiskCache { /// Load a cache from an explicit `path`, writing no `.gitignore`. /// @@ -293,6 +353,77 @@ mod tests { assert!(gitignore.lines().any(|line| line.trim() == "/.gitignore")); } + #[test] + fn clean_default_cache_removes_owned_state_cache_dir() { + let dir = tempfile::tempdir().expect("tempdir"); + let state_dir = dir + .path() + .canonicalize() + .expect("canonical tempdir") + .join(".tryke"); + let cache_dir = state_dir.join("cache"); + fs::create_dir_all(&cache_dir).expect("create cache dir"); + fs::write(cache_dir.join("discovery-v1.bin"), b"cache").expect("write cache"); + fs::write(cache_dir.join("future-cache.bin"), b"future").expect("write future cache"); + fs::write(state_dir.join(".gitignore"), b"# created by tryke\n*\n") + .expect("write gitignore"); + + let report = clean_project_cache(dir.path(), None).expect("clean cache"); + + assert_eq!(report.cache_dir, cache_dir); + assert_eq!(report.removed_entries, 1); + assert!(!report.cache_dir.exists()); + assert!(state_dir.join(".gitignore").exists()); + } + + #[test] + fn clean_default_cache_resolves_project_root_from_subdir() { + let dir = tempfile::tempdir().expect("tempdir"); + fs::write( + dir.path().join("pyproject.toml"), + b"[project]\nname = \"sample\"\n", + ) + .expect("write pyproject"); + let subdir = dir.path().join("src").join("pkg"); + fs::create_dir_all(&subdir).expect("create subdir"); + let project_root = dir.path().canonicalize().expect("canonical tempdir"); + let cache_dir = project_root.join(".tryke").join("cache"); + fs::create_dir_all(&cache_dir).expect("create cache dir"); + fs::write(cache_dir.join("discovery-v1.bin"), b"cache").expect("write cache"); + + let report = clean_project_cache(&subdir, None).expect("clean cache"); + + assert_eq!(report.cache_dir, cache_dir); + assert_eq!(report.removed_entries, 1); + assert!(!report.cache_dir.exists()); + } + + #[test] + fn clean_custom_cache_dir_removes_only_tryke_cache_files() { + let dir = tempfile::tempdir().expect("tempdir"); + let cache_dir = dir.path().join("custom-cache"); + fs::create_dir_all(&cache_dir).expect("create custom cache dir"); + fs::write(cache_dir.join("discovery-v1.bin"), b"cache").expect("write cache"); + fs::write(cache_dir.join("discovery-v1.tmp"), b"tmp").expect("write tmp cache"); + fs::write(cache_dir.join("keep-me.txt"), b"user data").expect("write user data"); + fs::write(cache_dir.join(".gitignore"), b"custom\n").expect("write user gitignore"); + + let report = clean_project_cache(dir.path(), Some(&cache_dir)).expect("clean custom cache"); + + assert_eq!(report.cache_dir, cache_dir); + assert_eq!(report.removed_entries, 2); + assert!(!report.cache_dir.join("discovery-v1.bin").exists()); + assert!(!report.cache_dir.join("discovery-v1.tmp").exists()); + assert_eq!( + fs::read_to_string(report.cache_dir.join("keep-me.txt")).expect("read user data"), + "user data" + ); + assert_eq!( + fs::read_to_string(report.cache_dir.join(".gitignore")).expect("read gitignore"), + "custom\n" + ); + } + #[test] fn roundtrip_with_entry() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/tryke_discovery/src/lib.rs b/crates/tryke_discovery/src/lib.rs index e869c66..2bd34f7 100644 --- a/crates/tryke_discovery/src/lib.rs +++ b/crates/tryke_discovery/src/lib.rs @@ -11,9 +11,11 @@ pub(crate) mod cache; pub(crate) mod db; mod discoverer; pub(crate) mod import_graph; +pub use cache::{CleanCacheReport, clean_project_cache}; pub use discoverer::Discoverer; use ignore::WalkBuilder; + use ruff_python_ast::{Expr, Stmt}; use ruff_python_parser::parse_module; use ruff_source_file::LineIndex; diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 1bcc264..d2fbc6c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -18,6 +18,7 @@ tryke [OPTIONS] [COMMAND] **Commands:** +- [`tryke clean`](#tryke-clean) — Remove tryke's persistent discovery cache - [`tryke graph`](#tryke-graph) — Print the import dependency graph for the project - [`tryke server`](#tryke-server) — Start a persistent worker server - [`tryke test`](#tryke-test) — Collect and run tests. @@ -44,6 +45,44 @@ tryke [OPTIONS] [COMMAND] Increase logging verbosity +### `tryke clean` + +Remove tryke's persistent discovery cache. + +Deletes the default `/.tryke/cache` directory. When `--cache-dir` or `[tool.tryke] cache_dir` points at a custom directory, only tryke-owned cache files inside that directory are removed. + +**Usage:** + +```text +tryke clean [OPTIONS] +``` + +**Options:** + +- `--cache-dir` `` + + Directory for tryke's persistent discovery cache. + + Overrides `[tool.tryke] cache_dir` in `pyproject.toml`. Defaults to `/.tryke/cache`. + +- `--no-progress` + + Disable the terminal's native graphical progress bar. + + By default tryke emits OSC 9;4 progress sequences, which terminals like Ghostty, WezTerm, iTerm2, Windows Terminal, and ConEmu render as a native progress indicator (taskbar badge, tab badge, etc.). Pass this flag in CI or in terminals that mis-render the sequence. + +- `-q`, `--quiet` + + Decrease logging verbosity + +- `--root` `` + + Project root used to resolve the default cache directory + +- `-v`, `--verbose` + + Increase logging verbosity + ### `tryke graph` Print the import dependency graph for the project.