feat: implement wt worktree manager

Amolith created

A CLI tool for managing git worktrees with a bare repository structure.

Commands:
- init: Convert existing repo to bare + worktree structure
- n/c: Clone repo into bare + worktree structure
- a: Add new worktree for branch
- r: Remove worktree (optionally delete branch)
- l: List worktrees
- f: Fetch and prune all remotes

Features:
- Global config for path styles, remotes, and defaults
- Project-level hooks (copy, symlink, run) for new worktrees
- Hook permission system with per-project approval
- Support for nested and flat branch path styles
- Multiple remote management for own/contributing workflows
- Dry-run mode for init command

Amp-Thread: https://ampcode.com/threads/T-019bcdca-a9c9-73ee-ac7c-8fbb2d10ef02
Amp-Thread: https://ampcode.com/threads/T-019bcde6-c4c6-71fa-a593-9913c2072f27
Amp-Thread: https://ampcode.com/threads/T-019bcdec-7940-723b-9f20-cd5cf8eab286
Amp-Thread: https://ampcode.com/threads/T-019bcde2-434f-722c-8b1d-0d4788f16c22
Amp-Thread: https://ampcode.com/threads/T-019bcdf0-fddd-71e8-b732-250b6adaeaba
Amp-Thread: https://ampcode.com/threads/T-019bcdf9-c281-76cc-be06-8762039c75f9
Amp-Thread: https://ampcode.com/threads/T-019bce01-6ba3-74fc-9950-0d54b95adb7a
Amp-Thread: https://ampcode.com/threads/T-019bce05-b956-7599-a0be-ebdf73acdcb6
Amp-Thread: https://ampcode.com/threads/T-019bce08-580e-73eb-a55c-63253b7ab599
Amp-Thread: https://ampcode.com/threads/T-019bce1b-11b4-718c-91b8-cd36f34f3aa4
Amp-Thread: https://ampcode.com/threads/T-019bce1f-dbff-7369-8f2b-59d7f6a09d0d
Amp-Thread: https://ampcode.com/threads/T-019bce26-10b6-747b-a364-0ab2fb74c1f6
Amp-Thread: https://ampcode.com/threads/T-019bce2b-b03a-74cd-9161-d0775b3df8a3
Amp-Thread: https://ampcode.com/threads/T-019bce2d-1c3c-748d-a19a-4378098c2ef0
Amp-Thread: https://ampcode.com/threads/T-019bce3f-cf1a-74a2-8d59-01966ebdf4c7
Amp-Thread: https://ampcode.com/threads/T-019bcdd2-12ee-70af-b4ab-5cbb7fc9a4d0
Amp-Thread: https://ampcode.com/threads/T-019bce4e-2986-775c-aff9-ad4a71f85ff0
Amp-Thread: https://ampcode.com/threads/T-019bce51-c314-75c5-82c7-aa75414d09cb
Amp-Thread: https://ampcode.com/threads/T-019bce65-5a4b-747a-a89c-f41b4b6bf06a
Amp-Thread: https://ampcode.com/threads/T-019bce69-0848-702c-8379-12dc822ad659
Amp-Thread: https://ampcode.com/threads/T-019bce79-2cdc-76ed-8608-81587e612f54
Amp-Thread: https://ampcode.com/threads/T-019bce80-071c-71ac-b7f9-39d5507b0352
Amp-Thread: https://ampcode.com/threads/T-019bce8a-cefc-77da-9598-5a7e8a99ed9a
Assisted-by: Claude Opus 4.5 via Amp

Change summary

.busted                    |    6 
.luacheckrc                |   37 
.luarc.json                |   57 +
AGENTS.md                  |  158 +++
Makefile                   |   20 
README.md                  |  154 +++
lux.lock                   |  238 +++++
lux.toml                   |   19 
spec/cmd_init_spec.lua     |  347 +++++++
spec/cmd_remove_spec.lua   |  271 +++++
spec/compat_spec.lua       |   58 +
spec/config_spec.lua       |  251 +++++
spec/git_parsing_spec.lua  |  377 ++++++++
spec/hooks_spec.lua        |  276 ++++++
spec/path_spec.lua         |  229 +++++
spec/project_root_spec.lua |  255 +++++
spec/url_parsing_spec.lua  |  129 ++
src/main.lua               | 1820 ++++++++++++++++++++++++++++++++++++++++
stylua.toml                |    7 
19 files changed, 4,709 insertions(+)

Detailed changes

.busted 🔗

@@ -0,0 +1,6 @@
+return {
+	default = {
+		ROOT = { "spec/" },
+		pattern = "_spec%.lua$",
+	},
+}

.luacheckrc 🔗

@@ -0,0 +1,37 @@
+-- luacheck configuration for strict linting
+std = "lua54"
+
+-- Enable all warnings
+max_line_length = 120
+max_code_line_length = 120
+max_string_line_length = false
+max_comment_line_length = false
+
+-- Strict unused variable checking
+unused = true
+unused_args = true
+unused_secondaries = true
+
+-- Ignore underscore-prefixed unused variables/functions (intentional stubs)
+ignore = {"21[123]/_.*"}
+
+-- Redefinition warnings
+redefined = true
+allow_defined = false
+allow_defined_top = false
+
+-- Global variable strictness
+globals = {}
+read_globals = {}
+new_globals = {}
+new_read_globals = {}
+
+-- Additional strict checks
+enable = {
+    "631", -- max_line_length
+    "611", -- whitespace on line consisting only of whitespace
+    "612", -- trailing whitespace in a string
+    "614", -- trailing whitespace in a comment
+    "621", -- inconsistent indentation
+    "631", -- line too long
+}

.luarc.json 🔗

@@ -0,0 +1,57 @@
+{
+  "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
+  "diagnostics": {
+    "enable": true,
+    "globals": [],
+    "severity": {
+      "undefined-global": "error",
+      "lowercase-global": "warning",
+      "unused-local": "warning",
+      "unused-vararg": "warning",
+      "redefined-local": "warning",
+      "duplicate-set-field": "warning",
+      "missing-return": "warning",
+      "missing-return-value": "warning",
+      "redundant-return-value": "warning",
+      "need-check-nil": "warning",
+      "cast-local-type": "warning",
+      "assign-type-mismatch": "warning",
+      "param-type-mismatch": "warning",
+      "return-type-mismatch": "warning",
+      "undefined-field": "warning"
+    },
+    "neededFileStatus": {
+      "codestyle-check": "Any"
+    }
+  },
+  "hint": {
+    "enable": true,
+    "paramName": "All",
+    "paramType": true,
+    "setType": true
+  },
+  "runtime": {
+    "version": "Lua 5.4"
+  },
+  "type": {
+    "castNumberToInteger": false,
+    "weakNilCheck": false,
+    "weakUnionCheck": false
+  },
+  "workspace": {
+    "checkThirdParty": true,
+    "library": [
+      ".lux/5.4/test_dependencies/5.4/01a3c364614bddff7370223a5a9c4580f8e62d144384148444c518ec5367a59b-mediator_lua@1.1.2-0/src",
+      ".lux/5.4/test_dependencies/5.4/14a12ec6080e7e9ec24d84932227f38833732b5d73a32673f63d448c204dc550-dkjson@2.8-2/src",
+      ".lux/5.4/test_dependencies/5.4/287e827f4a088d41bba04af5f61a13614346c16fe8150eb7c4246e67d6fd163e-lua-term@0.8-1/src",
+      ".lux/5.4/test_dependencies/5.4/2bd1c8e4fea66a88aeb65c1c8a3d0efdf188d681d2d47f163b976a771545aac9-penlight@1.15.0-1/src",
+      ".lux/5.4/test_dependencies/5.4/455cd98d50c6191a9685cffcda4ce783efbb957934625e134c39f43bd5df6818-luassert@1.9.0-1/src",
+      ".lux/5.4/test_dependencies/5.4/47b12edcdc032232157ace97bddf34bddd17f6f458095885e62bbd602ad9e9ec-luasystem@0.6.3-1/src",
+      ".lux/5.4/test_dependencies/5.4/4e9592a499c9ced4f8ce366db9db7d9c0dd1424ea8d4c8c16c1550ea3a61a696-say@1.4.1-3/src",
+      ".lux/5.4/test_dependencies/5.4/5abed54457baf9037ff989a1ec78172ee7483d87e794bc9175dbed61f08be72c-lua_cliargs@3.0.2-1/src",
+      ".lux/5.4/test_dependencies/5.4/611b19eca413e8557258c177563402d31094cde79f82d340f5648d2ac8f3b8a1-luafilesystem@1.9.0-1/src",
+      ".lux/5.4/test_dependencies/5.4/6ce29c2c535c40246c386c056f24689344cddb56ec397473931431e6b67694d2-say@1.4.1-3/src",
+      ".lux/5.4/test_dependencies/5.4/aa700d6a934caa749d578927c97e22211ffabf97234705d63e95dc6403f6aafe-busted@2.3.0-1/src"
+    ]
+  }
+}

AGENTS.md 🔗

@@ -0,0 +1,158 @@
+# wt - Lua 5.4 Project
+
+## Commands
+- `make all` — format, lint, type-check, and test
+- `make fmt` — format code with stylua
+- `make lint` — lint with luacheck
+- `make check` — type-check with emmylua
+- `make test` — run all tests with busted
+- `lx test spec/foo_spec.lua` — run a single test file
+- `lx run` — run the application (src/main.lua)
+- `make ci` — CI mode (format check + lint + check + test)
+
+## Structure
+- `src/` — application source code; entry point is `src/main.lua`
+- `spec/` — test files; must end in `_spec.lua` (busted framework)
+- `lux.toml` — project manifest (dependencies, metadata)
+
+## Lux Handles These (don't suggest manually)
+- Dependencies: use `lx add <pkg>`, not `luarocks install`
+- Tools (busted, luacheck, stylua): auto-installed on first use
+- Module paths: Lux loader resolves requires; don't manipulate `package.path`
+
+## Code Style
+- Lua 5.4 only; use LuaCATS annotations (`---@param`, `---@return`, etc.) for type safety
+- Tabs for indentation, 120 char line limit (see stylua.toml)
+- Extract reusable logic into `src/*.lua` modules; use `require()` to import
+- Handle errors explicitly; avoid silent failures
+
+## Architecture
+
+### Core Pattern: Bare Repo + Worktrees
+wt manages git repositories using a **bare repository structure**:
+- `.bare/` - the actual git repository (moved from `.git/`)
+- `.git` - a file pointing to `.bare/` (makes git commands work)
+- Worktrees checked out as sibling directories alongside `.bare/`
+
+This pattern allows multiple worktrees while keeping the repository clean.
+
+### Command Structure
+Each command (`c`, `n`, `a`, `r`, `l`, `f`, `init`) has:
+1. A `cmd_<name>(args)` function parsing arguments
+2. Validation and error handling with `die()` for user errors
+3. System command execution via `run_cmd()` or `run_cmd_silent()`
+4. Consistent exit codes: `EXIT_SUCCESS=0`, `EXIT_USER_ERROR=1`, `EXIT_SYSTEM_ERROR=2`
+
+### Exit Codes and Error Handling
+- Use `die(msg, code)` for errors - it prints to stderr and exits
+- `EXIT_USER_ERROR` (1) - user mistakes (bad args, existing dirs)
+- `EXIT_SYSTEM_ERROR` (2) - system failures (git failures, IO errors)
+- `EXIT_SUCCESS` (0) - normal completion
+
+### Configuration System
+
+#### Global Config (`~/.config/wt/config.lua`)
+Returns a Lua table with optional fields:
+- `branch_path_style` - `"nested"` (default) or `"flat"`
+- `flat_separator` - string separator for flat style (default: `_`)
+- `remotes` - table mapping remote names to URL templates with `${project}` placeholder
+- `default_remotes` - `"prompt"`, table of names, or nil (prompts if remotes exist)
+
+Example:
+```lua
+return {
+    branch_path_style = "flat",
+    flat_separator = "_",
+    remotes = {
+        github = "git@github.com:myuser/${project}.git",
+        gitlab = "git@gitlab.com:myuser/${project}.git"
+    },
+    default_remotes = "prompt" -- or {"github", "gitlab"}
+}
+```
+
+#### Project Config (`.wt.lua` in project root)
+Returns a Lua table with optional `hooks` field:
+- `hooks.copy` - array of file patterns to copy from source to new worktree
+- `hooks.symlink` - array of file patterns to symlink
+- `hooks.run` - array of shell commands to run in new worktree
+
+Hooks only run when `wt a` is executed from inside an existing worktree.
+
+Example:
+```lua
+return {
+    hooks = {
+        copy = {"Makefile", "*.mk"},
+        symlink = {".env", "config.toml"},
+        run = {"make setup", "npm install"}
+    }
+}
+```
+
+#### Hook Permissions
+Stored at `~/.local/share/wt/hook-dirs.lua` as a table mapping project root paths to boolean allowed status. Users are prompted once per project with `gum confirm`.
+
+### Remote Management
+- **URL Templates**: Use `${project}` placeholder that gets substituted
+- **Own Projects** (`--own` flag): First remote becomes `origin`, additional remotes added
+- **Contributing** (default): `origin` renamed to `upstream`, user's remotes added
+- **Multiple Remotes**: Support for pushing to multiple remotes from bare repo
+
+### Path Styles
+- **Nested**: Branch names keep slashes (`feature/foo` → `project/feature/foo`)
+- **Flat**: Slashes become separators (`feature/foo` → `project_feature_foo`)
+
+### Key Utility Functions
+
+All in `src/main.lua` (consider extracting to modules if codebase grows):
+
+- `find_project_root()` - Walk up from cwd to find `.bare/` or `.git` file pointing to `.bare`
+- `run_cmd(cmd)` - Execute command, return output and exit code
+- `run_cmd_silent(cmd)` - Execute command silently, return boolean success
+- `branch_to_path(root, branch, style, separator)` - Convert branch name to worktree path
+- `load_global_config()` - Load and parse `~/.config/wt/config.lua`
+- `load_project_config(root)` - Load and parse `<root>/.wt.lua`
+- `detect_source_worktree(root)` - Check if cwd is inside a worktree (not project root)
+- `check_hook_permission(root, hooks)` - Prompt user for hook permission if needed
+- `run_hooks(source, target, hooks, root)` - Execute copy/symlink/run hooks
+- `extract_project_name(url)` - Parse git URL to extract project name
+- `branch_exists_local(git_dir, branch)` - Check if branch exists locally
+- `find_branch_remotes(git_dir, branch)` - Find remotes containing branch
+
+### Testing
+No tests exist yet. When adding tests:
+- Create `spec/` directory if needed
+- Test files must end in `_spec.lua`
+- Use busted framework
+- Mock git commands and filesystem operations
+- Test both success and error paths
+
+### Git Command Patterns
+- Always use `GIT_DIR=<path> git <command>` for bare repo operations
+- Use `git -C <path> <command>` for worktree operations
+- Check exit codes from `run_cmd()` before assuming success
+- Quote paths properly when building shell commands
+
+### External Dependencies
+- `gum` - for interactive prompts (choose, confirm, table)
+- Standard POSIX utilities: `mkdir`, `cp`, `ln`, `rm`, `mv`
+- git (obviously)
+
+### Non-Obvious Behaviors
+
+1. **Source Worktree Detection**: `wt a` must be run from inside a worktree (not project root) for hooks to trigger. From project root, hooks are skipped with a warning.
+
+2. **Branch Existence Checking**: When adding a worktree without `-b`, checks both local branches and all remotes. Fails if branch exists on multiple remotes.
+
+3. **Bare HEAD Management**: During `wt init`, bare repo's HEAD is moved to a placeholder branch so the default branch can be checked out in a worktree.
+
+4. **Hook Permissions**: Permissions are stored by absolute project root path. If you move a project, you'll be prompted again.
+
+5. **Default Remote Strategy**: The `--own` flag significantly changes remote naming (origin vs upstream). Default behavior is optimized for contributing to others' projects.
+
+6. **Worktree Path Conflicts**: Checks for `.git` file in target path before creating worktree to prevent overwrites.
+
+7. **Force Flag**: `-f` bypasses uncommitted changes check but doesn't bypass branch deletion safety checks (bare HEAD, other worktrees).
+
+8. **Error Recovery**: Some operations attempt recovery (e.g., `wt init` tries to move `.bare` back to `.git` if .git file creation fails).

Makefile 🔗

@@ -0,0 +1,20 @@
+.PHONY: all fmt fmt-check lint check test ci
+
+all: fmt lint check test
+
+fmt:
+	lx fmt
+
+fmt-check:
+	lx fmt --check
+
+lint:
+	lx lint
+
+check:
+	lx check
+
+test:
+	lx test
+
+ci: fmt-check lint check test

README.md 🔗

@@ -0,0 +1,154 @@
+# wt
+
+[![REUSE status](https://api.reuse.software/badge/git.secluded.site/wt)](https://api.reuse.software/info/git.secluded.site/wt)
+[![Donate using Liberapay](https://img.shields.io/badge/Liberapay-F6C915?logo=liberapay&logoColor=black)](https://liberapay.com/Amolith/)
+
+Git worktree manager using a bare repository pattern. Clone once, check
+out many.
+
+## tl;dr
+
+```
+wt c git@git.host:user/project.git     # Clone into bare structure
+cd project/{canonical-branch}
+wt a feature/auth -b                   # Create worktree + branch
+cd ../feaTABTAB                        # Use cd + shell completions
+                                       #   to navigate branches
+wt l                                   # List worktrees
+wt r feature/auth -b                   # Remove worktree + branch
+```
+
+## Features
+
+- **Templated remote automation** — configure URL templates, select remotes on
+  clone, supports both contributing and own-project workflows
+- **Hooks** — copy files, create symlinks, run commands when adding worktrees
+- **Optional path styles** — nested (`feature/auth`) or flat (`feature_auth`)
+  worktree paths, schemes like `847-do-a-thing` work just fine too
+- **Bare repo structure** where `.bare/` holds the repository and worktrees live
+  as sibling directories
+- **Convert existing repos** — `wt init` migrates a normal clone to bare
+  structure
+
+## Installation
+
+Requires Lua 5.2 or later.
+
+```
+curl -o ~/.local/bin/wt https://git.secluded.site/wt/blob/main/src/main.lua?raw=1
+chmod +x ~/.local/bin/wt
+```
+
+## Usage
+
+### Commands
+
+| Command | Description |
+| --- | --- |
+| `wt c <url> [--remote name]... [--own]` | Clone into bare worktree structure |
+| `wt n <name> [--remote name]...` | Initialize fresh project |
+| `wt a <branch> [-b [<start-point>]]` | Add worktree, optionally create branch |
+| `wt r <branch> [-b] [-f]` | Remove worktree, optionally delete branch |
+| `wt l` | List worktrees with status |
+| `wt f` | Fetch all remotes |
+| `wt init [--dry-run] [-y]` | Convert existing repo to bare structure |
+
+### Clone workflows
+
+**Contributing to others' projects:**
+
+```
+wt c https://github.com/org/repo.git
+# origin → upstream, your remotes added from config
+```
+
+**Your own projects:**
+
+```
+wt c git@github.com:you/repo.git --own
+# First configured remote becomes origin
+```
+
+### Adding worktrees
+
+From inside an existing worktree, hooks from `.wt.lua` run automatically:
+
+```
+cd project/main
+wt a feature/new-thing -b              # New branch from current HEAD
+wt a bugfix/issue-123 -b origin/main   # New branch from specific start point
+wt a existing-branch                   # Checkout existing branch
+```
+
+## Configuration
+
+### Global config (`~/.config/wt/config.lua`)
+
+```lua
+return {
+    branch_path_style = "nested",  -- or "flat"
+    flat_separator = "_",          -- separator for flat style
+
+    remotes = {
+        github = "git@github.com:myuser/${project}.git",
+        gitlab = "git@gitlab.com:myuser/${project}.git",
+    },
+
+    default_remotes = "prompt",    -- or {"github", "gitlab"}
+}
+```
+
+### Project config (`.wt.lua` in project root)
+
+```lua
+return {
+    hooks = {
+        copy = {".env.example", "Makefile.local"},
+        symlink = {".env", "node_modules"},
+        run = {"make setup", "npm install"},
+    }
+}
+```
+
+Hooks only run when `wt a` is executed from inside an existing worktree.
+You'll be prompted once per project to allow hooks; permissions are stored in
+`~/.local/share/wt/hook-dirs.lua`.
+
+## Dependencies
+
+- [gum](https://github.com/charmbracelet/gum) — interactive prompts
+
+## Contributions
+
+Patch requests are in [amolith/wt](https://pr.pico.sh/r/amolith/wt) on
+[pr.pico.sh](https://pr.pico.sh). You don't need a new account to contribute,
+you don't need to fork this repo, you don't need to fiddle with `git
+send-email`, you don't need to faff with your email client to get `git
+request-pull` working...
+
+You just need:
+
+- Git
+- SSH
+- An SSH key
+
+```
+# Clone this repo, make your changes, and commit them
+# Create a new patch request with
+git format-patch origin/main --stdout | ssh pr.pico.sh pr create amolith/wt
+# After potential feedback, submit a revision to an existing patch request with
+git format-patch origin/main --stdout | ssh pr.pico.sh pr add {prID}
+# List patch requests
+ssh pr.pico.sh pr ls amolith/wt
+```
+
+See "How do Patch Requests work?" on [pr.pico.sh](https://pr.pico.sh)'s home
+page for a more complete example workflow.
+
+---
+
+Some other tools if this one interested you
+
+- [agent-skills](https://git.secluded.site/agent-skills) - collection of Agent Skills for extending LLM capabilities
+- [garble](https://git.secluded.site/garble) - transform stdin with an LLM (fix typos, translate, reformat)
+- [formatted-commit](https://git.secluded.site/formatted-commit) - CLI that turns LLM input into well-formatted Conventional Commits

lux.lock 🔗

@@ -0,0 +1,238 @@
+{
+  "version": "1.0.0",
+  "test_dependencies": {
+    "rocks": {
+      "01a3c364614bddff7370223a5a9c4580f8e62d144384148444c518ec5367a59b": {
+        "name": "mediator_lua",
+        "version": "1.1.2-0",
+        "pinned": false,
+        "opt": false,
+        "dependencies": [],
+        "constraint": ">=1.1.1",
+        "binaries": [],
+        "source": "luarocks_rockspec+https://luarocks.org/",
+        "source_url": {
+          "type": "url",
+          "url": "https://github.com/Olivine-Labs/mediator_lua/archive/v1.1.2-0.tar.gz"
+        },
+        "hashes": {
+          "rockspec": "sha256-dR3r7+CqAPqTwK5jcZIgVSiemUiwIx0UMMIUEY/bPzs=",
+          "source": "sha256-+vWFn9IIG+Tp5PuIc6LcZffv8/2T1t0U2mX44SP8/5s="
+        }
+      },
+      "14a12ec6080e7e9ec24d84932227f38833732b5d73a32673f63d448c204dc550": {
+        "name": "dkjson",
+        "version": "2.8-2",
+        "pinned": false,
+        "opt": false,
+        "dependencies": [],
+        "constraint": ">=2.1.0",
+        "binaries": [],
+        "source": "luarocks_rockspec+https://luarocks.org/",
+        "source_url": {
+          "type": "url",
+          "url": "https://dkolf.de/dkjson-lua/dkjson-2.8.tar.gz"
+        },
+        "hashes": {
+          "rockspec": "sha256-x2SxooqaXo4zME2WBwOOtQ76zCV9h2wXrnsS46rbJEM=",
+          "source": "sha256-JOjNO+uRwchh63uz+8m9QYu/+a1KpdBHGBYlgjajFTI="
+        }
+      },
+      "287e827f4a088d41bba04af5f61a13614346c16fe8150eb7c4246e67d6fd163e": {
+        "name": "lua-term",
+        "version": "0.8-1",
+        "pinned": false,
+        "opt": false,
+        "dependencies": [],
+        "constraint": ">=0.1",
+        "binaries": [],
+        "source": "luarocks_rockspec+https://luarocks.org/",
+        "source_url": {
+          "type": "url",
+          "url": "https://github.com/hoelzro/lua-term/archive/0.08.tar.gz"
+        },
+        "hashes": {
+          "rockspec": "sha256-QB/befI0oJBIBdaM+a+zuQT8dTk0f+ZDLQxq1IekSJw=",
+          "source": "sha256-j/lPOQ6p2YxzRpk3PKOwzlANZRsqscuNfSM2/Ft5ze0="
+        }
+      },
+      "2bd1c8e4fea66a88aeb65c1c8a3d0efdf188d681d2d47f163b976a771545aac9": {
+        "name": "penlight",
+        "version": "1.15.0-1",
+        "pinned": false,
+        "opt": false,
+        "dependencies": [
+          "611b19eca413e8557258c177563402d31094cde79f82d340f5648d2ac8f3b8a1"
+        ],
+        "constraint": ">=1.15.0",
+        "binaries": [],
+        "source": "luarocks_rockspec+https://luarocks.org/",
+        "source_url": {
+          "type": "git",
+          "url": "https://github.com/lunarmodules/penlight.git",
+          "ref": "1.15.0"
+        },
+        "hashes": {
+          "rockspec": "sha256-blt3sqlz+76U8RAtPtfvEqw4+VACI90TFHn+fTcQlVQ=",
+          "source": "sha256-yEkzr4v8avygFxp+NUvffg2fRxQJWTpRdIvluh/QBpY="
+        }
+      },
+      "455cd98d50c6191a9685cffcda4ce783efbb957934625e134c39f43bd5df6818": {
+        "name": "luassert",
+        "version": "1.9.0-1",
+        "pinned": false,
+        "opt": false,
+        "dependencies": [
+          "4e9592a499c9ced4f8ce366db9db7d9c0dd1424ea8d4c8c16c1550ea3a61a696"
+        ],
+        "constraint": ">=1.9.0",
+        "binaries": [],
+        "source": "luarocks_rockspec+https://luarocks.org/",
+        "source_url": {
+          "type": "git",
+          "url": "https://github.com/lunarmodules/luassert.git",
+          "ref": "v1.9.0"
+        },
+        "hashes": {
+          "rockspec": "sha256-rTPvF/GK/jMnH/q4wbwTCGBFELWh+JcvHeOCFAbIf64=",
+          "source": "sha256-jjdB95Vr5iVsh5T7E84WwZMW6/5H2k2R/ny2VBs2l3I="
+        }
+      },
+      "47b12edcdc032232157ace97bddf34bddd17f6f458095885e62bbd602ad9e9ec": {
+        "name": "luasystem",
+        "version": "0.6.3-1",
+        "pinned": false,
+        "opt": false,
+        "dependencies": [],
+        "constraint": ">=0.2.0",
+        "binaries": [],
+        "source": "luarocks_rockspec+https://luarocks.org/",
+        "source_url": {
+          "type": "git",
+          "url": "https://github.com/lunarmodules/luasystem.git",
+          "ref": "v0.6.3"
+        },
+        "hashes": {
+          "rockspec": "sha256-TAprv90NktNCgtoH3wHuRZS+FHvUCNZ2XcDvu23OFX8=",
+          "source": "sha256-8d2835/EcyDJX9yTn6MTfaZryjY1wkSP+IIIKGPDXMk="
+        }
+      },
+      "4e9592a499c9ced4f8ce366db9db7d9c0dd1424ea8d4c8c16c1550ea3a61a696": {
+        "name": "say",
+        "version": "1.4.1-3",
+        "pinned": false,
+        "opt": false,
+        "dependencies": [],
+        "constraint": ">=1.4.0",
+        "binaries": [],
+        "source": "luarocks_rockspec+https://luarocks.org/",
+        "source_url": {
+          "type": "git",
+          "url": "https://github.com/lunarmodules/say.git",
+          "ref": "v1.4.1"
+        },
+        "hashes": {
+          "rockspec": "sha256-WFKt1iWeyjO9A8SG0KUX8tkS9JvMqoVM8CKBUguuK0Y=",
+          "source": "sha256-IjNkK1leVtYgbEjUqguVMjbdW+0BHAOCE0pazrVuF50="
+        }
+      },
+      "5abed54457baf9037ff989a1ec78172ee7483d87e794bc9175dbed61f08be72c": {
+        "name": "lua_cliargs",
+        "version": "3.0.2-1",
+        "pinned": false,
+        "opt": false,
+        "dependencies": [],
+        "constraint": ">=3.0",
+        "binaries": [
+          "watch-tests.sh",
+          "release",
+          "lint",
+          "docs",
+          "coverage"
+        ],
+        "source": "luarocks_rockspec+https://luarocks.org/",
+        "source_url": {
+          "type": "git",
+          "url": "https://github.com/lunarmodules/lua_cliargs.git",
+          "ref": "v3.0.2"
+        },
+        "hashes": {
+          "rockspec": "sha256-7qJVHj/KebRkOmK8pxNspNeuXIksdExjKrNhdWOy474=",
+          "source": "sha256-wL3qBQ8Lu3q8DK2Kaeo1dgzIHd8evaxFYJg47CcQiSg="
+        }
+      },
+      "611b19eca413e8557258c177563402d31094cde79f82d340f5648d2ac8f3b8a1": {
+        "name": "luafilesystem",
+        "version": "1.9.0-1",
+        "pinned": false,
+        "opt": false,
+        "dependencies": [],
+        "constraint": null,
+        "binaries": [],
+        "source": "luarocks_rockspec+https://luarocks.org/",
+        "source_url": {
+          "type": "git",
+          "url": "https://github.com/lunarmodules/luafilesystem",
+          "ref": "v1_9_0"
+        },
+        "hashes": {
+          "rockspec": "sha256-Mrv7QKI6EAY6jzMrh0VWAZIy5KAem82cDPtCIRji4ck=",
+          "source": "sha256-xoNJra/yqxRG11TePcUKrAUU6cwypGnXIoLKZXNaoW0="
+        }
+      },
+      "6ce29c2c535c40246c386c056f24689344cddb56ec397473931431e6b67694d2": {
+        "name": "say",
+        "version": "1.4.1-3",
+        "pinned": false,
+        "opt": false,
+        "dependencies": [],
+        "constraint": ">=1.4",
+        "binaries": [],
+        "source": "luarocks_rockspec+https://luarocks.org/",
+        "source_url": {
+          "type": "git",
+          "url": "https://github.com/lunarmodules/say.git",
+          "ref": "v1.4.1"
+        },
+        "hashes": {
+          "rockspec": "sha256-WFKt1iWeyjO9A8SG0KUX8tkS9JvMqoVM8CKBUguuK0Y=",
+          "source": "sha256-IjNkK1leVtYgbEjUqguVMjbdW+0BHAOCE0pazrVuF50="
+        }
+      },
+      "aa700d6a934caa749d578927c97e22211ffabf97234705d63e95dc6403f6aafe": {
+        "name": "busted",
+        "version": "2.3.0-1",
+        "pinned": false,
+        "opt": false,
+        "dependencies": [
+          "5abed54457baf9037ff989a1ec78172ee7483d87e794bc9175dbed61f08be72c",
+          "47b12edcdc032232157ace97bddf34bddd17f6f458095885e62bbd602ad9e9ec",
+          "14a12ec6080e7e9ec24d84932227f38833732b5d73a32673f63d448c204dc550",
+          "6ce29c2c535c40246c386c056f24689344cddb56ec397473931431e6b67694d2",
+          "455cd98d50c6191a9685cffcda4ce783efbb957934625e134c39f43bd5df6818",
+          "287e827f4a088d41bba04af5f61a13614346c16fe8150eb7c4246e67d6fd163e",
+          "2bd1c8e4fea66a88aeb65c1c8a3d0efdf188d681d2d47f163b976a771545aac9",
+          "01a3c364614bddff7370223a5a9c4580f8e62d144384148444c518ec5367a59b"
+        ],
+        "constraint": null,
+        "binaries": [
+          "busted",
+          "busted"
+        ],
+        "source": "luarocks_rockspec+https://luarocks.org/",
+        "source_url": {
+          "type": "git",
+          "url": "https://github.com/lunarmodules/busted.git",
+          "ref": "v2.3.0"
+        },
+        "hashes": {
+          "rockspec": "sha256-JCyIPhIqs8DLMRjnKaaLUFbeqcTc4yYMRd6BPMAMxLU=",
+          "source": "sha256-ZSfnbsDiaIo/abVpwb/LV5Ktp5wFSZQNO0OdbnjqVSs="
+        }
+      }
+    },
+    "entrypoints": [
+      "aa700d6a934caa749d578927c97e22211ffabf97234705d63e95dc6403f6aafe"
+    ]
+  }
+}

lux.toml 🔗

@@ -0,0 +1,19 @@
+package = "wt"
+version = "0.1.0"
+lua = "==5.4"
+
+[description]
+summary = ""
+maintainer = "Amolith <amolith@secluded.site>"
+labels = [ "" ]
+license = "GPL-3.0-only"
+
+[dependencies]
+# Add your dependencies here
+# `busted = ">=2.0"`
+
+[run]
+args = [ "src/main.lua" ]
+
+[build]
+type = "builtin"

spec/cmd_init_spec.lua 🔗

@@ -0,0 +1,347 @@
+package.path = package.path .. ";./?.lua"
+local wt = dofile("src/main.lua")
+
+describe("cmd_init", function()
+	local temp_dir
+	local original_cwd
+
+	setup(function()
+		local handle = io.popen("pwd")
+		if handle then
+			original_cwd = handle:read("*l")
+			handle:close()
+		end
+		handle = io.popen("mktemp -d")
+		if handle then
+			temp_dir = handle:read("*l")
+			handle:close()
+		end
+	end)
+
+	teardown(function()
+		if original_cwd then
+			os.execute("cd " .. original_cwd)
+		end
+		if temp_dir then
+			os.execute("rm -rf " .. temp_dir)
+		end
+	end)
+
+	describe("already wt-managed repository", function()
+		it("reports already using wt structure when .git file points to .bare", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/already-wt"
+			os.execute("mkdir -p " .. project .. "/.bare")
+			os.execute("git init --bare " .. project .. "/.bare")
+			local f = io.open(project .. "/.git", "w")
+			if f then
+				f:write("gitdir: ./.bare\n")
+				f:close()
+			end
+
+			local output, code = wt.run_cmd("cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua init")
+			assert.are.equal(0, code)
+			assert.is_truthy(output:match("[Aa]lready"))
+		end)
+	end)
+
+	describe("inside a worktree", function()
+		it("errors when run from inside a worktree", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/wt-project"
+			local worktree = project .. "/main"
+			os.execute("mkdir -p " .. project .. "/.bare")
+			os.execute("git init --bare " .. project .. "/.bare")
+			os.execute("mkdir -p " .. worktree)
+			local f = io.open(worktree .. "/.git", "w")
+			if f then
+				f:write("gitdir: ../.bare/worktrees/main\n")
+				f:close()
+			end
+
+			local output, code = wt.run_cmd("cd " .. worktree .. " && lua " .. original_cwd .. "/src/main.lua init 2>&1")
+			assert.are_not.equal(0, code)
+			assert.is_truthy(output:match("worktree") or output:match("not a git"))
+		end)
+	end)
+
+	describe("not a git repository", function()
+		it("errors when run in non-git directory", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/not-git"
+			os.execute("mkdir -p " .. project)
+
+			local output, code = wt.run_cmd("cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua init 2>&1")
+			assert.are_not.equal(0, code)
+			assert.is_truthy(output:match("not a git"))
+		end)
+	end)
+
+	describe(".bare exists but no .git", function()
+		it("errors and suggests creating .git file", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/bare-only"
+			os.execute("mkdir -p " .. project .. "/.bare")
+			os.execute("git init --bare " .. project .. "/.bare")
+
+			local output, code = wt.run_cmd("cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua init 2>&1")
+			assert.are_not.equal(0, code)
+			assert.is_truthy(output:match("%.git") or output:match("gitdir"))
+		end)
+	end)
+
+	describe("existing git worktrees", function()
+		it("errors when .git/worktrees/ exists", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/has-worktrees"
+			os.execute("mkdir -p " .. project)
+			os.execute("git init " .. project)
+			os.execute("cd " .. project .. " && git commit --allow-empty -m 'initial'")
+			os.execute("mkdir -p " .. project .. "/.git/worktrees/feature-branch")
+
+			local output, code = wt.run_cmd("cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua init 2>&1")
+			assert.are_not.equal(0, code)
+			assert.is_truthy(output:match("worktree"))
+		end)
+	end)
+
+	describe("dirty repository", function()
+		it("errors when uncommitted changes exist", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/dirty-repo"
+			os.execute("mkdir -p " .. project)
+			os.execute("git init " .. project)
+			os.execute("cd " .. project .. " && git commit --allow-empty -m 'initial'")
+			local f = io.open(project .. "/dirty.txt", "w")
+			if f then
+				f:write("uncommitted\n")
+				f:close()
+			end
+			os.execute("cd " .. project .. " && git add dirty.txt")
+
+			local output, code = wt.run_cmd("cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua init 2>&1")
+			assert.are_not.equal(0, code)
+			assert.is_truthy(output:match("uncommitted") or output:match("stash") or output:match("commit"))
+		end)
+	end)
+
+	describe("--dry-run", function()
+		it("prints plan without modifying filesystem", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/dry-run-test"
+			os.execute("mkdir -p " .. project)
+			os.execute("git init " .. project)
+			os.execute("cd " .. project .. " && git commit --allow-empty -m 'initial'")
+
+			local cmd = "cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua init --dry-run 2>&1"
+			local output, code = wt.run_cmd(cmd)
+			assert.are.equal(0, code)
+			assert.is_truthy(output:match("[Dd]ry run") or output:match("planned"))
+
+			local git_still_dir = io.open(project .. "/.git/HEAD", "r")
+			assert.is_not_nil(git_still_dir)
+			if git_still_dir then
+				git_still_dir:close()
+			end
+
+			local bare_exists = io.open(project .. "/.bare/HEAD", "r")
+			assert.is_nil(bare_exists)
+		end)
+
+		it("lists orphaned files that would be removed", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/dry-orphans"
+			os.execute("mkdir -p " .. project)
+			os.execute("git init " .. project)
+			os.execute("cd " .. project .. " && git commit --allow-empty -m 'initial'")
+
+			local f = io.open(project .. "/README.md", "w")
+			if f then
+				f:write("# Test\n")
+				f:close()
+			end
+			os.execute("cd " .. project .. " && git add README.md && git commit -m 'add readme'")
+
+			local cmd = "cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua init --dry-run 2>&1"
+			local output, code = wt.run_cmd(cmd)
+			assert.are.equal(0, code)
+			assert.is_truthy(output:match("README") or output:match("orphan"))
+		end)
+
+		it("shows target worktree path", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/dry-worktree"
+			os.execute("mkdir -p " .. project)
+			os.execute("git init " .. project)
+			os.execute("cd " .. project .. " && git commit --allow-empty -m 'initial'")
+
+			local cmd = "cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua init --dry-run 2>&1"
+			local output, code = wt.run_cmd(cmd)
+			assert.are.equal(0, code)
+			assert.is_truthy(output:match("worktree") or output:match("main") or output:match("master"))
+		end)
+	end)
+
+	describe("-y/--yes flag", function()
+		it("bypasses confirmation prompt", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/yes-test"
+			os.execute("mkdir -p " .. project)
+			os.execute("git init " .. project)
+			os.execute("cd " .. project .. " && git commit --allow-empty -m 'initial'")
+
+			local output, code = wt.run_cmd("cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua init -y 2>&1")
+			assert.are.equal(0, code)
+			assert.is_truthy(output:match("[Cc]onverted") or output:match("[Bb]are"))
+
+			local bare_exists = io.open(project .. "/.bare/HEAD", "r")
+			assert.is_not_nil(bare_exists)
+			if bare_exists then
+				bare_exists:close()
+			end
+
+			local git_is_file = io.open(project .. "/.git", "r")
+			if git_is_file then
+				local content = git_is_file:read("*a")
+				git_is_file:close()
+				assert.is_truthy(content:match("gitdir"))
+			end
+		end)
+	end)
+
+	describe("successful conversion", function()
+		it("moves .git to .bare", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/convert-test"
+			os.execute("mkdir -p " .. project)
+			os.execute("git init " .. project)
+			os.execute("cd " .. project .. " && git commit --allow-empty -m 'initial'")
+
+			local _, code = wt.run_cmd("cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua init -y 2>&1")
+			assert.are.equal(0, code)
+
+			local bare_head = io.open(project .. "/.bare/HEAD", "r")
+			assert.is_not_nil(bare_head)
+			if bare_head then
+				bare_head:close()
+			end
+		end)
+
+		it("creates .git file pointing to .bare", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/git-file-test"
+			os.execute("mkdir -p " .. project)
+			os.execute("git init " .. project)
+			os.execute("cd " .. project .. " && git commit --allow-empty -m 'initial'")
+
+			local _, code = wt.run_cmd("cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua init -y 2>&1")
+			assert.are.equal(0, code)
+
+			local git_file = io.open(project .. "/.git", "r")
+			assert.is_not_nil(git_file)
+			if git_file then
+				local content = git_file:read("*a")
+				git_file:close()
+				assert.is_truthy(content:match("gitdir:.*%.bare"))
+			end
+		end)
+
+		it("creates worktree for default branch", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/worktree-created"
+			os.execute("mkdir -p " .. project)
+			os.execute("git init " .. project)
+			os.execute("cd " .. project .. " && git commit --allow-empty -m 'initial'")
+
+			local cmd = "cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua init -y 2>&1"
+			local _, code = wt.run_cmd(cmd)
+			assert.are.equal(0, code)
+
+			local worktrees_output, _ = wt.run_cmd("GIT_DIR=" .. project .. "/.bare git worktree list")
+			assert.is_truthy(worktrees_output:match("main") or worktrees_output:match("master"))
+		end)
+
+		it("removes orphaned files from root", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/orphan-cleanup"
+			os.execute("mkdir -p " .. project)
+			os.execute("git init " .. project)
+			local f = io.open(project .. "/tracked.txt", "w")
+			if f then
+				f:write("tracked\n")
+				f:close()
+			end
+			os.execute("cd " .. project .. " && git add tracked.txt && git commit -m 'add file'")
+
+			local _, code = wt.run_cmd("cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua init -y 2>&1")
+			assert.are.equal(0, code)
+
+			local orphan_check = io.open(project .. "/tracked.txt", "r")
+			assert.is_nil(orphan_check)
+		end)
+	end)
+
+	describe("warnings", function()
+		it("warns about submodules", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/submodule-warn"
+			os.execute("mkdir -p " .. project)
+			os.execute("git init " .. project)
+			os.execute("cd " .. project .. " && git commit --allow-empty -m 'initial'")
+			local f = io.open(project .. "/.gitmodules", "w")
+			if f then
+				f:write("[submodule \"lib\"]\n\tpath = lib\n\turl = https://example.com/lib.git\n")
+				f:close()
+			end
+			os.execute("cd " .. project .. " && git add .gitmodules && git commit -m 'add submodules'")
+
+			local output, _ = wt.run_cmd("cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua init --dry-run 2>&1")
+			assert.is_truthy(output:match("submodule") or output:match("[Ww]arning"))
+		end)
+	end)
+end)

spec/cmd_remove_spec.lua 🔗

@@ -0,0 +1,271 @@
+package.path = package.path .. ";./?.lua"
+local wt = dofile("src/main.lua")
+
+describe("cmd_remove", function()
+	local temp_dir
+	local original_cwd
+
+	setup(function()
+		local handle = io.popen("pwd")
+		if handle then
+			original_cwd = handle:read("*l")
+			handle:close()
+		end
+		handle = io.popen("mktemp -d")
+		if handle then
+			temp_dir = handle:read("*l")
+			handle:close()
+		end
+	end)
+
+	teardown(function()
+		if original_cwd then
+			os.execute("cd " .. original_cwd)
+		end
+		if temp_dir then
+			os.execute("rm -rf " .. temp_dir)
+		end
+	end)
+
+	---Helper to create a wt-managed project with worktrees
+	---@param name string project name
+	---@param branches string[] branches to create worktrees for
+	---@return string project_path
+	local function create_wt_project(name, branches)
+		local project = temp_dir .. "/" .. name
+		os.execute("mkdir -p " .. project .. "/.bare")
+		os.execute("git init --bare " .. project .. "/.bare")
+		local f = io.open(project .. "/.git", "w")
+		if f then
+			f:write("gitdir: ./.bare\n")
+			f:close()
+		end
+
+		local first_branch = branches[1] or "main"
+		os.execute("GIT_DIR=" .. project .. "/.bare git symbolic-ref HEAD refs/heads/" .. first_branch)
+		local first_wt = project .. "/" .. first_branch
+		os.execute("GIT_DIR=" .. project .. "/.bare git worktree add --orphan -b " .. first_branch .. " -- " .. first_wt)
+		os.execute("cd " .. first_wt .. " && git commit --allow-empty -m 'initial'")
+
+		for i = 2, #branches do
+			local branch = branches[i]
+			local wt_path = project .. "/" .. branch:gsub("/", "_")
+			local cmd = "GIT_DIR=" .. project .. "/.bare git worktree add -b "
+				.. branch .. " -- " .. wt_path .. " " .. first_branch
+			os.execute(cmd)
+		end
+
+		return project
+	end
+
+	describe("worktree not found", function()
+		it("errors when target worktree does not exist", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = create_wt_project("no-wt", { "main" })
+
+			local output, code = wt.run_cmd("cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua r nonexistent 2>&1")
+			assert.are_not.equal(0, code)
+			assert.is_truthy(output:match("no worktree found") or output:match("not found"))
+		end)
+	end)
+
+	describe("cwd inside target worktree", function()
+		it("errors when trying to remove worktree while inside it", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = create_wt_project("inside-wt", { "main", "feature" })
+			local feature_wt = project .. "/feature"
+
+			local output, code = wt.run_cmd("cd " .. feature_wt .. " && lua " .. original_cwd .. "/src/main.lua r feature 2>&1")
+			assert.are_not.equal(0, code)
+			assert.is_truthy(output:match("inside") or output:match("cannot remove"))
+		end)
+	end)
+
+	describe("uncommitted changes", function()
+		it("errors when worktree has uncommitted changes without -f", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = create_wt_project("dirty-wt", { "main", "dirty" })
+			local dirty_wt = project .. "/dirty"
+
+			local f = io.open(dirty_wt .. "/uncommitted.txt", "w")
+			if f then
+				f:write("dirty file\n")
+				f:close()
+			end
+			os.execute("cd " .. dirty_wt .. " && git add uncommitted.txt")
+
+			local output, code = wt.run_cmd("cd " .. project .. "/main && lua " .. original_cwd .. "/src/main.lua r dirty 2>&1")
+			assert.are_not.equal(0, code)
+			assert.is_truthy(output:match("uncommitted") or output:match("%-f") or output:match("force"))
+		end)
+
+		it("succeeds with -f when worktree has uncommitted changes", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = create_wt_project("force-dirty", { "main", "force-test" })
+			local target_wt = project .. "/force-test"
+
+			local f = io.open(target_wt .. "/uncommitted.txt", "w")
+			if f then
+				f:write("dirty file\n")
+				f:close()
+			end
+			os.execute("cd " .. target_wt .. " && git add uncommitted.txt")
+
+			local cmd = "cd " .. project .. "/main && lua " .. original_cwd .. "/src/main.lua r force-test -f 2>&1"
+			local output, code = wt.run_cmd(cmd)
+			assert.are.equal(0, code)
+			assert.is_truthy(output:match("[Rr]emoved") or output:match("[Ss]uccess"))
+
+			local check = io.open(target_wt .. "/.git", "r")
+			assert.is_nil(check)
+		end)
+	end)
+
+	describe("branch deletion with -b", function()
+		it("refuses to delete branch if it's bare repo HEAD", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = create_wt_project("bare-head", { "main", "other" })
+
+			os.execute("GIT_DIR=" .. project .. "/.bare git worktree remove -- " .. project .. "/main")
+
+			os.execute("GIT_DIR=" .. project .. "/.bare git symbolic-ref HEAD refs/heads/main")
+			os.execute("GIT_DIR=" .. project .. "/.bare git worktree add -- " .. project .. "/main main")
+
+			local cmd = "cd " .. project .. "/other && lua " .. original_cwd .. "/src/main.lua r main -b 2>&1"
+			local output, _ = wt.run_cmd(cmd)
+			assert.is_truthy(output:match("HEAD") or output:match("retained") or output:match("cannot delete"))
+		end)
+
+		it("refuses to delete branch if checked out in another worktree", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = create_wt_project("multi-checkout", { "main", "feature", "other" })
+
+			local cmd = "cd " .. project .. "/other && lua " .. original_cwd .. "/src/main.lua r feature -b 2>&1"
+			local output, code = wt.run_cmd(cmd)
+			assert.are.equal(0, code)
+			assert.is_truthy(output:match("[Rr]emoved"))
+			local ref_cmd = "GIT_DIR=" .. project .. "/.bare git show-ref --verify --quiet refs/heads/feature"
+			local branch_exists = wt.run_cmd_silent(ref_cmd)
+			assert.is_false(branch_exists)
+		end)
+
+		it("deletes branch when no conflicts exist", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = create_wt_project("clean-delete", { "main", "deleteme" })
+
+			os.execute("GIT_DIR=" .. project .. "/.bare git symbolic-ref HEAD refs/heads/main")
+
+			local cmd = "cd " .. project .. "/main && lua " .. original_cwd .. "/src/main.lua r deleteme -b 2>&1"
+			local output, code = wt.run_cmd(cmd)
+			assert.are.equal(0, code)
+			assert.is_truthy(output:match("branch") and output:match("[Rr]emoved"))
+
+			local ref_cmd = "GIT_DIR=" .. project .. "/.bare git show-ref --verify --quiet refs/heads/deleteme"
+			local branch_exists = wt.run_cmd_silent(ref_cmd)
+			assert.is_false(branch_exists)
+		end)
+	end)
+
+	describe("successful removal", function()
+		it("removes worktree without -b", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = create_wt_project("simple-remove", { "main", "removeme" })
+
+			local cmd = "cd " .. project .. "/main && lua " .. original_cwd .. "/src/main.lua r removeme 2>&1"
+			local output, code = wt.run_cmd(cmd)
+			assert.are.equal(0, code)
+			assert.is_truthy(output:match("[Rr]emoved"))
+
+			local check = io.open(project .. "/removeme/.git", "r")
+			assert.is_nil(check)
+
+			local ref_cmd = "GIT_DIR=" .. project .. "/.bare git show-ref --verify --quiet refs/heads/removeme"
+			local branch_exists = wt.run_cmd_silent(ref_cmd)
+			assert.is_true(branch_exists)
+		end)
+
+		it("finds worktree by branch regardless of path style", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = create_wt_project("path-agnostic", { "main" })
+
+			-- Create worktree at flat-style path even though default config is nested
+			local branch = "feature/auth"
+			local flat_path = project .. "/feature_auth"
+			local wt_cmd = "GIT_DIR=" .. project .. "/.bare git worktree add -b "
+				.. branch .. " -- " .. flat_path .. " main 2>&1"
+			os.execute(wt_cmd)
+
+			-- wt r should find it by branch, not by computed path
+			local cmd = "cd " .. project .. "/main && lua " .. original_cwd .. "/src/main.lua r feature/auth 2>&1"
+			local output, code = wt.run_cmd(cmd)
+			assert.are.equal(0, code)
+			assert.is_truthy(output:match("[Rr]emoved"))
+
+			-- Verify worktree is gone
+			local check = io.open(flat_path .. "/.git", "r")
+			assert.is_nil(check)
+		end)
+
+		it("finds worktree at arbitrary path", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = create_wt_project("arbitrary-path", { "main" })
+
+			-- Create worktree at completely arbitrary path
+			local branch = "my-feature"
+			local arbitrary_path = project .. "/some/weird/location"
+			os.execute("mkdir -p " .. project .. "/some/weird")
+			local wt_cmd = "GIT_DIR=" .. project .. "/.bare git worktree add -b "
+				.. branch .. " -- " .. arbitrary_path .. " main 2>&1"
+			os.execute(wt_cmd)
+
+			local cmd = "cd " .. project .. "/main && lua " .. original_cwd .. "/src/main.lua r my-feature 2>&1"
+			local output, code = wt.run_cmd(cmd)
+			assert.are.equal(0, code)
+			assert.is_truthy(output:match("[Rr]emoved"))
+		end)
+	end)
+
+	describe("usage errors", function()
+		it("errors when no branch argument provided", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = create_wt_project("no-arg", { "main" })
+
+			local output, code = wt.run_cmd("cd " .. project .. " && lua " .. original_cwd .. "/src/main.lua r 2>&1")
+			assert.are_not.equal(0, code)
+			assert.is_truthy(output:match("usage") or output:match("Usage"))
+		end)
+	end)
+end)

spec/compat_spec.lua 🔗

@@ -0,0 +1,58 @@
+describe("Lua version compatibility", function()
+	describe("os.execute return value", function()
+		it("returns true for successful commands", function()
+			local success = os.execute("true")
+			assert.is_true(success == true or success == 0)
+		end)
+
+		it("returns non-true for failed commands", function()
+			local success = os.execute("false")
+			assert.is_not_true(success == true)
+		end)
+	end)
+
+	describe("load() with 4 arguments", function()
+		it("works with mode and env parameters", function()
+			local chunk = load("return 42", "test", "t", {})
+			assert.is_function(chunk)
+			assert.are.equal(42, chunk())
+		end)
+
+		it("respects restricted environment", function()
+			local chunk = load("return { x = 1, y = 2 }", "test", "t", {})
+			assert.is_function(chunk)
+			local result = chunk()
+			assert.are.equal(1, result.x)
+			assert.are.equal(2, result.y)
+		end)
+	end)
+
+	describe("string patterns used in wt", function()
+		it("matches git URL project names", function()
+			local url = "git@github.com:user/project.git"
+			local name = url:match("([^/]+)%.git$") or url:match("([^/:]+)$")
+			assert.are.equal("project", name)
+		end)
+
+		it("matches https URL project names", function()
+			local url = "https://github.com/user/project.git"
+			local name = url:match("([^/]+)%.git$") or url:match("([^/:]+)$")
+			assert.are.equal("project", name)
+		end)
+
+		it("handles gitdir patterns", function()
+			local content = "gitdir: .bare"
+			assert.is_truthy(content:match("gitdir:%s*%.?/?%.bare"))
+		end)
+	end)
+
+	describe("io.popen", function()
+		it("captures command output", function()
+			local handle = io.popen("echo hello")
+			assert.is_not_nil(handle)
+			local output = handle:read("*a")
+			handle:close()
+			assert.are.equal("hello\n", output)
+		end)
+	end)
+end)

spec/config_spec.lua 🔗

@@ -0,0 +1,251 @@
+-- Load main.lua as a module (it exports functions when required)
+package.path = package.path .. ";./?.lua"
+local wt = dofile("src/main.lua")
+
+describe("load_global_config", function()
+	local temp_dir
+
+	setup(function()
+		-- Create temp directory for test configs
+		local handle = io.popen("mktemp -d")
+		if handle then
+			temp_dir = handle:read("*l")
+			handle:close()
+		end
+	end)
+
+	teardown(function()
+		-- Restore HOME and clean up
+		if temp_dir then
+			os.execute("rm -rf " .. temp_dir)
+		end
+	end)
+
+	describe("with no config file", function()
+		it("returns empty table when config file missing", function()
+			local config = wt.load_global_config()
+			assert.is_table(config)
+		end)
+	end)
+
+	describe("config file format", function()
+		-- Note: These tests document expected behavior but can't easily
+		-- override HOME in the running process. They serve as specification.
+
+		it("accepts simple table config", function()
+			-- Expected: { branch_path_style = "flat" }
+			-- The loader prepends "return " so file should NOT have return
+			local config = wt.load_global_config()
+			assert.is_table(config)
+		end)
+	end)
+end)
+
+describe("load_project_config", function()
+	local temp_dir
+
+	setup(function()
+		local handle = io.popen("mktemp -d")
+		if handle then
+			temp_dir = handle:read("*l")
+			handle:close()
+		end
+	end)
+
+	teardown(function()
+		if temp_dir then
+			os.execute("rm -rf " .. temp_dir)
+		end
+	end)
+
+	describe("with no config file", function()
+		it("returns empty table for nonexistent path", function()
+			local config = wt.load_project_config("/nonexistent/path")
+			assert.are.same({}, config)
+		end)
+
+		it("returns empty table for directory without .wt.lua", function()
+			local config = wt.load_project_config(temp_dir)
+			assert.are.same({}, config)
+		end)
+	end)
+
+	describe("with valid config file", function()
+		it("loads hooks configuration", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			-- Write a test config
+			local config_path = temp_dir .. "/.wt.lua"
+			local f = io.open(config_path, "w")
+			if f then
+				f:write([[{
+	hooks = {
+		copy = { ".env.example" },
+		symlink = { "node_modules" },
+		run = { "npm install" },
+	}
+}]])
+				f:close()
+
+				local config = wt.load_project_config(temp_dir)
+				assert.is_table(config.hooks)
+				assert.are.same({ ".env.example" }, config.hooks.copy)
+				assert.are.same({ "node_modules" }, config.hooks.symlink)
+				assert.are.same({ "npm install" }, config.hooks.run)
+
+				os.remove(config_path)
+			else
+				pending("could not write test config")
+			end
+		end)
+
+		it("loads branch_path_style from project config", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local config_path = temp_dir .. "/.wt.lua"
+			local f = io.open(config_path, "w")
+			if f then
+				f:write([[{ branch_path_style = "flat", flat_separator = "-" }]])
+				f:close()
+
+				local config = wt.load_project_config(temp_dir)
+				assert.are.equal("flat", config.branch_path_style)
+				assert.are.equal("-", config.flat_separator)
+
+				os.remove(config_path)
+			else
+				pending("could not write test config")
+			end
+		end)
+	end)
+
+	describe("error handling", function()
+		it("returns empty table for syntax error in config", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local config_path = temp_dir .. "/.wt.lua"
+			local f = io.open(config_path, "w")
+			if f then
+				f:write([[{ invalid syntax here !!!]])
+				f:close()
+
+				local config = wt.load_project_config(temp_dir)
+				assert.are.same({}, config)
+
+				os.remove(config_path)
+			else
+				pending("could not write test config")
+			end
+		end)
+
+		it("returns empty table for runtime error in config", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local config_path = temp_dir .. "/.wt.lua"
+			local f = io.open(config_path, "w")
+			if f then
+				f:write([[{ value = undefined_var + 1 }]])
+				f:close()
+
+				local config = wt.load_project_config(temp_dir)
+				assert.are.same({}, config)
+
+				os.remove(config_path)
+			else
+				pending("could not write test config")
+			end
+		end)
+
+		it("returns empty table when config returns non-table", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local config_path = temp_dir .. "/.wt.lua"
+			local f = io.open(config_path, "w")
+			if f then
+				f:write([["just a string"]])
+				f:close()
+
+				local config = wt.load_project_config(temp_dir)
+				assert.are.same({}, config)
+
+				os.remove(config_path)
+			else
+				pending("could not write test config")
+			end
+		end)
+
+		it("loads config with leading comment", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local config_path = temp_dir .. "/.wt.lua"
+			local f = io.open(config_path, "w")
+			if f then
+				f:write([[-- my config comment
+{ branch_path_style = "flat" }]])
+				f:close()
+
+				local config = wt.load_project_config(temp_dir)
+				assert.are.equal("flat", config.branch_path_style)
+
+				os.remove(config_path)
+			else
+				pending("could not write test config")
+			end
+		end)
+
+		it("loads config with explicit return", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local config_path = temp_dir .. "/.wt.lua"
+			local f = io.open(config_path, "w")
+			if f then
+				f:write([[return { branch_path_style = "flat" }]])
+				f:close()
+
+				local config = wt.load_project_config(temp_dir)
+				assert.are.equal("flat", config.branch_path_style)
+
+				os.remove(config_path)
+			else
+				pending("could not write test config")
+			end
+		end)
+	end)
+
+	describe("sandboxed environment", function()
+		it("cannot access os module in config", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local config_path = temp_dir .. "/.wt.lua"
+			local f = io.open(config_path, "w")
+			if f then
+				f:write([[{ home = os.getenv("HOME") }]])
+				f:close()
+
+				local config = wt.load_project_config(temp_dir)
+				-- Returns empty table due to runtime error (os is nil)
+				assert.are.same({}, config)
+
+				os.remove(config_path)
+			else
+				pending("could not write test config")
+			end
+		end)
+	end)
+end)

spec/git_parsing_spec.lua 🔗

@@ -0,0 +1,377 @@
+-- Load main.lua as a module (it exports functions when required)
+package.path = package.path .. ";./?.lua"
+local wt = dofile("src/main.lua")
+
+describe("escape_pattern", function()
+	it("escapes percent sign", function()
+		assert.are.equal("100%%", wt.escape_pattern("100%"))
+	end)
+
+	it("escapes dot (wildcard)", function()
+		assert.are.equal("file%.txt", wt.escape_pattern("file.txt"))
+	end)
+
+	it("escapes hyphen", function()
+		assert.are.equal("my%-branch", wt.escape_pattern("my-branch"))
+	end)
+
+	it("escapes brackets", function()
+		assert.are.equal("%[test%]", wt.escape_pattern("[test]"))
+	end)
+
+	it("escapes parentheses", function()
+		assert.are.equal("func%(%)", wt.escape_pattern("func()"))
+	end)
+
+	it("escapes caret and dollar", function()
+		assert.are.equal("%^start end%$", wt.escape_pattern("^start end$"))
+	end)
+
+	it("escapes asterisk and question mark", function()
+		assert.are.equal("glob%*%?", wt.escape_pattern("glob*?"))
+	end)
+
+	it("escapes plus sign", function()
+		assert.are.equal("a%+b", wt.escape_pattern("a+b"))
+	end)
+
+	it("leaves alphanumeric and slashes unchanged", function()
+		assert.are.equal("feature/foo/bar123", wt.escape_pattern("feature/foo/bar123"))
+	end)
+end)
+
+describe("parse_branch_remotes", function()
+	describe("simple branches", function()
+		it("parses single remote", function()
+			local output = "  origin/main\n"
+			local remotes = wt.parse_branch_remotes(output, "main")
+			assert.are.same({ "origin" }, remotes)
+		end)
+
+		it("parses multiple remotes", function()
+			local output = "  origin/main\n  upstream/main\n"
+			local remotes = wt.parse_branch_remotes(output, "main")
+			assert.are.same({ "origin", "upstream" }, remotes)
+		end)
+
+		it("returns empty for no matches", function()
+			local output = "  origin/develop\n"
+			local remotes = wt.parse_branch_remotes(output, "main")
+			assert.are.same({}, remotes)
+		end)
+
+		it("handles empty output", function()
+			local remotes = wt.parse_branch_remotes("", "main")
+			assert.are.same({}, remotes)
+		end)
+	end)
+
+	describe("slashy branches (the tricky case)", function()
+		it("correctly parses feature/foo on origin", function()
+			local output = "  origin/feature/foo\n"
+			local remotes = wt.parse_branch_remotes(output, "feature/foo")
+			assert.are.same({ "origin" }, remotes)
+		end)
+
+		it("correctly parses deeply nested branch", function()
+			local output = "  upstream/user/alice/feature/auth\n"
+			local remotes = wt.parse_branch_remotes(output, "user/alice/feature/auth")
+			assert.are.same({ "upstream" }, remotes)
+		end)
+
+		it("handles multiple remotes with slashy branch", function()
+			local output = "  origin/feature/auth\n  upstream/feature/auth\n"
+			local remotes = wt.parse_branch_remotes(output, "feature/auth")
+			assert.are.same({ "origin", "upstream" }, remotes)
+		end)
+
+		it("does not confuse similar branch names", function()
+			local output = "  origin/feature/foo\n  origin/feature/foobar\n"
+			local remotes = wt.parse_branch_remotes(output, "feature/foo")
+			assert.are.same({ "origin" }, remotes)
+		end)
+	end)
+
+	describe("special characters in branch names", function()
+		it("handles dots in branch name", function()
+			local output = "  origin/release/1.2.3\n"
+			local remotes = wt.parse_branch_remotes(output, "release/1.2.3")
+			assert.are.same({ "origin" }, remotes)
+		end)
+
+		it("handles hyphens in branch name", function()
+			local output = "  origin/fix/bug-123-thing\n"
+			local remotes = wt.parse_branch_remotes(output, "fix/bug-123-thing")
+			assert.are.same({ "origin" }, remotes)
+		end)
+
+		it("handles underscores", function()
+			local output = "  origin/my_feature_branch\n"
+			local remotes = wt.parse_branch_remotes(output, "my_feature_branch")
+			assert.are.same({ "origin" }, remotes)
+		end)
+	end)
+
+	describe("edge cases", function()
+		it("handles extra whitespace", function()
+			local output = "    origin/main   \n"
+			local remotes = wt.parse_branch_remotes(output, "main")
+			assert.are.same({ "origin" }, remotes)
+		end)
+
+		it("ignores HEAD pointer lines", function()
+			-- git branch -r sometimes shows: origin/HEAD -> origin/main
+			local output = "  origin/HEAD -> origin/main\n  origin/main\n"
+			local remotes = wt.parse_branch_remotes(output, "main")
+			assert.are.same({ "origin" }, remotes)
+		end)
+
+		it("handles branch name that looks like remote/branch", function()
+			local output = "  upstream/origin/main\n"
+			local remotes = wt.parse_branch_remotes(output, "origin/main")
+			assert.are.same({ "upstream" }, remotes)
+		end)
+	end)
+end)
+
+describe("parse_worktree_list", function()
+	describe("basic parsing", function()
+		it("parses single worktree with branch", function()
+			local output = [[worktree /home/user/project/main
+HEAD abc123def456
+branch refs/heads/main
+]]
+			local worktrees = wt.parse_worktree_list(output)
+			assert.are.equal(1, #worktrees)
+			assert.are.equal("/home/user/project/main", worktrees[1].path)
+			assert.are.equal("main", worktrees[1].branch)
+			assert.are.equal("abc123def456", worktrees[1].head)
+		end)
+
+		it("parses multiple worktrees", function()
+			local output = [[worktree /home/user/project/.bare
+bare
+
+worktree /home/user/project/main
+HEAD abc123
+branch refs/heads/main
+
+worktree /home/user/project/feature/auth
+HEAD def456
+branch refs/heads/feature/auth
+]]
+			local worktrees = wt.parse_worktree_list(output)
+			assert.are.equal(3, #worktrees)
+
+			assert.are.equal("/home/user/project/.bare", worktrees[1].path)
+			assert.is_true(worktrees[1].bare)
+
+			assert.are.equal("/home/user/project/main", worktrees[2].path)
+			assert.are.equal("main", worktrees[2].branch)
+
+			assert.are.equal("/home/user/project/feature/auth", worktrees[3].path)
+			assert.are.equal("feature/auth", worktrees[3].branch)
+		end)
+	end)
+
+	describe("special states", function()
+		it("parses detached HEAD worktree", function()
+			local output = [[worktree /home/user/project/detached
+HEAD abc123
+detached
+]]
+			local worktrees = wt.parse_worktree_list(output)
+			assert.are.equal(1, #worktrees)
+			assert.is_true(worktrees[1].detached)
+			assert.is_nil(worktrees[1].branch)
+		end)
+
+		it("parses bare repository entry", function()
+			local output = [[worktree /home/user/project/.bare
+bare
+]]
+			local worktrees = wt.parse_worktree_list(output)
+			assert.are.equal(1, #worktrees)
+			assert.is_true(worktrees[1].bare)
+		end)
+	end)
+
+	describe("branch name handling", function()
+		it("strips refs/heads/ prefix", function()
+			local output = [[worktree /path
+HEAD abc
+branch refs/heads/my-branch
+]]
+			local worktrees = wt.parse_worktree_list(output)
+			assert.are.equal("my-branch", worktrees[1].branch)
+		end)
+
+		it("handles slashy branch names", function()
+			local output = [[worktree /path
+HEAD abc
+branch refs/heads/feature/auth/oauth2
+]]
+			local worktrees = wt.parse_worktree_list(output)
+			assert.are.equal("feature/auth/oauth2", worktrees[1].branch)
+		end)
+	end)
+
+	describe("edge cases", function()
+		it("handles empty output", function()
+			local worktrees = wt.parse_worktree_list("")
+			assert.are.same({}, worktrees)
+		end)
+
+		it("handles paths with spaces", function()
+			local output = [[worktree /home/user/my project/main
+HEAD abc
+branch refs/heads/main
+]]
+			local worktrees = wt.parse_worktree_list(output)
+			assert.are.equal("/home/user/my project/main", worktrees[1].path)
+		end)
+	end)
+end)
+
+describe("path_inside", function()
+	describe("exact matches", function()
+		it("returns true for identical paths", function()
+			assert.is_true(wt.path_inside("/home/user", "/home/user"))
+		end)
+
+		it("returns true when both have trailing slashes", function()
+			assert.is_true(wt.path_inside("/home/user/", "/home/user/"))
+		end)
+
+		it("returns true with mixed trailing slashes", function()
+			assert.is_true(wt.path_inside("/home/user", "/home/user/"))
+			assert.is_true(wt.path_inside("/home/user/", "/home/user"))
+		end)
+	end)
+
+	describe("containment", function()
+		it("returns true for direct child", function()
+			assert.is_true(wt.path_inside("/home/user/project", "/home/user"))
+		end)
+
+		it("returns true for deep nesting", function()
+			assert.is_true(wt.path_inside("/home/user/project/src/lib", "/home/user"))
+		end)
+
+		it("returns false for sibling", function()
+			assert.is_false(wt.path_inside("/home/alice", "/home/bob"))
+		end)
+
+		it("returns false for parent", function()
+			assert.is_false(wt.path_inside("/home", "/home/user"))
+		end)
+
+		it("returns false for unrelated paths", function()
+			assert.is_false(wt.path_inside("/var/log", "/home/user"))
+		end)
+	end)
+
+	describe("prefix edge cases", function()
+		it("does not match partial directory names", function()
+			-- /home/user-backup is NOT inside /home/user
+			assert.is_false(wt.path_inside("/home/user-backup", "/home/user"))
+		end)
+
+		it("does not match substring prefixes", function()
+			assert.is_false(wt.path_inside("/home/username", "/home/user"))
+		end)
+
+		it("correctly handles similar names", function()
+			assert.is_false(wt.path_inside("/project-old/src", "/project"))
+			assert.is_true(wt.path_inside("/project/src", "/project"))
+		end)
+	end)
+
+	describe("root paths", function()
+		it("everything is inside root", function()
+			assert.is_true(wt.path_inside("/home/user", "/"))
+		end)
+
+		it("root is inside root", function()
+			assert.is_true(wt.path_inside("/", "/"))
+		end)
+	end)
+end)
+
+describe("summarize_hooks", function()
+	describe("single hook type", function()
+		it("summarizes copy hooks", function()
+			local hooks = { copy = { ".env", "Makefile" } }
+			local summary = wt.summarize_hooks(hooks)
+			assert.are.equal("copy: .env, Makefile", summary)
+		end)
+
+		it("summarizes symlink hooks", function()
+			local hooks = { symlink = { "node_modules" } }
+			local summary = wt.summarize_hooks(hooks)
+			assert.are.equal("symlink: node_modules", summary)
+		end)
+
+		it("summarizes run hooks", function()
+			local hooks = { run = { "npm install", "make setup" } }
+			local summary = wt.summarize_hooks(hooks)
+			assert.are.equal("run: npm install, make setup", summary)
+		end)
+	end)
+
+	describe("multiple hook types", function()
+		it("combines all types with semicolons", function()
+			local hooks = {
+				copy = { ".env" },
+				symlink = { "node_modules" },
+				run = { "npm install" },
+			}
+			local summary = wt.summarize_hooks(hooks)
+			assert.are.equal("copy: .env; symlink: node_modules; run: npm install", summary)
+		end)
+	end)
+
+	describe("truncation", function()
+		it("shows first 3 items with count for copy", function()
+			local hooks = { copy = { "a", "b", "c", "d", "e" } }
+			local summary = wt.summarize_hooks(hooks)
+			assert.are.equal("copy: a, b, c (+2 more)", summary)
+		end)
+
+		it("shows first 3 items with count for symlink", function()
+			local hooks = { symlink = { "1", "2", "3", "4" } }
+			local summary = wt.summarize_hooks(hooks)
+			assert.are.equal("symlink: 1, 2, 3 (+1 more)", summary)
+		end)
+
+		it("shows first 3 items with count for run", function()
+			local hooks = { run = { "cmd1", "cmd2", "cmd3", "cmd4", "cmd5", "cmd6" } }
+			local summary = wt.summarize_hooks(hooks)
+			assert.are.equal("run: cmd1, cmd2, cmd3 (+3 more)", summary)
+		end)
+
+		it("does not add suffix for exactly 3 items", function()
+			local hooks = { copy = { "a", "b", "c" } }
+			local summary = wt.summarize_hooks(hooks)
+			assert.are.equal("copy: a, b, c", summary)
+		end)
+	end)
+
+	describe("edge cases", function()
+		it("returns empty string for empty hooks", function()
+			local summary = wt.summarize_hooks({})
+			assert.are.equal("", summary)
+		end)
+
+		it("returns empty string for nil hooks table", function()
+			local summary = wt.summarize_hooks({})
+			assert.are.equal("", summary)
+		end)
+
+		it("skips empty arrays", function()
+			local hooks = { copy = {}, run = { "npm install" } }
+			local summary = wt.summarize_hooks(hooks)
+			assert.are.equal("run: npm install", summary)
+		end)
+	end)
+end)

spec/hooks_spec.lua 🔗

@@ -0,0 +1,276 @@
+package.path = package.path .. ";./?.lua"
+local wt = dofile("src/main.lua")
+
+describe("hook permission system", function()
+	local temp_dir
+
+	setup(function()
+		local handle = io.popen("mktemp -d")
+		if handle then
+			temp_dir = handle:read("*l")
+			handle:close()
+		end
+	end)
+
+	teardown(function()
+		if temp_dir then
+			os.execute("rm -rf " .. temp_dir)
+		end
+	end)
+
+	describe("load_hook_permissions", function()
+		it("returns empty table when file does not exist", function()
+			local perms = wt.load_hook_permissions(temp_dir .. "/nonexistent")
+			assert.are.same({}, perms)
+		end)
+
+		it("loads valid permissions file", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local perm_dir = temp_dir .. "/perms1/.local/share/wt"
+			os.execute("mkdir -p " .. perm_dir)
+
+			local f = io.open(perm_dir .. "/hook-dirs.lua", "w")
+			if f then
+				f:write('{\n\t["/home/user/project1"] = true,\n\t["/home/user/project2"] = false,\n}\n')
+				f:close()
+
+				local perms = wt.load_hook_permissions(temp_dir .. "/perms1")
+				assert.are.equal(true, perms["/home/user/project1"])
+				assert.are.equal(false, perms["/home/user/project2"])
+			else
+				pending("could not write test file")
+			end
+		end)
+
+		it("returns empty table for malformed file", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local perm_dir = temp_dir .. "/perms2/.local/share/wt"
+			os.execute("mkdir -p " .. perm_dir)
+
+			local f = io.open(perm_dir .. "/hook-dirs.lua", "w")
+			if f then
+				f:write('this is not valid lua {{{}')
+				f:close()
+
+				local perms = wt.load_hook_permissions(temp_dir .. "/perms2")
+				assert.are.same({}, perms)
+			else
+				pending("could not write test file")
+			end
+		end)
+	end)
+
+	describe("save_hook_permissions", function()
+		it("creates directory if it does not exist", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local home = temp_dir .. "/save1"
+			wt.save_hook_permissions({ ["/test/project"] = true }, home)
+
+			local f = io.open(home .. "/.local/share/wt/hook-dirs.lua", "r")
+			assert.is_not_nil(f)
+			if f then
+				f:close()
+			end
+		end)
+
+		it("saves permissions correctly", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local home = temp_dir .. "/save2"
+			wt.save_hook_permissions({
+				["/project/a"] = true,
+				["/project/b"] = false,
+			}, home)
+
+			local perms = wt.load_hook_permissions(home)
+			assert.are.equal(true, perms["/project/a"])
+			assert.are.equal(false, perms["/project/b"])
+		end)
+
+		it("overwrites existing permissions", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local home = temp_dir .. "/save3"
+			wt.save_hook_permissions({ ["/old"] = true }, home)
+			wt.save_hook_permissions({ ["/new"] = false }, home)
+
+			local perms = wt.load_hook_permissions(home)
+			assert.is_nil(perms["/old"])
+			assert.are.equal(false, perms["/new"])
+		end)
+	end)
+end)
+
+describe("run_hooks", function()
+	local temp_dir
+
+	setup(function()
+		local handle = io.popen("mktemp -d")
+		if handle then
+			temp_dir = handle:read("*l")
+			handle:close()
+		end
+	end)
+
+	teardown(function()
+		if temp_dir then
+			os.execute("rm -rf " .. temp_dir)
+		end
+	end)
+
+	describe("copy hooks", function()
+		it("copies files from source to target", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local source = temp_dir .. "/source1"
+			local target = temp_dir .. "/target1"
+			local home = temp_dir .. "/home1"
+			os.execute("mkdir -p " .. source)
+			os.execute("mkdir -p " .. target)
+
+			local f = io.open(source .. "/config.json", "w")
+			if f then
+				f:write('{"key": "value"}\n')
+				f:close()
+			end
+
+			wt.save_hook_permissions({ ["/project"] = true }, home)
+
+			local hooks = { copy = { "config.json" } }
+			wt.run_hooks(source, target, hooks, "/project", home)
+
+			local copied = io.open(target .. "/config.json", "r")
+			assert.is_not_nil(copied)
+			if copied then
+				local content = copied:read("*a")
+				copied:close()
+				assert.is_truthy(content:match("key"))
+			end
+		end)
+
+		it("creates parent directories for nested paths", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local source = temp_dir .. "/source2"
+			local target = temp_dir .. "/target2"
+			local home = temp_dir .. "/home2"
+			os.execute("mkdir -p " .. source .. "/nested/path")
+			os.execute("mkdir -p " .. target)
+
+			local f = io.open(source .. "/nested/path/file.txt", "w")
+			if f then
+				f:write("nested content\n")
+				f:close()
+			end
+
+			wt.save_hook_permissions({ ["/project2"] = true }, home)
+
+			local hooks = { copy = { "nested/path/file.txt" } }
+			wt.run_hooks(source, target, hooks, "/project2", home)
+
+			local copied = io.open(target .. "/nested/path/file.txt", "r")
+			assert.is_not_nil(copied)
+			if copied then
+				copied:close()
+			end
+		end)
+	end)
+
+	describe("symlink hooks", function()
+		it("creates symlinks from target to source", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local source = temp_dir .. "/source3"
+			local target = temp_dir .. "/target3"
+			local home = temp_dir .. "/home3"
+			os.execute("mkdir -p " .. source .. "/node_modules")
+			os.execute("mkdir -p " .. target)
+
+			local f = io.open(source .. "/node_modules/package.json", "w")
+			if f then
+				f:write('{"name": "test"}\n')
+				f:close()
+			end
+
+			wt.save_hook_permissions({ ["/project3"] = true }, home)
+
+			local hooks = { symlink = { "node_modules" } }
+			wt.run_hooks(source, target, hooks, "/project3", home)
+
+			local link_check, _ = wt.run_cmd("test -L " .. target .. "/node_modules && echo yes")
+			assert.is_truthy(link_check:match("yes"))
+		end)
+	end)
+
+	describe("run hooks", function()
+		it("executes commands in target directory", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local source = temp_dir .. "/source4"
+			local target = temp_dir .. "/target4"
+			local home = temp_dir .. "/home4"
+			os.execute("mkdir -p " .. source)
+			os.execute("mkdir -p " .. target)
+
+			wt.save_hook_permissions({ ["/project4"] = true }, home)
+
+			local hooks = { run = { "touch ran-hook.txt" } }
+			wt.run_hooks(source, target, hooks, "/project4", home)
+
+			local ran = io.open(target .. "/ran-hook.txt", "r")
+			assert.is_not_nil(ran)
+			if ran then
+				ran:close()
+			end
+		end)
+	end)
+
+	describe("permission denied", function()
+		it("skips hooks when permission is false", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local source = temp_dir .. "/source5"
+			local target = temp_dir .. "/target5"
+			local home = temp_dir .. "/home5"
+			os.execute("mkdir -p " .. source)
+			os.execute("mkdir -p " .. target)
+
+			local f = io.open(source .. "/shouldnt-copy.txt", "w")
+			if f then
+				f:write("content\n")
+				f:close()
+			end
+
+			wt.save_hook_permissions({ ["/project5"] = false }, home)
+
+			local hooks = { copy = { "shouldnt-copy.txt" } }
+			wt.run_hooks(source, target, hooks, "/project5", home)
+
+			local not_copied = io.open(target .. "/shouldnt-copy.txt", "r")
+			assert.is_nil(not_copied)
+		end)
+	end)
+end)

spec/path_spec.lua 🔗

@@ -0,0 +1,229 @@
+-- Load main.lua as a module (it exports functions when required)
+package.path = package.path .. ";./?.lua"
+local wt = dofile("src/main.lua")
+
+describe("split_path", function()
+	describe("basic paths", function()
+		it("splits absolute path into components", function()
+			local parts = wt.split_path("/home/user/project")
+			assert.are.same({ "home", "user", "project" }, parts)
+		end)
+
+		it("splits relative path into components", function()
+			local parts = wt.split_path("a/b/c")
+			assert.are.same({ "a", "b", "c" }, parts)
+		end)
+
+		it("handles single component", function()
+			local parts = wt.split_path("single")
+			assert.are.same({ "single" }, parts)
+		end)
+	end)
+
+	describe("edge cases", function()
+		it("returns empty table for root path", function()
+			local parts = wt.split_path("/")
+			assert.are.same({}, parts)
+		end)
+
+		it("returns empty table for empty string", function()
+			local parts = wt.split_path("")
+			assert.are.same({}, parts)
+		end)
+
+		it("collapses multiple slashes", function()
+			local parts = wt.split_path("/a//b///c/")
+			assert.are.same({ "a", "b", "c" }, parts)
+		end)
+
+		it("ignores trailing slash", function()
+			local parts = wt.split_path("/a/b/c/")
+			assert.are.same({ "a", "b", "c" }, parts)
+		end)
+
+		it("handles multiple leading slashes", function()
+			local parts = wt.split_path("///a/b")
+			assert.are.same({ "a", "b" }, parts)
+		end)
+	end)
+
+	describe("special characters", function()
+		it("preserves dots in path components", function()
+			local parts = wt.split_path("/home/user/.config")
+			assert.are.same({ "home", "user", ".config" }, parts)
+		end)
+
+		it("preserves hyphens in path components", function()
+			local parts = wt.split_path("/my-project/sub-dir")
+			assert.are.same({ "my-project", "sub-dir" }, parts)
+		end)
+
+		it("preserves spaces in path components", function()
+			local parts = wt.split_path("/path with/spaces here")
+			assert.are.same({ "path with", "spaces here" }, parts)
+		end)
+	end)
+end)
+
+describe("branch_to_path", function()
+	describe("nested style (default)", function()
+		it("preserves slashes in branch name", function()
+			local path = wt.branch_to_path("/repo", "feature/auth", "nested")
+			assert.are.equal("/repo/feature/auth", path)
+		end)
+
+		it("handles simple branch name", function()
+			local path = wt.branch_to_path("/repo", "main", "nested")
+			assert.are.equal("/repo/main", path)
+		end)
+
+		it("handles deeply nested branch", function()
+			local path = wt.branch_to_path("/repo", "feature/auth/oauth2", "nested")
+			assert.are.equal("/repo/feature/auth/oauth2", path)
+		end)
+	end)
+
+	describe("flat style", function()
+		it("replaces slashes with default separator", function()
+			local path = wt.branch_to_path("/repo", "feature/auth", "flat")
+			assert.are.equal("/repo/feature_auth", path)
+		end)
+
+		it("uses custom separator", function()
+			local path = wt.branch_to_path("/repo", "feature/auth", "flat", "-")
+			assert.are.equal("/repo/feature-auth", path)
+		end)
+
+		it("handles simple branch name (no slashes)", function()
+			local path = wt.branch_to_path("/repo", "main", "flat")
+			assert.are.equal("/repo/main", path)
+		end)
+
+		it("handles multiple slashes with custom separator", function()
+			local path = wt.branch_to_path("/repo", "a/b/c", "flat", ".")
+			assert.are.equal("/repo/a.b.c", path)
+		end)
+	end)
+
+	describe("edge cases", function()
+		it("handles empty branch name", function()
+			local path = wt.branch_to_path("/repo", "", "nested")
+			assert.are.equal("/repo/", path)
+		end)
+
+		it("handles root ending with slash", function()
+			local path = wt.branch_to_path("/repo/", "main", "nested")
+			-- Results in double slash - document this behavior
+			assert.are.equal("/repo//main", path)
+		end)
+
+		it("handles separator containing percent", function()
+			assert.are.equal("/repo/a%b", wt.branch_to_path("/repo", "a/b", "flat", "%"))
+		end)
+
+		it("handles branch starting with slash", function()
+			local path = wt.branch_to_path("/repo", "/feature", "nested")
+			-- Results in double slash
+			assert.are.equal("/repo//feature", path)
+		end)
+	end)
+
+	describe("common branch naming patterns", function()
+		it("handles issue-number branches", function()
+			local path = wt.branch_to_path("/repo", "847-do-a-thing", "nested")
+			assert.are.equal("/repo/847-do-a-thing", path)
+		end)
+
+		it("handles version branches", function()
+			local path = wt.branch_to_path("/repo", "release/1.2.3", "flat", "_")
+			assert.are.equal("/repo/release_1.2.3", path)
+		end)
+
+		it("handles user prefix branches", function()
+			local path = wt.branch_to_path("/repo", "user/alice/feature", "flat", "-")
+			assert.are.equal("/repo/user-alice-feature", path)
+		end)
+	end)
+end)
+
+describe("relative_path", function()
+	describe("sibling directories", function()
+		it("computes path to sibling", function()
+			local rel = wt.relative_path("/a/b", "/a/c")
+			assert.are.equal("../c", rel)
+		end)
+
+		it("computes path to sibling's child", function()
+			local rel = wt.relative_path("/a/b", "/a/c/d")
+			assert.are.equal("../c/d", rel)
+		end)
+	end)
+
+	describe("parent/child relationships", function()
+		it("computes path to child", function()
+			local rel = wt.relative_path("/a", "/a/b")
+			assert.are.equal("b", rel)
+		end)
+
+		it("computes path to deeply nested child", function()
+			local rel = wt.relative_path("/a", "/a/b/c/d")
+			assert.are.equal("b/c/d", rel)
+		end)
+
+		it("computes path to parent", function()
+			local rel = wt.relative_path("/a/b/c", "/a")
+			assert.are.equal("../..", rel)
+		end)
+	end)
+
+	describe("same path", function()
+		it("returns ./ for identical paths", function()
+			local rel = wt.relative_path("/a/b", "/a/b")
+			assert.are.equal("./", rel)
+		end)
+	end)
+
+	describe("completely different paths", function()
+		it("computes path across directory tree", function()
+			local rel = wt.relative_path("/home/alice/projects", "/var/log")
+			assert.are.equal("../../../var/log", rel)
+		end)
+	end)
+
+	describe("root paths", function()
+		it("handles from root to child", function()
+			local rel = wt.relative_path("/", "/a/b")
+			assert.are.equal("a/b", rel)
+		end)
+
+		it("handles from child to root", function()
+			local rel = wt.relative_path("/a/b", "/")
+			assert.are.equal("../..", rel)
+		end)
+	end)
+
+	describe("edge cases", function()
+		it("handles trailing slash in from path", function()
+			-- split_path normalizes trailing slashes
+			local rel = wt.relative_path("/a/b/", "/a/c")
+			assert.are.equal("../c", rel)
+		end)
+
+		it("handles paths with common deep prefix", function()
+			local rel = wt.relative_path("/repo/project/src/lib", "/repo/project/src/bin")
+			assert.are.equal("../bin", rel)
+		end)
+	end)
+
+	describe("real-world wt scenarios", function()
+		it("computes path from worktree to .bare", function()
+			local rel = wt.relative_path("/projects/myapp/feature/auth", "/projects/myapp/.bare")
+			assert.are.equal("../../.bare", rel)
+		end)
+
+		it("computes path between worktrees", function()
+			local rel = wt.relative_path("/projects/myapp/main", "/projects/myapp/feature/new")
+			assert.are.equal("../feature/new", rel)
+		end)
+	end)
+end)

spec/project_root_spec.lua 🔗

@@ -0,0 +1,255 @@
+package.path = package.path .. ";./?.lua"
+local wt = dofile("src/main.lua")
+
+describe("find_project_root", function()
+	local temp_dir
+
+	setup(function()
+		local handle = io.popen("mktemp -d")
+		if handle then
+			temp_dir = handle:read("*l")
+			handle:close()
+		end
+	end)
+
+	teardown(function()
+		if temp_dir then
+			os.execute("rm -rf " .. temp_dir)
+		end
+	end)
+
+	describe("wt-managed repository (.bare + .git file)", function()
+		it("finds root when cwd is project root", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/project"
+			os.execute("mkdir -p " .. project .. "/.bare")
+			os.execute("git init --bare " .. project .. "/.bare")
+			local f = io.open(project .. "/.git", "w")
+			if f then
+				f:write("gitdir: ./.bare\n")
+				f:close()
+			end
+
+			local root, err = wt.find_project_root(project)
+			assert.is_nil(err)
+			assert.are.equal(project, root)
+		end)
+
+		it("finds root when cwd is inside worktree", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/project2"
+			os.execute("mkdir -p " .. project .. "/.bare")
+			os.execute("git init --bare " .. project .. "/.bare")
+			local f = io.open(project .. "/.git", "w")
+			if f then
+				f:write("gitdir: ./.bare\n")
+				f:close()
+			end
+
+			os.execute("mkdir -p " .. project .. "/main/src/lib")
+			local wt_git = io.open(project .. "/main/.git", "w")
+			if wt_git then
+				wt_git:write("gitdir: ../.bare/worktrees/main\n")
+				wt_git:close()
+			end
+
+			local root, err = wt.find_project_root(project .. "/main/src/lib")
+			assert.is_nil(err)
+			assert.are.equal(project, root)
+		end)
+
+		it("finds root when cwd is in nested branch worktree", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/nested-wt"
+			os.execute("mkdir -p " .. project .. "/.bare")
+			os.execute("git init --bare " .. project .. "/.bare")
+			local f = io.open(project .. "/.git", "w")
+			if f then
+				f:write("gitdir: ./.bare\n")
+				f:close()
+			end
+
+			os.execute("mkdir -p " .. project .. "/feature/auth/src")
+			local wt_git = io.open(project .. "/feature/auth/.git", "w")
+			if wt_git then
+				wt_git:write("gitdir: ../../.bare/worktrees/feature-auth\n")
+				wt_git:close()
+			end
+
+			local root, err = wt.find_project_root(project .. "/feature/auth/src")
+			assert.is_nil(err)
+			assert.are.equal(project, root)
+		end)
+	end)
+
+	describe("non-wt repository", function()
+		it("returns error for normal git repo", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/normal-git"
+			os.execute("mkdir -p " .. project)
+			os.execute("git init " .. project)
+
+			local root, err = wt.find_project_root(project)
+			assert.is_nil(root)
+			assert.is_not_nil(err)
+			assert.is_truthy(err:match("not in a wt%-managed repository"))
+		end)
+
+		it("returns error for non-git directory", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/not-git"
+			os.execute("mkdir -p " .. project)
+
+			local root, err = wt.find_project_root(project)
+			assert.is_nil(root)
+			assert.is_not_nil(err)
+		end)
+	end)
+
+	describe("edge cases", function()
+		it("finds root with .bare but relative .git reference", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/rel-git"
+			os.execute("mkdir -p " .. project .. "/.bare")
+			os.execute("git init --bare " .. project .. "/.bare")
+
+			local f = io.open(project .. "/.git", "w")
+			if f then
+				f:write("gitdir: .bare\n")
+				f:close()
+			end
+
+			local root, err = wt.find_project_root(project)
+			assert.is_nil(err)
+			assert.are.equal(project, root)
+		end)
+	end)
+end)
+
+describe("detect_source_worktree", function()
+	local temp_dir
+
+	setup(function()
+		local handle = io.popen("mktemp -d")
+		if handle then
+			temp_dir = handle:read("*l")
+			handle:close()
+		end
+	end)
+
+	teardown(function()
+		if temp_dir then
+			os.execute("rm -rf " .. temp_dir)
+		end
+	end)
+
+	describe("at project root", function()
+		it("returns nil when cwd equals root", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/proj1"
+			os.execute("mkdir -p " .. project .. "/.bare")
+
+			local result = wt.detect_source_worktree(project, project)
+			assert.is_nil(result)
+		end)
+	end)
+
+	describe("inside a worktree", function()
+		it("returns worktree path when cwd has .git file", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/proj2"
+			local worktree = project .. "/main"
+			os.execute("mkdir -p " .. project .. "/.bare")
+			os.execute("mkdir -p " .. worktree .. "/src")
+
+			local f = io.open(worktree .. "/.git", "w")
+			if f then
+				f:write("gitdir: ../.bare/worktrees/main\n")
+				f:close()
+			end
+
+			local result = wt.detect_source_worktree(project, worktree)
+			assert.are.equal(worktree, result)
+		end)
+
+		it("finds worktree root when cwd is deep inside worktree", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/proj3"
+			local worktree = project .. "/main"
+			local deep_path = worktree .. "/src/lib/utils"
+			os.execute("mkdir -p " .. project .. "/.bare")
+			os.execute("mkdir -p " .. deep_path)
+
+			local f = io.open(worktree .. "/.git", "w")
+			if f then
+				f:write("gitdir: ../.bare/worktrees/main\n")
+				f:close()
+			end
+
+			local result = wt.detect_source_worktree(project, deep_path)
+			assert.are.equal(worktree, result)
+		end)
+
+		it("finds nested branch worktree", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/proj4"
+			local worktree = project .. "/feature/auth"
+			os.execute("mkdir -p " .. project .. "/.bare")
+			os.execute("mkdir -p " .. worktree .. "/src")
+
+			local f = io.open(worktree .. "/.git", "w")
+			if f then
+				f:write("gitdir: ../../.bare/worktrees/feature-auth\n")
+				f:close()
+			end
+
+			local result = wt.detect_source_worktree(project, worktree .. "/src")
+			assert.are.equal(worktree, result)
+		end)
+	end)
+
+	describe("not inside a worktree", function()
+		it("returns nil when in bare repo directory", function()
+			if not temp_dir then
+				pending("temp_dir not available")
+				return
+			end
+			local project = temp_dir .. "/proj5"
+			local bare = project .. "/.bare"
+			os.execute("mkdir -p " .. bare)
+
+			local result = wt.detect_source_worktree(project, bare)
+			assert.is_nil(result)
+		end)
+	end)
+end)

spec/url_parsing_spec.lua 🔗

@@ -0,0 +1,129 @@
+-- Load main.lua as a module (it exports functions when required)
+package.path = package.path .. ";./?.lua"
+local wt = dofile("src/main.lua")
+
+describe("extract_project_name", function()
+	describe("standard git URLs", function()
+		it("extracts from SSH URL with .git suffix", function()
+			assert.are.equal("project", wt.extract_project_name("git@github.com:user/project.git"))
+		end)
+
+		it("extracts from HTTPS URL with .git suffix", function()
+			assert.are.equal("project", wt.extract_project_name("https://github.com/user/project.git"))
+		end)
+
+		it("extracts from HTTPS URL without .git suffix", function()
+			assert.are.equal("project", wt.extract_project_name("https://github.com/user/project"))
+		end)
+
+		it("extracts from SSH URL without .git suffix", function()
+			assert.are.equal("project", wt.extract_project_name("git@github.com:user/project"))
+		end)
+
+		it("extracts from ssh:// protocol URL", function()
+			assert.are.equal("repo", wt.extract_project_name("ssh://git@host:2222/user/repo.git"))
+		end)
+
+		it("extracts from gitlab-style nested groups", function()
+			assert.are.equal("repo", wt.extract_project_name("git@gitlab.com:group/subgroup/repo.git"))
+		end)
+	end)
+
+	describe("SCP-style URLs", function()
+		it("extracts from SCP URL with path", function()
+			assert.are.equal("project", wt.extract_project_name("git@host:user/project.git"))
+		end)
+
+		it("extracts from SCP URL without path slash", function()
+			assert.are.equal("repo", wt.extract_project_name("git@host:repo.git"))
+		end)
+	end)
+
+	describe("edge cases", function()
+		it("returns nil for empty string", function()
+			assert.is_nil(wt.extract_project_name(""))
+		end)
+
+		it("returns nil for just a slash", function()
+			assert.is_nil(wt.extract_project_name("/"))
+		end)
+
+		it("extracts bare project name with .git suffix", function()
+			assert.are.equal("repo", wt.extract_project_name("repo.git"))
+		end)
+
+		it("extracts bare project name without suffix", function()
+			assert.are.equal("repo", wt.extract_project_name("repo"))
+		end)
+
+		it("extracts project from URL with trailing slash", function()
+			assert.are.equal("repo", wt.extract_project_name("https://github.com/user/repo.git/"))
+		end)
+
+		it("extracts project from URL with query string", function()
+			assert.are.equal("repo", wt.extract_project_name("https://github.com/user/repo.git?ref=main"))
+		end)
+
+		it("extracts project from URL with fragment", function()
+			assert.are.equal("repo", wt.extract_project_name("https://github.com/user/repo#readme"))
+		end)
+	end)
+
+	describe("special characters in project names", function()
+		it("extracts project with dots", function()
+			assert.are.equal("my.project", wt.extract_project_name("git@github.com:user/my.project.git"))
+		end)
+
+		it("extracts project with hyphens", function()
+			assert.are.equal("my-project", wt.extract_project_name("git@github.com:user/my-project.git"))
+		end)
+
+		it("extracts project with underscores", function()
+			assert.are.equal("my_project", wt.extract_project_name("git@github.com:user/my_project.git"))
+		end)
+
+		it("extracts project with numbers", function()
+			assert.are.equal("project123", wt.extract_project_name("git@github.com:user/project123.git"))
+		end)
+	end)
+end)
+
+describe("resolve_url_template", function()
+	describe("basic substitution", function()
+		it("substitutes ${project} in SSH template", function()
+			local result = wt.resolve_url_template("git@github.com:myuser/${project}.git", "myrepo")
+			assert.are.equal("git@github.com:myuser/myrepo.git", result)
+		end)
+
+		it("substitutes ${project} in HTTPS template", function()
+			local result = wt.resolve_url_template("https://github.com/myuser/${project}.git", "myrepo")
+			assert.are.equal("https://github.com/myuser/myrepo.git", result)
+		end)
+
+		it("handles multiple ${project} occurrences", function()
+			local result = wt.resolve_url_template("${project}/${project}", "x")
+			assert.are.equal("x/x", result)
+		end)
+
+		it("returns unchanged template with no placeholder", function()
+			local result = wt.resolve_url_template("git@host:static.git", "ignored")
+			assert.are.equal("git@host:static.git", result)
+		end)
+	end)
+
+	describe("special characters in project name", function()
+		it("handles hyphens in project name", function()
+			local result = wt.resolve_url_template("git@host:${project}.git", "my-repo")
+			assert.are.equal("git@host:my-repo.git", result)
+		end)
+
+		it("handles dots in project name", function()
+			local result = wt.resolve_url_template("git@host:${project}.git", "my.repo")
+			assert.are.equal("git@host:my.repo.git", result)
+		end)
+
+		it("handles percent in project name", function()
+			assert.are.equal("git@host:weird%name.git", wt.resolve_url_template("git@host:${project}.git", "weird%name"))
+		end)
+	end)
+end)

src/main.lua 🔗

@@ -0,0 +1,1820 @@
+#!/usr/bin/env lua
+
+if _VERSION < "Lua 5.2" then
+	io.stderr:write("error: wt requires Lua 5.2 or later\n")
+	os.exit(1)
+end
+
+-- Exit codes
+local EXIT_SUCCESS = 0
+local EXIT_USER_ERROR = 1
+local EXIT_SYSTEM_ERROR = 2
+
+---Execute command, return output and exit code
+---@param cmd string
+---@return string output
+---@return integer code
+local function run_cmd(cmd)
+	local handle = io.popen(cmd .. " 2>&1")
+	if not handle then
+		return "", EXIT_SYSTEM_ERROR
+	end
+	local output = handle:read("*a") or ""
+	local success, _, code = handle:close()
+	if success then
+		return output, 0
+	end
+	return output, code or EXIT_SYSTEM_ERROR
+end
+
+---Execute command silently, return success boolean
+---@param cmd string
+---@return boolean success
+local function run_cmd_silent(cmd)
+	local success = os.execute(cmd .. " >/dev/null 2>&1")
+	return success == true
+end
+
+---Walk up from cwd looking for .git file pointing to .bare, or .bare/ directory
+---@return string|nil root
+---@return string|nil error
+local function find_project_root()
+	local handle = io.popen("pwd")
+	if not handle then
+		return nil, "failed to get current directory"
+	end
+	local cwd = handle:read("*l")
+	handle:close()
+
+	if not cwd then
+		return nil, "failed to get current directory"
+	end
+
+	local path = cwd
+	while path and path ~= "" and path ~= "/" do
+		-- Check for .bare directory
+		local bare_check = io.open(path .. "/.bare/HEAD", "r")
+		if bare_check then
+			bare_check:close()
+			return path, nil
+		end
+
+		-- Check for .git file pointing to .bare
+		local git_file = io.open(path .. "/.git", "r")
+		if git_file then
+			local content = git_file:read("*a")
+			git_file:close()
+			if content and content:match("gitdir:%s*%.?/?%.bare") then
+				return path, nil
+			end
+		end
+
+		-- Move up one directory
+		path = path:match("(.+)/[^/]+$")
+	end
+
+	return nil, "not in a wt-managed repository"
+end
+
+---Substitute ${project} in template string
+---@param template string
+---@param project_name string
+---@return string
+local function resolve_url_template(template, project_name)
+	local escaped = project_name:gsub("%%", "%%%%")
+	return (template:gsub("%${project}", escaped))
+end
+
+---Parse git URLs to extract project name
+---@param url string
+---@return string|nil
+local function _extract_project_name(url) -- luacheck: ignore 211
+	if not url or url == "" then
+		return nil
+	end
+
+	url = url:gsub("[?#].*$", "")
+	url = url:gsub("/+$", "")
+
+	if url == "" or url == "/" then
+		return nil
+	end
+
+	url = url:gsub("%.git$", "")
+
+	if not url:match("://") then
+		local scp_path = url:match("^[^@]+@[^:]+:(.+)$")
+		if scp_path and scp_path ~= "" then
+			url = scp_path
+		end
+	end
+
+	local name = url:match("([^/]+)$") or url:match("([^:]+)$")
+	if not name or name == "" then
+		return nil
+	end
+	return name
+end
+
+---Print error message and exit
+---@param msg string
+---@param code? integer
+local function die(msg, code)
+	io.stderr:write("error: " .. msg .. "\n")
+	os.exit(code or EXIT_USER_ERROR)
+end
+
+---Print usage information
+local function print_usage()
+	print("wt - git worktree manager")
+	print("")
+	print("Usage: wt <command> [options]")
+	print("")
+	print("Commands:")
+	print("  c <url> [--remote name]... [--own]   Clone into bare worktree structure")
+	print("  n <project-name> [--remote name]...  Initialize fresh project")
+	print("  a <branch> [-b [<start-point>]]      Add worktree with optional hooks")
+	print("  r <branch> [-b] [-f]                 Remove worktree, optionally delete branch")
+	print("  l                                    List worktrees with status")
+	print("  f                                    Fetch all remotes")
+	print("  init [--dry-run] [-y]                 Convert existing repo to bare structure")
+	print("  help                                 Show this help message")
+end
+
+---Parse git URLs to extract project name (exported version)
+---@param url string
+---@return string|nil
+local function extract_project_name(url)
+	if not url or url == "" then
+		return nil
+	end
+
+	url = url:gsub("[?#].*$", "")
+	url = url:gsub("/+$", "")
+
+	if url == "" or url == "/" then
+		return nil
+	end
+
+	url = url:gsub("%.git$", "")
+
+	if not url:match("://") then
+		local scp_path = url:match("^[^@]+@[^:]+:(.+)$")
+		if scp_path and scp_path ~= "" then
+			url = scp_path
+		end
+	end
+
+	local name = url:match("([^/]+)$") or url:match("([^:]+)$")
+	if not name or name == "" then
+		return nil
+	end
+	return name
+end
+
+---Detect default branch from cloned bare repo
+---@param git_dir string
+---@return string
+local function detect_cloned_default_branch(git_dir)
+	-- First try the bare repo's own HEAD (set during clone)
+	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref HEAD")
+	if code == 0 and output ~= "" then
+		local branch = output:match("refs/heads/(.+)")
+		if branch then
+			return (branch:gsub("%s+$", ""))
+		end
+	end
+	return "main"
+end
+
+---Get default branch name from git config, fallback to "main"
+---@return string
+local function get_default_branch()
+	local output, code = run_cmd("git config --get init.defaultBranch")
+	if code == 0 and output ~= "" then
+		return (output:gsub("%s+$", ""))
+	end
+	return "main"
+end
+
+---Get current working directory
+---@return string|nil
+local function get_cwd()
+	local handle = io.popen("pwd")
+	if not handle then
+		return nil
+	end
+	local cwd = handle:read("*l")
+	handle:close()
+	return cwd
+end
+
+---Convert branch name to worktree path
+---@param root string
+---@param branch string
+---@param style string "nested" or "flat"
+---@param separator? string separator for flat style
+---@return string
+local function branch_to_path(root, branch, style, separator)
+	if style == "flat" then
+		local sep = separator or "_"
+		local escaped_sep = sep:gsub("%%", "%%%%")
+		local flat_name = branch:gsub("/", escaped_sep)
+		return root .. "/" .. flat_name
+	end
+	-- nested style (default): preserve slashes
+	return root .. "/" .. branch
+end
+
+---Load global config from ~/.config/wt/config.lua
+---@return {branch_path_style?: string, flat_separator?: string, remotes?: table<string, string>, default_remotes?: string[]|string}
+local function load_global_config()
+	local home = os.getenv("HOME")
+	if not home then
+		return {}
+	end
+	local config_path = home .. "/.config/wt/config.lua"
+	local f = io.open(config_path, "r")
+	if not f then
+		return {}
+	end
+	local content = f:read("*a")
+	f:close()
+	local chunk = load("return " .. content, config_path, "t", {})
+	if not chunk then
+		return {}
+	end
+	local ok, result = pcall(chunk)
+	if ok and type(result) == "table" then
+		return result
+	end
+	return {}
+end
+
+---Load project config from <root>/.wt.lua
+---@param root string
+---@return {hooks?: {copy?: string[], symlink?: string[], run?: string[]}}
+local function load_project_config(root)
+	local config_path = root .. "/.wt.lua"
+	local f = io.open(config_path, "r")
+	if not f then
+		return {}
+	end
+	local content = f:read("*a")
+	f:close()
+
+	local chunk = load(content, config_path, "t", {})
+	if not chunk then
+		chunk = load("return " .. content, config_path, "t", {})
+	end
+	if not chunk then
+		return {}
+	end
+	local ok, result = pcall(chunk)
+	if ok and type(result) == "table" then
+		return result
+	end
+	return {}
+end
+
+---Split path into components
+---@param path string
+---@return string[]
+local function split_path(path)
+	local parts = {}
+	for part in path:gmatch("[^/]+") do
+		table.insert(parts, part)
+	end
+	return parts
+end
+
+---Calculate relative path from one absolute path to another
+---@param from string absolute path of starting directory
+---@param to string absolute path of target
+---@return string relative path
+local function relative_path(from, to)
+	if from == to then
+		return "./"
+	end
+
+	local from_parts = split_path(from)
+	local to_parts = split_path(to)
+
+	local common = 0
+	for i = 1, math.min(#from_parts, #to_parts) do
+		if from_parts[i] == to_parts[i] then
+			common = i
+		else
+			break
+		end
+	end
+
+	local up_count = #from_parts - common
+	local result = {}
+
+	for _ = 1, up_count do
+		table.insert(result, "..")
+	end
+
+	for i = common + 1, #to_parts do
+		table.insert(result, to_parts[i])
+	end
+
+	if #result == 0 then
+		return "./"
+	end
+
+	return table.concat(result, "/")
+end
+
+---Check if cwd is inside a worktree (has .git file, not at project root)
+---@param root string
+---@return string|nil source_worktree path if inside worktree, nil if at project root
+local function detect_source_worktree(root)
+	local cwd = get_cwd()
+	if not cwd then
+		return nil
+	end
+	-- If cwd is the project root, no source worktree
+	if cwd == root then
+		return nil
+	end
+	-- Check if cwd has a .git file (indicating it's a worktree)
+	local git_file = io.open(cwd .. "/.git", "r")
+	if git_file then
+		git_file:close()
+		return cwd
+	end
+	-- Walk up to find worktree root
+	---@type string|nil
+	local path = cwd
+	while path and path ~= "" and path ~= "/" and path ~= root do
+		local gf = io.open(path .. "/.git", "r")
+		if gf then
+			gf:close()
+			return path
+		end
+		path = path:match("(.+)/[^/]+$")
+	end
+	return nil
+end
+
+---Check if branch exists locally
+---@param git_dir string
+---@param branch string
+---@return boolean
+local function branch_exists_local(git_dir, branch)
+	return run_cmd_silent("GIT_DIR=" .. git_dir .. " git show-ref --verify --quiet refs/heads/" .. branch)
+end
+
+---Escape special Lua pattern characters in a string
+---@param str string
+---@return string
+local function escape_pattern(str)
+	return (str:gsub("([%%%-%+%[%]%(%)%.%^%$%*%?])", "%%%1"))
+end
+
+---Parse git branch -r output to extract remotes containing a branch
+---@param output string git branch -r output
+---@param branch string branch name to find
+---@return string[] remote names
+local function parse_branch_remotes(output, branch)
+	local remotes = {}
+	for line in output:gmatch("[^\n]+") do
+		-- Match: "  origin/branch-name" or "  upstream/feature/foo"
+		-- For branch "feature/foo", we want remote "origin", not "origin/feature"
+		-- The remote name is everything before the LAST occurrence of /branch
+		local trimmed = line:match("^%s*(.-)%s*$")
+		if trimmed then
+			-- Check if line ends with /branch
+			local suffix = "/" .. branch
+			if trimmed:sub(-#suffix) == suffix then
+				local remote = trimmed:sub(1, #trimmed - #suffix)
+				-- Simple remote name (no slashes) - this is what we want
+				-- Remote names with slashes (e.g., "forks/alice") are ambiguous
+				-- and skipped for safety
+				if remote ~= "" and not remote:match("/") then
+					table.insert(remotes, remote)
+				end
+			end
+		end
+	end
+	return remotes
+end
+
+---Find which remotes have the branch
+---@param git_dir string
+---@param branch string
+---@return string[] remote names
+local function find_branch_remotes(git_dir, branch)
+	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git branch -r --list '*/" .. branch .. "'")
+	if code ~= 0 then
+		return {}
+	end
+	return parse_branch_remotes(output, branch)
+end
+
+---Load hook permissions from ~/.local/share/wt/hook-dirs.lua
+---@return table<string, boolean>
+local function load_hook_permissions()
+	local home = os.getenv("HOME")
+	if not home then
+		return {}
+	end
+	local path = home .. "/.local/share/wt/hook-dirs.lua"
+	local f = io.open(path, "r")
+	if not f then
+		return {}
+	end
+	local content = f:read("*a")
+	f:close()
+	local chunk = load("return " .. content, path, "t", {})
+	if not chunk then
+		return {}
+	end
+	local ok, result = pcall(chunk)
+	if ok and type(result) == "table" then
+		return result
+	end
+	return {}
+end
+
+---Save hook permissions to ~/.local/share/wt/hook-dirs.lua
+---@param perms table<string, boolean>
+local function save_hook_permissions(perms)
+	local home = os.getenv("HOME")
+	if not home then
+		return
+	end
+	local dir = home .. "/.local/share/wt"
+	run_cmd_silent("mkdir -p " .. dir)
+	local path = dir .. "/hook-dirs.lua"
+	local f = io.open(path, "w")
+	if not f then
+		return
+	end
+	f:write("{\n")
+	for k, v in pairs(perms) do
+		f:write('\t["' .. k .. '"] = ' .. tostring(v) .. ",\n")
+	end
+	f:write("}\n")
+	f:close()
+end
+
+---Summarize hooks for confirmation prompt
+---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
+---@return string
+local function summarize_hooks(hooks)
+	local parts = {}
+	if hooks.copy and #hooks.copy > 0 then
+		local items = {}
+		for i = 1, math.min(3, #hooks.copy) do
+			table.insert(items, hooks.copy[i])
+		end
+		local suffix = #hooks.copy > 3 and " (+" .. (#hooks.copy - 3) .. " more)" or ""
+		table.insert(parts, "copy: " .. table.concat(items, ", ") .. suffix)
+	end
+	if hooks.symlink and #hooks.symlink > 0 then
+		local items = {}
+		for i = 1, math.min(3, #hooks.symlink) do
+			table.insert(items, hooks.symlink[i])
+		end
+		local suffix = #hooks.symlink > 3 and " (+" .. (#hooks.symlink - 3) .. " more)" or ""
+		table.insert(parts, "symlink: " .. table.concat(items, ", ") .. suffix)
+	end
+	if hooks.run and #hooks.run > 0 then
+		local items = {}
+		for i = 1, math.min(3, #hooks.run) do
+			table.insert(items, hooks.run[i])
+		end
+		local suffix = #hooks.run > 3 and " (+" .. (#hooks.run - 3) .. " more)" or ""
+		table.insert(parts, "run: " .. table.concat(items, ", ") .. suffix)
+	end
+	return table.concat(parts, "; ")
+end
+
+---Check if hooks are allowed for a project, prompting if unknown
+---@param root string project root path
+---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
+---@return boolean allowed
+local function check_hook_permission(root, hooks)
+	local perms = load_hook_permissions()
+	if perms[root] ~= nil then
+		return perms[root]
+	end
+
+	-- Prompt user
+	local summary = summarize_hooks(hooks)
+	local prompt = "Allow hooks for " .. root .. "?\\n" .. summary
+	local allowed = run_cmd_silent("gum confirm " .. "'" .. prompt:gsub("'", "'\\''") .. "'")
+
+	perms[root] = allowed
+	save_hook_permissions(perms)
+	return allowed
+end
+
+---Run hooks from .wt.lua config
+---@param source string source worktree path
+---@param target string target worktree path
+---@param hooks {copy?: string[], symlink?: string[], run?: string[]}
+---@param root string project root path
+local function run_hooks(source, target, hooks, root)
+	-- Check permission before running any hooks
+	if not check_hook_permission(root, hooks) then
+		io.stderr:write("hooks skipped (not allowed for this project)\n")
+		return
+	end
+
+	if hooks.copy then
+		for _, item in ipairs(hooks.copy) do
+			local src = source .. "/" .. item
+			local dst = target .. "/" .. item
+			-- Create parent directory if needed
+			local parent = dst:match("(.+)/[^/]+$")
+			if parent then
+				run_cmd_silent("mkdir -p " .. parent)
+			end
+			local _, code = run_cmd("cp -r " .. src .. " " .. dst)
+			if code ~= 0 then
+				io.stderr:write("warning: failed to copy " .. item .. "\n")
+			end
+		end
+	end
+	if hooks.symlink then
+		for _, item in ipairs(hooks.symlink) do
+			local src = source .. "/" .. item
+			local dst = target .. "/" .. item
+			-- Create parent directory if needed
+			local parent = dst:match("(.+)/[^/]+$")
+			if parent then
+				run_cmd_silent("mkdir -p " .. parent)
+			end
+			local _, code = run_cmd("ln -s " .. src .. " " .. dst)
+			if code ~= 0 then
+				io.stderr:write("warning: failed to symlink " .. item .. "\n")
+			end
+		end
+	end
+	if hooks.run then
+		for _, cmd in ipairs(hooks.run) do
+			local _, code = run_cmd("cd " .. target .. " && " .. cmd)
+			if code ~= 0 then
+				io.stderr:write("warning: hook command failed: " .. cmd .. "\n")
+			end
+		end
+	end
+end
+
+---@param args string[]
+local function cmd_clone(args)
+	-- Parse arguments: <url> [--remote name]... [--own]
+	local url = nil
+	---@type string[]
+	local remote_flags = {}
+	local own = false
+
+	local i = 1
+	while i <= #args do
+		local a = args[i]
+		if a == "--remote" then
+			if not args[i + 1] then
+				die("--remote requires a name")
+			end
+			table.insert(remote_flags, args[i + 1])
+			i = i + 1
+		elseif a == "--own" then
+			own = true
+		elseif not url then
+			url = a
+		else
+			die("unexpected argument: " .. a)
+		end
+		i = i + 1
+	end
+
+	if not url then
+		die("usage: wt c <url> [--remote name]... [--own]")
+		return
+	end
+
+	-- Extract project name from URL
+	local project_name = extract_project_name(url)
+	if not project_name then
+		die("could not extract project name from URL: " .. url)
+		return
+	end
+
+	-- Check if project directory already exists
+	local cwd = get_cwd()
+	if not cwd then
+		die("failed to get current directory", EXIT_SYSTEM_ERROR)
+	end
+	local project_path = cwd .. "/" .. project_name
+	local check = io.open(project_path, "r")
+	if check then
+		check:close()
+		die("directory already exists: " .. project_path)
+	end
+
+	-- Clone bare repo
+	local bare_path = project_path .. "/.bare"
+	local output, code = run_cmd("git clone --bare " .. url .. " " .. bare_path)
+	if code ~= 0 then
+		die("failed to clone: " .. output, EXIT_SYSTEM_ERROR)
+	end
+
+	-- Write .git file pointing to .bare
+	local git_file_handle = io.open(project_path .. "/.git", "w")
+	if not git_file_handle then
+		die("failed to create .git file", EXIT_SYSTEM_ERROR)
+		return
+	end
+	git_file_handle:write("gitdir: ./.bare\n")
+	git_file_handle:close()
+
+	-- Detect default branch
+	local git_dir = bare_path
+	local default_branch = detect_cloned_default_branch(git_dir)
+
+	-- Load global config
+	local global_config = load_global_config()
+
+	-- Determine which remotes to use
+	---@type string[]
+	local selected_remotes = {}
+
+	if #remote_flags > 0 then
+		selected_remotes = remote_flags
+	elseif global_config.default_remotes then
+		if type(global_config.default_remotes) == "table" then
+			selected_remotes = global_config.default_remotes
+		elseif global_config.default_remotes == "prompt" then
+			if global_config.remotes then
+				local keys = {}
+				for k in pairs(global_config.remotes) do
+					table.insert(keys, k)
+				end
+				table.sort(keys)
+				if #keys > 0 then
+					local input = table.concat(keys, "\n")
+					local choose_type = own and "" or " --no-limit"
+					local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
+					output, code = run_cmd(cmd)
+					if code == 0 and output ~= "" then
+						for line in output:gmatch("[^\n]+") do
+							table.insert(selected_remotes, line)
+						end
+					end
+				end
+			end
+		end
+	elseif global_config.remotes then
+		local keys = {}
+		for k in pairs(global_config.remotes) do
+			table.insert(keys, k)
+		end
+		table.sort(keys)
+		if #keys > 0 then
+			local input = table.concat(keys, "\n")
+			local choose_type = own and "" or " --no-limit"
+			local cmd = "echo '" .. input .. "' | gum choose" .. choose_type
+			output, code = run_cmd(cmd)
+			if code == 0 and output ~= "" then
+				for line in output:gmatch("[^\n]+") do
+					table.insert(selected_remotes, line)
+				end
+			end
+		end
+	end
+
+	-- Track configured remotes for summary
+	---@type string[]
+	local configured_remotes = {}
+
+	if own then
+		-- User's own project: origin is their canonical remote
+		if #selected_remotes > 0 then
+			local first_remote = selected_remotes[1]
+			-- Rename origin to first remote
+			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin " .. first_remote)
+			if code ~= 0 then
+				io.stderr:write("warning: failed to rename origin to " .. first_remote .. ": " .. output .. "\n")
+			else
+				-- Configure fetch refspec
+				run_cmd(
+					"GIT_DIR="
+						.. git_dir
+						.. " git config remote."
+						.. first_remote
+						.. ".fetch '+refs/heads/*:refs/remotes/"
+						.. first_remote
+						.. "/*'"
+				)
+				table.insert(configured_remotes, first_remote)
+			end
+
+			-- Add additional remotes and push to them
+			for j = 2, #selected_remotes do
+				local remote_name = selected_remotes[j]
+				local template = global_config.remotes and global_config.remotes[remote_name]
+				if template then
+					local remote_url = resolve_url_template(template, project_name)
+					output, code =
+						run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
+					if code ~= 0 then
+						io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
+					else
+						run_cmd(
+							"GIT_DIR="
+								.. git_dir
+								.. " git config remote."
+								.. remote_name
+								.. ".fetch '+refs/heads/*:refs/remotes/"
+								.. remote_name
+								.. "/*'"
+						)
+						-- Push to additional remotes
+						output, code =
+							run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
+						if code ~= 0 then
+							io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
+						end
+						table.insert(configured_remotes, remote_name)
+					end
+				else
+					io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
+				end
+			end
+		else
+			-- No remotes selected, keep origin as-is
+			run_cmd("GIT_DIR=" .. git_dir .. " git config remote.origin.fetch '+refs/heads/*:refs/remotes/origin/*'")
+			table.insert(configured_remotes, "origin")
+		end
+	else
+		-- Contributing to someone else's project
+		-- Rename origin to upstream
+		output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote rename origin upstream")
+		if code ~= 0 then
+			io.stderr:write("warning: failed to rename origin to upstream: " .. output .. "\n")
+		else
+			run_cmd(
+				"GIT_DIR=" .. git_dir .. " git config remote.upstream.fetch '+refs/heads/*:refs/remotes/upstream/*'"
+			)
+			table.insert(configured_remotes, "upstream")
+		end
+
+		-- Add user's remotes and push to each
+		for _, remote_name in ipairs(selected_remotes) do
+			local template = global_config.remotes and global_config.remotes[remote_name]
+			if template then
+				local remote_url = resolve_url_template(template, project_name)
+				output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. remote_url)
+				if code ~= 0 then
+					io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
+				else
+					run_cmd(
+						"GIT_DIR="
+							.. git_dir
+							.. " git config remote."
+							.. remote_name
+							.. ".fetch '+refs/heads/*:refs/remotes/"
+							.. remote_name
+							.. "/*'"
+					)
+					-- Push to this remote
+					output, code =
+						run_cmd("GIT_DIR=" .. git_dir .. " git push " .. remote_name .. " " .. default_branch)
+					if code ~= 0 then
+						io.stderr:write("warning: failed to push to " .. remote_name .. ": " .. output .. "\n")
+					end
+					table.insert(configured_remotes, remote_name)
+				end
+			else
+				io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
+			end
+		end
+	end
+
+	-- Fetch all remotes
+	run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all")
+
+	-- Load config for path style
+	local style = global_config.branch_path_style or "nested"
+	local separator = global_config.flat_separator
+	local worktree_path = branch_to_path(project_path, default_branch, style, separator)
+
+	-- Create initial worktree
+	output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
+	if code ~= 0 then
+		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
+	end
+
+	-- Print summary
+	print("Created project: " .. project_path)
+	print("Default branch:  " .. default_branch)
+	print("Worktree:        " .. worktree_path)
+	if #configured_remotes > 0 then
+		print("Remotes:         " .. table.concat(configured_remotes, ", "))
+	end
+end
+
+---@param args string[]
+local function cmd_new(args)
+	-- Parse arguments: <project-name> [--remote name]...
+	local project_name = nil
+	---@type string[]
+	local remote_flags = {}
+
+	local i = 1
+	while i <= #args do
+		local a = args[i]
+		if a == "--remote" then
+			if not args[i + 1] then
+				die("--remote requires a name")
+			end
+			table.insert(remote_flags, args[i + 1])
+			i = i + 1
+		elseif not project_name then
+			project_name = a
+		else
+			die("unexpected argument: " .. a)
+		end
+		i = i + 1
+	end
+
+	if not project_name then
+		die("usage: wt n <project-name> [--remote name]...")
+		return
+	end
+
+	-- Check if project directory already exists
+	local cwd = get_cwd()
+	if not cwd then
+		die("failed to get current directory", EXIT_SYSTEM_ERROR)
+	end
+	local project_path = cwd .. "/" .. project_name
+	local check = io.open(project_path, "r")
+	if check then
+		check:close()
+		die("directory already exists: " .. project_path)
+	end
+
+	-- Load global config
+	local global_config = load_global_config()
+
+	-- Determine which remotes to use
+	---@type string[]
+	local selected_remotes = {}
+
+	if #remote_flags > 0 then
+		-- Use explicitly provided remotes
+		selected_remotes = remote_flags
+	elseif global_config.default_remotes then
+		if type(global_config.default_remotes) == "table" then
+			selected_remotes = global_config.default_remotes
+		elseif global_config.default_remotes == "prompt" then
+			-- Prompt with gum choose
+			if global_config.remotes then
+				local keys = {}
+				for k in pairs(global_config.remotes) do
+					table.insert(keys, k)
+				end
+				table.sort(keys)
+				if #keys > 0 then
+					local input = table.concat(keys, "\n")
+					local cmd = "echo '" .. input .. "' | gum choose --no-limit"
+					local output, code = run_cmd(cmd)
+					if code == 0 and output ~= "" then
+						for line in output:gmatch("[^\n]+") do
+							table.insert(selected_remotes, line)
+						end
+					end
+				end
+			end
+		end
+	elseif global_config.remotes then
+		-- No default_remotes configured, prompt if remotes exist
+		local keys = {}
+		for k in pairs(global_config.remotes) do
+			table.insert(keys, k)
+		end
+		table.sort(keys)
+		if #keys > 0 then
+			local input = table.concat(keys, "\n")
+			local cmd = "echo '" .. input .. "' | gum choose --no-limit"
+			local output, code = run_cmd(cmd)
+			if code == 0 and output ~= "" then
+				for line in output:gmatch("[^\n]+") do
+					table.insert(selected_remotes, line)
+				end
+			end
+		end
+	end
+
+	-- Create project structure
+	local bare_path = project_path .. "/.bare"
+	local output, code = run_cmd("mkdir -p " .. bare_path)
+	if code ~= 0 then
+		die("failed to create directory: " .. output, EXIT_SYSTEM_ERROR)
+	end
+
+	output, code = run_cmd("git init --bare " .. bare_path)
+	if code ~= 0 then
+		die("failed to init bare repo: " .. output, EXIT_SYSTEM_ERROR)
+	end
+
+	-- Write .git file pointing to .bare
+	local git_file_handle = io.open(project_path .. "/.git", "w")
+	if not git_file_handle then
+		die("failed to create .git file", EXIT_SYSTEM_ERROR)
+		return
+	end
+	git_file_handle:write("gitdir: ./.bare\n")
+	git_file_handle:close()
+
+	-- Add remotes
+	local git_dir = bare_path
+	for _, remote_name in ipairs(selected_remotes) do
+		local template = global_config.remotes and global_config.remotes[remote_name]
+		if template then
+			local url = resolve_url_template(template, project_name)
+			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git remote add " .. remote_name .. " " .. url)
+			if code ~= 0 then
+				io.stderr:write("warning: failed to add remote '" .. remote_name .. "': " .. output .. "\n")
+			else
+				-- Configure fetch refspec for the remote
+				run_cmd(
+					"GIT_DIR="
+						.. git_dir
+						.. " git config remote."
+						.. remote_name
+						.. ".fetch '+refs/heads/*:refs/remotes/"
+						.. remote_name
+						.. "/*'"
+				)
+			end
+		else
+			io.stderr:write("warning: remote '" .. remote_name .. "' not found in config\n")
+		end
+	end
+
+	-- Detect default branch
+	local default_branch = get_default_branch()
+
+	-- Load config for path style
+	local style = global_config.branch_path_style or "nested"
+	local separator = global_config.flat_separator
+	local worktree_path = branch_to_path(project_path, default_branch, style, separator)
+
+	-- Create orphan worktree
+	output, code =
+		run_cmd("GIT_DIR=" .. git_dir .. " git worktree add --orphan -b " .. default_branch .. " -- " .. worktree_path)
+	if code ~= 0 then
+		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
+	end
+
+	-- Print summary
+	print("Created project: " .. project_path)
+	print("Default branch:  " .. default_branch)
+	print("Worktree:        " .. worktree_path)
+	if #selected_remotes > 0 then
+		print("Remotes:         " .. table.concat(selected_remotes, ", "))
+	end
+end
+
+---@param args string[]
+local function cmd_add(args)
+	-- Parse arguments: <branch> [-b [<start-point>]]
+	---@type string|nil
+	local branch = nil
+	local create_branch = false
+	---@type string|nil
+	local start_point = nil
+
+	local i = 1
+	while i <= #args do
+		local a = args[i]
+		if a == "-b" then
+			create_branch = true
+			-- Check if next arg is start-point (not another flag)
+			if args[i + 1] and not args[i + 1]:match("^%-") then
+				start_point = args[i + 1]
+				i = i + 1
+			end
+		elseif not branch then
+			branch = a
+		else
+			die("unexpected argument: " .. a)
+		end
+		i = i + 1
+	end
+
+	if not branch then
+		die("usage: wt a <branch> [-b [<start-point>]]")
+		return
+	end
+
+	local root, err = find_project_root()
+	if not root then
+		die(err --[[@as string]])
+		return
+	end
+
+	local git_dir = root .. "/.bare"
+	local source_worktree = detect_source_worktree(root)
+
+	-- Load config for path style
+	local global_config = load_global_config()
+	local style = global_config.branch_path_style or "nested"
+	local separator = global_config.flat_separator or "_"
+
+	local target_path = branch_to_path(root, branch, style, separator)
+
+	-- Check if target already exists
+	local check = io.open(target_path .. "/.git", "r")
+	if check then
+		check:close()
+		die("worktree already exists at " .. target_path)
+	end
+
+	local output, code
+	if create_branch then
+		-- Create new branch with worktree
+		if start_point then
+			output, code = run_cmd(
+				"GIT_DIR="
+					.. git_dir
+					.. " git worktree add -b "
+					.. branch
+					.. " -- "
+					.. target_path
+					.. " "
+					.. start_point
+			)
+		else
+			output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -b " .. branch .. " -- " .. target_path)
+		end
+	else
+		-- Check if branch exists locally or on remotes
+		local exists_local = branch_exists_local(git_dir, branch)
+		local remotes = find_branch_remotes(git_dir, branch)
+
+		if not exists_local and #remotes == 0 then
+			die("branch '" .. branch .. "' not found locally or on any remote (use -b to create)")
+		end
+
+		if #remotes > 1 then
+			die("branch '" .. branch .. "' exists on multiple remotes: " .. table.concat(remotes, ", "))
+		end
+
+		output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree add -- " .. target_path .. " " .. branch)
+	end
+
+	if code ~= 0 then
+		die("failed to add worktree: " .. output, EXIT_SYSTEM_ERROR)
+	end
+
+	-- Run hooks if we have a source worktree
+	local project_config = load_project_config(root)
+	if source_worktree then
+		if project_config.hooks then
+			run_hooks(source_worktree, target_path, project_config.hooks, root)
+		end
+	elseif project_config.hooks then
+		io.stderr:write("warning: hooks skipped (run from inside a worktree to apply hooks)\n")
+	end
+
+	print(target_path)
+end
+
+---Check if path_a is inside (or equal to) path_b
+---@param path_a string the path to check
+---@param path_b string the container path
+---@return boolean
+local function path_inside(path_a, path_b)
+	-- Normalize: ensure no trailing slash for comparison
+	path_b = path_b:gsub("/$", "")
+	path_a = path_a:gsub("/$", "")
+	return path_a == path_b or path_a:sub(1, #path_b + 1) == path_b .. "/"
+end
+
+---Check if cwd is inside (or equal to) a given path
+---@param target string
+---@return boolean
+local function cwd_inside_path(target)
+	local cwd = get_cwd()
+	if not cwd then
+		return false
+	end
+	return path_inside(cwd, target)
+end
+
+---Get the bare repo's HEAD branch
+---@param git_dir string
+---@return string|nil branch name, nil on error
+local function get_bare_head(git_dir)
+	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git symbolic-ref --short HEAD")
+	if code ~= 0 then
+		return nil
+	end
+	return (output:gsub("%s+$", ""))
+end
+
+---Parse git worktree list --porcelain output
+---@param output string git worktree list --porcelain output
+---@return table[] array of {path: string, branch?: string, bare?: boolean, detached?: boolean}
+local function parse_worktree_list(output)
+	local worktrees = {}
+	local current = nil
+	for line in output:gmatch("[^\n]+") do
+		local key, value = line:match("^(%S+)%s*(.*)$")
+		if key == "worktree" then
+			if current then
+				table.insert(worktrees, current)
+			end
+			current = { path = value }
+		elseif current then
+			if key == "branch" and value then
+				current.branch = value:gsub("^refs/heads/", "")
+			elseif key == "bare" then
+				current.bare = true
+			elseif key == "detached" then
+				current.detached = true
+			elseif key == "HEAD" then
+				current.head = value
+			end
+		end
+	end
+	if current then
+		table.insert(worktrees, current)
+	end
+	return worktrees
+end
+
+---Check if branch is checked out in any worktree
+---@param git_dir string
+---@param branch string
+---@return string|nil path if checked out, nil otherwise
+local function branch_checked_out_at(git_dir, branch)
+	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
+	if code ~= 0 then
+		return nil
+	end
+	local worktrees = parse_worktree_list(output)
+	for _, wt in ipairs(worktrees) do
+		if wt.branch == branch then
+			return wt.path
+		end
+	end
+	return nil
+end
+
+---@param args string[]
+local function cmd_remove(args)
+	-- Parse arguments: <branch> [-b] [-f]
+	local branch = nil
+	local delete_branch = false
+	local force = false
+
+	for _, a in ipairs(args) do
+		if a == "-b" then
+			delete_branch = true
+		elseif a == "-f" then
+			force = true
+		elseif not branch then
+			branch = a
+		else
+			die("unexpected argument: " .. a)
+		end
+	end
+
+	if not branch then
+		die("usage: wt r <branch> [-b] [-f]")
+		return
+	end
+
+	local root, err = find_project_root()
+	if not root then
+		die(err --[[@as string]])
+		return
+	end
+
+	local git_dir = root .. "/.bare"
+
+	-- Find worktree by querying git for actual location (not computed from config)
+	local wt_output, wt_code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
+	if wt_code ~= 0 then
+		die("failed to list worktrees", EXIT_SYSTEM_ERROR)
+		return
+	end
+
+	local worktrees = parse_worktree_list(wt_output)
+	local target_path = nil
+	for _, wt in ipairs(worktrees) do
+		if wt.branch == branch then
+			target_path = wt.path
+			break
+		end
+	end
+
+	if not target_path then
+		die("no worktree found for branch '" .. branch .. "'")
+		return
+	end
+
+	-- Error if cwd is inside the worktree
+	if cwd_inside_path(target_path) then
+		die("cannot remove worktree while inside it")
+	end
+
+	-- Check for uncommitted changes
+	if not force then
+		local status_out = run_cmd("git -C " .. target_path .. " status --porcelain")
+		if status_out ~= "" then
+			die("worktree has uncommitted changes (use -f to force)")
+		end
+	end
+
+	-- Remove worktree
+	local remove_cmd = "GIT_DIR=" .. git_dir .. " git worktree remove"
+	if force then
+		remove_cmd = remove_cmd .. " --force"
+	end
+	remove_cmd = remove_cmd .. " -- " .. target_path
+
+	local output, code = run_cmd(remove_cmd)
+	if code ~= 0 then
+		die("failed to remove worktree: " .. output, EXIT_SYSTEM_ERROR)
+	end
+
+	-- Delete branch if requested
+	if delete_branch then
+		-- Check if branch is bare repo's HEAD
+		local bare_head = get_bare_head(git_dir)
+		if bare_head and bare_head == branch then
+			io.stderr:write("warning: cannot delete branch '" .. branch .. "' (it's the bare repo's HEAD)\n")
+			print("Worktree removed; branch retained")
+			return
+		end
+
+		-- Check if branch is checked out elsewhere
+		local checked_out = branch_checked_out_at(git_dir, branch)
+		if checked_out then
+			die("cannot delete branch '" .. branch .. "': checked out at " .. checked_out)
+		end
+
+		-- Delete branch
+		local delete_flag = force and "-D" or "-d"
+		local del_output, del_code = run_cmd("GIT_DIR=" .. git_dir .. " git branch " .. delete_flag .. " " .. branch)
+		if del_code ~= 0 then
+			io.stderr:write("warning: failed to delete branch: " .. del_output .. "\n")
+			print("Worktree removed; branch retained")
+			return
+		end
+
+		print("Worktree and branch '" .. branch .. "' removed")
+	else
+		print("Worktree removed")
+	end
+end
+
+local function cmd_list()
+	local root, err = find_project_root()
+	if not root then
+		die(err --[[@as string]])
+		return
+	end
+
+	local git_dir = root .. "/.bare"
+	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git worktree list --porcelain")
+	if code ~= 0 then
+		die("failed to list worktrees: " .. output, EXIT_SYSTEM_ERROR)
+	end
+
+	-- Parse porcelain output into worktree entries
+	---@type {path: string, head: string, branch: string}[]
+	local worktrees = {}
+	local current = {}
+
+	for line in output:gmatch("[^\n]+") do
+		local key, value = line:match("^(%S+)%s*(.*)$")
+		if key == "worktree" and value then
+			if current.path then
+				table.insert(worktrees, current)
+			end
+			-- Skip .bare directory
+			if value:match("/%.bare$") then
+				current = {}
+			else
+				current = { path = value, head = "", branch = "(detached)" }
+			end
+		elseif key == "HEAD" and value then
+			current.head = value:sub(1, 7)
+		elseif key == "branch" and value then
+			current.branch = value:gsub("^refs/heads/", "")
+		elseif key == "bare" then
+			-- Skip bare repo entry
+			current = {}
+		end
+	end
+	if current.path then
+		table.insert(worktrees, current)
+	end
+
+	if #worktrees == 0 then
+		print("No worktrees found")
+		return
+	end
+
+	-- Get current working directory
+	local cwd = get_cwd() or ""
+
+	-- Build table rows with status
+	local rows = {}
+	for _, wt in ipairs(worktrees) do
+		local rel_path = relative_path(cwd, wt.path)
+
+		-- Check dirty status
+		local status_out = run_cmd("git -C " .. wt.path .. " status --porcelain")
+		local status = status_out == "" and "clean" or "dirty"
+
+		table.insert(rows, rel_path .. "," .. wt.branch .. "," .. wt.head .. "," .. status)
+	end
+
+	-- Output via gum table
+	local table_input = "Path,Branch,HEAD,Status\n" .. table.concat(rows, "\n")
+	table_input = table_input:gsub("EOF", "eof")
+	local table_cmd = "gum table --print <<'EOF'\n" .. table_input .. "\nEOF"
+	local table_handle = io.popen(table_cmd, "r")
+	if not table_handle then
+		return
+	end
+	io.write(table_handle:read("*a") or "")
+	table_handle:close()
+end
+
+local function cmd_fetch()
+	local root, err = find_project_root()
+	if not root then
+		die(err --[[@as string]])
+		return
+	end
+
+	local git_dir = root .. "/.bare"
+	local output, code = run_cmd("GIT_DIR=" .. git_dir .. " git fetch --all --prune")
+	io.write(output)
+	if code ~= 0 then
+		os.exit(EXIT_SYSTEM_ERROR)
+	end
+end
+
+---List directory entries (excluding . and ..)
+---@param path string
+---@return string[]
+local function list_dir(path)
+	local entries = {}
+	local handle = io.popen("ls -A " .. path .. " 2>/dev/null")
+	if not handle then
+		return entries
+	end
+	for line in handle:lines() do
+		if line ~= "" then
+			table.insert(entries, line)
+		end
+	end
+	handle:close()
+	return entries
+end
+
+---Check if path is a directory
+---@param path string
+---@return boolean
+local function is_dir(path)
+	local f = io.open(path, "r")
+	if not f then
+		return false
+	end
+	f:close()
+	return run_cmd_silent("test -d " .. path)
+end
+
+---Check if path is a file (not directory)
+---@param path string
+---@return boolean
+local function is_file(path)
+	local f = io.open(path, "r")
+	if not f then
+		return false
+	end
+	f:close()
+	return run_cmd_silent("test -f " .. path)
+end
+
+---@param args string[]
+local function cmd_init(args)
+	-- Parse arguments
+	local dry_run = false
+	local skip_confirm = false
+	for _, a in ipairs(args) do
+		if a == "--dry-run" then
+			dry_run = true
+		elseif a == "-y" or a == "--yes" then
+			skip_confirm = true
+		else
+			die("unexpected argument: " .. a)
+		end
+	end
+
+	local cwd = get_cwd()
+	if not cwd then
+		die("failed to get current directory", EXIT_SYSTEM_ERROR)
+		return
+	end
+
+	-- Case 1: Already wt-managed (.git file pointing to .bare + .bare/ exists)
+	local git_path = cwd .. "/.git"
+	local bare_path = cwd .. "/.bare"
+
+	local bare_exists = is_dir(bare_path)
+	local git_file = io.open(git_path, "r")
+
+	if git_file then
+		local content = git_file:read("*a")
+		git_file:close()
+
+		-- Check if it's a file (not directory) pointing to .bare
+		if is_file(git_path) and content and content:match("gitdir:%s*%.?/?%.bare") then
+			if bare_exists then
+				print("Already using wt bare structure")
+				os.exit(EXIT_SUCCESS)
+			end
+		end
+
+		-- Check if .git is a file pointing elsewhere (inside a worktree)
+		if is_file(git_path) and content and content:match("^gitdir:") then
+			-- It's a worktree, not project root
+			die("inside a worktree; run from project root or use 'wt c' to clone fresh")
+		end
+	end
+
+	-- Check for .git directory
+	local git_dir_exists = is_dir(git_path)
+
+	if not git_dir_exists then
+		-- Case 5: No .git at all, or bare repo without .git dir
+		if bare_exists then
+			die("found .bare/ but no .git; manually create .git file with 'gitdir: ./.bare'")
+		end
+		die("not a git repository (no .git found)")
+	end
+
+	-- Now we have a .git directory
+	-- Case 3: Existing worktree setup (.git/worktrees/ exists)
+	local worktrees_path = git_path .. "/worktrees"
+	if is_dir(worktrees_path) then
+		local worktrees = list_dir(worktrees_path)
+		io.stderr:write("error: repository already uses git worktrees\n")
+		io.stderr:write("\n")
+		io.stderr:write("Converting an existing worktree setup is complex and error-prone.\n")
+		io.stderr:write("Recommend: clone fresh with 'wt c <url>' and recreate worktrees.\n")
+		if #worktrees > 0 then
+			io.stderr:write("\nExisting worktrees:\n")
+			for _, wt in ipairs(worktrees) do
+				io.stderr:write("  " .. wt .. "\n")
+			end
+		end
+		os.exit(EXIT_USER_ERROR)
+	end
+
+	-- Case 4: Normal clone (.git/ directory, no worktrees)
+	-- Check for uncommitted changes
+	local status_out = run_cmd("git status --porcelain")
+	if status_out ~= "" then
+		die("uncommitted changes; commit or stash before converting")
+	end
+
+	-- Detect default branch
+	local default_branch = detect_cloned_default_branch(git_path)
+
+	-- Warnings
+	local warnings = {}
+
+	-- Check for submodules
+	if is_file(cwd .. "/.gitmodules") then
+		table.insert(warnings, "submodules detected (.gitmodules exists); verify they work after conversion")
+	end
+
+	-- Check for nested .git directories (excluding the main one)
+	local nested_git_output, _ = run_cmd("find " .. cwd .. " -mindepth 2 -name .git -type d 2>/dev/null")
+	if nested_git_output ~= "" then
+		table.insert(warnings, "nested .git directories found; these may cause issues")
+	end
+
+	-- Find orphaned files (files in root that will be deleted)
+	local all_entries = list_dir(cwd)
+	local orphaned = {}
+	for _, entry in ipairs(all_entries) do
+		if entry ~= ".git" and entry ~= ".bare" then
+			table.insert(orphaned, entry)
+		end
+	end
+
+	-- Load global config for path style
+	local global_config = load_global_config()
+	local style = global_config.branch_path_style or "nested"
+	local separator = global_config.flat_separator
+	local worktree_path = branch_to_path(cwd, default_branch, style, separator)
+
+	if dry_run then
+		print("Dry run - planned actions:")
+		print("")
+		print("1. Move .git/ to .bare/")
+		print("2. Create .git file pointing to .bare/")
+		print("3. Create worktree: " .. worktree_path .. " (" .. default_branch .. ")")
+		if #orphaned > 0 then
+			print("4. Remove " .. #orphaned .. " orphaned items from root:")
+			for _, item in ipairs(orphaned) do
+				print("   - " .. item)
+			end
+		end
+		if #warnings > 0 then
+			print("")
+			print("Warnings:")
+			for _, w in ipairs(warnings) do
+				print("  ⚠ " .. w)
+			end
+		end
+		os.exit(EXIT_SUCCESS)
+	end
+
+	-- Show warnings
+	for _, w in ipairs(warnings) do
+		io.stderr:write("warning: " .. w .. "\n")
+	end
+
+	-- Confirm with gum (unless -y/--yes)
+	if not skip_confirm then
+		local confirm_msg = "Convert to wt bare structure? This will move .git to .bare"
+		if #orphaned > 0 then
+			confirm_msg = confirm_msg .. " and remove " .. #orphaned .. " items from root"
+		end
+
+		local confirm_code = os.execute("gum confirm '" .. confirm_msg .. "'")
+		if confirm_code ~= true then
+			print("Aborted")
+			os.exit(EXIT_USER_ERROR)
+		end
+	end
+
+	-- Step 1: Move .git to .bare
+	local output, code = run_cmd("mv " .. git_path .. " " .. bare_path)
+	if code ~= 0 then
+		die("failed to move .git to .bare: " .. output, EXIT_SYSTEM_ERROR)
+	end
+
+	-- Step 2: Write .git file
+	local git_file_handle = io.open(git_path, "w")
+	if not git_file_handle then
+		-- Try to recover
+		run_cmd("mv " .. bare_path .. " " .. git_path)
+		die("failed to create .git file", EXIT_SYSTEM_ERROR)
+		return
+	end
+	git_file_handle:write("gitdir: ./.bare\n")
+	git_file_handle:close()
+
+	-- Step 3: Detach HEAD so branch can be checked out in worktree
+	-- Point bare repo's HEAD to a placeholder so main branch can be used by worktree
+	run_cmd("GIT_DIR=" .. bare_path .. " git symbolic-ref HEAD refs/heads/__wt_detached_placeholder__")
+
+	-- Step 4: Create worktree for default branch
+	output, code = run_cmd("GIT_DIR=" .. bare_path .. " git worktree add -- " .. worktree_path .. " " .. default_branch)
+	if code ~= 0 then
+		die("failed to create worktree: " .. output, EXIT_SYSTEM_ERROR)
+	end
+
+	-- Step 5: Remove orphaned files from root
+	for _, item in ipairs(orphaned) do
+		local item_path = cwd .. "/" .. item
+		output, code = run_cmd("rm -rf " .. item_path)
+		if code ~= 0 then
+			io.stderr:write("warning: failed to remove " .. item .. ": " .. output .. "\n")
+		end
+	end
+
+	-- Summary
+	print("Converted to wt bare structure")
+	print("Bare repo:  " .. bare_path)
+	print("Worktree:   " .. worktree_path)
+	if #orphaned > 0 then
+		print("Removed:    " .. #orphaned .. " items from root")
+	end
+end
+
+-- Main entry point
+
+local function main()
+	local command = arg[1]
+
+	if not command or command == "help" or command == "--help" or command == "-h" then
+		print_usage()
+		os.exit(EXIT_SUCCESS)
+	end
+
+	-- Collect remaining args
+	local subargs = {}
+	for i = 2, #arg do
+		table.insert(subargs, arg[i])
+	end
+
+	if command == "c" then
+		cmd_clone(subargs)
+	elseif command == "n" then
+		cmd_new(subargs)
+	elseif command == "a" then
+		cmd_add(subargs)
+	elseif command == "r" then
+		cmd_remove(subargs)
+	elseif command == "l" then
+		cmd_list()
+	elseif command == "f" then
+		cmd_fetch()
+	elseif command == "init" then
+		cmd_init(subargs)
+	else
+		die("unknown command: " .. command)
+	end
+end
+
+-- Export for testing when required as module
+if pcall(debug.getlocal, 4, 1) then
+	return {
+		-- URL/project parsing
+		extract_project_name = extract_project_name,
+		resolve_url_template = resolve_url_template,
+		-- Path manipulation
+		branch_to_path = branch_to_path,
+		split_path = split_path,
+		relative_path = relative_path,
+		path_inside = path_inside,
+		-- Config loading
+		load_global_config = load_global_config,
+		load_project_config = load_project_config,
+		-- Git output parsing (testable without git)
+		parse_branch_remotes = parse_branch_remotes,
+		parse_worktree_list = parse_worktree_list,
+		escape_pattern = escape_pattern,
+		-- Hook helpers
+		summarize_hooks = summarize_hooks,
+		load_hook_permissions = function(home_override)
+			local home = home_override or os.getenv("HOME")
+			if not home then
+				return {}
+			end
+			local path = home .. "/.local/share/wt/hook-dirs.lua"
+			local f = io.open(path, "r")
+			if not f then
+				return {}
+			end
+			local content = f:read("*a")
+			f:close()
+			local chunk = load("return " .. content, path, "t", {})
+			if not chunk then
+				return {}
+			end
+			local ok, result = pcall(chunk)
+			if ok and type(result) == "table" then
+				return result
+			end
+			return {}
+		end,
+		save_hook_permissions = function(perms, home_override)
+			local home = home_override or os.getenv("HOME")
+			if not home then
+				return
+			end
+			local dir = home .. "/.local/share/wt"
+			run_cmd_silent("mkdir -p " .. dir)
+			local path = dir .. "/hook-dirs.lua"
+			local f = io.open(path, "w")
+			if not f then
+				return
+			end
+			f:write("{\n")
+			for k, v in pairs(perms) do
+				f:write('\t["' .. k .. '"] = ' .. tostring(v) .. ",\n")
+			end
+			f:write("}\n")
+			f:close()
+		end,
+		run_hooks = function(source, target, hooks, root, home_override)
+			local home = home_override or os.getenv("HOME")
+			if not home then
+				return
+			end
+			local perm_path = home .. "/.local/share/wt/hook-dirs.lua"
+			local perms = {}
+			local pf = io.open(perm_path, "r")
+			if pf then
+				local content = pf:read("*a")
+				pf:close()
+				local chunk = load("return " .. content, perm_path, "t", {})
+				if chunk then
+					local ok, result = pcall(chunk)
+					if ok and type(result) == "table" then
+						perms = result
+					end
+				end
+			end
+			if perms[root] == false then
+				io.stderr:write("hooks skipped (not allowed for this project)\n")
+				return
+			end
+			if hooks.copy then
+				for _, item in ipairs(hooks.copy) do
+					local src = source .. "/" .. item
+					local dst = target .. "/" .. item
+					local parent = dst:match("(.+)/[^/]+$")
+					if parent then
+						run_cmd_silent("mkdir -p " .. parent)
+					end
+					run_cmd("cp -r " .. src .. " " .. dst)
+				end
+			end
+			if hooks.symlink then
+				for _, item in ipairs(hooks.symlink) do
+					local src = source .. "/" .. item
+					local dst = target .. "/" .. item
+					local parent = dst:match("(.+)/[^/]+$")
+					if parent then
+						run_cmd_silent("mkdir -p " .. parent)
+					end
+					run_cmd("ln -s " .. src .. " " .. dst)
+				end
+			end
+			if hooks.run then
+				for _, cmd in ipairs(hooks.run) do
+					run_cmd("cd " .. target .. " && " .. cmd)
+				end
+			end
+		end,
+		-- Project root detection
+		find_project_root = function(cwd_override)
+			local cwd = cwd_override or get_cwd()
+			if not cwd then
+				return nil, "failed to get current directory"
+			end
+			local path = cwd
+			while path and path ~= "" and path ~= "/" do
+				local bare_check = io.open(path .. "/.bare/HEAD", "r")
+				if bare_check then
+					bare_check:close()
+					return path, nil
+				end
+				local git_file = io.open(path .. "/.git", "r")
+				if git_file then
+					local content = git_file:read("*a")
+					git_file:close()
+					if content and content:match("gitdir:%s*%.?/?%.bare") then
+						return path, nil
+					end
+				end
+				path = path:match("(.+)/[^/]+$")
+			end
+			return nil, "not in a wt-managed repository"
+		end,
+		detect_source_worktree = function(root, cwd_override)
+			local cwd = cwd_override or get_cwd()
+			if not cwd then
+				return nil
+			end
+			if cwd == root then
+				return nil
+			end
+			local git_file = io.open(cwd .. "/.git", "r")
+			if git_file then
+				git_file:close()
+				return cwd
+			end
+			local path = cwd
+			while path and path ~= "" and path ~= "/" and path ~= root do
+				local gf = io.open(path .. "/.git", "r")
+				if gf then
+					gf:close()
+					return path
+				end
+				path = path:match("(.+)/[^/]+$")
+			end
+			return nil
+		end,
+		-- Command execution (for integration tests)
+		run_cmd = run_cmd,
+		run_cmd_silent = run_cmd_silent,
+		-- Exit codes
+		EXIT_SUCCESS = EXIT_SUCCESS,
+		EXIT_USER_ERROR = EXIT_USER_ERROR,
+		EXIT_SYSTEM_ERROR = EXIT_SYSTEM_ERROR,
+	}
+end
+
+main()

stylua.toml 🔗

@@ -0,0 +1,7 @@
+column_width = 120
+line_endings = "Unix"
+indent_type = "Tabs"
+indent_width = 4
+quote_style = "AutoPreferDouble"
+call_parentheses = "Always"
+collapse_simple_statement = "Never"