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    // Commit mode publishes nothing. The fallback asks the remote which
121    // branch HEAD names. A private remote with no credential then sits
122    // until the sitting reads it as a tracker that does not answer.
123    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    // A commit of the edits is itself unpushed, so the fix publishes
208    // only when the configured destination is the permitted default.
209    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}