Compare commits
3 commits
88eb5122ab
...
3f6b953416
| Author | SHA1 | Date | |
|---|---|---|---|
| 3f6b953416 | |||
| 7455e540e3 | |||
| 5c5d6a6bc2 |
1 changed files with 380 additions and 22 deletions
402
workspaces.py
402
workspaces.py
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue