securegit 0.8.5

Zero-trust git replacement with 12 built-in security scanners, LLM redteam bridge, universal undo, durable backups, and a 50-tool MCP server
Documentation
pub mod bitbucket;
pub mod github;
pub mod gitlab;

use std::fmt;
use thiserror::Error;

#[derive(Error, Debug)]
pub enum SourceError {
    #[error("Invalid source format: {0}")]
    InvalidFormat(String),

    #[error("Unsupported source: {0}")]
    Unsupported(String),

    #[error("Authentication required for private repository")]
    AuthRequired,
}

#[derive(Debug, Clone)]
pub enum RepoSource {
    GitHub {
        owner: String,
        repo: String,
        ref_name: Option<String>,
    },
    GitLab {
        owner: String,
        repo: String,
        ref_name: Option<String>,
    },
    Bitbucket {
        owner: String,
        repo: String,
        ref_name: Option<String>,
    },
    DirectUrl {
        url: String,
    },
}

impl RepoSource {
    pub fn parse(input: &str) -> Result<Self, SourceError> {
        // github:owner/repo[@ref]
        if let Some(rest) = input.strip_prefix("github:") {
            return Self::parse_github_shorthand(rest);
        }

        // gitlab:owner/repo[@ref]
        if let Some(rest) = input.strip_prefix("gitlab:") {
            return Self::parse_gitlab_shorthand(rest);
        }

        // bitbucket:owner/repo[@ref]
        if let Some(rest) = input.strip_prefix("bitbucket:") {
            return Self::parse_bitbucket_shorthand(rest);
        }

        // https://github.com/owner/repo
        if input.starts_with("https://github.com/") {
            return Self::parse_github_url(input);
        }

        // https://gitlab.com/owner/repo
        if input.starts_with("https://gitlab.com/") {
            return Self::parse_gitlab_url(input);
        }

        // Direct URL
        if input.starts_with("http://") || input.starts_with("https://") {
            return Ok(Self::DirectUrl {
                url: input.to_string(),
            });
        }

        Err(SourceError::InvalidFormat(input.to_string()))
    }

    fn parse_github_shorthand(input: &str) -> Result<Self, SourceError> {
        let (repo_part, ref_name) = if let Some(idx) = input.find('@') {
            (&input[..idx], Some(input[idx + 1..].to_string()))
        } else {
            (input, None)
        };

        let parts: Vec<&str> = repo_part.split('/').collect();
        if parts.len() != 2 {
            return Err(SourceError::InvalidFormat(format!(
                "Expected owner/repo, got: {}",
                input
            )));
        }

        Ok(Self::GitHub {
            owner: parts[0].to_string(),
            repo: parts[1].to_string(),
            ref_name,
        })
    }

    fn parse_gitlab_shorthand(input: &str) -> Result<Self, SourceError> {
        let (repo_part, ref_name) = if let Some(idx) = input.find('@') {
            (&input[..idx], Some(input[idx + 1..].to_string()))
        } else {
            (input, None)
        };

        let parts: Vec<&str> = repo_part.split('/').collect();
        if parts.len() < 2 {
            return Err(SourceError::InvalidFormat(format!(
                "Expected owner/repo, got: {}",
                input
            )));
        }

        // Support nested groups: group/subgroup/repo → owner="group/subgroup", repo="repo"
        let repo = parts[parts.len() - 1].to_string();
        let owner = parts[..parts.len() - 1].join("/");

        Ok(Self::GitLab {
            owner,
            repo,
            ref_name,
        })
    }

    fn parse_bitbucket_shorthand(input: &str) -> Result<Self, SourceError> {
        let (repo_part, ref_name) = if let Some(idx) = input.find('@') {
            (&input[..idx], Some(input[idx + 1..].to_string()))
        } else {
            (input, None)
        };

        let parts: Vec<&str> = repo_part.split('/').collect();
        if parts.len() != 2 {
            return Err(SourceError::InvalidFormat(format!(
                "Expected owner/repo, got: {}",
                input
            )));
        }

        Ok(Self::Bitbucket {
            owner: parts[0].to_string(),
            repo: parts[1].to_string(),
            ref_name,
        })
    }

    fn parse_github_url(input: &str) -> Result<Self, SourceError> {
        let url = input.trim_end_matches('/').trim_end_matches(".git");
        let parts: Vec<&str> = url.split('/').collect();

        if parts.len() >= 5 && parts[2] == "github.com" {
            Ok(Self::GitHub {
                owner: parts[3].to_string(),
                repo: parts[4].to_string(),
                ref_name: None,
            })
        } else {
            Err(SourceError::InvalidFormat(input.to_string()))
        }
    }

    fn parse_gitlab_url(input: &str) -> Result<Self, SourceError> {
        let url = input.trim_end_matches('/').trim_end_matches(".git");
        let parts: Vec<&str> = url.split('/').collect();

        // Support nested groups: gitlab.com/group/subgroup/repo → owner="group/subgroup", repo="repo"
        if parts.len() >= 5 && parts[2] == "gitlab.com" {
            let repo = parts[parts.len() - 1].to_string();
            let owner = parts[3..parts.len() - 1].join("/");
            Ok(Self::GitLab {
                owner,
                repo,
                ref_name: None,
            })
        } else {
            Err(SourceError::InvalidFormat(input.to_string()))
        }
    }

    pub fn zip_url(&self) -> Result<String, SourceError> {
        match self {
            Self::GitHub {
                owner,
                repo,
                ref_name,
            } => {
                // GitHub archive API accepts:
                // - HEAD (default branch)
                // - branch names directly (main, master, develop)
                // - tag names (v1.0.0)
                // - commit SHAs
                // Note: Do NOT use refs/heads/ prefix - GitHub returns 404 for that
                let ref_str = ref_name.as_deref().unwrap_or("HEAD");
                Ok(format!(
                    "https://github.com/{}/{}/archive/{}.zip",
                    owner, repo, ref_str
                ))
            }
            Self::GitLab {
                owner,
                repo,
                ref_name,
            } => {
                let ref_str = ref_name.as_deref().unwrap_or("HEAD");
                Ok(format!(
                    "https://gitlab.com/{}/{}/-/archive/{}/{}-{}.zip",
                    owner, repo, ref_str, repo, ref_str
                ))
            }
            Self::Bitbucket {
                owner,
                repo,
                ref_name,
            } => {
                let ref_str = ref_name.as_deref().unwrap_or("main");
                Ok(format!(
                    "https://bitbucket.org/{}/{}/get/{}.zip",
                    owner, repo, ref_str
                ))
            }
            Self::DirectUrl { url } => Ok(url.clone()),
        }
    }

    pub fn host(&self) -> Option<String> {
        match self {
            Self::GitHub { .. } => Some("github.com".to_string()),
            Self::GitLab { .. } => Some("gitlab.com".to_string()),
            Self::Bitbucket { .. } => Some("bitbucket.org".to_string()),
            Self::DirectUrl { url } => url.split('/').nth(2).map(|s| s.to_string()),
        }
    }

    pub fn clone_url(&self) -> Result<String, SourceError> {
        match self {
            Self::GitHub { owner, repo, .. } => {
                Ok(format!("https://github.com/{}/{}.git", owner, repo))
            }
            Self::GitLab { owner, repo, .. } => {
                Ok(format!("https://gitlab.com/{}/{}.git", owner, repo))
            }
            Self::Bitbucket { owner, repo, .. } => {
                Ok(format!("https://bitbucket.org/{}/{}.git", owner, repo))
            }
            Self::DirectUrl { .. } => Err(SourceError::Unsupported(
                "Cannot clone from direct URL".to_string(),
            )),
        }
    }

    /// Get the canonical URL for this repository (for GraphRAG indexing)
    pub fn to_url(&self) -> String {
        match self {
            Self::GitHub { owner, repo, .. } => {
                format!("https://github.com/{}/{}", owner, repo)
            }
            Self::GitLab { owner, repo, .. } => {
                format!("https://gitlab.com/{}/{}", owner, repo)
            }
            Self::Bitbucket { owner, repo, .. } => {
                format!("https://bitbucket.org/{}/{}", owner, repo)
            }
            Self::DirectUrl { url } => url.clone(),
        }
    }

    /// Get HuggingFace model ID if this source is an HF model.
    /// Returns None for all current variants; stub for future HF source support.
    pub fn hf_model_id(&self) -> Option<&str> {
        None
    }

    /// Get the ref name (branch/tag) if specified
    pub fn ref_name(&self) -> Option<&str> {
        match self {
            Self::GitHub { ref_name, .. } => ref_name.as_deref(),
            Self::GitLab { ref_name, .. } => ref_name.as_deref(),
            Self::Bitbucket { ref_name, .. } => ref_name.as_deref(),
            Self::DirectUrl { .. } => None,
        }
    }
}

impl fmt::Display for RepoSource {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::GitHub {
                owner,
                repo,
                ref_name,
            } => {
                write!(f, "github:{}/{}", owner, repo)?;
                if let Some(r) = ref_name {
                    write!(f, "@{}", r)?;
                }
                Ok(())
            }
            Self::GitLab {
                owner,
                repo,
                ref_name,
            } => {
                write!(f, "gitlab:{}/{}", owner, repo)?;
                if let Some(r) = ref_name {
                    write!(f, "@{}", r)?;
                }
                Ok(())
            }
            Self::Bitbucket {
                owner,
                repo,
                ref_name,
            } => {
                write!(f, "bitbucket:{}/{}", owner, repo)?;
                if let Some(r) = ref_name {
                    write!(f, "@{}", r)?;
                }
                Ok(())
            }
            Self::DirectUrl { url } => write!(f, "{}", url),
        }
    }
}