Skip to main content

torii_lib/workspace/
mirror.rs

1use std::path::{Path, PathBuf};
2use std::fs;
3use serde::{Deserialize, Serialize};
4use crate::error::{Result, ToriiError};
5use crate::core::GitRepo;
6use crate::duration::format_duration;
7use dirs;
8
9#[derive(Debug, Serialize, Clone, PartialEq)]
10pub enum MirrorType {
11    Primary,
12    Replica,
13}
14
15impl<'de> serde::Deserialize<'de> for MirrorType {
16    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
17        let s = String::deserialize(d)?;
18        match s.as_str() {
19            "Primary" | "Master" => Ok(MirrorType::Primary),
20            "Replica" | "Slave" => Ok(MirrorType::Replica),
21            other => Err(serde::de::Error::unknown_variant(other, &["Primary", "Replica"])),
22        }
23    }
24}
25
26#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
27pub enum AccountType {
28    User,
29    Organization,
30}
31
32#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)]
33pub enum Protocol {
34    SSH,
35    HTTPS,
36}
37
38#[derive(Debug, Serialize, Deserialize, Clone)]
39pub struct Mirror {
40    pub name: String,
41    pub platform: String,
42    pub account_type: AccountType,
43    pub account_name: String,
44    pub repo_name: String,
45    pub url: String,
46    pub protocol: Protocol,
47    pub mirror_type: MirrorType,
48    pub enabled: bool,
49}
50
51#[derive(Debug, Serialize, Deserialize)]
52struct MirrorConfig {
53    mirrors: Vec<Mirror>,
54    #[serde(default)]
55    autofetch_enabled: bool,
56    #[serde(default = "default_autofetch_interval")]
57    autofetch_interval_minutes: u32,
58}
59
60fn default_autofetch_interval() -> u32 {
61    30 // Default: 30 minutes
62}
63
64impl Mirror {
65    /// Generate URL based on platform, account info, and protocol
66    pub fn generate_url(
67        platform: &str, 
68        _account_type: &AccountType, 
69        account_name: &str, 
70        repo_name: &str,
71        protocol: &Protocol,
72    ) -> String {
73        match protocol {
74            Protocol::SSH => {
75                match platform.to_lowercase().as_str() {
76                    "github" => format!("git@github.com:{}/{}.git", account_name, repo_name),
77                    "gitlab" => format!("git@gitlab.com:{}/{}.git", account_name, repo_name),
78                    "bitbucket" => format!("git@bitbucket.org:{}/{}.git", account_name, repo_name),
79                    "codeberg" => format!("git@codeberg.org:{}/{}.git", account_name, repo_name),
80                    "gitea" => format!("git@gitea.com:{}/{}.git", account_name, repo_name),
81                    "forgejo" => format!("git@codeberg.org:{}/{}.git", account_name, repo_name),
82                    "sourcehut" | "srht" => format!("git@git.sr.ht:~{}/{}", account_name, repo_name),
83                    "sourceforge" => format!("git@git.code.sf.net:p/{}/{}", account_name, repo_name),
84                    _ => format!("git@{}:{}/{}.git", platform, account_name, repo_name),
85                }
86            }
87            Protocol::HTTPS => {
88                match platform.to_lowercase().as_str() {
89                    "github" => format!("https://github.com/{}/{}.git", account_name, repo_name),
90                    "gitlab" => format!("https://gitlab.com/{}/{}.git", account_name, repo_name),
91                    "bitbucket" => format!("https://bitbucket.org/{}/{}.git", account_name, repo_name),
92                    "codeberg" => format!("https://codeberg.org/{}/{}.git", account_name, repo_name),
93                    "gitea" => format!("https://gitea.com/{}/{}.git", account_name, repo_name),
94                    "forgejo" => format!("https://codeberg.org/{}/{}.git", account_name, repo_name),
95                    "sourcehut" | "srht" => format!("https://git.sr.ht/~{}/{}", account_name, repo_name),
96                    "sourceforge" => format!("https://git.code.sf.net/p/{}/{}", account_name, repo_name),
97                    _ => format!("https://{}/{}/{}.git", platform, account_name, repo_name),
98                }
99            }
100        }
101    }
102    
103    /// Get display name for the mirror
104    #[allow(dead_code)]
105    pub fn display_name(&self) -> String {
106        format!("{}/{}", self.account_name, self.repo_name)
107    }
108}
109
110pub struct MirrorManager {
111    repo_path: PathBuf,
112    config_path: PathBuf,
113}
114
115impl MirrorManager {
116    pub fn new<P: AsRef<Path>>(repo_path: P) -> Result<Self> {
117        let repo_path = repo_path.as_ref().to_path_buf();
118        let torii_dir = repo_path.join(".torii");
119        fs::create_dir_all(&torii_dir)?;
120        
121        let config_path = torii_dir.join("mirrors.json");
122
123        Ok(Self {
124            repo_path,
125            config_path,
126        })
127    }
128
129    /// Load mirror configuration
130    fn load_config(&self) -> Result<MirrorConfig> {
131        if !self.config_path.exists() {
132            return Ok(MirrorConfig { 
133                mirrors: vec![],
134                autofetch_enabled: false,
135                autofetch_interval_minutes: 30,
136            });
137        }
138
139        let config_str = fs::read_to_string(&self.config_path)?;
140        let config: MirrorConfig = serde_json::from_str(&config_str)?;
141        
142        Ok(config)
143    }
144
145    /// Save mirror configuration
146    fn save_config(&self, config: &MirrorConfig) -> Result<()> {
147        let config_str = serde_json::to_string_pretty(config)?;
148        fs::write(&self.config_path, config_str)?;
149        Ok(())
150    }
151
152    /// Add a new mirror with simplified interface
153    pub fn add_mirror(
154        &self,
155        platform: &str,
156        account_type: AccountType,
157        account_name: &str,
158        repo_name: &str,
159        protocol: Protocol,
160        is_primary: bool,
161    ) -> Result<()> {
162        let mut config = self.load_config()?;
163        
164        // Check if master already exists
165        if is_primary && config.mirrors.iter().any(|m| m.mirror_type == MirrorType::Primary) {
166            return Err(ToriiError::Mirror(
167                "A primary mirror already exists. Use 'torii mirror promote' to change it.".to_string()
168            ));
169        }
170        
171        // Generate URL automatically
172        let url = Mirror::generate_url(platform, &account_type, account_name, repo_name, &protocol);
173        
174        // Generate remote name
175        let remote_name = if is_primary {
176            "origin".to_string()
177        } else {
178            format!("{}-{}", platform, account_name)
179        };
180
181        let mirror = Mirror {
182            name: remote_name.clone(),
183            platform: platform.to_string(),
184            account_type,
185            account_name: account_name.to_string(),
186            repo_name: repo_name.to_string(),
187            url: url.clone(),
188            protocol,
189            mirror_type: if is_primary { MirrorType::Primary } else { MirrorType::Replica },
190            enabled: true,
191        };
192
193        // Add the git remote first โ€” if that fails (invalid URL, libgit2
194        // error) we haven't yet polluted mirrors.json. Then persist the
195        // config; if THAT fails (disk full, perms), roll the remote back
196        // so disk state matches mirrors.json.
197        let repo = GitRepo::open(&self.repo_path)?;
198        self.add_git_remote(&repo, &remote_name, &url)?;
199
200        config.mirrors.push(mirror);
201        if let Err(e) = self.save_config(&config) {
202            let _ = repo.repository().remote_delete(&remote_name);
203            return Err(e);
204        }
205
206        Ok(())
207    }
208    
209    /// Set a mirror as master
210    pub fn set_primary(&self, platform: &str, account_name: &str) -> Result<()> {
211        let mut config = self.load_config()?;
212        
213        // Find the mirror
214        let mirror_index = config.mirrors.iter().position(|m| {
215            m.platform == platform && m.account_name == account_name
216        }).ok_or_else(|| ToriiError::Mirror("Mirror not found".to_string()))?;
217        
218        // Set all to replica
219        for mirror in &mut config.mirrors {
220            mirror.mirror_type = MirrorType::Replica;
221        }
222
223        // Set selected as primary
224        config.mirrors[mirror_index].mirror_type = MirrorType::Primary;
225        
226        self.save_config(&config)?;
227        Ok(())
228    }
229
230    /// Add git remote
231    fn add_git_remote(&self, repo: &GitRepo, name: &str, url: &str) -> Result<()> {
232        repo.repository().remote(name, url)?;
233        Ok(())
234    }
235
236    /// List all mirrors
237    pub fn list_mirrors(&self) -> Result<()> {
238        let config = self.load_config()?;
239
240        if config.mirrors.is_empty() {
241            println!("No mirrors configured");
242            println!();
243            println!("๐Ÿ’ก Add a primary mirror first:");
244            println!("   torii mirror add <platform> <user|org> <account> <repo> --primary");
245            return Ok(());
246        }
247
248        println!("๐Ÿชž Configured Mirrors:");
249        println!();
250
251        // Show primary first
252        for mirror in config.mirrors.iter().filter(|m| m.mirror_type == MirrorType::Primary) {
253            let status = if mirror.enabled { "โœ…" } else { "โŒ" };
254            let account_type = match mirror.account_type {
255                AccountType::User => "๐Ÿ‘ค",
256                AccountType::Organization => "๐Ÿข",
257            };
258            let protocol_icon = match mirror.protocol {
259                Protocol::SSH => "๐Ÿ”‘",
260                Protocol::HTTPS => "๐ŸŒ",
261            };
262            println!("  {} ๐Ÿ‘‘ PRIMARY - {} {} {} {}/{}",
263                status, 
264                protocol_icon,
265                account_type,
266                mirror.platform,
267                mirror.account_name,
268                mirror.repo_name
269            );
270            println!("     {}", mirror.url);
271            println!();
272        }
273
274        // Show replicas
275        let replicas: Vec<_> = config.mirrors.iter()
276            .filter(|m| m.mirror_type == MirrorType::Replica)
277            .collect();
278
279        if !replicas.is_empty() {
280            println!("  Replica Mirrors:");
281            for mirror in replicas {
282                let status = if mirror.enabled { "โœ…" } else { "โŒ" };
283                let account_type = match mirror.account_type {
284                    AccountType::User => "๐Ÿ‘ค",
285                    AccountType::Organization => "๐Ÿข",
286                };
287                let protocol_icon = match mirror.protocol {
288                    Protocol::SSH => "๐Ÿ”‘",
289                    Protocol::HTTPS => "๐ŸŒ",
290                };
291                println!("    {} {} {} {} {}/{}", 
292                    status,
293                    protocol_icon,
294                    account_type,
295                    mirror.platform,
296                    mirror.account_name,
297                    mirror.repo_name
298                );
299                println!("       {}", mirror.url);
300            }
301        }
302
303        Ok(())
304    }
305
306    /// Sync replicas silently if any are configured โ€” called automatically by `torii sync`
307    pub fn sync_replicas_if_any(&self, force: bool) -> Result<()> {
308        let config = self.load_config()?;
309        let replicas: Vec<_> = config.mirrors.iter()
310            .filter(|m| m.mirror_type == MirrorType::Replica && m.enabled)
311            .collect();
312        if replicas.is_empty() {
313            return Ok(());
314        }
315        let repo = GitRepo::open(&self.repo_path)?;
316        let mut failed = vec![];
317        for mirror in &replicas {
318            if let Err(e) = self.sync_to_mirror(&repo, mirror, force) {
319                failed.push(format!("{}/{}: {}", mirror.platform, mirror.account_name, e));
320            }
321        }
322        let ok = replicas.len() - failed.len();
323        if ok > 0 {
324            println!("๐Ÿชž Mirrors synced: {}/{}", ok, replicas.len());
325        }
326        for f in &failed {
327            eprintln!("โš ๏ธ  Mirror sync failed: {}", f);
328        }
329        Ok(())
330    }
331
332    /// Sync to all replica mirrors (push from master)
333    pub fn sync_all(&self, force: bool) -> Result<()> {
334        let config = self.load_config()?;
335        let repo = GitRepo::open(&self.repo_path)?;
336
337        // Find primary mirror
338        let primary = config.mirrors.iter()
339            .find(|m| m.mirror_type == MirrorType::Primary);
340
341        if primary.is_none() {
342            println!("โš ๏ธ  No primary mirror configured. Add one with:");
343            println!("   torii mirror add-primary <platform> <user|org> <account> <repo>");
344            return Ok(());
345        }
346
347        // Get replica mirrors
348        let replicas: Vec<_> = config.mirrors.iter()
349            .filter(|m| m.mirror_type == MirrorType::Replica && m.enabled)
350            .collect();
351
352        if replicas.is_empty() {
353            println!("โ„น๏ธ  No replica mirrors configured. Add one with:");
354            println!("   torii mirror add-replica <platform> <user|org> <account> <repo>");
355            return Ok(());
356        }
357
358        println!("๐Ÿ“ค Syncing from primary to {} replica mirror(s)...\n", replicas.len());
359
360        let mut success_count = 0;
361        let mut fail_count = 0;
362
363        for mirror in replicas {
364            println!("๐Ÿ”„ Syncing to {} {}/{} ...", 
365                mirror.platform, 
366                mirror.account_name, 
367                mirror.repo_name
368            );
369            
370            match self.sync_to_mirror(&repo, mirror, force) {
371                Ok(_) => {
372                    println!("  โœ… Synced successfully\n");
373                    success_count += 1;
374                }
375                Err(e) => {
376                    eprintln!("  โŒ Failed: {}\n", e);
377                    fail_count += 1;
378                    if !force {
379                        return Err(e);
380                    }
381                }
382            }
383        }
384
385        println!("๐Ÿ“Š Summary: {} succeeded, {} failed", success_count, fail_count);
386        Ok(())
387    }
388
389    /// Sync to a specific mirror
390    fn sync_to_mirror(&self, repo: &GitRepo, mirror: &Mirror, force: bool) -> Result<()> {
391        let mut remote = repo.repository().find_remote(&mirror.name)?;
392        let branch = repo.get_current_branch()?;
393        
394        let refspec = if force {
395            format!("+refs/heads/{}:refs/heads/{}", branch, branch)
396        } else {
397            format!("refs/heads/{}:refs/heads/{}", branch, branch)
398        };
399
400        // Setup SSH callbacks โ€” ed25519, then rsa, then agent
401        let mut callbacks = git2::RemoteCallbacks::new();
402        callbacks.credentials(|_url, username_from_url, _allowed_types| {
403            let username = username_from_url.unwrap_or("git");
404            let home = dirs::home_dir().unwrap_or_default();
405            let ed25519 = home.join(".ssh").join("id_ed25519");
406            let rsa = home.join(".ssh").join("id_rsa");
407            if ed25519.exists() {
408                git2::Cred::ssh_key(username, None, &ed25519, None)
409            } else if rsa.exists() {
410                git2::Cred::ssh_key(username, None, &rsa, None)
411            } else {
412                git2::Cred::ssh_key_from_agent(username)
413            }
414        });
415
416        let mut push_options = git2::PushOptions::new();
417        push_options.remote_callbacks(callbacks);
418
419        remote.push(&[&refspec], Some(&mut push_options))?;
420
421        // Push tags via git2 โ€” but only the ones whose OID differs from
422        // what the mirror already has. Pre-0.7.8 this re-pushed every
423        // local tag on every replica sync, which made GitLab retrigger
424        // its workflow:rules for every historical tag (see the matching
425        // comment in `core.rs::push_all_tags`). One extra
426        // ls-remote round-trip per replica saves N stale pipelines.
427        let tags = repo.repository().tag_names(None)?;
428        if !tags.is_empty() {
429            let local: std::collections::HashMap<String, git2::Oid> = tags.iter()
430                .flatten()
431                .filter_map(|t| {
432                    let refname = format!("refs/tags/{}", t);
433                    repo.repository().refname_to_id(&refname).ok().map(|oid| (t.to_string(), oid))
434                })
435                .collect();
436
437            let mut tag_remote = repo.repository().find_remote(&mirror.name)?;
438
439            let make_ssh_callbacks = || {
440                let mut cb = git2::RemoteCallbacks::new();
441                cb.credentials(|_url, username_from_url, _allowed_types| {
442                    let username = username_from_url.unwrap_or("git");
443                    let home = dirs::home_dir().unwrap_or_default();
444                    let ed25519 = home.join(".ssh").join("id_ed25519");
445                    let rsa = home.join(".ssh").join("id_rsa");
446                    if ed25519.exists() {
447                        git2::Cred::ssh_key(username, None, &ed25519, None)
448                    } else if rsa.exists() {
449                        git2::Cred::ssh_key(username, None, &rsa, None)
450                    } else {
451                        git2::Cred::ssh_key_from_agent(username)
452                    }
453                });
454                cb
455            };
456
457            // ls-remote on the mirror to learn what tags are already there.
458            let remote_tags: std::collections::HashMap<String, git2::Oid> = {
459                let cb = make_ssh_callbacks();
460                tag_remote.connect_auth(git2::Direction::Fetch, Some(cb), None)?;
461                let list = tag_remote.list()?;
462                let map = list.iter()
463                    .filter_map(|h| {
464                        h.name().strip_prefix("refs/tags/")
465                            .filter(|n| !n.ends_with("^{}"))
466                            .map(|n| (n.to_string(), h.oid()))
467                    })
468                    .collect::<std::collections::HashMap<_, _>>();
469                let _ = tag_remote.disconnect();
470                map
471            };
472
473            let refspecs: Vec<String> = local.iter()
474                .filter(|(name, oid)| remote_tags.get(*name) != Some(oid))
475                .map(|(t, _)| {
476                    let r = format!("refs/tags/{}:refs/tags/{}", t, t);
477                    if force { format!("+{}", r) } else { r }
478                })
479                .collect();
480
481            if !refspecs.is_empty() {
482                let refspec_refs: Vec<&str> = refspecs.iter().map(|s| s.as_str()).collect();
483                let mut tag_push_opts = git2::PushOptions::new();
484                tag_push_opts.remote_callbacks(make_ssh_callbacks());
485                let _ = tag_remote.push(&refspec_refs, Some(&mut tag_push_opts));
486            }
487        }
488
489        Ok(())
490    }
491
492    /// Remove a mirror by platform and account
493    pub fn remove_mirror_by_account(&self, platform: &str, account: &str) -> Result<()> {
494        let mut config = self.load_config()?;
495        
496        let mirror = config.mirrors.iter()
497            .find(|m| m.platform == platform && m.account_name == account)
498            .ok_or_else(|| ToriiError::Mirror("Mirror not found".to_string()))?;
499        
500        let remote_name = mirror.name.clone();
501        
502        config.mirrors.retain(|m| !(m.platform == platform && m.account_name == account));
503        self.save_config(&config)?;
504
505        let repo = GitRepo::open(&self.repo_path)?;
506        repo.repository().remote_delete(&remote_name)?;
507
508        Ok(())
509    }
510    
511    /// Remove a mirror by name (legacy)
512    #[allow(dead_code)]
513    pub fn remove_mirror(&self, name: &str) -> Result<()> {
514        let mut config = self.load_config()?;
515        
516        config.mirrors.retain(|m| m.name != name);
517        self.save_config(&config)?;
518
519        let repo = GitRepo::open(&self.repo_path)?;
520        repo.repository().remote_delete(name)?;
521
522        Ok(())
523    }
524
525    /// Configure autofetch settings
526    pub fn configure_autofetch(&self, enable: bool, interval: Option<u32>) -> Result<()> {
527        let mut config = self.load_config()?;
528        
529        config.autofetch_enabled = enable;
530        if let Some(interval_minutes) = interval {
531            config.autofetch_interval_minutes = interval_minutes;
532        }
533        
534        self.save_config(&config)?;
535        
536        if enable {
537            let duration_str = format_duration(config.autofetch_interval_minutes);
538            println!("โœ… Autofetch enabled: every {}", duration_str);
539            println!("๐Ÿ’ก Torii will automatically fetch updates from all mirrors");
540        } else {
541            println!("โŒ Autofetch disabled");
542        }
543        
544        Ok(())
545    }
546
547    /// Show autofetch status
548    pub fn show_autofetch_status(&self) -> Result<()> {
549        let config = self.load_config()?;
550        
551        println!("๐Ÿ”„ Autofetch Configuration:");
552        println!();
553        
554        if config.autofetch_enabled {
555            let duration_str = format_duration(config.autofetch_interval_minutes);
556            println!("  Status: โœ… Enabled");
557            println!("  Interval: {}", duration_str);
558            println!();
559            println!("๐Ÿ’ก Torii will automatically fetch from all mirrors every {}", duration_str);
560        } else {
561            println!("  Status: โŒ Disabled");
562            println!();
563            println!("๐Ÿ’ก Enable with:");
564            println!("   torii mirror autofetch --enable --interval <duration>");
565            println!("   Examples: 10m, 30s, 2h, 1d");
566        }
567        
568        Ok(())
569    }
570}