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        /// Limit status to tracked files matching these file or directory pathspecs.
60        #[arg(value_name = "PATHSPEC")]
61        paths: Vec<PathBuf>,
62    },
63    /// Show the diff that still needs review for a tracked file.
64    Diff {
65        /// Compare the latest vetted content with the workspace instead of HEAD.
66        #[arg(long)]
67        workspace: bool,
68        path: PathBuf,
69    },
70    /// Fetch, merge, and push review notes for the selected channel.
71    Sync {
72        /// Remote to sync with. Overrides vet.syncRemote and origin fallback.
73        #[arg(long)]
74        remote: Option<String>,
75    },
76    /// Prune notes for objects that are no longer present.
77    Prune,
78}
79
80pub fn run_cli() -> Result<ExitCode, AppError> {
81    let cli = Cli::parse();
82    let git = Git::discover()?;
83    let channel = review_channel_from_selection(cli.channel.as_deref(), &git)?;
84    let notes = GitNotesStore::new(&git, channel.notes_ref().clone());
85
86    match cli.command {
87        CommandKind::Mark { paths, allow_dirty } => {
88            let dirty_paths = if allow_dirty {
89                DirtyPathHandling::Allow
90            } else {
91                DirtyPathHandling::Prompt
92            };
93            mark_paths(&git, &notes, &paths, MarkOptions { dirty_paths })?;
94            Ok(ExitCode::SUCCESS)
95        }
96        CommandKind::Unmark { paths } => {
97            unmark_paths(&git, &notes, &paths)?;
98            Ok(ExitCode::SUCCESS)
99        }
100        CommandKind::Status {
101            json,
102            all,
103            check,
104            workspace,
105            paths,
106        } => {
107            let target = if workspace {
108                StatusTarget::Workspace
109            } else {
110                StatusTarget::Head
111            };
112            match status(
113                &git,
114                &notes,
115                &channel,
116                StatusMode {
117                    json,
118                    all: all || !paths.is_empty(),
119                    check,
120                    target,
121                },
122                &paths,
123            )? {
124                Gate::Open => Ok(ExitCode::SUCCESS),
125                Gate::Closed => Ok(ExitCode::from(1)),
126            }
127        }
128        CommandKind::Diff { path, workspace } => {
129            let target = if workspace {
130                DiffTarget::Workspace
131            } else {
132                DiffTarget::Head
133            };
134            diff_path(&git, &notes, &path, target)?;
135            Ok(ExitCode::SUCCESS)
136        }
137        CommandKind::Sync { remote } => {
138            let remote = git.select_sync_remote(remote.as_deref())?;
139            let mut progress = SyncProgressReporter::from_environment();
140            sync_notes(&notes, &channel, &remote, &mut progress)?;
141            Ok(ExitCode::SUCCESS)
142        }
143        CommandKind::Prune => {
144            notes.prune()?;
145            Ok(ExitCode::SUCCESS)
146        }
147    }
148}
149
150fn review_channel_from_selection(
151    explicit: Option<&str>,
152    git: &Git,
153) -> Result<ReviewChannel, AppError> {
154    match explicit {
155        Some(input) => review_channel_from_input(input, ReviewChannelSource::Cli),
156        None => git.configured_review_channel()?.map_or_else(
157            || {
158                review_channel_from_input(
159                    DEFAULT_REVIEW_CHANNEL,
160                    ReviewChannelSource::BuiltInDefault,
161                )
162            },
163            |input| review_channel_from_input(&input, ReviewChannelSource::Config),
164        ),
165    }
166}
167
168#[derive(Clone, Copy, Debug)]
169enum ReviewChannelSource {
170    Cli,
171    Config,
172    BuiltInDefault,
173}
174
175fn review_channel_from_input(
176    input: &str,
177    source: ReviewChannelSource,
178) -> Result<ReviewChannel, AppError> {
179    ReviewChannel::new(input).map_err(|error| channel_error_from_source(input, &error, source))
180}
181
182fn channel_error_from_source(
183    input: &str,
184    error: &ChannelError,
185    source: ReviewChannelSource,
186) -> AppError {
187    AppError::InvalidChannel {
188        channel: input.to_owned(),
189        details: details_from_source(error.to_string(), source),
190    }
191}
192
193fn details_from_source(details: String, source: ReviewChannelSource) -> String {
194    match source {
195        ReviewChannelSource::Config => format!("from git config vet.channel: {details}"),
196        ReviewChannelSource::Cli | ReviewChannelSource::BuiltInDefault => details,
197    }
198}