Skip to main content

git_vet/
cli.rs

1use std::io::{self, Write};
2use std::path::{Path, PathBuf};
3use std::process::ExitCode;
4
5use clap::builder::styling::{AnsiColor, Effects, Styles};
6use clap::{ColorChoice, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
7
8use crate::channel::{
9    ChannelError, ChannelTransfer, ChannelTransferKind, DEFAULT_REVIEW_CHANNEL, ReviewChannel,
10};
11use crate::commands::{
12    DiffTarget, DirtyPathHandling, Gate, MarkOptions, StatusMode, StatusTarget, diff_path,
13    list_channels, mark_paths, remove_channel, status, sync_notes, transfer_channel_notes,
14    unmark_paths,
15};
16use crate::error::AppError;
17use crate::git::Git;
18use crate::manpage;
19use crate::notes::{GitNotesChannelStore, GitNotesStore, NotesStore};
20use crate::sync_progress::SyncProgressReporter;
21use crate::ui::{ColorMode, ColorPolicy};
22
23const CLI_STYLES: Styles = Styles::styled()
24    .header(AnsiColor::BrightCyan.on_default().effects(Effects::BOLD))
25    .usage(AnsiColor::BrightCyan.on_default().effects(Effects::BOLD))
26    .literal(AnsiColor::BrightGreen.on_default().effects(Effects::BOLD))
27    .placeholder(AnsiColor::Cyan.on_default())
28    .error(AnsiColor::BrightRed.on_default().effects(Effects::BOLD))
29    .valid(AnsiColor::BrightGreen.on_default())
30    .invalid(AnsiColor::BrightYellow.on_default());
31
32#[derive(Parser, Debug)]
33#[command(
34    name = "git-vet",
35    version,
36    about = "Track human review state for Git-tracked file contents",
37    styles = CLI_STYLES,
38    after_help = "Typical workflow:\n  git vet status\n  git vet diff <path>\n  git vet mark <path>\n  git vet status --check"
39)]
40pub struct Cli {
41    /// Review channel for commands that operate on one selected channel.
42    #[arg(long, global = true)]
43    channel: Option<String>,
44    /// Control ANSI color output.
45    #[arg(long, value_enum, global = true)]
46    color: Option<CliColorMode>,
47    #[command(subcommand)]
48    command: CommandKind,
49}
50
51#[derive(Clone, Copy, Debug, Eq, PartialEq, ValueEnum)]
52enum CliColorMode {
53    Auto,
54    Always,
55    Never,
56}
57
58impl From<CliColorMode> for ColorMode {
59    fn from(mode: CliColorMode) -> Self {
60        match mode {
61            CliColorMode::Auto => Self::Auto,
62            CliColorMode::Always => Self::Always,
63            CliColorMode::Never => Self::Never,
64        }
65    }
66}
67
68#[derive(Subcommand, Debug)]
69enum CommandKind {
70    /// Install the git-vet(1) manual page for the current user.
71    InstallMan {
72        // Override the inherited global argument so it stays out of this command's help.
73        #[arg(long, hide = true)]
74        channel: Option<String>,
75        /// Install into this man1 directory instead of the user default.
76        #[arg(long, value_name = "DIRECTORY")]
77        man_dir: Option<PathBuf>,
78    },
79    /// Manage local review notes across channels.
80    Channel {
81        #[command(subcommand)]
82        command: ChannelCommand,
83    },
84    /// Sign off the current HEAD content of tracked files.
85    Mark {
86        /// Proceed even if target paths have uncommitted working-tree changes.
87        #[arg(long)]
88        allow_dirty: bool,
89        #[arg(required = true)]
90        paths: Vec<PathBuf>,
91    },
92    /// Remove sign-off for the current HEAD content of tracked files.
93    Unmark {
94        #[arg(required = true)]
95        paths: Vec<PathBuf>,
96    },
97    /// Show review state for tracked files.
98    Status {
99        /// Emit stable machine-readable JSON.
100        #[arg(long)]
101        json: bool,
102        /// Include files that are already vetted in human-readable output.
103        #[arg(long)]
104        all: bool,
105        /// Exit 1 when any in-scope tracked file is unreviewed.
106        #[arg(long)]
107        check: bool,
108        /// Classify local working-tree contents instead of committed HEAD contents.
109        #[arg(long)]
110        workspace: bool,
111        /// Limit status to tracked files matching these file or directory pathspecs.
112        #[arg(value_name = "PATHSPEC")]
113        paths: Vec<PathBuf>,
114    },
115    /// Show the diff that still needs review for a tracked file.
116    Diff {
117        /// Compare the latest vetted content with the workspace instead of HEAD.
118        #[arg(long)]
119        workspace: bool,
120        path: PathBuf,
121    },
122    /// Fetch, merge, and push review notes for the selected channel.
123    Sync {
124        /// Remote to sync with. Overrides vet.syncRemote and origin fallback.
125        #[arg(long)]
126        remote: Option<String>,
127    },
128    /// Prune notes for objects that are no longer present.
129    Prune,
130}
131
132#[derive(Subcommand, Debug)]
133enum ChannelCommand {
134    /// List local review-note channels.
135    List {
136        /// Emit stable machine-readable JSON.
137        #[arg(long)]
138        json: bool,
139    },
140    /// Copy all local review notes into a new channel.
141    Copy {
142        /// Channel whose local review notes are copied.
143        #[arg(value_name = "SOURCE")]
144        source: String,
145        /// New channel that receives the copied review notes.
146        #[arg(value_name = "DESTINATION")]
147        destination: String,
148    },
149    /// Move all local review notes into a new channel.
150    Move {
151        /// Channel whose local review notes are moved.
152        #[arg(value_name = "SOURCE")]
153        source: String,
154        /// New channel that receives the moved review notes.
155        #[arg(value_name = "DESTINATION")]
156        destination: String,
157    },
158    /// Remove a local review-note channel.
159    Remove {
160        /// Channel whose local review notes are removed.
161        #[arg(value_name = "CHANNEL")]
162        target: String,
163        /// Skip confirmation.
164        #[arg(long)]
165        force: bool,
166    },
167}
168
169pub struct CliError {
170    error: AppError,
171    color_error: bool,
172}
173
174impl CliError {
175    #[must_use]
176    pub const fn error(&self) -> &AppError {
177        &self.error
178    }
179
180    #[must_use]
181    pub const fn color_error(&self) -> bool {
182        self.color_error
183    }
184}
185
186pub fn run_cli() -> Result<ExitCode, CliError> {
187    let environment_color = ColorPolicy::resolve(None);
188    let Cli {
189        channel,
190        color,
191        command,
192    } = parse_cli(environment_color);
193    let color = ColorPolicy::resolve(color.map(Into::into));
194
195    if let CommandKind::InstallMan {
196        channel: command_channel,
197        man_dir,
198    } = &command
199    {
200        return install_man_page(
201            channel.as_deref().or(command_channel.as_deref()),
202            man_dir.as_deref(),
203        )
204        .map_err(|error| CliError {
205            error,
206            color_error: color.stderr(),
207        });
208    }
209
210    let git = Git::discover().map_err(|error| CliError {
211        error,
212        color_error: color.stderr(),
213    })?;
214    run_cli_inner(&git, channel.as_deref(), command, color).map_err(|error| CliError {
215        error,
216        color_error: color.stderr(),
217    })
218}
219
220fn parse_cli(color: ColorPolicy) -> Cli {
221    let command = Cli::command().color(if color.stderr() {
222        ColorChoice::Always
223    } else {
224        ColorChoice::Never
225    });
226    let matches = command.get_matches();
227    Cli::from_arg_matches(&matches).unwrap_or_else(|error| error.exit())
228}
229
230fn run_cli_inner(
231    git: &Git,
232    channel: Option<&str>,
233    command: CommandKind,
234    color: ColorPolicy,
235) -> Result<ExitCode, AppError> {
236    match command {
237        CommandKind::InstallMan {
238            channel: command_channel,
239            man_dir,
240        } => install_man_page(channel.or(command_channel.as_deref()), man_dir.as_deref()),
241        CommandKind::Channel { command } => run_channel_command(git, channel, command),
242        CommandKind::Mark { paths, allow_dirty } => {
243            with_selected_channel(git, channel, |_, notes| {
244                let dirty_paths = if allow_dirty {
245                    DirtyPathHandling::Allow
246                } else {
247                    DirtyPathHandling::Prompt
248                };
249                mark_paths(git, notes, &paths, MarkOptions { dirty_paths })?;
250                Ok(ExitCode::SUCCESS)
251            })
252        }
253        CommandKind::Unmark { paths } => with_selected_channel(git, channel, |_, notes| {
254            unmark_paths(git, notes, &paths)?;
255            Ok(ExitCode::SUCCESS)
256        }),
257        CommandKind::Status {
258            json,
259            all,
260            check,
261            workspace,
262            paths,
263        } => with_selected_channel(git, channel, |channel, notes| {
264            let target = if workspace {
265                StatusTarget::Workspace
266            } else {
267                StatusTarget::Head
268            };
269            match status(
270                git,
271                notes,
272                channel,
273                StatusMode {
274                    json,
275                    all: all || !paths.is_empty(),
276                    check,
277                    target,
278                    color,
279                },
280                &paths,
281            )? {
282                Gate::Open => Ok(ExitCode::SUCCESS),
283                Gate::Closed => Ok(ExitCode::from(1)),
284            }
285        }),
286        CommandKind::Diff { path, workspace } => with_selected_channel(git, channel, |_, notes| {
287            let target = if workspace {
288                DiffTarget::Workspace
289            } else {
290                DiffTarget::Head
291            };
292            diff_path(git, notes, &path, target, color)?;
293            Ok(ExitCode::SUCCESS)
294        }),
295        CommandKind::Sync { remote } => with_selected_channel(git, channel, |channel, notes| {
296            let remote = git.select_sync_remote(remote.as_deref())?;
297            let mut progress = SyncProgressReporter::from_environment(color.stderr());
298            sync_notes(notes, channel, &remote, &mut progress)?;
299            Ok(ExitCode::SUCCESS)
300        }),
301        CommandKind::Prune => with_selected_channel(git, channel, |_, notes| {
302            notes.prune()?;
303            Ok(ExitCode::SUCCESS)
304        }),
305    }
306}
307
308fn install_man_page(
309    explicit_channel: Option<&str>,
310    man_dir: Option<&Path>,
311) -> Result<ExitCode, AppError> {
312    if explicit_channel.is_some() {
313        return Err(AppError::ChannelOptionNotAllowedForInstallMan);
314    }
315
316    let destination = manpage::install(man_dir).map_err(|error| AppError::ManPageInstall {
317        details: error.to_string(),
318    })?;
319    writeln!(
320        io::stdout().lock(),
321        "Installed git-vet(1) at {}",
322        destination.display()
323    )?;
324    Ok(ExitCode::SUCCESS)
325}
326
327fn run_channel_command(
328    git: &Git,
329    explicit_channel: Option<&str>,
330    command: ChannelCommand,
331) -> Result<ExitCode, AppError> {
332    let (command_name, kind, source, destination) = match command {
333        ChannelCommand::List { json } => {
334            if explicit_channel.is_some() {
335                return Err(AppError::ChannelOptionNotAllowedForList);
336            }
337
338            let channels = GitNotesChannelStore::new(git);
339            list_channels(&channels, json)?;
340            return Ok(ExitCode::SUCCESS);
341        }
342        ChannelCommand::Copy {
343            source,
344            destination,
345        } => (
346            "channel copy",
347            ChannelTransferKind::Copy,
348            source,
349            destination,
350        ),
351        ChannelCommand::Move {
352            source,
353            destination,
354        } => (
355            "channel move",
356            ChannelTransferKind::Move,
357            source,
358            destination,
359        ),
360        ChannelCommand::Remove { target, force } => {
361            if explicit_channel.is_some() {
362                return Err(AppError::ChannelOptionNotAllowedForRemove);
363            }
364
365            let channel = review_channel_from_input(&target, ReviewChannelSource::RemoveTarget)?;
366            let channels = GitNotesChannelStore::new(git);
367            remove_channel(&channels, &channel, force)?;
368            return Ok(ExitCode::SUCCESS);
369        }
370    };
371
372    if explicit_channel.is_some() {
373        return Err(AppError::ChannelOptionNotAllowed {
374            command: command_name,
375        });
376    }
377
378    let source = review_channel_from_input(&source, ReviewChannelSource::TransferSource)?;
379    let destination =
380        review_channel_from_input(&destination, ReviewChannelSource::TransferDestination)?;
381    let transfer = ChannelTransfer::new(kind, source, destination)?;
382    let channels = GitNotesChannelStore::new(git);
383    transfer_channel_notes(git, &channels, &transfer)?;
384    Ok(ExitCode::SUCCESS)
385}
386
387fn with_selected_channel<T>(
388    git: &Git,
389    explicit_channel: Option<&str>,
390    operation: impl FnOnce(&ReviewChannel, &GitNotesStore<'_>) -> Result<T, AppError>,
391) -> Result<T, AppError> {
392    let channel = review_channel_from_selection(explicit_channel, git)?;
393    let notes = GitNotesStore::new(git, channel.notes_ref().clone());
394    operation(&channel, &notes)
395}
396
397fn review_channel_from_selection(
398    explicit: Option<&str>,
399    git: &Git,
400) -> Result<ReviewChannel, AppError> {
401    match explicit {
402        Some(input) => review_channel_from_input(input, ReviewChannelSource::Cli),
403        None => git.configured_review_channel()?.map_or_else(
404            || {
405                review_channel_from_input(
406                    DEFAULT_REVIEW_CHANNEL,
407                    ReviewChannelSource::BuiltInDefault,
408                )
409            },
410            |input| review_channel_from_input(&input, ReviewChannelSource::Config),
411        ),
412    }
413}
414
415#[derive(Clone, Copy, Debug)]
416enum ReviewChannelSource {
417    Cli,
418    Config,
419    BuiltInDefault,
420    TransferSource,
421    TransferDestination,
422    RemoveTarget,
423}
424
425fn review_channel_from_input(
426    input: &str,
427    source: ReviewChannelSource,
428) -> Result<ReviewChannel, AppError> {
429    ReviewChannel::new(input).map_err(|error| channel_error_from_source(input, &error, source))
430}
431
432fn channel_error_from_source(
433    input: &str,
434    error: &ChannelError,
435    source: ReviewChannelSource,
436) -> AppError {
437    AppError::InvalidChannel {
438        channel: input.to_owned(),
439        details: details_from_source(error.to_string(), source),
440    }
441}
442
443fn details_from_source(details: String, source: ReviewChannelSource) -> String {
444    match source {
445        ReviewChannelSource::Config => format!("from git config vet.channel: {details}"),
446        ReviewChannelSource::TransferSource => format!("from SOURCE argument: {details}"),
447        ReviewChannelSource::TransferDestination => {
448            format!("from DESTINATION argument: {details}")
449        }
450        ReviewChannelSource::RemoveTarget => format!("from CHANNEL argument: {details}"),
451        ReviewChannelSource::Cli | ReviewChannelSource::BuiltInDefault => details,
452    }
453}