Skip to content
Open
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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,27 @@ permissions:
issues: read

jobs:
test-deps:
# Unit tests for lib/deps.sh. No network and no clone, so they run on both
# platforms cheaply.
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: true
persist-credentials: false
- name: Install modern Bash (macOS)
if: runner.os == 'macOS'
run: |
brew install bash
echo "$(brew --prefix)/bin" >> "$GITHUB_PATH"
- name: Run deps unit tests
run: ./test/bats/bin/bats test/deps.bats

test-single-repo:
# Run on both Linux and macOS: macOS ships BSD userland (e.g. readlink
# without -f), so it guards against Linux-only assumptions in the scripts.
Expand All @@ -27,6 +48,7 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: true
persist-credentials: false
- name: Install modern Bash (macOS)
if: runner.os == 'macOS'
run: |
Expand All @@ -45,6 +67,7 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: true
persist-credentials: false
- name: Install modern Bash (macOS)
if: runner.os == 'macOS'
run: |
Expand All @@ -63,5 +86,6 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
submodules: true
persist-credentials: false
- name: Run self-repo smoke tests
run: ./test/bats/bin/bats test/self_repo_smoke.bats
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ create / checkout 時に Worktree Context(タスク名、作業ディレクト

実装は `lib/deps.sh` の `detect_deps_types` (検出) と `install_deps` (実行) に集約されている。

### mise 管理ツールチェーンの解決 (`mise exec`)

worktree は別プロセスが作るディレクトリなので、`mise activate` のシェルフックが発火せず **mise 管理のツール (dotnet / node / php 等) が PATH に載らない**。そのまま `dotnet restore` を呼ぶと `command not found` で install が失敗していた。

- worktree に効く mise 設定ファイルが見つかった場合、パッケージマネージャは `mise exec -- <cmd>` 経由で実行する (`run_deps_cmd` の第 1 引数以降にプレフィックスとして差し込む)。見つからない、あるいは `mise` が PATH に無い場合は従来どおり直接実行。
- 実行前に **`mise trust <config>` を実行する**。worktree は新しい絶対パスなのでプロジェクトルートで信頼済みの設定でも untrusted 扱いになり、`mise exec` は非対話環境ではプロンプトを出さず **エラー終了する** ため。`[env]` やテンプレートを含む設定で必須。`.tool-versions` はコード実行要素が無いので trust 対象外。
- **設定探索は `find_mise_configs <dir> [boundary]`** が担当し、`<dir>` から `<boundary>` (= task ディレクトリ) まで遡って探す。マルチリポでは mise.toml が task ディレクトリ側 (プロジェクトルートからの symlink) にあり、install はサブリポ内で走るため。boundary を超えて `/` まで遡らないので、ホーム配下の無関係な設定を拾わない。
- `install_deps <dir> [config-root]` の第 2 引数が boundary。`cmd_create.sh` / `cmd_checkout.sh` は `$task_dir` を渡す。
- **`WORKTREE_NO_MISE=1` で mise 連携全体を無効化**できる (常に PATH から直接実行)。
- セキュリティ上の注意: `worktree checkout <PR URL>` では **PR ブランチ側の mise 設定を trust する**ことになる。`npm install` / `composer install` 自体が既に任意コード実行なので相対的なリスク増は小さいが、信頼できない PR を扱う場合は `WORKTREE_NO_MISE=1` か `--no-install` を使う。

## mise 設定の引き継ぎ

`worktree create` は、ソース側に `mise.toml` / `mise.local.toml` が存在する場合、それらを新しい worktree にコピーする。gitignored なローカル上書き (`mise.local.toml` など) でも、mise のバージョン固定を引き継げる。
Expand Down
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,21 @@ mutually exclusive and resolved with priority `npm > pnpm > yarn`.

Skip with `--no-install`.

### mise-managed Toolchains

If a mise config (`mise.toml`, `mise.local.toml`, `.tool-versions`, ...) applies to the
worktree, the install commands run through `mise exec --`. A worktree is created by a
separate process, so the shell hook that puts mise-managed tools on `PATH` never fires
there and commands such as `dotnet restore` would otherwise fail with
`command not found`.

The config is also passed to `mise trust` first: a worktree is a brand new absolute path,
so a config trusted in the project root is untrusted there, and `mise exec` aborts on an
untrusted config instead of prompting. Note that this trusts the config as checked out —
including on a contributor's branch via `worktree checkout <PR URL>`. Set
`WORKTREE_NO_MISE=1` to skip mise entirely and run the package managers straight from
`PATH`.

## mise Version Inheritance

If `mise.toml` or `mise.local.toml` exists in the source (project root for single-repo, each sub-repo root for multi-repo), `worktree create` copies it into the new worktree. This keeps mise-managed tool versions consistent even when the config is gitignored.
Expand Down
2 changes: 1 addition & 1 deletion lib/cmd_checkout.sh
Original file line number Diff line number Diff line change
Expand Up @@ -362,7 +362,7 @@ cmd_checkout_pr() {
echo ""
log_info "Installing dependencies..."
log_info "${matching_repo_display} (${deps_types_csv}):"
install_deps "$worktree_path" || true
install_deps "$worktree_path" "$task_dir" || true
fi
else
echo ""
Expand Down
2 changes: 1 addition & 1 deletion lib/cmd_create.sh
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ cmd_create() {
deps_types_csv="$(detect_deps_types "$worktree_path" | paste -sd, -)"
if [ -n "$deps_types_csv" ]; then
log_info "${repo} (${deps_types_csv}):"
install_deps "$worktree_path" || true
install_deps "$worktree_path" "$task_dir" || true
fi
done
elif [ "$no_install" = true ]; then
Expand Down
125 changes: 119 additions & 6 deletions lib/deps.sh
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,109 @@ detect_deps_types() {
fi
}

# File names mise recognises as config files, looked up in every candidate
# directory when deciding whether package managers should run through mise.
MISE_CONFIG_NAMES=(
mise.toml
mise.local.toml
.mise.toml
.mise.local.toml
mise/config.toml
.mise/config.toml
.config/mise.toml
.config/mise/config.toml
.tool-versions
)

# True when mise integration is available and not disabled.
# Set WORKTREE_NO_MISE=1 to always run package managers straight from PATH.
mise_enabled() {
if [ "${WORKTREE_NO_MISE:-0}" = "1" ]; then
return 1
fi
command -v mise >/dev/null 2>&1
}

# Emit the mise config files that apply to <dir>, walking up from <dir> to
# <boundary> (inclusive). <boundary> defaults to <dir> and is ignored when it
# is not an ancestor of <dir>; the walk always stops at "/".
# The boundary keeps the search inside the worktree: in a multi-repo layout the
# config often lives in the task directory (symlinked from the project root)
# rather than in the sub-repo that is being installed.
# Usage: find_mise_configs <dir> [boundary]
find_mise_configs() {
local dir boundary
dir="$(cd "$1" 2>/dev/null && pwd)" || return 0

boundary="$dir"
if [ -n "${2:-}" ]; then
local candidate
if candidate="$(cd "$2" 2>/dev/null && pwd)"; then
case "${dir}/" in
"${candidate}"/*) boundary="$candidate" ;;
esac
fi
fi

local current="$dir"
while :; do
local name
for name in "${MISE_CONFIG_NAMES[@]}"; do
if [ -f "${current}/${name}" ]; then
echo "${current}/${name}"
fi
done
if [ "$current" = "$boundary" ] || [ "$current" = "/" ]; then
break
fi
current="$(dirname "$current")"
done

return 0
}

# Mark mise config files as trusted so `mise exec` can parse the ones that may
# execute code (templates, [env], tool options). A worktree is a brand new
# absolute path, so configs trusted in the project root are untrusted here and
# mise would abort instead of prompting in this non-interactive context.
# `.tool-versions` carries no executable content and needs no trust.
# Usage: trust_mise_configs <config-file>...
trust_mise_configs() {
local config
for config in "$@"; do
case "$config" in
*.toml) ;;
*) continue ;;
esac
if ! mise trust "$config" >/dev/null 2>&1; then
log_warn " mise trust failed for ${config} (continuing)"
fi
done
}

# Run a package manager inside <dir>, keeping only the last line of its output.
# The entrypoint runs under `set -o pipefail`, so the reported status is the
# package manager's, not tail's.
# Usage: run_deps_cmd <dir> <command> [args...]
run_deps_cmd() {
local dir="$1"
shift
(cd "$dir" && "$@" 2>&1 | tail -1)
}

# Install dependencies for every detected package manager.
# Returns 0 if at least one install succeeded, 1 otherwise (including no
# managers detected). Callers typically swallow the failure with `|| true`.
# Usage: install_deps <directory>
# When a mise config applies to the worktree, commands run through
# `mise exec --` so mise-managed toolchains (dotnet, node, php, ...) are on
# PATH: the shell hook that normally sets them up never fires for a directory
# created by another process, which otherwise fails with "command not found".
# <config-root> bounds the upward search for mise configs (see
# find_mise_configs); pass the task directory.
# Usage: install_deps <directory> [config-root]
install_deps() {
local dir="$1"
local config_root="${2:-$1}"

if [ ! -d "$dir" ]; then
return 0
Expand All @@ -49,45 +146,61 @@ install_deps() {
return 1
fi

# Resolve the command prefix: `mise exec --` when mise governs this
# worktree, empty otherwise.
local runner=()
if mise_enabled; then
local mise_configs=()
while IFS= read -r line; do
[ -n "$line" ] && mise_configs+=("$line")
done < <(find_mise_configs "$dir" "$config_root")

if [ ${#mise_configs[@]} -gt 0 ]; then
trust_mise_configs "${mise_configs[@]}"
runner=(mise exec --)
log_info " Using mise (${mise_configs[0]})"
fi
fi

local installed=false
local type
for type in "${types[@]}"; do
case "$type" in
npm)
log_info " Running npm install..."
if (cd "$dir" && npm install --no-audit --no-fund 2>&1 | tail -1); then
if run_deps_cmd "$dir" ${runner[@]+"${runner[@]}"} npm install --no-audit --no-fund; then
installed=true
else
log_warn " npm install failed"
fi
;;
pnpm)
log_info " Running pnpm install..."
if (cd "$dir" && pnpm install --frozen-lockfile 2>&1 | tail -1); then
if run_deps_cmd "$dir" ${runner[@]+"${runner[@]}"} pnpm install --frozen-lockfile; then
installed=true
else
log_warn " pnpm install failed"
fi
;;
yarn)
log_info " Running yarn install..."
if (cd "$dir" && yarn install --frozen-lockfile 2>&1 | tail -1); then
if run_deps_cmd "$dir" ${runner[@]+"${runner[@]}"} yarn install --frozen-lockfile; then
installed=true
else
log_warn " yarn install failed"
fi
;;
composer)
log_info " Running composer install..."
if (cd "$dir" && composer install --no-interaction 2>&1 | tail -1); then
if run_deps_cmd "$dir" ${runner[@]+"${runner[@]}"} composer install --no-interaction; then
installed=true
else
log_warn " composer install failed"
fi
;;
dotnet)
log_info " Running dotnet restore..."
if (cd "$dir" && dotnet restore 2>&1 | tail -1); then
if run_deps_cmd "$dir" ${runner[@]+"${runner[@]}"} dotnet restore; then
installed=true
else
log_warn " dotnet restore failed"
Expand Down
5 changes: 5 additions & 0 deletions skills/worktree-checkout/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ worktree checkout https://github.com/owner/repo/pull/123 --no-cd
| `--no-install` | Skip automatic dependency installation |
| `--no-cd` | Do not auto-cd into the new worktree (interactive shells with `worktree shell-init` only) |

> **Note (mise):** When a mise config applies to the new worktree, the dependency
> install runs through `mise exec --` (after `mise trust`) so mise-managed
> toolchains are on `PATH`. This trusts the config as checked out on the PR
> branch; use `WORKTREE_NO_MISE=1` or `--no-install` for untrusted PRs.
>
> **Note (subagents):** In URL mode, `checkout` auto-cds into the new worktree
> only in an interactive shell that has `eval "$(worktree shell-init)"` installed.
> From a subagent's `Bash` call there is no directory change, so `--no-cd` has no
Expand Down
2 changes: 1 addition & 1 deletion skills/worktree-create/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ worktree create $ARGUMENTS
5. Symlinks non-git items into the task directory (multi-repo only)
6. Copies `mise.toml` / `mise.local.toml` into each worktree (inherits mise version pinning even when gitignored)
7. Executes `.worktreerc` `post_create()` hook if present
8. Auto-installs dependencies based on lock files (unless `--no-install`)
8. Auto-installs dependencies based on lock files (unless `--no-install`). When a mise config applies to the worktree it is passed to `mise trust` and the install runs through `mise exec --`, so mise-managed toolchains resolve; `WORKTREE_NO_MISE=1` disables this

## Worktree layout

Expand Down
Loading
Loading