Compare commits

...

3 commits

Author SHA1 Message Date
3f6b953416 add status 2025-11-25 12:44:26 -06:00
7455e540e3 add wip and push commands 2025-11-25 12:18:48 -06:00
5c5d6a6bc2 no spaces in worktrees or dirs 2025-11-25 12:16:45 -06:00

View file

@ -24,6 +24,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
from rich.console import Console from rich.console import Console
from rich.table import Table from rich.table import Table
from rich.prompt import Prompt, Confirm from rich.prompt import Prompt, Confirm
from rich.panel import Panel
from iterfzf import iterfzf from iterfzf import iterfzf
app = typer.Typer( app = typer.Typer(
@ -252,6 +253,49 @@ def write_workspace_readme(ws_dir: Path, name: str, description: str) -> None:
(ws_dir / "readme.md").write_text(content, encoding="utf-8") (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 # Commands / main callback
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -312,7 +356,8 @@ def list_workspaces(
workspaces_dir.mkdir(parents=True, exist_ok=True) workspaces_dir.mkdir(parents=True, exist_ok=True)
table = Table(title=f"Workspaces ({workspaces_dir})") 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("Description", overflow="fold")
table.add_column("Repos", overflow="fold") table.add_column("Repos", overflow="fold")
@ -321,7 +366,7 @@ def list_workspaces(
raise typer.Exit(0) raise typer.Exit(0)
for ws in sorted(p for p in workspaces_dir.iterdir() if p.is_dir()): 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] = [] repos: List[str] = []
for child in sorted(p for p in ws.iterdir() if p.is_dir()): for child in sorted(p for p in ws.iterdir() if p.is_dir()):
if child.name.lower() == "readme.md": if child.name.lower() == "readme.md":
@ -333,7 +378,7 @@ def list_workspaces(
indicator = status.indicator indicator = status.indicator
repos.append(f"{child.name} [{branch}] {indicator}") repos.append(f"{child.name} [{branch}] {indicator}")
repos_str = "\n".join(repos) if repos else "-" 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) console.print(table)
@ -346,7 +391,10 @@ def list_workspaces(
def create_workspace( def create_workspace(
ctx: typer.Context, ctx: typer.Context,
name: Optional[str] = typer.Option( 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( description: Optional[str] = typer.Option(
None, None,
@ -359,25 +407,28 @@ def create_workspace(
Create a new workspace. Create a new workspace.
- Asks for name and description if not provided. - Asks for name and description if not provided.
- Creates directory under workspaces_dir. - Workspace directory uses a slugified version of the name.
- Creates README with format '# <name>\\n\\n<description>'. - README uses the original name as '# <name>\\n\\n<description>'.
""" """
_settings, _repos_dir, workspaces_dir = get_ctx_paths(ctx) _settings, _repos_dir, workspaces_dir = get_ctx_paths(ctx)
workspaces_dir.mkdir(parents=True, exist_ok=True) workspaces_dir.mkdir(parents=True, exist_ok=True)
if not name: if not name:
name = Prompt.ask("Workspace name") name = Prompt.ask("Workspace name (can contain spaces)")
if not description: if not description:
description = Prompt.ask("Workspace description", default="") 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(): 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) raise typer.Exit(1)
write_workspace_readme(ws_dir, name, description) 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 ---------------- # ---------------- list-repos ----------------
@ -391,7 +442,7 @@ def list_repos(
"--workspace", "--workspace",
"-w", "-w",
help=( help=(
"Workspace name to inspect. " "Workspace directory name to inspect. "
"If omitted, uses the workspace containing the current directory." "If omitted, uses the workspace containing the current directory."
), ),
), ),
@ -411,7 +462,8 @@ def list_repos(
console.print(f"[red]Workspace '{ws_dir.name}' does not exist at {ws_dir}[/red]") console.print(f"[red]Workspace '{ws_dir.name}' does not exist at {ws_dir}[/red]")
raise typer.Exit(1) 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("Repo (dir)", style="bold")
table.add_column("Branch") table.add_column("Branch")
table.add_column("Status") table.add_column("Status")
@ -464,6 +516,7 @@ def pick_repo_with_iterfzf(repos: List[Path]) -> Optional[Path]:
@app.command("add-repo") @app.command("add-repo")
@app.command("add", hidden=True)
def add_repo( def add_repo(
ctx: typer.Context, ctx: typer.Context,
workspace: Optional[str] = typer.Option( workspace: Optional[str] = typer.Option(
@ -471,7 +524,7 @@ def add_repo(
"--workspace", "--workspace",
"-w", "-w",
help=( help=(
"Workspace to add repo to. " "Workspace directory name to add repo to. "
"If omitted, uses the workspace containing the current directory." "If omitted, uses the workspace containing the current directory."
), ),
), ),
@ -490,7 +543,7 @@ def add_repo(
- Lists all directories in repos_dir as repos. - Lists all directories in repos_dir as repos.
- Uses iterfzf to pick repo if --repo not given. - 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. into workspace_dir / repo_name.
""" """
_settings, repos_dir, workspaces_dir = get_ctx_paths(ctx) _settings, repos_dir, workspaces_dir = get_ctx_paths(ctx)
@ -499,7 +552,9 @@ def add_repo(
console.print(f"[red]Workspace '{ws_dir.name}' does not exist at {ws_dir}[/red]") console.print(f"[red]Workspace '{ws_dir.name}' does not exist at {ws_dir}[/red]")
raise typer.Exit(1) 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) all_repos = list_all_repos(repos_dir)
if not all_repos: if not all_repos:
@ -533,7 +588,6 @@ def add_repo(
raise typer.Exit(0) raise typer.Exit(0)
# Ensure branch exists or create it # Ensure branch exists or create it
branch = ws_name
code, _out, err = run_cmd(["git", "rev-parse", "--verify", branch], cwd=repo_path) code, _out, err = run_cmd(["git", "rev-parse", "--verify", branch], cwd=repo_path)
if code != 0: if code != 0:
# create branch from current HEAD # create branch from current HEAD
@ -547,7 +601,7 @@ def add_repo(
) )
raise typer.Exit(1) 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) ws_dir.mkdir(parents=True, exist_ok=True)
code, _out, err = run_cmd( code, _out, err = run_cmd(
["git", "worktree", "add", str(target_dir), branch], ["git", "worktree", "add", str(target_dir), branch],
@ -562,7 +616,8 @@ def add_repo(
console.print( console.print(
f"[green]Added repo[/green] {repo_path.name} " 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 +672,7 @@ def remove_workspace(
"--workspace", "--workspace",
"-w", "-w",
help=( help=(
"Workspace to remove. " "Workspace directory name to remove. "
"If omitted, uses the workspace containing the current directory." "If omitted, uses the workspace containing the current directory."
), ),
), ),
@ -644,6 +699,8 @@ def remove_workspace(
console.print(f"[red]Workspace '{ws_dir.name}' does not exist at {ws_dir}[/red]") console.print(f"[red]Workspace '{ws_dir.name}' does not exist at {ws_dir}[/red]")
raise typer.Exit(1) raise typer.Exit(1)
title, _desc = read_workspace_readme(ws_dir)
# Collect worktrees (subdirs that look like git repos, excluding readme.md) # Collect worktrees (subdirs that look like git repos, excluding readme.md)
worktrees: List[Path] = [] worktrees: List[Path] = []
for child in sorted(p for p in ws_dir.iterdir() if p.is_dir()): for child in sorted(p for p in ws_dir.iterdir() if p.is_dir()):
@ -656,7 +713,8 @@ def remove_workspace(
if not worktrees: if not worktrees:
# No worktrees, just remove workspace dir # No worktrees, just remove workspace dir
if not Confirm.ask( 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, default=False,
): ):
raise typer.Exit(0) raise typer.Exit(0)
@ -688,7 +746,8 @@ def remove_workspace(
raise typer.Exit(1) raise typer.Exit(1)
if not Confirm.ask( 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, default=False,
): ):
raise typer.Exit(0) raise typer.Exit(0)
@ -726,8 +785,307 @@ def remove_workspace(
# Finally, remove the workspace directory itself # Finally, remove the workspace directory itself
shutil.rmtree(ws_dir) 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}'"
)
# ---------------- 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: <workspace display name>'.
"""
_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 <remote> <current-branch>'.
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__": if __name__ == "__main__":
app() app()