Skip to main content

amont_runtime/
setup.rs

1//! `amont setup` — choose what `commit-msg` enforces, once.
2//!
3//! The keys in [`crate::commit_style`] are the answer to "these defaults do not
4//! suit me". This is the answer to "I did not know there were any". An adoption
5//! feature is only half built if the dial exists and nobody finds it, so the
6//! same four settings are offered here, each with its current value as the
7//! default and one line saying what it is for.
8//!
9//! **Input is stdin, not `/dev/tty`.** `trust::confirm` opens `/dev/tty`
10//! because it prompts from inside a *hook*, where git owns stdin and reading it
11//! would consume a pre-push ref list. That reasoning does not reach here: this
12//! is a subcommand somebody typed, and its stdin IS the terminal. The general
13//! rule, worth stating once: `/dev/tty` for a prompt inside a hook, stdin for a
14//! prompt inside a subcommand. Reading stdin also works on Windows — where
15//! `confirm` is hard-coded to decline — and can be driven by a heredoc, which
16//! is how this is tested without a pty.
17//!
18//! Nothing is written until every question has been answered, and what does get
19//! written is printed as the `git config` commands that would produce it. A
20//! wizard that reports "saved" has told you nothing you can check, paste into
21//! your dotfiles, or hand to a teammate.
22
23use 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/// Where the answers are written.
31#[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
52/// One decided setting: the key, and the value to write — or `None` to unset,
53/// which is how a setting returns to the shipped default.
54struct Answer {
55    key: &'static str,
56    value: Option<String>,
57    changed: bool,
58}
59
60pub fn command(args: &[std::ffi::OsString]) -> Result<(), String> {
61    // INVARIANT: policy installed immediately after every manifest::load —
62    // setup DISPLAYS effective values, and without the policy it would show
63    // pre-policy state as "current" and offer to re-write it. What setup
64    // WRITES is --local/--global git config, so the ladder stays correct
65    // either way; only the display needed the truth.
66    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    // `--local` needs a repository, and refusing beats falling back to `.` —
89    // the same reasoning `trust::command` records: a fallback would write a
90    // setting into whatever directory somebody happened to be standing in.
91    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
121/// Every question, in order. `None` means the reader quit and nothing should be
122/// written.
123///
124/// Takes its input as a `BufRead` rather than reaching for stdin itself, which
125/// is what lets the whole flow — defaults, re-asking on a bad answer, quitting
126/// — be tested without a pseudo-terminal.
127fn 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
188/// Write the answers, and print exactly what was written.
189fn 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                // Unsetting is how a setting goes back to the shipped default,
204                // which is a state `git config <key> <default>` cannot express:
205                // one is "I chose this", the other is "I have no opinion".
206                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                        // Exit 5 is "nothing to unset", which is success here.
215                        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
244/// Not a terminal: print the keys and their current values as commands, and
245/// exit **0**.
246///
247/// `amont setup > setup.sh` in a provisioning script should produce
248/// something usable rather than an error. The refusal goes to stderr so it does
249/// not end up in that file.
250fn 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
275/// Read one answer. `None` means quit — `q`, or end of input.
276fn 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; // EOF
286    }
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, &current.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
387/// A chosen value, as a write instruction.
388///
389/// Choosing the shipped default writes an **unset**, not the value: the two
390/// are different statements, and a config file full of keys set to what they
391/// already were is noise somebody else has to read.
392fn 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    /// Choosing the default means the key goes away, not that it gets written
410    /// with a value equal to the default.
411    #[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    /// Answering with what is already in effect is not a change, so the wizard
423    /// writes nothing and says so. That is what makes re-running it safe.
424    #[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    /// A quit at any prompt writes nothing — a half-applied wizard is the worst
437    /// outcome available.
438    #[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    /// The whole flow, answered.
460    #[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    /// PROPERTY: pressing Enter through the whole wizard writes nothing.
471    ///
472    /// That is what makes re-running it safe — the brackets show what is in
473    /// effect, so accepting them all is by definition a no-op.
474    #[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    /// The brackets show the CURRENT value, not the shipped one, so a reader
485    /// who already configured something is not quietly offered a reset.
486    #[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        // Keeping a non-default value keeps it WRITTEN, not unset.
496        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    /// Answering with the shipped default removes the key rather than pinning
501    /// it — "I have no opinion" is a different statement from "I chose this".
502    #[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    /// A bad answer re-asks rather than aborting or silently taking a default.
518    #[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    /// Quitting partway writes nothing at all.
530    #[test]
531    fn quitting_midway_discards_the_answers_already_given() {
532        assert!(answers("global\nsuffix\nq\n", &Style::default()).is_none());
533        // And end of input is the same thing.
534        assert!(answers("global\nsuffix\n", &Style::default()).is_none());
535    }
536}