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::{
7    ChannelError, ChannelTransfer, ChannelTransferKind, DEFAULT_REVIEW_CHANNEL, ReviewChannel,
8};
9use crate::commands::{
10    DiffTarget, DirtyPathHandling, Gate, MarkOptions, StatusMode, StatusTarget, diff_path,
11    mark_paths, status, sync_notes, transfer_channel_notes, unmark_paths,
12};
13use crate::error::AppError;
14use crate::git::Git;
15use crate::notes::{GitNotesChannelStore, GitNotesStore, NotesStore};
16use crate::sync_progress::SyncProgressReporter;
17
18#[derive(Parser, Debug)]
19#[command(
20    name = "git-vet",
21    version,
22    about = "Track human review state for Git-tracked file contents"
23)]
24pub struct Cli {
25    /// Review channel for commands that operate on one selected channel.
26    #[arg(long, global = true)]
27    channel: Option<String>,
28    #[command(subcommand)]
29    command: CommandKind,
30}
31
32#[derive(Subcommand, Debug)]
33enum CommandKind {
34    /// Manage local review notes across channels.
35    Channel {
36        #[command(subcommand)]
37        command: ChannelCommand,
38    },
39    /// Sign off the current HEAD content of tracked files.
40    Mark {
41        /// Proceed even if target paths have uncommitted working-tree changes.
42        #[arg(long)]
43        allow_dirty: bool,
44        #[arg(required = true)]
45        paths: Vec<PathBuf>,
46    },
47    /// Remove sign-off for the current HEAD content of tracked files.
48    Unmark {
49        #[arg(required = true)]
50        paths: Vec<PathBuf>,
51    },
52    /// Show review state for tracked files.
53    Status {
54        /// Emit stable machine-readable JSON.
55        #[arg(long)]
56        json: bool,
57        /// Include files that are already vetted in human-readable output.
58        #[arg(long)]
59        all: bool,
60        /// Exit 1 when any in-scope tracked file is unreviewed.
61        #[arg(long)]
62        check: bool,
63        /// Classify local working-tree contents instead of committed HEAD contents.
64        #[arg(long)]
65        workspace: bool,
66        /// Limit status to tracked files matching these file or directory pathspecs.
67        #[arg(value_name = "PATHSPEC")]
68        paths: Vec<PathBuf>,
69    },
70    /// Show the diff that still needs review for a tracked file.
71    Diff {
72        /// Compare the latest vetted content with the workspace instead of HEAD.
73        #[arg(long)]
74        workspace: bool,
75        path: PathBuf,
76    },
77    /// Fetch, merge, and push review notes for the selected channel.
78    Sync {
79        /// Remote to sync with. Overrides vet.syncRemote and origin fallback.
80        #[arg(long)]
81        remote: Option<String>,
82    },
83    /// Prune notes for objects that are no longer present.
84    Prune,
85}
86
87#[derive(Subcommand, Debug)]
88enum ChannelCommand {
89    /// Copy all local review notes into a new channel.
90    Copy {
91        /// Channel whose local review notes are copied.
92        #[arg(value_name = "SOURCE")]
93        source: String,
94        /// New channel that receives the copied review notes.
95        #[arg(value_name = "DESTINATION")]
96        destination: String,
97    },
98    /// Move all local review notes into a new channel.
99    Move {
100        /// Channel whose local review notes are moved.
101        #[arg(value_name = "SOURCE")]
102        source: String,
103        /// New channel that receives the moved review notes.
104        #[arg(value_name = "DESTINATION")]
105        destination: String,
106    },
107}
108
109pub fn run_cli() -> Result<ExitCode, AppError> {
110    let Cli { channel, command } = Cli::parse();
111    let git = Git::discover()?;
112
113    match command {
114        CommandKind::Channel { command } => run_channel_command(&git, channel.as_deref(), command),
115        CommandKind::Mark { paths, allow_dirty } => {
116            with_selected_channel(&git, channel.as_deref(), |_, notes| {
117                let dirty_paths = if allow_dirty {
118                    DirtyPathHandling::Allow
119                } else {
120                    DirtyPathHandling::Prompt
121                };
122                mark_paths(&git, notes, &paths, MarkOptions { dirty_paths })?;
123                Ok(ExitCode::SUCCESS)
124            })
125        }
126        CommandKind::Unmark { paths } => {
127            with_selected_channel(&git, channel.as_deref(), |_, notes| {
128                unmark_paths(&git, notes, &paths)?;
129                Ok(ExitCode::SUCCESS)
130            })
131        }
132        CommandKind::Status {
133            json,
134            all,
135            check,
136            workspace,
137            paths,
138        } => with_selected_channel(&git, channel.as_deref(), |channel, notes| {
139            let target = if workspace {
140                StatusTarget::Workspace
141            } else {
142                StatusTarget::Head
143            };
144            match status(
145                &git,
146                notes,
147                channel,
148                StatusMode {
149                    json,
150                    all: all || !paths.is_empty(),
151                    check,
152                    target,
153                },
154                &paths,
155            )? {
156                Gate::Open => Ok(ExitCode::SUCCESS),
157                Gate::Closed => Ok(ExitCode::from(1)),
158            }
159        }),
160        CommandKind::Diff { path, workspace } => {
161            with_selected_channel(&git, channel.as_deref(), |_, notes| {
162                let target = if workspace {
163                    DiffTarget::Workspace
164                } else {
165                    DiffTarget::Head
166                };
167                diff_path(&git, notes, &path, target)?;
168                Ok(ExitCode::SUCCESS)
169            })
170        }
171        CommandKind::Sync { remote } => {
172            with_selected_channel(&git, channel.as_deref(), |channel, notes| {
173                let remote = git.select_sync_remote(remote.as_deref())?;
174                let mut progress = SyncProgressReporter::from_environment();
175                sync_notes(notes, channel, &remote, &mut progress)?;
176                Ok(ExitCode::SUCCESS)
177            })
178        }
179        CommandKind::Prune => with_selected_channel(&git, channel.as_deref(), |_, notes| {
180            notes.prune()?;
181            Ok(ExitCode::SUCCESS)
182        }),
183    }
184}
185
186fn run_channel_command(
187    git: &Git,
188    explicit_channel: Option<&str>,
189    command: ChannelCommand,
190) -> Result<ExitCode, AppError> {
191    let (command_name, kind, source, destination) = match command {
192        ChannelCommand::Copy {
193            source,
194            destination,
195        } => (
196            "channel copy",
197            ChannelTransferKind::Copy,
198            source,
199            destination,
200        ),
201        ChannelCommand::Move {
202            source,
203            destination,
204        } => (
205            "channel move",
206            ChannelTransferKind::Move,
207            source,
208            destination,
209        ),
210    };
211
212    if explicit_channel.is_some() {
213        return Err(AppError::ChannelOptionNotAllowed {
214            command: command_name,
215        });
216    }
217
218    let source = review_channel_from_input(&source, ReviewChannelSource::TransferSource)?;
219    let destination =
220        review_channel_from_input(&destination, ReviewChannelSource::TransferDestination)?;
221    let transfer = ChannelTransfer::new(kind, source, destination)?;
222    let channels = GitNotesChannelStore::new(git);
223    transfer_channel_notes(git, &channels, &transfer)?;
224    Ok(ExitCode::SUCCESS)
225}
226
227fn with_selected_channel<T>(
228    git: &Git,
229    explicit_channel: Option<&str>,
230    operation: impl FnOnce(&ReviewChannel, &GitNotesStore<'_>) -> Result<T, AppError>,
231) -> Result<T, AppError> {
232    let channel = review_channel_from_selection(explicit_channel, git)?;
233    let notes = GitNotesStore::new(git, channel.notes_ref().clone());
234    operation(&channel, &notes)
235}
236
237fn review_channel_from_selection(
238    explicit: Option<&str>,
239    git: &Git,
240) -> Result<ReviewChannel, AppError> {
241    match explicit {
242        Some(input) => review_channel_from_input(input, ReviewChannelSource::Cli),
243        None => git.configured_review_channel()?.map_or_else(
244            || {
245                review_channel_from_input(
246                    DEFAULT_REVIEW_CHANNEL,
247                    ReviewChannelSource::BuiltInDefault,
248                )
249            },
250            |input| review_channel_from_input(&input, ReviewChannelSource::Config),
251        ),
252    }
253}
254
255#[derive(Clone, Copy, Debug)]
256enum ReviewChannelSource {
257    Cli,
258    Config,
259    BuiltInDefault,
260    TransferSource,
261    TransferDestination,
262}
263
264fn review_channel_from_input(
265    input: &str,
266    source: ReviewChannelSource,
267) -> Result<ReviewChannel, AppError> {
268    ReviewChannel::new(input).map_err(|error| channel_error_from_source(input, &error, source))
269}
270
271fn channel_error_from_source(
272    input: &str,
273    error: &ChannelError,
274    source: ReviewChannelSource,
275) -> AppError {
276    AppError::InvalidChannel {
277        channel: input.to_owned(),
278        details: details_from_source(error.to_string(), source),
279    }
280}
281
282fn details_from_source(details: String, source: ReviewChannelSource) -> String {
283    match source {
284        ReviewChannelSource::Config => format!("from git config vet.channel: {details}"),
285        ReviewChannelSource::TransferSource => format!("from SOURCE argument: {details}"),
286        ReviewChannelSource::TransferDestination => {
287            format!("from DESTINATION argument: {details}")
288        }
289        ReviewChannelSource::Cli | ReviewChannelSource::BuiltInDefault => details,
290    }
291}