1#[path = "cli_command.rs"]
3mod cli_command;
4pub use cli_command::{Cli, Command};
5
6use crate::config::Store;
7use crate::distill::Confidence;
8use crate::ignores::Ignores;
9use crate::index::Index;
10use crate::inventory::InventoryStore;
11use crate::scanner::{self, OpenLoop, ScanOptions};
12use crate::state::State;
13use crate::{cache, distill, output, sessions, worktrees};
14use anyhow::{bail, ensure, Result};
15use sessions::SessionExcerpt;
16use std::path::{Path, PathBuf};
17
18struct ResumeEvidence {
19 default_branch: String,
20 commits: String,
21 diffstat: String,
22 excerpts: Vec<SessionExcerpt>,
23 confidence: Confidence,
24}
25
26type ScanResult = (
27 Vec<OpenLoop>,
28 Vec<(String, crate::inventory::InventoryFile)>,
29);
30
31fn progress(msg: &str) {
32 eprintln!("{msg}");
33}
34
35fn load_cfg_with_roots(base: &Path) -> Result<(Store, crate::config::Config)> {
39 let store = Store::new(base.to_path_buf());
40 let cfg = store.load()?;
41 ensure!(
42 !cfg.roots.is_empty(),
43 "no roots configured. Run: loops init <dir-with-your-repos>"
44 );
45 Ok((store, cfg))
46}
47
48fn candidate_of<'a>(l: &'a OpenLoop, key: &'a str, ignored: bool) -> crate::query::Candidate<'a> {
52 crate::query::Candidate {
53 repo_name: &l.repo_name,
54 branch: &l.branch,
55 key,
56 last_commit: l.last_commit,
57 ahead: l.ahead,
58 behind: l.behind,
59 ignored,
60 }
61}
62
63fn resolve_plan_persisting(
64 base: &Path,
65 cfg: &crate::config::Config,
66 query: &str,
67) -> Result<crate::query::ScanPlan> {
68 let mut state = State::load(base)?;
69 let plan = crate::query::resolve_plan(
70 query,
71 cfg,
72 &crate::query::ResolveOptions {
73 current_context: state.current_context(),
74 },
75 )?;
76
77 match crate::query::context_persistence_from_query(query)? {
78 crate::query::ContextPersistence::Set(name) => {
79 state.set_current_context(Some(name))?;
80 }
81 crate::query::ContextPersistence::Clear => {
82 state.set_current_context(None)?;
83 }
84 crate::query::ContextPersistence::Unchanged => {}
85 }
86
87 Ok(plan)
88}
89
90fn write_inventory(
92 inv_store: &InventoryStore,
93 updates: Vec<(String, crate::inventory::InventoryFile)>,
94) {
95 for (hash, file) in updates {
96 if let Err(e) = inv_store.save(&hash, &file) {
97 eprintln!(
98 "warning: failed to write inventory for {}: {e:#}",
99 file.repo_path.display()
100 );
101 }
102 }
103}
104
105#[allow(clippy::too_many_arguments)]
110fn scan_with_inventory(
111 base: &Path,
112 cfg: &crate::config::Config,
113 plan: &crate::query::ScanPlan,
114 roots: &[PathBuf],
115 labels: &[(PathBuf, String)],
116 need_ahead_behind: bool,
117 fresh: bool,
118 index: Option<&Index>,
119) -> Result<ScanResult> {
120 let inv_store = InventoryStore::new(base);
121 let opts = ScanOptions {
122 need_ahead_behind,
123 fresh,
124 inventory_dir: Some(inv_store.dir.clone()),
125 inventory_ttl_secs: cfg.inventory_ttl_secs,
126 };
127 let (found, warnings, inv_updates) = scanner::scan_indexed(
132 roots,
133 labels,
134 cfg.scan_depth,
135 &opts,
136 plan.repo_filters.first().map(|s| s.as_str()),
137 index,
138 );
139 for w in &warnings {
140 eprintln!("warning: {w}");
141 }
142 Ok((found, inv_updates))
143}
144
145fn resolve_loop(base: &Path, query: &str, fresh: bool) -> Result<OpenLoop> {
146 let (_store, cfg) = load_cfg_with_roots(base)?;
147 let mut plan = resolve_plan_persisting(base, &cfg, query)?;
148 plan.include_ignored = true; let labels = cfg.resolve_labels()?;
150 let roots = cfg.resolve_scan_roots(&plan)?;
151 let inv_store = InventoryStore::new(base);
152 let index = Index::open(base);
156 let (found, inv_updates) = scan_with_inventory(
157 base,
158 &cfg,
159 &plan,
160 &roots,
161 &labels,
162 plan.need_ahead_behind,
163 fresh,
164 Some(&index),
165 )?;
166 write_inventory(&inv_store, inv_updates);
167 let now = chrono::Utc::now();
168 let matches: Vec<&OpenLoop> = found
169 .iter()
170 .filter(|l| {
171 let key = l.key();
172 plan.matches(&candidate_of(l, &key, false), now)
173 })
174 .collect();
175 match matches.len() {
176 0 => bail!("no loop matches '{query}'. Run `loops` to see open ones."),
177 1 => Ok(matches[0].clone()),
178 _ => bail!(
179 "ambiguous query, candidates:\n{}",
180 matches
181 .iter()
182 .map(|l| format!(" {}", l.key()))
183 .collect::<Vec<_>>()
184 .join("\n")
185 ),
186 }
187}
188
189fn gather_resume_evidence(base: &Path, lp: &OpenLoop) -> Result<ResumeEvidence> {
190 let store = Store::new(base.to_path_buf());
191 let cfg = store.load()?;
192 let default_branch = scanner::default_branch(&lp.repo_path)?;
193 let commits = scanner::git_log(&lp.repo_path, &default_branch, &lp.branch)?;
194 let diffstat = scanner::diffstat(&lp.repo_path, &default_branch, &lp.branch)?;
195 let window = scanner::commit_window(&lp.repo_path, &default_branch, &lp.branch)?;
196 progress("matching AI sessions…");
197 let source = sessions::claude_code::ClaudeCode {
198 projects_dir: cfg.sessions_dir.clone(),
199 };
200 let index = Index::open(base);
204 let excerpts = source.excerpts_indexed(
205 &lp.repo_path,
206 &lp.branch,
207 window,
208 cfg.max_sessions,
209 cfg.max_session_kb,
210 Some(&index),
211 )?;
212 let confidence = distill::compute_confidence(&excerpts);
213 Ok(ResumeEvidence {
214 default_branch,
215 commits,
216 diffstat,
217 excerpts,
218 confidence,
219 })
220}
221
222pub fn run_list(base: &Path, query: &str, fresh: bool) -> Result<()> {
225 let (_store, cfg) = load_cfg_with_roots(base)?;
226 let mut plan = resolve_plan_persisting(base, &cfg, query)?;
227 plan.need_ahead_behind = true; let labels = cfg.resolve_labels()?;
229 let roots = cfg.resolve_scan_roots(&plan)?;
230 progress("scanning git repositories…");
231 let inv_store = InventoryStore::new(base);
232 let index = Index::open(base);
233 let need_ahead_behind = true;
234 let (found, inv_updates) = scan_with_inventory(
235 base,
236 &cfg,
237 &plan,
238 &roots,
239 &labels,
240 need_ahead_behind,
241 fresh,
242 Some(&index),
243 )?;
244 write_inventory(&inv_store, inv_updates);
245 let ignores = Ignores::load(base)?;
246 let now = chrono::Utc::now();
247 let visible: Vec<OpenLoop> = found
248 .into_iter()
249 .filter(|l| {
250 let key = l.key();
251 plan.matches(&candidate_of(l, &key, ignores.contains(&key)), now)
252 })
253 .collect();
254 if visible.is_empty() && !query.trim().is_empty() {
255 eprintln!("No loops match: {query}");
256 eprintln!("(hint: run `loops` to list all)");
257 }
258 print!("{}", output::render_table(&visible, now));
259 Ok(())
260}
261
262pub fn run_init(base: &Path, paths: &[PathBuf]) -> Result<()> {
265 ensure!(!paths.is_empty(), "usage: loops init <dir> [<dir>...]");
266 let store = Store::new(base.to_path_buf());
267 let cfg = store.add_roots(paths)?;
268 println!("roots registered:");
269 for r in &cfg.roots {
270 println!(" {}", r.display());
271 }
272 println!("\nconfig at {}", store.config_path().display());
273 Ok(())
274}
275
276pub fn run_ignore(base: &Path, key: &str) -> Result<()> {
279 ensure!(
280 key.contains('/'),
281 "expected format: repo/branch (run `loops` to see the keys)"
282 );
283 let mut ignores = Ignores::load(base)?;
284 ignores.add(key)?;
285 println!("ignored: {key}");
286 Ok(())
287}
288
289pub fn run_resume(base: &Path, query: &str, dry_run: bool, fresh: bool) -> Result<()> {
293 progress("scanning git…");
294 let lp = resolve_loop(base, query, fresh)?;
295
296 if dry_run {
297 let evidence = gather_resume_evidence(base, &lp)?;
298 let doc = distill::format_dry_run(
299 &lp,
300 &evidence.default_branch,
301 &evidence.commits,
302 &evidence.diffstat,
303 &evidence.excerpts,
304 evidence.confidence,
305 );
306 print!("{doc}");
307 return Ok(());
308 }
309
310 let cache = cache::Cache::new(base);
311 if let Some(hit) = cache.get(&lp) {
312 println!("{hit}");
313 return Ok(());
314 }
315
316 let evidence = gather_resume_evidence(base, &lp)?;
317 let prompt = distill::build_prompt(
318 &lp,
319 &evidence.default_branch,
320 &evidence.commits,
321 &evidence.diffstat,
322 &evidence.excerpts,
323 );
324 let store = Store::new(base.to_path_buf());
325 let cfg = store.load()?;
326 progress("distilling…");
327 let answer = distill::run_llm(&cfg.llm_command, &prompt)?;
328 let doc = distill::with_sources(&answer, &lp, &evidence.excerpts, evidence.confidence);
329 cache.put(&lp, &doc)?;
330 println!("{doc}");
331 Ok(())
332}
333
334pub fn run_refresh(base: &Path, query: &str) -> Result<()> {
337 let (_store, cfg) = load_cfg_with_roots(base)?;
338 let plan = resolve_plan_persisting(base, &cfg, query)?;
339 let labels = cfg.resolve_labels()?;
340 let roots = cfg.resolve_scan_roots(&plan)?;
341 progress("scanning git repositories…");
342 let inv_store = InventoryStore::new(base);
343 let index = Index::open(base);
346 let need_ahead_behind = true;
347 let fresh = true; let (found, inv_updates) = scan_with_inventory(
349 base,
350 &cfg,
351 &plan,
352 &roots,
353 &labels,
354 need_ahead_behind,
355 fresh,
356 Some(&index),
357 )?;
358
359 let scoped = if has_in_memory_filter(&plan) {
367 let ignores = Ignores::load(base)?;
368 let now = chrono::Utc::now();
369 let matching: std::collections::HashSet<&str> = found
370 .iter()
371 .filter(|l| {
372 let key = l.key();
373 plan.matches(&candidate_of(l, &key, ignores.contains(&key)), now)
374 })
375 .map(|l| l.head_sha.as_str())
376 .collect();
377 inv_updates
378 .into_iter()
379 .filter(|(_, file)| {
380 file.loops
381 .iter()
382 .any(|m| matching.contains(m.head_sha.as_str()))
383 })
384 .collect()
385 } else {
386 inv_updates
387 };
388
389 let n = scoped.len();
390 write_inventory(&inv_store, scoped);
391 inv_store.prune_orphans()?;
392 index.prune_missing_repos();
395 let noun = if n == 1 { "repo" } else { "repos" };
396 eprintln!("refreshed {n} {noun}");
397 Ok(())
398}
399
400fn has_in_memory_filter(plan: &crate::query::ScanPlan) -> bool {
405 !plan.terms.is_empty()
406 || !plan.branch_filters.is_empty()
407 || !plan.key_filters.is_empty()
408 || !plan.attr_filters.is_empty()
409}
410
411pub fn run_completions(shell: clap_complete::Shell) -> Result<()> {
412 use clap::CommandFactory;
413 let mut cmd = Cli::command();
414 clap_complete::generate(shell, &mut cmd, "loops", &mut std::io::stdout());
415 Ok(())
416}
417
418pub fn run_worktrees(base: &Path) -> Result<()> {
419 let (_store, cfg) = load_cfg_with_roots(base)?;
420 progress("scanning git worktrees…");
421 let (wts, warnings) = worktrees::scan_worktrees(&cfg.roots, cfg.scan_depth);
422 for w in &warnings {
423 eprintln!("warning: {w}");
424 }
425 print!("{}", output::render_worktrees(&wts, chrono::Utc::now()));
426 Ok(())
427}