Dev (#249)
* docs: restructure README.md — 2,539 → 209 lines (#247) - Cut from 2,539 lines / 73 sections to 209 lines / 18 sections - Consolidated 4 install methods into one unified section - Moved all skill details to domain-level READMEs (linked from table) - Front-loaded value prop and keywords for SEO - Added POWERFUL tier highlight section - Added skill-security-auditor showcase section - Removed stale Q4 2025 roadmap, outdated ROI claims, duplicate content - Fixed all internal links - Clean heading hierarchy (H2 for main sections only) Closes #233 Co-authored-by: Leo <leo@openclaw.ai> * fix: enhance 5 skills with scripts, references, and Anthropic best practices (#248) * fix(skill): enhance git-worktree-manager with scripts, references, and Anthropic best practices * fix(skill): enhance mcp-server-builder with scripts, references, and Anthropic best practices * fix(skill): enhance changelog-generator with scripts, references, and Anthropic best practices * fix(skill): enhance ci-cd-pipeline-builder with scripts, references, and Anthropic best practices * fix(skill): enhance prompt-engineer-toolkit with scripts, references, and Anthropic best practices * docs: update README, CHANGELOG, and plugin metadata * fix: correct marketing plugin count, expand thin references --------- Co-authored-by: Leo <leo@openclaw.ai> --------- Co-authored-by: Leo <leo@openclaw.ai>
This commit is contained in:
51
engineering/git-worktree-manager/README.md
Normal file
51
engineering/git-worktree-manager/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Git Worktree Manager
|
||||
|
||||
Production workflow for parallel branch development with isolated ports, env sync, and cleanup safety checks. This skill packages practical CLI tooling and operating guidance for multi-worktree teams.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
# Create + prepare a worktree
|
||||
python scripts/worktree_manager.py \
|
||||
--repo . \
|
||||
--branch feature/api-hardening \
|
||||
--name wt-api-hardening \
|
||||
--base-branch main \
|
||||
--install-deps \
|
||||
--format text
|
||||
|
||||
# Review stale worktrees
|
||||
python scripts/worktree_cleanup.py --repo . --stale-days 14 --format text
|
||||
```
|
||||
|
||||
## Included Tools
|
||||
|
||||
- `scripts/worktree_manager.py`: create/list-prep workflow, deterministic ports, `.env*` sync, optional dependency install
|
||||
- `scripts/worktree_cleanup.py`: stale/dirty/merged analysis with optional safe removal
|
||||
|
||||
Both support `--input <json-file>` and stdin JSON for automation.
|
||||
|
||||
## References
|
||||
|
||||
- `references/port-allocation-strategy.md`
|
||||
- `references/docker-compose-patterns.md`
|
||||
|
||||
## Installation
|
||||
|
||||
### Claude Code
|
||||
|
||||
```bash
|
||||
cp -R engineering/git-worktree-manager ~/.claude/skills/git-worktree-manager
|
||||
```
|
||||
|
||||
### OpenAI Codex
|
||||
|
||||
```bash
|
||||
cp -R engineering/git-worktree-manager ~/.codex/skills/git-worktree-manager
|
||||
```
|
||||
|
||||
### OpenClaw
|
||||
|
||||
```bash
|
||||
cp -R engineering/git-worktree-manager ~/.openclaw/skills/git-worktree-manager
|
||||
```
|
||||
@@ -6,152 +6,183 @@
|
||||
|
||||
## Overview
|
||||
|
||||
The Git Worktree Manager skill provides systematic management of Git worktrees for parallel development workflows. It handles worktree creation with automatic port allocation, environment file management, secret copying, and cleanup — enabling developers to run multiple Claude Code instances on separate features simultaneously without conflicts.
|
||||
Use this skill to run parallel feature work safely with Git worktrees. It standardizes branch isolation, port allocation, environment sync, and cleanup so each worktree behaves like an independent local app without stepping on another branch.
|
||||
|
||||
This skill is optimized for multi-agent workflows where each agent or terminal session owns one worktree.
|
||||
|
||||
## Core Capabilities
|
||||
|
||||
- **Worktree Lifecycle Management** — create, list, switch, and cleanup worktrees with automated setup
|
||||
- **Port Allocation & Isolation** — automatic port assignment per worktree to avoid dev server conflicts
|
||||
- **Environment Synchronization** — copy .env files, secrets, and config between main and worktrees
|
||||
- **Docker Compose Overrides** — generate per-worktree port override files for multi-service stacks
|
||||
- **Conflict Prevention** — detect and warn about shared resources, database names, and API endpoints
|
||||
- **Cleanup & Pruning** — safe removal with stale branch detection and uncommitted work warnings
|
||||
- Create worktrees from new or existing branches with deterministic naming
|
||||
- Auto-allocate non-conflicting ports per worktree and persist assignments
|
||||
- Copy local environment files (`.env*`) from main repo to new worktree
|
||||
- Optionally install dependencies based on lockfile detection
|
||||
- Detect stale worktrees and uncommitted changes before cleanup
|
||||
- Identify merged branches and safely remove outdated worktrees
|
||||
|
||||
## When to Use This Skill
|
||||
## When to Use
|
||||
|
||||
- Running multiple Claude Code sessions on different features simultaneously
|
||||
- Working on a hotfix while a feature branch has uncommitted work
|
||||
- Reviewing a PR while continuing development on your branch
|
||||
- Parallel CI/testing against multiple branches
|
||||
- Monorepo development with isolated package changes
|
||||
- You need 2+ concurrent branches open locally
|
||||
- You want isolated dev servers for feature, hotfix, and PR validation
|
||||
- You are working with multiple agents that must not share a branch
|
||||
- Your current branch is blocked but you need to ship a quick fix now
|
||||
- You want repeatable cleanup instead of ad-hoc `rm -rf` operations
|
||||
|
||||
## Worktree Creation Workflow
|
||||
## Key Workflows
|
||||
|
||||
### Step 1: Create Worktree
|
||||
### 1. Create a Fully-Prepared Worktree
|
||||
|
||||
1. Pick a branch name and worktree name.
|
||||
2. Run the manager script (creates branch if missing).
|
||||
3. Review generated port map.
|
||||
4. Start app using allocated ports.
|
||||
|
||||
```bash
|
||||
# Create worktree for a new feature branch
|
||||
git worktree add ../project-feature-auth -b feature/auth
|
||||
|
||||
# Create worktree from an existing remote branch
|
||||
git worktree add ../project-fix-123 origin/fix/issue-123
|
||||
|
||||
# Create worktree with tracking
|
||||
git worktree add --track -b feature/new-api ../project-new-api origin/main
|
||||
python scripts/worktree_manager.py \
|
||||
--repo . \
|
||||
--branch feature/new-auth \
|
||||
--name wt-auth \
|
||||
--base-branch main \
|
||||
--install-deps \
|
||||
--format text
|
||||
```
|
||||
|
||||
### Step 2: Environment Setup
|
||||
|
||||
After creating the worktree, automatically:
|
||||
|
||||
1. **Copy environment files:**
|
||||
```bash
|
||||
cp .env ../project-feature-auth/.env
|
||||
cp .env.local ../project-feature-auth/.env.local 2>/dev/null
|
||||
```
|
||||
|
||||
2. **Install dependencies:**
|
||||
```bash
|
||||
cd ../project-feature-auth
|
||||
[ -f "pnpm-lock.yaml" ] && pnpm install
|
||||
[ -f "yarn.lock" ] && yarn install
|
||||
[ -f "package-lock.json" ] && npm install
|
||||
[ -f "bun.lockb" ] && bun install
|
||||
```
|
||||
|
||||
3. **Allocate ports:**
|
||||
```
|
||||
Main worktree: localhost:3000 (dev), :5432 (db), :6379 (redis)
|
||||
Worktree 1: localhost:3010 (dev), :5442 (db), :6389 (redis)
|
||||
Worktree 2: localhost:3020 (dev), :5452 (db), :6399 (redis)
|
||||
```
|
||||
|
||||
### Step 3: Docker Compose Override
|
||||
|
||||
For Docker Compose projects, generate per-worktree override:
|
||||
|
||||
```yaml
|
||||
# docker-compose.worktree.yml (auto-generated)
|
||||
services:
|
||||
app:
|
||||
ports:
|
||||
- "3010:3000"
|
||||
db:
|
||||
ports:
|
||||
- "5442:5432"
|
||||
redis:
|
||||
ports:
|
||||
- "6389:6379"
|
||||
```
|
||||
|
||||
Usage: `docker compose -f docker-compose.yml -f docker-compose.worktree.yml up`
|
||||
|
||||
### Step 4: Database Isolation
|
||||
If you use JSON automation input:
|
||||
|
||||
```bash
|
||||
# Option A: Separate database per worktree
|
||||
createdb myapp_feature_auth
|
||||
|
||||
# Option B: DATABASE_URL override
|
||||
echo 'DATABASE_URL="postgresql://localhost:5442/myapp_wt1"' >> .env.local
|
||||
|
||||
# Option C: SQLite — file-based, automatic isolation
|
||||
cat config.json | python scripts/worktree_manager.py --format json
|
||||
# or
|
||||
python scripts/worktree_manager.py --input config.json --format json
|
||||
```
|
||||
|
||||
## Monorepo Optimization
|
||||
### 2. Run Parallel Sessions
|
||||
|
||||
Combine worktrees with sparse checkout for large repos:
|
||||
Recommended convention:
|
||||
|
||||
- Main repo: integration branch (`main`/`develop`) on default port
|
||||
- Worktree A: feature branch + offset ports
|
||||
- Worktree B: hotfix branch + next offset
|
||||
|
||||
Each worktree contains `.worktree-ports.json` with assigned ports.
|
||||
|
||||
### 3. Cleanup with Safety Checks
|
||||
|
||||
1. Scan all worktrees and stale age.
|
||||
2. Inspect dirty trees and branch merge status.
|
||||
3. Remove only merged + clean worktrees, or force explicitly.
|
||||
|
||||
```bash
|
||||
git worktree add --no-checkout ../project-packages-only
|
||||
cd ../project-packages-only
|
||||
git sparse-checkout init --cone
|
||||
git sparse-checkout set packages/shared packages/api
|
||||
git checkout feature/api-refactor
|
||||
python scripts/worktree_cleanup.py --repo . --stale-days 14 --format text
|
||||
python scripts/worktree_cleanup.py --repo . --remove-merged --format text
|
||||
```
|
||||
|
||||
## Claude Code Integration
|
||||
### 4. Docker Compose Pattern
|
||||
|
||||
Each worktree gets auto-generated CLAUDE.md:
|
||||
Use per-worktree override files mapped from allocated ports. The script outputs a deterministic port map; apply it to `docker-compose.worktree.yml`.
|
||||
|
||||
```markdown
|
||||
# Worktree: feature/auth
|
||||
# Dev server port: 3010
|
||||
# Created: 2026-03-01
|
||||
See [docker-compose-patterns.md](references/docker-compose-patterns.md) for concrete templates.
|
||||
|
||||
## Scope
|
||||
Focus on changes related to this branch only.
|
||||
### 5. Port Allocation Strategy
|
||||
|
||||
## Commands
|
||||
- Dev: PORT=3010 npm run dev
|
||||
- Test: npm test -- --related
|
||||
- Lint: npm run lint
|
||||
```
|
||||
Default strategy is `base + (index * stride)` with collision checks:
|
||||
|
||||
Run parallel sessions:
|
||||
```bash
|
||||
# Terminal 1: Main feature
|
||||
cd ~/project && claude
|
||||
# Terminal 2: Hotfix
|
||||
cd ~/project-hotfix && claude
|
||||
# Terminal 3: PR review
|
||||
cd ~/project-pr-review && claude
|
||||
```
|
||||
- App: `3000`
|
||||
- Postgres: `5432`
|
||||
- Redis: `6379`
|
||||
- Stride: `10`
|
||||
|
||||
See [port-allocation-strategy.md](references/port-allocation-strategy.md) for the full strategy and edge cases.
|
||||
|
||||
## Script Interfaces
|
||||
|
||||
- `python scripts/worktree_manager.py --help`
|
||||
- Create/list worktrees
|
||||
- Allocate/persist ports
|
||||
- Copy `.env*` files
|
||||
- Optional dependency installation
|
||||
- `python scripts/worktree_cleanup.py --help`
|
||||
- Stale detection by age
|
||||
- Dirty-state detection
|
||||
- Merged-branch detection
|
||||
- Optional safe removal
|
||||
|
||||
Both tools support stdin JSON and `--input` file mode for automation pipelines.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Shared node_modules** — Worktrees share git dir but NOT node_modules. Always install deps.
|
||||
2. **Port conflicts** — Two dev servers on :3000 = silent failures. Always allocate unique ports.
|
||||
3. **Database migrations** — Migrations in one worktree affect all if sharing same DB. Isolate.
|
||||
4. **Git hooks** — Live in `.git/hooks` (shared). Worktree-specific hooks need symlinks.
|
||||
5. **IDE confusion** — VSCode may show wrong branch. Open as separate window.
|
||||
6. **Stale worktrees** — Prune regularly: `git worktree prune`.
|
||||
1. Creating worktrees inside the main repo directory
|
||||
2. Reusing `localhost:3000` across all branches
|
||||
3. Sharing one database URL across isolated feature branches
|
||||
4. Removing a worktree with uncommitted changes
|
||||
5. Forgetting to prune old metadata after branch deletion
|
||||
6. Assuming merged status without checking against the target branch
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. Name worktrees by purpose: `project-auth`, `project-hotfix-123`, `project-pr-456`
|
||||
2. Never create worktrees inside the main repo directory
|
||||
3. Keep worktrees short-lived — merge and cleanup within days
|
||||
4. Use the setup script — manual creation skips env/port/deps
|
||||
5. One Claude Code instance per worktree — isolation is the point
|
||||
6. Commit before switching — even WIP commits prevent lost work
|
||||
1. One branch per worktree, one agent per worktree.
|
||||
2. Keep worktrees short-lived; remove after merge.
|
||||
3. Use a deterministic naming pattern (`wt-<topic>`).
|
||||
4. Persist port mappings in file, not memory or terminal notes.
|
||||
5. Run cleanup scan weekly in active repos.
|
||||
6. Use `--format json` for machine flows and `--format text` for human review.
|
||||
7. Never force-remove dirty worktrees unless changes are intentionally discarded.
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
Before claiming setup complete:
|
||||
|
||||
1. `git worktree list` shows expected path + branch.
|
||||
2. `.worktree-ports.json` exists and contains unique ports.
|
||||
3. `.env` files copied successfully (if present in source repo).
|
||||
4. Dependency install command exits with code `0` (if enabled).
|
||||
5. Cleanup scan reports no unintended stale dirty trees.
|
||||
|
||||
## References
|
||||
|
||||
- [port-allocation-strategy.md](references/port-allocation-strategy.md)
|
||||
- [docker-compose-patterns.md](references/docker-compose-patterns.md)
|
||||
- [README.md](README.md) for quick start and installation details
|
||||
|
||||
## Decision Matrix
|
||||
|
||||
Use this quick selector before creating a new worktree:
|
||||
|
||||
- Need isolated dependencies and server ports -> create a new worktree
|
||||
- Need only a quick local diff review -> stay on current tree
|
||||
- Need hotfix while feature branch is dirty -> create dedicated hotfix worktree
|
||||
- Need ephemeral reproduction branch for bug triage -> create temporary worktree and cleanup same day
|
||||
|
||||
## Operational Checklist
|
||||
|
||||
### Before Creation
|
||||
|
||||
1. Confirm main repo has clean baseline or intentional WIP commits.
|
||||
2. Confirm target branch naming convention.
|
||||
3. Confirm required base branch exists (`main`/`develop`).
|
||||
4. Confirm no reserved local ports are already occupied by non-repo services.
|
||||
|
||||
### After Creation
|
||||
|
||||
1. Verify `git status` branch matches expected branch.
|
||||
2. Verify `.worktree-ports.json` exists.
|
||||
3. Verify app boots on allocated app port.
|
||||
4. Verify DB and cache endpoints target isolated ports.
|
||||
|
||||
### Before Removal
|
||||
|
||||
1. Verify branch has upstream and is merged when intended.
|
||||
2. Verify no uncommitted files remain.
|
||||
3. Verify no running containers/processes depend on this worktree path.
|
||||
|
||||
## CI and Team Integration
|
||||
|
||||
- Use worktree path naming that maps to task ID (`wt-1234-auth`).
|
||||
- Include the worktree path in terminal title to avoid wrong-window commits.
|
||||
- In automated setups, persist creation metadata in CI artifacts/logs.
|
||||
- Trigger cleanup report in scheduled jobs and post summary to team channel.
|
||||
|
||||
## Failure Recovery
|
||||
|
||||
- If `git worktree add` fails due to existing path: inspect path, do not overwrite.
|
||||
- If dependency install fails: keep worktree created, mark status and continue manual recovery.
|
||||
- If env copy fails: continue with warning and explicit missing file list.
|
||||
- If port allocation collides with external service: rerun with adjusted base ports.
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# Docker Compose Patterns For Worktrees
|
||||
|
||||
## Pattern 1: Override File Per Worktree
|
||||
|
||||
Base compose file remains shared; each worktree has a local override.
|
||||
|
||||
`docker-compose.worktree.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
ports:
|
||||
- "3010:3000"
|
||||
db:
|
||||
ports:
|
||||
- "5442:5432"
|
||||
redis:
|
||||
ports:
|
||||
- "6389:6379"
|
||||
```
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.worktree.yml up -d
|
||||
```
|
||||
|
||||
## Pattern 2: `.env` Driven Ports
|
||||
|
||||
Use compose variable substitution and write worktree-specific values into `.env.local`.
|
||||
|
||||
`docker-compose.yml` excerpt:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
ports: ["${APP_PORT:-3000}:3000"]
|
||||
db:
|
||||
ports: ["${DB_PORT:-5432}:5432"]
|
||||
```
|
||||
|
||||
Worktree `.env.local`:
|
||||
|
||||
```env
|
||||
APP_PORT=3010
|
||||
DB_PORT=5442
|
||||
REDIS_PORT=6389
|
||||
```
|
||||
|
||||
## Pattern 3: Project Name Isolation
|
||||
|
||||
Use unique compose project name so container, network, and volume names do not collide.
|
||||
|
||||
```bash
|
||||
docker compose -p myapp_wt_auth up -d
|
||||
```
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
- Reusing default `5432` from multiple worktrees simultaneously
|
||||
- Sharing one database volume across incompatible migration branches
|
||||
- Forgetting to scope compose project name per worktree
|
||||
@@ -0,0 +1,46 @@
|
||||
# Port Allocation Strategy
|
||||
|
||||
## Objective
|
||||
|
||||
Allocate deterministic, non-overlapping local ports for each worktree to avoid collisions across concurrent development sessions.
|
||||
|
||||
## Default Mapping
|
||||
|
||||
- App HTTP: `3000`
|
||||
- Postgres: `5432`
|
||||
- Redis: `6379`
|
||||
- Stride per worktree: `10`
|
||||
|
||||
Formula by slot index `n`:
|
||||
|
||||
- `app = 3000 + (10 * n)`
|
||||
- `db = 5432 + (10 * n)`
|
||||
- `redis = 6379 + (10 * n)`
|
||||
|
||||
Examples:
|
||||
|
||||
- Slot 0: `3000/5432/6379`
|
||||
- Slot 1: `3010/5442/6389`
|
||||
- Slot 2: `3020/5452/6399`
|
||||
|
||||
## Collision Avoidance
|
||||
|
||||
1. Read `.worktree-ports.json` from existing worktrees.
|
||||
2. Skip any slot where one or more ports are already assigned.
|
||||
3. Persist selected mapping in the new worktree.
|
||||
|
||||
## Operational Notes
|
||||
|
||||
- Keep stride >= number of services to avoid accidental overlaps when adding ports later.
|
||||
- For custom service sets, reserve a contiguous block per worktree.
|
||||
- If you also run local infra outside worktrees, offset bases to avoid global collisions.
|
||||
|
||||
## Recommended File Format
|
||||
|
||||
```json
|
||||
{
|
||||
"app": 3010,
|
||||
"db": 5442,
|
||||
"redis": 6389
|
||||
}
|
||||
```
|
||||
196
engineering/git-worktree-manager/scripts/worktree_cleanup.py
Executable file
196
engineering/git-worktree-manager/scripts/worktree_cleanup.py
Executable file
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Inspect and clean stale git worktrees with safety checks.
|
||||
|
||||
Supports:
|
||||
- JSON input from stdin or --input file
|
||||
- Stale age detection
|
||||
- Dirty working tree detection
|
||||
- Merged branch detection
|
||||
- Optional removal of merged, clean stale worktrees
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
class CLIError(Exception):
|
||||
"""Raised for expected CLI errors."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorktreeInfo:
|
||||
path: str
|
||||
branch: str
|
||||
is_main: bool
|
||||
age_days: int
|
||||
stale: bool
|
||||
dirty: bool
|
||||
merged_into_base: bool
|
||||
|
||||
|
||||
def run(cmd: List[str], cwd: Optional[Path] = None, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, check=check)
|
||||
|
||||
|
||||
def load_json_input(input_file: Optional[str]) -> Dict[str, Any]:
|
||||
if input_file:
|
||||
try:
|
||||
return json.loads(Path(input_file).read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
raise CLIError(f"Failed reading --input file: {exc}") from exc
|
||||
if not sys.stdin.isatty():
|
||||
raw = sys.stdin.read().strip()
|
||||
if raw:
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise CLIError(f"Invalid JSON from stdin: {exc}") from exc
|
||||
return {}
|
||||
|
||||
|
||||
def parse_worktrees(repo: Path) -> List[Dict[str, str]]:
|
||||
proc = run(["git", "worktree", "list", "--porcelain"], cwd=repo)
|
||||
entries: List[Dict[str, str]] = []
|
||||
current: Dict[str, str] = {}
|
||||
for line in proc.stdout.splitlines():
|
||||
if not line.strip():
|
||||
if current:
|
||||
entries.append(current)
|
||||
current = {}
|
||||
continue
|
||||
key, _, value = line.partition(" ")
|
||||
current[key] = value
|
||||
if current:
|
||||
entries.append(current)
|
||||
return entries
|
||||
|
||||
|
||||
def get_branch(path: Path) -> str:
|
||||
proc = run(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=path)
|
||||
return proc.stdout.strip()
|
||||
|
||||
|
||||
def get_last_commit_age_days(path: Path) -> int:
|
||||
proc = run(["git", "log", "-1", "--format=%ct"], cwd=path)
|
||||
timestamp = int(proc.stdout.strip() or "0")
|
||||
age_seconds = int(time.time()) - timestamp
|
||||
return max(0, age_seconds // 86400)
|
||||
|
||||
|
||||
def is_dirty(path: Path) -> bool:
|
||||
proc = run(["git", "status", "--porcelain"], cwd=path)
|
||||
return bool(proc.stdout.strip())
|
||||
|
||||
|
||||
def is_merged(repo: Path, branch: str, base_branch: str) -> bool:
|
||||
if branch in ("HEAD", base_branch):
|
||||
return False
|
||||
try:
|
||||
run(["git", "merge-base", "--is-ancestor", branch, base_branch], cwd=repo)
|
||||
return True
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
|
||||
def format_text(items: List[WorktreeInfo], removed: List[str]) -> str:
|
||||
lines = ["Worktree cleanup report"]
|
||||
for item in items:
|
||||
lines.append(
|
||||
f"- {item.path} | branch={item.branch} | age={item.age_days}d | "
|
||||
f"stale={item.stale} dirty={item.dirty} merged={item.merged_into_base}"
|
||||
)
|
||||
if removed:
|
||||
lines.append("Removed:")
|
||||
for path in removed:
|
||||
lines.append(f"- {path}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Analyze and optionally cleanup stale git worktrees.")
|
||||
parser.add_argument("--input", help="Path to JSON input file. If omitted, reads JSON from stdin when piped.")
|
||||
parser.add_argument("--repo", default=".", help="Repository root path.")
|
||||
parser.add_argument("--base-branch", default="main", help="Base branch to evaluate merged branches.")
|
||||
parser.add_argument("--stale-days", type=int, default=14, help="Threshold for stale worktrees.")
|
||||
parser.add_argument("--remove-merged", action="store_true", help="Remove worktrees that are stale, clean, and merged.")
|
||||
parser.add_argument("--force", action="store_true", help="Allow removal even if dirty (use carefully).")
|
||||
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
payload = load_json_input(args.input)
|
||||
|
||||
repo = Path(str(payload.get("repo", args.repo))).resolve()
|
||||
stale_days = int(payload.get("stale_days", args.stale_days))
|
||||
base_branch = str(payload.get("base_branch", args.base_branch))
|
||||
remove_merged = bool(payload.get("remove_merged", args.remove_merged))
|
||||
force = bool(payload.get("force", args.force))
|
||||
|
||||
try:
|
||||
run(["git", "rev-parse", "--is-inside-work-tree"], cwd=repo)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise CLIError(f"Not a git repository: {repo}") from exc
|
||||
|
||||
try:
|
||||
run(["git", "rev-parse", "--verify", base_branch], cwd=repo)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise CLIError(f"Base branch not found: {base_branch}") from exc
|
||||
|
||||
entries = parse_worktrees(repo)
|
||||
if not entries:
|
||||
raise CLIError("No worktrees found.")
|
||||
|
||||
main_path = Path(entries[0].get("worktree", "")).resolve()
|
||||
infos: List[WorktreeInfo] = []
|
||||
removed: List[str] = []
|
||||
|
||||
for entry in entries:
|
||||
path = Path(entry.get("worktree", "")).resolve()
|
||||
branch = get_branch(path)
|
||||
age = get_last_commit_age_days(path)
|
||||
dirty = is_dirty(path)
|
||||
stale = age >= stale_days
|
||||
merged = is_merged(repo, branch, base_branch)
|
||||
info = WorktreeInfo(
|
||||
path=str(path),
|
||||
branch=branch,
|
||||
is_main=path == main_path,
|
||||
age_days=age,
|
||||
stale=stale,
|
||||
dirty=dirty,
|
||||
merged_into_base=merged,
|
||||
)
|
||||
infos.append(info)
|
||||
|
||||
if remove_merged and not info.is_main and info.stale and info.merged_into_base and (force or not info.dirty):
|
||||
try:
|
||||
cmd = ["git", "worktree", "remove", str(path)]
|
||||
if force:
|
||||
cmd.append("--force")
|
||||
run(cmd, cwd=repo)
|
||||
removed.append(str(path))
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise CLIError(f"Failed removing worktree {path}: {exc.stderr}") from exc
|
||||
|
||||
if args.format == "json":
|
||||
print(json.dumps({"worktrees": [asdict(i) for i in infos], "removed": removed}, indent=2))
|
||||
else:
|
||||
print(format_text(infos, removed))
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except CLIError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
240
engineering/git-worktree-manager/scripts/worktree_manager.py
Executable file
240
engineering/git-worktree-manager/scripts/worktree_manager.py
Executable file
@@ -0,0 +1,240 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create and prepare git worktrees with deterministic port allocation.
|
||||
|
||||
Supports:
|
||||
- JSON input from stdin or --input file
|
||||
- Worktree creation from existing/new branch
|
||||
- .env file sync from main repo
|
||||
- Optional dependency installation
|
||||
- JSON or text output
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass, asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
ENV_FILES = [".env", ".env.local", ".env.development", ".envrc"]
|
||||
LOCKFILE_COMMANDS = [
|
||||
("pnpm-lock.yaml", ["pnpm", "install"]),
|
||||
("yarn.lock", ["yarn", "install"]),
|
||||
("package-lock.json", ["npm", "install"]),
|
||||
("bun.lockb", ["bun", "install"]),
|
||||
("requirements.txt", [sys.executable, "-m", "pip", "install", "-r", "requirements.txt"]),
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorktreeResult:
|
||||
repo: str
|
||||
worktree_path: str
|
||||
branch: str
|
||||
created: bool
|
||||
ports: Dict[str, int]
|
||||
copied_env_files: List[str]
|
||||
dependency_install: str
|
||||
|
||||
|
||||
class CLIError(Exception):
|
||||
"""Raised for expected CLI errors."""
|
||||
|
||||
|
||||
def run(cmd: List[str], cwd: Optional[Path] = None, check: bool = True) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(cmd, cwd=cwd, text=True, capture_output=True, check=check)
|
||||
|
||||
|
||||
def load_json_input(input_file: Optional[str]) -> Dict[str, Any]:
|
||||
if input_file:
|
||||
try:
|
||||
return json.loads(Path(input_file).read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
raise CLIError(f"Failed reading --input file: {exc}") from exc
|
||||
|
||||
if not sys.stdin.isatty():
|
||||
data = sys.stdin.read().strip()
|
||||
if data:
|
||||
try:
|
||||
return json.loads(data)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise CLIError(f"Invalid JSON from stdin: {exc}") from exc
|
||||
return {}
|
||||
|
||||
|
||||
def parse_worktree_list(repo: Path) -> List[Dict[str, str]]:
|
||||
proc = run(["git", "worktree", "list", "--porcelain"], cwd=repo)
|
||||
entries: List[Dict[str, str]] = []
|
||||
current: Dict[str, str] = {}
|
||||
for line in proc.stdout.splitlines():
|
||||
if not line.strip():
|
||||
if current:
|
||||
entries.append(current)
|
||||
current = {}
|
||||
continue
|
||||
key, _, value = line.partition(" ")
|
||||
current[key] = value
|
||||
if current:
|
||||
entries.append(current)
|
||||
return entries
|
||||
|
||||
|
||||
def find_next_ports(repo: Path, app_base: int, db_base: int, redis_base: int, stride: int) -> Dict[str, int]:
|
||||
used_ports = set()
|
||||
for entry in parse_worktree_list(repo):
|
||||
wt_path = Path(entry.get("worktree", ""))
|
||||
ports_file = wt_path / ".worktree-ports.json"
|
||||
if ports_file.exists():
|
||||
try:
|
||||
payload = json.loads(ports_file.read_text(encoding="utf-8"))
|
||||
used_ports.update(int(v) for v in payload.values() if isinstance(v, int))
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
index = 0
|
||||
while True:
|
||||
ports = {
|
||||
"app": app_base + (index * stride),
|
||||
"db": db_base + (index * stride),
|
||||
"redis": redis_base + (index * stride),
|
||||
}
|
||||
if all(p not in used_ports for p in ports.values()):
|
||||
return ports
|
||||
index += 1
|
||||
|
||||
|
||||
def sync_env_files(src_repo: Path, dest_repo: Path) -> List[str]:
|
||||
copied = []
|
||||
for name in ENV_FILES:
|
||||
src = src_repo / name
|
||||
if src.exists() and src.is_file():
|
||||
dst = dest_repo / name
|
||||
shutil.copy2(src, dst)
|
||||
copied.append(name)
|
||||
return copied
|
||||
|
||||
|
||||
def install_dependencies_if_requested(worktree_path: Path, install: bool) -> str:
|
||||
if not install:
|
||||
return "skipped"
|
||||
|
||||
for lockfile, command in LOCKFILE_COMMANDS:
|
||||
if (worktree_path / lockfile).exists():
|
||||
try:
|
||||
run(command, cwd=worktree_path, check=True)
|
||||
return f"installed via {' '.join(command)}"
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise CLIError(f"Dependency install failed: {' '.join(command)}\n{exc.stderr}") from exc
|
||||
|
||||
return "no known lockfile found"
|
||||
|
||||
|
||||
def ensure_worktree(repo: Path, branch: str, name: str, base_branch: str) -> Path:
|
||||
wt_parent = repo.parent
|
||||
wt_path = wt_parent / name
|
||||
|
||||
existing_paths = {Path(e.get("worktree", "")) for e in parse_worktree_list(repo)}
|
||||
if wt_path in existing_paths:
|
||||
return wt_path
|
||||
|
||||
try:
|
||||
run(["git", "show-ref", "--verify", f"refs/heads/{branch}"], cwd=repo)
|
||||
run(["git", "worktree", "add", str(wt_path), branch], cwd=repo)
|
||||
except subprocess.CalledProcessError:
|
||||
try:
|
||||
run(["git", "worktree", "add", "-b", branch, str(wt_path), base_branch], cwd=repo)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise CLIError(f"Failed to create worktree: {exc.stderr}") from exc
|
||||
|
||||
return wt_path
|
||||
|
||||
|
||||
def format_text(result: WorktreeResult) -> str:
|
||||
lines = [
|
||||
"Worktree prepared",
|
||||
f"- repo: {result.repo}",
|
||||
f"- path: {result.worktree_path}",
|
||||
f"- branch: {result.branch}",
|
||||
f"- created: {result.created}",
|
||||
f"- ports: app={result.ports['app']} db={result.ports['db']} redis={result.ports['redis']}",
|
||||
f"- copied env files: {', '.join(result.copied_env_files) if result.copied_env_files else 'none'}",
|
||||
f"- dependency install: {result.dependency_install}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Create and prepare a git worktree.")
|
||||
parser.add_argument("--input", help="Path to JSON input file. If omitted, reads JSON from stdin when piped.")
|
||||
parser.add_argument("--repo", default=".", help="Path to repository root (default: current directory).")
|
||||
parser.add_argument("--branch", help="Branch name for the worktree.")
|
||||
parser.add_argument("--name", help="Worktree directory name (created adjacent to repo).")
|
||||
parser.add_argument("--base-branch", default="main", help="Base branch when creating a new branch.")
|
||||
parser.add_argument("--app-base", type=int, default=3000, help="Base app port.")
|
||||
parser.add_argument("--db-base", type=int, default=5432, help="Base DB port.")
|
||||
parser.add_argument("--redis-base", type=int, default=6379, help="Base Redis port.")
|
||||
parser.add_argument("--stride", type=int, default=10, help="Port stride between worktrees.")
|
||||
parser.add_argument("--install-deps", action="store_true", help="Install dependencies in the new worktree.")
|
||||
parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
payload = load_json_input(args.input)
|
||||
|
||||
repo = Path(str(payload.get("repo", args.repo))).resolve()
|
||||
branch = payload.get("branch", args.branch)
|
||||
name = payload.get("name", args.name)
|
||||
base_branch = str(payload.get("base_branch", args.base_branch))
|
||||
|
||||
app_base = int(payload.get("app_base", args.app_base))
|
||||
db_base = int(payload.get("db_base", args.db_base))
|
||||
redis_base = int(payload.get("redis_base", args.redis_base))
|
||||
stride = int(payload.get("stride", args.stride))
|
||||
install_deps = bool(payload.get("install_deps", args.install_deps))
|
||||
|
||||
if not branch or not name:
|
||||
raise CLIError("Missing required values: --branch and --name (or provide via JSON input).")
|
||||
|
||||
try:
|
||||
run(["git", "rev-parse", "--is-inside-work-tree"], cwd=repo)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
raise CLIError(f"Not a git repository: {repo}") from exc
|
||||
|
||||
wt_path = ensure_worktree(repo, branch, name, base_branch)
|
||||
created = (wt_path / ".worktree-ports.json").exists() is False
|
||||
|
||||
ports = find_next_ports(repo, app_base, db_base, redis_base, stride)
|
||||
(wt_path / ".worktree-ports.json").write_text(json.dumps(ports, indent=2), encoding="utf-8")
|
||||
|
||||
copied = sync_env_files(repo, wt_path)
|
||||
install_status = install_dependencies_if_requested(wt_path, install_deps)
|
||||
|
||||
result = WorktreeResult(
|
||||
repo=str(repo),
|
||||
worktree_path=str(wt_path),
|
||||
branch=branch,
|
||||
created=created,
|
||||
ports=ports,
|
||||
copied_env_files=copied,
|
||||
dependency_install=install_status,
|
||||
)
|
||||
|
||||
if args.format == "json":
|
||||
print(json.dumps(asdict(result), indent=2))
|
||||
else:
|
||||
print(format_text(result))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except CLIError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
raise SystemExit(2)
|
||||
Reference in New Issue
Block a user