diff --git a/src/agentspaces/modules/workspace/service.py b/src/agentspaces/modules/workspace/service.py index 1d74e5c..e3af7bb 100644 --- a/src/agentspaces/modules/workspace/service.py +++ b/src/agentspaces/modules/workspace/service.py @@ -152,6 +152,12 @@ def create( metadata_dir = self._resolver.metadata_dir(project, result.name) metadata_dir.mkdir(parents=True, exist_ok=True) + # Add .agentspace/ to the main repo's git exclude file so it doesn't + # block worktree removal. We use .git/info/exclude instead of .gitignore + # because .gitignore would itself be an untracked file that blocks removal. + # Note: Git only reads exclude from the main repo, not from worktree git dirs. + self._ensure_git_exclude_entry(repo_root, ".agentspace/") + # Set up Python environment if requested env_info = None if setup_venv: @@ -571,3 +577,45 @@ def _update_metadata_timestamp( ) save_workspace_metadata(updated, metadata_path) + + def _ensure_git_exclude_entry(self, repo_root: Path, entry: str) -> None: + """Ensure an entry exists in the repository's git exclude file. + + Uses .git/info/exclude instead of .gitignore because .gitignore would + itself be an untracked file that blocks worktree removal. The exclude + file lives in the git metadata and has the same effect. + + Args: + repo_root: Path to the main repository root (not a worktree). + entry: The gitignore pattern to add (e.g., ".agentspace/"). + """ + git_dir = repo_root / ".git" + + if not git_dir.exists() or not git_dir.is_dir(): + logger.warning("git_dir_not_found", path=str(git_dir)) + return + + # Ensure info directory exists + info_dir = git_dir / "info" + info_dir.mkdir(parents=True, exist_ok=True) + + exclude_path = info_dir / "exclude" + + # Read existing content if file exists + existing_lines: set[str] = set() + if exclude_path.exists(): + content = exclude_path.read_text() + existing_lines = {line.strip() for line in content.splitlines()} + + # Check if entry already exists (strip to handle trailing whitespace) + if entry.strip() in existing_lines: + return + + # Append entry to exclude file + with exclude_path.open("a") as f: + # Add newline before if file exists and doesn't end with newline + if exclude_path.exists(): + content = exclude_path.read_text() + if content and not content.endswith("\n"): + f.write("\n") + f.write(f"{entry}\n") diff --git a/tests/unit/modules/workspace/test_service.py b/tests/unit/modules/workspace/test_service.py index 12caba4..b651020 100644 --- a/tests/unit/modules/workspace/test_service.py +++ b/tests/unit/modules/workspace/test_service.py @@ -120,6 +120,56 @@ def test_create_workspace_creates_metadata_dir( metadata_dir = resolver.metadata_dir("test-repo", result.name) assert metadata_dir.exists() + def test_create_workspace_adds_agentspace_to_git_exclude( + self, git_repo: Path, temp_dir: Path + ) -> None: + """Should add .agentspace/ to the main repo's git exclude file. + + Uses .git/info/exclude instead of .gitignore so the exclude itself + doesn't become an untracked file that blocks worktree removal. + Git only reads exclude from the main repo, not from worktree git dirs. + """ + resolver = PathResolver(base=temp_dir / ".agentspaces") + service = WorkspaceService(resolver=resolver) + + service.create( + base_branch="HEAD", + setup_venv=False, + cwd=git_repo, + ) + + # Check the main repo's exclude file (not the worktree's) + exclude_path = git_repo / ".git" / "info" / "exclude" + assert exclude_path.exists() + content = exclude_path.read_text() + assert ".agentspace/" in content + + def test_create_workspace_git_exclude_idempotent( + self, git_repo: Path, temp_dir: Path + ) -> None: + """Should not duplicate .agentspace/ entry if already present.""" + resolver = PathResolver(base=temp_dir / ".agentspaces") + service = WorkspaceService(resolver=resolver) + + # Create workspace + service.create( + base_branch="HEAD", + setup_venv=False, + cwd=git_repo, + ) + + # Check the main repo's exclude file + exclude_path = git_repo / ".git" / "info" / "exclude" + original_content = exclude_path.read_text() + + # Call _ensure_git_exclude_entry again (simulating another create) + service._ensure_git_exclude_entry(git_repo, ".agentspace/") + + # Content should be unchanged + new_content = exclude_path.read_text() + assert new_content == original_content + assert new_content.count(".agentspace/") == 1 + def test_create_workspace_not_in_repo(self, temp_dir: Path) -> None: """Should raise error when not in a git repo.""" resolver = PathResolver(base=temp_dir / ".agentspaces") @@ -168,7 +218,11 @@ class TestWorkspaceServiceRemove: """Tests for WorkspaceService.remove method.""" def test_remove_workspace_success(self, git_repo: Path, temp_dir: Path) -> None: - """Should remove an existing workspace.""" + """Should remove an existing workspace without force. + + The .agentspace/ directory is added to git's exclude file on creation, + so it doesn't block worktree removal. + """ resolver = PathResolver(base=temp_dir / ".agentspaces") service = WorkspaceService(resolver=resolver) @@ -180,8 +234,8 @@ def test_remove_workspace_success(self, git_repo: Path, temp_dir: Path) -> None: ) assert created.path.exists() - # Remove it (force=True needed because metadata files are untracked) - service.remove(created.name, force=True, cwd=git_repo) + # Remove it - no force needed because .agentspace/ is gitignored + service.remove(created.name, cwd=git_repo) assert not created.path.exists() @@ -287,8 +341,8 @@ def test_get_active_returns_none_when_workspace_deleted( ) service.set_active(created.name, cwd=git_repo) - # Delete the workspace - service.remove(created.name, force=True, cwd=git_repo) + # Delete the workspace (no force needed, .agentspace/ is gitignored) + service.remove(created.name, cwd=git_repo) # Active should return None now result = service.get_active(cwd=git_repo)