1use std::fs;
2use std::io::{IsTerminal, Read, Write};
3use std::path::{Component, Path, PathBuf};
4
5use anyhow::{Context, Result, bail};
6use clap::{ArgGroup, Args, CommandFactory, Parser, Subcommand, ValueEnum};
7use clap_complete::{Shell, generate};
8use serde_json::{Value, json};
9use sha2::Digest;
10
11use crate::build_info;
12use crate::config;
13use crate::error::{ClassifiedError, ErrorKind};
14use crate::health;
15use crate::report;
16use crate::report_ops::{
17 ExplainSelector, PlanSelector, PromptPackOptions, compare_payload_with_policy, explain_payload,
18 failing_records_in, health_json_payload, plan_payload, render_compare_text,
19 render_explain_summary_text, render_explain_text, render_github_annotations, render_json,
20 render_plan_text, render_show_text, sarif_payload, show_payload, write_prompt_pack,
21};
22use crate::{PROJECT_NAME, VERSION, analyze, git};
23
24#[derive(Debug, Parser)]
25#[command(
26 name = "git-slop",
27 about = "Find the files that cost too much context.",
28 after_help = "QUICK START:\n git slop find Run a safe first scan\n git slop health Review repository health\n git slop list interventions Review maintenance candidates\n git slop html Build an interactive local report\n git slop init Adopt durable reports and ignore rules",
29 version = VERSION
30)]
31struct Cli {
32 #[arg(long, global = true, value_name = "PATH")]
34 repo: Option<PathBuf>,
35 #[arg(long, global = true, value_enum, default_value_t = ErrorFormat::Human)]
37 error_format: ErrorFormat,
38 #[command(subcommand)]
39 command: Command,
40}
41
42#[derive(Debug, Subcommand)]
43enum Command {
44 Init(InitArgs),
46 Find(FindArgs),
48 Show(ShowArgs),
50 Explain(ExplainArgs),
52 Plan(PlanArgs),
54 Policy(PolicyArgs),
56 Advise(Box<AdviseArgs>),
58 Check(CheckArgs),
60 Compare(CompareArgs),
62 Baseline(BaselineArgs),
64 Report(ReportArgs),
66 Sarif(SarifArgs),
68 Health(HealthArgs),
70 Config(ConfigArgs),
72 Doctor(DoctorArgs),
74 List(ListArgs),
76 Prune(PruneArgs),
78 Cache(CacheArgs),
80 Completions(CompletionsArgs),
82 Man(ManArgs),
84 Reference(ReferenceArgs),
86 Html(HtmlArgs),
88 Version,
90 BuildInfo(BuildInfoArgs),
92 Schema(SchemaArgs),
94}
95
96impl Command {
97 fn name(&self) -> &'static str {
98 match self {
99 Self::Init(_) => "init",
100 Self::Find(_) => "find",
101 Self::Show(_) => "show",
102 Self::Explain(_) => "explain",
103 Self::Plan(_) => "plan",
104 Self::Policy(_) => "policy",
105 Self::Advise(_) => "advise",
106 Self::Check(_) => "check",
107 Self::Compare(_) => "compare",
108 Self::Baseline(_) => "baseline",
109 Self::Report(_) => "report",
110 Self::Sarif(_) => "sarif",
111 Self::Health(_) => "health",
112 Self::Config(_) => "config",
113 Self::Doctor(_) => "doctor",
114 Self::List(_) => "list",
115 Self::Prune(_) => "prune",
116 Self::Cache(_) => "cache",
117 Self::Completions(_) => "completions",
118 Self::Man(_) => "man",
119 Self::Reference(_) => "reference",
120 Self::Html(_) => "html",
121 Self::Version => "version",
122 Self::BuildInfo(_) => "build-info",
123 Self::Schema(_) => "schema",
124 }
125 }
126}
127
128#[derive(Debug, Args)]
129struct FindArgs {
130 #[arg(long)]
132 allow_shallow: bool,
133 #[arg(long)]
135 scope: Option<String>,
136 #[arg(long)]
138 allow_empty_scope: bool,
139 #[arg(long)]
141 quiet: bool,
142 #[arg(long)]
144 no_progress: bool,
145 #[arg(long, value_name = "PATH")]
147 state_dir: Option<PathBuf>,
148 #[arg(long, value_name = "PATH")]
150 output_dir: Option<PathBuf>,
151 #[arg(long)]
153 no_cache: bool,
154 #[arg(long, conflicts_with_all = ["state_dir", "output_dir", "persist_unadopted"])]
156 ephemeral: bool,
157 #[arg(long, conflicts_with = "ephemeral")]
159 persist_unadopted: bool,
160 #[arg(long)]
162 allow_degraded: bool,
163 #[arg(long, value_name = "RFC3339")]
165 as_of: Option<String>,
166 #[arg(long, value_enum, default_value_t = ReportProfile::Standard)]
168 report_profile: ReportProfile,
169 #[arg(long, value_enum, default_value_t = ReportCompression::None)]
171 compression: ReportCompression,
172 #[arg(long)]
174 estimate_only: bool,
175 #[arg(long, value_enum, requires = "estimate_only")]
177 format: Option<DisplayFormat>,
178}
179
180#[derive(Debug, Clone, Copy, ValueEnum)]
181enum ReportProfile {
182 Compact,
183 Standard,
184 FullEvidence,
185}
186
187impl ReportProfile {
188 fn as_str(self) -> &'static str {
189 match self {
190 Self::Compact => "compact",
191 Self::Standard => "standard",
192 Self::FullEvidence => "full_evidence",
193 }
194 }
195}
196
197#[derive(Debug, Clone, Copy, ValueEnum)]
198enum ReportCompression {
199 None,
200 Gzip,
201 Zstd,
202}
203
204impl ReportCompression {
205 fn as_str(self) -> &'static str {
206 match self {
207 Self::None => "none",
208 Self::Gzip => "gzip",
209 Self::Zstd => "zstd",
210 }
211 }
212}
213
214#[derive(Debug, Args)]
215struct BuildInfoArgs {
216 #[arg(long, value_enum, default_value_t = BuildInfoFormat::Json)]
218 format: BuildInfoFormat,
219}
220
221#[derive(Debug, Args)]
222#[command(group(
223 ArgGroup::new("mode")
224 .args(["force", "repair", "check"])
225 .multiple(false)
226))]
227struct InitArgs {
228 #[arg(long)]
230 force: bool,
231 #[arg(long)]
233 repair: bool,
234 #[arg(long)]
236 check: bool,
237 #[arg(long)]
239 gitignore_only: bool,
240 #[arg(long, value_enum, default_value_t = InitFormat::Text)]
242 format: InitFormat,
243}
244
245#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
246enum InitFormat {
247 Text,
248 Json,
249}
250
251#[derive(Debug, Args)]
252struct ShowArgs {
253 target_path: String,
255 #[arg(long)]
257 report: Option<PathBuf>,
258 #[arg(long)]
260 require_current: bool,
261 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
263 format: DisplayFormat,
264}
265
266#[derive(Debug, Args)]
267#[command(group(
268 ArgGroup::new("selector")
269 .args(["path", "cluster", "relationship", "top"])
270 .multiple(false)
271))]
272struct ExplainArgs {
273 #[arg(long)]
275 report: Option<PathBuf>,
276 #[arg(long)]
278 require_current: bool,
279 #[arg(long)]
281 path: Option<String>,
282 #[arg(long)]
284 cluster: Option<String>,
285 #[arg(long)]
287 relationship: Option<String>,
288 #[arg(long)]
290 top: Option<i64>,
291 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
293 format: DisplayFormat,
294 #[arg(long)]
296 verbose: bool,
297 #[arg(long)]
299 prompt_pack: Option<PathBuf>,
300 #[arg(long, requires = "prompt_pack")]
302 force: bool,
303 #[arg(long, requires = "prompt_pack")]
305 include_repository_context: bool,
306 #[arg(long, default_value_t = 2048, requires = "include_repository_context")]
308 excerpt_bytes: usize,
309 #[arg(long, requires = "prompt_pack")]
311 include_local_paths: bool,
312}
313
314#[derive(Debug, Args)]
315#[command(group(
316 ArgGroup::new("selector")
317 .args(["path", "cluster", "relationship"])
318 .required(true)
319 .multiple(false)
320))]
321struct PlanArgs {
322 #[arg(long)]
324 report: Option<PathBuf>,
325 #[arg(long)]
327 require_current: bool,
328 #[arg(long)]
330 path: Option<String>,
331 #[arg(long)]
333 cluster: Option<String>,
334 #[arg(long)]
336 relationship: Option<String>,
337 #[arg(long, default_value_t = 3)]
339 max_slices: i64,
340 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
342 format: DisplayFormat,
343 #[arg(long)]
345 prompt_pack: Option<PathBuf>,
346 #[arg(long, requires = "prompt_pack")]
348 force: bool,
349 #[arg(long, requires = "prompt_pack")]
351 include_repository_context: bool,
352 #[arg(long, default_value_t = 2048, requires = "include_repository_context")]
354 excerpt_bytes: usize,
355 #[arg(long, requires = "prompt_pack")]
357 include_local_paths: bool,
358}
359
360#[derive(Debug, Args)]
361struct CheckArgs {
362 #[arg(long)]
364 report: Option<PathBuf>,
365 #[arg(long, value_enum)]
367 fail_on_context_band: Option<ContextBand>,
368 #[arg(long, value_enum)]
370 fail_on_slop_band: Option<SlopBand>,
371 #[arg(long, value_enum, default_value_t = CheckFormat::Text)]
373 format: CheckFormat,
374 #[arg(long)]
376 details: bool,
377 #[arg(long)]
379 include_folders: bool,
380 #[arg(long, default_value_t = 0, requires = "details")]
382 offset: usize,
383 #[arg(long, default_value_t = 1000, requires = "details")]
385 limit: usize,
386 #[arg(long)]
388 allow_incomplete_evidence: bool,
389 #[arg(long)]
391 evaluate_only: bool,
392 #[arg(long)]
394 require_current: bool,
395}
396
397#[derive(Debug, Args)]
398struct CompareArgs {
399 #[arg(long, value_name = "PATH")]
401 state_dir: Option<PathBuf>,
402 #[arg(
404 long,
405 required_unless_present_any = ["base_ref", "baseline"],
406 conflicts_with_all = ["base_ref", "baseline"]
407 )]
408 base: Option<PathBuf>,
409 #[arg(long, conflicts_with_all = ["base", "baseline"])]
411 base_ref: Option<String>,
412 #[arg(long, conflicts_with_all = ["base", "base_ref"])]
414 baseline: Option<String>,
415 #[arg(long, default_value = ".slop/latest/report.json")]
417 head: PathBuf,
418 #[arg(long)]
420 scope: Option<String>,
421 #[arg(long)]
423 allow_shallow: bool,
424 #[arg(long)]
426 allow_incomplete_evidence: bool,
427 #[arg(long, default_value_t = 10)]
429 top: i64,
430 #[arg(long, value_enum, default_value_t = CompareFormat::Text)]
432 format: CompareFormat,
433 #[arg(long, value_enum, default_value_t = CompareDetail::Top)]
435 detail: CompareDetail,
436 #[arg(long, default_value_t = 0)]
438 offset: usize,
439 #[arg(long, default_value_t = 1000)]
441 limit: usize,
442 #[arg(long)]
444 force: bool,
445 #[arg(long)]
447 include_local_paths: bool,
448 #[arg(long)]
450 include_unchanged: bool,
451 #[arg(long, value_enum, default_value_t = PolicySource::Base)]
453 policy_from: PolicySource,
454 #[arg(long)]
456 fail_on_regression: bool,
457}
458
459#[derive(Debug, Args)]
460struct BaselineArgs {
461 #[arg(long, value_name = "PATH", global = true)]
463 state_dir: Option<PathBuf>,
464 #[command(subcommand)]
465 command: BaselineCommand,
466}
467
468#[derive(Debug, Subcommand)]
469enum BaselineCommand {
470 Ensure {
472 #[arg(long, default_value = "default")]
474 name: String,
475 #[arg(long)]
477 report: Option<PathBuf>,
478 #[arg(long)]
480 require_current: bool,
481 #[arg(long)]
483 replace: bool,
484 #[arg(long)]
486 allow_dirty: bool,
487 #[arg(long)]
489 allow_incomplete_evidence: bool,
490 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
492 format: DisplayFormat,
493 },
494 Create {
496 #[arg(long, default_value = "default")]
498 name: String,
499 #[arg(long)]
501 report: Option<PathBuf>,
502 #[arg(long)]
504 require_current: bool,
505 #[arg(long)]
507 force: bool,
508 #[arg(long)]
510 allow_dirty: bool,
511 #[arg(long)]
513 allow_incomplete_evidence: bool,
514 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
516 format: DisplayFormat,
517 },
518 Update {
520 #[arg(long, default_value = "default")]
522 name: String,
523 #[arg(long)]
525 report: Option<PathBuf>,
526 #[arg(long)]
528 require_current: bool,
529 #[arg(long)]
531 allow_dirty: bool,
532 #[arg(long)]
534 allow_incomplete_evidence: bool,
535 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
537 format: DisplayFormat,
538 },
539 List {
541 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
543 format: DisplayFormat,
544 },
545 Inspect {
547 #[arg(long, default_value = "default")]
549 name: String,
550 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
552 format: DisplayFormat,
553 },
554 Validate {
556 #[arg(long, default_value = "default")]
558 name: String,
559 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
561 format: DisplayFormat,
562 },
563 Remove {
565 #[arg(long, default_value = "default")]
567 name: String,
568 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
570 format: DisplayFormat,
571 #[arg(long)]
573 yes: bool,
574 },
575}
576
577#[derive(Debug, Args)]
578struct ReportArgs {
579 #[command(subcommand)]
580 command: ReportCommand,
581}
582
583#[derive(Debug, Subcommand)]
584enum ReportCommand {
585 Validate {
587 #[arg(value_name = "REPORT_JSON", required_unless_present = "report")]
589 path: Option<PathBuf>,
590 #[arg(long, value_name = "REPORT_JSON", required_unless_present = "path")]
592 report: Option<PathBuf>,
593 #[arg(long)]
595 allow_legacy: bool,
596 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
598 format: DisplayFormat,
599 },
600 Migrate {
602 #[arg(value_name = "REPORT_JSON")]
604 path: PathBuf,
605 #[arg(long, value_name = "PATH")]
607 output: PathBuf,
608 },
609 Schema,
611}
612
613#[derive(Debug, Args)]
614struct SarifArgs {
615 #[arg(long)]
617 report: Option<PathBuf>,
618 #[arg(long)]
620 require_current: bool,
621 #[arg(long)]
623 top: Option<i64>,
624 #[arg(long, value_enum, default_value_t = SarifScope::ActionQueue)]
626 scope: SarifScope,
627 #[arg(long)]
629 output: Option<PathBuf>,
630 #[arg(long)]
632 include_local_paths: bool,
633}
634
635#[derive(Debug, Args)]
636struct HealthArgs {
637 #[arg(long)]
639 report: Option<PathBuf>,
640 #[arg(long, value_enum, default_value_t = HealthFormat::Text)]
642 format: HealthFormat,
643 #[arg(long, default_value_t = 10)]
645 max_annotations: usize,
646 #[arg(long)]
648 require_current: bool,
649}
650
651#[derive(Debug, Args)]
652struct ConfigArgs {
653 #[command(subcommand)]
654 command: ConfigCommand,
655}
656
657#[derive(Debug, Subcommand)]
658enum ConfigCommand {
659 Show {
661 #[arg(long)]
663 effective: bool,
664 },
665 Validate,
667 DiffDefaults,
669 Migrate {
671 #[arg(long)]
673 dry_run: bool,
674 #[arg(long)]
676 no_backup: bool,
677 },
678 Schema,
680}
681
682#[derive(Debug, Args)]
683struct DoctorArgs {
684 #[arg(long, num_args = 0..=1, default_missing_value = "__git_slop_active_state_bundle__")]
686 bundle: Option<PathBuf>,
687 #[arg(long, value_enum, default_value_t = DoctorFormat::Text)]
689 format: DoctorFormat,
690 #[arg(long)]
692 scope: Option<String>,
693 #[arg(long)]
695 require_current: bool,
696}
697
698#[derive(Debug, Clone, Copy, ValueEnum)]
699enum DoctorFormat {
700 Text,
701 Json,
702}
703
704include!("cli/list_args.rs");
705
706#[derive(Debug, Args)]
707struct PruneArgs {
708 #[arg(long, value_name = "PATH")]
710 state_dir: Option<PathBuf>,
711 #[arg(long)]
713 keep: Option<usize>,
714 #[arg(long)]
716 max_bytes: Option<u64>,
717 #[arg(long, conflicts_with = "yes")]
719 dry_run: bool,
720 #[arg(long, conflicts_with = "dry_run")]
722 yes: bool,
723 #[arg(long, value_enum, default_value_t = DisplayFormat::Text)]
725 format: DisplayFormat,
726}
727
728include!("cli/cache_args.rs");
729include!("cli/policy_args.rs");
730include!("cli/advice_args.rs");
731
732#[derive(Debug, Args)]
733struct CompletionsArgs {
734 shell: CompletionShell,
736}
737
738#[derive(Debug, Args)]
739struct ManArgs {
740 #[arg(long)]
742 output: Option<PathBuf>,
743}
744
745#[derive(Debug, Args)]
746struct ReferenceArgs {
747 #[arg(long)]
750 output: Option<PathBuf>,
751}
752
753#[derive(Debug, Args)]
754struct HtmlArgs {
755 #[arg(long)]
757 report: Option<PathBuf>,
758 #[arg(long)]
760 require_current: bool,
761 #[arg(long)]
763 output: Option<PathBuf>,
764 #[arg(long)]
766 include_local_paths: bool,
767 #[arg(long)]
769 serve: bool,
770 #[arg(long, requires = "serve")]
772 open: bool,
773 #[arg(long, default_value_t = 120, value_parser = clap::value_parser!(u64).range(1..=3600), requires = "serve")]
775 serve_seconds: u64,
776}
777
778include!("cli/formats.rs");
779include!("cli/support.rs");
780include!("cli/support/validation.rs");
781include!("cli/init.rs");
782include!("cli/analysis.rs");
783include!("cli/analysis/reporting.rs");
784include!("cli/analysis_receipt.rs");
785include!("cli/check.rs");
786include!("cli/baseline_compare.rs");
787include!("cli/reporting.rs");
788include!("cli/doctor/support.rs");
789include!("cli/doctor.rs");
790include!("cli/listing.rs");
791include!("cli/policy_cmd.rs");
792include!("cli/advice_cmd.rs");
793include!("cli/generation/server.rs");
794include!("cli/generation.rs");
795include!("cli/generation/artifacts.rs");
796include!("cli/generation/reference.rs");
797include!("cli/generation/reference/pages.rs");
798include!("cli/generation/reference/bundle.rs");
799include!("cli/entry.rs");
800
801#[cfg(test)]
802mod tests {
803 use clap::CommandFactory;
804
805 use super::Cli;
806
807 fn assert_descriptions(command: &clap::Command, path: &str) {
808 for argument in command.get_arguments() {
809 assert!(
810 argument
811 .get_help()
812 .is_some_and(|help| !help.to_string().trim().is_empty()),
813 "{path} argument {} has no generated reference description",
814 argument.get_id()
815 );
816 }
817 for subcommand in command.get_subcommands() {
818 assert_descriptions(subcommand, &format!("{path} {}", subcommand.get_name()));
819 }
820 }
821
822 #[test]
823 fn generated_reference_has_no_blank_argument_descriptions() {
824 assert_descriptions(&Cli::command(), "git-slop");
825 }
826}