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/// What parts [`tracker_sync_line`]'s counts from its fix.
10pub(crate) const FIX: &str = "; fix: ";
11
12/// The line a digest and a sitting print when the tracker has drifted.
13/// `None` when it is caught up, or when it is not a checkout.
14#[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/// Push tracker commits a finish did not already push.
29///
30/// Empty when the config does not push, nothing is waiting, a push is
31/// already running, or the checkout has no remote. Edits are not committed.
32#[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    // Resolve what an implicit push would update before doing any network
48    // write. Only the primary remote's default branch is published.
49    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
97/// The destination Git's configured implicit push would use, but only
98/// when it is exactly `origin`'s default branch.
99pub(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    // Commit mode publishes nothing. The fallback asks the remote which
124    // branch HEAD names. A private remote with no credential then sits
125    // until the sitting reads it as a tracker that does not answer.
126    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    // A commit of the edits is itself unpushed, so the fix publishes
211    // only when the configured destination is the permitted default.
212    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}