Skip to main content

dstack/
cmd_sync.rs

1use crate::config::Config;
2use std::process::Command;
3
4pub fn status(cfg: &Config) -> anyhow::Result<()> {
5    if cfg.repos.tracked.is_empty() {
6        eprintln!("No repos tracked. Add repos to [repos] in config.toml");
7        return Ok(());
8    }
9    // Fetch all remotes first for accurate ahead/behind
10    eprint!("Fetching remotes...");
11    for repo in &cfg.repos.tracked {
12        let path = format!("{}/{}", cfg.repos.root, repo);
13        if std::path::Path::new(&path).exists() {
14            let _ = Command::new("git")
15                .args(["-C", &path, "fetch", "--quiet"])
16                .status();
17        }
18    }
19    eprintln!(" done.\n");
20
21    println!(
22        "{:<20} {:<10} {:<8} {:<8} {}",
23        "REPO", "BRANCH", "AHEAD", "BEHIND", "STATUS"
24    );
25    println!("{}", "-".repeat(65));
26
27    let mut dirty_repos = 0;
28    let mut unpushed_repos = 0;
29
30    for repo in &cfg.repos.tracked {
31        let path = format!("{}/{}", cfg.repos.root, repo);
32        if !std::path::Path::new(&path).exists() {
33            println!("{:<20} {:<10} {:<8} {:<8} NOT FOUND", repo, "-", "-", "-");
34            continue;
35        }
36        let branch = git_output(&path, &["branch", "--show-current"]);
37        let branch = branch.trim();
38
39        // Ahead/behind tracking
40        let ab_output = git_output(
41            &path,
42            &[
43                "rev-list",
44                "--left-right",
45                "--count",
46                &format!("{}...@{{u}}", branch),
47            ],
48        );
49        let (ahead, behind) = parse_ahead_behind(&ab_output);
50
51        // Dirty file count
52        let dirty = git_output(&path, &["status", "--porcelain"]);
53        let dirty_count = dirty.lines().filter(|l| !l.is_empty()).count();
54
55        let mut status_parts = Vec::new();
56        if dirty_count > 0 {
57            status_parts.push(format!("{} dirty", dirty_count));
58            dirty_repos += 1;
59        }
60        if ahead > 0 {
61            status_parts.push(format!("{} to push", ahead));
62            unpushed_repos += 1;
63        }
64        if behind > 0 {
65            status_parts.push(format!("{} to pull", behind));
66        }
67        let status_str = if status_parts.is_empty() {
68            "clean".to_string()
69        } else {
70            status_parts.join(", ")
71        };
72
73        let ahead_str = if ahead > 0 {
74            format!("+{}", ahead)
75        } else {
76            "-".to_string()
77        };
78        let behind_str = if behind > 0 {
79            format!("-{}", behind)
80        } else {
81            "-".to_string()
82        };
83
84        println!(
85            "{:<20} {:<10} {:<8} {:<8} {}",
86            repo, branch, ahead_str, behind_str, status_str
87        );
88    }
89
90    // Summary
91    println!("{}", "-".repeat(65));
92    let total = cfg.repos.tracked.len();
93    let clean = total - dirty_repos.max(unpushed_repos);
94    println!(
95        "{} repos: {} clean, {} dirty, {} unpushed",
96        total, clean, dirty_repos, unpushed_repos
97    );
98
99    Ok(())
100}
101
102fn parse_ahead_behind(output: &str) -> (usize, usize) {
103    let parts: Vec<&str> = output.trim().split('\t').collect();
104    if parts.len() == 2 {
105        let ahead = parts[0].parse().unwrap_or(0);
106        let behind = parts[1].parse().unwrap_or(0);
107        (ahead, behind)
108    } else {
109        (0, 0)
110    }
111}
112
113pub fn sync(cfg: &Config, dry_run: bool) -> anyhow::Result<()> {
114    if cfg.repos.tracked.is_empty() {
115        anyhow::bail!("No repos tracked. Add repos to [repos] in config.toml");
116    }
117    for repo in &cfg.repos.tracked {
118        let path = format!("{}/{}", cfg.repos.root, repo);
119        if !std::path::Path::new(&path).exists() {
120            eprintln!("{}: NOT FOUND, skipping", repo);
121            continue;
122        }
123        let dirty = git_output(&path, &["status", "--porcelain"]);
124        if !dirty.trim().is_empty() {
125            eprintln!("{}: dirty — skipping (commit first)", repo);
126            continue;
127        }
128        if dry_run {
129            eprintln!("{}: clean (dry-run, would pull+push)", repo);
130            continue;
131        }
132        eprint!("{}: ", repo);
133        let pull = Command::new("git").args(["-C", &path, "pull", "--ff-only"]).status()?;
134        if pull.success() {
135            let push = Command::new("git").args(["-C", &path, "push"]).status()?;
136            eprintln!("{}", if push.success() { "synced" } else { "push failed" });
137        } else {
138            eprintln!("pull failed (diverged?)");
139        }
140    }
141    Ok(())
142}
143
144fn git_output(repo_path: &str, args: &[&str]) -> String {
145    Command::new("git")
146        .args([&["-C", repo_path], args].concat())
147        .output()
148        .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
149        .unwrap_or_default()
150}