2
0

refactor(watch): rename -f/config flag to -p/preset, default watch dir to src/

This commit is contained in:
2026-03-24 17:51:56 +09:00
parent b478846399
commit 4a47542f7a
2 changed files with 29 additions and 64 deletions

View File

@@ -115,9 +115,9 @@ fn main() {
}
}
Some("watch") => {
let dir = env::args().nth(2).unwrap_or_else(|| ".".to_string());
let config = parse_flag("-f");
if let Err(e) = aishell::watch::run(&dir, config.as_deref()) {
let dir = env::args().nth(2).unwrap_or_else(|| "src/".to_string());
let preset = parse_flag("-p");
if let Err(e) = aishell::watch::run(&dir, preset.as_deref()) {
eprintln!("aishell: {}", e);
std::process::exit(1);
}
@@ -158,7 +158,7 @@ fn print_help() {
println!(" aishell history [id] Show past sessions");
println!(" aishell history cmd [grep] Command history (filterable)");
println!(" aishell history clean Remove old sessions (keep 10)");
println!(" aishell watch <dir> [-f cfg] Watch files, auto-trigger loop");
println!(" aishell watch [dir] [-p preset] Watch files → run preset on change");
println!(" aishell next Resume loop");
println!(" aishell stop Stop the loop");
println!(" aishell help This help");

View File

@@ -3,65 +3,59 @@ use std::sync::mpsc;
use std::time::{Duration, Instant};
use notify::{Watcher, RecursiveMode, Event, EventKind};
const STATE_DIR: &str = "/tmp/aishell";
const DEBOUNCE_MS: u64 = 2000;
/// Watch a directory for file changes.
/// On change, write next=true to loop.json with changed file list.
pub fn run(dir: &str, config: Option<&str>) -> Result<(), String> {
/// Watch directory, run preset on change.
/// `aishell watch src/ -p review` → file change → review → auto-commit
pub fn run(dir: &str, preset: Option<&str>) -> Result<(), String> {
let dir = crate::config::expand_tilde(dir);
let dir_path = Path::new(&dir);
if !dir_path.is_dir() {
return Err(format!("not a directory: {dir}"));
}
// Start headless run in background if config provided
if let Some(cfg) = config {
let cfg = cfg.to_string();
std::thread::spawn(move || {
let _ = crate::headless::run(&cfg, None, None);
});
// Give it time to start
std::thread::sleep(Duration::from_secs(2));
}
eprintln!("watching: {dir}");
let preset = preset.unwrap_or("review");
eprintln!("watching: {dir} (preset: {preset})");
eprintln!("Ctrl+C to stop.\n");
let (tx, rx) = mpsc::channel::<Event>();
let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
if let Ok(event) = res {
let _ = tx.send(event);
}
if let Ok(event) = res { let _ = tx.send(event); }
}).map_err(|e| format!("watcher error: {e}"))?;
watcher.watch(dir_path, RecursiveMode::Recursive)
.map_err(|e| format!("watch error: {e}"))?;
let mut last_trigger = Instant::now();
let mut changed_files: Vec<String> = Vec::new();
let mut changed: Vec<String> = Vec::new();
loop {
match rx.recv_timeout(Duration::from_millis(500)) {
Ok(event) => {
if !is_relevant(&event) { continue; }
for path in &event.paths {
let p = path.display().to_string();
if !changed_files.contains(&p) {
changed_files.push(p);
}
for p in &event.paths {
let s = p.display().to_string();
if !changed.contains(&s) { changed.push(s); }
}
last_trigger = Instant::now();
}
Err(mpsc::RecvTimeoutError::Timeout) => {
// Debounce: if we have changes and enough time passed
if !changed_files.is_empty()
&& last_trigger.elapsed() > Duration::from_millis(DEBOUNCE_MS)
{
trigger_cycle(&changed_files);
changed_files.clear();
if !changed.is_empty() && last_trigger.elapsed() > Duration::from_millis(DEBOUNCE_MS) {
let short: Vec<&str> = changed.iter()
.map(|f| f.rsplit('/').next().unwrap_or(f))
.take(5).collect();
eprintln!(" changed: {}", short.join(", "));
// Run preset
if preset == "review" {
crate::headless::review();
} else {
let _ = crate::headless::run_preset(preset);
}
changed.clear();
eprintln!("\n watching...");
}
}
Err(_) => break,
@@ -76,35 +70,6 @@ fn is_relevant(event: &Event) -> bool {
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
) && event.paths.iter().any(|p| {
let s = p.display().to_string();
// Ignore hidden files, target/, /tmp/
!s.contains("/.") && !s.contains("/target/") && !s.contains("/tmp/")
})
}
fn trigger_cycle(files: &[String]) {
let short: Vec<&str> = files.iter()
.map(|f| f.rsplit('/').next().unwrap_or(f))
.take(5)
.collect();
eprintln!(" changed: {}", short.join(", "));
// Write to loop.json to signal the running headless loop
let loop_path = format!("{STATE_DIR}/loop.json");
if Path::new(&loop_path).exists() {
let control = serde_json::json!({
"ready": true,
"next": true,
"quit": false,
"changed_files": files,
});
let json = serde_json::to_string_pretty(&control).unwrap_or_default();
// atomic write
let tmp = format!("{loop_path}.tmp");
if std::fs::write(&tmp, &json).is_ok() {
let _ = std::fs::rename(&tmp, &loop_path);
}
} else {
eprintln!(" (no active loop to signal)");
}
}