Skip to main content

gn_cli/commands/
sync.rs

1use anyhow::{Context, Result};
2use clap::{Args, Subcommand};
3use gn_core::{LwwStrategy, Namespace};
4use std::path::PathBuf;
5use std::process::Command;
6
7#[derive(Args, Debug, Clone)]
8pub struct SyncArgs {
9    #[command(subcommand)]
10    pub action: Option<SyncAction>,
11
12    /// Quick flag for P2P sync (e.g. `gn sync --p2p`)
13    #[arg(long)]
14    pub p2p: bool,
15
16    /// Remote name for git push/pull (default: origin)
17    #[arg(short, long, default_value = "origin")]
18    pub remote: String,
19}
20
21#[derive(Subcommand, Debug, Clone)]
22pub enum SyncAction {
23    /// Push notes to remote
24    Push,
25    /// Pull notes from remote
26    Pull,
27    /// Sync bidirectionally with remote
28    Auto,
29    /// Export or import notes via git bundle for offline / USB / AirDrop transfer
30    Bundle(BundleArgs),
31    /// Local LAN P2P git notes sync without internet
32    P2p(P2pArgs),
33}
34
35#[derive(Args, Debug, Clone)]
36pub struct BundleArgs {
37    #[command(subcommand)]
38    pub command: BundleCommand,
39}
40
41#[derive(Subcommand, Debug, Clone)]
42pub enum BundleCommand {
43    /// Create a portable git bundle containing all refs/notes/* for transfer over USB or AirDrop
44    Export {
45        /// Destination path for the .bundle file
46        output: PathBuf,
47    },
48    /// Fetch and merge notes from a portable .bundle file
49    Import {
50        /// Path to the .bundle file to import
51        input: PathBuf,
52    },
53}
54
55#[derive(Args, Debug, Clone)]
56pub struct P2pArgs {
57    #[command(subcommand)]
58    pub command: Option<P2pCommand>,
59
60    /// Port to listen on (default: 9418)
61    #[arg(short, long, default_value_t = 9418)]
62    pub port: u16,
63}
64
65#[derive(Subcommand, Debug, Clone)]
66pub enum P2pCommand {
67    /// Start a lightweight local P2P git notes sync server on the local LAN
68    Serve {
69        /// Port to listen on (default: 9418)
70        #[arg(short, long, default_value_t = 9418)]
71        port: u16,
72    },
73    /// Pull and merge notes directly from a peer developer's IP on the local Wi-Fi
74    Connect {
75        /// Peer address in format <ip:port> or <ip> (default port: 9418)
76        peer: String,
77    },
78}
79
80fn get_repo_root() -> Result<PathBuf> {
81    let root_out = Command::new("git")
82        .args(["rev-parse", "--show-toplevel"])
83        .output()
84        .context("Failed to check git repository")?;
85
86    if !root_out.status.success() {
87        anyhow::bail!("Not inside a Git repository. Run 'git init' first.");
88    }
89
90    let repo_root_str = String::from_utf8_lossy(&root_out.stdout).trim().to_string();
91    Ok(PathBuf::from(repo_root_str))
92}
93
94pub fn run(args: &SyncArgs) -> Result<()> {
95    let repo_root = get_repo_root()?;
96
97    // Check --p2p flag first
98    if args.p2p {
99        return gn_sync::serve_p2p(&repo_root, 9418);
100    }
101
102    let default_action = SyncAction::Auto;
103    let action = args.action.as_ref().unwrap_or(&default_action);
104
105    match action {
106        SyncAction::Bundle(bundle_args) => match &bundle_args.command {
107            BundleCommand::Export { output } => {
108                let report = gn_sync::export_bundle(&repo_root, output)?;
109                println!(
110                    "\n\x1b[32m✔ Exported {} notes refs to git bundle:\x1b[0m {}",
111                    report.refs_count,
112                    report.path.display()
113                );
114                println!("  Transfer this bundle file to teammates via USB, AirDrop, or shared drive.");
115                println!("  Teammates can import it with: \x1b[36mgn sync bundle import <path>\x1b[0m\n");
116                Ok(())
117            }
118            BundleCommand::Import { input } => {
119                let strategy = LwwStrategy;
120                let report = gn_sync::import_bundle(&repo_root, input, &strategy)?;
121                println!(
122                    "\n\x1b[32m✔ Successfully imported git bundle:\x1b[0m {}",
123                    report.path.display()
124                );
125                println!("  Fetched: {} notes across {} refs", report.fetched, report.refs_count);
126                println!("  Merged:  {} notes (conflict-free LWW)\n", report.merged);
127                Ok(())
128            }
129        },
130        SyncAction::P2p(p2p_args) => {
131            let default_cmd = P2pCommand::Serve {
132                port: p2p_args.port,
133            };
134            let cmd = p2p_args.command.as_ref().unwrap_or(&default_cmd);
135            match cmd {
136                P2pCommand::Serve { port } => gn_sync::serve_p2p(&repo_root, *port),
137                P2pCommand::Connect { peer } => {
138                    let strategy = LwwStrategy;
139                    let report = gn_sync::connect_peer(&repo_root, peer, &strategy)?;
140                    println!(
141                        "\n\x1b[32m✔ Successfully synced with peer {}\x1b[0m",
142                        report.peer
143                    );
144                    println!("  Fetched: {} notes", report.fetched);
145                    println!("  Merged:  {} notes\n", report.merged);
146                    Ok(())
147                }
148            }
149        }
150        SyncAction::Push => {
151            let namespaces = vec![
152                Namespace::Comments,
153                Namespace::Review,
154                Namespace::Todos,
155            ];
156            gn_sync::push::push_notes(&repo_root, &args.remote, &namespaces)?;
157            println!("\x1b[32m✔ Pushed notes to {}\x1b[0m", args.remote);
158            Ok(())
159        }
160        SyncAction::Pull => {
161            let namespaces = vec![
162                Namespace::Comments,
163                Namespace::Review,
164                Namespace::Todos,
165            ];
166            let strategy = LwwStrategy;
167            let report = gn_sync::fetch::fetch_notes(&repo_root, &args.remote, &namespaces, &strategy)?;
168            println!(
169                "\x1b[32m✔ Pulled notes from {}: fetched={} merged={} conflicts={}\x1b[0m",
170                args.remote, report.fetched, report.merged, report.conflicts
171            );
172            Ok(())
173        }
174        SyncAction::Auto => {
175            let namespaces = vec![
176                Namespace::Comments,
177                Namespace::Review,
178                Namespace::Todos,
179            ];
180            let strategy = LwwStrategy;
181            let report = gn_sync::fetch::fetch_notes(&repo_root, &args.remote, &namespaces, &strategy)?;
182            let _ = gn_sync::push::push_notes(&repo_root, &args.remote, &namespaces);
183            println!(
184                "✓ Synced: fetched={} merged={} conflicts={}",
185                report.fetched, report.merged, report.conflicts
186            );
187            Ok(())
188        }
189    }
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use clap::Parser;
196
197    #[derive(Parser)]
198    struct TestCli {
199        #[command(subcommand)]
200        cmd: TestSub,
201    }
202
203    #[derive(Subcommand)]
204    enum TestSub {
205        Sync(SyncArgs),
206    }
207
208    #[test]
209    fn test_sync_cli_bundle_export_parsing() {
210        let cli = TestCli::try_parse_from(["app", "sync", "bundle", "export", "my_notes.bundle"]).unwrap();
211        match cli.cmd {
212            TestSub::Sync(args) => match args.action.unwrap() {
213                SyncAction::Bundle(b) => match b.command {
214                    BundleCommand::Export { output } => {
215                        assert_eq!(output, PathBuf::from("my_notes.bundle"));
216                    }
217                    _ => panic!("Expected Export"),
218                },
219                _ => panic!("Expected Bundle"),
220            },
221        }
222    }
223
224    #[test]
225    fn test_sync_cli_bundle_import_parsing() {
226        let cli = TestCli::try_parse_from(["app", "sync", "bundle", "import", "my_notes.bundle"]).unwrap();
227        match cli.cmd {
228            TestSub::Sync(args) => match args.action.unwrap() {
229                SyncAction::Bundle(b) => match b.command {
230                    BundleCommand::Import { input } => {
231                        assert_eq!(input, PathBuf::from("my_notes.bundle"));
232                    }
233                    _ => panic!("Expected Import"),
234                },
235                _ => panic!("Expected Bundle"),
236            },
237        }
238    }
239
240    #[test]
241    fn test_sync_cli_p2p_serve_parsing() {
242        let cli = TestCli::try_parse_from(["app", "sync", "p2p", "serve", "--port", "9999"]).unwrap();
243        match cli.cmd {
244            TestSub::Sync(args) => match args.action.unwrap() {
245                SyncAction::P2p(p) => match p.command.unwrap() {
246                    P2pCommand::Serve { port } => {
247                        assert_eq!(port, 9999);
248                    }
249                    _ => panic!("Expected Serve"),
250                },
251                _ => panic!("Expected P2p"),
252            },
253        }
254    }
255
256    #[test]
257    fn test_sync_cli_p2p_connect_parsing() {
258        let cli = TestCli::try_parse_from(["app", "sync", "p2p", "connect", "192.168.1.100:9418"]).unwrap();
259        match cli.cmd {
260            TestSub::Sync(args) => match args.action.unwrap() {
261                SyncAction::P2p(p) => match p.command.unwrap() {
262                    P2pCommand::Connect { peer } => {
263                        assert_eq!(peer, "192.168.1.100:9418");
264                    }
265                    _ => panic!("Expected Connect"),
266                },
267                _ => panic!("Expected P2p"),
268            },
269        }
270    }
271}