Skip to main content

openmoat_core/shell/
push.rs

1//! Commands that send local commits to a remote (`git push`, `gh pr create`)
2//! and the directories they run in. `moat guard` looks at what such a command
3//! would publish (docs/POLICY.md §4.2); finding the commands is the
4//! classifier's work, so every spelling it reads (`git -C dir push`,
5//! `cd sub && git push`, `sh -c 'git push'`) is found the same way.
6
7use super::{ShellContext, Sink, commands, git};
8use crate::action::AtomicAction;
9use crate::paths;
10
11/// At most this many pushes are listed for one command line; one more is
12/// listed with an unknown directory, so the caller checks what it can and
13/// asks for the rest.
14const MAX_PUSHES: usize = 16;
15
16/// Environment variables that make git use another repository or other refs
17/// than the one the working directory names.
18const MOVES_REPOSITORY: &[&str] = &[
19    "GIT_DIR",
20    "GIT_WORK_TREE",
21    "GIT_COMMON_DIR",
22    "GIT_NAMESPACE",
23    "GIT_OBJECT_DIRECTORY",
24    "GIT_ALTERNATE_OBJECT_DIRECTORIES",
25    "GIT_CEILING_DIRECTORIES",
26];
27
28/// A command that publishes local commits.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Push {
31    /// The command without git's global options: `git push …` or `gh pr create …`.
32    pub argv: Vec<String>,
33    /// Every directory the command may run in, absolute; `None` for one that
34    /// is not known (`cd "$X"`, `git --git-dir=…`, a `GIT_DIR=` assignment).
35    pub dirs: Vec<Option<String>>,
36}
37
38/// Note `argv` in `sink` when it is a push. `program` is its base name.
39pub(super) fn note(argv: &[String], program: &str, ctx: &ShellContext<'_>, sink: &mut Sink) {
40    let push = match program {
41        "git" => {
42            let git = git::parse(argv);
43            if git.subcommand.get(1).map(String::as_str) != Some("push") {
44                return;
45            }
46            let moved = git.keep_original || sets_repository(sink);
47            let dirs = ctx
48                .cwd
49                .iter()
50                .map(|cwd| {
51                    if moved {
52                        return None;
53                    }
54                    git.chdirs.iter().try_fold(cwd.clone()?, |dir, chdir| {
55                        paths::resolve(chdir, ctx.home, super::WORD_PROJECT, Some(&dir))
56                    })
57                })
58                .collect();
59            Push {
60                argv: git.subcommand,
61                dirs,
62            }
63        }
64        "gh" if argv.get(1..3).is_some_and(|s| s == ["pr", "create"]) => Push {
65            argv: argv.to_vec(),
66            dirs: if sets_repository(sink) {
67                vec![None]
68            } else {
69                ctx.cwd.to_vec()
70            },
71        },
72        _ => return,
73    };
74    if sink.pushes.len() > MAX_PUSHES {
75        return;
76    }
77    if sink.pushes.len() == MAX_PUSHES {
78        sink.pushes.push(Push {
79            argv: push.argv,
80            dirs: vec![None],
81        });
82    } else if !sink.pushes.contains(&push) {
83        sink.pushes.push(push);
84    }
85}
86
87/// Whether the line set, before this point, a variable that moves git to
88/// another repository.
89fn sets_repository(sink: &Sink) -> bool {
90    sink.atoms.iter().any(|atom| {
91        matches!(atom, AtomicAction::EnvSet { name } if MOVES_REPOSITORY.contains(&name.as_str()))
92    })
93}
94
95/// The pushes in `command`, read as the classifier reads it. A part the
96/// classifier stops at hides no push it would have found before.
97#[must_use]
98pub fn pushes(command: &str, ctx: &ShellContext<'_>) -> Vec<Push> {
99    let mut sink = Sink::default();
100    let _ = commands::classify_into(command, ctx, &mut sink, 0);
101    let split = command.replace("${IFS}", " ").replace("$IFS", " ");
102    if split != command {
103        let _ = commands::classify_into(&split, ctx, &mut sink, 0);
104    }
105    sink.pushes
106}
107
108#[cfg(test)]
109mod tests {
110    use super::{Push, pushes};
111    use crate::shell::ShellContext;
112
113    fn found(command: &str) -> Vec<Push> {
114        let cwd = [Some("/w/repo".to_owned())];
115        let ctx = ShellContext {
116            home: "/h",
117            cwd: &cwd,
118        };
119        pushes(command, &ctx)
120    }
121
122    fn dirs(command: &str) -> Vec<Vec<Option<String>>> {
123        found(command).into_iter().map(|p| p.dirs).collect()
124    }
125
126    fn at(dir: &str) -> Vec<Vec<Option<String>>> {
127        vec![vec![Some(dir.to_owned())]]
128    }
129
130    #[test]
131    fn every_spelling_of_a_push_is_found_with_its_directory() {
132        for cmd in [
133            "git push",
134            "git push origin main",
135            "/usr/bin/git push",
136            "git --no-pager push -u origin HEAD",
137            "sudo git push",
138            "sh -c 'git push'",
139            "echo $(git push)",
140            "git status && git push",
141            "gh pr create --fill",
142        ] {
143            assert_eq!(dirs(cmd), at("/w/repo"), "{cmd}");
144        }
145        assert_eq!(dirs("git -C sub push"), at("/w/repo/sub"));
146        assert_eq!(dirs("git -C /x -C y push"), at("/x/y"));
147        // `cd` may fail, so the command may still run where it started.
148        let both = |dir: &str| vec![vec![Some("/w/repo".to_owned()), Some(dir.to_owned())]];
149        assert_eq!(dirs("cd /other && git push"), both("/other"));
150        assert_eq!(dirs("cd ~/r; git push"), both("/h/r"));
151        assert_eq!(found("git -C sub push")[0].argv, ["git", "push"]);
152    }
153
154    #[test]
155    fn a_directory_that_cannot_be_known_is_none() {
156        assert_eq!(
157            dirs("cd \"$X\" && git push"),
158            vec![vec![Some("/w/repo".to_owned()), None]]
159        );
160        for cmd in [
161            "git --git-dir=/elsewhere/.git push",
162            "GIT_DIR=/elsewhere/.git git push",
163            "export GIT_WORK_TREE=/x; git push",
164            "GIT_DIR=/e gh pr create",
165        ] {
166            assert_eq!(dirs(cmd), vec![vec![None]], "{cmd}");
167        }
168    }
169
170    #[test]
171    fn other_commands_are_not_pushes() {
172        for cmd in [
173            "git status",
174            "git log --grep push",
175            "echo git push",
176            "gh pr view",
177            "gh pr merge 3",
178            "git stash push",
179        ] {
180            assert!(found(cmd).is_empty(), "{cmd}");
181        }
182    }
183
184    #[test]
185    fn many_pushes_end_in_one_with_an_unknown_directory() {
186        let line = (0..40)
187            .map(|i| format!("git -C d{i} push"))
188            .collect::<Vec<_>>()
189            .join("; ");
190        let all = found(&line);
191        assert_eq!(all.len(), 17);
192        assert_eq!(all[16].dirs, vec![None]);
193    }
194}