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 #[arg(long, global = true)]
27 channel: Option<String>,
28 #[command(subcommand)]
29 command: CommandKind,
30}
31
32#[derive(Subcommand, Debug)]
33enum CommandKind {
34 Channel {
36 #[command(subcommand)]
37 command: ChannelCommand,
38 },
39 Mark {
41 #[arg(long)]
43 allow_dirty: bool,
44 #[arg(required = true)]
45 paths: Vec<PathBuf>,
46 },
47 Unmark {
49 #[arg(required = true)]
50 paths: Vec<PathBuf>,
51 },
52 Status {
54 #[arg(long)]
56 json: bool,
57 #[arg(long)]
59 all: bool,
60 #[arg(long)]
62 check: bool,
63 #[arg(long)]
65 workspace: bool,
66 #[arg(value_name = "PATHSPEC")]
68 paths: Vec<PathBuf>,
69 },
70 Diff {
72 #[arg(long)]
74 workspace: bool,
75 path: PathBuf,
76 },
77 Sync {
79 #[arg(long)]
81 remote: Option<String>,
82 },
83 Prune,
85}
86
87#[derive(Subcommand, Debug)]
88enum ChannelCommand {
89 Copy {
91 #[arg(value_name = "SOURCE")]
93 source: String,
94 #[arg(value_name = "DESTINATION")]
96 destination: String,
97 },
98 Move {
100 #[arg(value_name = "SOURCE")]
102 source: String,
103 #[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, ¬es)
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}