Skip to main content

chronicle/sync/
push_fetch.rs

1use std::path::PathBuf;
2use std::process::Command;
3
4use crate::error::chronicle_error::GitSnafu;
5use crate::error::git_error::CommandFailedSnafu;
6use crate::error::{GitError, Result};
7use snafu::ResultExt;
8
9const NOTES_REF: &str = "refs/notes/chronicle";
10
11/// Current sync configuration for a remote.
12#[derive(Debug, Clone)]
13pub struct SyncConfig {
14    pub remote: String,
15    pub push_refspec: Option<String>,
16    pub fetch_refspec: Option<String>,
17}
18
19impl SyncConfig {
20    pub fn is_enabled(&self) -> bool {
21        self.push_refspec.is_some() && self.fetch_refspec.is_some()
22    }
23}
24
25/// Sync status between local and remote notes.
26#[derive(Debug, Clone)]
27pub struct SyncStatus {
28    pub enabled: bool,
29    pub local_count: usize,
30    pub remote_count: Option<usize>,
31    pub unpushed_count: usize,
32}
33
34/// Run a git command in the given repo directory.
35fn run_git(repo_dir: &PathBuf, args: &[&str]) -> std::result::Result<String, GitError> {
36    let output = Command::new("git")
37        .args(args)
38        .current_dir(repo_dir)
39        .output()
40        .map_err(|e| {
41            CommandFailedSnafu {
42                message: format!("failed to run git: {e}"),
43            }
44            .build()
45        })?;
46
47    if output.status.success() {
48        Ok(String::from_utf8_lossy(&output.stdout).to_string())
49    } else {
50        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
51        Err(CommandFailedSnafu {
52            message: stderr.trim().to_string(),
53        }
54        .build())
55    }
56}
57
58/// Run git and return (success, stdout, stderr) without failing on non-zero exit.
59fn run_git_raw(
60    repo_dir: &PathBuf,
61    args: &[&str],
62) -> std::result::Result<(bool, String, String), GitError> {
63    let output = Command::new("git")
64        .args(args)
65        .current_dir(repo_dir)
66        .output()
67        .map_err(|e| {
68            CommandFailedSnafu {
69                message: format!("failed to run git: {e}"),
70            }
71            .build()
72        })?;
73
74    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
75    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
76    Ok((output.status.success(), stdout, stderr))
77}
78
79/// Get the current sync configuration for a remote.
80pub fn get_sync_config(repo_dir: &PathBuf, remote: &str) -> Result<SyncConfig> {
81    let push_refspec = get_config_values(repo_dir, &format!("remote.{remote}.push"))
82        .context(GitSnafu)?
83        .into_iter()
84        .find(|r| r.contains(NOTES_REF));
85
86    let fetch_refspec = get_config_values(repo_dir, &format!("remote.{remote}.fetch"))
87        .context(GitSnafu)?
88        .into_iter()
89        .find(|r| r.contains(NOTES_REF));
90
91    Ok(SyncConfig {
92        remote: remote.to_string(),
93        push_refspec,
94        fetch_refspec,
95    })
96}
97
98/// Enable sync by adding push/fetch refspecs for chronicle notes.
99pub fn enable_sync(repo_dir: &PathBuf, remote: &str) -> Result<()> {
100    let config = get_sync_config(repo_dir, remote)?;
101
102    // Add push refspec if not already present
103    if config.push_refspec.is_none() {
104        run_git(
105            repo_dir,
106            &[
107                "config",
108                "--add",
109                &format!("remote.{remote}.push"),
110                NOTES_REF,
111            ],
112        )
113        .context(GitSnafu)?;
114    }
115
116    // Add fetch refspec if not already present
117    if config.fetch_refspec.is_none() {
118        let fetch_spec = format!("+{NOTES_REF}:{NOTES_REF}");
119        run_git(
120            repo_dir,
121            &[
122                "config",
123                "--add",
124                &format!("remote.{remote}.fetch"),
125                &fetch_spec,
126            ],
127        )
128        .context(GitSnafu)?;
129    }
130
131    Ok(())
132}
133
134/// Get the sync status for a remote.
135pub fn get_sync_status(repo_dir: &PathBuf, remote: &str) -> Result<SyncStatus> {
136    let config = get_sync_config(repo_dir, remote)?;
137    let enabled = config.is_enabled();
138
139    let local_count = count_local_notes(repo_dir).context(GitSnafu)?;
140
141    // Try to get remote note count (may fail if remote is unreachable)
142    let remote_count = count_remote_notes(repo_dir, remote).ok();
143
144    let unpushed_count = if let Some(rc) = remote_count {
145        local_count.saturating_sub(rc)
146    } else {
147        0
148    };
149
150    Ok(SyncStatus {
151        enabled,
152        local_count,
153        remote_count,
154        unpushed_count,
155    })
156}
157
158/// Pull (fetch) notes from a remote.
159pub fn pull_notes(repo_dir: &PathBuf, remote: &str) -> Result<()> {
160    run_git(
161        repo_dir,
162        &["fetch", remote, &format!("+{NOTES_REF}:{NOTES_REF}")],
163    )
164    .context(GitSnafu)?;
165
166    Ok(())
167}
168
169/// Count local notes under refs/notes/chronicle.
170fn count_local_notes(repo_dir: &PathBuf) -> std::result::Result<usize, GitError> {
171    let (success, stdout, _) = run_git_raw(repo_dir, &["notes", "--ref", NOTES_REF, "list"])?;
172    if !success {
173        return Ok(0);
174    }
175    Ok(stdout.lines().filter(|l| !l.is_empty()).count())
176}
177
178/// Count remote notes by ls-remote.
179fn count_remote_notes(repo_dir: &PathBuf, _remote: &str) -> Result<usize> {
180    // We can only check if the ref exists remotely; accurate count needs a fetch.
181    // After a fetch, we can count local notes (which now include fetched ones).
182    // For a quick status, just return the local count as an approximation.
183    let count = count_local_notes(repo_dir).context(GitSnafu)?;
184    Ok(count)
185}
186
187/// Get all values for a multi-valued git config key.
188fn get_config_values(repo_dir: &PathBuf, key: &str) -> std::result::Result<Vec<String>, GitError> {
189    let (success, stdout, _) = run_git_raw(repo_dir, &["config", "--get-all", key])?;
190    if !success {
191        return Ok(Vec::new());
192    }
193    Ok(stdout
194        .lines()
195        .filter(|l| !l.is_empty())
196        .map(|l| l.to_string())
197        .collect())
198}