Skip to main content

liboxen/repositories/
workspaces.rs

1use crate::config::RepositoryConfig;
2use crate::constants::{OXEN_HIDDEN_DIR, REPO_CONFIG_FILENAME};
3use crate::core;
4use crate::core::staged::staged_db_manager::get_staged_db_manager;
5use crate::core::versions::MinOxenVersion;
6use crate::error::OxenError;
7use crate::model::entry::metadata_entry::{WorkspaceChanges, WorkspaceMetadataEntry};
8use crate::model::{MetadataEntry, ParsedResource, StagedData, StagedEntryStatus, merkle_tree};
9use crate::opts::StorageOpts;
10use crate::repositories;
11use crate::repositories::merkle_tree::node::EMerkleTreeNode;
12use crate::util;
13
14use crate::model::{Commit, LocalRepository, NewCommitBody, Workspace, workspace::WorkspaceConfig};
15use crate::view::entries::EMetadataEntry;
16use crate::view::merge::Mergeable;
17
18pub mod data_frames;
19pub mod df;
20pub mod diff;
21pub mod files;
22pub mod status;
23pub mod upload;
24
25pub use df::df;
26pub use diff::diff;
27pub use upload::upload;
28
29use std::collections::HashMap;
30use std::path::Path;
31use uuid::Uuid;
32
33/// Loads a workspace from the filesystem. Must call create() first to create the workspace.
34///
35/// Returns an None if the workspace does not exist
36pub fn get(
37    repo: &LocalRepository,
38    workspace_id: impl AsRef<str>,
39) -> Result<Option<Workspace>, OxenError> {
40    let workspace_id = workspace_id.as_ref();
41    let workspace_id_hash = util::hasher::hash_str_sha256(workspace_id);
42    log::debug!("workspace::get workspace_id: {workspace_id:?} hash: {workspace_id_hash:?}");
43
44    let workspace_dir = Workspace::workspace_dir(repo, &workspace_id_hash);
45    let config_path = Workspace::config_path_from_dir(&workspace_dir);
46
47    log::debug!("workspace::get directory: {workspace_dir:?}");
48    if config_path.exists() {
49        get_by_dir(repo, workspace_dir)
50    } else if let Some(workspace) = get_by_name(repo, workspace_id)? {
51        let workspace_id = util::hasher::hash_str_sha256(&workspace.id);
52        let workspace_dir = Workspace::workspace_dir(repo, &workspace_id);
53        get_by_dir(repo, workspace_dir)
54    } else {
55        Ok(None)
56    }
57}
58
59pub fn get_by_dir(
60    repo: &LocalRepository,
61    workspace_dir: impl AsRef<Path>,
62) -> Result<Option<Workspace>, OxenError> {
63    let workspace_dir = workspace_dir.as_ref();
64    let workspace_id = workspace_dir.file_name().unwrap().to_str().unwrap();
65    let config_path = Workspace::config_path_from_dir(workspace_dir);
66
67    if !config_path.exists() {
68        log::debug!("workspace::get workspace not found: {workspace_dir:?}");
69        return Ok(None);
70    }
71
72    let config_contents = util::fs::read_from_path(&config_path)?;
73    let config: WorkspaceConfig = toml::from_str(&config_contents)
74        .map_err(|e| OxenError::basic_str(format!("Failed to parse workspace config: {e}")))?;
75
76    let Some(commit) = repositories::commits::get_by_id(repo, &config.workspace_commit_id)? else {
77        return Err(OxenError::basic_str(format!(
78            "Workspace {} has invalid commit_id {}",
79            workspace_id, config.workspace_commit_id
80        )));
81    };
82
83    // Read repo config file for the storage config
84    let config_file = repo.path.join(OXEN_HIDDEN_DIR).join(REPO_CONFIG_FILENAME);
85    let repo_config = RepositoryConfig::from_file(&config_file)?;
86    let storage_opts = repo_config
87        .storage
88        .map(|s| StorageOpts::from_repo_config(repo, &s))
89        .transpose()?;
90
91    Ok(Some(Workspace {
92        id: config.workspace_id.unwrap_or(workspace_id.to_owned()),
93        name: config.workspace_name,
94        base_repo: repo.clone(),
95        workspace_repo: LocalRepository::new(workspace_dir, storage_opts)?,
96        commit,
97        is_editable: config.is_editable,
98    }))
99}
100
101pub fn get_by_name(
102    repo: &LocalRepository,
103    workspace_name: impl AsRef<str>,
104) -> Result<Option<Workspace>, OxenError> {
105    let workspace_name = workspace_name.as_ref();
106    for workspace in iter_workspaces(repo)? {
107        if let Some(workspace) = workspace?
108            && workspace.name.as_deref() == Some(workspace_name)
109        {
110            return Ok(Some(workspace));
111        }
112    }
113    Ok(None)
114}
115
116/// Creates a new workspace and saves it to the filesystem
117pub fn create(
118    base_repo: &LocalRepository,
119    commit: &Commit,
120    workspace_id: impl AsRef<str>,
121    is_editable: bool,
122) -> Result<Workspace, OxenError> {
123    create_with_name(base_repo, commit, workspace_id, None, is_editable)
124}
125
126pub fn create_with_name(
127    base_repo: &LocalRepository,
128    commit: &Commit,
129    workspace_id: impl AsRef<str>,
130    workspace_name: Option<String>,
131    is_editable: bool,
132) -> Result<Workspace, OxenError> {
133    let workspace_id = workspace_id.as_ref();
134    let workspace_id_hash = util::hasher::hash_str_sha256(workspace_id);
135    let workspace_dir = Workspace::workspace_dir(base_repo, &workspace_id_hash);
136    let oxen_dir = workspace_dir.join(OXEN_HIDDEN_DIR);
137
138    log::debug!("index::workspaces::create called! {oxen_dir:?}");
139
140    if oxen_dir.exists() {
141        log::debug!("index::workspaces::create already have oxen repo directory {oxen_dir:?}");
142        return Err(OxenError::basic_str(format!(
143            "Workspace {workspace_id} already exists"
144        )));
145    }
146    let workspaces = list(base_repo)?;
147
148    // Check for existing non-editable workspaces on the same commit
149    for workspace in workspaces {
150        if !is_editable {
151            check_non_editable_workspace(&workspace, commit)?;
152        }
153        if let Some(workspace_name) = workspace_name.clone() {
154            check_existing_workspace_name(&workspace, &workspace_name)?;
155        }
156    }
157
158    log::debug!("index::workspaces::create Initializing oxen repo! 🐂");
159
160    let workspace_repo = init_workspace_repo(base_repo, &workspace_dir)?;
161
162    // Serialize the workspace config to TOML
163    let workspace_config = WorkspaceConfig {
164        workspace_commit_id: commit.id.clone(),
165        is_editable,
166        workspace_name: workspace_name.clone(),
167        workspace_id: Some(workspace_id.to_string()),
168    };
169
170    let toml_string = match toml::to_string(&workspace_config) {
171        Ok(s) => s,
172        Err(e) => {
173            return Err(OxenError::basic_str(format!(
174                "Failed to serialize workspace config to TOML: {e}"
175            )));
176        }
177    };
178
179    // Write the TOML string to WORKSPACE_CONFIG
180    let workspace_config_path = Workspace::config_path_from_dir(&workspace_dir);
181    log::debug!("index::workspaces::create writing workspace config to: {workspace_config_path:?}");
182    util::fs::write_to_path(&workspace_config_path, toml_string)?;
183
184    Ok(Workspace {
185        id: workspace_id.to_owned(),
186        name: workspace_name,
187        base_repo: base_repo.clone(),
188        workspace_repo,
189        commit: commit.clone(),
190        is_editable,
191    })
192}
193
194/// A wrapper around Workspace that automatically deletes the workspace when dropped
195pub struct TemporaryWorkspace {
196    workspace: Workspace,
197}
198
199impl TemporaryWorkspace {
200    /// Get a reference to the underlying workspace
201    pub fn workspace(&self) -> &Workspace {
202        &self.workspace
203    }
204}
205
206impl std::ops::Deref for TemporaryWorkspace {
207    type Target = Workspace;
208
209    fn deref(&self) -> &Self::Target {
210        &self.workspace
211    }
212}
213
214impl Drop for TemporaryWorkspace {
215    fn drop(&mut self) {
216        if let Err(e) = delete(&self.workspace) {
217            log::error!("Failed to delete temporary workspace: {e}");
218        }
219    }
220}
221
222/// Creates a new temporary workspace that will be deleted when the reference is dropped
223pub fn create_temporary(
224    base_repo: &LocalRepository,
225    commit: &Commit,
226) -> Result<TemporaryWorkspace, OxenError> {
227    let workspace_id = Uuid::new_v4().to_string();
228    let workspace_name = format!("temporary-{workspace_id}");
229    let workspace = create_with_name(base_repo, commit, workspace_id, Some(workspace_name), true)?;
230    Ok(TemporaryWorkspace { workspace })
231}
232
233fn check_non_editable_workspace(workspace: &Workspace, commit: &Commit) -> Result<(), OxenError> {
234    if workspace.commit.id == commit.id && !workspace.is_editable {
235        return Err(OxenError::basic_str(format!(
236            "A non-editable workspace already exists for commit {}",
237            commit.id
238        )));
239    }
240    Ok(())
241}
242
243fn check_existing_workspace_name(
244    workspace: &Workspace,
245    workspace_name: &str,
246) -> Result<(), OxenError> {
247    if workspace.name == Some(workspace_name.to_string()) || *workspace_name == workspace.id {
248        return Err(OxenError::basic_str(format!(
249            "A workspace with the name {workspace_name} already exists"
250        )));
251    }
252    Ok(())
253}
254
255/// Returns a lazy iterator over all workspaces in the repository.
256/// Each workspace is loaded from the filesystem on demand.
257fn iter_workspaces(
258    repo: &LocalRepository,
259) -> Result<impl Iterator<Item = Result<Option<Workspace>, OxenError>> + '_, OxenError> {
260    let workspaces_dir = Workspace::workspaces_dir(repo);
261    log::debug!("workspace::iter_workspaces got workspaces_dir: {workspaces_dir:?}");
262
263    let workspace_hashes = if workspaces_dir.exists() {
264        util::fs::list_dirs_in_dir(&workspaces_dir).map_err(|e| {
265            OxenError::basic_str(format!("Error listing workspace directories: {e}"))
266        })?
267    } else {
268        Vec::new()
269    };
270
271    log::debug!(
272        "workspace::iter_workspaces got {} workspaces",
273        workspace_hashes.len()
274    );
275
276    Ok(workspace_hashes
277        .into_iter()
278        .map(move |workspace_hash| get_by_dir(repo, &workspace_hash)))
279}
280
281pub fn list(repo: &LocalRepository) -> Result<Vec<Workspace>, OxenError> {
282    let mut workspaces = Vec::new();
283    for workspace in iter_workspaces(repo)? {
284        if let Some(workspace) = workspace? {
285            workspaces.push(workspace);
286        }
287    }
288    Ok(workspaces)
289}
290
291pub fn get_non_editable_by_commit_id(
292    repo: &LocalRepository,
293    commit_id: impl AsRef<str>,
294) -> Result<Workspace, OxenError> {
295    let workspaces = list(repo)?;
296    for workspace in workspaces {
297        if workspace.commit.id == commit_id.as_ref() && !workspace.is_editable {
298            return Ok(workspace);
299        }
300    }
301    Err(OxenError::basic_str(
302        "No non-editable workspace found for the given commit ID",
303    ))
304}
305
306pub fn delete(workspace: &Workspace) -> Result<(), OxenError> {
307    let workspace_id = workspace.id.to_string();
308    let workspace_dir = workspace.dir();
309    if !workspace_dir.exists() {
310        return Err(OxenError::WorkspaceNotFound(workspace_id.into()));
311    }
312
313    log::debug!("workspace::delete cleaning up workspace dir: {workspace_dir:?}");
314
315    // Clean up caches before deleting the workspace
316    merkle_tree::merkle_tree_node_cache::remove_from_cache(&workspace.workspace_repo.path)?;
317    core::staged::remove_from_cache(&workspace.workspace_repo.path)?;
318    match util::fs::remove_dir_all(&workspace_dir) {
319        Ok(_) => log::debug!("workspace::delete removed workspace dir: {workspace_dir:?}"),
320        Err(e) => log::error!("workspace::delete error removing workspace dir: {e:?}"),
321    }
322
323    Ok(())
324}
325
326pub fn clear(repo: &LocalRepository) -> Result<(), OxenError> {
327    let workspaces_dir = Workspace::workspaces_dir(repo);
328    if !workspaces_dir.exists() {
329        return Ok(());
330    }
331
332    util::fs::remove_dir_all(&workspaces_dir)?;
333    Ok(())
334}
335
336pub fn update_commit(workspace: &Workspace, new_commit_id: &str) -> Result<(), OxenError> {
337    let config_path = workspace.config_path();
338
339    if !config_path.exists() {
340        log::error!("Workspace config not found: {config_path:?}");
341        return Err(OxenError::WorkspaceNotFound(workspace.id.as_str().into()));
342    }
343
344    let config_contents = util::fs::read_from_path(&config_path)?;
345    let mut config: WorkspaceConfig = toml::from_str(&config_contents).map_err(|e| {
346        log::error!("Failed to parse workspace config: {config_path:?}, err: {e}");
347        OxenError::basic_str(format!("Failed to parse workspace config: {e}"))
348    })?;
349
350    log::debug!(
351        "Updating workspace {} commit from {} to {}",
352        workspace.id,
353        config.workspace_commit_id,
354        new_commit_id
355    );
356    config.workspace_commit_id = new_commit_id.to_string();
357
358    let toml_string = toml::to_string(&config).map_err(|e| {
359        log::error!("Failed to serialize workspace config to TOML: {config_path:?}, err: {e}");
360        OxenError::basic_str(format!("Failed to serialize workspace config to TOML: {e}"))
361    })?;
362
363    util::fs::write_to_path(&config_path, toml_string)?;
364
365    Ok(())
366}
367
368pub async fn commit(
369    workspace: &Workspace,
370    new_commit: &NewCommitBody,
371    branch_name: impl AsRef<str>,
372) -> Result<Commit, OxenError> {
373    match workspace.workspace_repo.min_version() {
374        MinOxenVersion::V0_10_0 => panic!("v0.10.0 no longer supported"),
375        _ => core::v_latest::workspaces::commit::commit(workspace, new_commit, branch_name).await,
376    }
377}
378
379pub fn mergeability(
380    workspace: &Workspace,
381    branch_name: impl AsRef<str>,
382) -> Result<Mergeable, OxenError> {
383    match workspace.workspace_repo.min_version() {
384        MinOxenVersion::V0_10_0 => panic!("v0.10.0 no longer supported"),
385        _ => core::v_latest::workspaces::commit::mergeability(workspace, branch_name),
386    }
387}
388
389fn init_workspace_repo(
390    repo: &LocalRepository,
391    workspace_dir: impl AsRef<Path>,
392) -> Result<LocalRepository, OxenError> {
393    let workspace_dir = workspace_dir.as_ref();
394    match repo.min_version() {
395        MinOxenVersion::V0_10_0 => panic!("v0.10.0 no longer supported"),
396        _ => core::v_latest::workspaces::init_workspace_repo(repo, workspace_dir),
397    }
398}
399
400pub fn populate_entries_with_workspace_data(
401    repo: &LocalRepository,
402    directory: &Path,
403    workspace: &Workspace,
404    entries: &[MetadataEntry],
405) -> Result<Vec<EMetadataEntry>, OxenError> {
406    let workspace_changes =
407        repositories::workspaces::status::status_from_dir(workspace, directory)?;
408    let mut dir_entries: Vec<EMetadataEntry> = Vec::new();
409    let mut entries: Vec<WorkspaceMetadataEntry> = entries
410        .iter()
411        .map(|entry| WorkspaceMetadataEntry::from_metadata_entry(entry.clone()))
412        .collect();
413
414    let (additions_map, other_changes_map) =
415        build_file_status_maps_for_directory(&workspace_changes);
416    for entry in entries.iter_mut() {
417        let status = other_changes_map.get(&entry.filename).cloned();
418        match status {
419            Some(status) => {
420                entry.changes = Some(WorkspaceChanges {
421                    status: status.clone(),
422                });
423                dir_entries.push(EMetadataEntry::WorkspaceMetadataEntry(entry.clone()));
424            }
425            _ => {
426                dir_entries.push(EMetadataEntry::WorkspaceMetadataEntry(entry.clone()));
427            }
428        }
429    }
430    for (file_path, status) in additions_map.iter() {
431        if *status == StagedEntryStatus::Added {
432            let staged_node = get_staged_db_manager(&workspace.workspace_repo)?
433                .read_from_staged_db(file_path)?
434                .ok_or_else(|| {
435                    OxenError::basic_str(format!(
436                        "Staged entry disappeared while resolving workspace metadata: {file_path:?}"
437                    ))
438                })?;
439
440            let metadata = match staged_node.node.node {
441                EMerkleTreeNode::File(file_node) => {
442                    repositories::metadata::from_file_node(repo, &file_node, &workspace.commit)?
443                }
444                EMerkleTreeNode::Directory(dir_node) => {
445                    repositories::metadata::from_dir_node(repo, &dir_node, &workspace.commit)?
446                }
447                _ => {
448                    return Err(OxenError::basic_str(
449                        "Unexpected node type found in staged db",
450                    ));
451                }
452            };
453
454            let mut ws_entry = WorkspaceMetadataEntry::from_metadata_entry(metadata);
455            ws_entry.changes = Some(WorkspaceChanges {
456                status: status.clone(),
457            });
458            dir_entries.push(EMetadataEntry::WorkspaceMetadataEntry(ws_entry));
459        }
460    }
461
462    Ok(dir_entries)
463}
464
465pub fn populate_entry_with_workspace_data(
466    file_path: &Path,
467    entry: MetadataEntry,
468    workspace: &Workspace,
469) -> Result<EMetadataEntry, OxenError> {
470    let workspace_changes =
471        repositories::workspaces::status::status_from_dir(workspace, file_path)?;
472    let (_additions_map, other_changes_map) = build_file_status_maps_for_file(&workspace_changes);
473    let mut entry = WorkspaceMetadataEntry::from_metadata_entry(entry.clone());
474    let changes = other_changes_map.get(file_path.to_str().unwrap()).cloned();
475    if let Some(status) = changes {
476        entry.changes = Some(WorkspaceChanges {
477            status: status.clone(),
478        });
479    }
480    Ok(EMetadataEntry::WorkspaceMetadataEntry(entry))
481}
482
483pub fn get_added_entry(
484    repo: &LocalRepository,
485    file_path: &Path,
486    workspace: &Workspace,
487    resource: &ParsedResource,
488) -> Result<EMetadataEntry, OxenError> {
489    let workspace_changes =
490        repositories::workspaces::status::status_from_dir(workspace, file_path)?;
491    let (additions_map, _other_changes_map) = build_file_status_maps_for_file(&workspace_changes);
492    if let Some(status) = additions_map.get(file_path.to_str().unwrap()).cloned() {
493        if status != StagedEntryStatus::Added {
494            return Err(OxenError::basic_str(
495                "Entry is not in the workspace's staged database",
496            ));
497        }
498
499        let staged_node = get_staged_db_manager(&workspace.workspace_repo)?
500            .read_from_staged_db(file_path)?
501            .expect("Staged node found in status not present in staged db");
502
503        let metadata = match staged_node.node.node {
504            EMerkleTreeNode::File(file_node) => {
505                repositories::metadata::from_file_node(repo, &file_node, &workspace.commit)?
506            }
507            EMerkleTreeNode::Directory(dir_node) => {
508                repositories::metadata::from_dir_node(repo, &dir_node, &workspace.commit)?
509            }
510            _ => {
511                return Err(OxenError::basic_str(
512                    "Unexpected node type found in staged db",
513                ));
514            }
515        };
516
517        let mut ws_entry = WorkspaceMetadataEntry::from_metadata_entry(metadata);
518        ws_entry.changes = Some(WorkspaceChanges {
519            status: StagedEntryStatus::Added,
520        });
521        ws_entry.resource = Some(resource.clone().into());
522        Ok(EMetadataEntry::WorkspaceMetadataEntry(ws_entry))
523    } else {
524        Err(OxenError::basic_str(
525            "Entry is not in the workspace's staged database",
526        ))
527    }
528}
529
530/// Build a hashmap mapping file paths to their status from workspace_changes.staged_files.
531///
532/// Returns a tuple of two hashmaps:
533/// - The first hashmap contains file paths mapped to their status if they are added.
534/// - The second hashmap contains file paths mapped to their status if they are modified or removed.
535///
536/// This allows us to track files that were added to the workspace efficiently.
537fn build_file_status_maps_for_directory(
538    workspace_changes: &StagedData,
539) -> (
540    HashMap<String, StagedEntryStatus>,
541    HashMap<String, StagedEntryStatus>,
542) {
543    let mut additions_map = HashMap::new();
544    let mut other_changes_map = HashMap::new();
545    workspace_changes.print();
546
547    for (file_path, entry) in workspace_changes.staged_files.iter() {
548        let status = entry.status.clone();
549        if status == StagedEntryStatus::Added {
550            // For added files, we use the full path as the key. As the staged files are relative to the repository root
551            let key = file_path.to_str().unwrap().to_string();
552            additions_map.insert(key, status);
553        } else {
554            // For modified or removed files, we use the file name as the key, as the file path is relative to the directory passed in.
555            let key = file_path.file_name().unwrap().to_string_lossy().to_string();
556            other_changes_map.insert(key, status);
557        }
558    }
559
560    (additions_map, other_changes_map)
561}
562
563// For files, we always use the full path as the key, as results are relative to the repository root
564fn build_file_status_maps_for_file(
565    workspace_changes: &StagedData,
566) -> (
567    HashMap<String, StagedEntryStatus>,
568    HashMap<String, StagedEntryStatus>,
569) {
570    let mut additions_map = HashMap::new();
571    let mut other_changes_map = HashMap::new();
572    for (file_path, entry) in workspace_changes.staged_files.iter() {
573        let status = entry.status.clone();
574        if status == StagedEntryStatus::Added {
575            additions_map.insert(file_path.to_str().unwrap().to_string(), status);
576        } else {
577            other_changes_map.insert(file_path.to_str().unwrap().to_string(), status);
578        }
579    }
580    (additions_map, other_changes_map)
581}
582
583#[cfg(test)]
584mod tests {
585    use super::*;
586    use crate::api;
587    use crate::constants::DEFAULT_BRANCH_NAME;
588    use crate::repositories;
589    use crate::test;
590    use crate::util;
591
592    #[tokio::test]
593    async fn test_can_commit_different_files_workspaces_without_merge_conflicts()
594    -> Result<(), OxenError> {
595        test::run_empty_local_repo_test_async(|repo| async move {
596            // Write two files, hello.txt and goodbye.txt, and commit them
597            let hello_file = repo.path.join("hello.txt");
598            let goodbye_file = repo.path.join("goodbye.txt");
599            util::fs::write_to_path(&hello_file, "Hello")?;
600            util::fs::write_to_path(&goodbye_file, "Goodbye")?;
601            repositories::add(&repo, &hello_file).await?;
602            repositories::add(&repo, &goodbye_file).await?;
603            let commit = repositories::commit(&repo, "Adding hello and goodbye files")?;
604
605            {
606                // Create temporary workspace in new scope
607                let temp_workspace = create_temporary(&repo, &commit)?;
608
609                // Update the hello file in the temporary workspace
610                let workspace_hello_file = temp_workspace.dir().join("hello.txt");
611                util::fs::write_to_path(&workspace_hello_file, "Hello again")?;
612                repositories::workspaces::files::add(&temp_workspace, workspace_hello_file).await?;
613                // Commit the changes to the "main" branch
614                repositories::workspaces::commit(
615                    &temp_workspace,
616                    &NewCommitBody {
617                        message: "Updating hello file".to_string(),
618                        author: "Bessie".to_string(),
619                        email: "bessie@oxen.ai".to_string(),
620                    },
621                    DEFAULT_BRANCH_NAME,
622                )
623                .await?;
624            } // temp_workspace goes out of scope here and gets cleaned up
625
626            {
627                // Create a new temporary workspace off of the same original commit
628                let temp_workspace = create_temporary(&repo, &commit)?;
629
630                // Update the goodbye file in the temporary workspace
631                let workspace_goodbye_file = temp_workspace.dir().join("goodbye.txt");
632                util::fs::write_to_path(&workspace_goodbye_file, "Goodbye again")?;
633                repositories::workspaces::files::add(&temp_workspace, workspace_goodbye_file)
634                    .await?;
635                // Commit the changes to the "main" branch
636                repositories::workspaces::commit(
637                    &temp_workspace,
638                    &NewCommitBody {
639                        message: "Updating goodbye file".to_string(),
640                        author: "Bessie".to_string(),
641                        email: "bessie@oxen.ai".to_string(),
642                    },
643                    DEFAULT_BRANCH_NAME,
644                )
645                .await?;
646            } // temp_workspace goes out of scope here and gets cleaned up
647
648            Ok(())
649        })
650        .await
651    }
652
653    #[tokio::test]
654    async fn test_cannot_commit_different_files_workspaces_with_merge_conflicts()
655    -> Result<(), OxenError> {
656        test::run_empty_local_repo_test_async(|repo| async move {
657            // Both workspaces try to commit the same file
658            let hello_file = repo.path.join("greetings").join("hello.txt");
659            util::fs::write_to_path(&hello_file, "Hello")?;
660            repositories::add(&repo, &hello_file).await?;
661            let commit = repositories::commit(&repo, "Adding hello file")?;
662
663            {
664                // Create temporary workspace in new scope
665                let temp_workspace = create_temporary(&repo, &commit)?;
666
667                // Update the hello file in the temporary workspace
668                let workspace_hello_file = temp_workspace.dir().join("greetings").join("hello.txt");
669                util::fs::write_to_path(&workspace_hello_file, "Hello again")?;
670                repositories::workspaces::files::add(&temp_workspace, workspace_hello_file).await?;
671                // Commit the changes to the "main" branch
672                repositories::workspaces::commit(
673                    &temp_workspace,
674                    &NewCommitBody {
675                        message: "Updating hello file".to_string(),
676                        author: "Bessie".to_string(),
677                        email: "bessie@oxen.ai".to_string(),
678                    },
679                    DEFAULT_BRANCH_NAME,
680                )
681                .await?;
682            } // temp_workspace goes out of scope here and gets cleaned up
683
684            {
685                // Create a new temporary workspace off of the same original commit
686                let temp_workspace = create_temporary(&repo, &commit)?;
687
688                // Update the hello file in the temporary workspace
689                let workspace_hello_file = temp_workspace.dir().join("greetings").join("hello.txt");
690                util::fs::write_to_path(&workspace_hello_file, "Hello again")?;
691                repositories::workspaces::files::add(&temp_workspace, workspace_hello_file).await?;
692                // Commit the changes to the "main" branch
693                let result = repositories::workspaces::commit(
694                    &temp_workspace,
695                    &NewCommitBody {
696                        message: "Updating hello file".to_string(),
697                        author: "Bessie".to_string(),
698                        email: "bessie@oxen.ai".to_string(),
699                    },
700                    DEFAULT_BRANCH_NAME,
701                )
702                .await;
703
704                // We should get a merge conflict error
705                assert!(result.is_err());
706            } // temp_workspace goes out of scope here and gets cleaned up
707
708            Ok(())
709        })
710        .await
711    }
712
713    #[tokio::test]
714    async fn test_can_commit_different_files_workspaces_without_merge_conflicts_in_subdirs()
715    -> Result<(), OxenError> {
716        test::run_empty_local_repo_test_async(|repo| async move {
717            // Write two files, greetings/hello.txt and greetings/goodbye.txt, and commit them
718            let hello_file = repo.path.join("greetings").join("hello.txt");
719            let goodbye_file = repo.path.join("greetings").join("goodbye.txt");
720            util::fs::write_to_path(&hello_file, "Hello")?;
721            util::fs::write_to_path(&goodbye_file, "Goodbye")?;
722            repositories::add(&repo, &hello_file).await?;
723            repositories::add(&repo, &goodbye_file).await?;
724            let commit = repositories::commit(&repo, "Adding hello and goodbye files")?;
725
726            {
727                // Create temporary workspace in new scope
728                let temp_workspace = create_temporary(&repo, &commit)?;
729
730                // Update the hello file in the temporary workspace
731                let workspace_hello_file = temp_workspace.dir().join("greetings").join("hello.txt");
732                util::fs::write_to_path(&workspace_hello_file, "Hello again")?;
733                repositories::workspaces::files::add(&temp_workspace, workspace_hello_file).await?;
734                // Commit the changes to the "main" branch
735                repositories::workspaces::commit(
736                    &temp_workspace,
737                    &NewCommitBody {
738                        message: "Updating hello file".to_string(),
739                        author: "Bessie".to_string(),
740                        email: "bessie@oxen.ai".to_string(),
741                    },
742                    DEFAULT_BRANCH_NAME,
743                )
744                .await?;
745            } // temp_workspace goes out of scope here and gets cleaned up
746
747            {
748                // Create a new temporary workspace off of the same original commit
749                let temp_workspace = create_temporary(&repo, &commit)?;
750
751                // Update the goodbye file in the temporary workspace
752                let workspace_goodbye_file =
753                    temp_workspace.dir().join("greetings").join("goodbye.txt");
754                util::fs::write_to_path(&workspace_goodbye_file, "Goodbye again")?;
755                repositories::workspaces::files::add(&temp_workspace, workspace_goodbye_file)
756                    .await?;
757                // Commit the changes to the "main" branch
758                repositories::workspaces::commit(
759                    &temp_workspace,
760                    &NewCommitBody {
761                        message: "Updating goodbye file".to_string(),
762                        author: "Bessie".to_string(),
763                        email: "bessie@oxen.ai".to_string(),
764                    },
765                    DEFAULT_BRANCH_NAME,
766                )
767                .await?;
768            } // temp_workspace goes out of scope here and gets cleaned up
769
770            Ok(())
771        })
772        .await
773    }
774
775    #[tokio::test]
776    async fn test_temporary_workspace_cleanup() -> Result<(), OxenError> {
777        test::run_empty_local_repo_test_async(|repo| async move {
778            // Write a test file and commit it
779            let test_file = repo.path.join("test.txt");
780            util::fs::write_to_path(&test_file, "Hello")?;
781            repositories::add(&repo, &test_file).await?;
782            let commit = repositories::commit(&repo, "Adding test file")?;
783            let workspaces_dir = repo.path.join(".oxen").join("workspaces");
784
785            {
786                // Create temporary workspace in new scope
787                let temp_workspace = create_temporary(&repo, &commit)?;
788
789                // Verify workspace exists and contains our file
790                assert!(temp_workspace.dir().exists());
791
792                // Test deref functionality by accessing workspace fields/methods
793                assert_eq!(temp_workspace.commit.id, commit.id);
794                assert!(temp_workspace.is_editable);
795
796                let workspace_entries = std::fs::read_dir(&workspaces_dir)?;
797                assert_eq!(workspace_entries.count(), 1);
798            } // temp_workspace goes out of scope here
799
800            // Verify workspace was cleaned up
801            let workspace_entries = std::fs::read_dir(&workspaces_dir)?;
802            assert_eq!(
803                workspace_entries.count(),
804                0,
805                "Workspace directory should be empty after cleanup"
806            );
807
808            Ok(())
809        })
810        .await
811    }
812
813    #[tokio::test]
814    async fn test_concurrent_workspace_commits() -> Result<(), OxenError> {
815        test::run_one_commit_sync_repo_test(|repo, remote_repo| async move {
816            // Create two files in different directories to avoid conflicts
817            let file1 = repo.path.join("dir1").join("file1.txt");
818            let file2 = repo.path.join("dir2").join("file2.txt");
819            util::fs::write_to_path(&file1, "File 1 content")?;
820            util::fs::write_to_path(&file2, "File 2 content")?;
821            repositories::add(&repo, &file1).await?;
822            repositories::add(&repo, &file2).await?;
823            let _commit = repositories::commit(&repo, "Adding initial files")?;
824            repositories::push(&repo).await?;
825
826            // Create two workspaces
827            let workspace1 =
828                api::client::workspaces::create(&remote_repo, DEFAULT_BRANCH_NAME, "workspace1")
829                    .await?;
830            let workspace2 =
831                api::client::workspaces::create(&remote_repo, DEFAULT_BRANCH_NAME, "workspace2")
832                    .await?;
833
834            // Modify files in each workspace
835            util::fs::write_to_path(&file1, "Updated file 1")?;
836            util::fs::write_to_path(&file2, "Updated file 2")?;
837            api::client::workspaces::files::upload_single_file(
838                &remote_repo,
839                &workspace1.id,
840                "dir1",
841                file1,
842            )
843            .await?;
844            api::client::workspaces::files::upload_single_file(
845                &remote_repo,
846                &workspace2.id,
847                "dir2",
848                file2,
849            )
850            .await?;
851
852            // Create commit bodies
853            let commit_body1 = NewCommitBody {
854                message: "Update file 1".to_string(),
855                author: "Bessie".to_string(),
856                email: "bessie@oxen.ai".to_string(),
857            };
858            let commit_body2 = NewCommitBody {
859                message: "Update file 2".to_string(),
860                author: "Bessie".to_string(),
861                email: "bessie@oxen.ai".to_string(),
862            };
863
864            // Clone necessary values for the second task
865            let remote_repo_clone1 = remote_repo.clone();
866            let remote_repo_clone2 = remote_repo.clone();
867
868            // Spawn two concurrent commit tasks
869            let commit_task1 = tokio::spawn(async move {
870                api::client::workspaces::commit(
871                    &remote_repo_clone1,
872                    DEFAULT_BRANCH_NAME,
873                    &workspace1.id,
874                    &commit_body1,
875                )
876                .await
877            });
878            let commit_task2 = tokio::spawn(async move {
879                api::client::workspaces::commit(
880                    &remote_repo_clone2,
881                    DEFAULT_BRANCH_NAME,
882                    &workspace2.id,
883                    &commit_body2,
884                )
885                .await
886            });
887
888            // Wait for both tasks to complete
889            let result1 = commit_task1.await.expect("Task 1 panicked")?;
890            let result2 = commit_task2.await.expect("Task 2 panicked")?;
891
892            // Verify both commits were successful
893            assert_ne!(result1.id, result2.id, "Commits should have different IDs");
894            assert!(!result1.id.is_empty(), "Commit 1 should have valid ID");
895            assert!(!result2.id.is_empty(), "Commit 2 should have valid ID");
896
897            Ok(remote_repo)
898        })
899        .await
900    }
901
902    #[tokio::test]
903    async fn test_fully_concurrent_workspace_operations() -> Result<(), OxenError> {
904        // Number of concurrent tasks to run
905        const NUM_TASKS: usize = 20;
906
907        test::run_one_commit_sync_repo_test(|repo, remote_repo| async move {
908            let mut handles = vec![];
909
910            // Spawn NUM_TASKS concurrent tasks
911            for i in 0..NUM_TASKS {
912                let remote_repo = remote_repo.clone();
913                let repo = repo.clone();
914                let handle = tokio::spawn(async move {
915                    // Create a unique branch for this task
916                    let branch_name = format!("branch-{i}");
917                    api::client::branches::create_from_branch(
918                        &remote_repo,
919                        &branch_name,
920                        DEFAULT_BRANCH_NAME,
921                    )
922                    .await?;
923
924                    // Create workspace from the new branch
925                    let workspace = api::client::workspaces::create(
926                        &remote_repo,
927                        &branch_name,
928                        &format!("workspace-{i}"),
929                    )
930                    .await?;
931
932                    // Add a unique file
933                    let file_path = repo.path.join(format!("file-{i}.txt"));
934                    util::fs::write_to_path(&file_path, format!("content {i}"))?;
935                    api::client::workspaces::files::upload_single_file(
936                        &remote_repo,
937                        &workspace.id,
938                        "",
939                        file_path,
940                    )
941                    .await?;
942
943                    // Commit changes back to the task's branch
944                    let commit_body = NewCommitBody {
945                        message: format!("Commit from task {i}"),
946                        author: "Test Author".to_string(),
947                        email: "test@oxen.ai".to_string(),
948                    };
949
950                    api::client::workspaces::commit(
951                        &remote_repo,
952                        &branch_name,
953                        &workspace.id,
954                        &commit_body,
955                    )
956                    .await?;
957
958                    Ok::<_, OxenError>(())
959                });
960                handles.push(handle);
961            }
962
963            // Wait for all tasks to complete and collect results
964            for handle in handles {
965                handle
966                    .await
967                    .map_err(|e| OxenError::basic_str(format!("Task error: {e}")))??;
968            }
969
970            Ok(remote_repo)
971        })
972        .await
973    }
974}