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#[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#[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
35fn 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
59fn 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
80pub 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
99pub fn enable_sync(repo_dir: &PathBuf, remote: &str) -> Result<()> {
101 let config = get_sync_config(repo_dir, remote)?;
102
103 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 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 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
173pub 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 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
197pub 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
208fn 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
217fn 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 Ok(Some(0))
228 } else {
229 Ok(None)
231 }
232}
233
234fn 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}