Skip to main content

gn_sync/
bundle.rs

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
16/// Create a portable git bundle containing all `refs/notes/*` for transfer over USB or AirDrop.
17pub fn export_bundle(repo_path: &Path, output: &Path) -> Result<BundleReport> {
18    // 1. Discover all refs under refs/notes/
19    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    // 2. Resolve output path
48    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    // 3. Create bundle: git bundle create <output> <ref1> <ref2> ...
62    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 &note_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
83/// Fetch and merge notes from a portable `.bundle` file.
84pub 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    // 1. Verify bundle
102    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    // 2. Fetch refs from bundle into an isolated temporary namespace
119    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    // 3. Discover all imported temporary refs
136    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(&note_to_write)?;
172            }
173            total_merged += merged.len();
174        }
175
176        // Clean up temporary ref
177        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        // Init repo A
236        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        // Init repo B with same commit
244        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(&note).unwrap();
262
263        // Export bundle from repo A
264        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        // Import bundle into repo B
269        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        // Verify note is in repo B
276        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}