use axum::{
extract::State,
http::StatusCode,
Json,
};
use serde::Serialize;
use super::sessions::ErrorResponse;
use crate::fleet::config::GitModules;
use crate::fleet::git::{GitOps, SubmoduleOps};
use crate::web::github;
use crate::web::state::WebState;
#[derive(Serialize)]
pub(crate) struct RepoStatus {
pub(crate) name: String,
pub(crate) path: String,
pub(crate) branch: Option<String>,
pub(crate) is_clean: bool,
pub(crate) is_detached: bool,
pub(crate) ahead: usize,
pub(crate) behind: usize,
pub(crate) uncommitted_files: Vec<String>,
pub(crate) exists: bool,
pub(crate) is_superproject: bool,
}
impl From<crate::fleet::git::SubmoduleStatus> for RepoStatus {
fn from(s: crate::fleet::git::SubmoduleStatus) -> Self {
Self {
name: s.name,
path: s.path,
branch: s.current_branch,
is_clean: s.is_clean,
is_detached: s.is_detached,
ahead: s.ahead,
behind: s.behind,
uncommitted_files: s.uncommitted_files,
exists: s.exists,
is_superproject: false,
}
}
}
#[derive(Debug, Clone)]
pub(crate) struct GitHubRepoRef {
pub(crate) owner: String,
pub(crate) repo: String,
}
#[derive(Debug, Clone)]
pub(crate) struct FleetRepo {
pub(crate) github: GitHubRepoRef,
}
pub async fn fleet_status(
State(_state): State<WebState>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let repo_root = workspace_root()?;
let repos = collect_repo_statuses(&repo_root).map_err(internal_error)?;
Ok(Json(serde_json::json!({ "repos": repos })))
}
pub async fn fleet_repos(
State(_state): State<WebState>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let repo_root = workspace_root()?;
let repos = collect_fleet_repos(&repo_root)
.map_err(internal_error)?
.into_iter()
.map(|repo| format!("{}/{}", repo.github.owner, repo.github.repo))
.collect::<Vec<_>>();
Ok(Json(serde_json::json!({ "repos": repos })))
}
pub(crate) fn workspace_root() -> Result<std::path::PathBuf, (StatusCode, Json<ErrorResponse>)> {
let cwd = std::env::current_dir().map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("cannot determine current directory: {}", e),
}),
)
})?;
GitOps::discover_repo_root(&cwd).map_err(|e| {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: format!("cannot resolve workspace root from '{}': {}", cwd.display(), e),
}),
)
})
}
pub(crate) fn collect_fleet_repos(
repo_root: &std::path::Path,
) -> crate::fleet::error::Result<Vec<FleetRepo>> {
let repo = GitOps::open_repo(repo_root)?;
let origin_url = GitOps::remote_url(&repo, "origin")?;
let (owner, repo_name) = github::parse_github_url(&origin_url).ok_or_else(|| {
crate::fleet::error::SuperGitError::Other(format!(
"workspace remote '{}' is not a supported GitHub URL",
origin_url
))
})?;
let mut repos = vec![FleetRepo {
github: GitHubRepoRef {
owner,
repo: repo_name,
},
}];
let submodules = match GitModules::parse(repo_root) {
Ok(gitmodules) => gitmodules.submodules,
Err(crate::fleet::error::SuperGitError::NoSubmodules) => Vec::new(),
Err(error) => return Err(error),
};
for submodule in submodules {
let (owner, repo_name) = github::parse_github_url(&submodule.url).ok_or_else(|| {
crate::fleet::error::SuperGitError::Other(format!(
"submodule '{}' remote '{}' is not a supported GitHub URL",
submodule.name, submodule.url
))
})?;
repos.push(FleetRepo {
github: GitHubRepoRef {
owner,
repo: repo_name,
},
});
}
Ok(repos)
}
pub(crate) fn collect_repo_statuses(
repo_root: &std::path::Path,
) -> crate::fleet::error::Result<Vec<RepoStatus>> {
let super_repo = GitOps::open_repo(repo_root)?;
let is_detached = GitOps::is_detached_head(&super_repo)?;
let branch = if is_detached {
None
} else {
Some(GitOps::current_branch(&super_repo)?)
};
let is_clean = GitOps::is_clean(&super_repo)?;
let uncommitted_files = if is_clean {
Vec::new()
} else {
GitOps::get_uncommitted_files(&super_repo)?
};
let (ahead, behind) = if is_detached {
(0, 0)
} else {
GitOps::get_ahead_behind(&super_repo)?
};
let mut repos = vec![RepoStatus {
name: repo_root
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| repo_root.display().to_string()),
path: ".".to_string(),
branch,
is_clean,
is_detached,
ahead,
behind,
uncommitted_files,
exists: true,
is_superproject: true,
}];
let submodules = match GitModules::parse(repo_root) {
Ok(gitmodules) => gitmodules.submodules,
Err(crate::fleet::error::SuperGitError::NoSubmodules) => Vec::new(),
Err(error) => return Err(error),
};
for submodule in &submodules {
repos.push(RepoStatus::from(SubmoduleOps::get_status(repo_root, submodule)?));
}
Ok(repos)
}
fn internal_error(error: crate::fleet::error::SuperGitError) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: error.to_string(),
}),
)
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
use tempfile::TempDir;
#[test]
fn workspace_root_resolves_from_nested_directory() {
let temp_dir = init_repo();
let nested = temp_dir.path().join("nested").join("dir");
std::fs::create_dir_all(&nested).unwrap();
let resolved = GitOps::discover_repo_root(&nested).unwrap();
assert_eq!(resolved.canonicalize().unwrap(), temp_dir.path().canonicalize().unwrap());
}
#[test]
fn collect_fleet_repos_includes_superproject_and_submodules() {
let temp_dir = init_repo();
std::fs::write(
temp_dir.path().join(".gitmodules"),
r#"[submodule "libs/core"]
path = libs/core
url = git@github.com:example/core.git
"#,
)
.unwrap();
let repos = collect_fleet_repos(temp_dir.path()).unwrap();
assert_eq!(repos.len(), 2);
assert_eq!(repos[0].github.owner, "example");
assert_eq!(repos[0].github.repo, "workspace");
assert_eq!(repos[1].github.repo, "core");
}
#[test]
fn collect_repo_statuses_returns_repos_shape() {
let temp_dir = init_repo();
std::fs::write(
temp_dir.path().join(".gitmodules"),
r#"[submodule "libs/core"]
path = libs/core
url = git@github.com:example/core.git
"#,
)
.unwrap();
std::fs::create_dir_all(temp_dir.path().join("libs/core")).unwrap();
Command::new("git")
.arg("init")
.arg(temp_dir.path().join("libs/core"))
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "wsb-tests@example.com"])
.current_dir(temp_dir.path().join("libs/core"))
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "wsb tests"])
.current_dir(temp_dir.path().join("libs/core"))
.output()
.unwrap();
std::fs::write(temp_dir.path().join("libs/core/README.md"), "core\n").unwrap();
Command::new("git")
.args(["add", "README.md"])
.current_dir(temp_dir.path().join("libs/core"))
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(temp_dir.path().join("libs/core"))
.output()
.unwrap();
let repos = collect_repo_statuses(temp_dir.path()).unwrap();
let json = serde_json::to_value(&repos).unwrap();
assert!(json.is_array());
assert_eq!(json.as_array().unwrap().len(), 2);
assert!(json.as_array().unwrap()[0].get("is_superproject").unwrap().as_bool().unwrap());
}
fn init_repo() -> TempDir {
let temp_dir = TempDir::new().unwrap();
Command::new("git")
.arg("init")
.current_dir(temp_dir.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.email", "wsb-tests@example.com"])
.current_dir(temp_dir.path())
.output()
.unwrap();
Command::new("git")
.args(["config", "user.name", "wsb tests"])
.current_dir(temp_dir.path())
.output()
.unwrap();
std::fs::write(temp_dir.path().join("README.md"), "workspace\n").unwrap();
Command::new("git")
.args(["add", "README.md"])
.current_dir(temp_dir.path())
.output()
.unwrap();
Command::new("git")
.args(["commit", "-m", "init"])
.current_dir(temp_dir.path())
.output()
.unwrap();
Command::new("git")
.arg("remote")
.arg("add")
.arg("origin")
.arg("git@github.com:example/workspace.git")
.current_dir(temp_dir.path())
.output()
.unwrap();
temp_dir
}
}