mirror of
https://github.com/max-sixty/worktrunk.git
synced 2026-09-14 20:00:38 +08:00
d7e3f88422
Phase 1 of the staged hook rename tracked in #2838: the worktree-creation hooks `pre-start`/`post-start` become `pre-create`/`post-create`. The old names keep working with no deprecation warning yet (Phase 2, months out, adds the warning). ## What changes - `pre-create`/`post-create` are canonical everywhere: the `HookType` enum, the `HooksConfig` serde fields, the `wt hook` CLI, completion, and all docs. - Old names keep working: `migrate_content()` rewrites `pre-start`/`post-start` config keys to `-create` before serde, and `parse_hook_type` accepts the old CLI names as silent aliases. `wt config update` rewrites them on disk; `wt config show` shows the migration diff. `wt hook <type>` execution and `wt hook show` both accept the old names; completion and `--help` advertise only the canonical names. - `detect_deprecations()` flags the old keys so `update`/`show` act on them, but `format_deprecation_warnings()` stays silent (Phase 2 adds the warning). A new empty-warnings guard in `check_and_migrate` keeps a `-start`-only config from emitting a stray hint. - The dead pre-0.32.0 `post-create` machinery is removed: the fatal `POST_CREATE_REMOVED_MSG` load error, the vestigial `HooksConfig.post_create` merge-fold, and `find_post_create_from_doc`. `post-create` is reclaimed as the canonical background creation hook. ## Semantic flip Before v0.32.0, the key `post-create` named a *blocking* hook. It now names the *background* one. Since v0.44.0, a pre-0.32.0 `post-create` config is a fatal load error on the `check_and_migrate` paths: `ProjectConfig::load` and user/system config loading, which fire on essentially every `wt` command. A repo carrying one has been unusable ever since. The one path that skips that check is `project_config_at_ref` (the base-ref read behind `wt switch --create`), which applies only structural migration. A pre-0.32.0 `post-create` surviving solely on a base ref, never checked out into a worktree, would now load as a background hook rather than folding into the blocking `pre-start`. That edge case is accepted: once `post-create` is valid again, reclaiming the name and detecting the dead key are mutually exclusive. ## Reviewing this diff 205 files, but the substance is ~36 files under `src/`. The rest is regenerated snapshots and auto-synced doc mirrors. Start with: - `src/config/deprecation.rs` — detection (`find_renamed_hook_key`), migration (`rename_hook_key`), removal of the fatal block, the empty-warnings guard, and the `DEPRECATED_SECTION_KEYS` entries that stop unknown-field detection from flagging the migrated keys. - `src/config/hooks.rs`, `src/git/mod.rs` — the serde field and enum renames. - `src/config/project.rs` — `ProjectConfig::load` deserializes `check_and_migrate`'s migrated content, so a current-worktree config using the old keys loads into the canonical fields. - `src/cli/hook.rs`, `src/commands/hook_commands.rs`, `src/completion.rs`, `src/main.rs` — the CLI alias layer; `wt hook show` accepts the old type names as hidden value-parser aliases. - `src/cli/mod.rs` — the `wt hook` docs, including the soft-deprecation note linking #2838. The ~93 modified snapshots also pick up deterministic env-block lines (`GIT_*: ""`, `LLVM_PROFILE_FILE`) that pre-existing snapshots already carry. That is stale-snapshot drift surfaced by the regeneration, not a behavior change. ## Testing Full suite green (3799 tests). New coverage: `snapshot_migrate_start_to_create` (migration preserves value shape and position), `test_deprecated_start_hook_key_runs_silently` and `test_standalone_hook_start_alias_runs_silently` (old config and CLI names run with no warning), `test_config_show_displays_start_hook_migration` (`config show` reveals the diff without an "unknown field" warning), and `test_hook_show_accepts_deprecated_start_hooks` (a current-worktree config using the old keys loads, and `wt hook show` takes both the canonical and the deprecated type arguments). Part of #2838. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
670 lines
23 KiB
Python
Executable File
670 lines
23 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Unified build script for demo GIFs.
|
|
|
|
Usage:
|
|
./build docs # Build doc site demos (light + dark, 1600x900)
|
|
./build social # Build social media demos (light only, 1200x700)
|
|
./build docs --only wt-merge # Build specific demo for docs
|
|
./build social --only wt-zellij-omnibus --shell # Debug shell for social demo
|
|
|
|
Output is written to docs/static/assets/{mode}/{theme}/, ensuring docs and social
|
|
builds don't overwrite each other. The assets repo mirrors this structure.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import time
|
|
from concurrent.futures import ProcessPoolExecutor, as_completed
|
|
from functools import partial
|
|
from pathlib import Path
|
|
|
|
# Add docs/demos/ to path for shared library
|
|
SCRIPT_DIR = Path(__file__).parent
|
|
sys.path.insert(0, str(SCRIPT_DIR))
|
|
|
|
from shared import ( # noqa: E402
|
|
FIXTURES_DIR,
|
|
VALIDATION_RS,
|
|
DemoEnv,
|
|
DemoSize,
|
|
git,
|
|
prepare_demo_repo,
|
|
setup_claude_code_config,
|
|
setup_zellij_config,
|
|
setup_fish_config,
|
|
check_dependencies,
|
|
check_ffmpeg_libass,
|
|
setup_demo_output,
|
|
record_all_themes,
|
|
SIZE_DOCS,
|
|
SIZE_SOCIAL,
|
|
record_text,
|
|
record_snapshot,
|
|
build_tape_replacements,
|
|
ensure_vhs_binary,
|
|
)
|
|
from shared.validation import ( # noqa: E402
|
|
TUI_CHECKPOINTS,
|
|
validate_tui_demo_verbose,
|
|
)
|
|
|
|
REPO_ROOT = SCRIPT_DIR.parent.parent
|
|
OUT_DIR = SCRIPT_DIR.parent / "static" / "assets"
|
|
TAPES_DIR = SCRIPT_DIR / "tapes"
|
|
SNAPSHOTS_DIR = SCRIPT_DIR / "snapshots"
|
|
|
|
# Demos that use TUI or interactive input - not suitable for command snapshots
|
|
TUI_DEMOS = {"wt-switch-picker", "wt-switch", "wt-statusline", "wt-zellij-omnibus"}
|
|
|
|
|
|
# =============================================================================
|
|
# Setup Helpers
|
|
# =============================================================================
|
|
|
|
|
|
def _add_billing_branch(env: DemoEnv):
|
|
"""Add a billing branch at same commit as main (merged state)."""
|
|
branch = "billing"
|
|
path = env.work_base / f"acme.{branch}"
|
|
git(["-C", str(env.repo), "worktree", "add", "-q", "-b", branch, str(path), "main"])
|
|
git(["-C", str(path), "push", "-u", "origin", branch, "-q"])
|
|
|
|
|
|
def _add_staged_validation(env: DemoEnv):
|
|
"""Add validation.rs with staged changes to hooks branch."""
|
|
hooks_path = env.work_base / "acme.hooks"
|
|
if hooks_path.exists():
|
|
new_file = hooks_path / "src" / "validation.rs"
|
|
new_file.write_text(VALIDATION_RS)
|
|
git(["-C", str(hooks_path), "add", "src/validation.rs"])
|
|
|
|
|
|
def _write_user_config(
|
|
env: DemoEnv, approved_commands: list[str], commit_generation: bool = True
|
|
):
|
|
"""Write worktrunk user config and approvals."""
|
|
config_dir = env.home / ".config" / "worktrunk"
|
|
project_id = str(env.bare_remote).removesuffix(".git")
|
|
|
|
# User config (portable settings only)
|
|
config_content = ""
|
|
if commit_generation:
|
|
config_content += """[commit.generation]
|
|
command = "llm -m claude-haiku-4.5"
|
|
|
|
"""
|
|
config_content += """[list]
|
|
summary = true
|
|
|
|
"""
|
|
(config_dir / "config.toml").write_text(config_content)
|
|
|
|
# Approvals (machine-local trust state, separate from config)
|
|
approvals_content = f'[projects."{project_id}"]\n'
|
|
approvals_content += f"approved-commands = {approved_commands!r}\n"
|
|
(config_dir / "approvals.toml").write_text(approvals_content)
|
|
|
|
|
|
# =============================================================================
|
|
# Demo Setup Functions
|
|
# =============================================================================
|
|
|
|
|
|
def prepare_basic(env: DemoEnv):
|
|
"""Basic repo setup - no hooks, no extras."""
|
|
prepare_demo_repo(env, REPO_ROOT)
|
|
|
|
|
|
def prepare_with_hooks(env: DemoEnv, include_deps: bool = False):
|
|
"""Standard hooks demo: dev server + cleanup + billing branch."""
|
|
dev_cmd = "npm run dev -- --port {{ branch | hash_port }}"
|
|
cleanup_cmd = "flyctl scale count 0"
|
|
|
|
post_start = ["[post-create]"]
|
|
approved = []
|
|
if include_deps:
|
|
post_start.append('deps = "npm install"')
|
|
approved.append("npm install")
|
|
post_start.append(f'dev = "{dev_cmd}"')
|
|
approved.append(dev_cmd)
|
|
approved.append(cleanup_cmd)
|
|
|
|
hooks_config = (
|
|
"\n".join(post_start) + f'\n\n[pre-remove]\ncleanup = "{cleanup_cmd}"\n'
|
|
)
|
|
|
|
prepare_demo_repo(env, REPO_ROOT, hooks_config=hooks_config)
|
|
_write_user_config(env, approved)
|
|
_add_billing_branch(env)
|
|
|
|
|
|
def prepare_merge_demo(
|
|
env: DemoEnv, commit_generation: bool = True, staged_changes: bool = True
|
|
):
|
|
"""Merge demo setup. Use commit_generation=False for social (no LLM in GIF)."""
|
|
prepare_demo_repo(env, REPO_ROOT)
|
|
_write_user_config(env, ["cargo nextest run"], commit_generation=commit_generation)
|
|
if staged_changes:
|
|
_add_staged_validation(env)
|
|
|
|
|
|
def prepare_switch(env: DemoEnv):
|
|
"""Switch demo with Claude Code UI."""
|
|
dev_cmd = "npm run dev -- --port {{ branch | hash_port }}"
|
|
hooks_config = f"""[post-create]
|
|
copy = "wt step copy-ignored"
|
|
dev = "{dev_cmd}"
|
|
"""
|
|
prepare_demo_repo(env, REPO_ROOT, hooks_config=hooks_config)
|
|
_write_user_config(env, [dev_cmd, "wt step copy-ignored"])
|
|
|
|
# Create .env file (gitignored) so copy-ignored has something to copy
|
|
gitignore = env.repo / ".gitignore"
|
|
gitignore.write_text(gitignore.read_text() + ".env\n")
|
|
env_file = env.repo / ".env"
|
|
env_file.write_text("DATABASE_URL=postgres://localhost/acme_dev\nAPI_KEY=dev-key-123\n")
|
|
|
|
setup_claude_code_config(
|
|
env,
|
|
[
|
|
str(env.repo),
|
|
str(env.work_base / "acme.alpha"),
|
|
str(env.work_base / "acme.api"),
|
|
str(env.work_base / "acme.dashboard"),
|
|
],
|
|
)
|
|
|
|
|
|
def prepare_devserver(env: DemoEnv):
|
|
"""Setup for wt-devserver demo - shows URL column in wt list."""
|
|
dev_cmd = "npm run dev -- --port {{ branch | hash_port }}"
|
|
hooks_config = f'''[post-create]
|
|
dev = "{dev_cmd}"
|
|
|
|
[list]
|
|
url = "http://localhost:{{{{ branch | hash_port }}}}"
|
|
'''
|
|
prepare_demo_repo(env, REPO_ROOT, hooks_config=hooks_config)
|
|
_write_user_config(env, [dev_cmd])
|
|
|
|
# Override npm mock to start nc listener so URL appears active
|
|
npm_mock = env.home / ".local" / "bin" / "npm"
|
|
npm_mock.write_text("""#!/bin/bash
|
|
if [[ "$1" == "run" && "$2" == "dev" ]]; then
|
|
PORT="$5"
|
|
echo ""
|
|
echo " VITE v5.4.2 ready in 342 ms"
|
|
echo ""
|
|
echo " ➜ Local: http://localhost:$PORT/"
|
|
# Start nc listener so wt list shows URL as active (not dimmed)
|
|
nc -l "$PORT" >/dev/null 2>&1 &
|
|
fi
|
|
""")
|
|
npm_mock.chmod(0o755)
|
|
|
|
|
|
def prepare_zellij_omnibus(env: DemoEnv):
|
|
"""Setup for wt-zellij-omnibus demo - comprehensive showcase."""
|
|
hooks_config = """[post-create]
|
|
dev = "npm run dev -- --port {{ branch | hash_port }}"
|
|
|
|
[pre-merge]
|
|
test = "cargo nextest run"
|
|
|
|
[list]
|
|
url = "http://localhost:{{ branch | hash_port }}"
|
|
"""
|
|
prepare_demo_repo(env, REPO_ROOT, hooks_config=hooks_config)
|
|
|
|
# Clean up branches that might exist from previous runs (billing, feature created during demo)
|
|
origin = env.home / "origin.git"
|
|
for branch in ["billing", "feature"]:
|
|
# Delete from origin if exists (ignore errors if not)
|
|
subprocess.run(
|
|
["git", "-C", str(origin), "branch", "-D", branch], capture_output=True
|
|
)
|
|
|
|
# Enable git colors for non-TTY environments (VHS)
|
|
git(["-C", str(env.repo), "config", "color.ui", "always"])
|
|
|
|
_write_user_config(
|
|
env,
|
|
["npm run dev -- --port {{ branch | hash_port }}", "cargo nextest run"],
|
|
)
|
|
|
|
# Zellij + Fish configs (with --create in wsl abbreviation)
|
|
setup_zellij_config(env, default_cwd=str(env.repo))
|
|
setup_fish_config(env, wsl_create=True)
|
|
|
|
# Claude Code config with pre-approved tools
|
|
worktree_branches = ["api", "auth", "billing", "feature"]
|
|
worktree_paths = [str(env.repo)] + [
|
|
str(env.work_base / f"acme.{branch}") for branch in worktree_branches
|
|
]
|
|
allowed_tools = [
|
|
"Bash",
|
|
"Read",
|
|
"Write",
|
|
"Edit",
|
|
"Glob",
|
|
"Grep",
|
|
"Task",
|
|
"TodoWrite",
|
|
]
|
|
setup_claude_code_config(env, worktree_paths, allowed_tools)
|
|
|
|
# Pre-create worktrees (api and auth only - billing created during demo)
|
|
demo_env = {
|
|
**os.environ,
|
|
"HOME": str(env.home),
|
|
"PATH": f"{REPO_ROOT}/target/debug:{env.home}/.local/bin:{os.environ.get('PATH', '')}",
|
|
}
|
|
|
|
def run_wt(args, cwd):
|
|
result = subprocess.run(
|
|
["wt"] + args, cwd=str(cwd), env=demo_env, capture_output=True, text=True
|
|
)
|
|
if result.returncode != 0:
|
|
raise RuntimeError(f"wt {' '.join(args)} failed: {result.stderr}")
|
|
|
|
run_wt(["switch", "--create", "api"], env.repo)
|
|
git(["-C", str(env.work_base / "acme.api"), "push", "-u", "origin", "api", "-q"])
|
|
|
|
run_wt(["switch", "--create", "auth"], env.repo)
|
|
git(["-C", str(env.work_base / "acme.auth"), "push", "-u", "origin", "auth", "-q"])
|
|
|
|
# Return to main worktree for the demo to start
|
|
run_wt(["switch", "main"], env.work_base / "acme.api")
|
|
|
|
|
|
# =============================================================================
|
|
# Demo Registrations
|
|
# =============================================================================
|
|
|
|
# Format: (tape_file, output_name, setup_func)
|
|
# All demos in a mode use that mode's default size.
|
|
# Note: setup_func must be a named function (not lambda) for multiprocessing.
|
|
|
|
DOCS_DEMOS = [
|
|
("wt-core.tape", "wt-core", prepare_with_hooks),
|
|
("wt-switch.tape", "wt-switch", prepare_switch),
|
|
("wt-list.tape", "wt-list", prepare_with_hooks),
|
|
("wt-commit.tape", "wt-commit", prepare_merge_demo),
|
|
("wt-statusline.tape", "wt-statusline", prepare_switch),
|
|
("wt-merge.tape", "wt-merge", prepare_merge_demo),
|
|
("wt-switch-picker.tape", "wt-switch-picker", prepare_basic),
|
|
("wt-zellij-omnibus.tape", "wt-zellij-omnibus", prepare_zellij_omnibus),
|
|
]
|
|
|
|
SOCIAL_DEMOS = [
|
|
("wt-switch.tape", "wt-switch", prepare_switch),
|
|
("wt-statusline.tape", "wt-statusline", prepare_switch),
|
|
("wt-list.tape", "wt-list", prepare_with_hooks),
|
|
("wt-list-remove.tape", "wt-list-remove", prepare_with_hooks),
|
|
("wt-hooks.tape", "wt-hooks", partial(prepare_with_hooks, include_deps=True)),
|
|
("wt-devserver.tape", "wt-devserver", prepare_devserver),
|
|
("wt-commit.tape", "wt-commit", prepare_merge_demo),
|
|
(
|
|
"wt-merge.tape",
|
|
"wt-merge",
|
|
partial(prepare_merge_demo, commit_generation=False, staged_changes=False),
|
|
),
|
|
("wt-switch-picker.tape", "wt-switch-picker", prepare_basic),
|
|
("wt-core.tape", "wt-core", prepare_with_hooks),
|
|
("wt-zellij-omnibus.tape", "wt-zellij-omnibus", prepare_zellij_omnibus),
|
|
]
|
|
|
|
TARGETS = {
|
|
"docs": {
|
|
"demos": DOCS_DEMOS,
|
|
"size": SIZE_DOCS,
|
|
"themes": ["light", "dark"],
|
|
},
|
|
"social": {
|
|
"demos": SOCIAL_DEMOS,
|
|
"size": SIZE_SOCIAL,
|
|
"themes": ["light"],
|
|
},
|
|
}
|
|
|
|
|
|
# =============================================================================
|
|
# Parallel Recording
|
|
# =============================================================================
|
|
|
|
|
|
def record_demo(
|
|
tape_file: str,
|
|
output_name: str,
|
|
setup_func,
|
|
mode_out_dir: Path,
|
|
themes: list[str],
|
|
demo_size: DemoSize,
|
|
vhs_binary: str,
|
|
text_mode: bool = False,
|
|
snapshot_mode: bool = False,
|
|
) -> float:
|
|
"""Record a single demo. Returns elapsed seconds."""
|
|
t0 = time.monotonic()
|
|
tape_path = TAPES_DIR / tape_file
|
|
if not tape_path.exists():
|
|
raise FileNotFoundError(f"{tape_file} not found")
|
|
|
|
# Create fresh environment in temp directory
|
|
demo_env_dir = Path(tempfile.mkdtemp(prefix="wt-demo-"))
|
|
setup_demo_output(demo_env_dir)
|
|
env = DemoEnv(name=output_name, out_dir=demo_env_dir, repo_name="acme")
|
|
setup_func(env)
|
|
|
|
replacements = build_tape_replacements(env, REPO_ROOT)
|
|
|
|
if snapshot_mode:
|
|
output_snap = SNAPSHOTS_DIR / f"{output_name}.snap"
|
|
record_snapshot(env, tape_path, output_snap, REPO_ROOT)
|
|
elapsed = time.monotonic() - t0
|
|
print(f"✓ {output_name}: Snapshot saved ({elapsed:.0f}s)")
|
|
return elapsed
|
|
|
|
if text_mode:
|
|
output_txt = mode_out_dir / f"{output_name}.txt"
|
|
record_text(env, tape_path, output_txt, replacements, REPO_ROOT)
|
|
elapsed = time.monotonic() - t0
|
|
print(f"✓ {output_name}: Text saved ({elapsed:.0f}s)")
|
|
return elapsed
|
|
|
|
output_gifs = {}
|
|
for theme in themes:
|
|
theme_dir = mode_out_dir / theme
|
|
theme_dir.mkdir(parents=True, exist_ok=True)
|
|
output_gifs[theme] = theme_dir / f"{output_name}.gif"
|
|
|
|
record_all_themes(
|
|
env,
|
|
tape_path,
|
|
output_gifs,
|
|
REPO_ROOT,
|
|
vhs_binary=vhs_binary,
|
|
size=demo_size,
|
|
)
|
|
|
|
elapsed = time.monotonic() - t0
|
|
|
|
# Validate TUI demos with OCR after recording
|
|
if output_name in TUI_CHECKPOINTS:
|
|
gif_path = output_gifs.get("light", list(output_gifs.values())[0])
|
|
success, output = validate_tui_demo_verbose(output_name, gif_path)
|
|
if success:
|
|
print(f"✓ {output_name}: GIFs saved + validation passed ({elapsed:.0f}s)")
|
|
else:
|
|
print(f"✓ {output_name}: GIFs saved ({elapsed:.0f}s)")
|
|
print(f"✗ {output_name}: Validation failed")
|
|
for line in output.split("\n"):
|
|
if line.startswith(" "):
|
|
print(line)
|
|
raise RuntimeError(f"TUI validation failed for {output_name}")
|
|
else:
|
|
print(f"✓ {output_name}: GIFs saved: {', '.join(themes)} ({elapsed:.0f}s)")
|
|
|
|
return elapsed
|
|
|
|
|
|
# =============================================================================
|
|
# Main
|
|
# =============================================================================
|
|
|
|
|
|
def spawn_debug_shell(env: DemoEnv, starship_config: Path):
|
|
"""Spawn an interactive fish shell with the demo environment set up."""
|
|
shell_env = os.environ.copy()
|
|
shell_env.update(
|
|
{
|
|
"HOME": str(env.home),
|
|
"XDG_CONFIG_HOME": str(env.home / ".config"),
|
|
"STARSHIP_CONFIG": str(starship_config),
|
|
"PATH": f"{REPO_ROOT / 'target' / 'debug'}:{env.home / '.local' / 'bin'}:{os.environ.get('PATH', '')}",
|
|
# Keep real rustup/cargo for any rust tools
|
|
"RUSTUP_HOME": str(Path.home() / ".rustup"),
|
|
"CARGO_HOME": str(Path.home() / ".cargo"),
|
|
}
|
|
)
|
|
|
|
print("\nSpawning fish shell in demo environment...")
|
|
print(f" HOME={env.home}")
|
|
print(f" Demo repo: {env.repo}\n")
|
|
|
|
init_cmd = "starship init fish | source; wt config shell init fish | source"
|
|
subprocess.run(["fish", "-C", init_cmd], env=shell_env, cwd=str(env.repo))
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Build demo GIFs for worktrunk",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
epilog="""
|
|
Examples:
|
|
./build docs social Build all demos for both targets
|
|
./build docs Build all doc site demos (light + dark)
|
|
./build social Build all social media demos (light only)
|
|
./build docs --only wt-merge Build specific demo for docs
|
|
./build docs --text Build text output instead of GIFs
|
|
./build social --only wt-zellij-omnibus --shell Debug shell for demo
|
|
""",
|
|
)
|
|
parser.add_argument(
|
|
"target",
|
|
nargs="+",
|
|
choices=["docs", "social"],
|
|
help="Target(s) to build demos for",
|
|
)
|
|
parser.add_argument("--only", help="Only record specific demo (e.g., wt-switch)")
|
|
parser.add_argument(
|
|
"--shell",
|
|
action="store_true",
|
|
help="Build demo and spawn interactive shell for debugging (requires --only)",
|
|
)
|
|
parser.add_argument(
|
|
"--text", action="store_true", help="Record text output instead of GIFs"
|
|
)
|
|
parser.add_argument(
|
|
"--snapshot",
|
|
action="store_true",
|
|
help="Record command output snapshots for regression testing (non-TUI demos only)",
|
|
)
|
|
parser.add_argument(
|
|
"--sequential",
|
|
action="store_true",
|
|
help="Record demos sequentially instead of in parallel",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.shell and not args.only:
|
|
print("--shell requires --only to specify which demo to debug")
|
|
return
|
|
|
|
if args.shell and args.text:
|
|
print("--shell and --text are mutually exclusive")
|
|
return
|
|
|
|
if args.shell and args.snapshot:
|
|
print("--shell and --snapshot are mutually exclusive")
|
|
return
|
|
|
|
if args.text and args.snapshot:
|
|
print("--text and --snapshot are mutually exclusive")
|
|
return
|
|
|
|
if args.only and len(args.target) > 1:
|
|
print("--only cannot be used with multiple targets")
|
|
return
|
|
|
|
# Check dependencies (VHS is built from source, not required in PATH)
|
|
deps = ["wt", "starship", "zellij"]
|
|
check_dependencies(deps)
|
|
if not args.text and not args.snapshot:
|
|
check_dependencies(["delta"])
|
|
check_ffmpeg_libass()
|
|
|
|
# Kill stale Zellij processes from previous demo runs (they interfere
|
|
# with new recordings by holding locks or sockets)
|
|
subprocess.run(["pkill", "-f", "zellij.*wt-demos"], capture_output=True)
|
|
|
|
# Ensure VHS is built (requires Go)
|
|
vhs_binary = str(ensure_vhs_binary())
|
|
|
|
# Build each target sequentially (parallel targets cause Zellij conflicts)
|
|
t0 = time.monotonic()
|
|
all_failed = []
|
|
for target in dict.fromkeys(args.target): # deduplicate, preserve order
|
|
failed = build_target(
|
|
target, args, vhs_binary,
|
|
)
|
|
all_failed.extend(f"{target}/{name}" for name in failed)
|
|
|
|
# Overall summary for multi-target builds
|
|
if len(args.target) > 1:
|
|
elapsed = time.monotonic() - t0
|
|
mins, secs = divmod(int(elapsed), 60)
|
|
print(f"\n{'=' * 60}")
|
|
print(f"Total: {mins}m{secs:02d}s")
|
|
|
|
if all_failed:
|
|
if len(args.target) <= 1:
|
|
print(f"\n{'=' * 60}")
|
|
print(f"Failed: {', '.join(all_failed)}")
|
|
sys.exit(1)
|
|
|
|
|
|
def build_target(target: str, args, vhs_binary: str) -> list[str]:
|
|
"""Build all demos for a single target. Returns list of failed demo names."""
|
|
t0 = time.monotonic()
|
|
mode_out_dir = OUT_DIR / target
|
|
mode_out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
if target == "docs":
|
|
shutil.copy(FIXTURES_DIR / "starship.toml", mode_out_dir / "starship.toml")
|
|
|
|
target_config = TARGETS[target]
|
|
demos = target_config["demos"]
|
|
demo_size = target_config["size"]
|
|
themes = target_config["themes"]
|
|
|
|
# Filter demos if --only specified
|
|
if args.only:
|
|
demos = [(t, n, s) for t, n, s in demos if n == args.only]
|
|
if not demos:
|
|
all_names = [n for _, n, _ in target_config["demos"]]
|
|
print(f"Unknown demo: {args.only}")
|
|
print(f"Available for {target}: {', '.join(all_names)}")
|
|
return []
|
|
|
|
# In snapshot mode, skip TUI demos (they validate via checkpoints during GIF recording)
|
|
if args.snapshot:
|
|
original_count = len(demos)
|
|
demos = [(t, n, s) for t, n, s in demos if n not in TUI_DEMOS]
|
|
skipped = original_count - len(demos)
|
|
if skipped:
|
|
print(f"Skipping {skipped} TUI demo(s) (validate during GIF recording instead)")
|
|
if not demos:
|
|
print("No snapshotable demos found")
|
|
return []
|
|
|
|
# Handle --shell mode (interactive, must be sequential)
|
|
if args.shell:
|
|
tape_file, output_name, setup_func = demos[0]
|
|
demo_env_dir = Path(tempfile.mkdtemp(prefix="wt-demo-"))
|
|
setup_demo_output(demo_env_dir)
|
|
env = DemoEnv(name=output_name, out_dir=demo_env_dir, repo_name="acme")
|
|
setup_func(env)
|
|
print(f"Demo repo: {env.repo}")
|
|
spawn_debug_shell(env, FIXTURES_DIR / "starship.toml")
|
|
return []
|
|
|
|
# TUI demos run sequentially (they use Zellij which conflicts when parallel)
|
|
tui_demos = [(t, n, s) for t, n, s in demos if n in TUI_DEMOS]
|
|
non_tui_demos = [(t, n, s) for t, n, s in demos if n not in TUI_DEMOS]
|
|
|
|
failed = []
|
|
succeeded = []
|
|
|
|
def _record_one(tape_file, output_name, setup_func):
|
|
try:
|
|
elapsed = record_demo(
|
|
tape_file,
|
|
output_name,
|
|
setup_func,
|
|
mode_out_dir,
|
|
themes,
|
|
demo_size,
|
|
vhs_binary,
|
|
args.text,
|
|
args.snapshot,
|
|
)
|
|
succeeded.append((output_name, elapsed))
|
|
except Exception as e:
|
|
print(f"✗ {output_name}: {e}")
|
|
failed.append(output_name)
|
|
|
|
# Record non-TUI demos (parallel unless --sequential)
|
|
if non_tui_demos:
|
|
if args.sequential or len(non_tui_demos) == 1:
|
|
print(f"\n[{target}] Recording {len(non_tui_demos)} demo(s) sequentially...")
|
|
for tape_file, output_name, setup_func in non_tui_demos:
|
|
_record_one(tape_file, output_name, setup_func)
|
|
else:
|
|
print(f"\n[{target}] Recording {len(non_tui_demos)} demo(s) in parallel...")
|
|
max_workers = min(len(non_tui_demos), os.cpu_count() or 4)
|
|
|
|
with ProcessPoolExecutor(max_workers=max_workers) as executor:
|
|
futures = {
|
|
executor.submit(
|
|
record_demo,
|
|
tape_file,
|
|
output_name,
|
|
setup_func,
|
|
mode_out_dir,
|
|
themes,
|
|
demo_size,
|
|
vhs_binary,
|
|
args.text,
|
|
args.snapshot,
|
|
): output_name
|
|
for tape_file, output_name, setup_func in non_tui_demos
|
|
}
|
|
|
|
for future in as_completed(futures):
|
|
output_name = futures[future]
|
|
try:
|
|
elapsed = future.result()
|
|
succeeded.append((output_name, elapsed))
|
|
except Exception as e:
|
|
print(f"✗ {output_name}: {e}")
|
|
failed.append(output_name)
|
|
|
|
# Record TUI demos sequentially (Zellij conflicts when parallel)
|
|
if tui_demos:
|
|
print(f"\n[{target}] Recording {len(tui_demos)} TUI demo(s) sequentially...")
|
|
for tape_file, output_name, setup_func in tui_demos:
|
|
_record_one(tape_file, output_name, setup_func)
|
|
|
|
# Summary
|
|
elapsed_total = time.monotonic() - t0
|
|
mins, secs = divmod(int(elapsed_total), 60)
|
|
passed_str = ", ".join(f"{n} ({e:.0f}s)" for n, e in succeeded)
|
|
print(f"\n[{target}] {len(succeeded)} passed, {len(failed)} failed ({mins}m{secs:02d}s)")
|
|
if succeeded:
|
|
print(f" Passed: {passed_str}")
|
|
if failed:
|
|
print(f" Failed: {', '.join(failed)}")
|
|
|
|
return failed
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|