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/)