Skip to main content

git_vet/
cli.rs

1use std::path::PathBuf;
2use std::process::ExitCode;
3
4use clap::{Parser, Subcommand};
5
6use crate::channel::{DEFAULT_REVIEW_CHANNEL, ReviewChannel, ReviewChannelCandidate};
7use crate::commands::{
8    DirtyPathHandling, Gate, MarkOptions, StatusMode, diff_path, mark_paths, status, unmark_paths,
9};
10use crate::error::AppError;
11use crate::git::Git;
12use crate::git_ref_format::{CheckRefFormatError, check_ref_format};
13use crate::notes::{GitNotesStore, NotesStore};
14
15#[derive(Parser, Debug)]
16#[command(
17    name = "git-vet",
18    version,
19    about = "Track human review state for Git-tracked file contents"
20)]
21pub struct Cli {
22    /// Review channel/pipeline to read or write.
23    #[arg(long, global = true, default_value = DEFAULT_REVIEW_CHANNEL)]
24    channel: String,
25    #[command(subcommand)]
26    command: CommandKind,
27}
28
29#[derive(Subcommand, Debug)]
30enum CommandKind {
31    /// Sign off the current HEAD content of tracked files.
32    Mark {
33        /// Proceed even if target paths have uncommitted working-tree changes.
34        #[arg(long)]
35        allow_dirty: bool,
36        #[arg(required = true)]
37        paths: Vec<PathBuf>,
38    },
39    /// Remove sign-off for the current HEAD content of tracked files.
40    Unmark {
41        #[arg(required = true)]
42        paths: Vec<PathBuf>,
43    },
44    /// Show review state for tracked files.
45    Status {
46        /// Emit stable machine-readable JSON.
47        #[arg(long)]
48        json: bool,
49        /// Include files that are already vetted in human-readable output.
50        #[arg(long)]
51        all: bool,
52        /// Exit 1 when any in-scope tracked file is unreviewed.
53        #[arg(long)]
54        check: bool,
55    },
56    /// Show the diff that still needs review for a tracked file.
57    Diff { path: PathBuf },
58    /// Fetch, merge, and push review notes for the selected channel.
59    Sync {
60        /// Remote to sync with. Overrides vet.syncRemote and origin fallback.
61        #[arg(long)]
62        remote: Option<String>,
63    },
64    /// Prune notes for objects that are no longer present.
65    Prune,
66}
67
68pub fn run_cli() -> Result<ExitCode, AppError> {
69    let cli = Cli::parse();
70    let channel = review_channel_from_cli(&cli.channel)?;
71    let git = Git::discover()?;
72    let notes = GitNotesStore::new(&git, channel.notes_ref().clone());
73
74    match cli.command {
75        CommandKind::Mark { paths, allow_dirty } => {
76            let dirty_paths = if allow_dirty {
77                DirtyPathHandling::Allow
78            } else {
79                DirtyPathHandling::Prompt
80            };
81            mark_paths(&git, &notes, &paths, MarkOptions { dirty_paths })?;
82            Ok(ExitCode::SUCCESS)
83        }
84        CommandKind::Unmark { paths } => {
85            unmark_paths(&git, &notes, &paths)?;
86            Ok(ExitCode::SUCCESS)
87        }
88        CommandKind::Status { json, all, check } => {
89            match status(&git, &notes, &channel, StatusMode { json, all, check })? {
90                Gate::Open => Ok(ExitCode::SUCCESS),
91                Gate::Closed => Ok(ExitCode::from(1)),
92            }
93        }
94        CommandKind::Diff { path } => {
95            diff_path(&git, &notes, &path)?;
96            Ok(ExitCode::SUCCESS)
97        }
98        CommandKind::Sync { remote } => {
99            let remote = git.select_sync_remote(remote.as_deref())?;
100            notes.sync(&remote)?;
101            Ok(ExitCode::SUCCESS)
102        }
103        CommandKind::Prune => {
104            notes.prune()?;
105            Ok(ExitCode::SUCCESS)
106        }
107    }
108}
109
110fn review_channel_from_cli(input: &str) -> Result<ReviewChannel, AppError> {
111    let candidate = ReviewChannelCandidate::new(input)?;
112
113    // Channel validity is exact Git refname validity for the concrete notes ref
114    // git-vet will use: refs/notes/vet/<channel>. Keep the Git subprocess at
115    // the CLI boundary instead of making ReviewChannel construction impure.
116    match check_ref_format(candidate.notes_ref_name()) {
117        Ok(()) => Ok(candidate.into_channel_after_git_check_ref_format()),
118        Err(CheckRefFormatError::Rejected { ref_name, details }) => Err(candidate
119            .channel_error(format!(
120                "`git check-ref-format` rejected {ref_name:?}: {details}"
121            ))
122            .into()),
123        Err(CheckRefFormatError::Io(error)) => Err(AppError::Io(error)),
124    }
125}