Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/tryke/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,17 @@ pub enum Commands {
python: Option<String>,
},

/// Remove tryke's persistent discovery cache.
///
/// Deletes the default `<project-root>/.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<PathBuf>,
},

/// Print the import dependency graph for the project.
///
/// Renders the static import graph that drives discovery, change
Expand Down
35 changes: 35 additions & 0 deletions crates/tryke/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
133 changes: 132 additions & 1 deletion crates/tryke_discovery/src/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<project-root>/.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<CleanCacheReport> {
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<CleanCacheReport> {
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<CleanCacheReport> {
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`.
///
Expand Down Expand Up @@ -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");
Expand Down
2 changes: 2 additions & 0 deletions crates/tryke_discovery/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
39 changes: 39 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -44,6 +45,44 @@ tryke [OPTIONS] [COMMAND]

Increase logging verbosity

### `tryke clean`

Remove tryke's persistent discovery cache.

Deletes the default `<project-root>/.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` `<CACHE_DIR>`

Directory for tryke's persistent discovery cache.

Overrides `[tool.tryke] cache_dir` in `pyproject.toml`. Defaults to `<project-root>/.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` `<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.
Expand Down
Loading