1use anyhow::{Context, Result};
2use gn_core::{MergeStrategy, Namespace, NotesEngine};
3use serde::{Deserialize, Serialize};
4use std::path::{Path, PathBuf};
5use std::process::Command;
6use uuid::Uuid;
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct BundleReport {
10 pub path: PathBuf,
11 pub refs_count: usize,
12 pub fetched: usize,
13 pub merged: usize,
14}
15
16pub fn export_bundle(repo_path: &Path, output: &Path) -> Result<BundleReport> {
18 let refs_output = Command::new("git")
20 .current_dir(repo_path)
21 .args(["for-each-ref", "--format=%(refname)", "refs/notes/"])
22 .output()
23 .context("Failed to query notes refs")?;
24
25 if !refs_output.status.success() {
26 let err = String::from_utf8_lossy(&refs_output.stderr);
27 anyhow::bail!("Failed to inspect notes refs: {}", err.trim());
28 }
29
30 let refs_str = String::from_utf8_lossy(&refs_output.stdout);
31 let note_refs: Vec<String> = refs_str
32 .lines()
33 .map(|l| l.trim().to_string())
34 .filter(|l| {
35 l.starts_with("refs/notes/")
36 && !l.contains("/bundle_")
37 && !l.contains("/imported_")
38 && !l.contains("/p2p_")
39 && !l.ends_with("_remote")
40 })
41 .collect();
42
43 if note_refs.is_empty() {
44 anyhow::bail!("No notes refs found in repository to export. Create notes first using 'gn add'.");
45 }
46
47 let resolved_output = if output.is_relative() {
49 std::env::current_dir()
50 .unwrap_or_else(|_| repo_path.to_path_buf())
51 .join(output)
52 } else {
53 output.to_path_buf()
54 };
55
56 if let Some(parent) = resolved_output.parent() {
57 std::fs::create_dir_all(parent)
58 .with_context(|| format!("Failed to create parent directory for bundle: {}", parent.display()))?;
59 }
60
61 let mut cmd = Command::new("git");
63 cmd.current_dir(repo_path);
64 cmd.arg("bundle").arg("create").arg(&resolved_output);
65 for r in ¬e_refs {
66 cmd.arg(r);
67 }
68
69 let create_output = cmd.output().context("Failed to execute git bundle create")?;
70 if !create_output.status.success() {
71 let err = String::from_utf8_lossy(&create_output.stderr);
72 anyhow::bail!("Failed to create git bundle: {}", err.trim());
73 }
74
75 Ok(BundleReport {
76 path: resolved_output,
77 refs_count: note_refs.len(),
78 fetched: 0,
79 merged: 0,
80 })
81}
82
83pub fn import_bundle(
85 repo_path: &Path,
86 input: &Path,
87 strategy: &dyn MergeStrategy,
88) -> Result<BundleReport> {
89 let resolved_input = if input.is_relative() {
90 std::env::current_dir()
91 .unwrap_or_else(|_| repo_path.to_path_buf())
92 .join(input)
93 } else {
94 input.to_path_buf()
95 };
96
97 if !resolved_input.exists() {
98 anyhow::bail!("Bundle file not found: {}", resolved_input.display());
99 }
100
101 let verify = Command::new("git")
103 .current_dir(repo_path)
104 .args(["bundle", "verify"])
105 .arg(&resolved_input)
106 .output()
107 .context("Failed to verify git bundle")?;
108
109 if !verify.status.success() {
110 let err = String::from_utf8_lossy(&verify.stderr);
111 anyhow::bail!(
112 "Invalid git bundle at {}: {}",
113 resolved_input.display(),
114 err.trim()
115 );
116 }
117
118 let temp_token = format!("bundle_{}", Uuid::new_v4().simple());
120 let refspec = format!("refs/notes/*:refs/notes/{}/*", temp_token);
121
122 let fetch_out = Command::new("git")
123 .current_dir(repo_path)
124 .args(["fetch"])
125 .arg(&resolved_input)
126 .arg(&refspec)
127 .output()
128 .context("Failed to fetch from bundle")?;
129
130 if !fetch_out.status.success() {
131 let err = String::from_utf8_lossy(&fetch_out.stderr);
132 anyhow::bail!("Failed to fetch notes from bundle: {}", err.trim());
133 }
134
135 let temp_ref_prefix = format!("refs/notes/{}/", temp_token);
137 let list_out = Command::new("git")
138 .current_dir(repo_path)
139 .args(["for-each-ref", "--format=%(refname)", &temp_ref_prefix])
140 .output()
141 .context("Failed to list imported refs")?;
142
143 let imported_refs: Vec<String> = String::from_utf8_lossy(&list_out.stdout)
144 .lines()
145 .map(|l| l.trim().to_string())
146 .filter(|l| l.starts_with(&temp_ref_prefix))
147 .collect();
148
149 let engine = NotesEngine::new(repo_path);
150 let mut total_fetched = 0;
151 let mut total_merged = 0;
152
153 for temp_ref in &imported_refs {
154 let ns_suffix = temp_ref
155 .strip_prefix(&temp_ref_prefix)
156 .unwrap_or(temp_ref);
157
158 let target_ns = Namespace::from_str(ns_suffix);
159 let temp_ns = Namespace::Custom(format!("{}/{}", temp_token, ns_suffix));
160
161 let local_notes = engine.read_notes(&target_ns).unwrap_or_default();
162 let imported_notes = engine.read_notes(&temp_ns).unwrap_or_default();
163
164 total_fetched += imported_notes.len();
165
166 if !imported_notes.is_empty() {
167 let merged = strategy.merge(&local_notes, &imported_notes);
168 for note in &merged {
169 let mut note_to_write = note.clone();
170 note_to_write.namespace = target_ns.clone();
171 engine.write_note(¬e_to_write)?;
172 }
173 total_merged += merged.len();
174 }
175
176 let _ = Command::new("git")
178 .current_dir(repo_path)
179 .args(["update-ref", "-d", temp_ref])
180 .status();
181 }
182
183 Ok(BundleReport {
184 path: resolved_input,
185 refs_count: imported_refs.len(),
186 fetched: total_fetched,
187 merged: total_merged,
188 })
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194 use gn_core::{LwwStrategy, Note};
195 use std::fs;
196
197 struct TestDir {
198 path: PathBuf,
199 }
200
201 impl TestDir {
202 fn new() -> Self {
203 let path = std::env::temp_dir().join(format!("gn_bundle_test_{}", Uuid::new_v4().simple()));
204 fs::create_dir_all(&path).unwrap();
205 Self { path }
206 }
207
208 fn path(&self) -> &Path {
209 &self.path
210 }
211 }
212
213 impl Drop for TestDir {
214 fn drop(&mut self) {
215 let _ = fs::remove_dir_all(&self.path);
216 }
217 }
218
219 fn run_git(dir: &Path, args: &[&str]) {
220 let status = Command::new("git")
221 .current_dir(dir)
222 .args(args)
223 .status()
224 .unwrap();
225 assert!(status.success(), "git {:?} failed", args);
226 }
227
228 #[test]
229 fn test_export_and_import_bundle() {
230 let repo_a = TestDir::new();
231 let repo_b = TestDir::new();
232 let bundle_dir = TestDir::new();
233 let bundle_file = bundle_dir.path().join("notes.bundle");
234
235 run_git(repo_a.path(), &["init"]);
237 run_git(repo_a.path(), &["config", "user.name", "Test User"]);
238 run_git(repo_a.path(), &["config", "user.email", "test@example.com"]);
239 fs::write(repo_a.path().join("file.txt"), "hello world\n").unwrap();
240 run_git(repo_a.path(), &["add", "."]);
241 run_git(repo_a.path(), &["commit", "-m", "Initial commit"]);
242
243 run_git(repo_b.path(), &["init"]);
245 run_git(repo_b.path(), &["config", "user.name", "Peer User"]);
246 run_git(repo_b.path(), &["config", "user.email", "peer@example.com"]);
247 fs::write(repo_b.path().join("file.txt"), "hello world\n").unwrap();
248 run_git(repo_b.path(), &["add", "."]);
249 run_git(repo_b.path(), &["commit", "-m", "Initial commit"]);
250
251 let engine_a = NotesEngine::new(repo_a.path());
252 let note = Note::new(
253 "HEAD".to_string(),
254 Some("file.txt".to_string()),
255 Some(1),
256 Some(1),
257 "Important note for bundle export".to_string(),
258 "Author <a@b.com>".to_string(),
259 Namespace::Comments,
260 );
261 engine_a.write_note(¬e).unwrap();
262
263 let export_report = export_bundle(repo_a.path(), &bundle_file).unwrap();
265 assert_eq!(export_report.refs_count, 1);
266 assert!(bundle_file.exists());
267
268 let strategy = LwwStrategy;
270 let import_report = import_bundle(repo_b.path(), &bundle_file, &strategy).unwrap();
271 assert_eq!(import_report.refs_count, 1);
272 assert_eq!(import_report.fetched, 1);
273 assert_eq!(import_report.merged, 1);
274
275 let engine_b = NotesEngine::new(repo_b.path());
277 let notes_b = engine_b.read_notes(&Namespace::Comments).unwrap();
278 assert_eq!(notes_b.len(), 1);
279 assert_eq!(notes_b[0].body, "Important note for bundle export");
280 }
281}