Compare commits

..

No commits in common. "3f6b95341642d54d60bc01b13ba90dcd37e5556a" and "88eb5122ab1dea9004b231bcc43c49745ced566b" have entirely different histories.

View file

@ -24,7 +24,6 @@ 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(
@ -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") (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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -356,8 +312,7 @@ 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 (dir)", style="bold") table.add_column("Workspace", 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")
@ -366,7 +321,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()):
title, desc = read_workspace_readme(ws) name, 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":
@ -378,7 +333,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, title or "-", desc or "-", repos_str) table.add_row(ws.name, desc or "-", repos_str)
console.print(table) console.print(table)
@ -391,10 +346,7 @@ 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, None, "--name", "-n", help="Name of the new workspace (directory name)."
"--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,
@ -407,28 +359,25 @@ 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.
- Workspace directory uses a slugified version of the name. - Creates directory under workspaces_dir.
- README uses the original name as '# <name>\\n\\n<description>'. - Creates README with format '# <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 (can contain spaces)") name = Prompt.ask("Workspace name")
if not description: if not description:
description = Prompt.ask("Workspace description", default="") description = Prompt.ask("Workspace description", default="")
dir_name = slugify_workspace_name(name) ws_dir = workspaces_dir / name
ws_dir = workspaces_dir / dir_name
if ws_dir.exists(): 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) raise typer.Exit(1)
write_workspace_readme(ws_dir, name, description) write_workspace_readme(ws_dir, name, description)
console.print( console.print(f"[green]Created workspace[/green] {ws_dir}")
f"[green]Created workspace[/green] title='{name}' dir='{ws_dir.name}' at {ws_dir}"
)
# ---------------- list-repos ---------------- # ---------------- list-repos ----------------
@ -442,7 +391,7 @@ def list_repos(
"--workspace", "--workspace",
"-w", "-w",
help=( help=(
"Workspace directory name to inspect. " "Workspace name to inspect. "
"If omitted, uses the workspace containing the current directory." "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]") 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) table = Table(title=f"Repos in workspace '{ws_dir.name}'")
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")
@ -516,7 +464,6 @@ 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(
@ -524,7 +471,7 @@ def add_repo(
"--workspace", "--workspace",
"-w", "-w",
help=( help=(
"Workspace directory name to add repo to. " "Workspace to add repo to. "
"If omitted, uses the workspace containing the current directory." "If omitted, uses the workspace containing the current directory."
), ),
), ),
@ -543,7 +490,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 a branch derived from the workspace name - Creates a worktree for branch named after the workspace
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)
@ -552,9 +499,7 @@ 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)
# Use display title from README to derive branch name ws_name = ws_dir.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:
@ -588,6 +533,7 @@ 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
@ -601,7 +547,7 @@ def add_repo(
) )
raise typer.Exit(1) 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) 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],
@ -616,8 +562,7 @@ 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]{title or ws_dir.name}[/bold] " f"to workspace [bold]{ws_name}[/bold] at {target_dir}"
f"(dir: {ws_dir.name}) on branch '{branch}' at {target_dir}"
) )
@ -672,7 +617,7 @@ def remove_workspace(
"--workspace", "--workspace",
"-w", "-w",
help=( help=(
"Workspace directory name to remove. " "Workspace to remove. "
"If omitted, uses the workspace containing the current directory." "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]") 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()):
@ -713,8 +656,7 @@ 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 " f"[red]No worktrees found.[/red] Remove empty workspace [bold]{ws_dir.name}[/bold]?",
f"[bold]{title or ws_dir.name}[/bold] (dir: {ws_dir.name})?",
default=False, default=False,
): ):
raise typer.Exit(0) raise typer.Exit(0)
@ -746,8 +688,7 @@ def remove_workspace(
raise typer.Exit(1) raise typer.Exit(1)
if not Confirm.ask( if not Confirm.ask(
f"Remove workspace [bold]{title or ws_dir.name}[/bold] (dir: {ws_dir.name}) " f"Remove workspace [bold]{ws_dir.name}[/bold] and clean up its worktrees?",
"and clean up its worktrees?",
default=False, default=False,
): ):
raise typer.Exit(0) raise typer.Exit(0)
@ -785,307 +726,8 @@ 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( console.print(f"[green]Removed workspace[/green] {ws_dir}")
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()