diff --git a/README.md b/README.md index cd66216..31ba728 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ server, port, and database.** [![runtime deps](https://img.shields.io/badge/runtime%20deps-none-2ea44f)](#install) [![license](https://img.shields.io/github/license/VSN2015/worktree-kit?color=blue)](LICENSE) -[Install](#install) · [Setup](#per-repo-setup) · [Config](#config-reference) · [Commands](#daily-use) · [Stack guides](#stack-guide-rails-on-docker-compose) · [Caveats](#caveats) +[Install](#install) · [Setup](#per-repo-setup) · [Config](#config-reference) · [Commands](#daily-use) · [Shell integration](#shell-integration) · [Stack guides](#stack-guide-rails-on-docker-compose) · [Caveats](#caveats) @@ -122,6 +122,10 @@ gracefully. version: 1 # schema version; reserved, not read today runner: compose # compose | host — how commands run (default: compose) +worktrees: + path: "{parent}/{repo}-worktrees/{branch}" # last segment becomes the slug + # trunk: master # optional; what `wt merge` targets + compose: # read only when runner: compose service: app # docker-compose.yml service whose image runs your code workdir: /app # where that image expects the checkout @@ -148,6 +152,8 @@ isolation: db_check: "bundle exec rails runner \"ActiveRecord::Base.connection.execute('SELECT 1 FROM schema_migrations LIMIT 1')\"" db_bootstrap: "SKIP_TEST_DATABASE=1 bundle exec rails db:create db:schema:load" migration_paths: [db/migrate] + db_drop: "SKIP_TEST_DATABASE=1 bundle exec rails db:drop" + redis_flush: "redis-cli -h redis -n {n} flushdb" ``` ### runner @@ -162,6 +168,34 @@ isolation: directory with the isolation env vars exported. `wt server` daemonizes with nohup and tracks a pidfile under `.git/wt-state/`. +### worktrees.path / worktrees.trunk + +- **`path`** (default `{parent}/{repo}-worktrees/{branch}`) — where + `wt new ` creates the worktree. **The last path segment becomes + the slug**, and the slug names the port, the Redis `{n}` slot, and the + `wt_{slug}` database — so keep `{branch}` (or `{branch_raw}`) last. + `{branch}` folds every character outside `[A-Za-z0-9._]` to `_`, + **hyphens included**: `wt new feat/refund-flow` gives directory (and slug) + `feat_refund_flow`, not `feat_refund-flow`. That is on purpose — it makes + the directory name and the slug identical, so what you see in `wt list` is + exactly what names your database. (`.` is the one survivor: branch + `feat.x` gives directory `feat.x` but slug `feat_x`.) `{branch_raw}` keeps + the branch name verbatim, slashes and all — which nests directories and + makes only the final segment the slug, so use it in a middle segment, not + the last one. A + template whose last segment isn't branch-unique (a fixed literal, say) + makes every worktree share one slug, and with it one port, one Redis DB, + and one database; `wt doctor` warns when it detects this. The expanded + path also can't contain a space — `wt`'s compose args and host env lists + are built by word-splitting, so `wt new` refuses such a path and + `wt doctor` warns about it too. `worktrees.path` is the natural key to + override per user in `worktree-kit.local.yml` — everyone shares the + repo's stack config but can keep worktrees under their own preferred + directory. +- **`trunk`** (default: `origin/HEAD`, then `main`, then `master`) — the + branch `wt merge` merges into, and the branch `wt rm` checks a branch is + fully merged against before removing it without `--force`. + ### compose.service / compose.workdir `service` (default `app`) names the service in your `docker-compose.yml` @@ -230,6 +264,27 @@ Three levels, each a superset of the last: checkout. If the worktree has files the primary lacks (i.e. new migrations), `wt server` auto-escalates that worktree to `own_db`, because migrating the shared DB would break every other branch. +- **`db_drop`** (optional) — destroys the per-worktree database. `wt rm` and + `wt merge` run it only when the worktree's database carries a `.dbowned` + marker — i.e. `db_bootstrap` created it, as opposed to `db_check` merely + finding one that already existed, and `wt reset` hasn't cleared that + marker since. Without `db_drop` configured (or when the database was only + adopted), wt prints the database name and leaves it in place. + Two cases leave a database wt *did* create without that marker, and both + are deliberate — wt errs toward keeping data: + **(a)** databases bootstrapped **before wt 0.2.0**, when `.dbowned` did not + exist yet. On your first `wt rm` after upgrading, those report "no + ownership marker" and are left alone. To reclaim one, drop the database by + hand and let the next `--own-db` run rebuild it: only the `db_bootstrap` + path writes `.dbowned`, and it runs only when the database is missing. + `wt reset` alone will not do it — it clears both markers, but the next + run then finds the database still there via `db_check` and re-adopts it, + which is case (b). + **(b)** a database re-adopted after `wt reset`: `db_check` (which the rails + templates configure) only ever writes `.dbready`, so the database that + `db_check` finds on the next run is never re-marked as owned. +- **`redis_flush`** (optional) — empties this worktree's Redis DB `{n}` on + `wt rm` / `wt merge`. Without it, the DB is left as-is. > [!IMPORTANT] > **wt only exports env vars — your app config must read them.** Nothing @@ -391,6 +446,36 @@ Two consequences of how slugs work: - The worktree itself carries no config; running `wt` from the primary checkout also works and is always treated as `shared`. +## Shell integration + +`wt switch`, `wt new`, `wt rm`, and `wt merge` can all change which worktree +you're standing in — but a subprocess can never `cd` its parent shell, so wt +prints the target path on stdout (the "cd channel") and leaves the actual +`cd` to your shell. Wire that up once: + +```sh +eval "$(wt shell-init zsh)" # or: bash, fish +``` + +Add that line to `~/.zshrc` (or `~/.bashrc`, or fish's `config.fish`) and +open a new shell. It defines a `wt` shell function that intercepts `switch`, +`new`, `rm`, and `merge` — running the real binary with +`WT_SHELL_INTEGRATION=1` set (so `wt doctor` can report it as active) and +`cd`-ing to whatever path it printed. Every other subcommand passes straight +through to the real `wt`. + +Without it, those four commands still do everything else they normally do — +they just print the path instead of changing directory: + +```sh +$ wt switch feat/login +/Users/you/code/myapp-worktrees/feat_login +wt: not cd'd — install shell integration: eval "$(wt shell-init zsh)" +``` + +so `cd "$(wt switch feat/login)"` works too, if you'd rather not install the +function. + ## Daily use Every command takes its context from the directory you run it in: `wt` @@ -411,8 +496,14 @@ running. | `wt logs [slug]` | follow a server's logs | | `wt localize ` | snapshot a personal overlay (`--list` / `--remove`) | | `wt reset [slug]` | clear the own-db bootstrap marker | +| `wt new [--from ] [--server]` | create a branch + worktree | +| `wt switch []` | cd to a worktree; no argument opens a picker | +| `wt rm [] [--keep-branch] [--force]` | tear down and remove a worktree | +| `wt merge [] [--into ] [-m ] [--no-remove] [--force]` | squash, rebase, fast-forward trunk, then tear down | +| `wt list [--all]` | list worktrees (branch, slug, status, server, isolation) | | `wt init` | write `worktree-kit.yml` from a stack template | | `wt doctor` | environment + config checks | +| `wt shell-init [bash\|zsh\|fish]` | emit the shell function that makes `switch`/`new`/`rm`/`merge` cd | The isolation flags `--shared` / `--isolated` / `--own-db` work on `run`, `server`, and `up`; the [isolation](#isolation) section above covers what @@ -559,13 +650,115 @@ Clears the marker that records "this worktree's database was bootstrapped", so the next `--own-db` run bootstraps again. It only clears the marker — the `wt_` database itself is never dropped; that's yours. +### wt new — create a branch + worktree + +```sh +wt new [--from ] [--server] +``` + +Creates the branch (or adopts an existing one of the same name) and a +`git worktree add` at `worktrees.path`, runs `hooks.prepare` once, and prints +the new worktree's path on the cd channel (see +[Shell integration](#shell-integration)). `--from ` branches off +`` instead of `HEAD`; `--server` starts the server immediately instead +(it runs `prepare` itself, so plain `wt new` doesn't also run it, to avoid +running the hook twice). + +```sh +wt new feat/login # new branch + worktree off HEAD +wt new feat/login --from origin/main # off a specific base +wt new feat/login --server # + start its server right away +``` + +### wt switch — jump to a worktree + +```sh +wt switch [] +``` + +Prints the worktree path for `` on the cd channel — or, with no +argument, opens an interactive picker (`fzf` if installed, else a numbered +menu) over every worktree, with a preview pane showing recent log and +status. Needs [shell integration](#shell-integration) to actually `cd`; +without it, `wt switch` just prints the path. + +### wt rm — tear down a worktree + +```sh +wt rm [] [--keep-branch] [--force] +``` + +Stops the worktree's server, drops its database via `isolation.db_drop` — +but only when the database carries a `.dbowned` marker, meaning wt itself +bootstrapped it rather than merely adopting one `db_check` found already +there (`wt reset` clears that marker, so an adopted-after-reset database is +left in place with a notice instead) — flushes its Redis slot if +`isolation.redis_flush` is configured, then removes the git worktree and +deletes the branch. Defaults to the branch checked out in the current +worktree. Prompts for confirmation, and refuses when the worktree is dirty +or has commits not yet in the [trunk](#worktreespath--worktreestrunk); +`--force` waives both the safety checks and the confirmation prompt. +`--keep-branch` removes the worktree but leaves the branch. + +> [!WARNING] +> `git worktree remove` deletes the **entire worktree directory**, not just +> git's registration of it — including files git never tracked: `.env`, local +> SQLite databases, uploads, `tmp/`, everything `.gitignore` covers. The +> dirty-worktree refusal does not protect these; it only looks at tracked and +> untracked files, and ignored files are invisible to it. Copy anything you +> want to keep out first. + +### wt merge — squash, rebase, fast-forward, tear down + +```sh +wt merge [] [--into ] [-m ] [--no-remove] [--force] +``` + +Squashes every commit on `` since it diverged from the +[trunk](#worktreespath--worktreestrunk) into one commit, rebases that commit +onto the trunk, fast-forwards the trunk to it in the primary checkout, then +tears the worktree down like **`wt rm --force`** does — `git worktree remove +--force` plus `git branch -D`, and **without prompting**. That deletes the +whole worktree directory, gitignored files included (`.env`, local databases, +uploads, `tmp/`), with no confirmation step in between. A backup ref +(`refs/wt/premerge/`) captures the branch's pre-squash HEAD before +anything is rewritten; a squash failure, a rebase conflict, or a +non-fast-forward trunk all restore the branch to that commit and leave the +worktree in place, with the recovery command printed. `--into ` +overrides `worktrees.trunk` for this run; `-m ` sets the squash commit +message (default: the first commit's subject, remaining subjects as the +body); `--no-remove` merges without tearing down. + +> [!IMPORTANT] +> `wt merge` never prompts — there is nothing for `--force` to skip there. +> Its `--force` only waives the one guard that exists: refusing to squash a +> branch that has an upstream (squashing would rewrite already-published +> history). This is different from `wt rm --force`, which waives both the +> safety refusals *and* the confirmation prompt. Note that the teardown is +> the forceful kind either way — passing `--force` does not make it any more +> destructive, and omitting it does not make it any less. Use `--no-remove` +> if you want the worktree directory kept. + +### wt list — see every worktree + +```sh +wt list [--all] +``` + +A table of every worktree: branch, slug, git status (`clean`/`dirty`), +running server (if any), isolation level, and path. `--all` also lists +branches that have no worktree. + ### wt init / wt doctor — setup and checks `wt init` detects the stack and writes `worktree-kit.yml` (see [Per-repo setup](#per-repo-setup)). `wt doctor` prints the version, primary -and worktree paths with slug and `{n}`, the config and runner in use, which -YAML backend was auto-detected, whether docker is up (compose repos), and -flags stale overlays. Run it after any config change. +and worktree paths with slug and `{n}`, the config and runner in use, the +resolved `worktrees.path` template (warning if its last segment isn't +branch-unique, or if it expands to a path containing a space), which YAML +backend was auto-detected, whether docker is up (compose repos), whether +`fzf` is installed, whether shell integration is active, and flags stale +overlays. Run it after any config change. ## Overlays (`wt localize`) diff --git a/bin/wt b/bin/wt index df7fd48..b19decc 100755 --- a/bin/wt +++ b/bin/wt @@ -13,7 +13,7 @@ # docker compose only for `runner: compose` repos. set -eu -WT_VERSION="0.1.8" +WT_VERSION="0.2.0" die() { echo "wt: $*" >&2; exit 1; } note() { echo "wt: $*" >&2; } @@ -50,16 +50,29 @@ ctx_init() { mkdir -p "$STATE_DIR/logs" } +load_config_soft() { + if [ -f "$CONFIG" ]; then + HAS_CONFIG=1 + load_config + RUNNER="$(cfg_get runner compose)" + SERVICE="$(cfg_get compose.service app)" + WORKDIR="$(cfg_get compose.workdir /app)" + CONTAINER_PORT="$(cfg_get hooks.container_port 3000)" + PREPARE="$(cfg_get hooks.prepare "")" + BUILD="$(cfg_get hooks.build "")" + SERVER_CMD="$(cfg_get hooks.server "")" + else + HAS_CONFIG=0 + RUNNER=""; SERVICE=""; WORKDIR=""; CONTAINER_PORT=3000 + PREPARE=""; BUILD=""; SERVER_CMD="" + fi +} + require_config() { - [ -f "$CONFIG" ] || die "no worktree-kit.yml at $PRIMARY — run 'wt init' in that repo" - load_config - RUNNER="$(cfg_get runner compose)" - SERVICE="$(cfg_get compose.service app)" - WORKDIR="$(cfg_get compose.workdir /app)" - CONTAINER_PORT="$(cfg_get hooks.container_port 3000)" - PREPARE="$(cfg_get hooks.prepare "")" - BUILD="$(cfg_get hooks.build "")" - SERVER_CMD="$(cfg_get hooks.server "")" + load_config_soft + if [ "$HAS_CONFIG" = 0 ]; then + die "no worktree-kit.yml at $PRIMARY — run 'wt init' in that repo" + fi if [ "$RUNNER" = compose ]; then need docker; fi } @@ -265,6 +278,265 @@ expand() { worktree_paths() { git worktree list --porcelain | awk '/^worktree /{print $2}'; } +# ---------- lifecycle ---------- + +TAB="$(printf '\t')" + +resolve_trunk() { # prints the trunk branch name, or empty + _t="" + if [ "${HAS_CONFIG:-0}" = 1 ]; then + _t="$(local_get worktrees.trunk "")" + if [ -z "$_t" ]; then _t="$(cfg_get worktrees.trunk "")"; fi + fi + if [ -z "$_t" ]; then + _t="$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null | sed 's|^origin/||')" || _t="" + fi + if [ -z "$_t" ]; then + if git show-ref --verify --quiet refs/heads/main; then + _t=main + elif git show-ref --verify --quiet refs/heads/master; then + _t=master + fi + fi + printf '%s\n' "$_t" +} + +worktree_for_branch() { # -> path, or empty. Always exits 0. + git worktree list --porcelain | awk -v b="refs/heads/$1" ' + /^worktree /{ p = $2 } + $1 == "branch" && $2 == b { print p; exit } + ' + true +} + +# One TAB-separated row per worktree: branch, slug, status, server, iso, path. +# wt list and the picker both read this, so they cannot drift. +worktree_rows() { + _trunk="$(resolve_trunk)" + git worktree list --porcelain | awk ' + /^worktree / { p = $2 } + /^branch / { b = $2; sub("refs/heads/", "", b); print p "\t" b } + /^detached/ { print p "\t(detached)" } + ' | while IFS="$TAB" read -r _p _b; do + _s="$(slugify "$_p")" + + _st=clean + if [ -n "$(git -C "$_p" status --porcelain 2>/dev/null)" ]; then _st=dirty; fi + if [ -n "$_trunk" ] && git -C "$_p" rev-parse --verify --quiet "$_trunk" >/dev/null 2>&1; then + _c="$(git -C "$_p" rev-list --left-right --count "$_trunk...HEAD" 2>/dev/null || printf '0\t0')" + _behind="$(printf '%s' "$_c" | cut -f1)" + _ahead="$(printf '%s' "$_c" | cut -f2)" + if [ "${_ahead:-0}" -gt 0 ]; then _st="$_st +$_ahead"; fi + if [ "${_behind:-0}" -gt 0 ]; then _st="$_st -$_behind"; fi + fi + + _srv="-"; _iso="-" + if [ "${HAS_CONFIG:-0}" = 1 ]; then + if [ "$RUNNER" = compose ]; then + _srv="$(docker ps --filter "label=wt.project=$PROJECT" --filter "label=wt.slug=$_s" \ + --format '{{.Label "wt.port"}}' 2>/dev/null | head -1)" + _iso="$(docker ps --filter "label=wt.project=$PROJECT" --filter "label=wt.slug=$_s" \ + --format '{{.Label "wt.isolation"}}' 2>/dev/null | head -1)" + elif [ -f "$STATE_DIR/$_s.pid" ]; then + _srv="$(cat "$STATE_DIR/$_s.port" 2>/dev/null || echo '?')" + fi + fi + if [ -n "$_srv" ] && [ "$_srv" != "-" ]; then _srv=":$_srv"; else _srv="-"; fi + if [ -z "$_iso" ]; then _iso="-"; fi + + printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$_b" "$_s" "$_st" "$_srv" "$_iso" "$_p" + done + true +} + +sanitize_branch() { # -> filesystem-safe + printf '%s' "$1" | tr -c 'A-Za-z0-9._' '_' +} + +path_template() { + _t="" + if [ "${HAS_CONFIG:-0}" = 1 ]; then + _t="$(local_get worktrees.path "")" + if [ -z "$_t" ]; then _t="$(cfg_get worktrees.path "")"; fi + fi + if [ -z "$_t" ]; then _t='{parent}/{repo}-worktrees/{branch}'; fi + printf '%s\n' "$_t" +} + +# Escapes a value used as sed REPLACEMENT text, where & means "the whole +# match", \ starts an escape, and | is our delimiter. Unlike expand()'s +# inputs (SLUG, N, PORT, PROJECT — all already sanitized to safe charsets), +# expand_path substitutes raw filesystem and git names: a repo directory +# called "R&D-app" expanded {repo} to "R{repo}D-app", and a branch with a | +# in it (legal in a git ref) broke the sed expression outright. +_esc() { printf '%s' "$1" | sed -e 's/[\\&|]/\\&/g'; } + +# {branch_raw} MUST be substituted before {branch}, or the longer token is +# eaten by the shorter one and leaves a stray "_raw". +expand_path() { #