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