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 #[arg(long)]
14 pub p2p: bool,
15
16 #[arg(short, long, default_value = "origin")]
18 pub remote: String,
19}
20
21#[derive(Subcommand, Debug, Clone)]
22pub enum SyncAction {
23 Push,
25 Pull,
27 Auto,
29 Bundle(BundleArgs),
31 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 Export {
45 output: PathBuf,
47 },
48 Import {
50 input: PathBuf,
52 },
53}
54
55#[derive(Args, Debug, Clone)]
56pub struct P2pArgs {
57 #[command(subcommand)]
58 pub command: Option<P2pCommand>,
59
60 #[arg(short, long, default_value_t = 9418)]
62 pub port: u16,
63}
64
65#[derive(Subcommand, Debug, Clone)]
66pub enum P2pCommand {
67 Serve {
69 #[arg(short, long, default_value_t = 9418)]
71 port: u16,
72 },
73 Connect {
75 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 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}