From 2f0d1cee19213c971160d2275f699a7fc4b9ba5a Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 5 Jul 2026 04:57:52 +0900 Subject: [PATCH] feat: add workspace repository registry --- .yoi/workspace-backend.local.toml | 9 + crates/workspace-server/src/config.rs | 135 ++++ crates/workspace-server/src/lib.rs | 4 +- crates/workspace-server/src/repositories.rs | 627 +++++++++--------- crates/workspace-server/src/server.rs | 229 +++++-- resources/workspace-backend.default.toml | 11 + .../lib/workspace-pages/WorkspacePage.svelte | 59 +- .../RepositoriesNavSection.svelte | 6 +- .../src/lib/workspace-sidebar/types.ts | 24 +- .../repositories/[repositoryId]/+page.svelte | 3 +- 10 files changed, 725 insertions(+), 382 deletions(-) create mode 100644 .yoi/workspace-backend.local.toml diff --git a/.yoi/workspace-backend.local.toml b/.yoi/workspace-backend.local.toml new file mode 100644 index 00000000..1c25e850 --- /dev/null +++ b/.yoi/workspace-backend.local.toml @@ -0,0 +1,9 @@ +# Dogfood Workspace Backend configuration. +# Repository URI values are resolved from this workspace config root. + +[[repositories]] +id = "main" +provider = "git" +uri = "." +display_name = "Yoi" +default_selector = "HEAD" diff --git a/crates/workspace-server/src/config.rs b/crates/workspace-server/src/config.rs index fac1ec5f..456d192f 100644 --- a/crates/workspace-server/src/config.rs +++ b/crates/workspace-server/src/config.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::hosts::RemoteRuntimeConfig; use crate::identity::WorkspaceIdentity; +use crate::repositories::ConfiguredRepository; use crate::server::{AuthConfig, ServerConfig}; use crate::{Error, Result}; @@ -26,6 +27,8 @@ pub struct WorkspaceBackendConfigFile { #[serde(default)] pub limits: WorkspaceBackendLimitsConfig, #[serde(default)] + pub repositories: Vec, + #[serde(default)] pub runtimes: WorkspaceBackendRuntimesConfig, } @@ -58,6 +61,18 @@ pub struct WorkspaceBackendLimitsConfig { pub max_records: Option, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceRepositoryConfigFile { + pub id: String, + pub provider: String, + pub uri: String, + #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub default_selector: Option, +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkspaceBackendRuntimesConfig { @@ -264,6 +279,11 @@ impl WorkspaceBackendConfigFile { .map(|path| resolve_workspace_path(workspace_root, path)); server.embedded_runtime_store_root = embedded_runtime_store_root; server.max_records = self.limits.max_records.unwrap_or(DEFAULT_MAX_RECORDS); + server.repositories = self + .repositories + .iter() + .map(|repository| resolve_repository(workspace_root, repository)) + .collect::>>()?; server.remote_runtime_sources = self .runtimes .remote @@ -299,6 +319,70 @@ impl ResolvedWorkspaceBackendConfig { } } +fn resolve_repository( + workspace_root: &Path, + config: &WorkspaceRepositoryConfigFile, +) -> Result { + let id = normalize_required_string("repository id", &config.id)?; + validate_repository_id(&id)?; + let provider = + normalize_required_string("repository provider", &config.provider)?.to_ascii_lowercase(); + let uri = normalize_required_string("repository uri", &config.uri)?; + let path = resolve_repository_uri(workspace_root, &id, &uri)?; + let display_name = normalize_optional_string(config.display_name.as_deref()); + let default_selector = normalize_optional_string(config.default_selector.as_deref()); + + Ok(ConfiguredRepository { + id, + provider, + uri, + path, + display_name, + default_selector, + }) +} + +fn normalize_required_string(field: &str, value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(Error::Config(format!("{field} must not be empty"))); + } + Ok(trimmed.to_string()) +} + +fn normalize_optional_string(value: Option<&str>) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } + }) +} + +fn validate_repository_id(id: &str) -> Result<()> { + if id + .chars() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.')) + { + Ok(()) + } else { + Err(Error::Config(format!( + "repository id `{id}` must contain only ASCII letters, digits, `_`, `-`, or `.`" + ))) + } +} + +fn resolve_repository_uri(workspace_root: &Path, id: &str, uri: &str) -> Result { + if uri.contains("://") { + return Err(Error::Config(format!( + "repository `{id}` uses a remote URI, but remote repository materialization is not implemented" + ))); + } + Ok(resolve_workspace_path(workspace_root, Path::new(uri))) +} + pub(crate) fn resolve_remote_runtime( config: &RemoteRuntimeConfigFile, ) -> Result { @@ -479,6 +563,57 @@ root = ".local-data" assert!(diff.text.contains("127.0.0.1:9999")); } + #[test] + fn resolves_repository_uri_relative_to_workspace_root() { + let dir = tempfile::tempdir().unwrap(); + let config = WorkspaceBackendConfigFile::parse_str( + r#" +[[repositories]] +id = "main" +provider = "git" +uri = "." +display_name = "Main" +default_selector = "HEAD" +"#, + "test", + ) + .unwrap(); + let resolved = config.resolve(dir.path(), identity()).unwrap(); + let repository = resolved.server.repositories.first().unwrap(); + + assert_eq!(repository.id, "main"); + assert_eq!(repository.provider, "git"); + assert_eq!(repository.path, dir.path()); + assert_eq!(repository.display_name.as_deref(), Some("Main")); + assert_eq!(repository.default_selector.as_deref(), Some("HEAD")); + } + + #[test] + fn remote_repository_uri_fails_closed() { + let dir = tempfile::tempdir().unwrap(); + let config = WorkspaceBackendConfigFile::parse_str( + r#" +[[repositories]] +id = "main" +provider = "git" +uri = "https://example.com/org/repo.git" +"#, + "test", + ) + .unwrap(); + let error = match config.resolve(dir.path(), identity()) { + Ok(_) => panic!("remote repository URI should fail closed"), + Err(error) => error, + }; + + assert!( + error + .to_string() + .contains("remote repository materialization is not implemented"), + "unexpected error: {error}" + ); + } + #[test] fn token_value_field_is_not_in_schema() { let error = WorkspaceBackendConfigFile::parse_str( diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 88073049..ad3dfc4b 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -23,8 +23,8 @@ pub use records::{ LocalProjectRecordReader, ObjectiveDetail, ObjectiveSummary, TicketDetail, TicketSummary, }; pub use repositories::{ - GitCommitSummary, GitRemoteSummary, GitRepositorySummary, LocalRepositoryReader, - RepositoryLogRead, RepositorySummary, + ConfiguredRepository, GitCommitSummary, GitRemoteSummary, GitRepositorySummary, + RepositoryLogRead, RepositoryRegistryReader, RepositorySummary, }; pub use server::{AuthConfig, ServerConfig, WorkspaceApi, build_router, serve}; pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord}; diff --git a/crates/workspace-server/src/repositories.rs b/crates/workspace-server/src/repositories.rs index 840149d3..8bc8bfe1 100644 --- a/crates/workspace-server/src/repositories.rs +++ b/crates/workspace-server/src/repositories.rs @@ -1,351 +1,355 @@ -use std::path::{Path, PathBuf}; -use std::process::{Command, Output}; +use std::{collections::BTreeSet, path::PathBuf, process::Command}; use serde::{Deserialize, Serialize}; -use crate::hosts::{DiagnosticSeverity, RuntimeDiagnostic}; +pub type RepositoryId = String; +pub type RepositorySelector = String; -const LEGACY_LOCAL_REPOSITORY_ID: &str = "local"; -const LOCAL_REPOSITORY_PREFIX: &str = "local-"; -const MAX_COMMAND_OUTPUT: usize = 4096; -const DEFAULT_LOG_LIMIT: usize = 10; -const MAX_LOG_LIMIT: usize = 50; -const MAX_FIELD_LEN: usize = 240; - -#[derive(Debug, Clone)] -pub struct LocalRepositoryReader { - workspace_root: PathBuf, - workspace_id: 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, } -impl LocalRepositoryReader { - pub fn new(workspace_root: impl Into, workspace_id: impl Into) -> Self { - Self { - workspace_root: workspace_root.into(), - workspace_id: workspace_id.into(), - } - } - - pub fn list(&self, workspace_display_name: &str) -> Vec { - vec![self.summary(workspace_display_name)] - } - - pub fn summary(&self, workspace_display_name: &str) -> RepositorySummary { - let git = inspect_git(&self.workspace_root); - RepositorySummary { - id: Self::repository_id_for_workspace(&self.workspace_id), - display_name: workspace_display_name.to_string(), - kind: "local".to_string(), - workspace_root: self.workspace_root.clone(), - record_authority: "local_workspace_root".to_string(), - git, - } - } - - pub fn recent_log(&self, requested_limit: Option) -> RepositoryLogRead { - let limit = requested_limit - .unwrap_or(DEFAULT_LOG_LIMIT) - .clamp(1, MAX_LOG_LIMIT); - git_log(&self.workspace_root, limit) - } - - pub fn repository_id_for_workspace(workspace_id: &str) -> String { - format!( - "{LOCAL_REPOSITORY_PREFIX}{}", - sanitize_identifier_fragment(workspace_id) - ) - } - - pub fn is_local_repository_id(id: &str, workspace_id: &str) -> bool { - id == LEGACY_LOCAL_REPOSITORY_ID || id == Self::repository_id_for_workspace(workspace_id) - } -} - -fn sanitize_identifier_fragment(value: &str) -> String { - let mut output = String::with_capacity(value.len()); - let mut previous_dash = false; - for ch in value.chars() { - let mapped = if ch.is_ascii_alphanumeric() { - ch.to_ascii_lowercase() - } else { - '-' - }; - if mapped == '-' { - if !previous_dash { - output.push(mapped); - } - previous_dash = true; - } else { - output.push(mapped); - previous_dash = false; - } - } - let output = output.trim_matches('-').to_string(); - if output.is_empty() { - "workspace".to_string() - } else { - output - } -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RepositorySummary { - pub id: String, + pub id: RepositoryId, pub display_name: String, pub kind: String, - pub workspace_root: PathBuf, + pub provider: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_selector: Option, pub record_authority: String, - pub git: GitRepositorySummary, + #[serde(skip_serializing_if = "Option::is_none")] + pub git: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GitRepositorySummary { pub status: String, - pub root: Option, - pub branch: Option, pub head: Option, - pub dirty: Option, - pub dirty_scope: String, - pub remote: Option, - pub diagnostics: Vec, + pub branch: Option, + pub dirty: bool, + pub remotes: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct GitRemoteSummary { pub name: String, - pub url: String, - pub redacted: bool, + pub fetch_url: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[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 subject: String, + pub short_hash: String, + pub summary: String, pub author_name: String, pub author_email: String, - pub timestamp: String, + pub author_date: String, + pub parents: Vec, + pub refs: Vec, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct RepositoryLogRead { - pub limit: usize, - pub items: Vec, - pub diagnostics: Vec, +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RepositoryLookupError { + UnknownRepository { id: RepositoryId }, + UnsupportedProvider { id: RepositoryId, provider: String }, } -fn inspect_git(workspace_root: &Path) -> GitRepositorySummary { - let mut diagnostics = Vec::new(); - let root = match git_stdout(workspace_root, &["rev-parse", "--show-toplevel"]) { - Ok(root) => PathBuf::from(root.trim()), - Err(message) => { - diagnostics.push(diagnostic( - "git_unavailable", - "info", - format!("Workspace root is not available as a Git repository: {message}"), - )); - return GitRepositorySummary { - status: "unavailable".to_string(), - root: None, - branch: None, - head: None, - dirty: None, - dirty_scope: "tracked_changes_only".to_string(), - remote: None, - diagnostics, +#[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(), + }], }; } - }; - let branch = git_stdout(workspace_root, &["branch", "--show-current"]) - .ok() - .map(|value| truncate_field(value.trim(), MAX_FIELD_LEN)) - .filter(|value| !value.is_empty()) - .or_else(|| Some("detached".to_string())); - let head = match git_stdout(workspace_root, &["rev-parse", "--verify", "HEAD"]) { - Ok(value) => Some(truncate_field(value.trim(), 40)), - Err(message) => { - diagnostics.push(diagnostic( - "git_head_unavailable", - "warn", - format!("Git HEAD summary is unavailable: {message}"), - )); - None + RepositoryListProjection { + items: self + .repositories + .iter() + .map(|repository| self.summary_for_config(repository)) + .collect(), + diagnostics: Vec::new(), } - }; - let dirty = match git_stdout( - workspace_root, - &["status", "--porcelain=v1", "--untracked-files=no"], - ) { - Ok(value) => Some(!value.trim().is_empty()), - Err(message) => { - diagnostics.push(diagnostic( - "git_status_unavailable", - "warn", - format!("Git dirty status is unavailable: {message}"), - )); - None - } - }; - let remote = match git_stdout(workspace_root, &["remote", "get-url", "origin"]) { - Ok(value) => { - let (url, redacted) = sanitize_remote_url(value.trim()); - Some(GitRemoteSummary { - name: "origin".to_string(), - url, - redacted, - }) - } - Err(_) => { - diagnostics.push(diagnostic( - "git_origin_remote_missing", - "info", - "No origin remote is configured or visible through the bounded Git summary." - .to_string(), - )); - None - } - }; - - GitRepositorySummary { - status: "available".to_string(), - root: Some(root), - branch, - head, - dirty, - dirty_scope: "tracked_changes_only".to_string(), - remote, - diagnostics, - } -} - -fn git_log(workspace_root: &Path, limit: usize) -> RepositoryLogRead { - let mut diagnostics = Vec::new(); - if let Err(message) = git_stdout(workspace_root, &["rev-parse", "--show-toplevel"]) { - diagnostics.push(diagnostic( - "git_unavailable", - "info", - format!("Recent Git log is unavailable for this local repository: {message}"), - )); - return RepositoryLogRead { - limit, - items: Vec::new(), - diagnostics, - }; } - match git_stdout( - workspace_root, - &[ - "log", - "--no-show-signature", - "--date=iso-strict", - "--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%s%x1e", - "-n", - &limit.to_string(), - ], - ) { - Ok(output) => RepositoryLogRead { - limit, - items: parse_log(output.as_str()), - diagnostics, - }, - Err(message) => { - diagnostics.push(diagnostic( - "git_log_unavailable", - "warn", - format!("Recent Git log is unavailable: {message}"), - )); - RepositoryLogRead { - limit, - items: Vec::new(), - diagnostics, + 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 parse_log(output: &str) -> Vec { - output - .split('\u{1e}') +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 record = record.trim_matches('\n'); - if record.is_empty() { + let trimmed = record.trim_matches('\n').trim_end(); + if trimmed.is_empty() { return None; } - let mut fields = record.split('\u{1f}'); + 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: truncate_field(fields.next()?, 40), - author_name: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN), - author_email: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN), - timestamp: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN), - subject: truncate_field(fields.next().unwrap_or_default(), MAX_FIELD_LEN), + hash, + short_hash, + summary, + author_name, + author_email, + author_date, + parents, + refs, }) }) .collect() } -fn git_stdout(workspace_root: &Path, args: &[&str]) -> Result { - let output = Command::new("git") - .arg("-C") - .arg(workspace_root) - .args(args) - .output() - .map_err(|error| truncate_field(&error.to_string(), MAX_FIELD_LEN))?; - command_stdout(output) +fn sanitize_remote_url(url: &str) -> String { + let Some((scheme, rest)) = url.split_once("://") else { + return url.to_string(); + }; + let Some((_credentials, host_path)) = rest.split_once('@') else { + return url.to_string(); + }; + format!("{scheme}://@{host_path}") } -fn command_stdout(output: Output) -> Result { - if output.status.success() { - return Ok(truncate_output( - String::from_utf8_lossy(&output.stdout).as_ref(), - )); - } - let stderr = truncate_output(String::from_utf8_lossy(&output.stderr).as_ref()); - if stderr.trim().is_empty() { - Err(format!("git exited with status {}", output.status)) +fn non_empty_string(value: &str) -> Option { + if value.is_empty() { + None } else { - Err(stderr.trim().to_string()) - } -} - -fn sanitize_remote_url(raw: &str) -> (String, bool) { - let bounded = truncate_field(raw, MAX_FIELD_LEN); - let Some(separator) = bounded.find("://") else { - return (bounded, false); - }; - let scheme_end = separator + 3; - let after_scheme = &bounded[scheme_end..]; - let Some(at_index) = after_scheme.find('@') else { - return (bounded, false); - }; - let host_and_path = &after_scheme[(at_index + 1)..]; - (format!("{}{}", &bounded[..scheme_end], host_and_path), true) -} - -fn truncate_output(value: &str) -> String { - truncate_field(value, MAX_COMMAND_OUTPUT) -} - -fn truncate_field(value: &str, limit: usize) -> String { - if value.len() <= limit { - return value.to_string(); - } - let mut end = limit; - while !value.is_char_boundary(end) { - end -= 1; - } - value[..end].to_string() -} - -fn diagnostic(code: &str, severity: &str, message: String) -> RuntimeDiagnostic { - RuntimeDiagnostic { - code: code.to_string(), - severity: match severity { - "error" => DiagnosticSeverity::Error, - "warning" => DiagnosticSeverity::Warning, - _ => DiagnosticSeverity::Info, - }, - message, + Some(value.to_string()) } } @@ -354,24 +358,35 @@ mod tests { use super::*; #[test] - fn sanitizes_userinfo_from_url_remotes() { + fn sanitizes_remote_credentials() { assert_eq!( - sanitize_remote_url("https://token@example.com/org/repo.git"), - ("https://example.com/org/repo.git".to_string(), true) + 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".to_string(), false) + "git@example.com:org/repo.git" ); } #[test] - fn parses_bounded_git_log_records() { - let parsed = parse_log( - "0123456789abcdef\u{1f}Alice\u{1f}a@example.test\u{1f}2026-01-01T00:00:00+00:00\u{1f}Subject\u{1e}\n", + 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() + } ); - assert_eq!(parsed.len(), 1); - assert_eq!(parsed[0].hash, "0123456789abcdef"); - assert_eq!(parsed[0].subject, "Subject"); } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 8dec228e..f76f7578 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -35,7 +35,10 @@ use crate::observation::{ use crate::records::{ LocalProjectRecordReader, ObjectiveDetail, ProjectRecordList, TicketDetail, TicketSummary, }; -use crate::repositories::{LocalRepositoryReader, RepositoryLogRead, RepositorySummary}; +use crate::repositories::{ + ConfiguredRepository, RepositoryListProjection, RepositoryLogRead, RepositoryLookupError, + RepositoryRegistryReader, RepositorySummary, +}; use crate::store::{ControlPlaneStore, WorkspaceRecord}; use crate::{Error, Result}; use worker_runtime::catalog::{ConfigBundleRef, ProfileSelector}; @@ -68,6 +71,7 @@ pub struct ServerConfig { pub static_assets_dir: Option, pub auth: AuthConfig, pub max_records: usize, + pub repositories: Vec, pub runtime_event_sources: Vec, pub remote_runtime_sources: Vec, } @@ -89,6 +93,7 @@ impl ServerConfig { token_configured: false, }, max_records: 200, + repositories: Vec::new(), runtime_event_sources: Vec::new(), remote_runtime_sources: Vec::new(), } @@ -202,19 +207,8 @@ impl WorkspaceApi { self.config.workspace_id.as_str() } - fn local_repository_reader(&self) -> LocalRepositoryReader { - LocalRepositoryReader::new( - self.config.workspace_root.clone(), - self.config.workspace_id.clone(), - ) - } - - fn local_repository_id(&self) -> String { - LocalRepositoryReader::repository_id_for_workspace(self.workspace_id()) - } - - fn workspace_display_name(&self) -> &str { - self.config.workspace_display_name.as_str() + fn repository_reader(&self) -> RepositoryRegistryReader { + RepositoryRegistryReader::new(self.config.repositories.clone()) } } @@ -479,6 +473,8 @@ pub struct RepositoryDetailResponse { pub struct RepositoryLogResponse { pub workspace_id: String, pub repository_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub default_selector: Option, pub limit: usize, pub items: Vec, pub diagnostics: Vec, @@ -633,13 +629,12 @@ async fn get_objective( async fn list_repositories( State(api): State, ) -> ApiResult> { - let reader = api.local_repository_reader(); - let items = reader.list(api.workspace_display_name()); + let RepositoryListProjection { items, diagnostics } = api.repository_reader().list(); Ok(Json(RepositoryListResponse { workspace_id: api.config.workspace_id, items, - source: "local_workspace_root".to_string(), - diagnostics: Vec::new(), + source: "workspace_backend_config".to_string(), + diagnostics: repository_diagnostics(diagnostics), })) } @@ -647,12 +642,11 @@ async fn repository_detail( State(api): State, AxumPath(repository_id): AxumPath, ) -> ApiResult> { - let _canonical_repository_id = ensure_local_repository(&api, &repository_id)?; - let reader = api.local_repository_reader(); + let item = repository_lookup(api.repository_reader().summary(&repository_id))?; Ok(Json(RepositoryDetailResponse { workspace_id: api.config.workspace_id.clone(), - item: reader.summary(api.workspace_display_name()), - source: "local_workspace_root".to_string(), + item, + source: "workspace_backend_config".to_string(), })) } @@ -661,18 +655,23 @@ async fn repository_log( AxumPath(repository_id): AxumPath, Query(query): Query, ) -> ApiResult> { - let canonical_repository_id = ensure_local_repository(&api, &repository_id)?; let RepositoryLogRead { + repository_id, + default_selector, limit, - items, + commits, diagnostics, - } = api.local_repository_reader().recent_log(query.limit); + } = repository_lookup( + api.repository_reader() + .recent_log(&repository_id, query.limit), + )?; Ok(Json(RepositoryLogResponse { workspace_id: api.config.workspace_id, - repository_id: canonical_repository_id, + repository_id, + default_selector, limit, - items, - diagnostics, + items: commits, + diagnostics: repository_diagnostics(diagnostics), })) } @@ -681,7 +680,8 @@ async fn repository_tickets( AxumPath(repository_id): AxumPath, Query(query): Query, ) -> ApiResult> { - let canonical_repository_id = ensure_local_repository(&api, &repository_id)?; + repository_lookup(api.repository_reader().summary(&repository_id))?; + let canonical_repository_id = repository_id; let limit = query.limit.unwrap_or(api.config.max_records).min(200); let ProjectRecordList { items, @@ -2056,13 +2056,58 @@ fn sanitize_backend_error(_message: &str) -> String { "operation failed; backend-private details were omitted".to_string() } -fn ensure_local_repository(api: &WorkspaceApi, repository_id: &str) -> Result { - let canonical_repository_id = api.local_repository_id(); - if LocalRepositoryReader::is_local_repository_id(repository_id, api.workspace_id()) { - Ok(canonical_repository_id) - } else { - Err(Error::UnknownRepository(repository_id.to_string())) - } +fn repository_diagnostics( + diagnostics: Vec, +) -> Vec { + diagnostics + .into_iter() + .map(|diagnostic| RuntimeDiagnostic { + code: diagnostic.code, + severity: match diagnostic.severity.as_str() { + "error" => DiagnosticSeverity::Error, + "warning" => DiagnosticSeverity::Warning, + _ => DiagnosticSeverity::Info, + }, + message: diagnostic.message, + }) + .collect() +} + +fn repository_lookup(result: std::result::Result) -> ApiResult { + result.map_err(|error| match error { + RepositoryLookupError::UnknownRepository { id } => { + let message = format!("repository `{id}` is not configured for this workspace"); + ApiError::with_diagnostics( + Error::RuntimeOperationFailed { + runtime_id: "workspace-repository-registry".to_string(), + code: "repository_not_configured".to_string(), + message: message.clone(), + }, + vec![RuntimeDiagnostic { + code: "repository_not_configured".to_string(), + severity: DiagnosticSeverity::Error, + message, + }], + ) + } + RepositoryLookupError::UnsupportedProvider { id, provider } => { + let message = format!( + "repository `{id}` uses unsupported provider `{provider}` for this operation" + ); + ApiError::with_diagnostics( + Error::RuntimeOperationFailed { + runtime_id: "workspace-repository-registry".to_string(), + code: "repository_provider_unsupported".to_string(), + message: message.clone(), + }, + vec![RuntimeDiagnostic { + code: "repository_provider_unsupported".to_string(), + severity: DiagnosticSeverity::Error, + message, + }], + ) + } + }) } fn ticket_kanban_columns(items: Vec) -> Vec { @@ -2231,6 +2276,14 @@ impl IntoResponse for ApiError { | Error::UnknownRepository(_) => StatusCode::NOT_FOUND, Error::Ticket(_) => StatusCode::NOT_FOUND, Error::RuntimeCapabilityUnsupported { .. } => StatusCode::NOT_IMPLEMENTED, + Error::RuntimeOperationFailed { code, .. } if code == "repository_not_configured" => { + StatusCode::NOT_FOUND + } + Error::RuntimeOperationFailed { code, .. } + if code == "repository_provider_unsupported" => + { + StatusCode::BAD_REQUEST + } Error::RuntimeOperationFailed { code, .. } if code == "remote_runtime_auth_failed" => { StatusCode::UNAUTHORIZED } @@ -2290,7 +2343,7 @@ mod tests { use crate::store::SqliteWorkspaceStore; const TEST_WORKSPACE_ID: &str = "0192f0e8-4d84-7d6e-a000-000000000001"; - const TEST_REPOSITORY_ID: &str = "local-0192f0e8-4d84-7d6e-a000-000000000001"; + const TEST_REPOSITORY_ID: &str = "main"; const TEST_CREATED_AT: &str = "2026-06-23T06:43:28Z"; #[test] @@ -2402,8 +2455,17 @@ mod tests { fn test_server_config(workspace_root: impl Into) -> ServerConfig { let workspace_root = workspace_root.into(); let store_root = workspace_root.join(".test-embedded-runtime-store"); - ServerConfig::local_dev(workspace_root, test_identity()) - .with_embedded_runtime_store_root(store_root) + let mut config = ServerConfig::local_dev(workspace_root.clone(), test_identity()) + .with_embedded_runtime_store_root(store_root); + config.repositories = vec![ConfiguredRepository { + id: TEST_REPOSITORY_ID.to_string(), + provider: "git".to_string(), + uri: ".".to_string(), + path: workspace_root, + display_name: Some("Test Repository".to_string()), + default_selector: Some("HEAD".to_string()), + }]; + config } async fn test_app(workspace_root: impl Into) -> Router { @@ -2861,16 +2923,26 @@ mod tests { let repositories = get_json(app.clone(), "/api/repositories").await; assert_eq!(repositories["items"][0]["id"], TEST_REPOSITORY_ID); - assert_eq!(repositories["items"][0]["kind"], "local"); + assert_eq!(repositories["items"][0]["kind"], "git"); + assert_eq!( + repositories["items"][0]["record_authority"], + "workspace-backend-config" + ); + assert!( + repositories + .to_string() + .contains("repository_git_unavailable") + ); - let repository_detail = get_json(app.clone(), "/api/repositories/local").await; + let repository_detail = get_json(app.clone(), "/api/repositories/main").await; assert_eq!(repository_detail["item"]["id"], TEST_REPOSITORY_ID); - let repository_log = get_json(app.clone(), "/api/repositories/local/log?limit=3").await; + let repository_log = get_json(app.clone(), "/api/repositories/main/log?limit=3").await; assert_eq!(repository_log["repository_id"], TEST_REPOSITORY_ID); + assert_eq!(repository_log["default_selector"], "HEAD"); assert_eq!(repository_log["limit"], 3); - let repository_tickets = get_json(app.clone(), "/api/repositories/local/tickets").await; + let repository_tickets = get_json(app.clone(), "/api/repositories/main/tickets").await; assert_eq!(repository_tickets["repository_id"], TEST_REPOSITORY_ID); let ready_column = repository_tickets["columns"] .as_array() @@ -3324,6 +3396,77 @@ mod tests { } } + #[tokio::test] + async fn empty_repository_config_returns_empty_list_with_warning() { + let root = tempfile::tempdir().unwrap(); + let mut config = test_server_config(root.path()); + config.repositories.clear(); + let api = WorkspaceApi::new_with_execution_backend( + config, + Arc::new(SqliteWorkspaceStore::in_memory().unwrap()), + Arc::new(DeterministicExecutionBackend::default()), + ) + .await + .unwrap(); + let app = build_router(api); + + let repositories = get_json(app, "/api/repositories").await; + + assert!(repositories["items"].as_array().unwrap().is_empty()); + assert_eq!( + repositories["diagnostics"][0]["code"], + "repository_config_empty" + ); + } + + #[tokio::test] + async fn repository_log_rejects_unknown_or_unsupported_configured_repository() { + let root = tempfile::tempdir().unwrap(); + let mut config = test_server_config(root.path()); + config.repositories = vec![ConfiguredRepository { + id: "files".to_string(), + provider: "local_fs".to_string(), + uri: ".".to_string(), + path: root.path().to_path_buf(), + display_name: None, + default_selector: None, + }]; + let api = WorkspaceApi::new_with_execution_backend( + config, + Arc::new(SqliteWorkspaceStore::in_memory().unwrap()), + Arc::new(DeterministicExecutionBackend::default()), + ) + .await + .unwrap(); + let app = build_router(api); + + let unknown = request_json( + app.clone(), + "GET", + "/api/repositories/main/log", + None, + StatusCode::NOT_FOUND, + ) + .await; + assert_eq!( + unknown["diagnostics"][0]["code"], + "repository_not_configured" + ); + + let unsupported = request_json( + app, + "GET", + "/api/repositories/files/log", + None, + StatusCode::BAD_REQUEST, + ) + .await; + assert_eq!( + unsupported["diagnostics"][0]["code"], + "repository_provider_unsupported" + ); + } + #[tokio::test] async fn embedded_runtime_api_routes_by_runtime_and_worker_ids_without_leaking_internals() { let dir = tempfile::tempdir().unwrap(); diff --git a/resources/workspace-backend.default.toml b/resources/workspace-backend.default.toml index 8c75c4ed..921d420d 100644 --- a/resources/workspace-backend.default.toml +++ b/resources/workspace-backend.default.toml @@ -41,6 +41,17 @@ frontend_url = "http://127.0.0.1:5173" [limits] max_records = 200 +# Repository registry. Browser/API repository projection reads only configured +# entries and never falls back to the backend process cwd. Relative URI values +# are resolved from this workspace config root. Git is the v0 supported provider. +# +# [[repositories]] +# id = "main" +# provider = "git" +# uri = "." +# display_name = "Main repository" +# default_selector = "HEAD" + # Remote Runtime sources. Token values must not be written here. # Use token_ref only after secret-ref resolution is implemented for this config. # diff --git a/web/workspace/src/lib/workspace-pages/WorkspacePage.svelte b/web/workspace/src/lib/workspace-pages/WorkspacePage.svelte index 90cbc418..85aa6876 100644 --- a/web/workspace/src/lib/workspace-pages/WorkspacePage.svelte +++ b/web/workspace/src/lib/workspace-pages/WorkspacePage.svelte @@ -17,14 +17,15 @@ type WorkspaceView = 'overview' | 'repository' | 'objectives' | 'objective'; type RouteState = - | { page: 'overview'; objectiveId?: undefined } - | { page: 'repository'; objectiveId?: undefined } - | { page: 'objectives'; objectiveId?: undefined } - | { page: 'objective'; objectiveId: string }; + | { page: 'overview'; objectiveId?: undefined; repositoryId?: undefined } + | { page: 'repository'; repositoryId: string; objectiveId?: undefined } + | { page: 'objectives'; objectiveId?: undefined; repositoryId?: undefined } + | { page: 'objective'; objectiveId: string; repositoryId?: undefined }; let { view = 'overview', - objectiveId = null + objectiveId = null, + repositoryId = 'main' }: { view?: WorkspaceView; repositoryId?: string; objectiveId?: string | null } = $props(); let workspace = $state(null); @@ -44,7 +45,7 @@ let objectiveDetailError = $state(null); let objectiveDetailLoading = $state(false); let objectiveDetailRequest = 0; - let route = $derived(routeFromView(view, objectiveId)); + let route = $derived(routeFromView(view, objectiveId, repositoryId)); let currentPath = $derived(pathFromRoute(route)); async function getJson(path: string): Promise { @@ -87,8 +88,11 @@ async function loadRepository() { repositoryError = null; + const selectedRepositoryId = route.page === 'repository' ? route.repositoryId : repositoryId; try { - const detail = await getJson('/api/repositories/local'); + const detail = await getJson( + `/api/repositories/${encodeURIComponent(selectedRepositoryId)}` + ); repository = detail.item; } catch (error) { repositoryError = error instanceof Error ? error.message : String(error); @@ -98,8 +102,11 @@ async function loadRepositoryTickets() { repositoryTicketsError = null; + const selectedRepositoryId = route.page === 'repository' ? route.repositoryId : repositoryId; try { - repositoryTickets = await getJson('/api/repositories/local/tickets'); + repositoryTickets = await getJson( + `/api/repositories/${encodeURIComponent(selectedRepositoryId)}/tickets` + ); } catch (error) { repositoryTicketsError = error instanceof Error ? error.message : String(error); repositoryTickets = null; @@ -137,9 +144,13 @@ } } - function routeFromView(view: WorkspaceView, objectiveId: string | null): RouteState { + function routeFromView( + view: WorkspaceView, + objectiveId: string | null, + repositoryId: string + ): RouteState { if (view === 'repository') { - return { page: 'repository' }; + return { page: 'repository', repositoryId }; } if (view === 'objective' && objectiveId) { return { page: 'objective', objectiveId }; @@ -152,7 +163,7 @@ function pathFromRoute(route: RouteState): string { if (route.page === 'repository') { - return '/repositories/local'; + return `/repositories/${route.repositoryId}`; } if (route.page === 'objective') { return `/objectives/${route.objectiveId}`; @@ -216,8 +227,12 @@
{repository.kind}
-
Workspace root
-
{repository.workspace_root}
+
Provider
+
{repository.provider}
+
+
+
Default selector
+
{repository.default_selector ?? 'none configured'}
Record authority
@@ -225,13 +240,25 @@
Git
-
{repository.git.status}
+
{repository.git?.status ?? 'not available'}
+ {#if repository.diagnostics && repository.diagnostics.length > 0} +
+
Diagnostics
+
+
    + {#each repository.diagnostics as diagnostic} +
  • {diagnostic.code}: {diagnostic.message}
  • + {/each} +
+
+
+ {/if} {:else if repositoryError}

{repositoryError}

{:else} -

Waiting for /api/repositories/local

+

Waiting for /api/repositories/{route.repositoryId}

{/if} @@ -245,7 +272,7 @@ {:else if repositoryTicketsError}

{repositoryTicketsError}

{:else} -

Waiting for /api/repositories/local/tickets

+

Waiting for /api/repositories/{route.repositoryId}/tickets

{/if} diff --git a/web/workspace/src/lib/workspace-sidebar/RepositoriesNavSection.svelte b/web/workspace/src/lib/workspace-sidebar/RepositoriesNavSection.svelte index 62aa83c7..33e3dd15 100644 --- a/web/workspace/src/lib/workspace-sidebar/RepositoriesNavSection.svelte +++ b/web/workspace/src/lib/workspace-sidebar/RepositoriesNavSection.svelte @@ -17,9 +17,9 @@ diff --git a/web/workspace/src/lib/workspace-sidebar/types.ts b/web/workspace/src/lib/workspace-sidebar/types.ts index f18ec385..d8a9fe05 100644 --- a/web/workspace/src/lib/workspace-sidebar/types.ts +++ b/web/workspace/src/lib/workspace-sidebar/types.ts @@ -181,34 +181,35 @@ export type RepositorySummary = { id: string; display_name: string; kind: string; - workspace_root: string; + provider: string; + default_selector?: string | null; record_authority: string; - git: GitRepositorySummary; + git?: GitRepositorySummary | null; + diagnostics?: Diagnostic[]; }; export type GitRepositorySummary = { status: string; - root?: string | null; branch?: string | null; head?: string | null; - dirty?: boolean | null; - dirty_scope: string; - remote?: GitRemoteSummary | null; - diagnostics: Diagnostic[]; + dirty: boolean; + remotes: GitRemoteSummary[]; }; export type GitRemoteSummary = { name: string; - url: string; - redacted: boolean; + fetch_url: string; }; export type GitCommitSummary = { hash: string; - subject: string; + short_hash: string; + summary: string; author_name: string; author_email: string; - timestamp: string; + author_date: string; + parents: string[]; + refs: string[]; }; export type RepositoryDetailResponse = { @@ -220,6 +221,7 @@ export type RepositoryDetailResponse = { export type RepositoryLogResponse = { workspace_id: string; repository_id: string; + default_selector?: string | null; limit: number; items: GitCommitSummary[]; diagnostics: Diagnostic[]; diff --git a/web/workspace/src/routes/repositories/[repositoryId]/+page.svelte b/web/workspace/src/routes/repositories/[repositoryId]/+page.svelte index e15fdf2b..3721c380 100644 --- a/web/workspace/src/routes/repositories/[repositoryId]/+page.svelte +++ b/web/workspace/src/routes/repositories/[repositoryId]/+page.svelte @@ -1,5 +1,6 @@ - +