1use std::collections::{BTreeMap, BTreeSet};
2use std::path::Path;
3
4use serde::Serialize;
5
6use crate::core::artifacts::ResolvedArtifact;
7use crate::core::tokens::count_tokens;
8
9const DEFAULT_IMPACT_DEPTH: usize = 3;
10const MAX_CHANGED_FILES_SHOWN: usize = 200;
11const MAX_DIFF_BYTES: usize = 1_048_576; #[derive(Debug, Clone, Serialize)]
14struct ChangedFile {
15 path: String,
16 status: String,
17 #[serde(skip_serializing_if = "Option::is_none")]
18 old_path: Option<String>,
19}
20
21#[derive(Debug, Clone, Serialize)]
22struct ImpactEntry {
23 file: String,
24 affected_files: Vec<String>,
25}
26
27#[derive(Debug, Serialize)]
28struct PrPackJson {
29 kind: &'static str,
30 project_root: String,
31 base: String,
32 impact_depth: usize,
33 changed_files: Vec<ChangedFile>,
34 related_tests: Vec<String>,
35 impacts: Vec<ImpactEntry>,
36 context_artifacts: Vec<ResolvedArtifact>,
37 warnings: Vec<String>,
38 tokens: u64,
39}
40
41pub fn handle(
42 action: &str,
43 project_root: &str,
44 base: Option<&str>,
45 format: Option<&str>,
46 depth: Option<usize>,
47 diff: Option<&str>,
48) -> String {
49 match action {
50 "pr" => handle_pr(project_root, base, format, depth, diff),
51 _ => "Unknown action. Use: pr, create, list, info, remove, install, export, import, auto_load, summary".to_string(),
52 }
53}
54
55#[allow(clippy::too_many_arguments)]
56pub fn handle_create(
57 project_root: &str,
58 name: &str,
59 version: Option<&str>,
60 description: Option<&str>,
61 author: Option<&str>,
62 tags: Option<&[String]>,
63 layers: Option<&[String]>,
64 level: Option<u32>,
65 scope: Option<&str>,
66) -> String {
67 let version = version.unwrap_or("1.0.0");
68 let description = description.unwrap_or("");
69 let level = level.unwrap_or(1).clamp(1, 3);
70
71 let requested_layers: Vec<&str> = layers.map_or_else(
72 || vec!["knowledge", "graph", "session", "gotchas"],
73 |l| l.iter().map(String::as_str).collect(),
74 );
75
76 let mut builder = crate::core::context_package::PackageBuilder::new(name, version)
77 .description(description)
78 .tags(tags.unwrap_or(&[]).to_vec())
79 .level(level);
80
81 if let Some(a) = author {
82 builder = builder.author(a);
83 }
84 if let Some(s) = scope {
85 builder = builder.scope(s);
86 }
87
88 let phash = crate::core::project_hash::hash_project_root(project_root);
89 builder = builder.project_hash(&phash);
90
91 if level >= 2 {
92 builder.build_context_graph(project_root);
93 }
94
95 if requested_layers.contains(&"knowledge") || requested_layers.contains(&"patterns") {
96 builder = builder.add_knowledge_from_project(project_root);
97 }
98 if requested_layers.contains(&"patterns") {
99 builder = builder.add_patterns_from_project(project_root);
100 }
101 if requested_layers.contains(&"graph") {
102 builder = builder.add_graph_from_project(project_root);
103 }
104 if requested_layers.contains(&"session") {
105 if let Some(session) = crate::core::session::SessionState::load_latest() {
106 builder = builder.add_session(&session);
107 }
108 }
109 if requested_layers.contains(&"gotchas") {
110 builder = builder.add_gotchas_from_project(project_root);
111 }
112
113 match builder.build() {
114 Ok((manifest, content)) => {
115 let registry = match crate::core::context_package::LocalRegistry::open() {
116 Ok(r) => r,
117 Err(e) => return format!("ERROR: cannot open registry: {e}"),
118 };
119
120 match registry.install(&manifest, &content) {
121 Ok(dir) => {
122 let layers_str = manifest
123 .layers
124 .iter()
125 .map(crate::core::context_package::PackageLayer::as_str)
126 .collect::<Vec<_>>()
127 .join(", ");
128 format!(
129 "Package created:\n Name: {}\n Version: {}\n Level: {}\n Layers: {}\n Knowledge facts: {}\n Graph nodes: {}\n Patterns: {}\n Gotchas: {}\n Size: {} bytes\n Stored: {}",
130 manifest.name,
131 manifest.version,
132 manifest.conformance_level.unwrap_or(1),
133 layers_str,
134 manifest.stats.knowledge_facts,
135 manifest.stats.graph_nodes,
136 manifest.stats.pattern_count,
137 manifest.stats.gotcha_count,
138 manifest.integrity.byte_size,
139 dir.display()
140 )
141 }
142 Err(e) => format!("ERROR: install failed: {e}"),
143 }
144 }
145 Err(e) => format!("ERROR: build failed: {e}"),
146 }
147}
148
149pub fn handle_list() -> String {
150 let registry = match crate::core::context_package::LocalRegistry::open() {
151 Ok(r) => r,
152 Err(e) => return format!("ERROR: {e}"),
153 };
154
155 match registry.list() {
156 Ok(entries) => {
157 if entries.is_empty() {
158 return "No packages installed.".to_string();
159 }
160 let mut out = String::new();
161 out.push_str(&format!("{} package(s):\n", entries.len()));
162 for e in &entries {
163 out.push_str(&format!(
164 "- {} v{} [{}] ({} bytes){}\n",
165 e.name,
166 e.version,
167 e.layers.join(", "),
168 e.byte_size,
169 if e.auto_load { " [auto-load]" } else { "" }
170 ));
171 }
172 out
173 }
174 Err(e) => format!("ERROR: {e}"),
175 }
176}
177
178pub fn handle_info(name: &str, version: Option<&str>) -> String {
179 let registry = match crate::core::context_package::LocalRegistry::open() {
180 Ok(r) => r,
181 Err(e) => return format!("ERROR: {e}"),
182 };
183
184 let resolved_ver;
185 let ver = if let Some(v) = version {
186 v
187 } else {
188 resolved_ver = registry
189 .list()
190 .ok()
191 .and_then(|entries| {
192 entries
193 .iter()
194 .filter(|e| e.name == name)
195 .max_by(|a, b| a.installed_at.cmp(&b.installed_at))
196 .map(|e| e.version.clone())
197 })
198 .unwrap_or_default();
199 &resolved_ver
200 };
201
202 match registry.load_package(name, ver) {
203 Ok((manifest, content)) => {
204 let layers_str = manifest
205 .layers
206 .iter()
207 .map(crate::core::context_package::PackageLayer::as_str)
208 .collect::<Vec<_>>()
209 .join(", ");
210 let mut out = format!(
211 "Package: {} v{}\nSchema: v{}\nLevel: {}\nLayers: {}\nDescription: {}\n",
212 manifest.name,
213 manifest.version,
214 manifest.schema_version,
215 manifest.conformance_level.unwrap_or(1),
216 layers_str,
217 manifest.description,
218 );
219 if let Some(ref a) = manifest.author {
220 out.push_str(&format!("Author: {a}\n"));
221 }
222 if let Some(ref s) = manifest.scope {
223 out.push_str(&format!("Scope: {s}\n"));
224 }
225 if !manifest.tags.is_empty() {
226 out.push_str(&format!("Tags: {}\n", manifest.tags.join(", ")));
227 }
228 out.push_str(&format!(
229 "Created: {}\nStats:\n Knowledge facts: {}\n Graph nodes: {}\n Graph edges: {}\n Patterns: {}\n Gotchas: {}\n Compression: {:.1}%\n Est. tokens: ~{}\nIntegrity:\n SHA256: {}\n Size: {} bytes\n",
230 manifest.created_at.format("%Y-%m-%d %H:%M UTC"),
231 manifest.stats.knowledge_facts,
232 manifest.stats.graph_nodes,
233 manifest.stats.graph_edges,
234 manifest.stats.pattern_count,
235 manifest.stats.gotcha_count,
236 manifest.stats.compression_ratio * 100.0,
237 content.estimated_token_count(),
238 manifest.integrity.sha256,
239 manifest.integrity.byte_size,
240 ));
241 out
242 }
243 Err(e) => format!("ERROR: {e}"),
244 }
245}
246
247pub fn handle_remove(name: &str, version: Option<&str>) -> String {
248 let registry = match crate::core::context_package::LocalRegistry::open() {
249 Ok(r) => r,
250 Err(e) => return format!("ERROR: {e}"),
251 };
252
253 match registry.remove(name, version) {
254 Ok(0) => format!("No matching package found: {name}"),
255 Ok(n) => format!("Removed {n} package(s)."),
256 Err(e) => format!("ERROR: {e}"),
257 }
258}
259
260pub fn handle_install(name: &str, version: Option<&str>, project_root: &str) -> String {
261 let registry = match crate::core::context_package::LocalRegistry::open() {
262 Ok(r) => r,
263 Err(e) => return format!("ERROR: {e}"),
264 };
265
266 let resolved_ver;
267 let ver = if let Some(v) = version {
268 v
269 } else {
270 resolved_ver = registry
271 .list()
272 .ok()
273 .and_then(|entries| {
274 entries
275 .iter()
276 .filter(|e| e.name == name)
277 .max_by(|a, b| a.installed_at.cmp(&b.installed_at))
278 .map(|e| e.version.clone())
279 })
280 .unwrap_or_default();
281 &resolved_ver
282 };
283
284 match registry.load_package(name, ver) {
285 Ok((manifest, content)) => {
286 match crate::core::context_package::load_package(&manifest, &content, project_root) {
287 Ok(report) => format!("{report}\nPackage applied successfully."),
288 Err(e) => format!("ERROR: load failed: {e}"),
289 }
290 }
291 Err(e) => format!("ERROR: {e}"),
292 }
293}
294
295pub fn handle_export(name: &str, version: Option<&str>, output: Option<&str>) -> String {
296 let registry = match crate::core::context_package::LocalRegistry::open() {
297 Ok(r) => r,
298 Err(e) => return format!("ERROR: {e}"),
299 };
300
301 let resolved_ver;
302 let ver = if let Some(v) = version {
303 v
304 } else {
305 resolved_ver = registry
306 .list()
307 .ok()
308 .and_then(|entries| {
309 entries
310 .iter()
311 .filter(|e| e.name == name)
312 .max_by(|a, b| a.installed_at.cmp(&b.installed_at))
313 .map(|e| e.version.clone())
314 })
315 .unwrap_or_default();
316 &resolved_ver
317 };
318
319 let out_path = output.map_or_else(
320 || crate::core::contracts::default_package_filename(name, ver),
321 ToString::to_string,
322 );
323
324 match registry.export_to_file(name, ver, &std::path::PathBuf::from(&out_path)) {
325 Ok(bytes) => format!("Exported: {out_path} ({bytes} bytes)"),
326 Err(e) => format!("ERROR: {e}"),
327 }
328}
329
330pub fn handle_import(file_path: &str, apply: bool, project_root: &str) -> String {
331 let registry = match crate::core::context_package::LocalRegistry::open() {
332 Ok(r) => r,
333 Err(e) => return format!("ERROR: {e}"),
334 };
335
336 match registry.import_from_file(std::path::Path::new(file_path)) {
337 Ok(manifest) => {
338 let layers_str = manifest
339 .layers
340 .iter()
341 .map(crate::core::context_package::PackageLayer::as_str)
342 .collect::<Vec<_>>()
343 .join(", ");
344 let mut out = format!(
345 "Imported: {} v{}\n Layers: {}\n Size: {} bytes\n",
346 manifest.name, manifest.version, layers_str, manifest.integrity.byte_size,
347 );
348 if apply {
349 match crate::core::context_package::LocalRegistry::open() {
350 Ok(reg) => match reg.load_package(&manifest.name, &manifest.version) {
351 Ok((m, c)) => {
352 match crate::core::context_package::load_package(&m, &c, project_root) {
353 Ok(report) => {
354 out.push_str(&format!("{report}\nPackage applied."));
355 }
356 Err(e) => out.push_str(&format!("ERROR applying: {e}")),
357 }
358 }
359 Err(e) => out.push_str(&format!("ERROR loading: {e}")),
360 },
361 Err(e) => out.push_str(&format!("ERROR: {e}")),
362 }
363 }
364 out
365 }
366 Err(e) => format!("ERROR: import failed: {e}"),
367 }
368}
369
370pub fn handle_auto_load(name: Option<&str>, version: Option<&str>, enable: bool) -> String {
371 let registry = match crate::core::context_package::LocalRegistry::open() {
372 Ok(r) => r,
373 Err(e) => return format!("ERROR: {e}"),
374 };
375
376 let Some(name) = name else {
377 return match registry.auto_load_packages() {
378 Ok(entries) => {
379 if entries.is_empty() {
380 "No packages set for auto-load.".to_string()
381 } else {
382 let mut out = "Auto-load packages:\n".to_string();
383 for e in &entries {
384 out.push_str(&format!("- {} v{}\n", e.name, e.version));
385 }
386 out
387 }
388 }
389 Err(e) => format!("ERROR: {e}"),
390 };
391 };
392
393 let resolved_ver;
394 let ver = if let Some(v) = version {
395 v
396 } else {
397 resolved_ver = registry
398 .list()
399 .ok()
400 .and_then(|entries| {
401 entries
402 .iter()
403 .filter(|e| e.name == name)
404 .max_by(|a, b| a.installed_at.cmp(&b.installed_at))
405 .map(|e| e.version.clone())
406 })
407 .unwrap_or_default();
408 &resolved_ver
409 };
410
411 match registry.set_auto_load(name, ver, enable) {
412 Ok(()) => {
413 if enable {
414 format!("Auto-load enabled for {name}@{ver}")
415 } else {
416 format!("Auto-load disabled for {name}@{ver}")
417 }
418 }
419 Err(e) => format!("ERROR: {e}"),
420 }
421}
422
423pub fn handle_summary(project_root: &str) -> String {
424 let phash = crate::core::project_hash::hash_project_root(project_root);
425
426 let registry = match crate::core::context_package::LocalRegistry::open() {
427 Ok(r) => r,
428 Err(e) => return format!("ERROR: {e}"),
429 };
430
431 let entries = registry.list().unwrap_or_default();
432 let matching: Vec<_> = entries.iter().filter(|_| true).collect();
433
434 let mut out = format!("Project: {project_root}\nProject hash: {phash}\n");
435 out.push_str(&format!("Installed packages: {}\n", matching.len()));
436
437 if !matching.is_empty() {
438 out.push_str("\nPackages:\n");
439 for e in &matching {
440 out.push_str(&format!(
441 "- {} v{} [{}]{}\n",
442 e.name,
443 e.version,
444 e.layers.join(", "),
445 if e.auto_load { " [auto-load]" } else { "" }
446 ));
447 }
448 }
449
450 let auto_count = matching.iter().filter(|e| e.auto_load).count();
451 out.push_str(&format!("Auto-load: {auto_count} package(s)\n"));
452 out
453}
454
455fn handle_pr(
456 project_root: &str,
457 base: Option<&str>,
458 format: Option<&str>,
459 depth: Option<usize>,
460 diff: Option<&str>,
461) -> String {
462 let root = project_root.to_string();
463 let base = base.map_or_else(
464 || detect_default_base(&root).unwrap_or_else(|| "HEAD~1".to_string()),
465 ToString::to_string,
466 );
467 let impact_depth = depth.unwrap_or(DEFAULT_IMPACT_DEPTH).max(1);
468
469 let mut warnings: Vec<String> = Vec::new();
470 let mut changed = if let Some(d) = diff {
471 if d.len() > MAX_DIFF_BYTES {
472 warnings.push(format!(
473 "Diff input too large ({} bytes, limit {MAX_DIFF_BYTES}). Truncating at char boundary.",
474 d.len()
475 ));
476 let mut boundary = MAX_DIFF_BYTES;
477 while boundary > 0 && !d.is_char_boundary(boundary) {
478 boundary -= 1;
479 }
480 let truncated = &d[..boundary];
481 parse_changes_from_input(truncated)
482 } else {
483 parse_changes_from_input(d)
484 }
485 } else {
486 git_diff_name_status(&root, &base, &mut warnings)
487 };
488
489 if changed.len() > MAX_CHANGED_FILES_SHOWN {
490 warnings.push(format!(
491 "Too many changed files ({}). Truncating to {MAX_CHANGED_FILES_SHOWN}.",
492 changed.len()
493 ));
494 changed.truncate(MAX_CHANGED_FILES_SHOWN);
495 }
496
497 let related_tests = collect_related_tests(&changed, &root);
498 let impacts = collect_impacts(&changed, &root, impact_depth);
499 let context_artifacts = collect_relevant_artifacts(&changed, &root, &mut warnings);
500
501 let format = format.unwrap_or("markdown");
502 match format {
503 "json" => {
504 let mut json = PrPackJson {
505 kind: "leanctx.pr_pack",
506 project_root: root,
507 base,
508 impact_depth,
509 changed_files: changed,
510 related_tests,
511 impacts,
512 context_artifacts,
513 warnings,
514 tokens: 0,
515 };
516 match serde_json::to_string_pretty(&json) {
517 Ok(s) => {
518 json.tokens = count_tokens(&s) as u64;
519 serde_json::to_string_pretty(&json)
520 .unwrap_or_else(|e| format!("{{\"error\": \"serialization failed: {e}\"}}"))
521 }
522 Err(e) => format!("{{\"error\": \"serialization failed: {e}\"}}"),
523 }
524 }
525 _ => format_markdown(
526 project_root,
527 &base,
528 impact_depth,
529 &changed,
530 &related_tests,
531 &impacts,
532 &context_artifacts,
533 &warnings,
534 ),
535 }
536}
537
538fn format_markdown(
539 project_root: &str,
540 base: &str,
541 impact_depth: usize,
542 changed: &[ChangedFile],
543 related_tests: &[String],
544 impacts: &[ImpactEntry],
545 artifacts: &[ResolvedArtifact],
546 warnings: &[String],
547) -> String {
548 let mut out = String::new();
549 out.push_str("# PR Context Pack\n\n");
550 out.push_str(&format!("- Project root: `{project_root}`\n"));
551 out.push_str(&format!("- Base: `{base}`\n"));
552 out.push_str(&format!("- Impact depth: `{impact_depth}`\n\n"));
553
554 if !warnings.is_empty() {
555 out.push_str("## Warnings\n");
556 for w in warnings {
557 out.push_str(&format!("- {w}\n"));
558 }
559 out.push('\n');
560 }
561
562 out.push_str("## Changed files\n");
563 for c in changed {
564 match &c.old_path {
565 Some(old) => out.push_str(&format!("- `{}` ({}) ← `{old}`\n", c.path, c.status)),
566 None => out.push_str(&format!("- `{}` ({})\n", c.path, c.status)),
567 }
568 }
569 out.push('\n');
570
571 if !artifacts.is_empty() {
572 out.push_str("## Context artifacts\n");
573 for a in artifacts {
574 let kind = if a.is_dir { "dir" } else { "file" };
575 let exists = if a.exists { "exists" } else { "missing" };
576 out.push_str(&format!(
577 "- `{}` ({kind}, {exists}) — {}\n",
578 a.path, a.description
579 ));
580 }
581 out.push('\n');
582 }
583
584 if !related_tests.is_empty() {
585 out.push_str("## Related tests\n");
586 for t in related_tests {
587 out.push_str(&format!("- `{t}`\n"));
588 }
589 out.push('\n');
590 }
591
592 if !impacts.is_empty() {
593 out.push_str("## Impact (property graph)\n");
594 for imp in impacts {
595 out.push_str(&format!(
596 "- `{}`: {} affected files\n",
597 imp.file,
598 imp.affected_files.len()
599 ));
600 for f in imp.affected_files.iter().take(30) {
601 out.push_str(&format!(" - `{f}`\n"));
602 }
603 if imp.affected_files.len() > 30 {
604 out.push_str(" - ...\n");
605 }
606 }
607 out.push('\n');
608 }
609
610 let tokens = count_tokens(&out);
611 out.push_str(&format!("[ctx_pack pr: {tokens} tok]\n"));
612 out
613}
614
615fn collect_related_tests(changed: &[ChangedFile], project_root: &str) -> Vec<String> {
616 let mut all: BTreeSet<String> = BTreeSet::new();
617 for c in changed {
618 for t in crate::tools::ctx_review::find_related_tests(&c.path, project_root) {
619 all.insert(t);
620 }
621 }
622 all.into_iter().collect()
623}
624
625fn collect_impacts(changed: &[ChangedFile], project_root: &str, depth: usize) -> Vec<ImpactEntry> {
626 let mut out = Vec::new();
627 for c in changed {
628 if c.status == "D" {
629 continue;
630 }
631 let raw = crate::tools::ctx_impact::handle(
632 "analyze",
633 Some(&c.path),
634 project_root,
635 Some(depth),
636 None,
637 );
638 let affected = parse_ctx_impact_output(&raw);
639 out.push(ImpactEntry {
640 file: c.path.clone(),
641 affected_files: affected,
642 });
643 }
644 out
645}
646
647fn parse_ctx_impact_output(raw: &str) -> Vec<String> {
648 let mut out: Vec<String> = Vec::new();
649 for line in raw.lines() {
650 let l = line.trim_end();
651 if let Some(rest) = l.strip_prefix(" ") {
652 let item = rest.trim().to_string();
653 if item.starts_with("...") {
654 continue;
655 }
656 if !item.is_empty() {
657 out.push(item);
658 }
659 }
660 }
661 out.sort();
662 out.dedup();
663 out
664}
665
666fn collect_relevant_artifacts(
667 changed: &[ChangedFile],
668 project_root: &str,
669 warnings: &mut Vec<String>,
670) -> Vec<ResolvedArtifact> {
671 let root = Path::new(project_root);
672 let resolved = crate::core::artifacts::load_resolved(root);
673 warnings.extend(resolved.warnings);
674
675 let mut out: Vec<ResolvedArtifact> = Vec::new();
676 for a in resolved.artifacts {
677 if !a.exists {
678 continue;
679 }
680 if is_artifact_relevant(&a, changed) {
681 out.push(a);
682 }
683 }
684 out.sort_by(|a, b| a.path.cmp(&b.path).then_with(|| a.name.cmp(&b.name)));
685 out
686}
687
688fn is_artifact_relevant(a: &ResolvedArtifact, changed: &[ChangedFile]) -> bool {
689 if a.path.is_empty() {
690 return false;
691 }
692 if a.is_dir {
693 let prefix = if a.path.ends_with('/') {
694 a.path.clone()
695 } else {
696 format!("{}/", a.path)
697 };
698 return changed.iter().any(|c| c.path.starts_with(&prefix));
699 }
700 changed.iter().any(|c| c.path == a.path)
701}
702
703fn parse_changes_from_input(input: &str) -> Vec<ChangedFile> {
704 if input.contains("diff --git") || input.contains("\n+++ ") || input.starts_with("diff --git") {
705 let paths = parse_unified_diff_paths(input);
706 let mut out = Vec::new();
707 for p in paths {
708 out.push(ChangedFile {
709 path: p,
710 status: "M".to_string(),
711 old_path: None,
712 });
713 }
714 return dedup_changes(out);
715 }
716
717 let mut out = Vec::new();
718 for line in input.lines() {
719 let trimmed = line.trim();
720 if trimmed.is_empty() {
721 continue;
722 }
723 let parts: Vec<&str> = trimmed.split_whitespace().collect();
724 if parts.len() >= 2 {
725 let status = parts[0].to_string();
726 if status.starts_with('R') && parts.len() >= 3 {
727 out.push(ChangedFile {
728 path: parts[2].to_string(),
729 status: "R".to_string(),
730 old_path: Some(parts[1].to_string()),
731 });
732 } else {
733 out.push(ChangedFile {
734 path: parts[1].to_string(),
735 status: status.chars().next().unwrap_or('M').to_string(),
736 old_path: None,
737 });
738 }
739 } else {
740 out.push(ChangedFile {
741 path: trimmed.to_string(),
742 status: "M".to_string(),
743 old_path: None,
744 });
745 }
746 }
747 dedup_changes(out)
748}
749
750fn parse_unified_diff_paths(diff: &str) -> Vec<String> {
751 let mut out: BTreeSet<String> = BTreeSet::new();
752 for line in diff.lines() {
753 if let Some(rest) = line.strip_prefix("+++ b/") {
754 let p = rest.trim();
755 if !p.is_empty() && p != "/dev/null" {
756 out.insert(p.to_string());
757 }
758 }
759 if let Some(rest) = line.strip_prefix("--- a/") {
760 let p = rest.trim();
761 if !p.is_empty() && p != "/dev/null" {
762 out.insert(p.to_string());
763 }
764 }
765 }
766 out.into_iter().collect()
767}
768
769fn git_diff_name_status(
770 project_root: &str,
771 base: &str,
772 warnings: &mut Vec<String>,
773) -> Vec<ChangedFile> {
774 let out = std::process::Command::new("git")
775 .args(["diff", "--name-status", &format!("{base}...HEAD")])
776 .current_dir(project_root)
777 .stdout(std::process::Stdio::piped())
778 .stderr(std::process::Stdio::piped())
779 .output();
780 let Ok(o) = out else {
781 warnings.push("Failed to execute git diff".to_string());
782 return Vec::new();
783 };
784 if !o.status.success() {
785 let stderr = String::from_utf8_lossy(&o.stderr);
786 warnings.push(format!("git diff failed: {}", stderr.trim()));
787 return Vec::new();
788 }
789 let s = String::from_utf8_lossy(&o.stdout);
790 parse_changes_from_input(&s)
791}
792
793fn detect_default_base(project_root: &str) -> Option<String> {
794 for cand in ["origin/main", "origin/master", "main", "master"] {
795 let ok = std::process::Command::new("git")
796 .args(["rev-parse", "--verify", cand])
797 .current_dir(project_root)
798 .stdout(std::process::Stdio::null())
799 .stderr(std::process::Stdio::null())
800 .status()
801 .ok()
802 .is_some_and(|s| s.success());
803 if ok {
804 return Some(cand.to_string());
805 }
806 }
807 None
808}
809
810fn dedup_changes(mut changes: Vec<ChangedFile>) -> Vec<ChangedFile> {
811 let mut seen: BTreeMap<String, usize> = BTreeMap::new();
812 let mut out: Vec<ChangedFile> = Vec::new();
813 for c in changes.drain(..) {
814 let key = c.path.clone();
815 if let Some(i) = seen.get(&key) {
816 out[*i] = c;
817 continue;
818 }
819 seen.insert(key, out.len());
820 out.push(c);
821 }
822 out
823}