1use async_trait::async_trait;
2use jj_lib::repo::Repo as _;
3use std::path::{Path, PathBuf};
4use std::sync::Arc;
5
6use crate::error::{WorkspaceManagerError, WorkspaceManagerResult};
7use crate::local::LocalWorkspace;
8use crate::manager::{
9 CreateWorkspaceRequest, DeleteWorkspaceRequest, ListWorkspacesRequest, WorkspaceCreateStrategy,
10 WorkspaceManager,
11};
12use crate::utils::VcsUtils;
13use crate::workspace_registry::WorkspaceRegistry;
14use crate::{
15 EnvironmentId, RepoId, RepoInfo, VcsKind, Workspace, WorkspaceId, WorkspaceInfo,
16 WorkspaceStatus,
17};
18
19use super::git;
20use super::jj;
21use super::layout::WorkspaceLayout;
22use uuid::Uuid;
23
24#[derive(Debug, Clone)]
25pub struct LocalWorkspaceManager {
26 layout: WorkspaceLayout,
27 environment_id: EnvironmentId,
28 registry: Arc<WorkspaceRegistry>,
29}
30
31impl LocalWorkspaceManager {
32 pub async fn new(root: PathBuf) -> WorkspaceManagerResult<Self> {
33 let registry = WorkspaceRegistry::open(&root).await?;
34 let manager = Self {
35 layout: WorkspaceLayout::new(root),
36 environment_id: EnvironmentId::local(),
37 registry: Arc::new(registry),
38 };
39 Ok(manager)
40 }
41
42 pub fn environment_id(&self) -> EnvironmentId {
43 self.environment_id
44 }
45
46 fn repo_id_for_path(path: &Path) -> RepoId {
47 let canonical = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
48 let uuid = Uuid::new_v5(&Uuid::NAMESPACE_URL, canonical.to_string_lossy().as_bytes());
49 RepoId::from_uuid(uuid)
50 }
51}
52
53#[async_trait]
54impl WorkspaceManager for LocalWorkspaceManager {
55 async fn resolve_workspace(&self, path: &Path) -> WorkspaceManagerResult<WorkspaceInfo> {
56 let vcs_info = VcsUtils::collect_vcs_info(path)
57 .ok_or_else(|| WorkspaceManagerError::NotSupported("No VCS detected".to_string()))?;
58
59 match vcs_info.kind {
60 VcsKind::Jj => {
61 let workspace_root = vcs_info.root;
62 if let Some(existing) = self.registry.find_by_path(&workspace_root).await? {
63 return Ok(existing);
64 }
65
66 let (workspace, _repo) = jj::load_jj_workspace(&workspace_root)?;
67 let repo_path = workspace.repo_path().to_path_buf();
68 let repo_id = Self::repo_id_for_path(&repo_path);
69
70 let repo_info = if let Some(existing) = self.registry.fetch_repo(repo_id).await? {
71 existing
72 } else {
73 let info = RepoInfo {
74 repo_id,
75 environment_id: self.environment_id,
76 root_path: workspace_root.clone(),
77 vcs_kind: Some(VcsKind::Jj),
78 };
79 self.registry.upsert_repo(&info).await?;
80 info
81 };
82
83 let workspace_name = workspace.workspace_name().as_str().to_string();
84 let info = WorkspaceInfo {
85 workspace_id: WorkspaceId::new(),
86 environment_id: self.environment_id,
87 repo_id: repo_info.repo_id,
88 parent_workspace_id: None,
89 name: Some(workspace_name),
90 path: workspace_root,
91 };
92 self.registry.insert_workspace(&info).await?;
93
94 Ok(info)
95 }
96 VcsKind::Git => {
97 let workspace_root = vcs_info.root;
98 if let Some(existing) = self.registry.find_by_path(&workspace_root).await? {
99 return Ok(existing);
100 }
101
102 let repo_id = git::repo_id_for_path(&workspace_root)?;
103 let repo_info = if let Some(existing) = self.registry.fetch_repo(repo_id).await? {
104 existing
105 } else {
106 let info = RepoInfo {
107 repo_id,
108 environment_id: self.environment_id,
109 root_path: workspace_root.clone(),
110 vcs_kind: Some(VcsKind::Git),
111 };
112 self.registry.upsert_repo(&info).await?;
113 info
114 };
115
116 let info = WorkspaceInfo {
117 workspace_id: WorkspaceId::new(),
118 environment_id: self.environment_id,
119 repo_id: repo_info.repo_id,
120 parent_workspace_id: None,
121 name: WorkspaceLayout::default_workspace_name_for_path(&workspace_root),
122 path: workspace_root,
123 };
124 self.registry.insert_workspace(&info).await?;
125
126 Ok(info)
127 }
128 }
129 }
130
131 async fn create_workspace(
132 &self,
133 request: CreateWorkspaceRequest,
134 ) -> WorkspaceManagerResult<WorkspaceInfo> {
135 match request.strategy {
136 WorkspaceCreateStrategy::JjWorkspace => {
137 let repo_info = self
138 .registry
139 .fetch_repo(request.repo_id)
140 .await?
141 .ok_or_else(|| {
142 WorkspaceManagerError::NotFound(format!(
143 "Repo not found: {}",
144 request.repo_id.as_uuid()
145 ))
146 })?;
147 if repo_info.vcs_kind != Some(VcsKind::Jj) {
148 return Err(WorkspaceManagerError::NotSupported(
149 "Workspace orchestration is only supported for jj repositories".to_string(),
150 ));
151 }
152
153 let jj_root = jj::ensure_jj_workspace_root(&repo_info.root_path)?;
154 let workspace_id = WorkspaceId::new();
155 let parent_workspace_id = request.parent_workspace_id;
156 let parent_wc_commit_id = if let Some(parent_workspace_id) = parent_workspace_id {
157 let parent_info = self
158 .registry
159 .fetch_workspace(parent_workspace_id)
160 .await?
161 .ok_or_else(|| {
162 WorkspaceManagerError::NotFound(format!(
163 "Parent workspace not found: {}",
164 parent_workspace_id.as_uuid()
165 ))
166 })?;
167 if parent_info.repo_id != repo_info.repo_id {
168 return Err(WorkspaceManagerError::InvalidRequest(format!(
169 "Parent workspace {} does not belong to repo {}",
170 parent_workspace_id.as_uuid(),
171 repo_info.repo_id.as_uuid()
172 )));
173 }
174
175 let parent_jj_root = jj::ensure_jj_workspace_root(&parent_info.path)?;
176 let (mut parent_workspace, parent_repo) =
177 jj::load_jj_workspace(&parent_jj_root)?;
178 let parent_repo =
179 jj::snapshot_working_copy(&mut parent_workspace, parent_repo).await?;
180 let parent_workspace_name = parent_workspace.workspace_name().to_owned();
181 let parent_wc_commit_id = parent_repo
182 .view()
183 .get_wc_commit_id(parent_workspace_name.as_ref())
184 .ok_or_else(|| {
185 WorkspaceManagerError::Other(format!(
186 "No working copy commit for workspace '{}'",
187 parent_workspace_name.as_str()
188 ))
189 })?
190 .clone();
191 Some(parent_wc_commit_id)
192 } else {
193 None
194 };
195
196 let requested_name = request
197 .name
198 .unwrap_or_else(|| WorkspaceLayout::default_workspace_name(workspace_id));
199 let jj_name = WorkspaceLayout::sanitize_name(&requested_name);
200
201 let parent_dir = self.layout.workspace_parent_dir(repo_info.repo_id);
202 std::fs::create_dir_all(&parent_dir)?;
203 let workspace_path = WorkspaceLayout::ensure_unique_path(&parent_dir, &jj_name);
204 std::fs::create_dir_all(&workspace_path)?;
205
206 {
207 let (workspace, repo) = jj::load_jj_workspace(&jj_root)?;
208 jj::ensure_workspace_name_available(&repo, &jj_name)?;
209
210 jj::init_workspace_with_existing_repo(
211 &workspace_path,
212 workspace.repo_path(),
213 &repo,
214 jj_name.as_str(),
215 )?;
216 }
217
218 if let Some(parent_wc_commit_id) = parent_wc_commit_id {
219 let (mut workspace, repo) = jj::load_jj_workspace(&workspace_path)?;
220 let workspace_name = workspace.workspace_name().to_owned();
221 let parent_wc_commit =
222 repo.store().get_commit(&parent_wc_commit_id).map_err(|e| {
223 WorkspaceManagerError::Other(format!(
224 "Failed to load parent working copy commit: {e}"
225 ))
226 })?;
227
228 let mut tx = repo.start_transaction();
229 let mut child_wc_commit_builder = tx.repo_mut().new_commit(
230 vec![parent_wc_commit.id().clone()],
231 parent_wc_commit.tree_id().clone(),
232 );
233
234 let mut author = child_wc_commit_builder.author().clone();
235 author.name.clone_from(&parent_wc_commit.author().name);
236 author.email.clone_from(&parent_wc_commit.author().email);
237 child_wc_commit_builder = child_wc_commit_builder.set_author(author);
238
239 let mut committer = child_wc_commit_builder.committer().clone();
240 committer
241 .name
242 .clone_from(&parent_wc_commit.committer().name);
243 committer
244 .email
245 .clone_from(&parent_wc_commit.committer().email);
246 child_wc_commit_builder = child_wc_commit_builder.set_committer(committer);
247
248 let child_wc_commit = child_wc_commit_builder.write().map_err(|e| {
249 WorkspaceManagerError::Other(format!(
250 "Failed to create initial working copy commit for new workspace: {e}"
251 ))
252 })?;
253 let child_wc_commit_id = child_wc_commit.id().clone();
254
255 tx.repo_mut()
256 .set_wc_commit(workspace_name.clone(), child_wc_commit_id.clone())
257 .map_err(|e| {
258 WorkspaceManagerError::Other(format!(
259 "Failed to update new workspace working copy commit: {e}"
260 ))
261 })?;
262 let workspace_name_ref: &jj_lib::ref_name::WorkspaceName =
263 workspace_name.as_ref();
264 let updated_repo = tx
265 .commit(format!(
266 "create initial working-copy commit in workspace '{}'",
267 workspace_name_ref.as_str()
268 ))
269 .map_err(|e| {
270 WorkspaceManagerError::Other(format!(
271 "Failed to commit new workspace working copy base: {e}"
272 ))
273 })?;
274 let child_wc_commit = updated_repo
275 .store()
276 .get_commit(&child_wc_commit_id)
277 .map_err(|e| {
278 WorkspaceManagerError::Other(format!(
279 "Failed to load created working copy commit: {e}"
280 ))
281 })?;
282
283 workspace
284 .check_out(updated_repo.op_id().clone(), None, &child_wc_commit)
285 .map_err(|e| {
286 WorkspaceManagerError::Other(format!(
287 "Failed to check out new workspace working copy commit: {e}"
288 ))
289 })?;
290 }
291
292 let info = WorkspaceInfo {
293 workspace_id,
294 environment_id: self.environment_id,
295 repo_id: repo_info.repo_id,
296 parent_workspace_id,
297 name: Some(requested_name),
298 path: workspace_path,
299 };
300 self.registry.insert_workspace(&info).await?;
301
302 Ok(info)
303 }
304 WorkspaceCreateStrategy::GitWorktree => {
305 let repo_info = self
306 .registry
307 .fetch_repo(request.repo_id)
308 .await?
309 .ok_or_else(|| {
310 WorkspaceManagerError::NotFound(format!(
311 "Repo not found: {}",
312 request.repo_id.as_uuid()
313 ))
314 })?;
315 if repo_info.vcs_kind != Some(VcsKind::Git) {
316 return Err(WorkspaceManagerError::NotSupported(
317 "Git worktree creation is only supported for git repositories".to_string(),
318 ));
319 }
320
321 let workspace_id = WorkspaceId::new();
322 let requested_name = request
323 .name
324 .unwrap_or_else(|| WorkspaceLayout::default_workspace_name(workspace_id));
325 let sanitized_name = WorkspaceLayout::sanitize_name(&requested_name);
326 let parent_dir = self.layout.workspace_parent_dir(repo_info.repo_id);
327 std::fs::create_dir_all(&parent_dir)?;
328 let workspace_path =
329 WorkspaceLayout::ensure_unique_path(&parent_dir, &sanitized_name);
330 let names = git::worktree_names(workspace_id, &sanitized_name, &workspace_path);
331
332 git::create_worktree(
333 &repo_info.root_path,
334 &workspace_path,
335 &names.worktree_name,
336 &names.branch_name,
337 )?;
338 let workspace_path =
339 std::fs::canonicalize(&workspace_path).unwrap_or(workspace_path);
340
341 let info = WorkspaceInfo {
342 workspace_id,
343 environment_id: self.environment_id,
344 repo_id: repo_info.repo_id,
345 parent_workspace_id: request.parent_workspace_id,
346 name: Some(requested_name),
347 path: workspace_path,
348 };
349 self.registry.insert_workspace(&info).await?;
350
351 Ok(info)
352 }
353 }
354 }
355
356 async fn list_workspaces(
357 &self,
358 request: ListWorkspacesRequest,
359 ) -> WorkspaceManagerResult<Vec<WorkspaceInfo>> {
360 if request.environment_id != self.environment_id {
361 return Err(WorkspaceManagerError::NotFound(
362 request.environment_id.as_uuid().to_string(),
363 ));
364 }
365 self.registry.list_workspaces(request.environment_id).await
366 }
367
368 async fn open_workspace(
369 &self,
370 workspace_id: WorkspaceId,
371 ) -> WorkspaceManagerResult<Arc<dyn Workspace>> {
372 let info = self
373 .registry
374 .fetch_workspace(workspace_id)
375 .await?
376 .ok_or_else(|| WorkspaceManagerError::NotFound(workspace_id.as_uuid().to_string()))?;
377
378 let workspace = LocalWorkspace::with_path(info.path.clone())
379 .await
380 .map_err(|e| WorkspaceManagerError::Other(e.to_string()))?;
381 Ok(Arc::new(workspace))
382 }
383
384 async fn get_workspace_status(
385 &self,
386 workspace_id: WorkspaceId,
387 ) -> WorkspaceManagerResult<WorkspaceStatus> {
388 let info = self
389 .registry
390 .fetch_workspace(workspace_id)
391 .await?
392 .ok_or_else(|| WorkspaceManagerError::NotFound(workspace_id.as_uuid().to_string()))?;
393
394 if let Ok(jj_root) = jj::ensure_jj_workspace_root(&info.path)
395 && let Ok((mut workspace, repo)) = jj::load_jj_workspace(&jj_root)
396 {
397 let _ = jj::snapshot_working_copy(&mut workspace, repo).await;
398 }
399
400 let vcs = VcsUtils::collect_vcs_info(&info.path);
401 Ok(WorkspaceStatus {
402 workspace_id: info.workspace_id,
403 environment_id: info.environment_id,
404 repo_id: info.repo_id,
405 path: info.path,
406 vcs,
407 })
408 }
409
410 async fn delete_workspace(
411 &self,
412 request: DeleteWorkspaceRequest,
413 ) -> WorkspaceManagerResult<()> {
414 let info = self
415 .registry
416 .fetch_workspace(request.workspace_id)
417 .await?
418 .ok_or_else(|| {
419 WorkspaceManagerError::NotFound(request.workspace_id.as_uuid().to_string())
420 })?;
421 let repo_info = self
422 .registry
423 .fetch_repo(info.repo_id)
424 .await?
425 .ok_or_else(|| {
426 WorkspaceManagerError::NotFound(format!(
427 "Repo not found: {}",
428 info.repo_id.as_uuid()
429 ))
430 })?;
431
432 let managed_root = self.layout.workspace_parent_dir(info.repo_id);
433 let managed_root =
434 std::fs::canonicalize(&managed_root).unwrap_or_else(|_| managed_root.clone());
435 let info_path = std::fs::canonicalize(&info.path).unwrap_or_else(|_| info.path.clone());
436 if !info_path.starts_with(&managed_root) {
437 return Err(WorkspaceManagerError::InvalidRequest(
438 "Only managed workspaces can be deleted".to_string(),
439 ));
440 }
441
442 match repo_info.vcs_kind {
443 Some(VcsKind::Jj) => {
444 let jj_root = jj::ensure_jj_workspace_root(&info.path)?;
445 let (mut workspace, repo) = jj::load_jj_workspace(&jj_root)?;
446 let workspace_name = workspace.workspace_name().to_owned();
447 let repo = jj::snapshot_working_copy(&mut workspace, repo).await?;
448 let mut tx = repo.start_transaction();
449 tx.repo_mut()
450 .remove_wc_commit(&workspace_name)
451 .map_err(|e| {
452 WorkspaceManagerError::Other(format!("Failed to remove jj workspace: {e}"))
453 })?;
454 if tx.repo_mut().has_rewrites() {
455 tx.repo_mut().rebase_descendants().map_err(|e| {
456 WorkspaceManagerError::Other(format!(
457 "Failed to rebase jj descendants after workspace removal: {e}"
458 ))
459 })?;
460 }
461 let workspace_name_ref: &jj_lib::ref_name::WorkspaceName = workspace_name.as_ref();
462 tx.commit(format!(
463 "forget workspace '{}'",
464 workspace_name_ref.as_str()
465 ))
466 .map_err(|e| {
467 WorkspaceManagerError::Other(format!(
468 "Failed to commit jj workspace removal: {e}"
469 ))
470 })?;
471
472 std::fs::remove_dir_all(&info.path)?;
473 }
474 Some(VcsKind::Git) => {
475 git::remove_worktree(&repo_info.root_path, &info.path)?;
476 if info.path.exists() {
477 std::fs::remove_dir_all(&info.path)?;
478 }
479 }
480 None => {
481 return Err(WorkspaceManagerError::NotSupported(
482 "Workspace orchestration requires a VCS".to_string(),
483 ));
484 }
485 }
486
487 self.registry.delete_workspace(request.workspace_id).await?;
488
489 Ok(())
490 }
491}
492
493#[async_trait]
494impl crate::manager::RepoManager for LocalWorkspaceManager {
495 async fn resolve_repo(
496 &self,
497 environment_id: EnvironmentId,
498 path: &Path,
499 ) -> WorkspaceManagerResult<RepoInfo> {
500 if environment_id != self.environment_id {
501 return Err(WorkspaceManagerError::NotFound(
502 environment_id.as_uuid().to_string(),
503 ));
504 }
505
506 let vcs_info = VcsUtils::collect_vcs_info(path)
507 .ok_or_else(|| WorkspaceManagerError::NotSupported("No VCS detected".to_string()))?;
508
509 match vcs_info.kind {
510 VcsKind::Git => {
511 let repo_root = vcs_info.root;
512 let repo_id = git::repo_id_for_path(&repo_root)?;
513 if let Some(existing) = self.registry.fetch_repo(repo_id).await? {
514 return Ok(existing);
515 }
516 let info = RepoInfo {
517 repo_id,
518 environment_id: self.environment_id,
519 root_path: repo_root,
520 vcs_kind: Some(VcsKind::Git),
521 };
522 self.registry.upsert_repo(&info).await?;
523 Ok(info)
524 }
525 VcsKind::Jj => {
526 let workspace_root = vcs_info.root;
527 let (workspace, _repo) = jj::load_jj_workspace(&workspace_root)?;
528 let repo_path = workspace.repo_path().to_path_buf();
529 let repo_id = Self::repo_id_for_path(&repo_path);
530 if let Some(existing) = self.registry.fetch_repo(repo_id).await? {
531 return Ok(existing);
532 }
533 let info = RepoInfo {
534 repo_id,
535 environment_id: self.environment_id,
536 root_path: workspace_root,
537 vcs_kind: Some(VcsKind::Jj),
538 };
539 self.registry.upsert_repo(&info).await?;
540 Ok(info)
541 }
542 }
543 }
544
545 async fn list_repos(
546 &self,
547 environment_id: EnvironmentId,
548 ) -> WorkspaceManagerResult<Vec<RepoInfo>> {
549 if environment_id != self.environment_id {
550 return Err(WorkspaceManagerError::NotFound(
551 environment_id.as_uuid().to_string(),
552 ));
553 }
554 self.registry.list_repos(environment_id).await
555 }
556}
557
558#[cfg(test)]
559mod tests {
560 use super::*;
561 use jj_lib::repo::Repo;
562 use jj_lib::repo_path::RepoPathBuf;
563 use tempfile::TempDir;
564
565 #[tokio::test]
566 async fn test_no_default_workspace_registered() {
567 let temp = TempDir::new().unwrap();
568 let manager = LocalWorkspaceManager::new(temp.path().to_path_buf())
569 .await
570 .unwrap();
571
572 let workspaces = manager
573 .list_workspaces(ListWorkspacesRequest {
574 environment_id: manager.environment_id(),
575 })
576 .await
577 .unwrap();
578 assert!(workspaces.is_empty());
579 }
580
581 #[tokio::test]
582 async fn test_create_workspace_requires_jj_repo() {
583 let temp = TempDir::new().unwrap();
584 let manager = LocalWorkspaceManager::new(temp.path().to_path_buf())
585 .await
586 .unwrap();
587
588 let result = manager
589 .create_workspace(CreateWorkspaceRequest {
590 repo_id: RepoId::new(),
591 name: Some("child".to_string()),
592 parent_workspace_id: None,
593 strategy: WorkspaceCreateStrategy::JjWorkspace,
594 })
595 .await;
596
597 assert!(matches!(result, Err(WorkspaceManagerError::NotFound(_))));
598 }
599
600 #[tokio::test]
601 async fn test_jj_workspace_create_bases_on_parent_workspace_current_change() {
602 let temp_dir = tempfile::tempdir().unwrap();
603 let settings = {
604 let mut config = jj_lib::config::StackedConfig::with_defaults();
605 let overrides = jj_lib::config::ConfigLayer::parse(
606 jj_lib::config::ConfigSource::CommandArg,
607 r#"
608user.name = "Test User"
609user.email = "test@example.com"
610operation.hostname = "test-host"
611operation.username = "test-user"
612signing.behavior = "drop"
613debug.randomness-seed = 0
614debug.commit-timestamp = "2001-01-01T00:00:00Z"
615debug.operation-timestamp = "2001-01-01T00:00:00Z"
616"#,
617 )
618 .unwrap();
619 config.add_layer(overrides);
620 jj_lib::settings::UserSettings::from_config(config).unwrap()
621 };
622 let (workspace, _repo) =
623 jj_lib::workspace::Workspace::init_simple(&settings, temp_dir.path()).unwrap();
624 drop(workspace);
625
626 let manager_root = TempDir::new().unwrap();
627 let manager = LocalWorkspaceManager::new(manager_root.path().to_path_buf())
628 .await
629 .unwrap();
630
631 let parent_info = manager.resolve_workspace(temp_dir.path()).await.unwrap();
632 let child_info = manager
633 .create_workspace(CreateWorkspaceRequest {
634 repo_id: parent_info.repo_id,
635 name: Some("jj-subagent".to_string()),
636 parent_workspace_id: Some(parent_info.workspace_id),
637 strategy: WorkspaceCreateStrategy::JjWorkspace,
638 })
639 .await
640 .unwrap();
641
642 let (mut parent_workspace, parent_repo) = jj::load_jj_workspace(temp_dir.path()).unwrap();
643 let parent_repo = jj::snapshot_working_copy(&mut parent_workspace, parent_repo)
644 .await
645 .unwrap();
646 let parent_workspace_name = parent_workspace.workspace_name().to_owned();
647 let parent_wc_commit_id = parent_repo
648 .view()
649 .get_wc_commit_id(parent_workspace_name.as_ref())
650 .unwrap()
651 .clone();
652
653 let (mut child_workspace, child_repo) = jj::load_jj_workspace(&child_info.path).unwrap();
654 let child_repo = jj::snapshot_working_copy(&mut child_workspace, child_repo)
655 .await
656 .unwrap();
657 let child_workspace_name = child_workspace.workspace_name().to_owned();
658 let child_wc_commit_id = child_repo
659 .view()
660 .get_wc_commit_id(child_workspace_name.as_ref())
661 .unwrap()
662 .clone();
663 let child_wc_commit = child_repo.store().get_commit(&child_wc_commit_id).unwrap();
664
665 assert_eq!(child_wc_commit.parent_ids(), &[parent_wc_commit_id.clone()]);
666 assert_ne!(child_wc_commit.id(), &parent_wc_commit_id);
667
668 let parent_wc_commit = child_repo.store().get_commit(&parent_wc_commit_id).unwrap();
669 assert_eq!(child_wc_commit.tree_id(), parent_wc_commit.tree_id());
670 assert_eq!(
671 child_wc_commit.author().email,
672 parent_wc_commit.author().email,
673 "child workspace commit should inherit parent author email"
674 );
675 assert_eq!(
676 child_wc_commit.committer().email,
677 parent_wc_commit.committer().email,
678 "child workspace commit should inherit parent committer email"
679 );
680 }
681
682 #[test]
683 fn test_delete_workspace_snapshots_and_preserves_revision() {
684 let temp_dir = tempfile::tempdir().unwrap();
685 let settings = {
686 let mut config = jj_lib::config::StackedConfig::with_defaults();
687 let overrides = jj_lib::config::ConfigLayer::parse(
688 jj_lib::config::ConfigSource::CommandArg,
689 r#"
690user.name = "Test User"
691user.email = "test@example.com"
692operation.hostname = "test-host"
693operation.username = "test-user"
694signing.behavior = "drop"
695debug.randomness-seed = 0
696debug.commit-timestamp = "2001-01-01T00:00:00Z"
697debug.operation-timestamp = "2001-01-01T00:00:00Z"
698"#,
699 )
700 .unwrap();
701 config.add_layer(overrides);
702 jj_lib::settings::UserSettings::from_config(config).unwrap()
703 };
704 let (workspace, _repo) =
705 jj_lib::workspace::Workspace::init_simple(&settings, temp_dir.path()).unwrap();
706 let repo_path = workspace.repo_path();
707 let config_path = repo_path.join("config.toml");
708 std::fs::write(config_path, r#"snapshot.auto-track = "all()""#).unwrap();
709 drop(workspace);
710
711 let runtime = tokio::runtime::Runtime::new().unwrap();
712 let manager_root = tempfile::tempdir().unwrap();
713 let manager = runtime
714 .block_on(LocalWorkspaceManager::new(
715 manager_root.path().to_path_buf(),
716 ))
717 .unwrap();
718
719 let base_info = runtime
720 .block_on(manager.resolve_workspace(temp_dir.path()))
721 .unwrap();
722 let child_info = runtime
723 .block_on(manager.create_workspace(CreateWorkspaceRequest {
724 repo_id: base_info.repo_id,
725 name: Some("subagent-test".to_string()),
726 parent_workspace_id: Some(base_info.workspace_id),
727 strategy: WorkspaceCreateStrategy::JjWorkspace,
728 }))
729 .unwrap();
730
731 assert!(child_info.path.exists());
732 let managed_root =
733 std::fs::canonicalize(manager.layout.workspace_parent_dir(child_info.repo_id))
734 .unwrap_or_else(|_| manager.layout.workspace_parent_dir(child_info.repo_id));
735 let child_path =
736 std::fs::canonicalize(&child_info.path).unwrap_or_else(|_| child_info.path.clone());
737 assert!(
738 child_path.starts_with(&managed_root),
739 "workspace path should be managed (path: {child_path:?}, managed_root: {managed_root:?})"
740 );
741 std::fs::write(child_info.path.join("subagent.txt"), "content").unwrap();
742
743 runtime
744 .block_on(manager.delete_workspace(DeleteWorkspaceRequest {
745 workspace_id: child_info.workspace_id,
746 }))
747 .unwrap();
748
749 assert!(!child_info.path.exists());
750
751 let (_workspace, repo) = jj::load_jj_workspace(temp_dir.path()).unwrap();
752 let repo_path = RepoPathBuf::from_internal_string("subagent.txt").unwrap();
753 let found = repo.view().heads().iter().any(|commit_id| {
754 let commit = repo.store().get_commit(commit_id).unwrap();
755 commit
756 .tree()
757 .unwrap()
758 .path_value(repo_path.as_ref())
759 .unwrap()
760 .is_present()
761 });
762
763 assert!(
764 found,
765 "snapshot commit should remain after workspace cleanup"
766 );
767 }
768
769 #[tokio::test]
770 async fn test_git_worktree_create_and_delete() {
771 let temp_dir = TempDir::new().unwrap();
772 let repo_root = temp_dir.path().join("repo");
773 std::fs::create_dir_all(&repo_root).unwrap();
774
775 let repo = gix::init(&repo_root).unwrap();
776 let signature = gix::actor::Signature {
777 name: "Test User".into(),
778 email: "test@example.com".into(),
779 time: gix::actor::date::Time::default(),
780 };
781 let mut time_buf = gix::actor::date::parse::TimeBuf::default();
782 let sig_ref = signature.to_ref(&mut time_buf);
783 let head_id = repo
784 .commit_as(
785 sig_ref,
786 sig_ref,
787 "HEAD",
788 "initial",
789 repo.empty_tree().id,
790 Vec::<gix::ObjectId>::new(),
791 )
792 .unwrap();
793 let head_oid = head_id.detach();
794
795 let manager_root = TempDir::new().unwrap();
796 let manager = LocalWorkspaceManager::new(manager_root.path().to_path_buf())
797 .await
798 .unwrap();
799
800 let base = manager.resolve_workspace(&repo_root).await.unwrap();
801 let created = manager
802 .create_workspace(CreateWorkspaceRequest {
803 repo_id: base.repo_id,
804 name: Some("git-subagent".to_string()),
805 parent_workspace_id: Some(base.workspace_id),
806 strategy: WorkspaceCreateStrategy::GitWorktree,
807 })
808 .await
809 .unwrap();
810
811 assert!(created.path.exists());
812 let gitfile = created.path.join(".git");
813 assert!(gitfile.is_file());
814 let private_git_dir = gix::discover::path::from_gitdir_file(&gitfile).unwrap();
815 assert!(private_git_dir.is_dir());
816 assert!(private_git_dir.join("refs").is_dir());
817
818 let orig_head = std::fs::read_to_string(private_git_dir.join("ORIG_HEAD")).unwrap();
819 assert_eq!(orig_head.trim(), head_oid.to_string());
820 let head_log = std::fs::read_to_string(private_git_dir.join("logs").join("HEAD")).unwrap();
821 assert!(head_log.contains(&head_oid.to_string()));
822 assert!(head_log.contains("checkout: moving from (initial)"));
823
824 let worktree_repo = gix::discover(&created.path).unwrap();
825 assert!(matches!(
826 worktree_repo.kind(),
827 gix::repository::Kind::WorkTree { is_linked: true }
828 ));
829
830 let vcs_info = VcsUtils::collect_vcs_info(&created.path).unwrap();
831 assert_eq!(vcs_info.kind, VcsKind::Git);
832 if let crate::VcsStatus::Git(status) = vcs_info.status {
833 assert!(status.error.is_none());
834 } else {
835 panic!("expected git status");
836 }
837
838 let main_repo = gix::discover(&repo_root).unwrap();
839 let worktrees = main_repo.worktrees().unwrap();
840 let mut listed = false;
841 for proxy in worktrees {
842 if let Ok(base) = proxy.base()
843 && base == created.path
844 {
845 listed = true;
846 break;
847 }
848 }
849 assert!(listed, "worktree should be listed");
850
851 manager
852 .delete_workspace(DeleteWorkspaceRequest {
853 workspace_id: created.workspace_id,
854 })
855 .await
856 .unwrap();
857
858 assert!(!created.path.exists());
859 assert!(!private_git_dir.exists());
860 }
861
862 #[tokio::test]
863 async fn test_git_worktree_delete_when_worktree_missing() {
864 let temp_dir = TempDir::new().unwrap();
865 let repo_root = temp_dir.path().join("repo");
866 std::fs::create_dir_all(&repo_root).unwrap();
867
868 let repo = gix::init(&repo_root).unwrap();
869 let signature = gix::actor::Signature {
870 name: "Test User".into(),
871 email: "test@example.com".into(),
872 time: gix::actor::date::Time::default(),
873 };
874 let mut time_buf = gix::actor::date::parse::TimeBuf::default();
875 let sig_ref = signature.to_ref(&mut time_buf);
876 repo.commit_as(
877 sig_ref,
878 sig_ref,
879 "HEAD",
880 "initial",
881 repo.empty_tree().id,
882 Vec::<gix::ObjectId>::new(),
883 )
884 .unwrap();
885
886 let manager_root = TempDir::new().unwrap();
887 let manager = LocalWorkspaceManager::new(manager_root.path().to_path_buf())
888 .await
889 .unwrap();
890
891 let base = manager.resolve_workspace(&repo_root).await.unwrap();
892 let created = manager
893 .create_workspace(CreateWorkspaceRequest {
894 repo_id: base.repo_id,
895 name: Some("git-subagent-missing".to_string()),
896 parent_workspace_id: Some(base.workspace_id),
897 strategy: WorkspaceCreateStrategy::GitWorktree,
898 })
899 .await
900 .unwrap();
901
902 let gitfile = created.path.join(".git");
903 let private_git_dir = gix::discover::path::from_gitdir_file(&gitfile).unwrap();
904
905 std::fs::remove_dir_all(&created.path).unwrap();
906
907 manager
908 .delete_workspace(DeleteWorkspaceRequest {
909 workspace_id: created.workspace_id,
910 })
911 .await
912 .unwrap();
913
914 assert!(!private_git_dir.exists());
915 }
916}