diff --git a/.busted b/.busted new file mode 100644 index 0000000000000000000000000000000000000000..bd11a103d6cc023affde289dd16e241a7d09f941 --- /dev/null +++ b/.busted @@ -0,0 +1,6 @@ +return { + default = { + ROOT = { "spec/" }, + pattern = "_spec%.lua$", + }, +} diff --git a/.luacheckrc b/.luacheckrc new file mode 100644 index 0000000000000000000000000000000000000000..64cc83e18817747640f1ed692cd72ff6f1ab517b --- /dev/null +++ b/.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 +} diff --git a/.luarc.json b/.luarc.json new file mode 100644 index 0000000000000000000000000000000000000000..657650e721c0566f77d1f4c2a06df0b8c9196ed6 --- /dev/null +++ b/.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" + ] + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..b033007fc810d7d8f636bc76d05699b59acdd46a --- /dev/null +++ b/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 `, 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_(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 `/.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= git ` for bare repo operations +- Use `git -C ` 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). diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..5ff4707e130eaf6bab135a5c77452d42060d0ff5 --- /dev/null +++ b/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 diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..aef7a5120b1e158e49b9580a1f91be84067fd35e --- /dev/null +++ b/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 [--remote name]... [--own]` | Clone into bare worktree structure | +| `wt n [--remote name]...` | Initialize fresh project | +| `wt a [-b []]` | Add worktree, optionally create branch | +| `wt r [-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 diff --git a/lux.lock b/lux.lock new file mode 100644 index 0000000000000000000000000000000000000000..0177bd901fc953a07c6e4f96363a00615ac8d3dc --- /dev/null +++ b/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" + ] + } +} \ No newline at end of file diff --git a/lux.toml b/lux.toml new file mode 100644 index 0000000000000000000000000000000000000000..36209a2905f5a6f83e371d4fcb5b4573ea650357 --- /dev/null +++ b/lux.toml @@ -0,0 +1,19 @@ +package = "wt" +version = "0.1.0" +lua = "==5.4" + +[description] +summary = "" +maintainer = "Amolith " +labels = [ "" ] +license = "GPL-3.0-only" + +[dependencies] +# Add your dependencies here +# `busted = ">=2.0"` + +[run] +args = [ "src/main.lua" ] + +[build] +type = "builtin" diff --git a/spec/cmd_init_spec.lua b/spec/cmd_init_spec.lua new file mode 100644 index 0000000000000000000000000000000000000000..065b2ff989bc3ac8cbbad86df712d7d52413e108 --- /dev/null +++ b/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) diff --git a/spec/cmd_remove_spec.lua b/spec/cmd_remove_spec.lua new file mode 100644 index 0000000000000000000000000000000000000000..346bb27fa3dbcdab809a80dcc71d87ad1f0c2289 --- /dev/null +++ b/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) diff --git a/spec/compat_spec.lua b/spec/compat_spec.lua new file mode 100644 index 0000000000000000000000000000000000000000..d0e71671f2330d8f67914571c35faeb97e89ccb7 --- /dev/null +++ b/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) diff --git a/spec/config_spec.lua b/spec/config_spec.lua new file mode 100644 index 0000000000000000000000000000000000000000..8c364e35ed3b455a44a6003e0cc96137a9612c0b --- /dev/null +++ b/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) diff --git a/spec/git_parsing_spec.lua b/spec/git_parsing_spec.lua new file mode 100644 index 0000000000000000000000000000000000000000..b8e061fd7a4f14ef3142d0e1dd6a421f7b5d4fee --- /dev/null +++ b/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) diff --git a/spec/hooks_spec.lua b/spec/hooks_spec.lua new file mode 100644 index 0000000000000000000000000000000000000000..b5fad9fcfef10b51d3f4549eb954d2cb180237f2 --- /dev/null +++ b/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) diff --git a/spec/path_spec.lua b/spec/path_spec.lua new file mode 100644 index 0000000000000000000000000000000000000000..178f2e0fff793535a3c7a8b1a6ef666f903b8aa5 --- /dev/null +++ b/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) diff --git a/spec/project_root_spec.lua b/spec/project_root_spec.lua new file mode 100644 index 0000000000000000000000000000000000000000..dd3c8d71941e8a0e72d891e60af73b4c04c338c3 --- /dev/null +++ b/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) diff --git a/spec/url_parsing_spec.lua b/spec/url_parsing_spec.lua new file mode 100644 index 0000000000000000000000000000000000000000..3745603516792b0f058693066c2c9eecb5ecb452 --- /dev/null +++ b/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) diff --git a/src/main.lua b/src/main.lua new file mode 100644 index 0000000000000000000000000000000000000000..0f5abc6a1f8b4ea560782f166875dfff021dda88 --- /dev/null +++ b/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 [options]") + print("") + print("Commands:") + print(" c [--remote name]... [--own] Clone into bare worktree structure") + print(" n [--remote name]... Initialize fresh project") + print(" a [-b []] Add worktree with optional hooks") + print(" r [-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, 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 /.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 +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 +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: [--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 [--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: [--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 [--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: [-b []] + ---@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 [-b []]") + 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: [-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 [-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 ' 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() diff --git a/stylua.toml b/stylua.toml new file mode 100644 index 0000000000000000000000000000000000000000..4d8c0476b6536f5e4917c532d7c8fb218fa96972 --- /dev/null +++ b/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"