diff --git a/workspaces.py b/workspaces.py index d8ae44a..af589d4 100755 --- a/workspaces.py +++ b/workspaces.py @@ -24,7 +24,6 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from rich.console import Console from rich.table import Table from rich.prompt import Prompt, Confirm -from rich.panel import Panel from iterfzf import iterfzf app = typer.Typer( @@ -253,49 +252,6 @@ def write_workspace_readme(ws_dir: Path, name: str, description: str) -> None: (ws_dir / "readme.md").write_text(content, encoding="utf-8") -# --- name/branch helpers ---------------------------------------------------- - - -def slugify_workspace_name(name: str) -> str: - """ - Turn arbitrary workspace name into a safe directory/worktree name. - - - Lowercase - - Replace ':' with '-' FIRST (special case so 'fix:my issue' -> 'fix/my-issue' branch later) - - Replace spaces with '-' - - Replace any char not [a-z0-9._/-] with '-' - - Collapse multiple '-' and strip leading/trailing '-' and '/' - """ - name = name.replace(":", "-") - name = name.replace(" ", "-") - safe = [] - for ch in name: - if re.match(r"[a-zA-Z0-9._/-]", ch): - safe.append(ch.lower()) - else: - safe.append("-") - s = "".join(safe) - s = re.sub(r"-+", "-", s) - s = s.strip("-/") - return s or "workspace" - - -def branch_name_for_workspace(name: str) -> str: - """ - Compute branch name from workspace name. - - Rules: - - Start from slugified name (no spaces/specials). - - Replace *first* '-' with '/' so: - 'fix-my-issue' -> 'fix/my-issue' - (original 'fix:my issue' -> slug 'fix-my-issue' -> branch 'fix/my-issue') - """ - base = slugify_workspace_name(name) - if "-" in base: - return base.replace("-", "/", 1) - return base - - # --------------------------------------------------------------------------- # Commands / main callback # --------------------------------------------------------------------------- @@ -356,8 +312,7 @@ def list_workspaces( workspaces_dir.mkdir(parents=True, exist_ok=True) table = Table(title=f"Workspaces ({workspaces_dir})") - table.add_column("Workspace (dir)", style="bold") - table.add_column("Title", overflow="fold") + table.add_column("Workspace", style="bold") table.add_column("Description", overflow="fold") table.add_column("Repos", overflow="fold") @@ -366,7 +321,7 @@ def list_workspaces( raise typer.Exit(0) for ws in sorted(p for p in workspaces_dir.iterdir() if p.is_dir()): - title, desc = read_workspace_readme(ws) + name, desc = read_workspace_readme(ws) repos: List[str] = [] for child in sorted(p for p in ws.iterdir() if p.is_dir()): if child.name.lower() == "readme.md": @@ -378,7 +333,7 @@ def list_workspaces( indicator = status.indicator repos.append(f"{child.name} [{branch}] {indicator}") repos_str = "\n".join(repos) if repos else "-" - table.add_row(ws.name, title or "-", desc or "-", repos_str) + table.add_row(ws.name, desc or "-", repos_str) console.print(table) @@ -391,10 +346,7 @@ def list_workspaces( def create_workspace( ctx: typer.Context, name: Optional[str] = typer.Option( - None, - "--name", - "-n", - help="Name of the new workspace (display name; can contain spaces).", + None, "--name", "-n", help="Name of the new workspace (directory name)." ), description: Optional[str] = typer.Option( None, @@ -407,28 +359,25 @@ def create_workspace( Create a new workspace. - Asks for name and description if not provided. - - Workspace directory uses a slugified version of the name. - - README uses the original name as '# \\n\\n'. + - Creates directory under workspaces_dir. + - Creates README with format '# \\n\\n'. """ _settings, _repos_dir, workspaces_dir = get_ctx_paths(ctx) workspaces_dir.mkdir(parents=True, exist_ok=True) if not name: - name = Prompt.ask("Workspace name (can contain spaces)") + name = Prompt.ask("Workspace name") if not description: description = Prompt.ask("Workspace description", default="") - dir_name = slugify_workspace_name(name) - ws_dir = workspaces_dir / dir_name + ws_dir = workspaces_dir / name if ws_dir.exists(): - console.print(f"[red]Workspace dir '{dir_name}' already exists at {ws_dir}[/red]") + console.print(f"[red]Workspace '{name}' already exists at {ws_dir}[/red]") raise typer.Exit(1) write_workspace_readme(ws_dir, name, description) - console.print( - f"[green]Created workspace[/green] title='{name}' dir='{ws_dir.name}' at {ws_dir}" - ) + console.print(f"[green]Created workspace[/green] {ws_dir}") # ---------------- list-repos ---------------- @@ -442,7 +391,7 @@ def list_repos( "--workspace", "-w", help=( - "Workspace directory name to inspect. " + "Workspace name to inspect. " "If omitted, uses the workspace containing the current directory." ), ), @@ -462,8 +411,7 @@ def list_repos( console.print(f"[red]Workspace '{ws_dir.name}' does not exist at {ws_dir}[/red]") raise typer.Exit(1) - title, _desc = read_workspace_readme(ws_dir) - table = Table(title=f"Repos in workspace '{title}' (dir: {ws_dir.name})") + table = Table(title=f"Repos in workspace '{ws_dir.name}'") table.add_column("Repo (dir)", style="bold") table.add_column("Branch") table.add_column("Status") @@ -516,7 +464,6 @@ def pick_repo_with_iterfzf(repos: List[Path]) -> Optional[Path]: @app.command("add-repo") -@app.command("add", hidden=True) def add_repo( ctx: typer.Context, workspace: Optional[str] = typer.Option( @@ -524,7 +471,7 @@ def add_repo( "--workspace", "-w", help=( - "Workspace directory name to add repo to. " + "Workspace to add repo to. " "If omitted, uses the workspace containing the current directory." ), ), @@ -543,7 +490,7 @@ def add_repo( - Lists all directories in repos_dir as repos. - Uses iterfzf to pick repo if --repo not given. - - Creates a worktree for a branch derived from the workspace name + - Creates a worktree for branch named after the workspace into workspace_dir / repo_name. """ _settings, repos_dir, workspaces_dir = get_ctx_paths(ctx) @@ -552,9 +499,7 @@ def add_repo( console.print(f"[red]Workspace '{ws_dir.name}' does not exist at {ws_dir}[/red]") raise typer.Exit(1) - # Use display title from README to derive branch name - title, _desc = read_workspace_readme(ws_dir) - branch = branch_name_for_workspace(title or ws_dir.name) + ws_name = ws_dir.name all_repos = list_all_repos(repos_dir) if not all_repos: @@ -588,6 +533,7 @@ def add_repo( raise typer.Exit(0) # Ensure branch exists or create it + branch = ws_name code, _out, err = run_cmd(["git", "rev-parse", "--verify", branch], cwd=repo_path) if code != 0: # create branch from current HEAD @@ -601,7 +547,7 @@ def add_repo( ) raise typer.Exit(1) - # Create worktree (dir name is safe because repo_path.name is) + # Create worktree ws_dir.mkdir(parents=True, exist_ok=True) code, _out, err = run_cmd( ["git", "worktree", "add", str(target_dir), branch], @@ -616,8 +562,7 @@ def add_repo( console.print( f"[green]Added repo[/green] {repo_path.name} " - f"to workspace [bold]{title or ws_dir.name}[/bold] " - f"(dir: {ws_dir.name}) on branch '{branch}' at {target_dir}" + f"to workspace [bold]{ws_name}[/bold] at {target_dir}" ) @@ -672,7 +617,7 @@ def remove_workspace( "--workspace", "-w", help=( - "Workspace directory name to remove. " + "Workspace to remove. " "If omitted, uses the workspace containing the current directory." ), ), @@ -699,8 +644,6 @@ def remove_workspace( console.print(f"[red]Workspace '{ws_dir.name}' does not exist at {ws_dir}[/red]") raise typer.Exit(1) - title, _desc = read_workspace_readme(ws_dir) - # Collect worktrees (subdirs that look like git repos, excluding readme.md) worktrees: List[Path] = [] for child in sorted(p for p in ws_dir.iterdir() if p.is_dir()): @@ -713,8 +656,7 @@ def remove_workspace( if not worktrees: # No worktrees, just remove workspace dir if not Confirm.ask( - f"[red]No worktrees found.[/red] Remove empty workspace " - f"[bold]{title or ws_dir.name}[/bold] (dir: {ws_dir.name})?", + f"[red]No worktrees found.[/red] Remove empty workspace [bold]{ws_dir.name}[/bold]?", default=False, ): raise typer.Exit(0) @@ -746,8 +688,7 @@ def remove_workspace( raise typer.Exit(1) if not Confirm.ask( - f"Remove workspace [bold]{title or ws_dir.name}[/bold] (dir: {ws_dir.name}) " - "and clean up its worktrees?", + f"Remove workspace [bold]{ws_dir.name}[/bold] and clean up its worktrees?", default=False, ): raise typer.Exit(0) @@ -785,307 +726,8 @@ def remove_workspace( # Finally, remove the workspace directory itself shutil.rmtree(ws_dir) - console.print( - f"[green]Removed workspace[/green] title='{title or ws_dir.name}' dir='{ws_dir.name}'" - ) + console.print(f"[green]Removed workspace[/green] {ws_dir}") -# ---------------- wip (stage + commit) ---------------- - - -def git_status_porcelain(repo_path: Path) -> List[Tuple[str, str]]: - """ - Return a list of (status_code, path) for changes in the repo/worktree. - - Uses 'git status --porcelain'. - """ - code, out, err = run_cmd(["git", "status", "--porcelain"], cwd=repo_path) - if code != 0: - console.print( - f"[red]Failed to get status for {repo_path.name}:[/red]\n{err}" - ) - return [] - - changes: List[Tuple[str, str]] = [] - for line in out.splitlines(): - if not line.strip(): - continue - # Format: 'XY path' - status = line[:2] - path = line[3:] - changes.append((status, path)) - return changes - - -def choose_files_for_wip(repo_path: Path, changes: List[Tuple[str, str]]) -> List[str]: - """ - Interactively choose which files to stage for WIP. - - - Show list of changed files. - - Ask user: - * stage all - * stage none - * pick some using iterfzf (multi-select) - """ - if not changes: - return [] - - files = [p for _s, p in changes] - - console.print( - Panel( - "\n".join(files), - title=f"Changed files in {repo_path.name}", - subtitle="Use 'all', 'none', or 'pick' when prompted", - ) - ) - - choice = Prompt.ask( - "Stage which files?", - choices=["all", "none", "pick"], - default="all", - ) - - if choice == "none": - return [] - - if choice == "all": - return files - - # Multi-select via iterfzf - selected = iterfzf(files, multi=True, prompt="pick files> ") - if not selected: - return [] - # iterfzf may return a single string or list depending on version; - # normalize to list of strings. - if isinstance(selected, str): - selected_files = [selected] - else: - selected_files = list(selected) - - return selected_files - - -@app.command("wip") -def wip_workspace( - ctx: typer.Context, - workspace: Optional[str] = typer.Option( - None, - "--workspace", - "-w", - help=( - "Workspace directory name to WIP-commit. " - "If omitted, uses the workspace containing the current directory." - ), - ), -): - """ - For each repo in the workspace: - - - Show list of changed files. - - Ask whether to stage all, none, or pick some files. - - Stage chosen files. - - Make commit: 'wip: '. - """ - _settings, _repos_dir, workspaces_dir = get_ctx_paths(ctx) - ws_dir = find_workspace_dir(workspaces_dir, workspace) - if not ws_dir.exists(): - console.print(f"[red]Workspace '{ws_dir.name}' does not exist at {ws_dir}[/red]") - raise typer.Exit(1) - - title, _desc = read_workspace_readme(ws_dir) - commit_message = f"wip: {title or ws_dir.name}" - - worktrees: List[Path] = [] - for child in sorted(p for p in ws_dir.iterdir() if p.is_dir()): - if child.name.lower() == "readme.md": - continue - if not ensure_git_repo(child): - continue - worktrees.append(child) - - if not worktrees: - console.print(f"[yellow]No repos in workspace {ws_dir.name}[/yellow]") - raise typer.Exit(0) - - for wt in worktrees: - console.print(f"\n[bold]Repo:[/bold] {wt.name}") - changes = git_status_porcelain(wt) - if not changes: - console.print(" [green]No changes.[/green]") - continue - - files_to_stage = choose_files_for_wip(wt, changes) - if not files_to_stage: - console.print(" [yellow]Nothing selected to stage.[/yellow]") - continue - - # Stage selected files - for f in files_to_stage: - code, _out, err = run_cmd(["git", "add", f], cwd=wt) - if code != 0: - console.print( - f"[red]Failed to add {f} in {wt.name}:[/red]\n{err}" - ) - - # Check if there is anything to commit - code, out, _err = run_cmd(["git", "diff", "--cached", "--name-only"], cwd=wt) - if code != 0 or not out.strip(): - console.print(" [yellow]No staged changes to commit.[/yellow]") - continue - - # Commit - code, _out, err = run_cmd(["git", "commit", "-m", commit_message], cwd=wt) - if code != 0: - console.print( - f"[red]Failed to commit in {wt.name}:[/red]\n{err}" - ) - else: - console.print( - f" [green]Created WIP commit in {wt.name}:[/green] '{commit_message}'" - ) - - -# ---------------- push ---------------- - - -@app.command("push") -def push_workspace( - ctx: typer.Context, - workspace: Optional[str] = typer.Option( - None, - "--workspace", - "-w", - help=( - "Workspace directory name to push. " - "If omitted, uses the workspace containing the current directory." - ), - ), - remote: str = typer.Option( - "origin", - "--remote", - "-r", - help="Remote name to push to (default: origin).", - ), -): - """ - For each repo in the workspace, run 'git push '. - - Skips repos with no current branch or when push fails. - """ - _settings, _repos_dir, workspaces_dir = get_ctx_paths(ctx) - ws_dir = find_workspace_dir(workspaces_dir, workspace) - if not ws_dir.exists(): - console.print(f"[red]Workspace '{ws_dir.name}' does not exist at {ws_dir}[/red]") - raise typer.Exit(1) - - title, _desc = read_workspace_readme(ws_dir) - console.print( - f"Pushing all repos in workspace [bold]{title or ws_dir.name}[/bold] " - f"(dir: {ws_dir.name}) to remote '{remote}'" - ) - - worktrees: List[Path] = [] - for child in sorted(p for p in ws_dir.iterdir() if p.is_dir()): - if child.name.lower() == "readme.md": - continue - if not ensure_git_repo(child): - continue - worktrees.append(child) - - if not worktrees: - console.print(f"[yellow]No repos in workspace {ws_dir.name}[/yellow]") - raise typer.Exit(0) - - for wt in worktrees: - branch = get_current_branch(wt) - if not branch or branch == "HEAD": - console.print( - f"[yellow]Skipping {wt.name}: no current branch (detached HEAD?).[/yellow]" - ) - continue - - console.print(f"\n[bold]Repo:[/bold] {wt.name} [bold]Branch:[/bold] {branch}") - code, _out, err = run_cmd(["git", "push", remote, branch], cwd=wt) - if code != 0: - console.print( - f"[red]Failed to push {wt.name} ({branch}) to {remote}:[/red]\n{err}" - ) - else: - console.print( - f"[green]Pushed {wt.name} ({branch}) to {remote}[/green]" - ) - - -# ---------------- status (current workspace) ---------------- - - -@app.command("status") -def status_workspace( - ctx: typer.Context, - workspace: Optional[str] = typer.Option( - None, - "--workspace", - "-w", - help=( - "Workspace directory name to show status for. " - "If omitted, uses the workspace containing the current directory." - ), - ), -): - """ - Show detailed status for the current (or specified) workspace. - - For each repo in the workspace: - - repo directory name - - branch - - ahead/behind/dirty indicator - - number of changed files - """ - _settings, _repos_dir, workspaces_dir = get_ctx_paths(ctx) - ws_dir = find_workspace_dir(workspaces_dir, workspace) - if not ws_dir.exists(): - console.print(f"[red]Workspace '{ws_dir.name}' does not exist at {ws_dir}[/red]") - raise typer.Exit(1) - - title, desc = read_workspace_readme(ws_dir) - - table = Table( - title=f"Status for workspace '{title}' (dir: {ws_dir.name})", - show_lines=False, - ) - table.add_column("Repo (dir)", style="bold") - table.add_column("Branch") - table.add_column("Ahead/Behind/Dirty") - table.add_column("Changed Files", justify="right") - - worktrees: List[Path] = [] - for child in sorted(p for p in ws_dir.iterdir() if p.is_dir()): - if child.name.lower() == "readme.md": - continue - if not ensure_git_repo(child): - continue - worktrees.append(child) - - if not worktrees: - console.print(f"[yellow]No repos in workspace {ws_dir.name}[/yellow]") - raise typer.Exit(0) - - for wt in worktrees: - branch = get_current_branch(wt) or "?" - status = get_git_status(wt) - changes = git_status_porcelain(wt) - table.add_row( - wt.name, - branch, - status.indicator, - str(len(changes)), - ) - - console.print(table) - - if desc: - console.print(Panel(desc, title="Workspace description")) - if __name__ == "__main__": app()