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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 23 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ ignore = "0.4.23"
serde = { version = "1.0.228", features = ["derive"] }
serde_yaml = "0.9.34"
walkdir = "2.5.0"
tempfile = "3.19.1"
thiserror = "1.0"
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding a direct dependency on thiserror = "1.0" introduces a second thiserror major version alongside an existing transitive thiserror 2.x (see Cargo.lock), increasing compile time and binary size. Prefer aligning to the already-used major version (if MSRV allows) or otherwise pin/justify the dual-version situation.

Suggested change
thiserror = "1.0"
thiserror = "2"

Copilot uses AI. Check for mistakes.

[dev-dependencies]
tempfile = "3.19.1"
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -80,21 +81,25 @@ fyai

```
USAGE:
fyai [OPTIONS]
fyai [OPTIONS] [SUBCOMMAND]

OPTIONS:
-d, --dir <DIR> Sets the input directory [default: .]
-o, --output <FILE> Sets the output file [default: fyai.txt]
--repo <URL> Clone a git repository (e.g., GitHub/GitLab) into a temporary directory before processing
--repo-branch <BRANCH> Branch or tag to checkout when using --repo
--repo-commit <COMMIT> Commit SHA to checkout when using --repo
--include-dirs <DIRS> Comma-separated list of directories to include (e.g., src,docs)
-x, --exclude-dirs <DIRS> Comma-separated list of directories to exclude (e.g., node_modules,dist)
--include-files <FILES> Comma-separated list of files to include (e.g., README.md,main.rs)
--exclude-files <FILES> Comma-separated list of files to exclude (e.g., LICENSE,config.json)
--include-ext <EXT> Comma-separated list of file extensions to include (e.g., txt,md)
-e, --exclude-ext <EXT> Comma-separated list of file extensions to exclude (e.g., log,tmp)
-n, --min-size <BYTES> Exclude files smaller than this size in bytes (default: 51200)
-n, --min-size <BYTES> Exclude files smaller than this size in bytes
-m, --max-size <BYTES> Exclude files larger than this size in bytes
--respect-gitignore <BOOL> 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

Expand All @@ -104,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
Expand Down Expand Up @@ -155,6 +163,24 @@ 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
```

- 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:
Expand All @@ -179,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/)
Expand Down
20 changes: 20 additions & 0 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,26 @@ 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").conflicts_with("directory"),
Copy link

Copilot AI Feb 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

--repo is declared as conflicts_with("directory"), but directory has a default_value("."). With clap defaults treated as present, this makes --repo conflict even when the user did not pass --dir, effectively breaking the remote-repo feature. Consider removing the default on directory (set it in code when --repo is absent) or using clap value-source/conditional defaults so the conflict only triggers when --dir is explicitly provided.

Suggested change
.help("Clone a git repository (GitHub/GitLab) into a temporary directory before processing").conflicts_with("directory"),
.help("Clone a git repository (GitHub/GitLab) into a temporary directory before processing"),

Copilot uses AI. Check for mistakes.
)
.arg(
Arg::new("repo_branch")
.long("repo-branch")
.value_name("BRANCH")
.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")
.long("include-dirs")
Expand Down
10 changes: 6 additions & 4 deletions src/clipboard.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
46 changes: 16 additions & 30 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -39,14 +40,13 @@ pub struct FileConfig {

impl FileConfig {
/// Load config from a YAML file path.
pub fn from_path<P: AsRef<Path>>(path: P) -> io::Result<Self> {
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<P: AsRef<Path>>(path: P) -> AppResult<Self> {
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)
}
}
Expand Down Expand Up @@ -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::<String>("directory") {
Ok(Some(_)) => true,
Ok(None) => false,
Expand All @@ -151,24 +151,14 @@ pub fn config_from_matches(matches: clap::ArgMatches) -> std::io::Result<(Config

let directory = matches
.try_get_one::<String>("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::<String>("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::<String>("include_dirs") {
Expand Down Expand Up @@ -234,17 +224,13 @@ pub fn config_from_matches(matches: clap::ArgMatches) -> std::io::Result<(Config
};

let min_size = match matches.try_get_one::<String>("min_size") {
Ok(Some(s)) => Some(s.parse::<u64>().map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid min-size")
})?),
Ok(Some(s)) => Some(s.parse::<u64>().map_err(|_| AppError::InvalidMinSize)?),
Ok(None) => None,
Err(_) => None,
};

let max_size = match matches.try_get_one::<String>("max_size") {
Ok(Some(s)) => Some(s.parse::<u64>().map_err(|_| {
std::io::Error::new(std::io::ErrorKind::InvalidInput, "Invalid max-size")
})?),
Ok(Some(s)) => Some(s.parse::<u64>().map_err(|_| AppError::InvalidMaxSize)?),
Ok(None) => None,
Err(_) => None,
};
Expand Down
49 changes: 49 additions & 0 deletions src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
use std::io;
use std::path::PathBuf;

use thiserror::Error;

pub type AppResult<T> = Result<T, AppError>;

#[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),

#[error("Failed to run git checkout: {0}")]
GitCheckoutExec(#[source] io::Error),

#[error("git checkout failed: {0}")]
GitCheckoutFailed(String),
}
Loading