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