Skip to main content

steer_workspace/local/
manager.rs

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}