Skip to main content

ljos_cli/
tracker_sync.rs

1//! Whether the tracker checkout still has work the other hosts cannot see.
2//!
3//! Unpushed commits and uncommitted issue edits are one line, with the
4//! command that publishes them. Finish pushes the commits when the tracker
5//! config allows a push. Stray edits are left for that command.
6
7use std::path::Path;
8
9/// The line a digest and a sitting print when the tracker has drifted.
10/// `None` when it is caught up, or when it is not a checkout.
11#[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/// Push tracker commits a finish did not already push.
26///
27/// Empty when the config does not push, nothing is waiting, a push is
28/// already running, or the checkout has no remote. Edits are not committed.
29#[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    // Resolve what an implicit push would update before doing any network
45    // write. Only the primary remote's default branch is published.
46    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
94/// The destination Git's configured implicit push would use, but only
95/// when it is exactly `origin`'s default branch.
96pub(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    // A commit of the edits is itself unpushed, so the fix publishes
202    // only when the configured destination is the permitted default.
203    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}