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";
10const KNOWLEDGE_REF: &str = "refs/notes/chronicle-knowledge";
11
12/// Current sync configuration for a remote.
13#[derive(Debug, Clone)]
14pub struct SyncConfig {
15    pub remote: String,
16    pub push_refspec: Option<String>,
17    pub fetch_refspec: Option<String>,
18}
19
20impl SyncConfig {
21    pub fn is_enabled(&self) -> bool {
22        self.push_refspec.is_some() && self.fetch_refspec.is_some()
23    }
24}
25
26/// Sync status between local and remote notes.
27#[derive(Debug, Clone)]
28pub struct SyncStatus {
29    pub enabled: bool,
30    pub local_count: usize,
31    pub remote_count: Option<usize>,
32    pub unpushed_count: usize,
33}
34
35/// Run a git command in the given repo directory.
36fn run_git(repo_dir: &PathBuf, args: &[&str]) -> std::result::Result<String, GitError> {
37    let output = Command::new("git")
38        .args(args)
39        .current_dir(repo_dir)
40        .output()
41        .map_err(|e| {
42            CommandFailedSnafu {
43                message: format!("failed to run git: {e}"),
44            }
45            .build()
46        })?;
47
48    if output.status.success() {
49        Ok(String::from_utf8_lossy(&output.stdout).to_string())
50    } else {
51        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
52        Err(CommandFailedSnafu {
53            message: stderr.trim().to_string(),
54        }
55        .build())
56    }
57}
58
59/// Run git and return (success, stdout, stderr) without failing on non-zero exit.
60fn run_git_raw(
61    repo_dir: &PathBuf,
62    args: &[&str],
63) -> std::result::Result<(bool, String, String), GitError> {
64    let output = Command::new("git")
65        .args(args)
66        .current_dir(repo_dir)
67        .output()
68        .map_err(|e| {
69            CommandFailedSnafu {
70                message: format!("failed to run git: {e}"),
71            }
72            .build()
73        })?;
74
75    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
76    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
77    Ok((output.status.success(), stdout, stderr))
78}
79
80/// Get the current sync configuration for a remote.
81pub fn get_sync_config(repo_dir: &PathBuf, remote: &str) -> Result<SyncConfig> {
82    let push_refspec = get_config_values(repo_dir, &format!("remote.{remote}.push"))
83        .context(GitSnafu)?
84        .into_iter()
85        .find(|r| r.contains(NOTES_REF));
86
87    let fetch_refspec = get_config_values(repo_dir, &format!("remote.{remote}.fetch"))
88        .context(GitSnafu)?
89        .into_iter()
90        .find(|r| r.contains(NOTES_REF));
91
92    Ok(SyncConfig {
93        remote: remote.to_string(),
94        push_refspec,
95        fetch_refspec,
96    })
97}
98
99/// Enable sync by adding push/fetch refspecs for chronicle notes.
100pub fn enable_sync(repo_dir: &PathBuf, remote: &str) -> Result<()> {
101    let config = get_sync_config(repo_dir, remote)?;
102
103    // Add push refspec if not already present
104    if config.push_refspec.is_none() {
105        run_git(
106            repo_dir,
107            &[
108                "config",
109                "--add",
110                &format!("remote.{remote}.push"),
111                NOTES_REF,
112            ],
113        )
114        .context(GitSnafu)?;
115    }
116
117    // Add fetch refspec if not already present
118    if config.fetch_refspec.is_none() {
119        let fetch_spec = format!("{NOTES_REF}:{NOTES_REF}");
120        run_git(
121            repo_dir,
122            &[
123                "config",
124                "--add",
125                &format!("remote.{remote}.fetch"),
126                &fetch_spec,
127            ],
128        )
129        .context(GitSnafu)?;
130    }
131
132    // Also configure sync for the knowledge ref
133    let knowledge_push = get_config_values(repo_dir, &format!("remote.{remote}.push"))
134        .context(GitSnafu)?
135        .into_iter()
136        .any(|r| r.contains(KNOWLEDGE_REF));
137
138    if !knowledge_push {
139        run_git(
140            repo_dir,
141            &[
142                "config",
143                "--add",
144                &format!("remote.{remote}.push"),
145                KNOWLEDGE_REF,
146            ],
147        )
148        .context(GitSnafu)?;
149    }
150
151    let knowledge_fetch = get_config_values(repo_dir, &format!("remote.{remote}.fetch"))
152        .context(GitSnafu)?
153        .into_iter()
154        .any(|r| r.contains(KNOWLEDGE_REF));
155
156    if !knowledge_fetch {
157        let knowledge_fetch_spec = format!("{KNOWLEDGE_REF}:{KNOWLEDGE_REF}");
158        run_git(
159            repo_dir,
160            &[
161                "config",
162                "--add",
163                &format!("remote.{remote}.fetch"),
164                &knowledge_fetch_spec,
165            ],
166        )
167        .context(GitSnafu)?;
168    }
169
170    Ok(())
171}
172
173/// Get the sync status for a remote.
174pub fn get_sync_status(repo_dir: &PathBuf, remote: &str) -> Result<SyncStatus> {
175    let config = get_sync_config(repo_dir, remote)?;
176    let enabled = config.is_enabled();
177
178    let local_count = count_local_notes(repo_dir).context(GitSnafu)?;
179
180    // Try to get remote note count (may fail if remote is unreachable)
181    let remote_count = count_remote_notes(repo_dir, remote).ok().flatten();
182
183    let unpushed_count = if let Some(rc) = remote_count {
184        local_count.saturating_sub(rc)
185    } else {
186        0
187    };
188
189    Ok(SyncStatus {
190        enabled,
191        local_count,
192        remote_count,
193        unpushed_count,
194    })
195}
196
197/// Pull (fetch) notes from a remote.
198pub fn pull_notes(repo_dir: &PathBuf, remote: &str) -> Result<()> {
199    run_git(
200        repo_dir,
201        &["fetch", remote, &format!("{NOTES_REF}:{NOTES_REF}")],
202    )
203    .context(GitSnafu)?;
204
205    Ok(())
206}
207
208/// Count local notes under refs/notes/chronicle.
209fn count_local_notes(repo_dir: &PathBuf) -> std::result::Result<usize, GitError> {
210    let (success, stdout, _) = run_git_raw(repo_dir, &["notes", "--ref", NOTES_REF, "list"])?;
211    if !success {
212        return Ok(0);
213    }
214    Ok(stdout.lines().filter(|l| !l.is_empty()).count())
215}
216
217/// Check remote notes via ls-remote.
218/// Returns Some(0) if the remote has no chronicle notes ref,
219/// or None if the ref exists (we can't count without fetching).
220fn count_remote_notes(
221    repo_dir: &PathBuf,
222    remote: &str,
223) -> std::result::Result<Option<usize>, GitError> {
224    let (success, stdout, _) = run_git_raw(repo_dir, &["ls-remote", remote, NOTES_REF])?;
225    if !success || stdout.trim().is_empty() {
226        // Remote ref doesn't exist — zero remote notes
227        Ok(Some(0))
228    } else {
229        // Remote ref exists but we can't count individual notes without fetching
230        Ok(None)
231    }
232}
233
234/// Get all values for a multi-valued git config key.
235fn get_config_values(repo_dir: &PathBuf, key: &str) -> std::result::Result<Vec<String>, GitError> {
236    let (success, stdout, _) = run_git_raw(repo_dir, &["config", "--get-all", key])?;
237    if !success {
238        return Ok(Vec::new());
239    }
240    Ok(stdout
241        .lines()
242        .filter(|l| !l.is_empty())
243        .map(|l| l.to_string())
244        .collect())
245}