1use std::path::Path;
8
9#[must_use]
12pub fn tracker_sync_line() -> Option<String> {
13 let layout = vissue_core::Layout::resolve(None, None).ok()?;
14 let root = layout.root();
15 let unpushed = unpushed_count(root).unwrap_or(0);
16 let edits = org_edits(root);
17 if unpushed == 0 && edits.count == 0 {
18 return None;
19 }
20 let phrase = counts_phrase(unpushed, edits.count);
21 let fix = fix_command(root, unpushed, &edits);
22 Some(format!("tracker: {phrase}; fix: {fix}\n"))
23}
24
25#[must_use]
30pub fn push_leftover() -> String {
31 if !allows_push() {
32 return String::new();
33 }
34 let Ok(layout) = vissue_core::Layout::resolve(None, None) else {
35 return String::new();
36 };
37 let root = layout.root().to_path_buf();
38 if crate::tracker_push_logs().0 || !has_remote(&root) {
39 return String::new();
40 }
41 let Some(waiting) = unpushed_count(&root).filter(|n| *n > 0) else {
42 return String::new();
43 };
44 let Some(destination) = safe_push_destination(&root) else {
47 return String::new();
48 };
49 match crate::tracker_push::publish_explicit(&root, &destination.remote, &destination.branch) {
50 Ok(()) => {
51 crate::clear_push_refusal();
52 format!("tracker git: pushed {waiting}\n")
53 }
54 Err(why) => {
55 crate::record_push_refusal(&why);
56 format!("tracker git: push refused: {why}\n")
57 }
58 }
59}
60
61fn allows_push() -> bool {
62 let mode = std::env::var("LJOS_TRACKER_GIT").unwrap_or_default();
63 !matches!(mode.as_str(), "off" | "0" | "false" | "commit")
64}
65
66fn git_leaves_edits() -> bool {
67 matches!(
68 std::env::var("LJOS_TRACKER_GIT")
69 .unwrap_or_default()
70 .as_str(),
71 "off" | "0" | "false"
72 )
73}
74
75fn unpushed_count(root: &Path) -> Option<u64> {
76 let up = crate::tracker_upstream(root)?;
77 let range = format!("{up}..HEAD");
78 crate::git_ok_stdout(root, &["rev-list", "--count", &range])?
79 .trim()
80 .parse()
81 .ok()
82}
83
84fn has_remote(root: &Path) -> bool {
85 crate::git_ok_stdout(root, &["remote"])
86 .is_some_and(|text| text.lines().any(|line| !line.trim().is_empty()))
87}
88
89pub(crate) struct PushDestination {
90 pub(crate) remote: String,
91 pub(crate) branch: String,
92}
93
94pub(crate) fn safe_push_destination(root: &Path) -> Option<PushDestination> {
97 let push = crate::git_ok_stdout(root, &["rev-parse", "--symbolic-full-name", "@{push}"])?;
98 let push = push.trim();
99 let default = remote_default_branch(root, "origin")?;
100 let expected = format!("refs/remotes/origin/{default}");
101 if push != expected {
102 return None;
103 }
104 Some(PushDestination {
105 remote: "origin".to_string(),
106 branch: default,
107 })
108}
109
110fn remote_default_branch(root: &Path, remote: &str) -> Option<String> {
111 let remote_head = format!("refs/remotes/{remote}/HEAD");
112 if let Some(target) = crate::git_ok_stdout(root, &["symbolic-ref", "--quiet", &remote_head]) {
113 let prefix = format!("refs/remotes/{remote}/");
114 if let Some(branch) = target.trim().strip_prefix(&prefix) {
115 if !branch.is_empty() {
116 return Some(branch.to_string());
117 }
118 }
119 }
120 let out = crate::git_ok_stdout(root, &["ls-remote", "--symref", remote, "HEAD"])?;
121 out.lines().find_map(|line| {
122 let target = line.strip_prefix("ref: refs/heads/")?;
123 let (branch, name) = target.split_once('\t')?;
124 (name == "HEAD" && !branch.is_empty()).then(|| branch.to_string())
125 })
126}
127
128struct OrgEdits {
129 count: usize,
130 untracked: Vec<String>,
131}
132
133fn org_edits(root: &Path) -> OrgEdits {
134 if git_leaves_edits() {
135 return OrgEdits {
136 count: 0,
137 untracked: Vec::new(),
138 };
139 }
140 let Some(status) = crate::git_ok_stdout(
141 root,
142 &["status", "--porcelain", "--untracked-files=all", "--", "."],
143 ) else {
144 return OrgEdits {
145 count: 0,
146 untracked: Vec::new(),
147 };
148 };
149 let mut edits = OrgEdits {
150 count: 0,
151 untracked: Vec::new(),
152 };
153 for line in status.lines() {
154 let Some(path) = line.get(3..) else {
155 continue;
156 };
157 let path = path.rsplit(" -> ").next().unwrap_or(path).trim_matches('"');
158 if !path.ends_with(".org") {
159 continue;
160 }
161 edits.count += 1;
162 if line.starts_with("??") {
163 edits.untracked.push(path.to_string());
164 }
165 }
166 edits
167}
168
169fn counts_phrase(unpushed: u64, edits: usize) -> String {
170 let mut parts = Vec::new();
171 if unpushed == 1 {
172 parts.push("1 unpushed".to_string());
173 } else if unpushed > 1 {
174 parts.push(format!("{unpushed} unpushed"));
175 }
176 if edits == 1 {
177 parts.push("1 uncommitted edit".to_string());
178 } else if edits > 1 {
179 parts.push(format!("{edits} uncommitted edits"));
180 }
181 parts.join(", ")
182}
183
184fn fix_command(root: &Path, unpushed: u64, edits: &OrgEdits) -> String {
185 let quoted = crate::tools::sh_quote(&root.display().to_string());
186 let mut parts = Vec::new();
187 if edits.count > 0 {
188 let message = crate::tools::sh_quote("tracker: commit issue edits");
189 parts.push(format!("git -C {quoted} add -u -- ."));
190 if !edits.untracked.is_empty() {
191 let paths = edits
192 .untracked
193 .iter()
194 .map(|path| crate::tools::sh_quote(path))
195 .collect::<Vec<_>>()
196 .join(" ");
197 parts.push(format!("git -C {quoted} add -- {paths}"));
198 }
199 parts.push(format!("git -C {quoted} commit -m {message}"));
200 }
201 if unpushed > 0 || (edits.count > 0 && has_remote(root)) {
204 if let Some(destination) = safe_push_destination(root) {
205 let remote = crate::tools::sh_quote(&destination.remote);
206 let spec = crate::tools::sh_quote(&format!("HEAD:refs/heads/{}", destination.branch));
207 parts.push(format!("git -C {quoted} push {remote} {spec}"));
208 }
209 }
210 parts.join(" && ")
211}
212
213#[cfg(test)]
214mod tests {
215 use std::path::Path;
216
217 fn git(dir: &Path, args: &[&str]) {
218 let out = crate::git_cmd()
219 .arg("-C")
220 .arg(dir)
221 .args(args)
222 .output()
223 .unwrap();
224 assert!(
225 out.status.success(),
226 "git {args:?}: {}",
227 String::from_utf8_lossy(&out.stderr)
228 );
229 }
230
231 fn checkout(dir: &Path) -> (std::path::PathBuf, std::path::PathBuf) {
232 let origin = dir.join("origin.git");
233 let root = dir.join("tracker");
234 git(
235 dir,
236 &[
237 "init",
238 "-q",
239 "--bare",
240 "-b",
241 "main",
242 origin.to_str().unwrap(),
243 ],
244 );
245 std::fs::create_dir_all(root.join("Software/probe")).unwrap();
246 git(&root, &["init", "-q", "-b", "main"]);
247 for (key, value) in [
248 ("user.email", "seat@example.invalid"),
249 ("user.name", "seat"),
250 ("commit.gpgsign", "false"),
251 ("core.hooksPath", "/dev/null"),
252 ] {
253 git(&root, &["config", key, value]);
254 }
255 std::fs::write(
256 root.join("Software/probe/issues.org"),
257 "* TODO [#C] a\n:PROPERTIES:\n:ID: probe-a\n:END:\n",
258 )
259 .unwrap();
260 git(&root, &["add", "."]);
261 git(&root, &["commit", "-q", "-m", "seed"]);
262 git(
263 &root,
264 &["remote", "add", "origin", origin.to_str().unwrap()],
265 );
266 git(&root, &["push", "-q", "-u", "origin", "HEAD"]);
267 (root, origin)
268 }
269
270 fn isolate(root: &Path) {
271 unsafe {
272 std::env::set_var("VISSUE_ROOT", root);
273 std::env::remove_var("ISSUE_ROOT");
274 std::env::remove_var("LJOS_TRACKER_GIT");
275 std::env::set_var("PACKSET_URL", "off");
276 }
277 }
278
279 #[test]
280 fn the_sync_line_names_the_count_and_the_fix() {
281 let _lock = crate::test_env_lock();
282 let dir = tempfile::tempdir().unwrap();
283 let (root, _) = checkout(dir.path());
284 isolate(&root);
285 assert!(super::tracker_sync_line().is_none());
286
287 git(&root, &["commit", "-q", "--allow-empty", "-m", "again"]);
288 let line = super::tracker_sync_line().unwrap();
289 assert!(line.starts_with("tracker: 1 unpushed; fix: "), "{line}");
290 assert!(line.contains("git -C "), "{line}");
291 assert!(
292 line.contains("push 'origin' 'HEAD:refs/heads/main'"),
293 "{line}"
294 );
295 assert!(!line.contains("uncommitted"), "{line}");
296
297 std::fs::write(root.join("Software/probe/issues.org"), "* DONE [#C] a\n").unwrap();
298 std::fs::create_dir_all(root.join("Software/probe/issues")).unwrap();
299 std::fs::write(root.join("Software/probe/issues/extra.org"), "* TODO b\n").unwrap();
300 let both = super::tracker_sync_line().unwrap();
301 assert!(both.contains("1 unpushed, 2 uncommitted edits"), "{both}");
302 assert!(both.contains("add -u -- ."), "{both}");
303 assert!(
304 both.contains("add -- 'Software/probe/issues/extra.org'"),
305 "{both}"
306 );
307 assert!(!both.contains("add -- ."), "{both}");
308 assert!(both.contains("commit -m "), "{both}");
309 assert!(
310 both.contains("push 'origin' 'HEAD:refs/heads/main'"),
311 "{both}"
312 );
313
314 unsafe { std::env::set_var("LJOS_TRACKER_GIT", "off") };
315 let off = super::tracker_sync_line().unwrap();
316 assert!(off.contains("1 unpushed"), "{off}");
317 assert!(!off.contains("uncommitted"), "{off}");
318
319 unsafe { std::env::remove_var("LJOS_TRACKER_GIT") };
320 git(&root, &["add", "."]);
321 git(&root, &["commit", "-q", "-m", "edits"]);
322 git(&root, &["push", "-q"]);
323 assert!(super::tracker_sync_line().is_none());
324
325 let full =
326 "tracker: 1 unpushed; fix: git -C 'x' push\n== doctor\nok\tpack\tanswers\n".to_string();
327 let short = crate::compact_sitting(&full, "probe-a");
328 assert!(
329 short.starts_with("tracker: 1 unpushed; fix: git -C 'x' push\n"),
330 "{short}"
331 );
332 }
333
334 #[test]
335 fn finish_pushes_when_the_config_allows_it() {
336 let _lock = crate::test_env_lock();
337 let dir = tempfile::tempdir().unwrap();
338 let (root, origin) = checkout(dir.path());
339 isolate(&root);
340 git(
341 &root,
342 &["commit", "-q", "--allow-empty", "-m", "local only"],
343 );
344 let said = super::push_leftover();
345 assert!(said.contains("pushed 1"), "{said}");
346 let ahead = crate::git_cmd()
347 .arg("-C")
348 .arg(&root)
349 .args(["rev-list", "--count", "origin/main..HEAD"])
350 .output()
351 .unwrap();
352 assert_eq!(String::from_utf8_lossy(&ahead.stdout).trim(), "0");
353 let _ = origin;
354
355 git(&root, &["commit", "-q", "--allow-empty", "-m", "held"]);
356 unsafe { std::env::set_var("LJOS_TRACKER_GIT", "commit") };
357 assert!(super::push_leftover().is_empty());
358 let still = crate::git_cmd()
359 .arg("-C")
360 .arg(&root)
361 .args(["rev-list", "--count", "origin/main..HEAD"])
362 .output()
363 .unwrap();
364 assert_eq!(String::from_utf8_lossy(&still.stdout).trim(), "1");
365 }
366
367 fn review_heads(origin: &Path) -> String {
368 let out = crate::git_cmd()
369 .arg("--git-dir")
370 .arg(origin)
371 .args([
372 "for-each-ref",
373 "--format=%(refname) %(objectname:short)",
374 "refs/heads",
375 ])
376 .output()
377 .unwrap();
378 String::from_utf8_lossy(&out.stdout).into_owned()
379 }
380
381 #[test]
382 fn review_repro_finish_pushes_a_new_branch_that_is_not_the_default() {
383 let _lock = crate::test_env_lock();
384 let dir = tempfile::tempdir().unwrap();
385 let (root, origin) = checkout(dir.path());
386 isolate(&root);
387 git(&root, &["remote", "set-head", "origin", "main"]);
388 git(&root, &["checkout", "-q", "-b", "wip"]);
389 git(&root, &["commit", "-q", "--allow-empty", "-m", "on wip"]);
390 let said = super::push_leftover();
391 let heads = review_heads(&origin);
392 assert!(!heads.contains("refs/heads/wip"), "said={said:?}\n{heads}");
393 assert!(said.is_empty(), "{said}");
394 }
395
396 #[test]
397 fn review_repro_finish_pushes_a_tracked_feature_branch() {
398 let _lock = crate::test_env_lock();
399 let dir = tempfile::tempdir().unwrap();
400 let (root, origin) = checkout(dir.path());
401 isolate(&root);
402 git(&root, &["checkout", "-q", "-b", "feature"]);
403 git(&root, &["push", "-q", "-u", "origin", "feature"]);
404 let before = review_heads(&origin);
405 git(
406 &root,
407 &["commit", "-q", "--allow-empty", "-m", "feature work"],
408 );
409 let said = super::push_leftover();
410 let after = review_heads(&origin);
411 assert_eq!(before, after, "said={said:?}");
412 assert!(said.is_empty(), "{said}");
413 }
414
415 #[test]
416 fn review_repro_the_suggested_fix_leaves_a_new_issue_file() {
417 let _lock = crate::test_env_lock();
418 let dir = tempfile::tempdir().unwrap();
419 let (root, _) = checkout(dir.path());
420 isolate(&root);
421 std::fs::create_dir_all(root.join("Software/probe/issues")).unwrap();
422 std::fs::write(root.join("Software/probe/issues/extra.org"), "* TODO b\n").unwrap();
423 let line = super::tracker_sync_line().unwrap();
424 let fix = line.split_once("fix: ").unwrap().1.trim().to_string();
425 let ran = std::process::Command::new("sh")
426 .arg("-c")
427 .arg(&fix)
428 .output()
429 .unwrap();
430 let again = super::tracker_sync_line();
431 assert!(
432 again.is_none(),
433 "fix={fix}\nexit={:?} {}\nstill: {again:?}",
434 ran.status.code(),
435 String::from_utf8_lossy(&ran.stderr)
436 );
437 }
438
439 #[test]
440 fn review_repro_a_feature_branch_with_no_upstream_counts_as_unpushed() {
441 let _lock = crate::test_env_lock();
442 let dir = tempfile::tempdir().unwrap();
443 let (root, origin) = checkout(dir.path());
444 isolate(&root);
445 git(&root, &["remote", "set-head", "origin", "main"]);
446 git(&root, &["checkout", "-q", "-b", "feature"]);
447 git(
448 &root,
449 &["commit", "-q", "--allow-empty", "-m", "local only"],
450 );
451 let line = super::tracker_sync_line().unwrap();
452 assert!(line.contains("1 unpushed"), "{line}");
453 let said = super::push_leftover();
454 assert!(said.is_empty(), "{said}");
455 let heads = review_heads(&origin);
456 assert!(!heads.contains("refs/heads/feature"), "{heads}");
457 }
458
459 #[test]
460 fn review2_main_tracking_a_feature_branch_pushes_the_feature() {
461 let _lock = crate::test_env_lock();
462 let dir = tempfile::tempdir().unwrap();
463 let (root, origin) = checkout(dir.path());
464 isolate(&root);
465 git(&root, &["remote", "set-head", "origin", "main"]);
466 git(&root, &["push", "-q", "origin", "main:feature"]);
467 git(&root, &["fetch", "-q", "origin"]);
468 git(&root, &["branch", "-q", "-u", "origin/feature"]);
469 git(&root, &["config", "push.default", "upstream"]);
470 let before = review_heads(&origin);
471 git(&root, &["commit", "-q", "--allow-empty", "-m", "on main"]);
472 let said = super::push_leftover();
473 let after = review_heads(&origin);
474 eprintln!("REVIEW2 said={said:?}\nbefore={before}\nafter={after}");
475 assert_eq!(before, after, "said={said:?}");
476 }
477
478 #[test]
479 fn review2_main_tracking_a_feature_branch_default_push_config() {
480 let _lock = crate::test_env_lock();
481 let dir = tempfile::tempdir().unwrap();
482 let (root, origin) = checkout(dir.path());
483 isolate(&root);
484 git(&root, &["remote", "set-head", "origin", "main"]);
485 git(&root, &["push", "-q", "origin", "main:feature"]);
486 git(&root, &["fetch", "-q", "origin"]);
487 git(&root, &["branch", "-q", "-u", "origin/feature"]);
488 let before = review_heads(&origin);
489 git(&root, &["commit", "-q", "--allow-empty", "-m", "on main"]);
490 let said = super::push_leftover();
491 let after = review_heads(&origin);
492 eprintln!("REVIEW2 said={said:?}\nbefore={before}\nafter={after}");
493 assert_eq!(before, after, "said={said:?}");
494 assert!(said.is_empty(), "{said}");
495 }
496
497 #[test]
498 fn review2_main_tracking_another_remote_pushes_there() {
499 let _lock = crate::test_env_lock();
500 let dir = tempfile::tempdir().unwrap();
501 let (root, origin) = checkout(dir.path());
502 isolate(&root);
503 let fork = dir.path().join("fork.git");
504 git(
505 dir.path(),
506 &["init", "-q", "--bare", "-b", "main", fork.to_str().unwrap()],
507 );
508 git(&root, &["remote", "add", "fork", fork.to_str().unwrap()]);
509 git(&root, &["remote", "set-head", "origin", "main"]);
510 git(&root, &["push", "-q", "-u", "fork", "main"]);
511 let before_fork = review_heads(&fork);
512 git(&root, &["commit", "-q", "--allow-empty", "-m", "on main"]);
513 let said = super::push_leftover();
514 let after_fork = review_heads(&fork);
515 let after_origin = review_heads(&origin);
516 eprintln!("REVIEW2 said={said:?}\nfork before={before_fork}\nfork after={after_fork}\norigin={after_origin}");
517 assert_eq!(before_fork, after_fork, "said={said:?}");
518 }
519
520 #[test]
521 fn review2_the_fix_commits_and_pushes_a_stray_file() {
522 let _lock = crate::test_env_lock();
523 let dir = tempfile::tempdir().unwrap();
524 let (root, origin) = checkout(dir.path());
525 isolate(&root);
526 std::fs::write(root.join("Software/probe/issues.org"), "* DONE [#C] a\n").unwrap();
527 std::fs::write(root.join("scratch.env"), "GITHUB_TOKEN=ghp_notreal\n").unwrap();
528 let line = super::tracker_sync_line().unwrap();
529 eprintln!("REVIEW2 line={line}");
530 let fix = line.split_once("fix: ").unwrap().1.trim().to_string();
531 let ran = std::process::Command::new("sh")
532 .arg("-c")
533 .arg(&fix)
534 .output()
535 .unwrap();
536 let tree = crate::git_cmd()
537 .arg("--git-dir")
538 .arg(&origin)
539 .args(["ls-tree", "-r", "--name-only", "main"])
540 .output()
541 .unwrap();
542 let tree = String::from_utf8_lossy(&tree.stdout).into_owned();
543 eprintln!(
544 "REVIEW2 exit={:?} origin main tree:\n{tree}",
545 ran.status.code()
546 );
547 assert!(!tree.contains("scratch.env"), "{tree}");
548 }
549}