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
33pub 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 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
116pub 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 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 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 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
194pub struct TemporaryWorkspace {
196 workspace: Workspace,
197}
198
199impl TemporaryWorkspace {
200 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
222pub 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
255fn 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 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
530fn 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 let key = file_path.to_str().unwrap().to_string();
552 additions_map.insert(key, status);
553 } else {
554 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
563fn 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 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 let temp_workspace = create_temporary(&repo, &commit)?;
608
609 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 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 } {
627 let temp_workspace = create_temporary(&repo, &commit)?;
629
630 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 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 } 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 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 let temp_workspace = create_temporary(&repo, &commit)?;
666
667 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 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 } {
685 let temp_workspace = create_temporary(&repo, &commit)?;
687
688 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 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 assert!(result.is_err());
706 } 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 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 let temp_workspace = create_temporary(&repo, &commit)?;
729
730 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 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 } {
748 let temp_workspace = create_temporary(&repo, &commit)?;
750
751 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 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 } 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 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 let temp_workspace = create_temporary(&repo, &commit)?;
788
789 assert!(temp_workspace.dir().exists());
791
792 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 } 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 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 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 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 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 let remote_repo_clone1 = remote_repo.clone();
866 let remote_repo_clone2 = remote_repo.clone();
867
868 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 let result1 = commit_task1.await.expect("Task 1 panicked")?;
890 let result2 = commit_task2.await.expect("Task 2 panicked")?;
891
892 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 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 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 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 let workspace = api::client::workspaces::create(
926 &remote_repo,
927 &branch_name,
928 &format!("workspace-{i}"),
929 )
930 .await?;
931
932 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 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 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}