wsb 0.93.391653

Workspace - A tool suite for file operations, version management, and development workflow automation
Documentation
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,
}

/// GET /api/v1/fleet/status — repo status for superproject + all submodules
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 })))
}

/// GET /api/v1/fleet/repos — list repos with GitHub owner/repo pairs
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
    }
}