openmoat_core/shell/
push.rs1use super::{ShellContext, Sink, commands, git};
8use crate::action::AtomicAction;
9use crate::paths;
10
11const MAX_PUSHES: usize = 16;
15
16const 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#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct Push {
31 pub argv: Vec<String>,
33 pub dirs: Vec<Option<String>>,
36}
37
38pub(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
87fn 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#[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 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}