diff --git a/workspaces.py b/workspaces.py index af589d4..4da1559 100755 --- a/workspaces.py +++ b/workspaces.py @@ -252,6 +252,49 @@ 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 # --------------------------------------------------------------------------- @@ -312,7 +355,8 @@ def list_workspaces( workspaces_dir.mkdir(parents=True, exist_ok=True) table = Table(title=f"Workspaces ({workspaces_dir})") - table.add_column("Workspace", style="bold") + table.add_column("Workspace (dir)", style="bold") + table.add_column("Title", overflow="fold") table.add_column("Description", overflow="fold") table.add_column("Repos", overflow="fold") @@ -321,7 +365,7 @@ def list_workspaces( raise typer.Exit(0) for ws in sorted(p for p in workspaces_dir.iterdir() if p.is_dir()): - name, desc = read_workspace_readme(ws) + title, 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": @@ -333,7 +377,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, desc or "-", repos_str) + table.add_row(ws.name, title or "-", desc or "-", repos_str) console.print(table) @@ -342,11 +386,13 @@ def list_workspaces( @app.command("create") -@app.command("new", hidden=True) def create_workspace( ctx: typer.Context, name: Optional[str] = typer.Option( - None, "--name", "-n", help="Name of the new workspace (directory name)." + None, + "--name", + "-n", + help="Name of the new workspace (display name; can contain spaces).", ), description: Optional[str] = typer.Option( None, @@ -359,25 +405,28 @@ def create_workspace( Create a new workspace. - Asks for name and description if not provided. - - Creates directory under workspaces_dir. - - Creates README with format '# \\n\\n'. + - Workspace directory uses a slugified version of the name. + - README uses the original name as '# \\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") + name = Prompt.ask("Workspace name (can contain spaces)") if not description: description = Prompt.ask("Workspace description", default="") - ws_dir = workspaces_dir / name + dir_name = slugify_workspace_name(name) + ws_dir = workspaces_dir / dir_name if ws_dir.exists(): - console.print(f"[red]Workspace '{name}' already exists at {ws_dir}[/red]") + console.print(f"[red]Workspace dir '{dir_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] {ws_dir}") + console.print( + f"[green]Created workspace[/green] title='{name}' dir='{ws_dir.name}' at {ws_dir}" + ) # ---------------- list-repos ---------------- @@ -391,7 +440,7 @@ def list_repos( "--workspace", "-w", help=( - "Workspace name to inspect. " + "Workspace directory name to inspect. " "If omitted, uses the workspace containing the current directory." ), ), @@ -411,7 +460,8 @@ def list_repos( console.print(f"[red]Workspace '{ws_dir.name}' does not exist at {ws_dir}[/red]") raise typer.Exit(1) - table = Table(title=f"Repos in workspace '{ws_dir.name}'") + title, _desc = read_workspace_readme(ws_dir) + table = Table(title=f"Repos in workspace '{title}' (dir: {ws_dir.name})") table.add_column("Repo (dir)", style="bold") table.add_column("Branch") table.add_column("Status") @@ -471,7 +521,7 @@ def add_repo( "--workspace", "-w", help=( - "Workspace to add repo to. " + "Workspace directory name to add repo to. " "If omitted, uses the workspace containing the current directory." ), ), @@ -490,7 +540,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 branch named after the workspace + - Creates a worktree for a branch derived from the workspace name into workspace_dir / repo_name. """ _settings, repos_dir, workspaces_dir = get_ctx_paths(ctx) @@ -499,7 +549,9 @@ def add_repo( console.print(f"[red]Workspace '{ws_dir.name}' does not exist at {ws_dir}[/red]") raise typer.Exit(1) - ws_name = ws_dir.name + # 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) all_repos = list_all_repos(repos_dir) if not all_repos: @@ -533,7 +585,6 @@ 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 @@ -547,7 +598,7 @@ def add_repo( ) raise typer.Exit(1) - # Create worktree + # Create worktree (dir name is safe because repo_path.name is) ws_dir.mkdir(parents=True, exist_ok=True) code, _out, err = run_cmd( ["git", "worktree", "add", str(target_dir), branch], @@ -562,7 +613,8 @@ def add_repo( console.print( f"[green]Added repo[/green] {repo_path.name} " - f"to workspace [bold]{ws_name}[/bold] at {target_dir}" + f"to workspace [bold]{title or ws_dir.name}[/bold] " + f"(dir: {ws_dir.name}) on branch '{branch}' at {target_dir}" ) @@ -617,7 +669,7 @@ def remove_workspace( "--workspace", "-w", help=( - "Workspace to remove. " + "Workspace directory name to remove. " "If omitted, uses the workspace containing the current directory." ), ), @@ -644,6 +696,8 @@ 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()): @@ -656,7 +710,8 @@ 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 [bold]{ws_dir.name}[/bold]?", + f"[red]No worktrees found.[/red] Remove empty workspace " + f"[bold]{title or ws_dir.name}[/bold] (dir: {ws_dir.name})?", default=False, ): raise typer.Exit(0) @@ -688,7 +743,8 @@ def remove_workspace( raise typer.Exit(1) if not Confirm.ask( - f"Remove workspace [bold]{ws_dir.name}[/bold] and clean up its worktrees?", + f"Remove workspace [bold]{title or ws_dir.name}[/bold] (dir: {ws_dir.name}) " + "and clean up its worktrees?", default=False, ): raise typer.Exit(0) @@ -726,7 +782,9 @@ def remove_workspace( # Finally, remove the workspace directory itself shutil.rmtree(ws_dir) - console.print(f"[green]Removed workspace[/green] {ws_dir}") + console.print( + f"[green]Removed workspace[/green] title='{title or ws_dir.name}' dir='{ws_dir.name}'" + ) if __name__ == "__main__":