437 lines
14 KiB
Rust
437 lines
14 KiB
Rust
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<String>,
|
|
pub default_selector: Option<RepositorySelector>,
|
|
}
|
|
|
|
#[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<RepositorySelector>,
|
|
pub record_authority: String,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub git: Option<GitRepositorySummary>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
pub diagnostics: Vec<RepositoryDiagnostic>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct GitRepositorySummary {
|
|
pub status: String,
|
|
pub head: Option<String>,
|
|
pub branch: Option<String>,
|
|
pub dirty: bool,
|
|
pub remotes: Vec<GitRemoteSummary>,
|
|
}
|
|
|
|
#[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<RepositorySummary>,
|
|
pub diagnostics: Vec<RepositoryDiagnostic>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct RepositoryLogRead {
|
|
pub repository_id: RepositoryId,
|
|
pub default_selector: Option<RepositorySelector>,
|
|
pub limit: usize,
|
|
pub commits: Vec<GitCommitSummary>,
|
|
pub diagnostics: Vec<RepositoryDiagnostic>,
|
|
}
|
|
|
|
#[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<String>,
|
|
pub refs: Vec<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum RepositoryLookupError {
|
|
UnknownRepository { id: RepositoryId },
|
|
UnsupportedProvider { id: RepositoryId, provider: String },
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct RepositoryRegistryReader {
|
|
repositories: Vec<ConfiguredRepository>,
|
|
}
|
|
|
|
impl RepositoryRegistryReader {
|
|
pub fn new(repositories: Vec<ConfiguredRepository>) -> 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<RepositorySummary, RepositoryLookupError> {
|
|
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<usize>,
|
|
) -> Result<RepositoryLogRead, RepositoryLookupError> {
|
|
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<GitRepositorySummary, String> {
|
|
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<Vec<GitCommitSummary>, 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<String, String>
|
|
where
|
|
I: IntoIterator<Item = &'a str>,
|
|
{
|
|
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<GitRemoteSummary> {
|
|
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<GitCommitSummary> {
|
|
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 "<redacted-local-path>".to_string();
|
|
}
|
|
|
|
let Some((scheme, rest)) = trimmed.split_once("://") else {
|
|
return trimmed.to_string();
|
|
};
|
|
if scheme.eq_ignore_ascii_case("file") {
|
|
return "file://<redacted-local-path>".to_string();
|
|
}
|
|
let Some((_credentials, host_path)) = rest.split_once('@') else {
|
|
return trimmed.to_string();
|
|
};
|
|
format!("{scheme}://<redacted>@{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<String> {
|
|
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://<redacted>@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"),
|
|
"<redacted-local-path>"
|
|
);
|
|
assert_eq!(
|
|
sanitize_remote_url("/Users/alice/private/repo.git"),
|
|
"<redacted-local-path>"
|
|
);
|
|
assert_eq!(
|
|
sanitize_remote_url("C:\\Users\\alice\\private\\repo.git"),
|
|
"<redacted-local-path>"
|
|
);
|
|
assert_eq!(
|
|
sanitize_remote_url("file:///home/alice/private/repo.git"),
|
|
"file://<redacted-local-path>"
|
|
);
|
|
assert_eq!(
|
|
sanitize_remote_url("file://localhost/home/alice/private/repo.git"),
|
|
"file://<redacted-local-path>"
|
|
);
|
|
}
|
|
|
|
#[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()
|
|
}
|
|
);
|
|
}
|
|
}
|