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