merge: repository registry implementation

This commit is contained in:
Keisuke Hirata 2026-07-05 05:18:01 +09:00
commit a786fd85a7
No known key found for this signature in database
14 changed files with 952 additions and 402 deletions

View File

@ -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"

View File

@ -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<WorkspaceRepositoryConfigFile>,
#[serde(default)]
pub runtimes: WorkspaceBackendRuntimesConfig,
}
@ -58,6 +61,18 @@ pub struct WorkspaceBackendLimitsConfig {
pub max_records: Option<usize>,
}
#[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<String>,
#[serde(default)]
pub default_selector: Option<String>,
}
#[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::<Result<Vec<_>>>()?;
server.remote_runtime_sources = self
.runtimes
.remote
@ -299,6 +319,70 @@ impl ResolvedWorkspaceBackendConfig {
}
}
fn resolve_repository(
workspace_root: &Path,
config: &WorkspaceRepositoryConfigFile,
) -> Result<ConfiguredRepository> {
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<String> {
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<String> {
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<PathBuf> {
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<RemoteRuntimeConfig> {
@ -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(

View File

@ -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};

View File

@ -1,351 +1,379 @@
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::{
collections::BTreeSet,
path::{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<String>,
pub default_selector: Option<RepositorySelector>,
}
impl LocalRepositoryReader {
pub fn new(workspace_root: impl Into<PathBuf>, workspace_id: impl Into<String>) -> Self {
Self {
workspace_root: workspace_root.into(),
workspace_id: workspace_id.into(),
}
}
pub fn list(&self, workspace_display_name: &str) -> Vec<RepositorySummary> {
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<usize>) -> 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<RepositorySelector>,
pub record_authority: String,
pub git: GitRepositorySummary,
#[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, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct GitRepositorySummary {
pub status: String,
pub root: Option<PathBuf>,
pub branch: Option<String>,
pub head: Option<String>,
pub dirty: Option<bool>,
pub dirty_scope: String,
pub remote: Option<GitRemoteSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
pub branch: Option<String>,
pub dirty: bool,
pub remotes: Vec<GitRemoteSummary>,
}
#[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<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 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<String>,
pub refs: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RepositoryLogRead {
pub limit: usize,
pub items: Vec<GitCommitSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
#[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<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(),
}],
};
}
};
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<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 parse_log(output: &str) -> Vec<GitCommitSummary> {
output
.split('\u{1e}')
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 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<String, String> {
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 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 command_stdout(output: Output) -> Result<String, String> {
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 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 {
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 +382,55 @@ mod tests {
use super::*;
#[test]
fn sanitizes_userinfo_from_url_remotes() {
fn sanitizes_remote_credentials_and_local_paths() {
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://<redacted>@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"
);
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 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");
}
}

View File

@ -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<PathBuf>,
pub auth: AuthConfig,
pub max_records: usize,
pub repositories: Vec<ConfiguredRepository>,
pub runtime_event_sources: Vec<RuntimeObservationSourceConfig>,
pub remote_runtime_sources: Vec<RemoteRuntimeConfig>,
}
@ -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<String>,
pub limit: usize,
pub items: Vec<crate::repositories::GitCommitSummary>,
pub diagnostics: Vec<RuntimeDiagnostic>,
@ -633,13 +629,12 @@ async fn get_objective(
async fn list_repositories(
State(api): State<WorkspaceApi>,
) -> ApiResult<Json<RepositoryListResponse>> {
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<WorkspaceApi>,
AxumPath(repository_id): AxumPath<String>,
) -> ApiResult<Json<RepositoryDetailResponse>> {
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<String>,
Query(query): Query<LogQuery>,
) -> ApiResult<Json<RepositoryLogResponse>> {
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<String>,
Query(query): Query<TicketKanbanQuery>,
) -> ApiResult<Json<RepositoryTicketsResponse>> {
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<String> {
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<crate::repositories::RepositoryDiagnostic>,
) -> Vec<RuntimeDiagnostic> {
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<T>(result: std::result::Result<T, RepositoryLookupError>) -> ApiResult<T> {
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<TicketSummary>) -> Vec<TicketKanbanColumn> {
@ -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<PathBuf>) -> 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<PathBuf>) -> 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();

View File

@ -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.
#

View File

@ -6,7 +6,7 @@
"dev": "deno run -A npm:vite@7.2.7 dev",
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server -- serve --workspace . --db .yoi/workspace.db --listen 127.0.0.1:8787",
"check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json",
"test": "deno test --allow-read=src src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts",
"test": "deno test --allow-read=src src/lib/workspace-console/model.test.ts src/lib/workspace-console/worker-console.ui.test.ts src/lib/workspace-settings/model.test.ts src/lib/workspace-sidebar/worker-launch.test.ts src/lib/workspace-sidebar/repository-nav.test.ts",
"build": "deno run -A npm:vite@7.2.7 build",
"preview": "deno run -A npm:vite@7.2.7 preview"
},

View File

@ -8,6 +8,7 @@
ObjectiveDetail,
ObjectiveListResponse,
RepositoryDetailResponse,
RepositoryListResponse,
RepositorySummary,
RepositoryTicketsResponse,
Worker,
@ -17,19 +18,21 @@
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
}: { view?: WorkspaceView; repositoryId?: string; objectiveId?: string | null } = $props();
objectiveId = null,
repositoryId = null
}: { view?: WorkspaceView; repositoryId?: string | null; objectiveId?: string | null } = $props();
let workspace = $state<WorkspaceResponse | null>(null);
let hosts = $state<ListResponse<Host> | null>(null);
let workers = $state<ListResponse<Worker> | null>(null);
let repositories = $state<RepositoryListResponse | null>(null);
let repository = $state<RepositorySummary | null>(null);
let repositoryTickets = $state<RepositoryTicketsResponse | null>(null);
let objectives = $state<ObjectiveListResponse | null>(null);
@ -38,13 +41,14 @@
let workspaceError = $state<string | null>(null);
let hostsError = $state<string | null>(null);
let workersError = $state<string | null>(null);
let repositoriesError = $state<string | null>(null);
let repositoryError = $state<string | null>(null);
let repositoryTicketsError = $state<string | null>(null);
let objectivesError = $state<string | null>(null);
let objectiveDetailError = $state<string | null>(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<T>(path: string): Promise<T> {
@ -85,10 +89,22 @@
}
}
async function loadRepository() {
async function loadRepositories() {
repositoriesError = null;
try {
repositories = await getJson<RepositoryListResponse>('/api/repositories');
} catch (error) {
repositoriesError = error instanceof Error ? error.message : String(error);
repositories = null;
}
}
async function loadRepository(id: string) {
repositoryError = null;
try {
const detail = await getJson<RepositoryDetailResponse>('/api/repositories/local');
const detail = await getJson<RepositoryDetailResponse>(
`/api/repositories/${encodeURIComponent(id)}`
);
repository = detail.item;
} catch (error) {
repositoryError = error instanceof Error ? error.message : String(error);
@ -96,10 +112,12 @@
}
}
async function loadRepositoryTickets() {
async function loadRepositoryTickets(id: string) {
repositoryTicketsError = null;
try {
repositoryTickets = await getJson<RepositoryTicketsResponse>('/api/repositories/local/tickets');
repositoryTickets = await getJson<RepositoryTicketsResponse>(
`/api/repositories/${encodeURIComponent(id)}/tickets`
);
} catch (error) {
repositoryTicketsError = error instanceof Error ? error.message : String(error);
repositoryTickets = null;
@ -137,9 +155,13 @@
}
}
function routeFromView(view: WorkspaceView, objectiveId: string | null): RouteState {
if (view === 'repository') {
return { page: 'repository' };
function routeFromView(
view: WorkspaceView,
objectiveId: string | null,
repositoryId: string | null
): RouteState {
if (view === 'repository' && repositoryId) {
return { page: 'repository', repositoryId };
}
if (view === 'objective' && objectiveId) {
return { page: 'objective', objectiveId };
@ -152,7 +174,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}`;
@ -171,11 +193,22 @@
void loadWorkspace();
void loadHosts();
void loadWorkers();
void loadRepository();
void loadRepositoryTickets();
void loadRepositories();
void loadObjectives();
});
$effect(() => {
if (route.page === 'repository') {
void loadRepository(route.repositoryId);
void loadRepositoryTickets(route.repositoryId);
} else {
repository = null;
repositoryTickets = null;
repositoryError = null;
repositoryTicketsError = null;
}
});
$effect(() => {
const selectedObjectiveId = route.page === 'objective' ? route.objectiveId : null;
if (selectedObjectiveId) {
@ -199,7 +232,7 @@
</svelte:head>
<div class="workspace-layout">
<WorkspaceSidebar {workspace} {workspaceError} {currentPath} />
<WorkspaceSidebar {workspace} {workspaceError} {repositories} {repositoriesError} {currentPath} />
<main class="shell">
{#if route.page === 'repository'}
@ -216,8 +249,12 @@
<dd>{repository.kind}</dd>
</div>
<div>
<dt>Workspace root</dt>
<dd><code>{repository.workspace_root}</code></dd>
<dt>Provider</dt>
<dd>{repository.provider}</dd>
</div>
<div>
<dt>Default selector</dt>
<dd>{repository.default_selector ?? 'none configured'}</dd>
</div>
<div>
<dt>Record authority</dt>
@ -225,13 +262,25 @@
</div>
<div>
<dt>Git</dt>
<dd>{repository.git.status}</dd>
<dd>{repository.git?.status ?? 'not available'}</dd>
</div>
{#if repository.diagnostics && repository.diagnostics.length > 0}
<div>
<dt>Diagnostics</dt>
<dd>
<ul>
{#each repository.diagnostics as diagnostic}
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
{/each}
</ul>
</dd>
</div>
{/if}
</dl>
{:else if repositoryError}
<p class="error">{repositoryError}</p>
{:else}
<p>Waiting for <code>/api/repositories/local</code></p>
<p>Waiting for <code>/api/repositories/{route.repositoryId}</code></p>
{/if}
</section>
@ -245,7 +294,7 @@
{:else if repositoryTicketsError}
<p class="error">{repositoryTicketsError}</p>
{:else}
<p>Waiting for <code>/api/repositories/local/tickets</code></p>
<p>Waiting for <code>/api/repositories/{route.repositoryId}/tickets</code></p>
{/if}
</section>

View File

@ -1,26 +1,46 @@
<script lang="ts">
import type { WorkspaceResponse } from './types';
import { projectRepositoryNav } from './repository-nav';
import type { RepositoryListResponse } from './types';
type Props = {
workspace: WorkspaceResponse | null;
repositories: RepositoryListResponse | null;
repositoriesError?: string | null;
currentPath?: string;
};
let { workspace, currentPath = '/' }: Props = $props();
let { repositories, repositoriesError = null, currentPath = '/' }: Props = $props();
let navigation = $derived(projectRepositoryNav(repositories, currentPath));
</script>
<section class="nav-section" aria-labelledby="repositories-heading">
<div class="section-heading-row">
<h2 id="repositories-heading">repositories</h2>
<span class="section-count">1</span>
<span class="section-count">{navigation.count}</span>
</div>
<ul class="nav-list" aria-label="Repositories">
<li>
<a class="nav-item" class:active={currentPath.startsWith('/repositories')} href="/repositories/local">
<span class="item-title">{workspace?.display_name ?? 'local workspace'}</span>
<span class="item-meta">local repository · read-only</span>
</a>
</li>
</ul>
{#if repositoriesError}
<p class="nav-empty error">Repository registry unavailable.</p>
{:else if !repositories}
<p class="nav-empty">Loading repositories…</p>
{:else if navigation.items.length === 0}
<p class="nav-empty">No repositories configured.</p>
{#if navigation.diagnostics.length > 0}
<ul class="diagnostics" aria-label="Repository diagnostics">
{#each navigation.diagnostics as diagnostic}
<li><code>{diagnostic.code}</code>: {diagnostic.message}</li>
{/each}
</ul>
{/if}
{:else}
<ul class="nav-list" aria-label="Repositories">
{#each navigation.items as item (item.id)}
<li>
<a class="nav-item" class:active={item.active} href={item.href} aria-current={item.active ? 'page' : undefined}>
<span class="item-title">{item.title}</span>
<span class="item-meta">{item.meta}</span>
</a>
</li>
{/each}
</ul>
{/if}
</section>

View File

@ -2,15 +2,23 @@
import ObjectivesNavSection from './ObjectivesNavSection.svelte';
import RepositoriesNavSection from './RepositoriesNavSection.svelte';
import WorkersNavSection from './WorkersNavSection.svelte';
import type { WorkspaceResponse } from './types';
import type { RepositoryListResponse, WorkspaceResponse } from './types';
type Props = {
workspace: WorkspaceResponse | null;
workspaceError?: string | null;
repositories?: RepositoryListResponse | null;
repositoriesError?: string | null;
currentPath?: string;
};
let { workspace, workspaceError = null, currentPath = '/' }: Props = $props();
let {
workspace,
workspaceError = null,
repositories = null,
repositoriesError = null,
currentPath = '/'
}: Props = $props();
let settingsActive = $derived(currentPath.startsWith("/settings"));
</script>
@ -43,7 +51,7 @@
</header>
<nav class="sidebar-sections" aria-label="Workspace sections">
<RepositoriesNavSection {workspace} {currentPath} />
<RepositoriesNavSection {repositories} {repositoriesError} {currentPath} />
<ObjectivesNavSection {currentPath} />
<WorkersNavSection {currentPath} />

View File

@ -0,0 +1,70 @@
import { projectRepositoryNav } from "./repository-nav.ts";
import type { RepositoryListResponse } from "./types.ts";
declare const Deno: {
test(name: string, fn: () => void): void;
};
function assertEquals<T>(actual: T, expected: T): void {
const actualJson = JSON.stringify(actual);
const expectedJson = JSON.stringify(expected);
if (actualJson !== expectedJson) {
throw new Error(`Expected ${expectedJson}, got ${actualJson}`);
}
}
function repositories(
items: RepositoryListResponse["items"],
): RepositoryListResponse {
return {
workspace_id: "workspace-1",
items,
source: "workspace_backend_config",
diagnostics: [],
};
}
Deno.test("repository nav does not invent main for an empty registry", () => {
const projection = projectRepositoryNav({
workspace_id: "workspace-1",
items: [],
source: "workspace_backend_config",
diagnostics: [
{
code: "repository_config_empty",
severity: "warning",
message: "No repositories configured",
},
],
});
assertEquals(projection.count, 0);
assertEquals(projection.items, []);
assertEquals(projection.diagnostics[0].code, "repository_config_empty");
});
Deno.test("repository nav links configured non-main repository ids", () => {
const projection = projectRepositoryNav(
repositories([
{
id: "infra",
display_name: "Infrastructure",
kind: "git",
provider: "git",
record_authority: "workspace-backend-config",
git: null,
diagnostics: [],
},
]),
"/repositories/infra",
);
assertEquals(projection.count, 1);
assertEquals(projection.items[0], {
id: "infra",
title: "Infrastructure",
href: "/repositories/infra",
meta: "git repository · read-only",
active: true,
});
});

View File

@ -0,0 +1,36 @@
import type { Diagnostic, RepositoryListResponse } from "./types";
export type RepositoryNavItem = {
id: string;
title: string;
href: string;
meta: string;
active: boolean;
};
export type RepositoryNavProjection = {
count: number;
items: RepositoryNavItem[];
diagnostics: Diagnostic[];
};
export function projectRepositoryNav(
repositories: RepositoryListResponse | null,
currentPath = "/",
): RepositoryNavProjection {
const summaries = repositories?.items ?? [];
return {
count: summaries.length,
diagnostics: repositories?.diagnostics ?? [],
items: summaries.map((repository) => {
const href = `/repositories/${encodeURIComponent(repository.id)}`;
return {
id: repository.id,
title: repository.display_name || repository.id,
href,
meta: `${repository.provider} repository · read-only`,
active: currentPath === href || currentPath.startsWith(`${href}/`),
};
}),
};
}

View File

@ -181,34 +181,42 @@ 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 RepositoryListResponse = {
workspace_id: string;
items: RepositorySummary[];
source: string;
diagnostics: Diagnostic[];
};
export type RepositoryDetailResponse = {
@ -220,6 +228,7 @@ export type RepositoryDetailResponse = {
export type RepositoryLogResponse = {
workspace_id: string;
repository_id: string;
default_selector?: string | null;
limit: number;
items: GitCommitSummary[];
diagnostics: Diagnostic[];

View File

@ -1,5 +1,6 @@
<script lang="ts">
import { page } from '$app/state';
import WorkspacePage from '$lib/workspace-pages/WorkspacePage.svelte';
</script>
<WorkspacePage view="repository" />
<WorkspacePage view="repository" repositoryId={page.params.repositoryId} />