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