From fd796c05b6478c6d6df8895e23fa5fc1fe9ba051 Mon Sep 17 00:00:00 2001 From: Amolith Date: Sat, 17 Jan 2026 17:43:41 -0700 Subject: [PATCH] feat: implement wt worktree manager A CLI tool for managing git worktrees with a bare repository structure. Commands: - init: Convert existing repo to bare + worktree structure - n/c: Clone repo into bare + worktree structure - a: Add new worktree for branch - r: Remove worktree (optionally delete branch) - l: List worktrees - f: Fetch and prune all remotes Features: - Global config for path styles, remotes, and defaults - Project-level hooks (copy, symlink, run) for new worktrees - Hook permission system with per-project approval - Support for nested and flat branch path styles - Multiple remote management for own/contributing workflows - Dry-run mode for init command Amp-Thread: https://ampcode.com/threads/T-019bcdca-a9c9-73ee-ac7c-8fbb2d10ef02 Amp-Thread: https://ampcode.com/threads/T-019bcde6-c4c6-71fa-a593-9913c2072f27 Amp-Thread: https://ampcode.com/threads/T-019bcdec-7940-723b-9f20-cd5cf8eab286 Amp-Thread: https://ampcode.com/threads/T-019bcde2-434f-722c-8b1d-0d4788f16c22 Amp-Thread: https://ampcode.com/threads/T-019bcdf0-fddd-71e8-b732-250b6adaeaba Amp-Thread: https://ampcode.com/threads/T-019bcdf9-c281-76cc-be06-8762039c75f9 Amp-Thread: https://ampcode.com/threads/T-019bce01-6ba3-74fc-9950-0d54b95adb7a Amp-Thread: https://ampcode.com/threads/T-019bce05-b956-7599-a0be-ebdf73acdcb6 Amp-Thread: https://ampcode.com/threads/T-019bce08-580e-73eb-a55c-63253b7ab599 Amp-Thread: https://ampcode.com/threads/T-019bce1b-11b4-718c-91b8-cd36f34f3aa4 Amp-Thread: https://ampcode.com/threads/T-019bce1f-dbff-7369-8f2b-59d7f6a09d0d Amp-Thread: https://ampcode.com/threads/T-019bce26-10b6-747b-a364-0ab2fb74c1f6 Amp-Thread: https://ampcode.com/threads/T-019bce2b-b03a-74cd-9161-d0775b3df8a3 Amp-Thread: https://ampcode.com/threads/T-019bce2d-1c3c-748d-a19a-4378098c2ef0 Amp-Thread: https://ampcode.com/threads/T-019bce3f-cf1a-74a2-8d59-01966ebdf4c7 Amp-Thread: https://ampcode.com/threads/T-019bcdd2-12ee-70af-b4ab-5cbb7fc9a4d0 Amp-Thread: https://ampcode.com/threads/T-019bce4e-2986-775c-aff9-ad4a71f85ff0 Amp-Thread: https://ampcode.com/threads/T-019bce51-c314-75c5-82c7-aa75414d09cb Amp-Thread: https://ampcode.com/threads/T-019bce65-5a4b-747a-a89c-f41b4b6bf06a Amp-Thread: https://ampcode.com/threads/T-019bce69-0848-702c-8379-12dc822ad659 Amp-Thread: https://ampcode.com/threads/T-019bce79-2cdc-76ed-8608-81587e612f54 Amp-Thread: https://ampcode.com/threads/T-019bce80-071c-71ac-b7f9-39d5507b0352 Amp-Thread: https://ampcode.com/threads/T-019bce8a-cefc-77da-9598-5a7e8a99ed9a Assisted-by: Claude Opus 4.5 via Amp --- .busted | 6 + .luacheckrc | 37 + .luarc.json | 57 ++ AGENTS.md | 158 ++++ Makefile | 20 + README.md | 154 +++ lux.lock | 238 +++++ lux.toml | 19 + spec/cmd_init_spec.lua | 347 +++++++ spec/cmd_remove_spec.lua | 271 ++++++ spec/compat_spec.lua | 58 ++ spec/config_spec.lua | 251 +++++ spec/git_parsing_spec.lua | 377 ++++++++ spec/hooks_spec.lua | 276 ++++++ spec/path_spec.lua | 229 +++++ spec/project_root_spec.lua | 255 +++++ spec/url_parsing_spec.lua | 129 +++ src/main.lua | 1820 ++++++++++++++++++++++++++++++++++++ stylua.toml | 7 + 19 files changed, 4709 insertions(+) create mode 100644 .busted create mode 100644 .luacheckrc create mode 100644 .luarc.json create mode 100644 AGENTS.md create mode 100644 Makefile create mode 100644 README.md create mode 100644 lux.lock create mode 100644 lux.toml create mode 100644 spec/cmd_init_spec.lua create mode 100644 spec/cmd_remove_spec.lua create mode 100644 spec/compat_spec.lua create mode 100644 spec/config_spec.lua create mode 100644 spec/git_parsing_spec.lua create mode 100644 spec/hooks_spec.lua create mode 100644 spec/path_spec.lua create mode 100644 spec/project_root_spec.lua create mode 100644 spec/url_parsing_spec.lua create mode 100644 src/main.lua create mode 100644 stylua.toml 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"