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::{ChannelError, DEFAULT_REVIEW_CHANNEL, ReviewChannel, ReviewChannelCandidate};
7use crate::commands::{
8    DirtyPathHandling, Gate, MarkOptions, StatusMode, diff_path, mark_paths, status, sync_notes,
9    unmark_paths,
10};
11use crate::error::AppError;
12use crate::git::Git;
13use crate::git_ref_format::{CheckRefFormatError, check_ref_format};
14use crate::notes::{GitNotesStore, NotesStore};
15use crate::sync_progress::SyncProgressReporter;
16
17#[derive(Parser, Debug)]
18#[command(
19    name = "git-vet",
20    version,
21    about = "Track human review state for Git-tracked file contents"
22)]
23pub struct Cli {
24    /// Review channel/pipeline to read or write.
25    #[arg(long, global = true)]
26    channel: Option<String>,
27    #[command(subcommand)]
28    command: CommandKind,
29}
30
31#[derive(Subcommand, Debug)]
32enum CommandKind {
33    /// Sign off the current HEAD content of tracked files.
34    Mark {
35        /// Proceed even if target paths have uncommitted working-tree changes.
36        #[arg(long)]
37        allow_dirty: bool,
38        #[arg(required = true)]
39        paths: Vec<PathBuf>,
40    },
41    /// Remove sign-off for the current HEAD content of tracked files.
42    Unmark {
43        #[arg(required = true)]
44        paths: Vec<PathBuf>,
45    },
46    /// Show review state for tracked files.
47    Status {
48        /// Emit stable machine-readable JSON.
49        #[arg(long)]
50        json: bool,
51        /// Include files that are already vetted in human-readable output.
52        #[arg(long)]
53        all: bool,
54        /// Exit 1 when any in-scope tracked file is unreviewed.
55        #[arg(long)]
56        check: bool,
57    },
58    /// Show the diff that still needs review for a tracked file.
59    Diff { path: PathBuf },
60    /// Fetch, merge, and push review notes for the selected channel.
61    Sync {
62        /// Remote to sync with. Overrides vet.syncRemote and origin fallback.
63        #[arg(long)]
64        remote: Option<String>,
65    },
66    /// Prune notes for objects that are no longer present.
67    Prune,
68}
69
70pub fn run_cli() -> Result<ExitCode, AppError> {
71    let cli = Cli::parse();
72    let git = Git::discover()?;
73    let channel = review_channel_from_selection(cli.channel.as_deref(), &git)?;
74    let notes = GitNotesStore::new(&git, channel.notes_ref().clone());
75
76    match cli.command {
77        CommandKind::Mark { paths, allow_dirty } => {
78            let dirty_paths = if allow_dirty {
79                DirtyPathHandling::Allow
80            } else {
81                DirtyPathHandling::Prompt
82            };
83            mark_paths(&git, &notes, &paths, MarkOptions { dirty_paths })?;
84            Ok(ExitCode::SUCCESS)
85        }
86        CommandKind::Unmark { paths } => {
87            unmark_paths(&git, &notes, &paths)?;
88            Ok(ExitCode::SUCCESS)
89        }
90        CommandKind::Status { json, all, check } => {
91            match status(&git, &notes, &channel, StatusMode { json, all, check })? {
92                Gate::Open => Ok(ExitCode::SUCCESS),
93                Gate::Closed => Ok(ExitCode::from(1)),
94            }
95        }
96        CommandKind::Diff { path } => {
97            diff_path(&git, &notes, &path)?;
98            Ok(ExitCode::SUCCESS)
99        }
100        CommandKind::Sync { remote } => {
101            let remote = git.select_sync_remote(remote.as_deref())?;
102            let mut progress = SyncProgressReporter::from_environment();
103            sync_notes(&notes, &channel, &remote, &mut progress)?;
104            Ok(ExitCode::SUCCESS)
105        }
106        CommandKind::Prune => {
107            notes.prune()?;
108            Ok(ExitCode::SUCCESS)
109        }
110    }
111}
112
113fn review_channel_from_selection(
114    explicit: Option<&str>,
115    git: &Git,
116) -> Result<ReviewChannel, AppError> {
117    match explicit {
118        Some(input) => review_channel_from_input(input, ReviewChannelSource::Cli),
119        None => git.configured_review_channel()?.map_or_else(
120            || {
121                review_channel_from_input(
122                    DEFAULT_REVIEW_CHANNEL,
123                    ReviewChannelSource::BuiltInDefault,
124                )
125            },
126            |input| review_channel_from_input(&input, ReviewChannelSource::Config),
127        ),
128    }
129}
130
131#[derive(Clone, Copy, Debug)]
132enum ReviewChannelSource {
133    Cli,
134    Config,
135    BuiltInDefault,
136}
137
138fn review_channel_from_input(
139    input: &str,
140    source: ReviewChannelSource,
141) -> Result<ReviewChannel, AppError> {
142    let candidate = ReviewChannelCandidate::new(input)
143        .map_err(|error| channel_error_from_source(error, source))?;
144
145    // Channel validity is exact Git refname validity for the concrete notes ref
146    // git-vet will use: refs/notes/vet/<channel>. Keep the Git subprocess at
147    // the CLI/config boundary instead of making ReviewChannel construction
148    // impure.
149    match check_ref_format(candidate.notes_ref_name()) {
150        Ok(()) => Ok(candidate.into_channel_after_git_check_ref_format()),
151        Err(CheckRefFormatError::Rejected { ref_name, details }) => Err(candidate
152            .channel_error(details_from_source(
153                format!("`git check-ref-format` rejected {ref_name:?}: {details}"),
154                source,
155            ))
156            .into()),
157        Err(CheckRefFormatError::Io(error)) => Err(AppError::Io(error)),
158    }
159}
160
161fn channel_error_from_source(error: ChannelError, source: ReviewChannelSource) -> AppError {
162    ChannelError {
163        channel: error.channel,
164        details: details_from_source(error.details, source),
165    }
166    .into()
167}
168
169fn details_from_source(details: String, source: ReviewChannelSource) -> String {
170    match source {
171        ReviewChannelSource::Config => format!("from git config vet.channel: {details}"),
172        ReviewChannelSource::Cli | ReviewChannelSource::BuiltInDefault => details,
173    }
174}