use std::{ collections::BTreeSet, path::{Path, PathBuf}, process::Command, }; use serde::{Deserialize, Serialize}; pub type RepositoryId = String; pub type RepositorySelector = String; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ConfiguredRepository { pub id: RepositoryId, pub provider: String, pub uri: String, pub path: PathBuf, pub display_name: Option, pub default_selector: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RepositorySummary { pub id: RepositoryId, pub display_name: String, pub kind: String, pub provider: String, #[serde(skip_serializing_if = "Option::is_none")] pub default_selector: Option, pub record_authority: String, #[serde(skip_serializing_if = "Option::is_none")] pub git: Option, #[serde(skip_serializing_if = "Vec::is_empty")] pub diagnostics: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GitRepositorySummary { pub status: String, pub head: Option, pub branch: Option, pub dirty: bool, pub remotes: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GitRemoteSummary { pub name: String, pub fetch_url: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RepositoryDiagnostic { pub severity: String, pub code: String, pub message: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct RepositoryListProjection { pub items: Vec, pub diagnostics: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RepositoryLogRead { pub repository_id: RepositoryId, pub default_selector: Option, pub limit: usize, pub commits: Vec, pub diagnostics: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GitCommitSummary { pub hash: String, pub short_hash: String, pub summary: String, pub author_name: String, pub author_email: String, pub author_date: String, pub parents: Vec, pub refs: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub enum RepositoryLookupError { UnknownRepository { id: RepositoryId }, UnsupportedProvider { id: RepositoryId, provider: String }, } #[derive(Debug, Clone)] pub struct RepositoryRegistryReader { repositories: Vec, } impl RepositoryRegistryReader { pub fn new(repositories: Vec) -> Self { Self { repositories } } pub fn list(&self) -> RepositoryListProjection { if self.repositories.is_empty() { return RepositoryListProjection { items: Vec::new(), diagnostics: vec![RepositoryDiagnostic { severity: "warning".to_string(), code: "repository_config_empty".to_string(), message: "No repositories are configured for this workspace backend." .to_string(), }], }; } RepositoryListProjection { items: self .repositories .iter() .map(|repository| self.summary_for_config(repository)) .collect(), diagnostics: Vec::new(), } } pub fn summary(&self, id: &str) -> Result { let repository = self .find(id) .ok_or_else(|| RepositoryLookupError::UnknownRepository { id: id.to_string() })?; Ok(self.summary_for_config(repository)) } pub fn recent_log( &self, id: &str, limit: Option, ) -> Result { let repository = self .find(id) .ok_or_else(|| RepositoryLookupError::UnknownRepository { id: id.to_string() })?; if repository.provider != "git" { return Err(RepositoryLookupError::UnsupportedProvider { id: repository.id.clone(), provider: repository.provider.clone(), }); } let limit = limit.unwrap_or(40).clamp(1, 200); let mut diagnostics = Vec::new(); let commits = match self.git_log(repository, limit) { Ok(commits) => commits, Err(message) => { diagnostics.push(RepositoryDiagnostic { severity: "warning".to_string(), code: "repository_git_log_unavailable".to_string(), message, }); Vec::new() } }; Ok(RepositoryLogRead { repository_id: repository.id.clone(), default_selector: repository.default_selector.clone(), limit, commits, diagnostics, }) } fn find(&self, id: &str) -> Option<&ConfiguredRepository> { self.repositories .iter() .find(|repository| repository.id == id) } fn summary_for_config(&self, repository: &ConfiguredRepository) -> RepositorySummary { let display_name = repository .display_name .clone() .unwrap_or_else(|| repository.id.clone()); let mut diagnostics = Vec::new(); let git = match repository.provider.as_str() { "git" => match self.inspect_git(repository) { Ok(git) => Some(git), Err(message) => { diagnostics.push(RepositoryDiagnostic { severity: "warning".to_string(), code: "repository_git_unavailable".to_string(), message, }); None } }, provider => { diagnostics.push(RepositoryDiagnostic { severity: "warning".to_string(), code: "repository_provider_unsupported".to_string(), message: format!( "Repository provider `{provider}` is configured but is not supported by the workspace backend API." ), }); None } }; RepositorySummary { id: repository.id.clone(), display_name, kind: repository.provider.clone(), provider: repository.provider.clone(), default_selector: repository.default_selector.clone(), record_authority: "workspace-backend-config".to_string(), git, diagnostics, } } fn inspect_git( &self, repository: &ConfiguredRepository, ) -> Result { let head = git_stdout(&repository.path, ["rev-parse", "HEAD"])?; let branch = git_stdout(&repository.path, ["branch", "--show-current"]) .ok() .and_then(|value| non_empty_string(value.trim())); let status = git_stdout(&repository.path, ["status", "--porcelain"])?; let remotes = git_stdout(&repository.path, ["remote", "-v"]) .map(|raw| parse_remotes(&raw)) .unwrap_or_default(); Ok(GitRepositorySummary { status: "available".to_string(), head: non_empty_string(head.trim()), branch, dirty: !status.trim().is_empty(), remotes, }) } fn git_log( &self, repository: &ConfiguredRepository, limit: usize, ) -> Result, String> { let limit_arg = format!("-{limit}"); let output = git_stdout( &repository.path, [ "log", "--date=iso-strict", "--decorate=short", "--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%aI%x1f%P%x1f%D%x1e", limit_arg.as_str(), ], )?; Ok(parse_git_log(&output)) } } fn git_stdout<'a, I>(repository_path: &PathBuf, args: I) -> Result where I: IntoIterator, { let output = Command::new("git") .arg("-C") .arg(repository_path) .args(args) .output() .map_err(|_| { "Git command could not be executed; backend-private path details were omitted." .to_string() })?; if !output.status.success() { return Err("Git command failed; backend-private path details were omitted.".to_string()); } Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } fn parse_remotes(raw: &str) -> Vec { let mut seen = BTreeSet::new(); let mut remotes = Vec::new(); for line in raw.lines() { let mut parts = line.split_whitespace(); let Some(name) = parts.next() else { continue; }; let Some(url) = parts.next() else { continue; }; if !seen.insert((name.to_string(), url.to_string())) { continue; } remotes.push(GitRemoteSummary { name: name.to_string(), fetch_url: sanitize_remote_url(url), }); } remotes } fn parse_git_log(raw: &str) -> Vec { raw.split('\u{1e}') .filter_map(|record| { let trimmed = record.trim_matches('\n').trim_end(); if trimmed.is_empty() { return None; } let mut fields = trimmed.split('\u{1f}'); let hash = fields.next()?.to_string(); let short_hash = fields.next().unwrap_or_default().to_string(); let summary = fields.next().unwrap_or_default().to_string(); let author_name = fields.next().unwrap_or_default().to_string(); let author_email = fields.next().unwrap_or_default().to_string(); let author_date = fields.next().unwrap_or_default().to_string(); let parents = fields .next() .unwrap_or_default() .split_whitespace() .map(ToString::to_string) .collect(); let refs = fields .next() .unwrap_or_default() .split(',') .map(str::trim) .filter(|reference| !reference.is_empty()) .map(ToString::to_string) .collect(); Some(GitCommitSummary { hash, short_hash, summary, author_name, author_email, author_date, parents, refs, }) }) .collect() } fn sanitize_remote_url(url: &str) -> String { let trimmed = url.trim(); if is_local_path_like(trimmed) { return "".to_string(); } let Some((scheme, rest)) = trimmed.split_once("://") else { return trimmed.to_string(); }; if scheme.eq_ignore_ascii_case("file") { return "file://".to_string(); } let Some((_credentials, host_path)) = rest.split_once('@') else { return trimmed.to_string(); }; format!("{scheme}://@{host_path}") } fn is_local_path_like(value: &str) -> bool { Path::new(value).is_absolute() || is_windows_absolute_path_like(value) } fn is_windows_absolute_path_like(value: &str) -> bool { let bytes = value.as_bytes(); bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && matches!(bytes[2], b'\\' | b'/') } fn non_empty_string(value: &str) -> Option { if value.is_empty() { None } else { Some(value.to_string()) } } #[cfg(test)] mod tests { use super::*; #[test] fn sanitizes_remote_credentials_and_local_paths() { assert_eq!( sanitize_remote_url("https://user:token@example.com/org/repo.git"), "https://@example.com/org/repo.git" ); assert_eq!( sanitize_remote_url("git@example.com:org/repo.git"), "git@example.com:org/repo.git" ); assert_eq!( sanitize_remote_url("/home/alice/private/repo.git"), "" ); assert_eq!( sanitize_remote_url("/Users/alice/private/repo.git"), "" ); assert_eq!( sanitize_remote_url("C:\\Users\\alice\\private\\repo.git"), "" ); assert_eq!( sanitize_remote_url("file:///home/alice/private/repo.git"), "file://" ); assert_eq!( sanitize_remote_url("file://localhost/home/alice/private/repo.git"), "file://" ); } #[test] fn empty_registry_reports_diagnostic_without_implicit_repository() { let projection = RepositoryRegistryReader::new(Vec::new()).list(); assert!(projection.items.is_empty()); assert_eq!(projection.diagnostics.len(), 1); assert_eq!(projection.diagnostics[0].code, "repository_config_empty"); } #[test] fn unknown_repository_is_not_resolved_from_fallback() { let reader = RepositoryRegistryReader::new(Vec::new()); assert_eq!( reader.summary("main").unwrap_err(), RepositoryLookupError::UnknownRepository { id: "main".to_string() } ); } }