1use crate::git::Git;
6use std::collections::HashSet;
7use std::path::Path;
8
9pub fn current_branch(git: &dyn Git, dir: &Path) -> String {
11 git.run(dir, &["rev-parse", "--abbrev-ref", "HEAD"])
12 .trimmed()
13 .to_string()
14}
15
16pub fn committed(git: &dyn Git, dir: &Path) -> bool {
18 git.run(dir, &["status", "-s"]).trimmed().is_empty()
19}
20
21pub fn all_commits_pushed(git: &dyn Git, dir: &Path) -> bool {
24 git.run(
25 dir,
26 &["log", "--oneline", "--branches", "--not", "--remotes"],
27 )
28 .trimmed()
29 .is_empty()
30}
31
32pub fn branches_have_remote(git: &dyn Git, dir: &Path) -> bool {
34 let remotes: HashSet<String> = git
35 .run(
36 dir,
37 &[
38 "for-each-ref",
39 "--format=%(refname:short)",
40 "refs/remotes/origin/*",
41 ],
42 )
43 .stdout
44 .lines()
45 .filter_map(|l| l.trim().strip_prefix("origin/").map(str::to_string))
46 .filter(|b| b != "HEAD")
47 .collect();
48
49 git.run(
50 dir,
51 &["for-each-ref", "--format=%(refname:short)", "refs/heads/*"],
52 )
53 .stdout
54 .lines()
55 .map(str::trim)
56 .filter(|l| !l.is_empty())
57 .all(|local| remotes.contains(local))
58}
59
60pub fn not_behind_remote(git: &dyn Git, dir: &Path) -> bool {
63 let cur = current_branch(git, dir);
64 if cur.is_empty() {
65 return true;
66 }
67 let remote_ref = format!("refs/remotes/origin/{cur}");
68 if !git.run(dir, &["show-ref", "--quiet", &remote_ref]).success {
69 return true;
70 }
71 let range = format!("origin/{cur}...{cur}");
72 let out = git.run(dir, &["rev-list", "--left-right", "--count", &range]);
73 out.trimmed()
75 .split_whitespace()
76 .next()
77 .and_then(|s| s.parse::<u64>().ok())
78 .map(|behind| behind == 0)
79 .unwrap_or(true)
80}
81
82fn is_integration(branch: &str, base_branch: &str) -> bool {
85 branch == base_branch || branch == "main" || branch == "master"
86}
87
88pub fn correct_branch(git: &dyn Git, dir: &Path, base_branch: &str) -> bool {
92 let cur = current_branch(git, dir);
93 if !is_integration(&cur, base_branch) {
94 return true; }
96 let has_feature = git
97 .run(dir, &["ls-remote", "--heads", "origin"])
98 .stdout
99 .lines()
100 .filter_map(|l| {
101 l.split_once("refs/heads/")
102 .map(|(_, b)| b.trim().to_string())
103 })
104 .any(|b| !is_integration(&b, base_branch));
105 !has_feature
106}
107
108#[derive(Debug, Clone)]
110pub struct RepoStatus {
111 pub branch: String,
112 pub committed: bool,
113 pub all_commits_pushed: bool,
114 pub branches_have_remote: bool,
115 pub not_behind_remote: bool,
116 pub correct_branch: bool,
117 pub problem: Option<String>,
122}
123
124impl RepoStatus {
125 pub fn unusable(reason: impl Into<String>) -> Self {
128 RepoStatus {
129 branch: String::new(),
130 committed: false,
131 all_commits_pushed: false,
132 branches_have_remote: false,
133 not_behind_remote: false,
134 correct_branch: false,
135 problem: Some(reason.into()),
136 }
137 }
138
139 pub fn ok(&self) -> bool {
141 self.problem.is_none()
142 && self.committed
143 && self.all_commits_pushed
144 && self.branches_have_remote
145 && self.not_behind_remote
146 && self.correct_branch
147 }
148}
149
150pub fn evaluate(git: &dyn Git, dir: &Path, base_branch: &str) -> RepoStatus {
152 RepoStatus {
153 branch: current_branch(git, dir),
154 committed: committed(git, dir),
155 all_commits_pushed: all_commits_pushed(git, dir),
156 branches_have_remote: branches_have_remote(git, dir),
157 not_behind_remote: not_behind_remote(git, dir),
158 correct_branch: correct_branch(git, dir, base_branch),
159 problem: None,
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use crate::git::test_support::FakeGit;
167 use std::path::Path;
168
169 fn d() -> &'static Path {
170 Path::new("/x")
171 }
172
173 #[test]
174 fn committed_is_true_when_status_clean() {
175 assert!(committed(&FakeGit::new().ok("status -s", ""), d()));
176 assert!(!committed(
177 &FakeGit::new().ok("status -s", " M file.rs"),
178 d()
179 ));
180 }
181
182 #[test]
183 fn pushed_is_true_when_no_unpushed_commits() {
184 let clean = FakeGit::new().ok("log --oneline --branches --not --remotes", "");
185 assert!(all_commits_pushed(&clean, d()));
186 let dirty = FakeGit::new().ok("log --oneline --branches --not --remotes", "abc123 wip");
187 assert!(!all_commits_pushed(&dirty, d()));
188 }
189
190 #[test]
191 fn branches_have_remote_checks_every_local() {
192 let ok = FakeGit::new()
193 .ok(
194 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
195 "origin/dev\norigin/main\norigin/HEAD",
196 )
197 .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev");
198 assert!(branches_have_remote(&ok, d()));
199
200 let missing = FakeGit::new()
201 .ok(
202 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
203 "origin/dev",
204 )
205 .ok(
206 "for-each-ref --format=%(refname:short) refs/heads/*",
207 "dev\nlocal-only",
208 );
209 assert!(!branches_have_remote(&missing, d()));
210 }
211
212 #[test]
213 fn not_behind_true_when_no_remote_branch() {
214 let g = FakeGit::new()
215 .ok("rev-parse --abbrev-ref HEAD", "dev")
216 .fail("show-ref --quiet refs/remotes/origin/dev");
217 assert!(not_behind_remote(&g, d()));
218 }
219
220 #[test]
221 fn not_behind_reflects_left_count() {
222 let aligned = FakeGit::new()
223 .ok("rev-parse --abbrev-ref HEAD", "dev")
224 .ok("show-ref --quiet refs/remotes/origin/dev", "")
225 .ok("rev-list --left-right --count origin/dev...dev", "0\t3");
226 assert!(not_behind_remote(&aligned, d()));
227
228 let behind = FakeGit::new()
229 .ok("rev-parse --abbrev-ref HEAD", "dev")
230 .ok("show-ref --quiet refs/remotes/origin/dev", "")
231 .ok("rev-list --left-right --count origin/dev...dev", "2\t0");
232 assert!(!not_behind_remote(&behind, d()));
233 }
234
235 #[test]
236 fn correct_branch_only_flags_base_with_features() {
237 let on_base_with_feature = FakeGit::new().ok("rev-parse --abbrev-ref HEAD", "dev").ok(
239 "ls-remote --heads origin",
240 "aaa\trefs/heads/dev\nbbb\trefs/heads/feature-x",
241 );
242 assert!(!correct_branch(&on_base_with_feature, d(), "dev"));
243
244 let on_base_no_feature = FakeGit::new()
246 .ok("rev-parse --abbrev-ref HEAD", "dev")
247 .ok("ls-remote --heads origin", "aaa\trefs/heads/dev");
248 assert!(correct_branch(&on_base_no_feature, d(), "dev"));
249
250 let on_feature = FakeGit::new().ok("rev-parse --abbrev-ref HEAD", "feature-x");
252 assert!(correct_branch(&on_feature, d(), "dev"));
253
254 let dev_plus_main = FakeGit::new().ok("rev-parse --abbrev-ref HEAD", "dev").ok(
257 "ls-remote --heads origin",
258 "aaa\trefs/heads/dev\nbbb\trefs/heads/main",
259 );
260 assert!(correct_branch(&dev_plus_main, d(), "dev"));
261
262 let on_main_with_feature = FakeGit::new().ok("rev-parse --abbrev-ref HEAD", "main").ok(
264 "ls-remote --heads origin",
265 "aaa\trefs/heads/main\nbbb\trefs/heads/feature-y",
266 );
267 assert!(!correct_branch(&on_main_with_feature, d(), "dev"));
268 }
269
270 #[test]
271 fn evaluate_all_clear() {
272 let g = FakeGit::new()
273 .ok("rev-parse --abbrev-ref HEAD", "dev")
274 .ok("status -s", "")
275 .ok("log --oneline --branches --not --remotes", "")
276 .ok(
277 "for-each-ref --format=%(refname:short) refs/remotes/origin/*",
278 "origin/dev",
279 )
280 .ok("for-each-ref --format=%(refname:short) refs/heads/*", "dev")
281 .ok("show-ref --quiet refs/remotes/origin/dev", "")
282 .ok("rev-list --left-right --count origin/dev...dev", "0\t0")
283 .ok("ls-remote --heads origin", "aaa\trefs/heads/dev");
284 let st = evaluate(&g, d(), "dev");
285 assert!(st.ok(), "expected all-clear, got {st:?}");
286 assert_eq!(st.branch, "dev");
287 }
288}