diff --git a/src/workspace.rs b/src/workspace.rs --- a/src/workspace.rs +++ b/src/workspace.rs @@ -1,449 +1,574 @@ -use std::path::{Path, PathBuf}; - -use serde::{Deserialize, Serialize}; - -use crate::api::Repository; -use crate::collab_wire::{ - AttachRepositoryRequest, ConfigureEnvironmentRequest, CreateWorkspaceRequest, - GetCheckoutRequest, ListEnvironmentsRequest, ListWorkspacesRequest, OwnerKind, RepositoryRole, - Workspace, -}; -use crate::error::{Error, Result}; -use crate::{OwnerKindArgument, RepositoryRoleArgument}; - -pub async fn create( - host: &str, - name: &str, - slug: Option<&str>, - owner_kind: OwnerKindArgument, - owner_id: Option<&str>, - environment: &str, - branch: &str, -) -> Result<()> { - let workspace = create_remote( - host, - name, - slug.unwrap_or(&crate::collaboration::slug(name)), - owner_kind, - owner_id.unwrap_or_default(), - environment, - branch, - ) - .await?; - println!("{}\t{}\t{}", workspace.id, workspace.slug, workspace.name); - Ok(()) -} - -pub async fn list(host: &str) -> Result<()> { - let mut client = crate::collaboration::connect(host).await?; - let response = client - .grpc - .list_workspaces(ListWorkspacesRequest { - session_token: client.session_token, - }) - .await - .map_err(crate::collaboration::error)? - .into_inner(); - for workspace in response.workspaces { - println!("{}\t{}\t{}", workspace.id, workspace.slug, workspace.name); - } - Ok(()) -} - -pub async fn configure_environment( - host: &str, - workspace_id: &str, - name: &str, - source_branch: &str, - infra_branch: Option<&str>, -) -> Result<()> { - let mut client = crate::collaboration::connect(host).await?; - let environment = client - .grpc - .configure_environment(ConfigureEnvironmentRequest { - session_token: client.session_token, - workspace_id: workspace_id.to_owned(), - name: name.to_owned(), - default_source_branch: source_branch.to_owned(), - infra_branch: infra_branch.unwrap_or_default().to_owned(), - }) - .await - .map_err(crate::collaboration::error)? - .into_inner(); - println!( - "{}\t{}\t{}", - environment.name, environment.default_source_branch, environment.infra_branch - ); - Ok(()) -} - -pub async fn list_environments(host: &str, workspace_id: &str) -> Result<()> { - let mut client = crate::collaboration::connect(host).await?; - let response = client - .grpc - .list_environments(ListEnvironmentsRequest { - session_token: client.session_token, - workspace_id: workspace_id.to_owned(), - }) - .await - .map_err(crate::collaboration::error)? - .into_inner(); - for environment in response.environments { - println!( - "{}\t{}\t{}", - environment.name, environment.default_source_branch, environment.infra_branch - ); - } - Ok(()) -} - -pub async fn add_repository( - host: &str, - workspace_id: &str, - repository: &str, - role: RepositoryRoleArgument, - branch: &str, -) -> Result<()> { - let repository = Repository::parse(repository)?; - let mut client = crate::collaboration::connect(host).await?; - client - .grpc - .attach_repository(AttachRepositoryRequest { - session_token: client.session_token, - workspace_id: workspace_id.to_owned(), - owner: repository.owner, - name: repository.name, - role: match role { - RepositoryRoleArgument::Source => RepositoryRole::Source.into(), - RepositoryRoleArgument::Infra => RepositoryRole::Infra.into(), - }, - branch: branch.to_owned(), - }) - .await - .map_err(crate::collaboration::error)?; - Ok(()) -} - -pub async fn checkout( - host: &str, - workspace_id: &str, - directory: Option<&Path>, - environment: Option<&str>, - partial: bool, - as_agent: bool, -) -> Result<()> { - let mut client = crate::collaboration::connect(host).await?; - let checkout = client - .grpc - .get_checkout(GetCheckoutRequest { - session_token: client.session_token, - workspace_id: workspace_id.to_owned(), - environment: environment.unwrap_or_default().to_owned(), - partial, - }) - .await - .map_err(crate::collaboration::error)? - .into_inner(); - let workspace = checkout - .workspace - .ok_or_else(|| Error::Command("collaboration returned no workspace".to_owned()))?; - let root = directory - .map(Path::to_path_buf) - .unwrap_or_else(|| PathBuf::from(&workspace.slug)); - std::fs::create_dir_all(&root)?; - let mut repositories = Vec::new(); - for repository in checkout.repositories { - let role = RepositoryRole::try_from(repository.role) - .map_err(|_| Error::Command("collaboration returned an invalid role".to_owned()))?; - let target = match role { - RepositoryRole::Source => root.join("sources").join(&repository.name), - RepositoryRole::Infra => root.join("infra"), - RepositoryRole::Unspecified => { - return Err(Error::Command( - "collaboration returned an unspecified role".to_owned(), - )); - } - }; - if target.exists() { - crate::repository::reconcile_branch( - host, - &format!("{}/{}", repository.owner, repository.name), - &target, - &repository.branch, - as_agent, - ) - .await?; - } else { - if let Some(parent) = target.parent() { - std::fs::create_dir_all(parent)?; - } - crate::repository::clone_branch( - host, - &format!("{}/{}", repository.owner, repository.name), - &target, - &repository.branch, - as_agent, - ) - .await?; - } - repositories.push(LocalRepository { - id: repository.repository_id, - path: target - .strip_prefix(&root) - .map_err(|_| Error::Command("workspace path escaped its root".to_owned()))? - .to_string_lossy() - .into_owned(), - branch: repository.branch, - }); - } - write_state( - &root, - &WorkspaceState { - workspace_id: workspace.id, - default_project_id: None, - environment: checkout.environment, - partial: checkout.omitted_repositories > 0, - omitted_repositories: checkout.omitted_repositories, - repositories, - }, - )?; - println!("{}", root.display()); - if checkout.omitted_repositories > 0 { - println!("omitted\t{}", checkout.omitted_repositories); - } - Ok(()) -} - -pub async fn init( - host: &str, - name: Option<&str>, - key: Option<&str>, - environment: &str, -) -> Result<()> { - let root = git_output(&["rev-parse", "--show-toplevel"])?; - let root = PathBuf::from(root); - let remote = git_output(&["-C", path(&root)?, "remote", "get-url", "origin"])?; - let repository = repository_from_remote(&remote)?; - let branch = git_output(&["-C", path(&root)?, "branch", "--show-current"])?; - if branch.is_empty() { - return Err(Error::Configuration( - "detached HEAD cannot initialize a workspace".to_owned(), - )); - } - let name = name.unwrap_or(&repository.name); - let slug = crate::collaboration::slug(name); - let workspace = create_remote( - host, - name, - &slug, - OwnerKindArgument::User, - "", - environment, - &branch, - ) - .await?; - add_repository( - host, - &workspace.id, - &repository.to_string(), - RepositoryRoleArgument::Source, - &branch, - ) - .await?; - let repository_id = attached_repository_id( - host, - &workspace.id, - environment, - &repository.owner, - &repository.name, - ) - .await?; - let project_key = key.map(str::to_owned).unwrap_or_else(|| default_key(name)); - let project = crate::project::create_remote( - host, - &workspace.id, - &project_key, - name, - &slug, - "", - crate::ProjectKindArgument::Continuous, - ) - .await?; - write_state( - &root, - &WorkspaceState { - workspace_id: workspace.id.clone(), - default_project_id: Some(project.id), - environment: environment.to_owned(), - partial: false, - omitted_repositories: 0, - repositories: vec![LocalRepository { - id: repository_id, - path: ".".to_owned(), - branch, - }], - }, - )?; - println!("{}\t{}", workspace.id, root.display()); - Ok(()) -} - -async fn attached_repository_id( - host: &str, - workspace_id: &str, - environment: &str, - owner: &str, - name: &str, -) -> Result { - let mut client = crate::collaboration::connect(host).await?; - let checkout = client - .grpc - .get_checkout(GetCheckoutRequest { - session_token: client.session_token, - workspace_id: workspace_id.to_owned(), - environment: environment.to_owned(), - partial: false, - }) - .await - .map_err(crate::collaboration::error)? - .into_inner(); - checkout - .repositories - .into_iter() - .find(|repository| repository.owner == owner && repository.name == name) - .map(|repository| repository.repository_id) - .ok_or_else(|| { - Error::Command("attached repository is missing from the checkout plan".to_owned()) - }) -} - -async fn create_remote( - host: &str, - name: &str, - slug: &str, - owner_kind: OwnerKindArgument, - owner_id: &str, - environment: &str, - branch: &str, -) -> Result { - let mut client = crate::collaboration::connect(host).await?; - client - .grpc - .create_workspace(CreateWorkspaceRequest { - session_token: client.session_token, - owner_kind: match owner_kind { - OwnerKindArgument::User => OwnerKind::User.into(), - OwnerKindArgument::Organization => OwnerKind::Organization.into(), - }, - owner_id: owner_id.to_owned(), - name: name.to_owned(), - slug: slug.to_owned(), - default_environment: environment.to_owned(), - default_source_branch: branch.to_owned(), - }) - .await - .map_err(crate::collaboration::error) - .map(tonic::Response::into_inner) -} - -#[derive(Deserialize, Serialize)] -struct WorkspaceState { - workspace_id: String, - #[serde(default)] - default_project_id: Option, - environment: String, - partial: bool, - omitted_repositories: u32, - repositories: Vec, -} - -pub fn workspace_context(explicit: Option<&str>) -> Result { - explicit - .map(str::to_owned) - .map_or_else(|| read_context().map(|state| state.workspace_id), Ok) -} - -pub fn project_context(explicit: Option<&str>) -> Result { - if let Some(project_id) = explicit { - return Ok(project_id.to_owned()); - } - read_context()? - .default_project_id - .ok_or_else(|| Error::Configuration("workspace has no default project".to_owned())) -} - -fn read_context() -> Result { - let mut directory = std::env::current_dir()?; - loop { - let path = directory.join(".syn/workspace.json"); - match std::fs::read(&path) { - Ok(bytes) => return Ok(serde_json::from_slice(&bytes)?), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} - Err(error) => return Err(error.into()), - } - if !directory.pop() { - return Err(Error::Configuration( - "no .syn/workspace.json found in this directory tree".to_owned(), - )); - } - } -} - -#[derive(Deserialize, Serialize)] -struct LocalRepository { - id: String, - path: String, - branch: String, -} - -fn write_state(root: &Path, state: &WorkspaceState) -> Result<()> { - let directory = root.join(".syn"); - std::fs::create_dir_all(&directory)?; - std::fs::write( - directory.join("workspace.json"), - serde_json::to_vec_pretty(state)?, - )?; - Ok(()) -} - -fn git_output(arguments: &[&str]) -> Result { - let output = std::process::Command::new("git").args(arguments).output()?; - if !output.status.success() { - return Err(Error::Command(format!("git exited with {}", output.status))); - } - String::from_utf8(output.stdout) - .map(|value| value.trim().to_owned()) - .map_err(|_| Error::Command("git returned non-UTF-8 output".to_owned())) -} - -fn path(value: &Path) -> Result<&str> { - value - .to_str() - .ok_or_else(|| Error::Configuration("workspace path is not UTF-8".to_owned())) -} - -fn repository_from_remote(remote: &str) -> Result { - let path = if let Ok(url) = url::Url::parse(remote) { - url.path().trim_start_matches('/').to_owned() - } else if let Some((_, path)) = remote.split_once(':') { - path.to_owned() - } else { - return Err(Error::Configuration( - "origin is not a supported Git URL".to_owned(), - )); - }; - Repository::parse(path.trim_end_matches(".git")) -} - -fn default_key(name: &str) -> String { - let mut key: String = name - .chars() - .filter(char::is_ascii_alphanumeric) - .take(3) - .flat_map(char::to_uppercase) - .collect(); - while key.len() < 2 { - key.push('X'); - } - key -} +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::api::Repository; +use crate::collab_wire::{ + AttachRepositoryRequest, ConfigureEnvironmentRequest, CreateWorkspaceRequest, + GetCheckoutRequest, ListEnvironmentsRequest, ListWorkspacesRequest, OwnerKind, RepositoryRole, + Workspace, +}; +use crate::error::{Error, Result}; +use crate::{OwnerKindArgument, RepositoryRoleArgument}; + +pub async fn create( + host: &str, + name: &str, + slug: Option<&str>, + owner_kind: OwnerKindArgument, + owner_id: Option<&str>, + environment: &str, + branch: &str, +) -> Result<()> { + let workspace = create_remote( + host, + name, + slug.unwrap_or(&crate::collaboration::slug(name)), + owner_kind, + owner_id.unwrap_or_default(), + environment, + branch, + ) + .await?; + println!("{}\t{}\t{}", workspace.id, workspace.slug, workspace.name); + Ok(()) +} + +pub async fn list(host: &str) -> Result<()> { + let mut client = crate::collaboration::connect(host).await?; + let response = client + .grpc + .list_workspaces(ListWorkspacesRequest { + session_token: client.session_token, + }) + .await + .map_err(crate::collaboration::error)? + .into_inner(); + for workspace in response.workspaces { + println!("{}\t{}\t{}", workspace.id, workspace.slug, workspace.name); + } + Ok(()) +} + +pub async fn configure_environment( + host: &str, + workspace_id: &str, + name: &str, + source_branch: &str, + infra_branch: Option<&str>, +) -> Result<()> { + let mut client = crate::collaboration::connect(host).await?; + let environment = client + .grpc + .configure_environment(ConfigureEnvironmentRequest { + session_token: client.session_token, + workspace_id: workspace_id.to_owned(), + name: name.to_owned(), + default_source_branch: source_branch.to_owned(), + infra_branch: infra_branch.unwrap_or_default().to_owned(), + }) + .await + .map_err(crate::collaboration::error)? + .into_inner(); + println!( + "{}\t{}\t{}", + environment.name, environment.default_source_branch, environment.infra_branch + ); + Ok(()) +} + +pub async fn list_environments(host: &str, workspace_id: &str) -> Result<()> { + let mut client = crate::collaboration::connect(host).await?; + let response = client + .grpc + .list_environments(ListEnvironmentsRequest { + session_token: client.session_token, + workspace_id: workspace_id.to_owned(), + }) + .await + .map_err(crate::collaboration::error)? + .into_inner(); + for environment in response.environments { + println!( + "{}\t{}\t{}", + environment.name, environment.default_source_branch, environment.infra_branch + ); + } + Ok(()) +} + +pub async fn add_repository( + host: &str, + workspace_id: &str, + repository: &str, + role: RepositoryRoleArgument, + branch: &str, +) -> Result<()> { + let repository = Repository::parse(repository)?; + let mut client = crate::collaboration::connect(host).await?; + client + .grpc + .attach_repository(AttachRepositoryRequest { + session_token: client.session_token, + workspace_id: workspace_id.to_owned(), + owner: repository.owner, + name: repository.name, + role: match role { + RepositoryRoleArgument::Source => RepositoryRole::Source.into(), + RepositoryRoleArgument::Infra => RepositoryRole::Infra.into(), + }, + branch: branch.to_owned(), + }) + .await + .map_err(crate::collaboration::error)?; + Ok(()) +} + +pub async fn checkout( + host: &str, + workspace_id: &str, + directory: Option<&Path>, + environment: Option<&str>, + partial: bool, + as_agent: bool, +) -> Result<()> { + let mut client = crate::collaboration::connect(host).await?; + let checkout = client + .grpc + .get_checkout(GetCheckoutRequest { + session_token: client.session_token, + workspace_id: workspace_id.to_owned(), + environment: environment.unwrap_or_default().to_owned(), + partial, + }) + .await + .map_err(crate::collaboration::error)? + .into_inner(); + let workspace = checkout + .workspace + .ok_or_else(|| Error::Command("collaboration returned no workspace".to_owned()))?; + let root = directory + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(&workspace.slug)); + let target = CheckoutTarget::prepare(root)?; + let root = target.path(); + let omitted_repositories = checkout.omitted_repositories; + let result = async { + let mut repositories = Vec::new(); + for repository in checkout.repositories { + let role = RepositoryRole::try_from(repository.role) + .map_err(|_| Error::Command("collaboration returned an invalid role".to_owned()))?; + let repository_target = match role { + RepositoryRole::Source => root.join("sources").join(&repository.name), + RepositoryRole::Infra => root.join("infra"), + RepositoryRole::Unspecified => { + return Err(Error::Command( + "collaboration returned an unspecified role".to_owned(), + )); + } + }; + if repository_target.exists() { + crate::repository::reconcile_branch( + host, + &format!("{}/{}", repository.owner, repository.name), + &repository_target, + &repository.branch, + as_agent, + ) + .await?; + } else { + if let Some(parent) = repository_target.parent() { + std::fs::create_dir_all(parent)?; + } + crate::repository::clone_branch( + host, + &format!("{}/{}", repository.owner, repository.name), + &repository_target, + &repository.branch, + as_agent, + ) + .await?; + } + repositories.push(LocalRepository { + id: repository.repository_id, + path: repository_target + .strip_prefix(root) + .map_err(|_| Error::Command("workspace path escaped its root".to_owned()))? + .to_string_lossy() + .into_owned(), + branch: repository.branch, + }); + } + write_state( + root, + &WorkspaceState { + workspace_id: workspace.id, + default_project_id: None, + environment: checkout.environment, + partial: omitted_repositories > 0, + omitted_repositories, + repositories, + }, + ) + } + .await; + if let Err(error) = result { + if let Err(cleanup) = target.rollback() { + return Err(Error::Command(format!( + "{error}; cannot remove the incomplete checkout: {cleanup}" + ))); + } + return Err(error); + } + let root = target.commit()?; + println!("{}", root.display()); + if omitted_repositories > 0 { + println!("omitted\t{omitted_repositories}"); + } + Ok(()) +} + +pub async fn init( + host: &str, + name: Option<&str>, + key: Option<&str>, + environment: &str, +) -> Result<()> { + let root = git_output(&["rev-parse", "--show-toplevel"])?; + let root = PathBuf::from(root); + let remote = git_output(&["-C", path(&root)?, "remote", "get-url", "origin"])?; + let repository = repository_from_remote(&remote)?; + let branch = git_output(&["-C", path(&root)?, "branch", "--show-current"])?; + if branch.is_empty() { + return Err(Error::Configuration( + "detached HEAD cannot initialize a workspace".to_owned(), + )); + } + let name = name.unwrap_or(&repository.name); + let slug = crate::collaboration::slug(name); + let workspace = create_remote( + host, + name, + &slug, + OwnerKindArgument::User, + "", + environment, + &branch, + ) + .await?; + add_repository( + host, + &workspace.id, + &repository.to_string(), + RepositoryRoleArgument::Source, + &branch, + ) + .await?; + let repository_id = attached_repository_id( + host, + &workspace.id, + environment, + &repository.owner, + &repository.name, + ) + .await?; + let project_key = key.map(str::to_owned).unwrap_or_else(|| default_key(name)); + let project = crate::project::create_remote( + host, + &workspace.id, + &project_key, + name, + &slug, + "", + crate::ProjectKindArgument::Continuous, + ) + .await?; + write_state( + &root, + &WorkspaceState { + workspace_id: workspace.id.clone(), + default_project_id: Some(project.id), + environment: environment.to_owned(), + partial: false, + omitted_repositories: 0, + repositories: vec![LocalRepository { + id: repository_id, + path: ".".to_owned(), + branch, + }], + }, + )?; + println!("{}\t{}", workspace.id, root.display()); + Ok(()) +} + +async fn attached_repository_id( + host: &str, + workspace_id: &str, + environment: &str, + owner: &str, + name: &str, +) -> Result { + let mut client = crate::collaboration::connect(host).await?; + let checkout = client + .grpc + .get_checkout(GetCheckoutRequest { + session_token: client.session_token, + workspace_id: workspace_id.to_owned(), + environment: environment.to_owned(), + partial: false, + }) + .await + .map_err(crate::collaboration::error)? + .into_inner(); + checkout + .repositories + .into_iter() + .find(|repository| repository.owner == owner && repository.name == name) + .map(|repository| repository.repository_id) + .ok_or_else(|| { + Error::Command("attached repository is missing from the checkout plan".to_owned()) + }) +} + +async fn create_remote( + host: &str, + name: &str, + slug: &str, + owner_kind: OwnerKindArgument, + owner_id: &str, + environment: &str, + branch: &str, +) -> Result { + let mut client = crate::collaboration::connect(host).await?; + client + .grpc + .create_workspace(CreateWorkspaceRequest { + session_token: client.session_token, + owner_kind: match owner_kind { + OwnerKindArgument::User => OwnerKind::User.into(), + OwnerKindArgument::Organization => OwnerKind::Organization.into(), + }, + owner_id: owner_id.to_owned(), + name: name.to_owned(), + slug: slug.to_owned(), + default_environment: environment.to_owned(), + default_source_branch: branch.to_owned(), + }) + .await + .map_err(crate::collaboration::error) + .map(tonic::Response::into_inner) +} + +#[derive(Deserialize, Serialize)] +struct WorkspaceState { + workspace_id: String, + #[serde(default)] + default_project_id: Option, + environment: String, + partial: bool, + omitted_repositories: u32, + repositories: Vec, +} + +pub fn workspace_context(explicit: Option<&str>) -> Result { + explicit + .map(str::to_owned) + .map_or_else(|| read_context().map(|state| state.workspace_id), Ok) +} + +pub fn project_context(explicit: Option<&str>) -> Result { + if let Some(project_id) = explicit { + return Ok(project_id.to_owned()); + } + read_context()? + .default_project_id + .ok_or_else(|| Error::Configuration("workspace has no default project".to_owned())) +} + +fn read_context() -> Result { + let mut directory = std::env::current_dir()?; + loop { + let path = directory.join(".syn/workspace.json"); + match std::fs::read(&path) { + Ok(bytes) => return Ok(serde_json::from_slice(&bytes)?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + if !directory.pop() { + return Err(Error::Configuration( + "no .syn/workspace.json found in this directory tree".to_owned(), + )); + } + } +} + +#[derive(Deserialize, Serialize)] +struct LocalRepository { + id: String, + path: String, + branch: String, +} + +fn write_state(root: &Path, state: &WorkspaceState) -> Result<()> { + let directory = root.join(".syn"); + std::fs::create_dir_all(&directory)?; + std::fs::write( + directory.join("workspace.json"), + serde_json::to_vec_pretty(state)?, + )?; + Ok(()) +} + +fn git_output(arguments: &[&str]) -> Result { + let output = std::process::Command::new("git").args(arguments).output()?; + if !output.status.success() { + return Err(Error::Command(format!("git exited with {}", output.status))); + } + String::from_utf8(output.stdout) + .map(|value| value.trim().to_owned()) + .map_err(|_| Error::Command("git returned non-UTF-8 output".to_owned())) +} + +fn path(value: &Path) -> Result<&str> { + value + .to_str() + .ok_or_else(|| Error::Configuration("workspace path is not UTF-8".to_owned())) +} + +fn repository_from_remote(remote: &str) -> Result { + let path = if let Ok(url) = url::Url::parse(remote) { + url.path().trim_start_matches('/').to_owned() + } else if let Some((_, path)) = remote.split_once(':') { + path.to_owned() + } else { + return Err(Error::Configuration( + "origin is not a supported Git URL".to_owned(), + )); + }; + Repository::parse(path.trim_end_matches(".git")) +} + +fn default_key(name: &str) -> String { + let mut key: String = name + .chars() + .filter(char::is_ascii_alphanumeric) + .take(3) + .flat_map(char::to_uppercase) + .collect(); + while key.len() < 2 { + key.push('X'); + } + key +} + +enum CheckoutTarget { + Fresh { + root: PathBuf, + staging: PathBuf, + root_existed: bool, + }, + Existing(PathBuf), +} + +impl CheckoutTarget { + fn prepare(root: PathBuf) -> Result { + let root_existed = match std::fs::symlink_metadata(&root) { + Ok(metadata) if metadata.is_dir() => true, + Ok(_) => { + return Err(Error::Configuration(format!( + "{} is not a directory", + root.display() + ))); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => false, + Err(error) => return Err(error.into()), + }; + if root_existed && std::fs::read_dir(&root)?.next().transpose()?.is_some() { + return Ok(Self::Existing(root)); + } + let parent = root + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + std::fs::create_dir_all(parent)?; + let staging = parent.join(format!(".syncode-checkout-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&staging)?; + Ok(Self::Fresh { + root, + staging, + root_existed, + }) + } + + fn path(&self) -> &Path { + match self { + Self::Fresh { staging, .. } => staging, + Self::Existing(root) => root, + } + } + + fn rollback(&self) -> std::io::Result<()> { + match self { + Self::Fresh { staging, .. } => std::fs::remove_dir_all(staging), + Self::Existing(_) => Ok(()), + } + } + + fn commit(self) -> Result { + match self { + Self::Fresh { + root, + staging, + root_existed, + } => { + if root_existed { + std::fs::remove_dir(&root)?; + } + if let Err(error) = std::fs::rename(&staging, &root) { + if root_existed { + std::fs::create_dir(&root)?; + } + return Err(error.into()); + } + Ok(root) + } + Self::Existing(root) => Ok(root), + } + } +} + +#[cfg(test)] +mod tests { + use super::CheckoutTarget; + + #[test] + fn a_fresh_checkout_uses_a_staging_directory() -> Result<(), Box> { + let root = std::env::temp_dir().join(format!("syncode-cli-{}", uuid::Uuid::new_v4())); + let target = CheckoutTarget::prepare(root.clone())?; + + assert_ne!(root, target.path()); + let staging = target.path().to_owned(); + target.rollback()?; + assert!(!root.exists()); + assert!(!staging.exists()); + + Ok(()) + } + + #[test] + fn a_fresh_checkout_replaces_an_empty_target_only_on_commit() + -> Result<(), Box> { + let root = std::env::temp_dir().join(format!("syncode-cli-{}", uuid::Uuid::new_v4())); + std::fs::create_dir(&root)?; + let target = CheckoutTarget::prepare(root.clone())?; + std::fs::write(target.path().join("complete"), "yes")?; + + assert!(std::fs::read_dir(&root)?.next().is_none()); + assert_eq!(root, target.commit()?); + assert_eq!("yes", std::fs::read_to_string(root.join("complete"))?); + + std::fs::remove_dir_all(root)?; + Ok(()) + } +}