Files
Aperant/auto-build/workspace.py
T
2025-12-10 09:10:55 +01:00

623 lines
18 KiB
Python

#!/usr/bin/env python3
"""
Workspace Selection and Management
===================================
Provides a user-friendly interface for choosing where auto-build should work.
Designed for "vibe coders" - people who may not understand git internals.
Key principles:
1. Simple language - no git jargon
2. Safe defaults - protect user's work
3. Clear outcomes - explain what will happen
4. No dangerous options - discard requires separate deliberate action
Terminology mapping (technical -> user-friendly):
- worktree -> "separate workspace"
- branch -> "version of your project"
- uncommitted changes -> "unsaved work"
- merge -> "add to your project"
- working directory -> "your project"
"""
import subprocess
import sys
from enum import Enum
from pathlib import Path
from typing import Optional
from worktree import WorktreeManager, WorktreeInfo, STAGING_WORKTREE_NAME
class WorkspaceMode(Enum):
"""How auto-build should work."""
ISOLATED = "isolated" # Work in a separate worktree (safe)
DIRECT = "direct" # Work directly in user's project
class WorkspaceChoice(Enum):
"""User's choice after build completes."""
MERGE = "merge" # Add changes to project
REVIEW = "review" # Show what changed
TEST = "test" # Test the feature in the staging worktree
LATER = "later" # Decide later
def has_uncommitted_changes(project_dir: Path) -> bool:
"""Check if user has unsaved work."""
result = subprocess.run(
["git", "status", "--porcelain"],
cwd=project_dir,
capture_output=True,
text=True,
)
return bool(result.stdout.strip())
def get_current_branch(project_dir: Path) -> str:
"""Get the current branch name."""
result = subprocess.run(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=project_dir,
capture_output=True,
text=True,
)
return result.stdout.strip()
def get_existing_build_worktree(project_dir: Path, spec_name: str) -> Optional[Path]:
"""Check if there's an existing staging worktree."""
worktree_path = project_dir / ".worktrees" / STAGING_WORKTREE_NAME
if worktree_path.exists():
return worktree_path
return None
def choose_workspace(
project_dir: Path,
spec_name: str,
force_isolated: bool = False,
force_direct: bool = False,
) -> WorkspaceMode:
"""
Let user choose where auto-build should work.
Uses simple, non-technical language. Safe defaults.
Args:
project_dir: The project directory
spec_name: Name of the spec being built
force_isolated: Skip prompts and use isolated mode
force_direct: Skip prompts and use direct mode
Returns:
WorkspaceMode indicating where to work
"""
# Handle forced modes
if force_isolated:
return WorkspaceMode.ISOLATED
if force_direct:
return WorkspaceMode.DIRECT
# Check for unsaved work
has_unsaved = has_uncommitted_changes(project_dir)
if has_unsaved:
# Unsaved work detected - use isolated mode for safety
print()
print("=" * 60)
print(" YOUR WORK IS PROTECTED")
print("=" * 60)
print()
print("You have unsaved work in your project.")
print()
print("To keep your work safe, the AI will build in a")
print("separate workspace. Your current files won't be")
print("touched until you're ready.")
print()
try:
input("Press Enter to continue...")
except KeyboardInterrupt:
print("\n\nCancelled.")
sys.exit(0)
return WorkspaceMode.ISOLATED
# Clean working directory - give choice
print()
print("=" * 60)
print(" WHERE SHOULD THE AI BUILD YOUR FEATURE?")
print("=" * 60)
print()
print("[1] In a separate workspace (Recommended)")
print(" Your current files stay untouched")
print(" You can review changes before keeping them")
print(" Easy to undo if you don't like it")
print()
print("[2] Right here in your project")
print(" Changes happen directly in your files")
print(" Best if you're not working on anything else")
print()
try:
choice = input("Your choice [1]: ").strip() or "1"
except KeyboardInterrupt:
print("\n\nCancelled.")
sys.exit(0)
if choice == "2":
print()
print("Got it! Working directly in your project.")
return WorkspaceMode.DIRECT
else:
print()
print("Got it! Using a separate workspace for safety.")
return WorkspaceMode.ISOLATED
def setup_workspace(
project_dir: Path,
spec_name: str,
mode: WorkspaceMode,
) -> tuple[Path, Optional[WorktreeManager]]:
"""
Set up the workspace based on user's choice.
Uses the staging worktree pattern - all work happens in one worktree
that the user can test before merging.
Args:
project_dir: The project directory
spec_name: Name of the spec being built
mode: The workspace mode to use
Returns:
Tuple of (working_directory, worktree_manager or None)
"""
if mode == WorkspaceMode.DIRECT:
# Work directly in project
return project_dir, None
# Create isolated workspace using staging worktree
print()
print("Setting up separate workspace...")
manager = WorktreeManager(project_dir)
manager.setup()
# Get or create the staging worktree
info = manager.get_or_create_staging(spec_name)
print(f"Workspace ready: {info.path}")
print()
return info.path, manager
def show_build_summary(manager: WorktreeManager, name: str = STAGING_WORKTREE_NAME) -> None:
"""Show a summary of what was built."""
summary = manager.get_change_summary(name)
files = manager.get_changed_files(name)
total = summary["new_files"] + summary["modified_files"] + summary["deleted_files"]
if total == 0:
print(" No changes were made.")
return
print()
print("What was built:")
if summary["new_files"] > 0:
print(f" + {summary['new_files']} new file{'s' if summary['new_files'] != 1 else ''}")
if summary["modified_files"] > 0:
print(f" ~ {summary['modified_files']} modified file{'s' if summary['modified_files'] != 1 else ''}")
if summary["deleted_files"] > 0:
print(f" - {summary['deleted_files']} deleted file{'s' if summary['deleted_files'] != 1 else ''}")
def show_changed_files(manager: WorktreeManager, name: str = STAGING_WORKTREE_NAME) -> None:
"""Show detailed list of changed files."""
files = manager.get_changed_files(name)
if not files:
print(" No changes.")
return
status_labels = {
"A": "+ (new)",
"M": "~ (modified)",
"D": "- (deleted)",
}
print()
print("Changed files:")
for status, filepath in files:
label = status_labels.get(status, status)
print(f" {label} {filepath}")
def finalize_workspace(
project_dir: Path,
spec_name: str,
manager: Optional[WorktreeManager],
) -> WorkspaceChoice:
"""
Handle post-build workflow - let user decide what to do with changes.
Safe design:
- No "discard" option (requires separate --discard command)
- Default is "test" - encourages testing before merging
- Everything is preserved until user explicitly merges or discards
Args:
project_dir: The project directory
spec_name: Name of the spec that was built
manager: The worktree manager (None if direct mode was used)
Returns:
WorkspaceChoice indicating what user wants to do
"""
if manager is None:
# Direct mode - nothing to finalize
print()
print("=" * 60)
print(" BUILD COMPLETE!")
print("=" * 60)
print()
print("Changes were made directly to your project.")
print("Use 'git status' to see what changed.")
return WorkspaceChoice.MERGE # Already merged
# Isolated mode - show options with testing as the recommended path
print()
print("=" * 60)
print(" BUILD COMPLETE!")
print("=" * 60)
print()
print("The AI built your feature in a separate workspace.")
show_build_summary(manager)
# Get the staging path for test instructions
staging_path = manager.get_staging_path()
print()
print("What would you like to do?")
print()
print("[1] Test the feature (Recommended)")
print(" Run the app and try it out before adding to your project")
print()
print("[2] Add to my project now")
print(" Merge the changes into your files immediately")
print()
print("[3] Review what changed")
print(" See exactly what files were modified")
print()
print("[4] Decide later")
print(" Your build is saved - you can come back anytime")
print()
try:
choice = input("Your choice [1]: ").strip() or "1"
except KeyboardInterrupt:
print("\n\nNo problem! Your build is saved.")
choice = "4"
if choice == "1":
return WorkspaceChoice.TEST
elif choice == "2":
return WorkspaceChoice.MERGE
elif choice == "3":
return WorkspaceChoice.REVIEW
else:
return WorkspaceChoice.LATER
def handle_workspace_choice(
choice: WorkspaceChoice,
project_dir: Path,
spec_name: str,
manager: WorktreeManager,
) -> None:
"""
Execute the user's choice.
Args:
choice: What the user wants to do
project_dir: The project directory
spec_name: Name of the spec
manager: The worktree manager
"""
staging_path = manager.get_staging_path()
if choice == WorkspaceChoice.TEST:
# Show testing instructions
print()
print("=" * 60)
print(" TEST YOUR FEATURE")
print("=" * 60)
print()
print("Your feature is ready to test in a separate workspace.")
print()
print("To test it, open a NEW terminal and run:")
print()
if staging_path:
print(f" cd {staging_path}")
else:
print(f" cd {project_dir}/.worktrees/{STAGING_WORKTREE_NAME}")
# Show likely test/run commands
if staging_path:
commands = manager.get_test_commands(staging_path)
print()
print("Then run your project:")
for cmd in commands[:2]: # Show top 2 commands
print(f" {cmd}")
print()
print("-" * 60)
print()
print("When you're done testing:")
print(f" python auto-build/run.py --spec {spec_name} --merge")
print()
print("To discard (if you don't like it):")
print(f" python auto-build/run.py --spec {spec_name} --discard")
print()
elif choice == WorkspaceChoice.MERGE:
print()
print("Adding changes to your project...")
success = manager.merge_staging(delete_after=True)
if success:
print()
print("Done! Your feature has been added to your project.")
else:
print()
print("There was a conflict merging the changes.")
print("Your build is still saved in the separate workspace.")
print()
print("You may need to merge manually or ask for help.")
elif choice == WorkspaceChoice.REVIEW:
show_changed_files(manager)
print()
print("-" * 60)
print()
print("To see full details of changes:")
info = manager.get_staging_info()
if info:
print(f" git diff {info.base_branch}...{info.branch}")
print()
print("To test the feature:")
if staging_path:
print(f" cd {staging_path}")
print()
print("To add these changes to your project:")
print(f" python auto-build/run.py --spec {spec_name} --merge")
print()
else: # LATER
print()
print("No problem! Your build is saved.")
print()
print("To test the feature:")
if staging_path:
print(f" cd {staging_path}")
else:
print(f" cd {project_dir}/.worktrees/{STAGING_WORKTREE_NAME}")
print()
print("When you're ready to add it:")
print(f" python auto-build/run.py --spec {spec_name} --merge")
print()
print("To see what was built:")
print(f" python auto-build/run.py --spec {spec_name} --review")
print()
def merge_existing_build(project_dir: Path, spec_name: str) -> bool:
"""
Merge an existing build into the project.
Called when user runs: python auto-build/run.py --spec X --merge
Args:
project_dir: The project directory
spec_name: Name of the spec
Returns:
True if merge succeeded
"""
worktree_path = get_existing_build_worktree(project_dir, spec_name)
if not worktree_path:
print()
print(f"No existing build found for '{spec_name}'.")
print()
print("To start a new build:")
print(f" python auto-build/run.py --spec {spec_name}")
return False
print()
print("=" * 60)
print(" ADDING BUILD TO YOUR PROJECT")
print("=" * 60)
manager = WorktreeManager(project_dir)
# Load the staging worktree info
manager.get_staging_info()
show_build_summary(manager)
print()
success = manager.merge_staging(delete_after=True)
if success:
print()
print("Done! Your feature has been added to your project.")
return True
else:
print()
print("There was a conflict merging the changes.")
print("You may need to merge manually.")
return False
def review_existing_build(project_dir: Path, spec_name: str) -> bool:
"""
Show what an existing build contains.
Called when user runs: python auto-build/run.py --spec X --review
Args:
project_dir: The project directory
spec_name: Name of the spec
Returns:
True if build exists
"""
worktree_path = get_existing_build_worktree(project_dir, spec_name)
if not worktree_path:
print()
print(f"No existing build found for '{spec_name}'.")
print()
print("To start a new build:")
print(f" python auto-build/run.py --spec {spec_name}")
return False
print()
print("=" * 60)
print(" BUILD CONTENTS")
print("=" * 60)
manager = WorktreeManager(project_dir)
# Load the staging worktree info
info = manager.get_staging_info()
show_build_summary(manager)
show_changed_files(manager)
print()
print("-" * 60)
print()
print("To test the feature:")
print(f" cd {worktree_path}")
print()
print("To add these changes to your project:")
print(f" python auto-build/run.py --spec {spec_name} --merge")
print()
print("To see full diff:")
if info:
print(f" git diff {info.base_branch}...{info.branch}")
print()
return True
def discard_existing_build(project_dir: Path, spec_name: str) -> bool:
"""
Discard an existing build (with confirmation).
Called when user runs: python auto-build/run.py --spec X --discard
Requires typing "delete" to confirm - prevents accidents.
Args:
project_dir: The project directory
spec_name: Name of the spec
Returns:
True if discarded
"""
worktree_path = get_existing_build_worktree(project_dir, spec_name)
if not worktree_path:
print()
print(f"No existing build found for '{spec_name}'.")
return False
print()
print("=" * 60)
print(" DELETE BUILD RESULTS?")
print("=" * 60)
manager = WorktreeManager(project_dir)
# Load the staging worktree info
manager.get_staging_info()
print()
print("This will permanently delete all work for this build.")
show_build_summary(manager)
print()
print("Are you sure? Type 'delete' to confirm: ", end="")
try:
confirmation = input().strip().lower()
except KeyboardInterrupt:
print("\n\nCancelled. Your build is still saved.")
return False
if confirmation != "delete":
print()
print("Cancelled. Your build is still saved.")
return False
# Actually delete
manager.remove_staging(delete_branch=True)
print()
print("Build deleted.")
return True
def check_existing_build(project_dir: Path, spec_name: str) -> bool:
"""
Check if there's an existing build and offer options.
Returns True if user wants to continue with existing build,
False if they want to start fresh (after discarding).
"""
worktree_path = get_existing_build_worktree(project_dir, spec_name)
if not worktree_path:
return False # No existing build
print()
print("=" * 60)
print(" EXISTING BUILD FOUND")
print("=" * 60)
print()
print("There's already a build in progress for this spec.")
print()
print("[1] Continue where it left off")
print("[2] Review what was built")
print("[3] Add to my project now")
print("[4] Start fresh (discard current build)")
print()
try:
choice = input("Your choice [1]: ").strip() or "1"
except KeyboardInterrupt:
print("\n\nCancelled.")
sys.exit(0)
if choice == "1":
return True # Continue with existing
elif choice == "2":
review_existing_build(project_dir, spec_name)
print()
input("Press Enter to continue building...")
return True
elif choice == "3":
merge_existing_build(project_dir, spec_name)
return False # Start fresh after merge
elif choice == "4":
discarded = discard_existing_build(project_dir, spec_name)
return not discarded # If discarded, start fresh
else:
return True # Default to continue