1use crate::config::Store;
3use crate::distill::Confidence;
4use crate::ignores::Ignores;
5use crate::scanner::{self, OpenLoop};
6use crate::{cache, distill, output, sessions, worktrees};
7use anyhow::{bail, ensure, Result};
8use clap::{Parser, Subcommand};
9use sessions::{SessionExcerpt, SessionSource};
10use std::path::{Path, PathBuf};
11
12#[derive(Parser)]
13#[command(name = "loops", version, about = "Recover the context of paused work")]
14#[command(args_conflicts_with_subcommands = true)]
15pub struct Cli {
16 #[command(subcommand)]
17 pub command: Option<Command>,
18 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
20 pub query: Vec<String>,
21}
22
23#[derive(Subcommand)]
24pub enum Command {
25 Init { paths: Vec<PathBuf> },
27 Resume {
29 query: String,
30 #[arg(long)]
32 dry_run: bool,
33 },
34 Ignore { key: String },
36 #[command(visible_alias = "wt")]
38 Worktrees,
39 Completions { shell: clap_complete::Shell },
41}
42
43struct ResumeEvidence {
44 default_branch: String,
45 commits: String,
46 diffstat: String,
47 excerpts: Vec<SessionExcerpt>,
48 confidence: Confidence,
49}
50
51fn progress(msg: &str) {
52 eprintln!("{msg}");
53}
54
55fn resolve_loop(base: &Path, query: &str) -> Result<OpenLoop> {
56 let store = Store::new(base.to_path_buf());
57 let cfg = store.load()?;
58 ensure!(
59 !cfg.roots.is_empty(),
60 "no roots configured. Run: loops init <dir-with-your-repos>"
61 );
62 let mut plan = crate::query::parse(query)?;
63 plan.include_ignored = true; let labels = cfg.resolve_labels()?;
65 let (found, warnings) = scanner::scan(&cfg.roots, &labels, cfg.scan_depth);
66 for w in &warnings {
67 eprintln!("warning: {w}");
68 }
69 let now = chrono::Utc::now();
70 let matches: Vec<&OpenLoop> = found
71 .iter()
72 .filter(|l| {
73 let key = l.key();
74 plan.matches(
75 &crate::query::Candidate {
76 repo_name: &l.repo_name,
77 branch: &l.branch,
78 key: &key,
79 last_commit: l.last_commit,
80 ahead: Some(l.ahead),
81 behind: Some(l.behind),
82 ignored: false,
83 },
84 now,
85 )
86 })
87 .collect();
88 match matches.len() {
89 0 => bail!("no loop matches '{query}'. Run `loops` to see open ones."),
90 1 => Ok(matches[0].clone()),
91 _ => bail!(
92 "ambiguous query, candidates:\n{}",
93 matches
94 .iter()
95 .map(|l| format!(" {}", l.key()))
96 .collect::<Vec<_>>()
97 .join("\n")
98 ),
99 }
100}
101
102fn gather_resume_evidence(base: &Path, lp: &OpenLoop) -> Result<ResumeEvidence> {
103 let store = Store::new(base.to_path_buf());
104 let cfg = store.load()?;
105 let default_branch = scanner::default_branch(&lp.repo_path)?;
106 let commits = scanner::git_log(&lp.repo_path, &default_branch, &lp.branch)?;
107 let diffstat = scanner::diffstat(&lp.repo_path, &default_branch, &lp.branch)?;
108 let window = scanner::commit_window(&lp.repo_path, &default_branch, &lp.branch)?;
109 progress("matching AI sessions…");
110 let source = sessions::claude_code::ClaudeCode {
111 projects_dir: cfg.sessions_dir.clone(),
112 };
113 let excerpts = source.excerpts(
114 &lp.repo_path,
115 &lp.branch,
116 window,
117 cfg.max_sessions,
118 cfg.max_session_kb,
119 )?;
120 let confidence = distill::compute_confidence(&excerpts);
121 Ok(ResumeEvidence {
122 default_branch,
123 commits,
124 diffstat,
125 excerpts,
126 confidence,
127 })
128}
129
130pub fn run_list(base: &Path, query: &str) -> Result<()> {
131 let store = Store::new(base.to_path_buf());
132 let cfg = store.load()?;
133 ensure!(
134 !cfg.roots.is_empty(),
135 "no roots configured. Run: loops init <dir-with-your-repos>"
136 );
137 let plan = crate::query::parse(query)?;
138 let labels = cfg.resolve_labels()?;
139 progress("scanning git repositories…");
140 let (found, warnings) = scanner::scan(&cfg.roots, &labels, cfg.scan_depth);
141 for w in &warnings {
142 eprintln!("warning: {w}");
143 }
144 let ignores = Ignores::load(base)?;
145 let now = chrono::Utc::now();
146 let visible: Vec<OpenLoop> = found
147 .into_iter()
148 .filter(|l| {
149 let key = l.key();
150 plan.matches(
151 &crate::query::Candidate {
152 repo_name: &l.repo_name,
153 branch: &l.branch,
154 key: &key,
155 last_commit: l.last_commit,
156 ahead: Some(l.ahead),
157 behind: Some(l.behind),
158 ignored: ignores.contains(&key),
159 },
160 now,
161 )
162 })
163 .collect();
164 if visible.is_empty() && !query.trim().is_empty() {
165 eprintln!("No loops match: {query}");
166 eprintln!("(hint: run `loops` to list all)");
167 }
168 print!("{}", output::render_table(&visible, now));
169 Ok(())
170}
171
172pub fn run_init(base: &Path, paths: &[PathBuf]) -> Result<()> {
173 ensure!(!paths.is_empty(), "usage: loops init <dir> [<dir>...]");
174 let store = Store::new(base.to_path_buf());
175 let cfg = store.add_roots(paths)?;
176 println!("roots registered:");
177 for r in &cfg.roots {
178 println!(" {}", r.display());
179 }
180 println!("\nconfig at {}", store.config_path().display());
181 Ok(())
182}
183
184pub fn run_ignore(base: &Path, key: &str) -> Result<()> {
185 ensure!(
186 key.contains('/'),
187 "expected format: repo/branch (run `loops` to see the keys)"
188 );
189 let mut ignores = Ignores::load(base)?;
190 ignores.add(key)?;
191 println!("ignored: {key}");
192 Ok(())
193}
194
195pub fn run_resume(base: &Path, query: &str, dry_run: bool) -> Result<()> {
196 progress("scanning git…");
197 let lp = resolve_loop(base, query)?;
198
199 if dry_run {
200 let evidence = gather_resume_evidence(base, &lp)?;
201 let doc = distill::format_dry_run(
202 &lp,
203 &evidence.default_branch,
204 &evidence.commits,
205 &evidence.diffstat,
206 &evidence.excerpts,
207 evidence.confidence,
208 );
209 print!("{doc}");
210 return Ok(());
211 }
212
213 let cache = cache::Cache::new(base);
214 if let Some(hit) = cache.get(&lp) {
215 println!("{hit}");
216 return Ok(());
217 }
218
219 let evidence = gather_resume_evidence(base, &lp)?;
220 let prompt = distill::build_prompt(
221 &lp,
222 &evidence.default_branch,
223 &evidence.commits,
224 &evidence.diffstat,
225 &evidence.excerpts,
226 );
227 let store = Store::new(base.to_path_buf());
228 let cfg = store.load()?;
229 progress("distilling…");
230 let answer = distill::run_llm(&cfg.llm_command, &prompt)?;
231 let doc = distill::with_sources(&answer, &lp, &evidence.excerpts, evidence.confidence);
232 cache.put(&lp, &doc)?;
233 println!("{doc}");
234 Ok(())
235}
236
237pub fn run_completions(shell: clap_complete::Shell) -> Result<()> {
238 use clap::CommandFactory;
239 let mut cmd = Cli::command();
240 clap_complete::generate(shell, &mut cmd, "loops", &mut std::io::stdout());
241 Ok(())
242}
243
244pub fn run_worktrees(base: &Path) -> Result<()> {
245 let store = Store::new(base.to_path_buf());
246 let cfg = store.load()?;
247 ensure!(
248 !cfg.roots.is_empty(),
249 "no roots configured. Run: loops init <dir-with-your-repos>"
250 );
251 progress("scanning git worktrees…");
252 let (wts, warnings) = worktrees::scan_worktrees(&cfg.roots, cfg.scan_depth);
253 for w in &warnings {
254 eprintln!("warning: {w}");
255 }
256 print!("{}", output::render_worktrees(&wts, chrono::Utc::now()));
257 Ok(())
258}