Skip to content

Commit 75ad612

Browse files
committed
feat: add exclude file patterns and clipboard support
FR-031: `--exclude <GLOB>` CLI flag (repeatable) and `exclude_patterns` config option filter files from analysis and diff context using globset. Excluded files are listed in output but not sent to the LLM. CLI patterns are additive with config patterns. FR-033: `--clipboard` flag copies the generated message to the system clipboard via platform commands (pbcopy/xclip/clip) and prints to stdout, skipping the commit confirmation prompt entirely.
1 parent a328d04 commit 75ad612

5 files changed

Lines changed: 157 additions & 1 deletion

File tree

Cargo.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ keyring = { version = "3", optional = true }
8888

8989
# Utilities
9090
regex = "1.12"
91+
globset = "0.4"
9192

9293
[features]
9394
default = [

src/app.rs

Lines changed: 120 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ use std::path::PathBuf;
77

88
use console::style;
99
use dialoguer::Confirm;
10+
use globset::{Glob, GlobSetBuilder};
1011
use tokio::signal;
1112
use tokio::sync::mpsc;
1213
use tokio_util::sync::CancellationToken;
@@ -90,6 +91,9 @@ impl App {
9091
changes.stats.deletions
9192
));
9293

94+
// Step 1.5: Exclude files matching glob patterns
95+
let changes = self.apply_exclude_patterns(changes, &progress)?;
96+
9397
// Step 2: Check for safety issues
9498
if safety::check_for_conflicts(&changes) {
9599
return Err(Error::MergeConflicts);
@@ -338,7 +342,14 @@ impl App {
338342
self.select_candidate(&candidates)?
339343
};
340344

341-
// Step 7: Confirm and commit
345+
// Step 7: Clipboard / dry-run / confirm and commit
346+
if self.cli.clipboard {
347+
Self::copy_to_clipboard(&message)?;
348+
eprintln!("{} Copied to clipboard!", style("✓").green().bold());
349+
println!("{}", message);
350+
return Ok(());
351+
}
352+
342353
if self.cli.dry_run {
343354
println!("{}", message);
344355
return Ok(());
@@ -401,6 +412,12 @@ impl App {
401412
"Learn from history: {} (sample: {})",
402413
self.config.learn_from_history, self.config.history_sample_size
403414
);
415+
if !self.config.exclude_patterns.is_empty() {
416+
println!(
417+
"Exclude patterns: {}",
418+
self.config.exclude_patterns.join(", ")
419+
);
420+
}
404421
println!();
405422
println!("[format]");
406423
println!(" include_body: {}", self.config.format.include_body);
@@ -1265,6 +1282,108 @@ fi
12651282
parts.join("\n")
12661283
}
12671284

1285+
// ─── Exclude Helpers ───
1286+
1287+
/// Filter staged changes by removing files matching exclude glob patterns.
1288+
/// Returns the filtered changes. Excluded files are listed in output.
1289+
fn apply_exclude_patterns(
1290+
&self,
1291+
mut changes: StagedChanges,
1292+
progress: &Progress,
1293+
) -> Result<StagedChanges> {
1294+
if self.config.exclude_patterns.is_empty() {
1295+
return Ok(changes);
1296+
}
1297+
1298+
let mut builder = GlobSetBuilder::new();
1299+
for pattern in &self.config.exclude_patterns {
1300+
let glob = Glob::new(pattern).map_err(|e| {
1301+
Error::Config(format!("Invalid exclude pattern '{}': {}", pattern, e))
1302+
})?;
1303+
builder.add(glob);
1304+
}
1305+
let glob_set = builder
1306+
.build()
1307+
.map_err(|e| Error::Config(format!("Failed to build exclude patterns: {}", e)))?;
1308+
1309+
let original_count = changes.files.len();
1310+
let mut excluded: Vec<PathBuf> = Vec::new();
1311+
1312+
changes.files.retain(|f| {
1313+
if glob_set.is_match(&f.path) {
1314+
excluded.push(f.path.clone());
1315+
false
1316+
} else {
1317+
true
1318+
}
1319+
});
1320+
1321+
if !excluded.is_empty() {
1322+
// Recalculate stats from remaining files
1323+
changes.stats.files_changed = changes.files.len();
1324+
changes.stats.insertions = changes.files.iter().map(|f| f.additions).sum();
1325+
changes.stats.deletions = changes.files.iter().map(|f| f.deletions).sum();
1326+
1327+
progress.info(&format!(
1328+
"Excluded {}/{} files matching patterns:",
1329+
excluded.len(),
1330+
original_count,
1331+
));
1332+
for path in &excluded {
1333+
debug!(path = %path.display(), "excluded by pattern");
1334+
}
1335+
}
1336+
1337+
if changes.files.is_empty() {
1338+
return Err(Error::NoStagedChanges);
1339+
}
1340+
1341+
Ok(changes)
1342+
}
1343+
1344+
// ─── Clipboard Helpers ───
1345+
1346+
/// Copy text to the system clipboard using platform-specific commands.
1347+
fn copy_to_clipboard(text: &str) -> Result<()> {
1348+
let (cmd, args): (&str, &[&str]) = if cfg!(target_os = "macos") {
1349+
("pbcopy", &[])
1350+
} else if cfg!(target_os = "windows") {
1351+
("clip", &[])
1352+
} else {
1353+
// Linux: try xclip first, fall back to xsel
1354+
("xclip", &["-selection", "clipboard"])
1355+
};
1356+
1357+
let mut child = std::process::Command::new(cmd)
1358+
.args(args)
1359+
.stdin(std::process::Stdio::piped())
1360+
.stdout(std::process::Stdio::null())
1361+
.stderr(std::process::Stdio::piped())
1362+
.spawn()
1363+
.map_err(|e| {
1364+
Error::Config(format!(
1365+
"Failed to run clipboard command '{}': {}. Install it or use --dry-run instead.",
1366+
cmd, e
1367+
))
1368+
})?;
1369+
1370+
if let Some(ref mut stdin) = child.stdin {
1371+
use std::io::Write;
1372+
stdin.write_all(text.as_bytes())?;
1373+
}
1374+
1375+
let status = child.wait()?;
1376+
if !status.success() {
1377+
return Err(Error::Config(format!(
1378+
"Clipboard command '{}' failed with exit code {}",
1379+
cmd,
1380+
status.code().unwrap_or(-1)
1381+
)));
1382+
}
1383+
1384+
Ok(())
1385+
}
1386+
12681387
// ─── Security Helpers ───
12691388

12701389
/// Check if a URL host resolves to a loopback address (localhost, 127.0.0.1, ::1).

src/cli.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,14 @@ pub struct Cli {
4545
#[arg(long)]
4646
pub no_scope: bool,
4747

48+
/// Copy generated message to clipboard instead of committing
49+
#[arg(long)]
50+
pub clipboard: bool,
51+
52+
/// Exclude files matching glob pattern (repeatable)
53+
#[arg(long = "exclude", value_name = "GLOB")]
54+
pub exclude: Vec<String>,
55+
4856
/// Generate commit message in specified language (e.g., de, ja, fr)
4957
#[arg(long)]
5058
pub locale: Option<String>,

src/config.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,11 @@ pub struct Config {
143143
#[serde(default = "default_history_sample_size")]
144144
pub history_sample_size: usize,
145145

146+
/// Glob patterns for files to exclude from analysis and diff context
147+
/// Excluded files are listed in output but not sent to the LLM.
148+
#[serde(default)]
149+
pub exclude_patterns: Vec<String>,
150+
146151
/// Path to custom system prompt file (overrides built-in SYSTEM_PROMPT)
147152
#[serde(default)]
148153
pub system_prompt_path: Option<PathBuf>,
@@ -210,6 +215,7 @@ impl Default for Config {
210215
locale: None,
211216
learn_from_history: false,
212217
history_sample_size: default_history_sample_size(),
218+
exclude_patterns: Vec::new(),
213219
system_prompt_path: None,
214220
template_path: None,
215221
format: CommitFormat::default(),
@@ -239,6 +245,7 @@ impl std::fmt::Debug for Config {
239245
.field("locale", &self.locale)
240246
.field("learn_from_history", &self.learn_from_history)
241247
.field("history_sample_size", &self.history_sample_size)
248+
.field("exclude_patterns", &self.exclude_patterns)
242249
.field("system_prompt_path", &self.system_prompt_path)
243250
.field("template_path", &self.template_path)
244251
.field("format", &self.format)
@@ -355,6 +362,9 @@ impl Config {
355362
if let Some(ref l) = cli.locale {
356363
self.locale = Some(l.clone());
357364
}
365+
if !cli.exclude.is_empty() {
366+
self.exclude_patterns.extend(cli.exclude.iter().cloned());
367+
}
358368
Ok(())
359369
}
360370

@@ -504,6 +514,10 @@ max_file_lines = 100
504514
# Number of recent commits to sample for style learning (default: 50)
505515
# history_sample_size = 50
506516
517+
# Exclude files matching glob patterns from analysis and diff context
518+
# Excluded files are listed in output but not sent to the LLM.
519+
# exclude_patterns = ["*.lock", "**/*.generated.*"]
520+
507521
# Custom system prompt file (overrides built-in prompt)
508522
# system_prompt_path = "/path/to/system_prompt.txt"
509523

0 commit comments

Comments
 (0)