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    // Outside a repository there is no manifest and so no policy — the same
67    // state the global store used to be in when nothing had installed it.
68    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    // `--local` needs a repository, and refusing beats falling back to `.` —
94    // the same reasoning `trust::command` records: a fallback would write a
95    // setting into whatever directory somebody happened to be standing in.
96    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
126/// Every question, in order. `None` means the reader quit and nothing should be
127/// written.
128///
129/// Takes its input as a `BufRead` rather than reaching for stdin itself, which
130/// is what lets the whole flow — defaults, re-asking on a bad answer, quitting
131/// — be tested without a pseudo-terminal.
132fn 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
193/// Write the answers, and print exactly what was written.
194fn 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                // Unsetting is how a setting goes back to the shipped default,
209                // which is a state `git config <key> <default>` cannot express:
210                // one is "I chose this", the other is "I have no opinion".
211                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                        // Exit 5 is "nothing to unset", which is success here.
220                        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
249/// Not a terminal: print the keys and their current values as commands, and
250/// exit **0**.
251///
252/// `amont setup > setup.sh` in a provisioning script should produce
253/// something usable rather than an error. The refusal goes to stderr so it does
254/// not end up in that file.
255fn 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
280/// Read one answer. `None` means quit — `q`, or end of input.
281fn 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; // EOF
291    }
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, &current.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
392/// A chosen value, as a write instruction.
393///
394/// Choosing the shipped default writes an **unset**, not the value: the two
395/// are different statements, and a config file full of keys set to what they
396/// already were is noise somebody else has to read.
397fn 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    /// Choosing the default means the key goes away, not that it gets written
415    /// with a value equal to the default.
416    #[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    /// Answering with what is already in effect is not a change, so the wizard
428    /// writes nothing and says so. That is what makes re-running it safe.
429    #[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    /// A quit at any prompt writes nothing — a half-applied wizard is the worst
442    /// outcome available.
443    #[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    /// The whole flow, answered.
465    #[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    /// PROPERTY: pressing Enter through the whole wizard writes nothing.
476    ///
477    /// That is what makes re-running it safe — the brackets show what is in
478    /// effect, so accepting them all is by definition a no-op.
479    #[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    /// The brackets show the CURRENT value, not the shipped one, so a reader
490    /// who already configured something is not quietly offered a reset.
491    #[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        // Keeping a non-default value keeps it WRITTEN, not unset.
501        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    /// Answering with the shipped default removes the key rather than pinning
506    /// it — "I have no opinion" is a different statement from "I chose this".
507    #[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    /// A bad answer re-asks rather than aborting or silently taking a default.
523    #[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    /// Quitting partway writes nothing at all.
535    #[test]
536    fn quitting_midway_discards_the_answers_already_given() {
537        assert!(answers("global\nsuffix\nq\n", &Style::default()).is_none());
538        // And end of input is the same thing.
539        assert!(answers("global\nsuffix\n", &Style::default()).is_none());
540    }
541}