Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 14 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,20 @@ extras Optional workflow, deployment, and support add-ons
8. Run `./scripts/docker-compose.sh up -d postgres redis`.
9. Run `./scripts/dev.sh`.

The port helper prints `WEB_URL` first, followed by `WEB_PORT` and the rest of
the assigned worktree ports, so coding orchestrators that scan startup output
for a URL open the web surface first.
The port helper's `env` command is the print-only URL/port mode. It prints
`WEB_URL` first, followed by `WEB_PORT` and the rest of the assigned worktree
ports, so coding orchestrators that scan startup output for a URL open the web
surface first.

If a stale same-worktree dev process is still holding the web port, rerun with:

```bash
./scripts/dev.sh --reclaim-ports
```

Port reclaim is opt-in and intentionally narrow: the script checks the listener
with `lsof` and refuses to kill it unless the process chain looks like this
worktree's own JS dev server.

## Worktree And Docker Hygiene

Expand Down
63 changes: 61 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ Local development follows the pattern used across existing projects:
./scripts/check-all.sh
```

`worktree-ports.sh env` prints `WEB_URL` first, then `WEB_PORT`, then the
remaining assigned ports and derived connection strings. Keep that order when
`worktree-ports.sh env` is the print-only URL/port mode. It prints `WEB_URL`
first, then `WEB_PORT`, then the remaining assigned ports and derived
connection strings, and exits without starting services. Keep that order when
adapting the helper so coding workspace tools discover the web surface before
API or infrastructure URLs.

Expand All @@ -38,6 +39,64 @@ worker health, database, cache, and OTEL example ports. When only one public por
is present, the helper assigns it to the selected primary target and keeps other
ports on the normal deterministic worktree allocation.

## Optional Port Reclaim

Dev scripts may offer opt-in port reclaim so a developer can rerun the same
worktree script after a stale dev process is left behind:

```bash
./scripts/dev.sh --reclaim-ports
DEVKIT_RECLAIM_PORTS=1 ./scripts/dev.sh
```

Reclaiming must stay conservative. Before killing a process, inspect the port
owner with `lsof`, verify the process cwd or parent process cwd is under the
current worktree, and verify the command looks like the same service type the
script is about to start. Refuse to kill unrelated listeners and print the pid,
cwd, and command so the developer can decide manually.

The root `scripts/dev.sh` includes a small shell example for single host-run
web-dev processes. It walks a short parent chain from the process listening on
`WEB_PORT` and only sends `SIGTERM` when it finds a same-worktree JS dev command
such as Next, Vite, Astro, webpack, rsbuild, Bun, or pnpm. The shell helper is
generic enough for adapted repos to add other host-run app processes:

```sh
# Example only: add a service-specific signature before enabling this.
reclaim_service_port api "$API_PORT"
reclaim_service_port worker-health "$WORKER_HEALTH_PORT"
```

Each extra service needs its own command matcher, such as a Uvicorn app import
for an API, a queue-worker binary name, or a bot executable. Do not treat all
same-worktree processes as reclaimable; a repo can run unrelated listeners from
the same checkout.

Avoid reclaiming Docker-owned infrastructure ports from the host dev script.
For Postgres, Redis, MinIO, and similar Compose services, use stable
`COMPOSE_PROJECT_NAME` with `./scripts/docker-compose.sh up` / `down` so
same-worktree runs are idempotent. If an infrastructure port is held by
something else, report the owner and ask the developer to stop it or change the
configured port.

For multi-service repos, prefer a small service-aware mux helper instead of
copying generic shell globs. A good pattern is:

```text
scripts/dev.sh web
-> scripts/dev_mux.py --ensure-port web
-> lsof owner pids for the web URL port
-> walk parent/child process tree
-> require same worktree path scope
-> require service signature, such as the uvicorn app import or discord-bot
-> stop the related service process tree
-> verify the port is free before starting
```

This avoids killing a different service that happens to run from the same repo,
and avoids prefix-path mistakes such as treating `/tmp/app/foo-bar` as being
inside `/tmp/app/foo`.

## Worktree Includes

Use `.worktreeinclude` to allowlist ignored local files that should be copied into new sibling worktrees. Treat entries as gitignore-style path patterns, not shell globs passed directly to `cp`.
Expand Down
182 changes: 182 additions & 0 deletions scripts/dev.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,45 @@
set -eu

cd "$(dirname "$0")/.."
WORKTREE_ROOT="$(pwd -P)"

usage() {
cat >&2 <<'EOF'
usage: dev.sh [--reclaim-ports]

Options:
--reclaim-ports Before starting, stop this worktree's own JS dev process
if it is still listening on WEB_PORT. Adapted repos can
call reclaim_service_port for other host-run app services.
--no-reclaim-ports
Disable reclaiming even when DEVKIT_RECLAIM_PORTS=1.
--help Show this help.

Environment:
DEVKIT_RECLAIM_PORTS=1 Same as --reclaim-ports.
EOF
}

RECLAIM_PORTS="${DEVKIT_RECLAIM_PORTS:-0}"
while [ "$#" -gt 0 ]; do
case "$1" in
--reclaim-ports)
RECLAIM_PORTS=1
;;
--no-reclaim-ports)
RECLAIM_PORTS=0
;;
--help|-h)
usage
exit 0
;;
*)
usage
exit 2
;;
esac
shift
done

eval "$(./scripts/worktree-ports.sh export)"
export WEB_HOST="${WEB_HOST:-127.0.0.1}"
Expand Down Expand Up @@ -50,6 +89,149 @@ detect_js_runner() {

JS_RUNNER="$(detect_js_runner)"

port_listener_pids() {
port="$1"
if ! command -v lsof >/dev/null 2>&1; then
echo "dev.sh --reclaim-ports requires lsof to inspect port owners." >&2
return 1
fi
lsof -nP -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null | sort -u
}

process_cwd() {
pid="$1"
lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | sed -n '1p'
}

process_command() {
pid="$1"
ps -p "$pid" -o command= 2>/dev/null || true
}

process_parent_pid() {
pid="$1"
ps -p "$pid" -o ppid= 2>/dev/null | tr -d ' ' || true
}

is_inside_worktree() {
path="$1"
case "$path" in
"$WORKTREE_ROOT"|"$WORKTREE_ROOT"/*) return 0 ;;
*) return 1 ;;
esac
}

is_expected_service_command() {
service_name="$1"
command_line="$2"

case "$service_name" in
web)
is_expected_web_dev_command "$command_line"
;;
*)
return 1
;;
esac
}

is_expected_web_dev_command() {
command_line="$1"
case "$command_line" in
next\ dev*|*" next dev"*|*next-server*|\
vite\ *|*" vite "*|\
astro\ dev*|*" astro dev"*|\
remix\ vite:dev*|*" remix vite:dev"*|\
webpack\ serve*|*" webpack serve"*|\
rspack\ serve*|*" rspack serve"*|\
rsbuild\ dev*|*" rsbuild dev"*|\
parcel\ serve*|*" parcel serve"*|\
tanstack\ start*|*" tanstack start"*|\
tsc\ --noEmit\ --watch*|*" tsc --noEmit --watch"*|\
bun\ run*|*" bun run"*|\
pnpm\ *|*" pnpm "*)
return 0
;;
*)
return 1
;;
esac
}

is_expected_service_process() {
service_name="$1"
pid="$2"
depth=0

while [ -n "$pid" ] && [ "$pid" -gt 1 ] 2>/dev/null && [ "$depth" -lt 8 ]; do
command_line="$(process_command "$pid")"
cwd="$(process_cwd "$pid")"

if [ -n "$cwd" ] && is_inside_worktree "$cwd" && is_expected_service_command "$service_name" "$command_line"; then
return 0
fi

pid="$(process_parent_pid "$pid")"
depth=$((depth + 1))
done

return 1
}

wait_for_port_release() {
port="$1"
attempts=0
while [ "$attempts" -lt 20 ]; do
if [ -z "$(port_listener_pids "$port")" ]; then
return 0
fi
attempts=$((attempts + 1))
sleep 0.1
done
return 1
}

reclaim_service_port() {
service_name="$1"
port="$2"
pids="$(port_listener_pids "$port")"
if [ -z "$pids" ]; then
return 0
fi

reclaim_pids=""
for pid in $pids; do
if ! is_expected_service_process "$service_name" "$pid"; then
command_line="$(process_command "$pid")"
cwd="$(process_cwd "$pid")"
echo "Refusing to reclaim ${service_name} port ${port}; pid ${pid} does not look like this worktree's ${service_name} process." >&2
echo " cwd: ${cwd:-unknown}" >&2
echo " cmd: ${command_line:-unknown}" >&2
return 1
fi

reclaim_pids="${reclaim_pids}${pid} "
done

for pid in $reclaim_pids; do
echo "Reclaiming ${service_name} port ${port} from pid ${pid}"
kill "$pid" 2>/dev/null || true
done

if wait_for_port_release "$port"; then
return 0
fi

echo "${service_name} port ${port} is still in use after SIGTERM; refusing to force-kill it." >&2
return 1
}

if [ "$RECLAIM_PORTS" = "1" ]; then
if ! reclaim_service_port web "$WEB_PORT"; then
echo "Continuing because the root dev script does not bind WEB_PORT." >&2
fi
fi
Comment thread
michaelmwu marked this conversation as resolved.

cleanup() {
if [ -n "${WEB_PID:-}" ]; then kill "$WEB_PID" 2>/dev/null || true; fi
}
Expand Down