From cc590f28670e2e574a7afecf61955d618923aecc Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Mon, 9 Feb 2026 12:58:15 +0000 Subject: [PATCH 1/8] Initial plan From 3082b48a1f6675d46fe8549e0df78edeed5115c5 Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Mon, 9 Feb 2026 13:04:17 +0000 Subject: [PATCH 2/8] feat: add remote repository command --- Cargo.toml | 2 +- README.md | 8 +++ src/cli.rs | 12 +++++ src/main.rs | 8 +++ src/repository.rs | 58 +++++++++++++++++++++ src/tests/mod.rs | 2 + src/tests/repository_tests.rs | 94 +++++++++++++++++++++++++++++++++++ 7 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 src/repository.rs create mode 100644 src/tests/repository_tests.rs diff --git a/Cargo.toml b/Cargo.toml index deec0e1..957ba73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,6 @@ ignore = "0.4.23" serde = { version = "1.0.228", features = ["derive"] } serde_yaml = "0.9.34" walkdir = "2.5.0" +tempfile = "3.19.1" [dev-dependencies] -tempfile = "3.19.1" diff --git a/README.md b/README.md index 9405e17..dbdda4e 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,8 @@ USAGE: OPTIONS: -d, --dir Sets the input directory [default: .] -o, --output Sets the output file [default: fyai.txt] + --repo Clone a git repository (e.g., GitHub/GitLab) into a temporary directory before processing + --repo-branch Branch, tag, or commit to checkout when using --repo --include-dirs Comma-separated list of directories to include (e.g., src,docs) -x, --exclude-dirs Comma-separated list of directories to exclude (e.g., node_modules,dist) --include-files Comma-separated list of files to include (e.g., README.md,main.rs) @@ -155,6 +157,12 @@ CONFIG FILE SUPPORT: fyai --respect-gitignore false ``` +- Run against a remote GitHub/GitLab repository without leaving the clone on disk: + + ```bash + fyai --repo https://github.com/owner/repo.git --repo-branch main + ``` + ## Output Format The combined file includes headers for each source file: diff --git a/src/cli.rs b/src/cli.rs index cb6ea58..06afe57 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -20,6 +20,18 @@ pub fn create_commands() -> Command { .help("Sets the output file") .default_value("fyai.txt"), ) + .arg( + Arg::new("repo") + .long("repo") + .value_name("URL") + .help("Clone a git repository (GitHub/GitLab) into a temporary directory before processing"), + ) + .arg( + Arg::new("repo_branch") + .long("repo-branch") + .value_name("BRANCH") + .help("Branch, tag, or commit to checkout when using --repo"), + ) .arg( Arg::new("include_dirs") .long("include-dirs") diff --git a/src/main.rs b/src/main.rs index 6df30ed..7a62c81 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,6 +14,7 @@ mod config; mod data; mod file_processing; mod gitignore; +mod repository; /// Run the core application logic using a fully-resolved `Config`. /// @@ -113,6 +114,9 @@ fn main() -> io::Result<()> { return Ok(()); } + let repo_url = matches.get_one::("repo").cloned(); + let repo_branch = matches.get_one::("repo_branch").cloned(); + // Normal flow: parse CLI args and config file // `config_from_matches_with_explicit` returns both the parsed CLI `Config` and an // `ExplicitFlags` struct indicating which CLI options were explicitly set. @@ -140,6 +144,10 @@ fn main() -> io::Result<()> { // Merge configs (CLI takes precedence, but allow file to provide values when CLI didn't explicitly set them) let config = crate::config::merge_config(file_config, cli_config, explicit); + if let Some(repo_url) = repo_url { + return crate::repository::run_on_repository(&repo_url, repo_branch.as_deref(), config); + } + // Delegate to the extracted function so it can be tested in isolation. run_with_config(config) } diff --git a/src/repository.rs b/src/repository.rs new file mode 100644 index 0000000..247f200 --- /dev/null +++ b/src/repository.rs @@ -0,0 +1,58 @@ +use std::{io, path::PathBuf, process::Command}; + +use tempfile::TempDir; + +use crate::config::Config; + +pub(crate) fn clone_repository( + repo_url: &str, + branch: Option<&str>, +) -> io::Result<(TempDir, PathBuf)> { + let temp_dir = tempfile::tempdir()?; + let clone_path = temp_dir.path().join("repo"); + + let mut cmd = Command::new("git"); + cmd.arg("clone").arg("--depth").arg("1"); + if let Some(branch) = branch { + cmd.args(["--branch", branch]); + } + cmd.arg(repo_url).arg(&clone_path); + + let output = cmd + .output() + .map_err(|e| io::Error::other(format!("Failed to run git clone: {e}")))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let mut msg = String::from("git clone failed"); + let details = if !stderr.trim().is_empty() { + stderr.trim() + } else { + stdout.trim() + }; + if !details.is_empty() { + msg.push_str(": "); + msg.push_str(details); + } + return Err(io::Error::other(msg)); + } + + Ok((temp_dir, clone_path)) +} + +pub fn run_on_repository( + repo_url: &str, + branch: Option<&str>, + config: Config, +) -> io::Result<()> { + let (temp_dir, clone_path) = clone_repository(repo_url, branch)?; + + let mut config = config; + config.directory = clone_path; + + let result = crate::run_with_config(config); + + drop(temp_dir); + + result +} diff --git a/src/tests/mod.rs b/src/tests/mod.rs index 80b6b65..879d5b8 100644 --- a/src/tests/mod.rs +++ b/src/tests/mod.rs @@ -13,3 +13,5 @@ mod file_processing_tests; mod gitignore_tests; #[cfg(test)] mod main_tests; +#[cfg(test)] +mod repository_tests; diff --git a/src/tests/repository_tests.rs b/src/tests/repository_tests.rs new file mode 100644 index 0000000..ddb6d68 --- /dev/null +++ b/src/tests/repository_tests.rs @@ -0,0 +1,94 @@ +use std::{fs, path::PathBuf, process::Command}; + +use tempfile::TempDir; + +use crate::{ + repository::{clone_repository, run_on_repository}, + tests::common::create_test_config, +}; + +fn init_sample_repo() -> TempDir { + let repo_dir = TempDir::new().expect("create temp repo"); + + let status = Command::new("git") + .args(["init", "."]) + .current_dir(repo_dir.path()) + .status() + .expect("init git repo"); + assert!(status.success(), "git init failed"); + + for (key, value) in [("user.email", "test@example.com"), ("user.name", "Test User")] { + let status = Command::new("git") + .args(["config", key, value]) + .current_dir(repo_dir.path()) + .status() + .expect("configure git"); + assert!(status.success(), "git config {key} failed", key = key); + } + + fs::write(repo_dir.path().join("README.md"), "hello remote repo") + .expect("write README.md"); + + let status = Command::new("git") + .args(["add", "."]) + .current_dir(repo_dir.path()) + .status() + .expect("git add"); + assert!(status.success(), "git add failed"); + + let status = Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(repo_dir.path()) + .status() + .expect("git commit"); + assert!(status.success(), "git commit failed"); + + repo_dir +} + +#[test] +fn run_on_repository_processes_remote_repo() { + let remote_repo = init_sample_repo(); + let output_dir = TempDir::new().expect("create output temp dir"); + let output_path = output_dir.path().join("combined.txt"); + + let config = create_test_config(PathBuf::from("."), output_path.clone(), |_| {}); + + run_on_repository( + remote_repo + .path() + .to_str() + .expect("repo path should be valid UTF-8"), + None, + config, + ) + .expect("run_on_repository should succeed"); + + let contents = fs::read_to_string(&output_path).expect("read output file"); + assert!( + contents.contains("README.md") && contents.contains("hello remote repo"), + "output should include cloned file contents" + ); +} + +#[test] +fn clone_repository_cleans_up_temp_dir() { + let remote_repo = init_sample_repo(); + let clone_path = { + let (_temp_dir, path) = clone_repository( + remote_repo + .path() + .to_str() + .expect("repo path should be valid UTF-8"), + None, + ) + .expect("clone_repository should succeed"); + assert!(path.join(".git").exists(), "expected cloned repo"); + path + }; + + assert!( + !clone_path.exists(), + "temp clone directory should be cleaned up after drop" + ); +} From 331383a296d3a5b572917af395f9f8a30184218d Mon Sep 17 00:00:00 2001 From: atrtde Date: Mon, 9 Feb 2026 09:25:09 -0500 Subject: [PATCH 3/8] chore(format): run format --- src/repository.rs | 6 +----- src/tests/repository_tests.rs | 8 +++++--- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/repository.rs b/src/repository.rs index 247f200..88153a5 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -40,11 +40,7 @@ pub(crate) fn clone_repository( Ok((temp_dir, clone_path)) } -pub fn run_on_repository( - repo_url: &str, - branch: Option<&str>, - config: Config, -) -> io::Result<()> { +pub fn run_on_repository(repo_url: &str, branch: Option<&str>, config: Config) -> io::Result<()> { let (temp_dir, clone_path) = clone_repository(repo_url, branch)?; let mut config = config; diff --git a/src/tests/repository_tests.rs b/src/tests/repository_tests.rs index ddb6d68..0ebd20e 100644 --- a/src/tests/repository_tests.rs +++ b/src/tests/repository_tests.rs @@ -17,7 +17,10 @@ fn init_sample_repo() -> TempDir { .expect("init git repo"); assert!(status.success(), "git init failed"); - for (key, value) in [("user.email", "test@example.com"), ("user.name", "Test User")] { + for (key, value) in [ + ("user.email", "test@example.com"), + ("user.name", "Test User"), + ] { let status = Command::new("git") .args(["config", key, value]) .current_dir(repo_dir.path()) @@ -26,8 +29,7 @@ fn init_sample_repo() -> TempDir { assert!(status.success(), "git config {key} failed", key = key); } - fs::write(repo_dir.path().join("README.md"), "hello remote repo") - .expect("write README.md"); + fs::write(repo_dir.path().join("README.md"), "hello remote repo").expect("write README.md"); let status = Command::new("git") .args(["add", "."]) From 35b1849531507b0fdfedad78681dd2877f5a4b10 Mon Sep 17 00:00:00 2001 From: atrtde Date: Mon, 9 Feb 2026 09:44:03 -0500 Subject: [PATCH 4/8] fix: review error handling and use thiserror crate for this --- Cargo.lock | 25 ++++++++++++++++++-- Cargo.toml | 1 + src/clipboard.rs | 10 ++++---- src/config.rs | 46 +++++++++++++----------------------- src/error.rs | 43 +++++++++++++++++++++++++++++++++ src/main.rs | 45 ++++++++++++++++++++++++----------- src/repository.rs | 23 +++++++++--------- src/tests/cli_tests.rs | 5 ++-- src/tests/clipboard_tests.rs | 16 ++++++++----- src/tests/config_tests.rs | 13 +++++----- src/tests/main_tests.rs | 5 ++-- 11 files changed, 154 insertions(+), 78 deletions(-) create mode 100644 src/error.rs diff --git a/Cargo.lock b/Cargo.lock index f3d0f24..0786342 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -223,6 +223,7 @@ dependencies = [ "serde", "serde_yaml", "tempfile", + "thiserror 1.0.69", "walkdir", ] @@ -428,7 +429,7 @@ checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.16", "libredox", - "thiserror", + "thiserror 2.0.17", ] [[package]] @@ -549,13 +550,33 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.17", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 957ba73..bfde2e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,5 +23,6 @@ serde = { version = "1.0.228", features = ["derive"] } serde_yaml = "0.9.34" walkdir = "2.5.0" tempfile = "3.19.1" +thiserror = "1.0" [dev-dependencies] diff --git a/src/clipboard.rs b/src/clipboard.rs index c43fbcd..cf6f7cf 100644 --- a/src/clipboard.rs +++ b/src/clipboard.rs @@ -1,19 +1,21 @@ use clipboard::{ClipboardContext, ClipboardProvider}; use std::fs::File; -use std::io::{self, Error, Read}; +use std::io::Read; use std::path::Path; +use crate::error::{AppError, AppResult}; + /// Copies the contents of the specified file to the system clipboard. -pub fn copy_to_clipboard(output_path: &Path) -> io::Result<()> { +pub fn copy_to_clipboard(output_path: &Path) -> AppResult<()> { let mut output_contents = String::new(); File::open(output_path)?.read_to_string(&mut output_contents)?; let mut clipboard: ClipboardContext = - ClipboardProvider::new().map_err(|e| Error::other(format!("Clipboard error: {}", e)))?; + ClipboardProvider::new().map_err(|e| AppError::Clipboard(e.to_string()))?; clipboard .set_contents(output_contents) - .map_err(|e| Error::other(format!("Clipboard error: {}", e)))?; + .map_err(|e| AppError::Clipboard(e.to_string()))?; Ok(()) } diff --git a/src/config.rs b/src/config.rs index 66017b4..270572a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,8 +1,9 @@ use serde::{Deserialize, Serialize}; use std::fs; -use std::io; use std::path::{Path, PathBuf}; +use crate::error::{AppError, AppResult}; + /// Main config struct used throughout the app. #[derive(Debug, PartialEq, Clone)] pub struct Config { @@ -39,14 +40,13 @@ pub struct FileConfig { impl FileConfig { /// Load config from a YAML file path. - pub fn from_path>(path: P) -> io::Result { - let content = fs::read_to_string(path)?; - let config: FileConfig = serde_yaml::from_str(&content).map_err(|e| { - io::Error::new( - io::ErrorKind::InvalidData, - format!("YAML parse error: {}", e), - ) - })?; + pub fn from_path>(path: P) -> AppResult { + let content = fs::read_to_string(path.as_ref())?; + let config: FileConfig = + serde_yaml::from_str(&content).map_err(|e| AppError::YamlParse { + path: path.as_ref().to_path_buf(), + source: e, + })?; Ok(config) } } @@ -127,7 +127,7 @@ pub fn merge_config(file: FileConfig, cli: Config, explicit: ExplicitFlags) -> C /// Returns both the built `Config` and an `ExplicitFlags` struct that indicates /// which CLI values were actually provided on the command line (as opposed to /// being left as clap defaults). -pub fn config_from_matches(matches: clap::ArgMatches) -> std::io::Result<(Config, ExplicitFlags)> { +pub fn config_from_matches(matches: clap::ArgMatches) -> AppResult<(Config, ExplicitFlags)> { let directory_set = match matches.try_get_one::("directory") { Ok(Some(_)) => true, Ok(None) => false, @@ -151,24 +151,14 @@ pub fn config_from_matches(matches: clap::ArgMatches) -> std::io::Result<(Config let directory = matches .try_get_one::("directory") - .map_err(|e| { - std::io::Error::new( - std::io::ErrorKind::InvalidInput, - format!("Missing directory: {}", e), - ) - })? - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing directory"))? + .map_err(|_| AppError::MissingDirectory)? + .ok_or(AppError::MissingDirectory)? .into(); let output = matches .try_get_one::("output") - .map_err(|e| { - std::io::Error::new( - io::ErrorKind::InvalidInput, - format!("Missing output: {}", e), - ) - })? - .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "Missing output"))? + .map_err(|_| AppError::MissingOutput)? + .ok_or(AppError::MissingOutput)? .into(); let include_dirs = match matches.try_get_one::("include_dirs") { @@ -234,17 +224,13 @@ pub fn config_from_matches(matches: clap::ArgMatches) -> std::io::Result<(Config }; let min_size = match matches.try_get_one::("min_size") { - Ok(Some(s)) => Some(s.parse::().map_err(|_| { - std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid min-size") - })?), + Ok(Some(s)) => Some(s.parse::().map_err(|_| AppError::InvalidMinSize)?), Ok(None) => None, Err(_) => None, }; let max_size = match matches.try_get_one::("max_size") { - Ok(Some(s)) => Some(s.parse::().map_err(|_| { - std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid max-size") - })?), + Ok(Some(s)) => Some(s.parse::().map_err(|_| AppError::InvalidMaxSize)?), Ok(None) => None, Err(_) => None, }; diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..2dcc8a2 --- /dev/null +++ b/src/error.rs @@ -0,0 +1,43 @@ +use std::io; +use std::path::PathBuf; + +use thiserror::Error; + +pub type AppResult = Result; + +#[derive(Debug, Error)] +pub enum AppError { + #[error(transparent)] + Io(#[from] io::Error), + + #[error("Clipboard error: {0}")] + Clipboard(String), + + #[error("YAML parse error in {path}: {source}")] + YamlParse { + path: PathBuf, + #[source] + source: serde_yaml::Error, + }, + + #[error("Missing directory")] + MissingDirectory, + + #[error("Missing output")] + MissingOutput, + + #[error("Invalid min-size")] + InvalidMinSize, + + #[error("Invalid max-size")] + InvalidMaxSize, + + #[error("Config file already exists at {path}. Use --force to overwrite.")] + ConfigAlreadyExists { path: String }, + + #[error("Failed to run git clone: {0}")] + GitCloneExec(#[source] io::Error), + + #[error("git clone failed: {0}")] + GitCloneFailed(String), +} diff --git a/src/main.rs b/src/main.rs index 7a62c81..adf7607 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,6 @@ -use std::io; - use crate::clipboard::copy_to_clipboard; use crate::data::{IGNORED_DIRS, IGNORED_FILES}; +use crate::error::{AppError, AppResult}; use crate::file_processing::{get_directory_structure, process_files}; use crate::gitignore::build_gitignore; @@ -12,6 +11,7 @@ mod cli; mod clipboard; mod config; mod data; +mod error; mod file_processing; mod gitignore; mod repository; @@ -20,7 +20,7 @@ mod repository; /// /// This function is extracted from `main` and made public so tests can call it /// directly with a controlled `Config`. -pub fn run_with_config(config: crate::config::Config) -> io::Result<()> { +pub fn run_with_config(config: crate::config::Config) -> AppResult<()> { let gitignore = build_gitignore(&config.directory, IGNORED_FILES, IGNORED_DIRS, &config)?; let dir_structure = @@ -31,17 +31,40 @@ pub fn run_with_config(config: crate::config::Config) -> io::Result<()> { println!("Project tree written to {}", config.output.display()); } else { process_files(&config, &gitignore, &dir_structure, IGNORED_DIRS)?; - copy_to_clipboard(&config.output)?; + let mut copied = true; + if let Err(err) = copy_to_clipboard(&config.output) { + if matches!(err, AppError::Clipboard(_)) && should_ignore_clipboard_error() { + copied = false; + eprintln!("Warning: clipboard unavailable; skipping copy. {}", err); + } else { + return Err(err); + } + } println!( "Files combined successfully into {}", config.output.display() ); - println!("Output copied to clipboard successfully!"); + if copied { + println!("Output copied to clipboard successfully!"); + } } Ok(()) } -pub fn handle_init_subcommand(matches: &clap::ArgMatches) -> io::Result { +fn should_ignore_clipboard_error() -> bool { + if std::env::var_os("CI").is_some() { + return true; + } + if cfg!(target_os = "linux") { + let has_display = std::env::var_os("DISPLAY").is_some() + || std::env::var_os("WAYLAND_DISPLAY").is_some() + || std::env::var_os("SWAYSOCK").is_some(); + return !has_display; + } + false +} + +pub fn handle_init_subcommand(matches: &clap::ArgMatches) -> AppResult { if let Some(sub_m) = matches.subcommand_matches("init") { let global = sub_m.get_flag("global"); let force = sub_m.get_flag("force"); @@ -60,13 +83,7 @@ pub fn handle_init_subcommand(matches: &clap::ArgMatches) -> io::Result { }; if path.exists() && !force { - return Err(io::Error::new( - io::ErrorKind::AlreadyExists, - format!( - "Config file already exists at {}. Use --force to overwrite.", - display_path - ), - )); + return Err(AppError::ConfigAlreadyExists { path: display_path }); } let template = r#"# fyai.yaml - Configuration file for fyai @@ -106,7 +123,7 @@ tree_only: false # Only output directory tree, no file contents Ok(false) } -fn main() -> io::Result<()> { +fn main() -> AppResult<()> { let matches = crate::cli::create_commands().get_matches(); // Handle init subcommand via helper so tests can call it directly. diff --git a/src/repository.rs b/src/repository.rs index 88153a5..535b75d 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -1,13 +1,14 @@ -use std::{io, path::PathBuf, process::Command}; +use std::{path::PathBuf, process::Command}; use tempfile::TempDir; use crate::config::Config; +use crate::error::{AppError, AppResult}; pub(crate) fn clone_repository( repo_url: &str, branch: Option<&str>, -) -> io::Result<(TempDir, PathBuf)> { +) -> AppResult<(TempDir, PathBuf)> { let temp_dir = tempfile::tempdir()?; let clone_path = temp_dir.path().join("repo"); @@ -18,29 +19,27 @@ pub(crate) fn clone_repository( } cmd.arg(repo_url).arg(&clone_path); - let output = cmd - .output() - .map_err(|e| io::Error::other(format!("Failed to run git clone: {e}")))?; + let output = cmd.output().map_err(AppError::GitCloneExec)?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); let stdout = String::from_utf8_lossy(&output.stdout); - let mut msg = String::from("git clone failed"); let details = if !stderr.trim().is_empty() { stderr.trim() } else { stdout.trim() }; - if !details.is_empty() { - msg.push_str(": "); - msg.push_str(details); - } - return Err(io::Error::other(msg)); + let details = if details.is_empty() { + "unknown error" + } else { + details + }; + return Err(AppError::GitCloneFailed(details.to_string())); } Ok((temp_dir, clone_path)) } -pub fn run_on_repository(repo_url: &str, branch: Option<&str>, config: Config) -> io::Result<()> { +pub fn run_on_repository(repo_url: &str, branch: Option<&str>, config: Config) -> AppResult<()> { let (temp_dir, clone_path) = clone_repository(repo_url, branch)?; let mut config = config; diff --git a/src/tests/cli_tests.rs b/src/tests/cli_tests.rs index f0bb7a2..43c6c2f 100644 --- a/src/tests/cli_tests.rs +++ b/src/tests/cli_tests.rs @@ -2,6 +2,7 @@ mod tests { use std::path::PathBuf; + use crate::error::AppError; use crate::{cli::create_commands, config::config_from_matches}; #[test] @@ -100,7 +101,7 @@ mod tests { let result = config_from_matches(args); assert!(result.is_err()); - assert_eq!(result.unwrap_err().to_string(), "Invalid min-size"); + assert!(matches!(result.unwrap_err(), AppError::InvalidMinSize)); } #[test] @@ -109,7 +110,7 @@ mod tests { let result = config_from_matches(args); assert!(result.is_err()); - assert_eq!(result.unwrap_err().to_string(), "Invalid max-size"); + assert!(matches!(result.unwrap_err(), AppError::InvalidMaxSize)); } #[test] diff --git a/src/tests/clipboard_tests.rs b/src/tests/clipboard_tests.rs index 927d662..6895040 100644 --- a/src/tests/clipboard_tests.rs +++ b/src/tests/clipboard_tests.rs @@ -1,11 +1,12 @@ #[cfg(test)] mod tests { use crate::clipboard::copy_to_clipboard; + use crate::error::{AppError, AppResult}; use crate::tests::common::{create_file, setup_temp_dir}; use std::io; #[test] - fn test_copy_to_clipboard_valid_file() -> io::Result<()> { + fn test_copy_to_clipboard_valid_file() -> AppResult<()> { let temp_dir = setup_temp_dir(); let file_path = temp_dir.path().join("test.txt"); create_file(&file_path, "Hello, clipboard!")?; @@ -25,23 +26,26 @@ mod tests { || result .as_ref() .err() - .is_some_and(|e| e.kind() == io::ErrorKind::Other) + .is_some_and(|e| matches!(e, AppError::Clipboard(_))) ); Ok(()) } #[test] - fn test_copy_to_clipboard_nonexistent_file() -> io::Result<()> { + fn test_copy_to_clipboard_nonexistent_file() -> AppResult<()> { let temp_dir = setup_temp_dir(); let file_path = temp_dir.path().join("nonexistent.txt"); let result = copy_to_clipboard(&file_path); assert!(result.is_err()); - assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound); + match result.unwrap_err() { + AppError::Io(err) => assert_eq!(err.kind(), io::ErrorKind::NotFound), + err => panic!("Expected io::Error NotFound, got {err:?}"), + } Ok(()) } #[test] - fn test_copy_to_clipboard_empty_file() -> io::Result<()> { + fn test_copy_to_clipboard_empty_file() -> AppResult<()> { let temp_dir = setup_temp_dir(); let file_path = temp_dir.path().join("empty.txt"); create_file(&file_path, "")?; @@ -61,7 +65,7 @@ mod tests { || result .as_ref() .err() - .is_some_and(|e| e.kind() == io::ErrorKind::Other) + .is_some_and(|e| matches!(e, AppError::Clipboard(_))) ); Ok(()) } diff --git a/src/tests/config_tests.rs b/src/tests/config_tests.rs index c2ff5a0..b6679f4 100644 --- a/src/tests/config_tests.rs +++ b/src/tests/config_tests.rs @@ -1,11 +1,11 @@ use std::fs; -use std::io; use std::path::PathBuf; use std::sync::{Mutex, OnceLock}; use clap::{Arg, ArgAction, Command}; use crate::config::{Config, FileConfig, config_from_matches, discover_config_file, merge_config}; +use crate::error::AppError; static TEST_LOCK: OnceLock> = OnceLock::new(); @@ -46,7 +46,7 @@ respect_gitignore: false #[test] fn test_fileconfig_from_path_invalid_yaml() { - // invalid YAML should produce an io::Error with InvalidData + // invalid YAML should surface a structured YAML parse error let path = "./bad_fyai_config.yaml"; fs::write(path, "not: [valid").expect("write bad yaml"); @@ -57,7 +57,7 @@ fn test_fileconfig_from_path_invalid_yaml() { assert!(res.is_err()); let err = res.err().unwrap(); - assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert!(matches!(err, AppError::YamlParse { .. })); } #[test] @@ -235,6 +235,8 @@ fn test_config_from_matches_invalid_min_size() { let res = config_from_matches(matches); assert!(res.is_err()); + let err = res.unwrap_err(); + assert!(matches!(err, AppError::InvalidMinSize)); } // ---- New tests covering additional branches and error cases ---- @@ -357,8 +359,7 @@ fn test_missing_directory_error_message() { let res = config_from_matches(matches); assert!(res.is_err()); let err = res.unwrap_err(); - // The error message was constructed with "Missing directory" - assert!(err.to_string().to_lowercase().contains("missing directory")); + assert!(matches!(err, AppError::MissingDirectory)); } #[test] @@ -371,7 +372,7 @@ fn test_missing_output_error_message() { let res = config_from_matches(matches); assert!(res.is_err()); let err = res.unwrap_err(); - assert!(err.to_string().to_lowercase().contains("missing output")); + assert!(matches!(err, AppError::MissingOutput)); } /// Additional tests to cover branches where args are registered-but-not-provided diff --git a/src/tests/main_tests.rs b/src/tests/main_tests.rs index 6e138ec..76f87cb 100644 --- a/src/tests/main_tests.rs +++ b/src/tests/main_tests.rs @@ -1,11 +1,12 @@ use std::{ - env, fs, io, + env, fs, path::PathBuf, sync::{Mutex, OnceLock}, }; use tempfile::TempDir; use crate::cli::create_commands; +use crate::error::AppError; static SERIALIZE_TESTS: OnceLock> = OnceLock::new(); @@ -80,7 +81,7 @@ fn test_init_already_exists_without_force_errors() { "Expected error when config exists and --force not provided" ); let err = res.unwrap_err(); - assert_eq!(err.kind(), io::ErrorKind::AlreadyExists); + assert!(matches!(err, AppError::ConfigAlreadyExists { .. })); } #[test] From 389a399ce67ce99985670d7b0acb6294ad6235c5 Mon Sep 17 00:00:00 2001 From: atrtde Date: Mon, 9 Feb 2026 09:45:10 -0500 Subject: [PATCH 5/8] fix: add conflict between directory and repo --- src/cli.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli.rs b/src/cli.rs index 06afe57..3ab8b5e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -24,7 +24,7 @@ pub fn create_commands() -> Command { Arg::new("repo") .long("repo") .value_name("URL") - .help("Clone a git repository (GitHub/GitLab) into a temporary directory before processing"), + .help("Clone a git repository (GitHub/GitLab) into a temporary directory before processing").conflicts_with("directory"), ) .arg( Arg::new("repo_branch") From 2b7d4b3adb7bb1318fc9b01fe25feb329b3541e0 Mon Sep 17 00:00:00 2001 From: atrtde Date: Mon, 9 Feb 2026 09:52:22 -0500 Subject: [PATCH 6/8] feat(repo): add optional repo-commit checkout --- src/cli.rs | 10 ++++++- src/error.rs | 6 ++++ src/main.rs | 8 ++++- src/repository.rs | 56 +++++++++++++++++++++++++---------- src/tests/repository_tests.rs | 39 ++++++++++++++++++++++++ 5 files changed, 101 insertions(+), 18 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 3ab8b5e..2838972 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -30,7 +30,15 @@ pub fn create_commands() -> Command { Arg::new("repo_branch") .long("repo-branch") .value_name("BRANCH") - .help("Branch, tag, or commit to checkout when using --repo"), + .help("Branch or tag to checkout when using --repo") + .requires("repo"), + ) + .arg( + Arg::new("repo_commit") + .long("repo-commit") + .value_name("COMMIT") + .help("Commit SHA to checkout when using --repo") + .requires("repo"), ) .arg( Arg::new("include_dirs") diff --git a/src/error.rs b/src/error.rs index 2dcc8a2..9281873 100644 --- a/src/error.rs +++ b/src/error.rs @@ -40,4 +40,10 @@ pub enum AppError { #[error("git clone failed: {0}")] GitCloneFailed(String), + + #[error("Failed to run git checkout: {0}")] + GitCheckoutExec(#[source] io::Error), + + #[error("git checkout failed: {0}")] + GitCheckoutFailed(String), } diff --git a/src/main.rs b/src/main.rs index adf7607..afc2d24 100644 --- a/src/main.rs +++ b/src/main.rs @@ -133,6 +133,7 @@ fn main() -> AppResult<()> { let repo_url = matches.get_one::("repo").cloned(); let repo_branch = matches.get_one::("repo_branch").cloned(); + let repo_commit = matches.get_one::("repo_commit").cloned(); // Normal flow: parse CLI args and config file // `config_from_matches_with_explicit` returns both the parsed CLI `Config` and an @@ -162,7 +163,12 @@ fn main() -> AppResult<()> { let config = crate::config::merge_config(file_config, cli_config, explicit); if let Some(repo_url) = repo_url { - return crate::repository::run_on_repository(&repo_url, repo_branch.as_deref(), config); + return crate::repository::run_on_repository( + &repo_url, + repo_branch.as_deref(), + repo_commit.as_deref(), + config, + ); } // Delegate to the extracted function so it can be tested in isolation. diff --git a/src/repository.rs b/src/repository.rs index 535b75d..238a533 100644 --- a/src/repository.rs +++ b/src/repository.rs @@ -5,15 +5,34 @@ use tempfile::TempDir; use crate::config::Config; use crate::error::{AppError, AppResult}; +fn command_error_details(output: &std::process::Output) -> String { + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + let details = if !stderr.trim().is_empty() { + stderr.trim() + } else { + stdout.trim() + }; + if details.is_empty() { + "unknown error".to_string() + } else { + details.to_string() + } +} + pub(crate) fn clone_repository( repo_url: &str, branch: Option<&str>, + commit: Option<&str>, ) -> AppResult<(TempDir, PathBuf)> { let temp_dir = tempfile::tempdir()?; let clone_path = temp_dir.path().join("repo"); let mut cmd = Command::new("git"); - cmd.arg("clone").arg("--depth").arg("1"); + cmd.arg("clone"); + if commit.is_none() { + cmd.arg("--depth").arg("1"); + } if let Some(branch) = branch { cmd.args(["--branch", branch]); } @@ -21,26 +40,31 @@ pub(crate) fn clone_repository( let output = cmd.output().map_err(AppError::GitCloneExec)?; if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - let stdout = String::from_utf8_lossy(&output.stdout); - let details = if !stderr.trim().is_empty() { - stderr.trim() - } else { - stdout.trim() - }; - let details = if details.is_empty() { - "unknown error" - } else { - details - }; - return Err(AppError::GitCloneFailed(details.to_string())); + return Err(AppError::GitCloneFailed(command_error_details(&output))); + } + + if let Some(commit) = commit { + let output = Command::new("git") + .arg("-C") + .arg(&clone_path) + .args(["checkout", commit]) + .output() + .map_err(AppError::GitCheckoutExec)?; + if !output.status.success() { + return Err(AppError::GitCheckoutFailed(command_error_details(&output))); + } } Ok((temp_dir, clone_path)) } -pub fn run_on_repository(repo_url: &str, branch: Option<&str>, config: Config) -> AppResult<()> { - let (temp_dir, clone_path) = clone_repository(repo_url, branch)?; +pub fn run_on_repository( + repo_url: &str, + branch: Option<&str>, + commit: Option<&str>, + config: Config, +) -> AppResult<()> { + let (temp_dir, clone_path) = clone_repository(repo_url, branch, commit)?; let mut config = config; config.directory = clone_path; diff --git a/src/tests/repository_tests.rs b/src/tests/repository_tests.rs index 0ebd20e..3babdaa 100644 --- a/src/tests/repository_tests.rs +++ b/src/tests/repository_tests.rs @@ -48,6 +48,16 @@ fn init_sample_repo() -> TempDir { repo_dir } +fn get_head_commit(repo_dir: &TempDir) -> String { + let output = Command::new("git") + .args(["rev-parse", "HEAD"]) + .current_dir(repo_dir.path()) + .output() + .expect("git rev-parse"); + assert!(output.status.success(), "git rev-parse failed"); + String::from_utf8_lossy(&output.stdout).trim().to_string() +} + #[test] fn run_on_repository_processes_remote_repo() { let remote_repo = init_sample_repo(); @@ -62,6 +72,7 @@ fn run_on_repository_processes_remote_repo() { .to_str() .expect("repo path should be valid UTF-8"), None, + None, config, ) .expect("run_on_repository should succeed"); @@ -73,6 +84,33 @@ fn run_on_repository_processes_remote_repo() { ); } +#[test] +fn run_on_repository_supports_commit() { + let remote_repo = init_sample_repo(); + let commit = get_head_commit(&remote_repo); + let output_dir = TempDir::new().expect("create output temp dir"); + let output_path = output_dir.path().join("combined.txt"); + + let config = create_test_config(PathBuf::from("."), output_path.clone(), |_| {}); + + run_on_repository( + remote_repo + .path() + .to_str() + .expect("repo path should be valid UTF-8"), + None, + Some(&commit), + config, + ) + .expect("run_on_repository should succeed with commit"); + + let contents = fs::read_to_string(&output_path).expect("read output file"); + assert!( + contents.contains("README.md") && contents.contains("hello remote repo"), + "output should include cloned file contents" + ); +} + #[test] fn clone_repository_cleans_up_temp_dir() { let remote_repo = init_sample_repo(); @@ -83,6 +121,7 @@ fn clone_repository_cleans_up_temp_dir() { .to_str() .expect("repo path should be valid UTF-8"), None, + None, ) .expect("clone_repository should succeed"); assert!(path.join(".git").exists(), "expected cloned repo"); From f630ac2763612344c9205c96e489121d9de76757 Mon Sep 17 00:00:00 2001 From: atrtde Date: Mon, 9 Feb 2026 09:55:48 -0500 Subject: [PATCH 7/8] docs: add changelog for 1.7.0 --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..bf7b89f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## 1.7.0 - 2026-02-09 + +Added +- `--repo` to process a remote git repository in a temporary directory. +- `--repo-branch` to checkout a branch or tag when using `--repo`. +- `--repo-commit` to checkout a specific commit when using `--repo`. +- Repository integration tests covering clone, cleanup, and commit checkout. + +Changed +- Error handling now uses typed `AppError` variants (via `thiserror`), removing string-based checks for clipboard and config errors. +- When `--repo-commit` is used, cloning no longer uses `--depth 1` to ensure the commit is available. +- Documentation updated with a remote-repo usage example. +- Applied a formatting pass to keep code style consistent. + +Fixed +- CLI now prevents using `--repo` and `--dir` together. From a98541ba3c7ff902dbae74da0d5d3ce9d821149f Mon Sep 17 00:00:00 2001 From: atrtde Date: Mon, 9 Feb 2026 09:57:11 -0500 Subject: [PATCH 8/8] docs: update README for repo commit support --- README.md | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index dbdda4e..63d5c87 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ A command-line tool to combine files from a directory into a single file for AI ## Features - Combines multiple text files into one output file +- Can process a remote git repository in a temporary directory - Supports configuration via CLI options **and config files** (YAML) - Filters files by: - Size @@ -80,23 +81,25 @@ fyai ``` USAGE: - fyai [OPTIONS] + fyai [OPTIONS] [SUBCOMMAND] OPTIONS: -d, --dir Sets the input directory [default: .] -o, --output Sets the output file [default: fyai.txt] --repo Clone a git repository (e.g., GitHub/GitLab) into a temporary directory before processing - --repo-branch Branch, tag, or commit to checkout when using --repo + --repo-branch Branch or tag to checkout when using --repo + --repo-commit Commit SHA to checkout when using --repo --include-dirs Comma-separated list of directories to include (e.g., src,docs) -x, --exclude-dirs Comma-separated list of directories to exclude (e.g., node_modules,dist) --include-files Comma-separated list of files to include (e.g., README.md,main.rs) --exclude-files Comma-separated list of files to exclude (e.g., LICENSE,config.json) --include-ext Comma-separated list of file extensions to include (e.g., txt,md) -e, --exclude-ext Comma-separated list of file extensions to exclude (e.g., log,tmp) - -n, --min-size Exclude files smaller than this size in bytes (default: 51200) + -n, --min-size Exclude files smaller than this size in bytes -m, --max-size Exclude files larger than this size in bytes --respect-gitignore Whether to respect .gitignore rules (true/false) [default: true] --tree-only Only output the project directory tree, no file contents + -t, --test Run in test mode -h, --help Print help information -V, --version Print version information @@ -106,6 +109,9 @@ CONFIG FILE SUPPORT: Global config: ~/.config/fyai.yaml (used if no local config found) CLI options override config file values. See README for details and examples. + +SUBCOMMANDS: + init Generate a template fyai.yaml config file ``` ### Examples @@ -163,6 +169,18 @@ CONFIG FILE SUPPORT: fyai --repo https://github.com/owner/repo.git --repo-branch main ``` +- Run against a specific commit: + + ```bash + fyai --repo https://github.com/owner/repo.git --repo-commit 1234abcd + ``` + +- Generate a config template: + + ```bash + fyai init + ``` + ## Output Format The combined file includes headers for each source file: @@ -187,6 +205,10 @@ Contributions are welcome! Please: This project is licensed under the MIT License. +## Changelog + +See `CHANGELOG.md` for release notes. + ## Acknowledgments - Built with [Rust](https://www.rust-lang.org/)