Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/agentspaces/modules/workspace/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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")
64 changes: 59 additions & 5 deletions tests/unit/modules/workspace/test_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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)

Expand All @@ -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()

Expand Down Expand Up @@ -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)
Expand Down