Skip to main content

ljos_cli/
gates.rs

1//! Git call reading for the one push check.
2//!
3//! A push onto a protected branch asks when the ticket has no settled
4//! consensus. That check lives in the sitting gate and reads
5//! [`consensus_push`] and [`protected_push`] here. Commit, merge, evidence
6//! and foreign-owner gates are gone.
7
8use std::path::{Path, PathBuf};
9use std::process::Stdio;
10
11/// Nothing left for a push to warn about. The evidence gate is gone.
12#[must_use]
13pub fn take_push_warning() -> Option<String> {
14    None
15}
16
17#[cfg(test)]
18thread_local! {
19    /// User and seat config for this thread, instead of [`super::config_base`].
20    static LAYER_HOME: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
21    /// `LJOS_SEAT` for this thread, so a test can name a seat file without
22    /// changing the process environment.
23    static LAYER_SEAT: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
24}
25
26/// No commit, merge, or evidence gate remains. Always `None`.
27#[must_use]
28pub fn command_gate(_line: &str, _cwd: Option<&Path>) -> Option<super::Rule> {
29    None
30}
31
32/// One git call a shell line runs, its aliases expanded: the checkout it
33/// runs in, the subcommand, its arguments, and each `-c KEY=VALUE`.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct GitCall {
36    pub cwd: PathBuf,
37    pub sub: String,
38    pub args: Vec<String>,
39    pub config: Vec<(String, String)>,
40}
41
42/// Every git call `line` runs from `cwd`, the process directory when
43/// absent. `cd DIR`, `git -C DIR`, `sh -c`, `$(...)` and a `!` alias's
44/// shell are followed the way [`command_gate`] follows them.
45///
46/// # Errors
47///
48/// The aliases nest too deep to read, so what the line runs is not known.
49pub fn git_calls(line: &str, cwd: Option<&Path>) -> Result<Vec<GitCall>, String> {
50    let mut out = Vec::new();
51    if !line.to_ascii_lowercase().contains("git") {
52        return Ok(out);
53    }
54    let found = std::env::current_dir().ok();
55    let base = cwd.or(found.as_deref()).unwrap_or_else(|| Path::new("."));
56    let mut queue = invocations(line, base, 0);
57    let mut i = 0;
58    while i < queue.len() {
59        let inv = queue[i].clone();
60        i += 1;
61        if inv.depth > 3 {
62            return Err("its git aliases nest too deep to read".into());
63        }
64        let Some(git) = git_of(&inv) else {
65            continue;
66        };
67        match git.shell {
68            Some(shell) => {
69                let extras = invocations(&shell, &git.cwd, inv.depth.saturating_add(1));
70                queue.splice(i..i, extras);
71            }
72            None => out.push(GitCall {
73                cwd: git.cwd,
74                sub: git.sub,
75                args: git.args,
76                config: git.config,
77            }),
78        }
79    }
80    Ok(out)
81}
82
83/// The static guard and then the pack's rules. `None` lets the line run.
84#[must_use]
85pub fn guard_verdict(line: &str, cwd: Option<&Path>, rules: &[super::Rule]) -> Option<super::Rule> {
86    let fast = super::static_guard_in(line, cwd);
87    let here = cwd.and_then(Path::to_str).map(str::to_string).or_else(|| {
88        std::env::current_dir()
89            .ok()
90            .and_then(|dir| dir.into_os_string().into_string().ok())
91    });
92    let gated = super::redirect_seat_verb(
93        super::gate_push(
94            super::ruled_verdict(rules, line).as_ref(),
95            line,
96            here.as_deref(),
97        ),
98        line,
99    );
100    if gated.as_ref().is_some_and(|rule| rule.verdict == "deny") {
101        return gated;
102    }
103    fast.or(gated)
104}
105
106#[derive(Debug, Clone)]
107struct Inv {
108    cwd: PathBuf,
109    words: Vec<String>,
110    depth: u8,
111}
112
113#[derive(Debug, Clone)]
114struct GitCmd {
115    cwd: PathBuf,
116    config: Vec<(String, String)>,
117    sub: String,
118    args: Vec<String>,
119    shell: Option<String>,
120}
121
122#[derive(Debug, Clone, Default)]
123struct GateConfig {
124    protected: Vec<String>,
125}
126
127fn branch_of(name: &str) -> String {
128    name.trim().trim_start_matches("refs/heads/").to_string()
129}
130
131/// User `ljos.toml`, then the seat's, then the repository's `ljos.toml`,
132/// then `LJOS_CONFIG`, then `.ljos/gates.toml`. A later file overrides the
133/// keys it sets. Identity is whatever the last of these files names. With
134/// none of them naming a person, there is no identity gate.
135fn config_layers(root: &Path) -> Vec<PathBuf> {
136    let mut paths = person_layers();
137    paths.push(root.join("ljos.toml"));
138    if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|path| !path.is_empty()) {
139        paths.push(PathBuf::from(path));
140    }
141    paths.push(root.join(".ljos").join("gates.toml"));
142    paths
143}
144
145fn user_config_base() -> Option<PathBuf> {
146    #[cfg(test)]
147    if let Some(path) = LAYER_HOME.with(|slot| slot.borrow().clone()) {
148        return Some(path);
149    }
150    super::config_base()
151}
152
153fn configured_seat() -> Option<String> {
154    #[cfg(test)]
155    if let Some(name) = LAYER_SEAT.with(|slot| slot.borrow().clone()) {
156        return clean_seat(&name);
157    }
158    std::env::var("LJOS_SEAT")
159        .ok()
160        .and_then(|name| clean_seat(&name))
161}
162
163fn clean_seat(name: &str) -> Option<String> {
164    let name = name.trim();
165    if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") {
166        return None;
167    }
168    Some(name.to_string())
169}
170
171fn read_layer(path: &Path) -> Result<Option<toml::map::Map<String, toml::Value>>, String> {
172    let text = std::fs::read_to_string(path).map_err(|e| {
173        format!(
174            "`{}` cannot be read ({e}). Fix the file, then retry.",
175            path.display()
176        )
177    })?;
178    if text.trim().is_empty() {
179        return Ok(None);
180    }
181    let value: toml::Value = toml::from_str(&text).map_err(|e| {
182        format!(
183            "`{}` does not parse ({e}). Fix the file, then retry.",
184            path.display()
185        )
186    })?;
187    match value {
188        toml::Value::Table(root) => Ok(Some(root)),
189        _ => Err(format!(
190            "`{}` is not a table. Fix the file, then retry.",
191            path.display()
192        )),
193    }
194}
195
196fn apply_gate_file(cfg: &mut GateConfig, path: &Path) -> Result<(), String> {
197    let Some(root) = read_layer(path)? else {
198        return Ok(());
199    };
200    let gates = root.get("gates").and_then(toml::Value::as_table);
201    let src = gates.unwrap_or(&root);
202    if src.contains_key("protected_branches") {
203        cfg.protected = req_strings(src, "protected_branches", path)?
204            .into_iter()
205            .map(|name| branch_of(&name))
206            .filter(|name| !name.is_empty())
207            .collect();
208    }
209    Ok(())
210}
211
212fn req_strings(
213    table: &toml::map::Map<String, toml::Value>,
214    key: &str,
215    path: &Path,
216) -> Result<Vec<String>, String> {
217    let Some(items) = table.get(key).and_then(toml::Value::as_array) else {
218        return Err(format!(
219            "`{}` key `{key}` must be a list of strings. Fix the file, then retry.",
220            path.display()
221        ));
222    };
223    let mut out = Vec::new();
224    for item in items {
225        let Some(text) = item.as_str() else {
226            return Err(format!(
227                "`{}` key `{key}` must be a list of strings. Fix the file, then retry.",
228                path.display()
229            ));
230        };
231        out.push(text.to_string());
232    }
233    Ok(out)
234}
235
236/// The branches a `--force-with-lease` push asks before it replaces:
237/// `main`, `master` and `develop`, and every branch `protected_branches`
238/// names in the config layers [`config_layers`] reads (the user and seat
239/// `ljos.toml`, the repository's `ljos.toml`, `LJOS_CONFIG` and
240/// `.ljos/gates.toml`). Outside a checkout the user and seat files and
241/// `LJOS_CONFIG` still count. `None` when one of them does not parse, so
242/// the caller asks.
243pub(crate) fn lease_protected(cwd: Option<&Path>) -> Option<Vec<String>> {
244    let mut cfg = GateConfig::default();
245    let layers = match cwd.and_then(repo_root) {
246        Some(root) => config_layers(&root),
247        None => user_layers(),
248    };
249    for path in layers {
250        if path.is_file() {
251            apply_gate_file(&mut cfg, &path).ok()?;
252        }
253    }
254    let mut out: Vec<String> = ["main", "master", "develop"].map(String::from).to_vec();
255    out.extend(cfg.protected);
256    Some(out)
257}
258
259/// The config layers that do not live in a checkout: the user and seat
260/// `ljos.toml`, then `LJOS_CONFIG`.
261fn user_layers() -> Vec<PathBuf> {
262    let mut paths = person_layers();
263    if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|path| !path.is_empty()) {
264        paths.push(PathBuf::from(path));
265    }
266    paths
267}
268
269/// The user and seat `ljos.toml`: the person's own files, which the seat
270/// guard keeps a runner from writing.
271fn person_layers() -> Vec<PathBuf> {
272    let mut paths = Vec::new();
273    if let Some(base) = user_config_base() {
274        paths.push(base.join("ljos").join("ljos.toml"));
275        if let Some(seat) = configured_seat() {
276            paths.push(base.join("ljos").join("seats").join(seat).join("ljos.toml"));
277        }
278    }
279    paths
280}
281
282/// What a runner's push onto one of `branches` waits for: `quorum` seats
283/// agreeing on the ticket it sits on.
284#[derive(Debug, Clone, PartialEq, Eq)]
285pub struct ConsensusPush {
286    pub quorum: usize,
287    pub branches: Vec<String>,
288}
289
290/// The [`ConsensusPush`] settings: `quorum` (2 seats unless set) and
291/// `consensus_branches` (`develop` and `main` unless set), under `[gates]`
292/// or at the top of a file. The user and seat `ljos.toml` set either. The
293/// repository's `ljos.toml`, `LJOS_CONFIG` and `.ljos/gates.toml` only
294/// raise the quorum and add branches, since a runner can write them.
295///
296/// # Errors
297///
298/// A layer that does not parse, or a key of the wrong kind.
299pub fn consensus_push(cwd: Option<&Path>) -> Result<ConsensusPush, String> {
300    let mut out = ConsensusPush {
301        quorum: 2,
302        branches: vec!["develop".into(), "main".into()],
303    };
304    for path in person_layers() {
305        let (quorum, branches) = consensus_keys(&path)?;
306        out.quorum = quorum.unwrap_or(out.quorum);
307        out.branches = branches.unwrap_or(out.branches);
308    }
309    let root = cwd.and_then(repo_root);
310    let mut rest: Vec<PathBuf> = root.iter().map(|r| r.join("ljos.toml")).collect();
311    if let Some(path) = std::env::var_os("LJOS_CONFIG").filter(|path| !path.is_empty()) {
312        rest.push(PathBuf::from(path));
313    }
314    rest.extend(root.iter().map(|r| r.join(".ljos").join("gates.toml")));
315    for path in rest {
316        let (quorum, branches) = consensus_keys(&path)?;
317        out.quorum = out.quorum.max(quorum.unwrap_or(0));
318        for branch in branches.unwrap_or_default() {
319            if !out.branches.contains(&branch) {
320                out.branches.push(branch);
321            }
322        }
323    }
324    Ok(out)
325}
326
327fn consensus_keys(path: &Path) -> Result<(Option<usize>, Option<Vec<String>>), String> {
328    if !path.is_file() {
329        return Ok((None, None));
330    }
331    let Some(root) = read_layer(path)? else {
332        return Ok((None, None));
333    };
334    let src = root
335        .get("gates")
336        .and_then(toml::Value::as_table)
337        .unwrap_or(&root);
338    let quorum = match src.get("quorum") {
339        None => None,
340        Some(toml::Value::Integer(n)) if *n >= 1 => Some(usize::try_from(*n).unwrap_or(usize::MAX)),
341        Some(_) => {
342            return Err(format!(
343                "`{}` key `quorum` must be a whole number of seats, 1 or more. Fix the file, then retry.",
344                path.display()
345            ))
346        }
347    };
348    let branches = if src.contains_key("consensus_branches") {
349        let names = req_strings(src, "consensus_branches", path)?;
350        Some(
351            names
352                .iter()
353                .map(|name| branch_of(name))
354                .filter(|name| !name.is_empty())
355                .collect(),
356        )
357    } else {
358        None
359    };
360    Ok((quorum, branches))
361}
362
363/// The config layers a command run in `cwd` reads: [`config_layers`] in a
364/// checkout, and the files outside one otherwise.
365pub(crate) fn rewrite_layers(cwd: Option<&Path>) -> Vec<PathBuf> {
366    match cwd.and_then(repo_root) {
367        Some(root) => config_layers(&root),
368        None => user_layers(),
369    }
370}
371
372/// What `path` sets `rewrite` to, under `[gates]` or at the top of the
373/// file, and `None` when it does not set it. A repository sets it to
374/// `false` to have its lines run as they were written.
375///
376/// # Errors
377///
378/// The file cannot be read or does not parse, or `rewrite` is not a
379/// true-or-false value.
380pub(crate) fn rewrite_key(path: &Path) -> Result<Option<bool>, String> {
381    let Some(root) = read_layer(path)? else {
382        return Ok(None);
383    };
384    let src = root
385        .get("gates")
386        .and_then(toml::Value::as_table)
387        .unwrap_or(&root);
388    match src.get("rewrite") {
389        None => Ok(None),
390        Some(toml::Value::Boolean(on)) => Ok(Some(*on)),
391        Some(_) => Err(format!(
392            "`{}` key `rewrite` must be true or false. Fix the file, then retry.",
393            path.display()
394        )),
395    }
396}
397
398/// The file `ljos-policyd check --config` reads: `policyd_config` in the
399/// user's `ljos.toml`, then the seat's, under `[gates]` or at the top of
400/// the file, the later file winning. A relative path is from the directory
401/// of the file that names it. The repository's files and `LJOS_CONFIG` do
402/// not name it, since a runner can write them. A file that does not parse
403/// names nothing. `None` when no file names one, or the last names `""`:
404/// `check` is then asked as it always was.
405#[must_use]
406pub(crate) fn policyd_config() -> Option<PathBuf> {
407    let mut named = None;
408    for path in person_layers() {
409        if !path.is_file() {
410            continue;
411        }
412        let Ok(Some(root)) = read_layer(&path) else {
413            continue;
414        };
415        let raw = root
416            .get("gates")
417            .and_then(|gates| gates.get("policyd_config"))
418            .or_else(|| root.get("policyd_config"))
419            .and_then(toml::Value::as_str);
420        let Some(raw) = raw else {
421            continue;
422        };
423        named = (!raw.is_empty()).then(|| {
424            let found = super::expand(raw);
425            match path.parent() {
426                Some(dir) if found.is_relative() => dir.join(found),
427                _ => found,
428            }
429        });
430    }
431    named
432}
433
434/// Where `call`, a `git push` on `line`, lands that needs a consensus: a
435/// branch in `branches`, in backticks, or why the push cannot be told
436/// from one that does. `None` when each ref it updates is read and none
437/// is in `branches`. The push is read the way git reads it: the first
438/// positional is the repository; a bare push lands on the current branch
439/// and its upstream unless `push.default` says otherwise; a branch named
440/// alone lands on its upstream under `push.default=upstream`. A tag, or
441/// any ref outside `refs/heads/`, is not a branch.
442#[must_use]
443pub fn protected_push(call: &GitCall, line: &str, branches: &[String]) -> Option<String> {
444    let unread = |why: String| {
445        Some(format!(
446            "a branch the guard cannot tell from a protected one, since {why}"
447        ))
448    };
449    let cwd = call.cwd.as_path();
450    let args = &call.args;
451    for word in [
452        "GIT_CONFIG",
453        "GIT_DIR",
454        "--git-dir",
455        "GIT_NAMESPACE",
456        "--namespace",
457    ] {
458        if line.contains(word) {
459            return unread(format!("the line sets `{word}`"));
460        }
461    }
462    for (key, _) in &call.config {
463        let key = key.to_ascii_lowercase();
464        if ["remote.", "push.", "branch."]
465            .iter()
466            .any(|p| key.starts_with(p))
467        {
468            return unread(format!("it sets `{key}` for this push"));
469        }
470    }
471    if args.iter().any(|a| has_subst(a)) {
472        return unread("an argument is filled in when the line runs".into());
473    }
474    if let Some(all) = args
475        .iter()
476        .find(|a| matches!(a.as_str(), "--all" | "--mirror" | "--branches" | "--prune"))
477    {
478        return Some(format!("every branch `{all}` reaches"));
479    }
480    if let Some(flag) = args.iter().find(|a| {
481        a.starts_with("--repo")
482            || (a.starts_with("--recurse-submodules")
483                && !a.ends_with("=check")
484                && !a.ends_with("=no"))
485    }) {
486        return unread(format!("`{flag}` moves where it goes"));
487    }
488    if git_text(cwd, &["config", "--get-regexp", r"^remote\..*\.push$"]).is_some() {
489        return unread("a remote in this checkout sets its own push refspecs".into());
490    }
491    let mode = git_config(cwd, "push.default")
492        .unwrap_or_default()
493        .to_ascii_lowercase();
494    let upstream = |branch: &str| git_config(cwd, &format!("branch.{branch}.merge"));
495    let here = || -> Result<Vec<String>, String> {
496        if ["checkout", "switch", "symbolic-ref"]
497            .iter()
498            .any(|w| line.contains(w))
499        {
500            return Err("the line may change branch before it pushes".into());
501        }
502        let head = git_text(cwd, &["symbolic-ref", "-q", "--short", "HEAD"])
503            .filter(|head| !head.is_empty())
504            .ok_or_else(|| "HEAD names no branch".to_string())?;
505        let up = upstream(&head).filter(|_| mode != "current");
506        Ok(std::iter::once(head).chain(up).collect())
507    };
508    let pos = positionals(args);
509    let specs = pos.get(1..).unwrap_or_default();
510    let mut dests = Vec::new();
511    if specs.is_empty() {
512        match mode.as_str() {
513            "nothing" => return None,
514            "matching" => {
515                return Some("every branch both sides have, under `push.default=matching`".into())
516            }
517            _ => match here() {
518                Ok(here) => dests.extend(here),
519                Err(why) => return unread(why),
520            },
521        }
522    }
523    for spec in specs {
524        let spec = spec.strip_prefix('+').unwrap_or(spec);
525        if spec.contains('*') {
526            let dst = spec.rsplit(':').next().unwrap_or(spec);
527            if !dst.starts_with("refs/") || dst.starts_with("refs/heads/") {
528                return Some(format!("the branches `{spec}` matches"));
529            }
530            continue;
531        }
532        match spec.split_once(':') {
533            Some((_, "HEAD" | "@")) => {
534                return unread(format!("`{spec}` lands on what the far side's HEAD names"))
535            }
536            Some((src, "")) => dests.push(src.to_string()),
537            Some((_, dst)) => dests.push(dst.to_string()),
538            None if matches!(spec, "HEAD" | "@") => match here() {
539                Ok(here) => dests.extend(here),
540                Err(why) => return unread(why),
541            },
542            None => {
543                dests.push(spec.to_string());
544                if matches!(mode.as_str(), "upstream" | "tracking") {
545                    dests.extend(upstream(spec));
546                }
547            }
548        }
549    }
550    dests.iter().find_map(|dest| {
551        let name = dest
552            .strip_prefix("refs/heads/")
553            .or_else(|| dest.strip_prefix("heads/"))
554            .unwrap_or(dest);
555        branches
556            .iter()
557            .find(|b| b.eq_ignore_ascii_case(name))
558            .map(|b| format!("`{b}`"))
559    })
560}
561
562fn repo_root(cwd: &Path) -> Option<PathBuf> {
563    git_text(cwd, &["rev-parse", "--show-toplevel"]).map(PathBuf::from)
564}
565
566fn git_config(cwd: &Path, key: &str) -> Option<String> {
567    let value = git_text(cwd, &["config", "--get", key])?;
568    (!value.is_empty()).then_some(value)
569}
570
571fn git_text(cwd: &Path, args: &[&str]) -> Option<String> {
572    let mut cmd = crate::git_cmd();
573    // An exported GIT_DIR would answer for another repository. The
574    // directory this command is judged in is the one named here.
575    crate::clear_git_repo_env(&mut cmd);
576    cmd.arg("-C")
577        .arg(cwd)
578        .args(args)
579        .stdin(Stdio::null())
580        .stdout(Stdio::piped())
581        .stderr(Stdio::null());
582    crate::apply_git_command_env(&mut cmd);
583    let out = crate::bounded_output(&mut cmd).ok()?;
584    if !out.status.success() {
585        return None;
586    }
587    Some(
588        String::from_utf8_lossy(&out.stdout)
589            .trim_end_matches(['\r', '\n'])
590            .to_string(),
591    )
592}
593
594fn has_subst(s: &str) -> bool {
595    if s.contains('`') {
596        return true;
597    }
598    let bytes = s.as_bytes();
599    bytes.iter().enumerate().any(|(i, c)| {
600        if *c != b'$' {
601            return false;
602        }
603        matches!(
604            bytes.get(i + 1).copied().unwrap_or(0),
605            b'(' | b'{' | b'_' | b'a'..=b'z' | b'A'..=b'Z'
606        )
607    })
608}
609
610pub(crate) fn is_dry(args: &[String]) -> bool {
611    args.iter().any(|a| {
612        a == "--dry-run"
613            || a == "-n"
614            || (a.starts_with('-') && !a.starts_with("--") && a.chars().skip(1).any(|c| c == 'n'))
615    })
616}
617
618fn positionals(args: &[String]) -> Vec<String> {
619    let mut out = Vec::new();
620    let mut i = 0;
621    while i < args.len() {
622        let a = &args[i];
623        if a == "--" {
624            out.extend(args[i + 1..].iter().cloned());
625            break;
626        }
627        if matches!(
628            a.as_str(),
629            "-o" | "--push-option" | "--receive-pack" | "--exec" | "--repo"
630        ) {
631            i += 2;
632            continue;
633        }
634        if a.starts_with('-') {
635            i += 1;
636            continue;
637        }
638        out.push(a.clone());
639        i += 1;
640    }
641    out
642}
643
644fn invocations(line: &str, base: &Path, depth: u8) -> Vec<Inv> {
645    let mut cwd = base.to_path_buf();
646    let mut out = Vec::new();
647    for sub in super::nested_lines(line, 0) {
648        for seg in super::raw_segments(&sub) {
649            let words = super::shell_words(&seg);
650            if words.is_empty() {
651                continue;
652            }
653            let (at, _) = super::prefix_end(&words);
654            if at >= words.len() {
655                continue;
656            }
657            let cmd = words[at..].to_vec();
658            if cmd.is_empty() {
659                continue;
660            }
661            if super::base_name(&cmd[0]) == "cd" {
662                if let Some(d) = cmd.get(1) {
663                    if d != "-" && !d.starts_with('-') {
664                        cwd = join_dir(&cwd, d);
665                    }
666                }
667                continue;
668            }
669            out.push(Inv {
670                cwd: cwd.clone(),
671                words: cmd,
672                depth,
673            });
674        }
675    }
676    out
677}
678
679fn join_dir(base: &Path, dir: &str) -> PathBuf {
680    let path = Path::new(dir);
681    if path.is_absolute() {
682        path.to_path_buf()
683    } else {
684        base.join(path)
685    }
686}
687
688fn parse_git(inv: &Inv) -> Option<GitCmd> {
689    if super::base_name(&inv.words[0]) != "git" {
690        return None;
691    }
692    let mut i = 1;
693    let mut cwd = inv.cwd.clone();
694    let mut config = Vec::new();
695    while i < inv.words.len() {
696        let w = inv.words[i].as_str();
697        if w == "--" {
698            i += 1;
699            break;
700        }
701        if w == "-C" {
702            if let Some(d) = inv.words.get(i + 1) {
703                cwd = join_dir(&cwd, d);
704            }
705            i += 2;
706            continue;
707        }
708        if w == "-c" {
709            if let Some((k, v)) = inv.words.get(i + 1).and_then(|kv| kv.split_once('=')) {
710                config.push((k.to_string(), v.to_string()));
711            }
712            i += 2;
713            continue;
714        }
715        if let Some((k, v)) = w
716            .strip_prefix("-c")
717            .filter(|s| !s.is_empty() && s.contains('='))
718            .and_then(|kv| kv.split_once('='))
719        {
720            config.push((k.to_string(), v.to_string()));
721            i += 1;
722            continue;
723        }
724        if matches!(
725            w,
726            "--git-dir"
727                | "--work-tree"
728                | "--namespace"
729                | "--config-env"
730                | "--exec-path"
731                | "--super-prefix"
732        ) {
733            i += 2;
734            continue;
735        }
736        if w.starts_with("--git-dir=")
737            || w.starts_with("--work-tree=")
738            || w.starts_with("--namespace=")
739            || w.starts_with("--config-env=")
740            || w.starts_with("--exec-path=")
741            || w.starts_with("--super-prefix=")
742        {
743            i += 1;
744            continue;
745        }
746        if w.starts_with('-') {
747            i += 1;
748            continue;
749        }
750        break;
751    }
752    let sub = inv.words.get(i)?.clone();
753    let args = inv.words.get(i + 1..).unwrap_or(&[]).to_vec();
754    Some(GitCmd {
755        cwd,
756        config,
757        sub,
758        args,
759        shell: None,
760    })
761}
762
763fn git_of(inv: &Inv) -> Option<GitCmd> {
764    let mut git = parse_git(inv)?;
765    let mut seen = std::collections::HashSet::new();
766    while let Some(value) = alias_of(&git) {
767        if !seen.insert(git.sub.clone()) {
768            break;
769        }
770        if let Some(shell) = value.strip_prefix('!') {
771            git.shell = Some(shell.trim().to_string());
772            break;
773        }
774        let mut parts: Vec<String> = value.split_whitespace().map(str::to_string).collect();
775        if parts.is_empty() {
776            break;
777        }
778        git.sub = parts.remove(0);
779        parts.append(&mut git.args);
780        git.args = parts;
781    }
782    Some(git)
783}
784
785fn alias_of(git: &GitCmd) -> Option<String> {
786    if matches!(
787        git.sub.as_str(),
788        "commit"
789            | "push"
790            | "merge"
791            | "checkout"
792            | "switch"
793            | "status"
794            | "diff"
795            | "add"
796            | "log"
797            | "show"
798            | "fetch"
799            | "pull"
800            | "rebase"
801            | "stash"
802            | "branch"
803            | "tag"
804            | "remote"
805            | "init"
806            | "clone"
807            | "restore"
808            | "reset"
809            | "clean"
810            | "rev-parse"
811            | "config"
812            | "help"
813            | "version"
814            | "blame"
815            | "grep"
816            | "mv"
817            | "rm"
818            | "apply"
819            | "cherry-pick"
820            | "revert"
821            | "bisect"
822            | "reflog"
823    ) {
824        return None;
825    }
826    let key = format!("alias.{}", git.sub);
827    if let Some((_, value)) = git.config.iter().rev().find(|(k, _)| k == &key) {
828        return Some(value.clone());
829    }
830    git_config(&git.cwd, &key)
831}
832
833#[cfg(test)]
834mod tests {
835    use super::*;
836
837    const AUTHOR_NAME: &str = "Ada Lovelace";
838    const AUTHOR_EMAIL: &str = "ada@example.com";
839
840    fn git(dir: &Path, args: &[&str]) {
841        let output = crate::git_cmd()
842            .arg("-C")
843            .arg(dir)
844            .args(args)
845            .env("GIT_AUTHOR_NAME", AUTHOR_NAME)
846            .env("GIT_AUTHOR_EMAIL", AUTHOR_EMAIL)
847            .env("GIT_COMMITTER_NAME", AUTHOR_NAME)
848            .env("GIT_COMMITTER_EMAIL", AUTHOR_EMAIL)
849            .output()
850            .unwrap();
851        assert!(
852            output.status.success(),
853            "git {args:?}: {}",
854            String::from_utf8_lossy(&output.stderr)
855        );
856    }
857
858    /// The checkout, and the environment lock. Gate tests read the process
859    /// home: a temp home suspends the guard. Another test sets that home
860    /// for its own case, so this lock is held until the checkout is dropped.
861    struct Held {
862        _env: crate::EnvLock,
863        dir: tempfile::TempDir,
864    }
865
866    impl std::ops::Deref for Held {
867        type Target = tempfile::TempDir;
868
869        fn deref(&self) -> &Self::Target {
870            &self.dir
871        }
872    }
873
874    fn repo() -> Held {
875        let env = crate::test_env_lock();
876        let dir = tempfile::tempdir().unwrap();
877        git(dir.path(), &["init", "-q", "-b", "main"]);
878        git(dir.path(), &["config", "user.name", AUTHOR_NAME]);
879        git(dir.path(), &["config", "user.email", AUTHOR_EMAIL]);
880        git(dir.path(), &["config", "commit.gpgsign", "false"]);
881        git(
882            dir.path(),
883            &[
884                "remote",
885                "add",
886                "origin",
887                "https://example.com/acme/widgets.git",
888            ],
889        );
890        Held { _env: env, dir }
891    }
892
893    fn seed(dir: &Path) {
894        std::fs::write(dir.join("f.txt"), "one\n").unwrap();
895        git(dir, &["add", "f.txt"]);
896        git(dir, &["commit", "-q", "-m", "note the seat"]);
897    }
898
899    fn gates(dir: &Path, body: &str) {
900        std::fs::create_dir_all(dir.join(".ljos")).unwrap();
901        std::fs::write(dir.join(".ljos").join("gates.toml"), body).unwrap();
902    }
903
904    #[test]
905    fn git_calls_follow_dirs_shells_and_aliases() {
906        let dir = repo();
907        let root = dir.path();
908        std::fs::create_dir_all(root.join("sub")).unwrap();
909        git(root, &["config", "alias.ci", "commit -q"]);
910        git(
911            root,
912            &["config", "alias.ship", "!git add . && git push origin HEAD"],
913        );
914        let calls = git_calls(
915            "cd sub && git -C .. ci -m x; sh -c 'git ship'; echo $(git cherry-pick abc)",
916            Some(root),
917        )
918        .unwrap();
919        let subs: Vec<&str> = calls.iter().map(|c| c.sub.as_str()).collect();
920        assert_eq!(subs, ["commit", "add", "push", "cherry-pick"], "{calls:?}");
921        assert_eq!(calls[0].args, ["-q", "-m", "x"]);
922        assert_eq!(calls[0].cwd, root.join("sub").join(".."));
923        assert_eq!(calls[2].args, ["origin", "HEAD"]);
924        assert!(git_calls("ls -la", Some(root)).unwrap().is_empty());
925
926        for (name, next) in [("a", "b"), ("b", "c"), ("c", "d"), ("d", "e")] {
927            git(
928                root,
929                &["config", &format!("alias.{name}"), &format!("!git {next}")],
930            );
931        }
932        let deep = git_calls("git a", Some(root)).unwrap_err();
933        assert!(deep.contains("too deep"), "{deep}");
934    }
935
936    struct Layers;
937
938    impl Drop for Layers {
939        fn drop(&mut self) {
940            LAYER_HOME.with(|slot| *slot.borrow_mut() = None);
941            LAYER_SEAT.with(|slot| *slot.borrow_mut() = None);
942        }
943    }
944
945    fn layers(home: &Path, seat: Option<&str>) -> Layers {
946        LAYER_HOME.with(|slot| *slot.borrow_mut() = Some(home.to_path_buf()));
947        LAYER_SEAT.with(|slot| *slot.borrow_mut() = seat.map(str::to_string));
948        Layers
949    }
950
951    fn write_toml(path: &Path, body: &str) {
952        if let Some(parent) = path.parent() {
953            std::fs::create_dir_all(parent).unwrap();
954        }
955        std::fs::write(path, body).unwrap();
956    }
957
958    #[test]
959    fn the_person_sets_the_consensus_push_and_a_checkout_only_tightens_it() {
960        let dir = repo();
961        let cwd = dir.path();
962        let home = tempfile::tempdir().unwrap();
963        let _layers = layers(home.path(), Some("box"));
964        let read = || consensus_push(Some(cwd)).unwrap();
965        let names = |all: &[&str]| all.iter().map(|b| (*b).to_string()).collect::<Vec<_>>();
966        assert_eq!(read().quorum, 2);
967        assert_eq!(read().branches, names(&["develop", "main"]));
968        write_toml(
969            &home.path().join("ljos/ljos.toml"),
970            "[gates]\nquorum = 3\nconsensus_branches = [\"main\", \"release\"]\n",
971        );
972        assert_eq!(read().quorum, 3);
973        assert_eq!(read().branches, names(&["main", "release"]));
974        write_toml(
975            &home.path().join("ljos/seats/box/ljos.toml"),
976            "quorum = 1\n",
977        );
978        assert_eq!(read().quorum, 1, "the seat file is the person's too");
979        write_toml(
980            &cwd.join("ljos.toml"),
981            "quorum = 1\nconsensus_branches = []\n",
982        );
983        gates(
984            cwd,
985            "quorum = 2\nconsensus_branches = [\"refs/heads/stable\"]\n",
986        );
987        assert_eq!(read().quorum, 2, "a checkout raises the quorum");
988        assert_eq!(read().branches, names(&["main", "release", "stable"]));
989        write_toml(&cwd.join("ljos.toml"), "quorum = 0\n");
990        let zero = consensus_push(Some(cwd)).unwrap_err();
991        assert!(zero.contains("`quorum`"), "{zero}");
992        write_toml(&cwd.join("ljos.toml"), "quorum = [\n");
993        let broken = consensus_push(Some(cwd)).unwrap_err();
994        assert!(broken.contains("does not parse"), "{broken}");
995    }
996
997    #[test]
998    fn a_tight_protected_branch_list_keeps_each_name() {
999        let dir = repo();
1000        let home = tempfile::tempdir().unwrap();
1001        let _layers = layers(home.path(), None);
1002        let cwd = dir.path();
1003        let want = ["main", "master", "develop", "feat", "y"]
1004            .map(str::to_string)
1005            .to_vec();
1006        for body in [
1007            "protected_branches = [\"feat\",\"y\"]\n",
1008            "protected_branches = [\"feat\", \"y\"]\n",
1009            "protected_branches = [ \"feat\" , \"y\" ]\n",
1010            "protected_branches = ['feat','y']\n",
1011            "protected_branches = [\"feat\",\"y\",]\n",
1012            "protected_branches = [\"feat\",\"y\"]\r\n",
1013        ] {
1014            gates(cwd, body);
1015            assert_eq!(lease_protected(Some(cwd)).as_ref(), Some(&want), "{body:?}");
1016        }
1017        gates(cwd, "protected_branches = \"release\"\n");
1018        assert!(
1019            lease_protected(Some(cwd)).is_none(),
1020            "a value that is not a list asks"
1021        );
1022    }
1023
1024    #[test]
1025    fn a_push_lands_where_git_would_put_it_and_an_unread_one_is_protected() {
1026        let dir = repo();
1027        let cwd = dir.path();
1028        seed(cwd);
1029        git(cwd, &["checkout", "-q", "-b", "topic"]);
1030        let branches = ["develop".to_string(), "main".to_string()];
1031        let lands = |line: &str| {
1032            let calls = git_calls(line, Some(cwd)).unwrap();
1033            let push = calls.iter().find(|c| c.sub == "push").unwrap();
1034            protected_push(push, line, &branches)
1035        };
1036        let unread = |line: &str| {
1037            lands(line).is_some_and(|d| d.starts_with("a branch the guard cannot tell"))
1038        };
1039        for line in [
1040            "git push",
1041            "git push origin",
1042            "git push origin topic",
1043            "git push -u origin HEAD",
1044            "git push origin topic:refs/heads/topic",
1045            "git push origin v1:refs/tags/develop",
1046            "git push origin 'refs/tags/*:refs/tags/*'",
1047            "git push develop topic",
1048        ] {
1049            assert_eq!(lands(line), None, "{line}");
1050        }
1051        for (line, dest) in [
1052            ("git push origin HEAD:develop", "`develop`"),
1053            ("git push origin topic:refs/heads/develop", "`develop`"),
1054            ("git push origin +topic:heads/develop", "`develop`"),
1055            ("git push origin :develop", "`develop`"),
1056            ("git push --delete origin develop", "`develop`"),
1057            ("git push origin topic HEAD:Main", "`main`"),
1058            (
1059                "git push https://example.com/acme/widgets.git main",
1060                "`main`",
1061            ),
1062            ("git push --all origin", "every branch `--all` reaches"),
1063            (
1064                "git push origin 'refs/heads/*:refs/heads/*'",
1065                "the branches `refs/heads/*:refs/heads/*` matches",
1066            ),
1067        ] {
1068            assert_eq!(lands(line).as_deref(), Some(dest), "{line}");
1069        }
1070        for line in [
1071            "git checkout develop && git push",
1072            "GIT_DIR=../other git push origin topic",
1073            "git -c remote.origin.push=refs/heads/topic:refs/heads/develop push origin topic",
1074            "git push origin $BRANCH",
1075            "git push --repo=origin topic",
1076            "git push origin topic:HEAD",
1077        ] {
1078            assert!(unread(line), "{line}: {:?}", lands(line));
1079        }
1080
1081        git(cwd, &["config", "branch.topic.merge", "refs/heads/develop"]);
1082        assert_eq!(lands("git push").as_deref(), Some("`develop`"));
1083        assert_eq!(lands("git push origin HEAD").as_deref(), Some("`develop`"));
1084        assert_eq!(
1085            lands("git push origin topic"),
1086            None,
1087            "simple names the branch"
1088        );
1089        git(cwd, &["config", "push.default", "upstream"]);
1090        assert_eq!(lands("git push origin topic").as_deref(), Some("`develop`"));
1091        git(cwd, &["config", "push.default", "current"]);
1092        assert_eq!(lands("git push"), None);
1093        git(cwd, &["config", "push.default", "nothing"]);
1094        assert_eq!(lands("git push"), None);
1095        git(cwd, &["config", "push.default", "matching"]);
1096        assert!(lands("git push")
1097            .unwrap()
1098            .starts_with("every branch both sides have"));
1099        git(cwd, &["config", "--unset", "push.default"]);
1100        git(
1101            cwd,
1102            &[
1103                "config",
1104                "remote.origin.push",
1105                "refs/heads/topic:refs/heads/develop",
1106            ],
1107        );
1108        assert!(unread("git push origin topic"));
1109        git(cwd, &["config", "--unset", "remote.origin.push"]);
1110        git(cwd, &["checkout", "-q", "--detach"]);
1111        assert!(unread("git push origin HEAD"));
1112        assert_eq!(lands("git push origin HEAD:refs/heads/topic"), None);
1113    }
1114}