use wsb::prelude::*;
use axum::{
extract::{Query, State},
http::StatusCode,
Json,
};
use std::{
collections::HashMap,
fs,
path::{Component, Path, PathBuf},
};
use super::{fleet::workspace_root_of, sessions::ErrorResponse};
use wsb::fleet::{config::GitModules, error::SuperGitError};
use wsb::git_backend::FileStatusKind;
use crate::web::handlers::fleet::RepoDetailedStatus;
use crate::web::ops::OpKind;
use crate::web::state::WebState;
use wsb::workspace_state::WorkspaceState;
use axum::extract::State as AxumState;
#[derive(Debug, Deserialize)]
pub struct WorkspacePathQuery {
#[serde(default)]
path: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceUpdateRequest {
#[serde(default)]
pub path: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct WorkspaceUpdateResponse {
pub path: String,
pub version: String,
}
#[derive(Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum WorkspaceEntryKind {
File,
Directory,
}
#[derive(Debug, Serialize, Clone)]
pub struct WorkspaceSubmoduleInfo {
pub name: String,
pub version: Option<String>,
pub github_repo: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct WorkspaceEntry {
pub path: String,
pub name: String,
pub kind: WorkspaceEntryKind,
pub hidden: bool,
pub submodule: Option<WorkspaceSubmoduleInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_status: Option<FileStatusKind>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_status: Option<RepoDetailedStatus>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repo_path: Option<String>,
}
pub const MAX_TREE_BATCH: usize = 512;
#[derive(Debug, Deserialize)]
pub struct WorkspaceTreesRequest {
pub paths: Vec<String>,
}
#[derive(Debug, Serialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum WorkspaceTreeResult {
Listed {
path: String,
entries: Vec<WorkspaceEntry>,
},
Failed {
path: String,
error: String,
},
}
#[derive(Debug, Serialize)]
pub struct WorkspaceTreesResponse {
pub root: String,
pub trees: Vec<WorkspaceTreeResult>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct WorkspaceChangedEvent {
pub listings: Vec<String>,
pub subtree: Option<String>,
}
impl WorkspaceChangedEvent {
pub fn listings(paths: Vec<String>) -> Self {
Self {
listings: paths,
subtree: None,
}
}
pub fn repo_status(repo_path: &str) -> Self {
if repo_path == "." {
return Self {
listings: vec![String::new()],
subtree: Some(String::new()),
};
}
let containing_listing = match std::path::Path::new(repo_path).parent() {
Some(parent) if !parent.as_os_str().is_empty() => wsb::platform::slash_path(parent),
_ => String::new(),
};
Self {
listings: vec![containing_listing],
subtree: Some(repo_path.to_string()),
}
}
pub fn everything() -> Self {
Self {
listings: vec![String::new()],
subtree: Some(String::new()),
}
}
}
#[derive(Debug, Serialize)]
pub struct WorkspaceFileResponse {
pub root: String,
pub path: String,
pub content: String,
pub mtime_secs: u64,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceFileSaveRequest {
pub path: String,
pub content: String,
pub expected_mtime_secs: u64,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceCreateFileRequest {
pub path: String,
#[serde(default)]
pub content: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceCreateDirRequest {
pub path: String,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceDeleteRequest {
pub path: String,
#[serde(default)]
pub recursive: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceRenameRequest {
pub from: String,
pub to: String,
}
#[derive(Debug, Deserialize)]
pub struct WorkspaceCopyRequest {
pub from: String,
pub to: String,
}
fn parent_dir_path(relative: &Path) -> String {
match relative.parent() {
Some(p) if !p.as_os_str().is_empty() => wsb::platform::slash_path(p),
_ => String::new(),
}
}
fn conflict_error(error: String) -> (StatusCode, Json<ErrorResponse>) {
(StatusCode::CONFLICT, Json(ErrorResponse { error }))
}
pub async fn create_file(
State(state): State<WebState>,
Json(body): Json<WorkspaceCreateFileRequest>,
) -> Result<Json<WorkspaceFileResponse>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root_of(&state);
let relative = sanitize_relative_path(&body.path)?;
if relative.as_os_str().is_empty() {
return Err(bad_request("file path must not be empty".to_string()));
}
let target = resolve_workspace_path(&root, &relative)?;
let content = body.content.unwrap_or_default();
if target.exists() {
return Err(conflict_error(format!(
"path '{}' already exists",
display_relative_path(&relative)
)));
}
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(internal_error)?;
}
fs::write(&target, content.as_bytes()).map_err(internal_error)?;
let mtime_secs = fs::metadata(&target)
.map(|m| {
m.modified()
.map(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
})
.unwrap_or(0)
})
.unwrap_or(0);
let _ = state
.file_changed_tx
.send(crate::web::state::FileChangedEvent {
path: display_relative_path(&relative),
mtime_secs: Some(mtime_secs),
});
let parent_path = parent_dir_path(&relative);
let _ = state
.workspace_changed_tx
.send(WorkspaceChangedEvent::listings(vec![parent_path]));
Ok(Json(WorkspaceFileResponse {
root: root.display().to_string(),
path: display_relative_path(&relative),
content,
mtime_secs,
}))
}
#[derive(Debug, Serialize)]
pub struct WorkspaceCreateDirResponse {
pub root: String,
pub path: String,
}
pub async fn create_directory(
State(state): State<WebState>,
Json(body): Json<WorkspaceCreateDirRequest>,
) -> Result<Json<WorkspaceCreateDirResponse>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root_of(&state);
let relative = sanitize_relative_path(&body.path)?;
if relative.as_os_str().is_empty() {
return Err(bad_request("directory path must not be empty".to_string()));
}
let target = resolve_workspace_path(&root, &relative)?;
if target.exists() {
return Err(conflict_error(format!(
"path '{}' already exists",
display_relative_path(&relative)
)));
}
fs::create_dir_all(&target).map_err(internal_error)?;
let parent_path = parent_dir_path(&relative);
let _ = state
.workspace_changed_tx
.send(WorkspaceChangedEvent::listings(vec![parent_path]));
Ok(Json(WorkspaceCreateDirResponse {
root: root.display().to_string(),
path: display_relative_path(&relative),
}))
}
pub async fn delete_entry(
State(state): State<WebState>,
Json(body): Json<WorkspaceDeleteRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root_of(&state);
let relative = sanitize_relative_path(&body.path)?;
if relative.as_os_str().is_empty() {
return Err(bad_request("path must not be empty".to_string()));
}
let target = resolve_workspace_path(&root, &relative)?;
let recursive = body.recursive.unwrap_or(false);
let rel_str = display_relative_path(&relative);
let label = format!("delete {}", rel_str);
let parent_path = parent_dir_path(&relative);
let rel_str_for_closure = rel_str.clone();
let op_handle = state.ops.start_op(OpKind::FileOperation, label).await;
let result =
tokio::task::spawn_blocking(move || -> Result<(), (StatusCode, Json<ErrorResponse>)> {
let rel_str = rel_str_for_closure;
let meta = fs::metadata(&target)
.map_err(|_| bad_request(format!("path '{}' does not exist", rel_str)))?;
if meta.is_dir() {
if !recursive {
let is_empty = fs::read_dir(&target)
.map_err(internal_error)?
.next()
.is_none();
if !is_empty {
return Err(bad_request(format!(
"directory '{}' is not empty; set recursive=true to delete",
rel_str
)));
}
fs::remove_dir(&target).map_err(internal_error)?;
} else {
fs::remove_dir_all(&target).map_err(internal_error)?;
}
} else {
fs::remove_file(&target).map_err(internal_error)?;
}
Ok(())
})
.await
.map_err(|e| internal_error(format!("delete task panicked: {e}")));
match result {
Ok(Ok(())) => {
op_handle.complete().await;
let _ = state
.file_changed_tx
.send(crate::web::state::FileChangedEvent {
path: rel_str,
mtime_secs: None,
});
let _ = state
.workspace_changed_tx
.send(WorkspaceChangedEvent::listings(vec![parent_path]));
Ok(Json(serde_json::json!({ "deleted": true })))
}
Ok(Err(e)) => {
op_handle.fail(e.1.error.clone()).await;
Err(e)
}
Err(e) => {
op_handle.fail(e.1.error.clone()).await;
Err(e)
}
}
}
pub async fn rename_entry(
State(state): State<WebState>,
Json(body): Json<WorkspaceRenameRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root_of(&state);
let from_rel = sanitize_relative_path(&body.from)?;
let to_rel = sanitize_relative_path(&body.to)?;
if from_rel.as_os_str().is_empty() {
return Err(bad_request("from path must not be empty".to_string()));
}
if to_rel.as_os_str().is_empty() {
return Err(bad_request("to path must not be empty".to_string()));
}
let from_target = resolve_workspace_path(&root, &from_rel)?;
let to_target = resolve_workspace_path(&root, &to_rel)?;
if to_target.exists() {
return Err(conflict_error(format!(
"destination '{}' already exists",
display_relative_path(&to_rel)
)));
}
let from_str = display_relative_path(&from_rel);
let to_str = display_relative_path(&to_rel);
let label = format!("rename {} → {}", from_str, to_str);
let from_parent = parent_dir_path(&from_rel);
let to_parent = parent_dir_path(&to_rel);
let op_handle = state.ops.start_op(OpKind::FileOperation, label).await;
if let Some(parent) = to_target.parent() {
if let Err(e) = fs::create_dir_all(parent) {
let msg = e.to_string();
op_handle.fail(msg.clone()).await;
return Err(internal_error(msg));
}
}
let result = tokio::task::spawn_blocking(move || {
fs::rename(&from_target, &to_target).map_err(internal_error)
})
.await
.map_err(|e| internal_error(format!("rename task panicked: {e}")));
match result {
Ok(Ok(())) => {
op_handle.complete().await;
let _ = state
.file_changed_tx
.send(crate::web::state::FileChangedEvent {
path: from_str,
mtime_secs: None,
});
let mut changed_dirs = vec![from_parent, to_parent];
changed_dirs.dedup();
let _ = state
.workspace_changed_tx
.send(WorkspaceChangedEvent::listings(changed_dirs));
Ok(Json(serde_json::json!({ "renamed": true, "to": to_str })))
}
Ok(Err(e)) => {
op_handle.fail(e.1.error.clone()).await;
Err(e)
}
Err(e) => {
op_handle.fail(e.1.error.clone()).await;
Err(e)
}
}
}
pub async fn copy_entry(
State(state): State<WebState>,
Json(body): Json<WorkspaceCopyRequest>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root_of(&state);
let from_rel = sanitize_relative_path(&body.from)?;
let to_rel = sanitize_relative_path(&body.to)?;
if from_rel.as_os_str().is_empty() {
return Err(bad_request("from path must not be empty".to_string()));
}
if to_rel.as_os_str().is_empty() {
return Err(bad_request("to path must not be empty".to_string()));
}
let from_target = resolve_workspace_path(&root, &from_rel)?;
let to_target = resolve_workspace_path(&root, &to_rel)?;
if to_target.exists() {
return Err(conflict_error(format!(
"destination '{}' already exists",
display_relative_path(&to_rel)
)));
}
let from_str = display_relative_path(&from_rel);
let to_str = display_relative_path(&to_rel);
let label = format!("copy {} → {}", from_str, to_str);
let to_parent = parent_dir_path(&to_rel);
let to_str_clone = to_str.clone();
let op_handle = state.ops.start_op(OpKind::FileOperation, label).await;
let result =
tokio::task::spawn_blocking(move || -> Result<(), (StatusCode, Json<ErrorResponse>)> {
let meta = fs::metadata(&from_target)
.map_err(|_| bad_request(format!("source '{}' does not exist", from_str)))?;
if let Some(parent) = to_target.parent() {
fs::create_dir_all(parent).map_err(internal_error)?;
}
if meta.is_dir() {
copy_dir_recursive(&from_target, &to_target).map_err(internal_error)?;
} else {
fs::copy(&from_target, &to_target).map_err(internal_error)?;
}
Ok(())
})
.await
.map_err(|e| internal_error(format!("copy task panicked: {e}")));
match result {
Ok(Ok(())) => {
op_handle.complete().await;
let _ = state
.workspace_changed_tx
.send(WorkspaceChangedEvent::listings(vec![to_parent]));
Ok(Json(
serde_json::json!({ "copied": true, "to": to_str_clone }),
))
}
Ok(Err(e)) => {
op_handle.fail(e.1.error.clone()).await;
Err(e)
}
Err(e) => {
op_handle.fail(e.1.error.clone()).await;
Err(e)
}
}
}
fn copy_dir_recursive(src: &Path, dst: &Path) -> std::io::Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let file_type = entry.file_type()?;
let dest_path = dst.join(entry.file_name());
if file_type.is_dir() {
copy_dir_recursive(&entry.path(), &dest_path)?;
} else {
fs::copy(entry.path(), &dest_path)?;
}
}
Ok(())
}
pub async fn list_trees(
State(state): State<WebState>,
Json(body): Json<WorkspaceTreesRequest>,
) -> Result<Json<WorkspaceTreesResponse>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root_of(&state);
let requested = plan_tree_batch(&body.paths)?;
let git_snapshot: HashMap<String, RepoDetailedStatus> =
state.git_status_snapshot.read().await.clone();
let listing_root = root.clone();
let trees =
tokio::task::spawn_blocking(move || read_listings(&listing_root, requested, &git_snapshot))
.await
.map_err(|e| internal_error(format!("list_trees task panicked: {e}")))?;
Ok(Json(WorkspaceTreesResponse {
root: root.display().to_string(),
trees,
}))
}
fn plan_tree_batch(
paths: &[String],
) -> Result<Vec<(String, PathBuf)>, (StatusCode, Json<ErrorResponse>)> {
if paths.is_empty() {
return Err(bad_request(
"a tree request must name at least one path".to_string(),
));
}
if paths.len() > MAX_TREE_BATCH {
return Err(bad_request(format!(
"a tree request may name at most {MAX_TREE_BATCH} paths, got {}",
paths.len()
)));
}
let mut requested: Vec<(String, PathBuf)> = Vec::with_capacity(paths.len());
let mut seen = std::collections::HashSet::new();
for raw in paths {
let relative = sanitize_relative_path(raw)?;
let display = display_relative_path(&relative);
if seen.insert(display.clone()) {
requested.push((display, relative));
}
}
Ok(requested)
}
fn read_listings(
root: &Path,
requested: Vec<(String, PathBuf)>,
git_snapshot: &HashMap<String, RepoDetailedStatus>,
) -> Vec<WorkspaceTreeResult> {
let mut trees = Vec::with_capacity(requested.len());
for (path, relative) in requested {
let target = match resolve_workspace_path(root, &relative) {
Ok(target) => target,
Err((_, Json(error))) => {
trees.push(WorkspaceTreeResult::Failed {
path,
error: error.error,
});
continue;
}
};
if !target.exists() {
trees.push(WorkspaceTreeResult::Failed {
path,
error: format!("workspace path '{}' no longer exists", relative.display()),
});
continue;
}
match read_listing(&target, &relative, git_snapshot) {
Ok(entries) => trees.push(WorkspaceTreeResult::Listed { path, entries }),
Err((_, Json(error))) => trees.push(WorkspaceTreeResult::Failed {
path,
error: error.error,
}),
}
}
trees
}
fn read_listing(
target: &Path,
relative: &Path,
git_snapshot: &HashMap<String, RepoDetailedStatus>,
) -> Result<Vec<WorkspaceEntry>, (StatusCode, Json<ErrorResponse>)> {
let metadata = fs::metadata(target).map_err(internal_error)?;
if !metadata.is_dir() {
return Err(bad_request(format!(
"workspace path '{}' is not a directory",
display_relative_path(relative)
)));
}
let mut raw_entries = fs::read_dir(target)
.map_err(internal_error)?
.collect::<Result<Vec<_>, _>>()
.map_err(internal_error)?;
raw_entries.sort_by(|left, right| {
let left_kind = left.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
let right_kind = right.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
match right_kind.cmp(&left_kind) {
std::cmp::Ordering::Equal => left.file_name().cmp(&right.file_name()),
ordering => ordering,
}
});
let visible_directory_paths = raw_entries
.iter()
.filter_map(|entry| {
let file_type = entry.file_type().ok()?;
if !file_type.is_dir() {
return None;
}
let name = entry.file_name().to_string_lossy().into_owned();
Some(join_relative_path(relative, &name))
})
.collect::<Vec<_>>();
let dir_relative = display_relative_path(relative);
let listing_repo_root = is_repository_root(target);
let submodules = if listing_repo_root {
load_workspace_submodules(target, &dir_relative, &visible_directory_paths)?
} else {
HashMap::new()
};
let uninitialized_submodules = if listing_repo_root {
load_uninitialized_submodules(target, &dir_relative, &visible_directory_paths)?
} else {
vec![]
};
let relative_str = dir_relative.clone();
let owning_repo: Option<&RepoDetailedStatus> = git_snapshot
.values()
.filter(|r| {
if r.is_superproject {
true
} else {
relative_str == r.path || relative_str.starts_with(&format!("{}/", r.path))
}
})
.max_by_key(|r| r.depth);
let (file_status_map, repo_path_prefix): (HashMap<String, FileStatusKind>, &str) =
if let Some(repo) = owning_repo {
let prefix = if repo.is_superproject { "" } else { &repo.path };
let map = repo
.files
.iter()
.map(|f| {
let ws_path = if prefix.is_empty() {
f.path.clone()
} else {
format!("{prefix}/{}", f.path)
};
(ws_path, f.status)
})
.collect();
(map, prefix)
} else {
(HashMap::new(), "")
};
let mut entries = raw_entries
.into_iter()
.map(|entry| {
let file_type = entry.file_type().map_err(internal_error)?;
let file_name = entry.file_name().to_string_lossy().into_owned();
let entry_relative = join_relative_path(relative, &file_name);
let submodule = if file_type.is_dir() {
submodules.get(&entry_relative).cloned()
} else {
None
};
let (git_status, repo_status, repo_path) = if file_type.is_dir() {
if submodule.is_some() {
let rs = git_snapshot.get(&entry_relative).cloned();
(None, rs, None)
} else {
let dir_status = aggregate_dir_status(&entry_relative, &file_status_map);
(dir_status, None, None)
}
} else {
let fs = file_status_map.get(&entry_relative).copied();
let rp = if repo_path_prefix.is_empty() {
None
} else {
Some(repo_path_prefix.to_string())
};
(fs, None, rp)
};
Ok(WorkspaceEntry {
path: entry_relative.clone(),
name: submodule
.as_ref()
.map(|s| s.name.clone())
.unwrap_or(file_name),
kind: if file_type.is_dir() {
WorkspaceEntryKind::Directory
} else {
WorkspaceEntryKind::File
},
hidden: is_hidden_name(&entry_relative),
submodule,
git_status,
repo_status,
repo_path,
})
})
.collect::<Result<Vec<_>, _>>()?;
for sub in &uninitialized_submodules {
let rs = git_snapshot.get(&sub.path).cloned();
entries.push(WorkspaceEntry {
path: sub.path.clone(),
name: sub.name.clone(),
kind: WorkspaceEntryKind::Directory,
hidden: is_hidden_name(&sub.path),
submodule: Some(WorkspaceSubmoduleInfo {
name: sub.name.clone(),
version: None,
github_repo: sub.github_repo.clone(),
error: Some("submodule not initialized".to_string()),
}),
git_status: None,
repo_status: rs,
repo_path: None,
});
}
entries.sort_by(compare_workspace_entries);
Ok(entries)
}
fn aggregate_dir_status(
dir_path: &str,
file_status_map: &HashMap<String, FileStatusKind>,
) -> Option<FileStatusKind> {
let prefix = format!("{dir_path}/");
let mut worst: Option<FileStatusKind> = None;
for (path, &status) in file_status_map {
if !path.starts_with(&prefix) {
continue;
}
if status == FileStatusKind::Ignored {
continue;
}
worst = Some(match worst {
None => status,
Some(current) => higher_priority_status(current, status),
});
if worst == Some(FileStatusKind::Conflict) {
return worst;
}
}
worst
}
fn higher_priority_status(a: FileStatusKind, b: FileStatusKind) -> FileStatusKind {
if status_priority(b) > status_priority(a) {
b
} else {
a
}
}
fn status_priority(s: FileStatusKind) -> u8 {
match s {
FileStatusKind::Conflict => 6,
FileStatusKind::Deleted => 5,
FileStatusKind::Modified => 4,
FileStatusKind::Staged => 3,
FileStatusKind::Renamed => 2,
FileStatusKind::Untracked => 1,
FileStatusKind::Ignored => 0,
}
}
fn load_workspace_submodules(
repo_dir: &Path,
dir_relative: &str,
visible_directory_paths: &[String],
) -> Result<HashMap<String, WorkspaceSubmoduleInfo>, (StatusCode, Json<ErrorResponse>)> {
let gitmodules = match GitModules::parse(repo_dir) {
Ok(gm) => gm,
Err(SuperGitError::NoSubmodules) => return Ok(HashMap::new()),
Err(e) => {
return Err(internal_error(format!(
"failed to read the submodules declared in '{}': {e}",
repo_dir.join(".gitmodules").display()
)))
}
};
let visible_directory_paths = visible_directory_paths
.iter()
.cloned()
.collect::<std::collections::HashSet<_>>();
let mut submodules = HashMap::with_capacity(gitmodules.submodules.len());
for submodule in gitmodules.submodules {
let relative_path = workspace_relative_submodule_path(dir_relative, &submodule.path);
if !visible_directory_paths.contains(&relative_path) {
continue;
}
let submodule_root = repo_dir.join(&submodule.path);
let version = wsb::state::read_version_from_path(&submodule_root)
.map(|version| version.full_version)
.ok();
let github_repo =
wsb_github::parse_github_url(&submodule.url).map(|(owner, repo)| format!("{owner}/{repo}"));
let (name, error) = match read_submodule_project_name(&submodule_root) {
Some(name) => (name, None),
None => (submodule.name, None),
};
submodules.insert(
relative_path,
WorkspaceSubmoduleInfo {
name,
version,
github_repo,
error,
},
);
}
Ok(submodules)
}
fn workspace_relative_submodule_path(dir_relative: &str, submodule_path: &Path) -> String {
if dir_relative.is_empty() {
wsb::platform::slash_path(submodule_path)
} else {
wsb::platform::slash_path(&Path::new(dir_relative).join(submodule_path))
}
}
fn is_repository_root(dir: &Path) -> bool {
dir.join(".git").exists()
}
struct UninitializedSubmodule {
path: String,
name: String,
github_repo: Option<String>,
}
fn load_uninitialized_submodules(
repo_dir: &Path,
dir_relative: &str,
existing_directory_paths: &[String],
) -> Result<Vec<UninitializedSubmodule>, (StatusCode, Json<ErrorResponse>)> {
let gitmodules = match GitModules::parse(repo_dir) {
Ok(gm) => gm,
Err(SuperGitError::NoSubmodules) => return Ok(Vec::new()),
Err(e) => {
return Err(internal_error(format!(
"failed to read the submodules declared in '{}': {e}",
repo_dir.join(".gitmodules").display()
)))
}
};
let existing: std::collections::HashSet<_> = existing_directory_paths.iter().collect();
let mut result = Vec::new();
for submodule in gitmodules.submodules {
let relative_path = workspace_relative_submodule_path(dir_relative, &submodule.path);
if existing.contains(&relative_path) {
continue;
}
let github_repo =
wsb_github::parse_github_url(&submodule.url).map(|(owner, repo)| format!("{owner}/{repo}"));
result.push(UninitializedSubmodule {
path: relative_path,
name: submodule.name,
github_repo,
});
}
Ok(result)
}
fn read_submodule_project_name(submodule_root: &Path) -> Option<String> {
if !submodule_root.join(".wsb").join("state.json").exists() {
return None;
}
let state = WorkspaceState::load(submodule_root).ok()?;
state.project_name.and_then(|name| {
let trimmed = name.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
}
pub async fn read_file(
State(state): State<WebState>,
Query(query): Query<WorkspacePathQuery>,
) -> Result<Json<WorkspaceFileResponse>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root_of(&state);
let requested = query.path.as_deref().unwrap_or("").trim();
if requested.is_empty() {
return Err(bad_request("workspace file path is required".to_string()));
}
let relative = sanitize_relative_path(requested)?;
let target = resolve_workspace_path(&root, &relative)?;
let (root, relative, content, mtime_secs) = tokio::task::spawn_blocking(move || {
let metadata = fs::metadata(&target).map_err(internal_error)?;
if !metadata.is_file() {
return Err(bad_request(format!(
"workspace path '{}' is not a file",
display_relative_path(&relative)
)));
}
let mtime_secs = metadata
.modified()
.map(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
})
.unwrap_or(0);
let bytes = fs::read(&target).map_err(internal_error)?;
let content = String::from_utf8(bytes).map_err(|_| {
bad_request(format!(
"workspace file '{}' is not valid UTF-8 text",
display_relative_path(&relative)
))
})?;
Ok((root, relative, content, mtime_secs))
})
.await
.map_err(|e| internal_error(format!("read_file task panicked: {e}")))??;
Ok(Json(WorkspaceFileResponse {
root: root.display().to_string(),
path: display_relative_path(&relative),
content,
mtime_secs,
}))
}
pub async fn save_file(
State(state): State<WebState>,
Json(body): Json<WorkspaceFileSaveRequest>,
) -> Result<Json<WorkspaceFileResponse>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root_of(&state);
let relative = sanitize_relative_path(&body.path)?;
let target = resolve_workspace_path(&root, &relative)?;
let content = body.content.clone();
let expected_mtime = body.expected_mtime_secs;
let (root, relative, new_mtime_secs) = tokio::task::spawn_blocking(move || {
let metadata = fs::metadata(&target).map_err(internal_error)?;
if !metadata.is_file() {
return Err(bad_request(format!(
"workspace path '{}' is not a file",
display_relative_path(&relative)
)));
}
let disk_mtime = metadata
.modified()
.map(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
})
.unwrap_or(0);
if disk_mtime > expected_mtime {
return Err((
StatusCode::CONFLICT,
Json(ErrorResponse {
error: format!(
"file '{}' was modified on disk (disk mtime {}, client mtime {}); \
reload before saving",
display_relative_path(&relative),
disk_mtime,
expected_mtime
),
}),
));
}
fs::write(&target, content.as_bytes()).map_err(internal_error)?;
let new_mtime = fs::metadata(&target)
.map(|m| {
m.modified()
.map(|t| {
t.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
})
.unwrap_or(0)
})
.unwrap_or(0);
Ok((root, relative, new_mtime))
})
.await
.map_err(|e| internal_error(format!("save_file task panicked: {e}")))??;
let event = crate::web::state::FileChangedEvent {
path: display_relative_path(&relative),
mtime_secs: Some(new_mtime_secs),
};
let _ = state.file_changed_tx.send(event);
Ok(Json(WorkspaceFileResponse {
root: root.display().to_string(),
path: display_relative_path(&relative),
content: body.content,
mtime_secs: new_mtime_secs,
}))
}
pub async fn run_update(
AxumState(state): AxumState<WebState>,
Json(body): Json<WorkspaceUpdateRequest>,
) -> Result<Json<WorkspaceUpdateResponse>, (StatusCode, Json<ErrorResponse>)> {
let root = workspace_root_of(&state);
let relative = sanitize_relative_path(body.path.as_deref().unwrap_or(""))?;
validate_update_target(&root, &relative)?;
let target = resolve_workspace_path(&root, &relative)?;
let metadata = fs::metadata(&target).map_err(internal_error)?;
if !metadata.is_dir() {
return Err(bad_request(format!(
"workspace path '{}' is not a directory",
display_relative_path(&relative)
)));
}
let display_path = display_relative_path(&relative);
let version = wsb::workspace_update::run_workspace_update_async(&target)
.await
.map_err(internal_error)?
.version;
Ok(Json(WorkspaceUpdateResponse {
path: display_path,
version,
}))
}
fn sanitize_relative_path(raw: &str) -> Result<PathBuf, (StatusCode, Json<ErrorResponse>)> {
let path = Path::new(raw);
if path.is_absolute() {
return Err(bad_request(format!(
"workspace path '{}' must be relative to the workspace root",
raw
)));
}
let mut relative = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
Component::Normal(part) => relative.push(part),
Component::ParentDir => {
return Err(bad_request(format!(
"workspace path '{}' must not traverse outside the workspace root",
raw
)))
}
Component::RootDir | Component::Prefix(_) => {
return Err(bad_request(format!(
"workspace path '{}' must be relative to the workspace root",
raw
)))
}
}
}
Ok(relative)
}
fn validate_update_target(
root: &Path,
relative: &Path,
) -> Result<(), (StatusCode, Json<ErrorResponse>)> {
if relative.as_os_str().is_empty() {
return Ok(());
}
let tree = match wsb::fleet::git::tree::SubmoduleTree::discover_allow_empty(root) {
Ok(tree) => tree,
Err(error) => return Err(internal_error(error)),
};
let relative_str = relative.to_string_lossy();
if tree.find(&relative_str).is_some() {
return Ok(());
}
Err(bad_request(format!(
"workspace path '{}' is not a submodule root",
relative_str
)))
}
fn resolve_workspace_path(
root: &Path,
relative: &Path,
) -> Result<PathBuf, (StatusCode, Json<ErrorResponse>)> {
let target = root.join(relative);
if !target.starts_with(root) {
return Err(bad_request(format!(
"workspace path '{}' resolved outside the workspace root",
display_relative_path(relative)
)));
}
Ok(target)
}
fn join_relative_path(base: &Path, name: &str) -> String {
if base.as_os_str().is_empty() {
name.to_string()
} else {
wsb::platform::slash_path(&base.join(name))
}
}
fn display_relative_path(path: &Path) -> String {
wsb::platform::slash_path(path)
}
fn compare_workspace_entries(left: &WorkspaceEntry, right: &WorkspaceEntry) -> std::cmp::Ordering {
let left_is_directory = left.kind == WorkspaceEntryKind::Directory;
let right_is_directory = right.kind == WorkspaceEntryKind::Directory;
match right_is_directory.cmp(&left_is_directory) {
std::cmp::Ordering::Equal => {
let left_is_submodule = left.submodule.is_some();
let right_is_submodule = right.submodule.is_some();
match right_is_submodule.cmp(&left_is_submodule) {
std::cmp::Ordering::Equal => left.name.cmp(&right.name),
ordering => ordering,
}
}
ordering => ordering,
}
}
fn is_hidden_name(path: &str) -> bool {
Path::new(path)
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with('.'))
}
fn bad_request(error: String) -> (StatusCode, Json<ErrorResponse>) {
(StatusCode::BAD_REQUEST, Json(ErrorResponse { error }))
}
fn internal_error(error: impl ToString) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: error.to_string(),
}),
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn sanitize_relative_path_rejects_parent_traversal() {
let error = sanitize_relative_path("../secret.txt").unwrap_err();
assert_eq!(error.0, StatusCode::BAD_REQUEST);
assert!(error.1.error.contains("must not traverse outside"));
}
#[test]
fn list_tree_sorts_directories_before_files() {
let temp = TempDir::new().unwrap();
fs::create_dir(temp.path().join("src")).unwrap();
fs::write(temp.path().join("Cargo.toml"), "wsb").unwrap();
fs::write(temp.path().join("README.md"), "docs").unwrap();
let mut entries = fs::read_dir(temp.path())
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
entries.sort_by(|left, right| {
let left_kind = left.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
let right_kind = right.file_type().map(|ft| ft.is_dir()).unwrap_or(false);
match right_kind.cmp(&left_kind) {
std::cmp::Ordering::Equal => left.file_name().cmp(&right.file_name()),
ordering => ordering,
}
});
let names = entries
.into_iter()
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(names, vec!["src", "Cargo.toml", "README.md"]);
}
#[test]
fn resolve_workspace_path_stays_under_root() {
let temp = TempDir::new().unwrap();
let relative = match sanitize_relative_path("src/lib.rs") {
Ok(value) => value,
Err(_) => panic!("expected relative path to sanitize"),
};
let resolved = match resolve_workspace_path(temp.path(), &relative) {
Ok(value) => value,
Err(_) => panic!("expected workspace path to resolve under root"),
};
assert!(resolved.starts_with(temp.path()));
assert!(resolved.ends_with("src/lib.rs"));
}
#[test]
fn hidden_entries_are_detected_from_leaf_name() {
assert!(is_hidden_name(".git"));
assert!(is_hidden_name("dir/.env"));
assert!(!is_hidden_name("src/lib.rs"));
}
#[test]
fn load_workspace_submodules_reads_versions_from_version_txt() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join(".gitmodules"),
r#"[submodule "core"]
path = libs/core
url = https://github.com/example/core.git
"#,
)
.unwrap();
fs::create_dir_all(temp.path().join("libs/core")).unwrap();
fs::write(temp.path().join("libs/core/version.txt"), "0.414.56214\n").unwrap();
let submodules =
load_workspace_submodules(temp.path(), "", &[String::from("libs/core")]).unwrap();
assert_eq!(submodules.len(), 1);
assert_eq!(submodules["libs/core"].name, "core");
assert_eq!(
submodules["libs/core"].version.as_deref(),
Some("0.414.56214")
);
}
#[test]
fn load_workspace_submodules_omits_version_when_unavailable() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join(".gitmodules"),
r#"[submodule "thesis"]
path = thesis
url = https://github.com/example/thesis.git
"#,
)
.unwrap();
fs::create_dir_all(temp.path().join("thesis")).unwrap();
let submodules =
load_workspace_submodules(temp.path(), "", &[String::from("thesis")]).unwrap();
assert_eq!(submodules.len(), 1);
assert_eq!(submodules["thesis"].name, "thesis");
assert_eq!(submodules["thesis"].version, None);
}
#[test]
fn read_submodule_project_name_uses_workspace_state_when_present() {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".wsb")).unwrap();
fs::write(
temp.path().join(".wsb/state.json"),
r#"{
"version": 1,
"project_name": "Capdag Core",
"tools": {},
"wstemplate_entries": [],
"completion_hint_shown": false
}"#,
)
.unwrap();
assert_eq!(
read_submodule_project_name(temp.path()).as_deref(),
Some("Capdag Core")
);
}
#[test]
fn compare_workspace_entries_places_submodules_before_other_nodes() {
let mut entries = vec![
WorkspaceEntry {
path: "README.md".to_string(),
name: "README.md".to_string(),
kind: WorkspaceEntryKind::File,
hidden: false,
submodule: None,
git_status: None,
repo_status: None,
repo_path: None,
},
WorkspaceEntry {
path: "src".to_string(),
name: "src".to_string(),
kind: WorkspaceEntryKind::Directory,
hidden: false,
submodule: None,
git_status: None,
repo_status: None,
repo_path: None,
},
WorkspaceEntry {
path: "capdag".to_string(),
name: "capdag".to_string(),
kind: WorkspaceEntryKind::Directory,
hidden: false,
submodule: Some(WorkspaceSubmoduleInfo {
name: "capdag".to_string(),
version: Some("0.414.56214".to_string()),
github_repo: None,
error: None,
}),
git_status: None,
repo_status: None,
repo_path: None,
},
];
entries.sort_by(compare_workspace_entries);
assert_eq!(
entries
.iter()
.map(|entry| entry.name.as_str())
.collect::<Vec<_>>(),
vec!["capdag", "src", "README.md"]
);
}
#[test]
fn test9605_a_listing_reads_only_its_own_repositorys_declarations() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join(".gitmodules"),
r#"[submodule "child"]
path = child
url = https://github.com/example/child.git
"#,
)
.unwrap();
fs::create_dir_all(temp.path().join("child/foo")).unwrap();
fs::create_dir_all(temp.path().join("child/src")).unwrap();
let visible = vec!["child/foo".to_string(), "child/src".to_string()];
let result =
load_workspace_submodules(&temp.path().join("child"), "child", &visible).unwrap();
assert!(
result.is_empty(),
"a repository's declarations belong to it alone, got: {result:?}"
);
}
#[test]
fn test9606_nested_submodules_are_reported_under_their_full_path() {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("libs/core/vendor/dep")).unwrap();
fs::write(
temp.path().join("libs/core/.gitmodules"),
r#"[submodule "dep"]
path = vendor/dep
url = https://github.com/example/dep.git
"#,
)
.unwrap();
fs::write(
temp.path().join("libs/core/vendor/dep/version.txt"),
"1.2.3\n",
)
.unwrap();
let visible = vec!["libs/core/vendor/dep".to_string()];
let result =
load_workspace_submodules(&temp.path().join("libs/core"), "libs/core", &visible)
.unwrap();
assert_eq!(result.len(), 1);
let nested = result
.get("libs/core/vendor/dep")
.expect("the nested submodule is keyed by its workspace-relative path");
assert_eq!(nested.name, "dep");
assert_eq!(nested.version.as_deref(), Some("1.2.3"));
}
#[test]
fn test9607_a_plain_directory_declares_no_submodules() {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("fixtures/sub")).unwrap();
fs::write(
temp.path().join("fixtures/.gitmodules"),
r#"[submodule "sub"]
path = sub
url = https://github.com/example/sub.git
"#,
)
.unwrap();
assert!(!is_repository_root(&temp.path().join("fixtures")));
fs::create_dir_all(temp.path().join("repo/.git")).unwrap();
assert!(is_repository_root(&temp.path().join("repo")));
fs::create_dir_all(temp.path().join("sub")).unwrap();
fs::write(
temp.path().join("sub/.git"),
"gitdir: ../.git/modules/sub\n",
)
.unwrap();
assert!(is_repository_root(&temp.path().join("sub")));
}
#[test]
fn load_uninitialized_submodules_only_reports_missing_root_children() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join(".gitmodules"),
r#"[submodule "sub"]
path = sub
url = https://github.com/example/sub.git
[submodule "initialized"]
path = initialized
url = https://github.com/example/initialized.git
"#,
)
.unwrap();
fs::create_dir_all(temp.path().join("initialized")).unwrap();
let root_visible_dirs = vec!["initialized".to_string()];
let result = load_uninitialized_submodules(temp.path(), "", &root_visible_dirs).unwrap();
assert_eq!(result.len(), 1, "only 'sub' is uninitialized");
assert_eq!(result[0].path, "sub");
}
#[test]
fn test9647_an_unreadable_gitmodules_fails_the_listing_that_declares_it() {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join(".git")).unwrap();
fs::create_dir_all(temp.path().join("libs")).unwrap();
fs::write(temp.path().join(".gitmodules"), "path = libs\n").unwrap();
let planned = plan_tree_batch(&[String::new()]).unwrap();
let trees = read_listings(temp.path(), planned, &HashMap::new());
match &trees[0] {
WorkspaceTreeResult::Failed { path, error } => {
assert_eq!(path, "");
assert!(error.contains(".gitmodules"), "got: {error}");
}
other => panic!("an unreadable .gitmodules must fail the listing, got {other:?}"),
}
}
#[test]
fn test9638_a_batch_refuses_bad_requests_and_isolates_bad_paths() {
let empty = plan_tree_batch(&[]).unwrap_err();
assert_eq!(empty.0, StatusCode::BAD_REQUEST);
assert!(empty.1.error.contains("at least one path"));
let too_many: Vec<String> = (0..=MAX_TREE_BATCH).map(|i| format!("dir{i}")).collect();
let refused = plan_tree_batch(&too_many).unwrap_err();
assert_eq!(refused.0, StatusCode::BAD_REQUEST);
assert!(refused.1.error.contains(&MAX_TREE_BATCH.to_string()));
let escaping = plan_tree_batch(&["../outside".to_string()]).unwrap_err();
assert_eq!(escaping.0, StatusCode::BAD_REQUEST);
let planned =
plan_tree_batch(&["src".to_string(), "./src".to_string(), "docs".to_string()]).unwrap();
assert_eq!(
planned
.iter()
.map(|(path, _)| path.as_str())
.collect::<Vec<_>>(),
vec!["src", "docs"]
);
}
#[test]
fn test9639_one_unreadable_listing_does_not_lose_the_others() {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("src")).unwrap();
fs::write(temp.path().join("src/main.rs"), "fn main() {}\n").unwrap();
fs::write(temp.path().join("README.md"), "docs\n").unwrap();
let planned = plan_tree_batch(&[
String::new(),
"src".to_string(),
"gone".to_string(),
"README.md".to_string(),
])
.unwrap();
let trees = read_listings(temp.path(), planned, &HashMap::new());
assert_eq!(trees.len(), 4);
match &trees[0] {
WorkspaceTreeResult::Listed { path, entries } => {
assert_eq!(path, "");
assert!(entries.iter().any(|e| e.path == "src"));
assert!(entries.iter().any(|e| e.path == "README.md"));
}
other => panic!("the root listing should have been read, got {other:?}"),
}
match &trees[1] {
WorkspaceTreeResult::Listed { path, entries } => {
assert_eq!(path, "src");
assert_eq!(
entries.iter().map(|e| e.path.as_str()).collect::<Vec<_>>(),
vec!["src/main.rs"]
);
}
other => panic!("src should have been read, got {other:?}"),
}
match &trees[2] {
WorkspaceTreeResult::Failed { path, error } => {
assert_eq!(path, "gone");
assert!(error.contains("no longer exists"), "got: {error}");
}
other => panic!("a missing directory should fail, got {other:?}"),
}
match &trees[3] {
WorkspaceTreeResult::Failed { path, error } => {
assert_eq!(path, "README.md");
assert!(error.contains("not a directory"), "got: {error}");
}
other => panic!("a file is not a listing, got {other:?}"),
}
}
#[test]
fn test9632_a_repo_status_names_its_listing_and_its_subtree() {
let superproject = WorkspaceChangedEvent::repo_status(".");
assert_eq!(superproject.listings, vec![String::new()]);
assert_eq!(superproject.subtree.as_deref(), Some(""));
let top_level = WorkspaceChangedEvent::repo_status("capdag");
assert_eq!(top_level.listings, vec![String::new()]);
assert_eq!(top_level.subtree.as_deref(), Some("capdag"));
let nested = WorkspaceChangedEvent::repo_status("capdag/capdag-rs");
assert_eq!(nested.listings, vec!["capdag".to_string()]);
assert_eq!(nested.subtree.as_deref(), Some("capdag/capdag-rs"));
let deep = WorkspaceChangedEvent::repo_status("libs/core/vendor/dep");
assert_eq!(deep.listings, vec!["libs/core/vendor".to_string()]);
assert_eq!(deep.subtree.as_deref(), Some("libs/core/vendor/dep"));
}
#[test]
fn test9637_a_content_change_names_no_subtree() {
let created = WorkspaceChangedEvent::listings(vec!["src/web".to_string()]);
assert_eq!(created.listings, vec!["src/web".to_string()]);
assert_eq!(created.subtree, None);
let lost = WorkspaceChangedEvent::everything();
assert_eq!(lost.listings, vec![String::new()]);
assert_eq!(lost.subtree.as_deref(), Some(""));
}
#[test]
fn test9615_update_targets_are_submodules_at_any_depth() {
let temp = TempDir::new().unwrap();
fs::write(
temp.path().join(".gitmodules"),
"[submodule \"core\"]\n\tpath = libs/core\n\turl = https://github.com/example/core.git\n",
)
.unwrap();
fs::create_dir_all(temp.path().join("libs/core/.git")).unwrap();
fs::write(
temp.path().join("libs/core/.gitmodules"),
"[submodule \"dep\"]\n\tpath = vendor/dep\n\turl = https://github.com/example/dep.git\n",
)
.unwrap();
fs::create_dir_all(temp.path().join("libs/core/vendor/dep")).unwrap();
fs::create_dir_all(temp.path().join("libs/core/src")).unwrap();
assert!(validate_update_target(temp.path(), Path::new("libs/core")).is_ok());
assert!(validate_update_target(temp.path(), Path::new("libs/core/vendor/dep")).is_ok());
assert!(validate_update_target(temp.path(), Path::new("")).is_ok());
let error = validate_update_target(temp.path(), Path::new("libs/core/src")).unwrap_err();
assert_eq!(error.0, StatusCode::BAD_REQUEST);
assert!(error.1.error.contains("is not a submodule root"));
}
#[test]
fn test9608_uninitialized_nested_submodules_keep_their_full_path() {
let temp = TempDir::new().unwrap();
fs::create_dir_all(temp.path().join("libs/core")).unwrap();
fs::write(
temp.path().join("libs/core/.gitmodules"),
r#"[submodule "dep"]
path = vendor/dep
url = https://github.com/example/dep.git
"#,
)
.unwrap();
let result =
load_uninitialized_submodules(&temp.path().join("libs/core"), "libs/core", &[])
.unwrap();
assert_eq!(result.len(), 1);
assert_eq!(result[0].path, "libs/core/vendor/dep");
assert_eq!(result[0].name, "dep");
}
}