diff --git a/crates/liboxen/src/model/staged_dir_stats.rs b/crates/liboxen/src/model/staged_dir_stats.rs index c43809a00a..13568b34dc 100644 --- a/crates/liboxen/src/model/staged_dir_stats.rs +++ b/crates/liboxen/src/model/staged_dir_stats.rs @@ -2,12 +2,14 @@ use std::hash::{Hash, Hasher}; use std::path::PathBuf; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use super::StagedEntryStatus; // Used for a quick summary of directory -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] pub struct StagedDirStats { + #[schema(value_type = String)] pub path: PathBuf, pub num_files_staged: usize, pub total_files: usize, diff --git a/crates/liboxen/src/model/summarized_staged_dir_stats.rs b/crates/liboxen/src/model/summarized_staged_dir_stats.rs index 5ac0ee9ccf..d72d204d4c 100644 --- a/crates/liboxen/src/model/summarized_staged_dir_stats.rs +++ b/crates/liboxen/src/model/summarized_staged_dir_stats.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use crate::model::StagedDirStats; @@ -22,10 +23,11 @@ use std::path::{Path, PathBuf}; /// Rolled up to: /// annotations/ -> num_staged: 3, total: 4 -#[derive(Serialize, Deserialize, Debug, Clone)] +#[derive(Serialize, Deserialize, Debug, Clone, ToSchema)] pub struct SummarizedStagedDirStats { pub num_files_staged: usize, pub total_files: usize, + #[schema(value_type = HashMap>)] pub paths: HashMap>, } diff --git a/crates/liboxen/src/view/remote_staged_status.rs b/crates/liboxen/src/view/remote_staged_status.rs index 0088e0ff2b..e05295b415 100644 --- a/crates/liboxen/src/view/remote_staged_status.rs +++ b/crates/liboxen/src/view/remote_staged_status.rs @@ -1,6 +1,7 @@ use std::{collections::HashMap, path::PathBuf}; use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; use crate::{ model::{ @@ -13,7 +14,7 @@ use crate::{ use super::{PaginatedDirEntries, StatusMessage, entries::EMetadataEntry}; // TODO: Removed dirs -#[derive(Deserialize, Serialize, Debug)] +#[derive(Deserialize, Serialize, Debug, ToSchema)] pub struct RemoteStagedStatus { pub added_dirs: SummarizedStagedDirStats, pub added_files: PaginatedDirEntries, @@ -21,7 +22,7 @@ pub struct RemoteStagedStatus { pub removed_files: PaginatedDirEntries, } -#[derive(Deserialize, Serialize, Debug)] +#[derive(Deserialize, Serialize, Debug, ToSchema)] pub struct RemoteStagedStatusResponse { #[serde(flatten)] pub status: StatusMessage, diff --git a/crates/oxen-py/src/py_workspace.rs b/crates/oxen-py/src/py_workspace.rs index f160684e41..f6e0abf7a6 100644 --- a/crates/oxen-py/src/py_workspace.rs +++ b/crates/oxen-py/src/py_workspace.rs @@ -171,6 +171,14 @@ impl PyWorkspace { Ok(()) } + fn unstage(&self, path: PathBuf) -> Result<(), PyOxenError> { + pyo3_async_runtimes::tokio::get_runtime().block_on(async { + api::client::workspaces::changes::rm(self.repo.repo()?, &self.get_identifier(), path) + .await + })?; + Ok(()) + } + fn delete(&self) -> Result<(), PyOxenError> { pyo3_async_runtimes::tokio::get_runtime().block_on(async { api::client::workspaces::delete(self.repo.repo()?, &self.id).await diff --git a/crates/oxen-server/src/controllers/workspaces.rs b/crates/oxen-server/src/controllers/workspaces.rs index 35f42e463e..bac595a09f 100644 --- a/crates/oxen-server/src/controllers/workspaces.rs +++ b/crates/oxen-server/src/controllers/workspaces.rs @@ -392,7 +392,8 @@ pub async fn mergeability(req: HttpRequest) -> Result Result { diff --git a/crates/oxen-server/src/controllers/workspaces/changes.rs b/crates/oxen-server/src/controllers/workspaces/changes.rs index ffcf250d9e..a9be0c8b5d 100644 --- a/crates/oxen-server/src/controllers/workspaces/changes.rs +++ b/crates/oxen-server/src/controllers/workspaces/changes.rs @@ -18,6 +18,25 @@ use actix_web::{HttpRequest, HttpResponse, web}; use std::path::PathBuf; +/// List staged changes in a workspace +#[utoipa::path( + get, + path = "/api/repos/{namespace}/{repo_name}/workspaces/{workspace_id}/changes", + description = "List the staged changes (added, modified, and removed files) in a workspace. The added, modified, and removed lists are each paginated independently, with the same page and page_size applied to each list.", + tag = "Workspace Files", + params( + ("namespace" = String, Path, description = "The namespace of the repository", example = "ox"), + ("repo_name" = String, Path, description = "The name of the repository", example = "ImageNet-1k"), + ("workspace_id" = String, Path, description = "The UUID of the workspace", example = "580c0587-c157-417b-9118-8686d63d2745"), + ("page" = Option, Query, description = "Page number for pagination (default 1)"), + ("page_size" = Option, Query, description = "Number of entries per page (default 100, must be at least 1)") + ), + responses( + (status = 200, description = "Staged changes in the workspace", body = RemoteStagedStatusResponse), + (status = 400, description = "Invalid page_size"), + (status = 404, description = "Workspace not found") + ) +)] pub async fn list_root( req: HttpRequest, query: web::Query, @@ -31,6 +50,11 @@ pub async fn list_root( let repo = get_repo(app_data, namespace, repo_name)?; let page_num = query.page.unwrap_or(constants::DEFAULT_PAGE_NUM); let page_size = query.page_size.unwrap_or(constants::DEFAULT_PAGE_SIZE); + if page_size == 0 { + return Err(OxenHttpError::BadRequest( + "page_size must be at least 1".into(), + )); + } log::debug!("/changes looking up workspace_id: {workspace_id}"); let Some(workspace) = repositories::workspaces::get(&repo, &workspace_id)? else { @@ -55,6 +79,26 @@ pub async fn list_root( Ok(HttpResponse::Ok().json(response)) } +/// List staged changes under a directory in a workspace +#[utoipa::path( + get, + path = "/api/repos/{namespace}/{repo_name}/workspaces/{workspace_id}/changes/{path}", + description = "List the staged changes (added, modified, and removed files) under a directory in a workspace. The added, modified, and removed lists are each paginated independently, with the same page and page_size applied to each list.", + tag = "Workspace Files", + params( + ("namespace" = String, Path, description = "The namespace of the repository", example = "ox"), + ("repo_name" = String, Path, description = "The name of the repository", example = "ImageNet-1k"), + ("workspace_id" = String, Path, description = "The UUID of the workspace", example = "580c0587-c157-417b-9118-8686d63d2745"), + ("path" = String, Path, description = "The directory to list staged changes under", example = "images/train"), + ("page" = Option, Query, description = "Page number for pagination (default 1)"), + ("page_size" = Option, Query, description = "Number of entries per page (default 100, must be at least 1)") + ), + responses( + (status = 200, description = "Staged changes under the directory", body = RemoteStagedStatusResponse), + (status = 400, description = "Invalid page_size"), + (status = 404, description = "Workspace not found") + ) +)] pub async fn list( req: HttpRequest, query: web::Query, @@ -69,6 +113,11 @@ pub async fn list( let path = PathBuf::from(path_param(&req, "path")?); let page_num = query.page.unwrap_or(constants::DEFAULT_PAGE_NUM); let page_size = query.page_size.unwrap_or(constants::DEFAULT_PAGE_SIZE); + if page_size == 0 { + return Err(OxenHttpError::BadRequest( + "page_size must be at least 1".into(), + )); + } log::debug!("/changes looking up workspace_id: {workspace_id}"); let Some(workspace) = repositories::workspaces::get(&repo, &workspace_id)? else { diff --git a/crates/oxen-server/src/errors.rs b/crates/oxen-server/src/errors.rs index af7cf93cb5..c037bf484a 100644 --- a/crates/oxen-server/src/errors.rs +++ b/crates/oxen-server/src/errors.rs @@ -206,7 +206,7 @@ impl error::ResponseError for OxenHttpError { "status_message": MSG_CONFLICT, }); - HttpResponse::NotFound().json(error_json) + HttpResponse::Conflict().json(error_json) } OxenHttpError::DatasetAlreadyIndexed(path) => { let error_json = json!({ diff --git a/crates/oxen-server/src/main.rs b/crates/oxen-server/src/main.rs index e3622c9e5a..c5869dae35 100644 --- a/crates/oxen-server/src/main.rs +++ b/crates/oxen-server/src/main.rs @@ -141,6 +141,8 @@ const START_SERVER_USAGE: &str = "Usage: `oxen-server start -i 0.0.0.0 -p 3000`" crate::controllers::workspaces::mergeability, crate::controllers::workspaces::commit, // Workspaces - changes + crate::controllers::workspaces::changes::list_root, + crate::controllers::workspaces::changes::list, crate::controllers::workspaces::changes::unstage, crate::controllers::workspaces::changes::unstage_many, // Workspaces - files diff --git a/oxen-python/python/oxen/workspace.py b/oxen-python/python/oxen/workspace.py index 64386ef473..7c857880a6 100644 --- a/oxen-python/python/oxen/workspace.py +++ b/oxen-python/python/oxen/workspace.py @@ -285,14 +285,28 @@ def add_bytes(self, src: str, buf: bytes, dst: str = "") -> None: def rm(self, path: str) -> None: """ - Remove a file from the workspace + Unstage a file that was previously added to the workspace. + Despite the name, this does not stage a deletion of a file in + the base repo. Prefer `unstage`, which does the same thing + through the non-deprecated endpoint. Args: path: `str` - The path to the file on workspace to be removed + The path to the staged file to unstage """ self._workspace.rm(path) + def unstage(self, path: str) -> None: + """ + Unstage a file that was previously added to the workspace, + without touching the base repo. + + Args: + path: `str` + The path to the staged file to unstage + """ + self._workspace.unstage(path) + def commit( self, message: str,