1use crate::commit_style::{
24 self, Gitmoji, Style, KEY_BODY_WRAP, KEY_DESCRIPTION_MAX, KEY_GITMOJI, KEY_SUBJECT_MAX,
25};
26use crate::git;
27use crate::ui::highlight;
28use std::io::{BufRead, IsTerminal, Write};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum Where {
33 Local,
34 Global,
35}
36
37impl Where {
38 fn flag(self) -> &'static str {
39 match self {
40 Where::Local => "--local",
41 Where::Global => "--global",
42 }
43 }
44 fn word(self) -> &'static str {
45 match self {
46 Where::Local => "local",
47 Where::Global => "global",
48 }
49 }
50}
51
52struct Answer {
55 key: &'static str,
56 value: Option<String>,
57 changed: bool,
58}
59
60pub fn command(args: &[std::ffi::OsString]) -> Result<(), String> {
61 if let Ok(root) = crate::hooks::common::repo_root_checked() {
67 let manifest = crate::manifest::load(std::path::Path::new(&root));
68 crate::policy::install(manifest.policy.clone());
69 }
70 let dry_run = args.iter().any(|a| a == "--dry-run");
71 let asked_local = args.iter().any(|a| a == "--local");
72 let asked_global = args.iter().any(|a| a == "--global");
73 if asked_local && asked_global {
74 return Err("amont setup: --local and --global contradict each other".to_string());
75 }
76 if let Some(bad) = args.iter().find(|a| {
77 !matches!(
78 a.to_str(),
79 Some("--dry-run") | Some("--local") | Some("--global")
80 )
81 }) {
82 return Err(format!(
83 "amont setup: unknown argument {:?}\nusage: amont setup [--local|--global] [--dry-run]",
84 bad.to_string_lossy()
85 ));
86 }
87
88 if asked_local && git::stdout(&["rev-parse", "--show-toplevel"]).is_none() {
92 return Err("amont setup --local: not inside a git repository".to_string());
93 }
94
95 let (style, _) = commit_style::describe();
96
97 if !std::io::stdin().is_terminal() {
98 return offer_the_commands(
99 &style,
100 if asked_local {
101 Where::Local
102 } else {
103 Where::Global
104 },
105 );
106 }
107
108 let stdin = std::io::stdin();
109 let mut input = stdin.lock();
110 let forced = match (asked_local, asked_global) {
111 (true, _) => Some(Where::Local),
112 (_, true) => Some(Where::Global),
113 _ => None,
114 };
115 match ask_all(&mut input, &style, forced)? {
116 Some((scope, answers)) => apply(&answers, scope, dry_run),
117 None => quit(),
118 }
119}
120
121fn ask_all(
128 input: &mut impl BufRead,
129 style: &Style,
130 forced_scope: Option<Where>,
131) -> Result<Option<(Where, Vec<Answer>)>, String> {
132 println!("amont setup — what `commit-msg` enforces, and how it decorates.");
133 println!("Nothing here disables a check; it changes what the check asks for.");
134 println!("Enter keeps the current value. `q` quits without writing anything.");
135
136 let scope = match forced_scope {
137 Some(s) => s,
138 None => match ask_scope(input)? {
139 Some(s) => s,
140 None => return Ok(None),
141 },
142 };
143
144 let mut answers = Vec::new();
145 match ask_gitmoji(input, style.gitmoji)? {
146 Some(a) => answers.push(a),
147 None => return Ok(None),
148 }
149 for (key, label, why, current, default) in [
150 (
151 KEY_SUBJECT_MAX,
152 "Maximum length of the whole subject line",
153 "72 is git's own convention, and what `git log --oneline` fits",
154 style.subject_max,
155 commit_style::DEFAULT_SUBJECT_MAX,
156 ),
157 (
158 KEY_DESCRIPTION_MAX,
159 "Maximum length of the description, after the `type: `",
160 "50 is the strict end of the convention; 68 still fits a 72-column \
161 subject with a short type and no scope",
162 style.description_max,
163 commit_style::DEFAULT_DESCRIPTION_MAX,
164 ),
165 (
166 KEY_BODY_WRAP,
167 "Hard-wrap the body at how many columns",
168 "0 leaves the body exactly as written — what keeps a pasted stack \
169 trace or a fenced code block intact",
170 style.body_wrap,
171 commit_style::DEFAULT_BODY_WRAP,
172 ),
173 ] {
174 match ask_number(input, key, label, why, current, default)? {
175 Some(a) => answers.push(a),
176 None => return Ok(None),
177 }
178 }
179
180 Ok(Some((scope, answers)))
181}
182
183fn quit() -> Result<(), String> {
184 println!("\nnothing written.");
185 Ok(())
186}
187
188fn apply(answers: &[Answer], scope: Where, dry_run: bool) -> Result<(), String> {
190 let changed: Vec<&Answer> = answers.iter().filter(|a| a.changed).collect();
191 println!();
192 if changed.is_empty() {
193 println!("nothing to change — every setting is already what you chose.");
194 } else {
195 println!(
196 "{} ({}):",
197 if dry_run { "would write" } else { "wrote" },
198 scope.word()
199 );
200 for a in &changed {
201 match &a.value {
202 Some(v) => println!(" git config {} {} {v}", scope.flag(), a.key),
203 None => println!(" git config {} --unset {}", scope.flag(), a.key),
207 }
208 }
209 if !dry_run {
210 for a in &changed {
211 let ok = match &a.value {
212 Some(v) => git::succeeds(&["config", scope.flag(), a.key, v]),
213 None => {
214 git::succeeds(&["config", scope.flag(), "--unset", a.key])
216 || matches!(
217 git::output(&["config", scope.flag(), "--get", a.key]),
218 Some(o) if o.code == 1
219 )
220 }
221 };
222 if !ok {
223 return Err(format!("amont setup: could not write {}", a.key));
224 }
225 }
226 }
227 }
228
229 let unchanged: Vec<&Answer> = answers.iter().filter(|a| !a.changed).collect();
230 if !unchanged.is_empty() {
231 println!("\nunchanged:");
232 for a in unchanged {
233 println!(" {}", a.key);
234 }
235 }
236
237 println!("\nTwo more, per repository rather than per person:");
238 println!(" git config amont.fix true # let a check fix what it finds");
239 println!(" git config amont.testPushedTree true # test what you push, not your tree");
240 println!("\nRead it all back with: amont list");
241 Ok(())
242}
243
244fn offer_the_commands(style: &Style, scope: Where) -> Result<(), String> {
251 eprintln!("amont setup: not a terminal — nothing to ask. The keys, with their current values:");
252 println!(
253 "git config {} {KEY_GITMOJI} {}",
254 scope.flag(),
255 style.gitmoji.as_str()
256 );
257 println!(
258 "git config {} {KEY_SUBJECT_MAX} {}",
259 scope.flag(),
260 style.subject_max
261 );
262 println!(
263 "git config {} {KEY_DESCRIPTION_MAX} {}",
264 scope.flag(),
265 style.description_max
266 );
267 println!(
268 "git config {} {KEY_BODY_WRAP} {}",
269 scope.flag(),
270 style.body_wrap
271 );
272 Ok(())
273}
274
275fn prompt(input: &mut impl BufRead, question: &str, why: &str, current: &str) -> Option<String> {
277 println!("\n{question}");
278 if !why.is_empty() {
279 println!(" {why}");
280 }
281 print!(" [{}] > ", highlight(current));
282 let _ = std::io::stdout().flush();
283 let mut line = String::new();
284 if input.read_line(&mut line).ok()? == 0 {
285 return None; }
287 let answer = line.trim().to_string();
288 if answer.eq_ignore_ascii_case("q") {
289 return None;
290 }
291 Some(answer)
292}
293
294fn ask_scope(input: &mut impl BufRead) -> Result<Option<Where>, String> {
295 loop {
296 let Some(answer) = prompt(
297 input,
298 "Where should these settings go?",
299 "global is usually right — how you write commit messages is the same \
300 statement in every repository you have",
301 "global",
302 ) else {
303 return Ok(None);
304 };
305 match answer.as_str() {
306 "" | "global" => return Ok(Some(Where::Global)),
307 "local" => {
308 if git::stdout(&["rev-parse", "--show-toplevel"]).is_none() {
309 println!(" not inside a git repository — `local` has nowhere to go.");
310 continue;
311 }
312 return Ok(Some(Where::Local));
313 }
314 other => println!(" {other:?} is neither `global` nor `local`."),
315 }
316 }
317}
318
319fn ask_gitmoji(input: &mut impl BufRead, current: Gitmoji) -> Result<Option<Answer>, String> {
320 println!("\nWhere should the type's gitmoji go?");
321 for g in Gitmoji::ALL {
322 println!(" {:<9} {:<22} {}", g.as_str(), g.example(), g.explain());
323 }
324 loop {
325 print!(" [{}] > ", highlight(current.as_str()));
326 let _ = std::io::stdout().flush();
327 let mut line = String::new();
328 if input.read_line(&mut line).map_err(|e| e.to_string())? == 0 {
329 return Ok(None);
330 }
331 let answer = line.trim();
332 if answer.eq_ignore_ascii_case("q") {
333 return Ok(None);
334 }
335 let chosen = if answer.is_empty() {
336 current
337 } else {
338 match Gitmoji::parse(&answer.to_ascii_lowercase()) {
339 Some(g) => g,
340 None => {
341 println!(" {answer:?} is not one of the four above.");
342 continue;
343 }
344 }
345 };
346 return Ok(Some(answer_for(
347 KEY_GITMOJI,
348 chosen.as_str().to_string(),
349 commit_style::DEFAULT_GITMOJI.as_str().to_string(),
350 current.as_str().to_string(),
351 )));
352 }
353}
354
355fn ask_number(
356 input: &mut impl BufRead,
357 key: &'static str,
358 label: &str,
359 why: &str,
360 current: usize,
361 default: usize,
362) -> Result<Option<Answer>, String> {
363 loop {
364 let Some(answer) = prompt(input, label, why, ¤t.to_string()) else {
365 return Ok(None);
366 };
367 let chosen = if answer.is_empty() {
368 current
369 } else {
370 match answer.parse::<usize>() {
371 Ok(n) => n,
372 Err(_) => {
373 println!(" {answer:?} is not a number.");
374 continue;
375 }
376 }
377 };
378 return Ok(Some(answer_for(
379 key,
380 chosen.to_string(),
381 default.to_string(),
382 current.to_string(),
383 )));
384 }
385}
386
387fn answer_for(key: &'static str, chosen: String, default: String, current: String) -> Answer {
393 let changed = chosen != current;
394 Answer {
395 key,
396 value: if chosen == default {
397 None
398 } else {
399 Some(chosen)
400 },
401 changed,
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408
409 #[test]
412 fn choosing_the_default_unsets_the_key() {
413 let a = answer_for(KEY_SUBJECT_MAX, "72".into(), "72".into(), "100".into());
414 assert!(a.value.is_none(), "should unset");
415 assert!(a.changed);
416
417 let b = answer_for(KEY_SUBJECT_MAX, "100".into(), "72".into(), "72".into());
418 assert_eq!(b.value.as_deref(), Some("100"));
419 assert!(b.changed);
420 }
421
422 #[test]
425 fn keeping_the_current_value_changes_nothing() {
426 let a = answer_for(KEY_SUBJECT_MAX, "100".into(), "72".into(), "100".into());
427 assert!(!a.changed);
428 }
429
430 #[test]
431 fn the_two_scopes_spell_themselves_for_git() {
432 assert_eq!(Where::Local.flag(), "--local");
433 assert_eq!(Where::Global.flag(), "--global");
434 }
435
436 #[test]
439 fn q_and_eof_both_mean_quit() {
440 let mut q = std::io::Cursor::new(b"q\n".to_vec());
441 assert!(prompt(&mut q, "x", "", "d").is_none());
442 let mut eof = std::io::Cursor::new(Vec::new());
443 assert!(prompt(&mut eof, "x", "", "d").is_none());
444 let mut enter = std::io::Cursor::new(b"\n".to_vec());
445 assert_eq!(prompt(&mut enter, "x", "", "d").as_deref(), Some(""));
446 }
447
448 fn answers(input: &str, style: &Style) -> Option<(Where, Vec<Answer>)> {
449 let mut cursor = std::io::Cursor::new(input.as_bytes().to_vec());
450 ask_all(&mut cursor, style, None).expect("the flow does not fail")
451 }
452
453 fn value(list: &[Answer], key: &str) -> Option<String> {
454 list.iter()
455 .find(|a| a.key == key)
456 .and_then(|a| a.value.clone())
457 }
458
459 #[test]
461 fn every_answer_reaches_its_key() {
462 let (scope, list) = answers("global\nsuffix\n100\n68\n0\n", &Style::default()).unwrap();
463 assert_eq!(scope, Where::Global);
464 assert_eq!(value(&list, KEY_GITMOJI).as_deref(), Some("suffix"));
465 assert_eq!(value(&list, KEY_SUBJECT_MAX).as_deref(), Some("100"));
466 assert_eq!(value(&list, KEY_DESCRIPTION_MAX).as_deref(), Some("68"));
467 assert_eq!(value(&list, KEY_BODY_WRAP).as_deref(), Some("0"));
468 }
469
470 #[test]
475 fn accepting_every_default_changes_nothing() {
476 let (_, list) = answers("\n\n\n\n\n", &Style::default()).unwrap();
477 assert!(
478 list.iter().all(|a| !a.changed),
479 "something was marked changed: {:?}",
480 list.iter().map(|a| a.key).collect::<Vec<_>>()
481 );
482 }
483
484 #[test]
487 fn the_offered_default_is_what_is_in_effect() {
488 let configured = Style {
489 gitmoji: Gitmoji::Prefix,
490 subject_max: 100,
491 ..Style::default()
492 };
493 let (_, list) = answers("\n\n\n\n\n", &configured).unwrap();
494 assert!(list.iter().all(|a| !a.changed));
495 assert_eq!(value(&list, KEY_GITMOJI).as_deref(), Some("prefix"));
497 assert_eq!(value(&list, KEY_SUBJECT_MAX).as_deref(), Some("100"));
498 }
499
500 #[test]
503 fn returning_to_the_default_unsets_rather_than_pins() {
504 let configured = Style {
505 subject_max: 100,
506 ..Style::default()
507 };
508 let (_, list) = answers("\nnone\n72\n\n\n", &configured).unwrap();
509 let subject = list
510 .iter()
511 .find(|a| a.key == KEY_SUBJECT_MAX)
512 .expect("asked");
513 assert!(subject.changed);
514 assert!(subject.value.is_none(), "should unset, not write 72");
515 }
516
517 #[test]
519 fn an_unusable_answer_is_asked_again() {
520 let (_, list) = answers(
521 "sideways\nglobal\nnope\nsuffix\nlots\n80\n\n\n",
522 &Style::default(),
523 )
524 .unwrap();
525 assert_eq!(value(&list, KEY_GITMOJI).as_deref(), Some("suffix"));
526 assert_eq!(value(&list, KEY_SUBJECT_MAX).as_deref(), Some("80"));
527 }
528
529 #[test]
531 fn quitting_midway_discards_the_answers_already_given() {
532 assert!(answers("global\nsuffix\nq\n", &Style::default()).is_none());
533 assert!(answers("global\nsuffix\n", &Style::default()).is_none());
535 }
536}