From 7ecbcf66621abcaaa45454471b53de8afa9706d1 Mon Sep 17 00:00:00 2001 From: Yaroslav Halchenko Date: Sun, 17 May 2026 10:08:02 -0400 Subject: [PATCH] Deduplicate the unified mount list before invoking podman MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the same host path appeared both as a default yolo mount (CWD, ~/.claude, ~/.gitconfig, worktree-original-repo) and in a config entry — or in multiple config entries / on the CLI — yolo previously passed two `-v` flags for it, with conflicting SELinux labels (default `:z` vs config-derived `:Z`), and podman refused to start. In my case it was the skills/ folder which I wanted to make available to all sessions but then I wanted to work on it in yolo as well, which caused duplication and yolo did not start. Restructure the mount handling so that, right before composing the `podman run` command, all `-v` sources are gathered into one ordered list and deduplicated by host path (the first colon-delimited segment of the spec). When two entries share a host path, the LATER one in the list wins: 1. Config volumes (YOLO_PODMAN_VOLUMES, lowest) 2. Default yolo mounts (claude home, gitconfig, workspace) 3. Worktree-original-repo (when --worktree=bind / ask-confirmed) 4. CLI -v / --volume (explicit user intent, highest) So: - workspace (CWD) overrides a config entry for the same path — keeping the `:z` (shared/rw) label needed for the directory you're working in - worktree-original beats a config entry for that path - a CLI `-v` overrides config and default mounts (explicit override wins) - user-wide + project configs that list the same path collapse Comparison is exact-string on host paths after `expand_volume`. So: - `~/data` and `$HOME/data` collapse (same expanded string) - `/foo/bar` and `/foo//bar` do not (no canonicalisation) - `~/data` (rw) and `~/data::ro` collapse to the later one when present Implementation changes: - New `dedup_mount_specs` function (sourceable, testable) that walks a flat list of specs and emits the last-occurrence-per-host-path subset - Config volume processing populates a flat `CONFIG_MOUNT_SPECS` array instead of prepending into `PODMAN_ARGS` - Worktree handling populates `WORKTREE_MOUNT_SPECS` (specs only; previously stored as alternating `-v ` pairs) - Right before `podman run`, CLI `-v` / `--volume` (also `-v=X` and `--volume=X`) are extracted from `PODMAN_ARGS` into `CLI_MOUNT_SPECS`; non-mount args stay in `PODMAN_ARGS` - The four sources (config / defaults / worktree / CLI) are concatenated in priority order and passed to `dedup_mount_specs`; the result is expanded back into `-v ` pairs and inserted into `podman run` ## Files - `bin/yolo` — `dedup_mount_specs` helper, unified mount-list assembly and dedup right before `podman run`; CLI `-v`/`--volume` extraction - `tests/yolo.bats` — function-level tests for `dedup_mount_specs` plus end-to-end cases: cross-config dedup, intra-config dedup, CWD vs config, ~/.claude vs config, CLI vs config, CLI vs workspace, and worktree-original-repo vs config - `SPEC.md` — §3 "Volume Mount Handling" now documents the unified dedup, priority order, and CLI extraction; §2 just points at §3 Co-Authored-By: Claude Code 2.1.143 / Claude Opus 4.7 --- SPEC.md | 33 +++++++++++++ bin/yolo | 103 +++++++++++++++++++++++++++++++++++++---- tests/yolo.bats | 121 ++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 247 insertions(+), 10 deletions(-) diff --git a/SPEC.md b/SPEC.md index a31ea27..1748687 100644 --- a/SPEC.md +++ b/SPEC.md @@ -77,6 +77,8 @@ auto-created from the built-in template and a message is printed to stderr. | `YOLO_CLAUDE_ARGS` | `string[]` | Arguments passed to claude | User-wide and project arrays are concatenated (user-wide first). +Cross-source deduplication of mounts (config vs default vs CLI) happens +later, just before `podman run` — see §3 "Volume Mount Handling". #### Scalars (project overrides user-wide; CLI overrides both) @@ -127,6 +129,37 @@ concurrent yolo containers to access the same paths without EACCES errors. The `~/.claude` directory is auto-created if missing. +### Mount Deduplication + +Just before invoking `podman run`, yolo collects every `-v` mount it +would pass — from config (`YOLO_PODMAN_VOLUMES`, after `expand_volume`), +from default mounts (claude home, gitconfig, workspace, worktree-original +when applicable), and from CLI `-v` / `--volume` flags — into a single +ordered list and deduplicates by **host path** (the first colon-delimited +segment of the spec). When two entries share a host path, the LATER one +in this list wins (the earlier one is dropped). + +Priority order, lowest to highest (last wins): + +1. `YOLO_PODMAN_VOLUMES` (config) +2. Default mounts in this order: claude home, gitconfig, workspace, worktree-original +3. CLI `-v` / `--volume` + +This yields: + +- Running yolo from a directory that's also listed in `YOLO_PODMAN_VOLUMES` + keeps the workspace mount (`:z`, shared/rw) and drops the config duplicate + (`:Z`) — avoiding podman's "duplicate mount point" rejection. +- A worktree's original-repo mount overrides a config entry for the same path. +- A CLI `-v` overrides config and default mounts (explicit user intent wins). + +Comparison is exact-string on host paths after `expand_volume` — no +canonicalisation, so `~/data` and `$HOME/data` collapse (both expand to +the same string) but `/foo/bar` and `/foo//bar` do not. + +CLI mount extraction handles `-v X`, `-v=X`, `--volume X`, `--volume=X`. +Non-mount items in `PODMAN_ARGS` pass through unchanged. + --- ## 4. Path Modes diff --git a/bin/yolo b/bin/yolo index f4fe95a..61e4cbf 100755 --- a/bin/yolo +++ b/bin/yolo @@ -172,6 +172,35 @@ expand_volume() { fi } +# Deduplicate a flat list of mount specs by host path (the segment before +# the first colon). When two entries share a host path, the LATER one in +# the input wins; relative order among kept entries is preserved. +# Writes result to the global DEDUPED_MOUNT_SPECS array. +dedup_mount_specs() { + DEDUPED_MOUNT_SPECS=() + local n=$# + local i=1 + while [ "$i" -le "$n" ]; do + local spec="${!i}" + local host="${spec%%:*}" + local j=$((i+1)) + local found_later=0 + while [ "$j" -le "$n" ]; do + local later="${!j}" + local later_host="${later%%:*}" + if [ "$later_host" = "$host" ]; then + found_later=1 + break + fi + j=$((j+1)) + done + if [ "$found_later" -eq 0 ]; then + DEDUPED_MOUNT_SPECS+=("$spec") + fi + i=$((i+1)) + done +} + main() { set -e @@ -190,6 +219,7 @@ USE_CONFIG=1 YOLO_PODMAN_VOLUMES=() YOLO_PODMAN_OPTIONS=() YOLO_CLAUDE_ARGS=() +CONFIG_MOUNT_SPECS=() while [ $# -gt 0 ]; do case "$1" in @@ -321,10 +351,12 @@ if [ "$USE_CONFIG" -eq 1 ]; then YOLO_PODMAN_OPTIONS=("${_USER_OPTIONS[@]}" "${YOLO_PODMAN_OPTIONS[@]}") YOLO_CLAUDE_ARGS=("${_USER_CLAUDE_ARGS[@]}" "${YOLO_CLAUDE_ARGS[@]}") - # Process volumes and expand shorthand syntax + # Expand config volumes into a flat list of specs. + # Cross-source deduplication (config vs default mounts vs CLI) happens + # later, just before composing the podman command. + CONFIG_MOUNT_SPECS=() for vol in "${YOLO_PODMAN_VOLUMES[@]}"; do - expanded=$(expand_volume "$vol") - PODMAN_ARGS=("-v" "$expanded" "${PODMAN_ARGS[@]}") + CONFIG_MOUNT_SPECS+=("$(expand_volume "$vol")") done # Add podman options @@ -343,7 +375,7 @@ CLAUDE_HOME_DIR="$HOME/.claude" mkdir -p "$CLAUDE_HOME_DIR" # Detect if we're in a git worktree and find the original repo -WORKTREE_MOUNTS=() +WORKTREE_MOUNT_SPECS=() gitdir_path="" dot_git="$(pwd)/.git" is_worktree=0 @@ -387,7 +419,7 @@ if [ "$is_worktree" -eq 1 ]; then exit 1 ;; bind) - WORKTREE_MOUNTS+=("-v" "$original_repo_dir:$original_repo_dir:z") + WORKTREE_MOUNT_SPECS+=("$original_repo_dir:$original_repo_dir:z") ;; skip) # Do nothing - skip bind mount @@ -398,7 +430,7 @@ if [ "$is_worktree" -eq 1 ]; then read -p "Bind mount original repository? [y/N] " -n 1 -r >&2 echo >&2 if [[ $REPLY =~ ^[Yy]$ ]]; then - WORKTREE_MOUNTS+=("-v" "$original_repo_dir:$original_repo_dir:z") + WORKTREE_MOUNT_SPECS+=("$original_repo_dir:$original_repo_dir:z") fi ;; esac @@ -452,13 +484,64 @@ if [ "$USE_NVIDIA" -eq 1 ]; then NVIDIA_ARGS+=(--security-opt "label=disable") fi +# Extract -v / --volume entries from PODMAN_ARGS into CLI_MOUNT_SPECS so +# they can participate in deduplication alongside config and default +# mounts. Remaining (non-mount) options stay in PODMAN_ARGS. +CLI_MOUNT_SPECS=() +_NEW_PODMAN_ARGS=() +_i=0 +while [ "$_i" -lt "${#PODMAN_ARGS[@]}" ]; do + case "${PODMAN_ARGS[$_i]}" in + -v|--volume) + if [ $((_i+1)) -lt "${#PODMAN_ARGS[@]}" ]; then + CLI_MOUNT_SPECS+=("${PODMAN_ARGS[$((_i+1))]}") + _i=$((_i+2)) + else + # Trailing -v with no value — let podman complain about it + _NEW_PODMAN_ARGS+=("${PODMAN_ARGS[$_i]}") + _i=$((_i+1)) + fi + ;; + -v=*|--volume=*) + CLI_MOUNT_SPECS+=("${PODMAN_ARGS[$_i]#*=}") + _i=$((_i+1)) + ;; + *) + _NEW_PODMAN_ARGS+=("${PODMAN_ARGS[$_i]}") + _i=$((_i+1)) + ;; + esac +done +PODMAN_ARGS=("${_NEW_PODMAN_ARGS[@]}") + +# Compose the unified mount list, ordered lowest-priority to highest. +# dedup_mount_specs keeps the LAST occurrence per host path, so entries +# later in this list override earlier ones with the same host path: +# 1. Config volumes (lowest) — from YOLO_PODMAN_VOLUMES +# 2. yolo default mounts — claude home, gitconfig, workspace +# 3. Worktree-original-repo mount — when applicable +# 4. CLI -v / --volume (highest) — explicit user intent on the command line +# This means: workspace (CWD) overrides a config entry for the same path +# (keeping the rw, shared :z label), worktree-original beats a config entry +# for the same path, and any CLI -v overrides everything. +ALL_MOUNT_SPECS=() +ALL_MOUNT_SPECS+=("${CONFIG_MOUNT_SPECS[@]}") +ALL_MOUNT_SPECS+=("$CLAUDE_MOUNT") +ALL_MOUNT_SPECS+=("$HOME/.gitconfig:/tmp/.gitconfig:ro,z") +ALL_MOUNT_SPECS+=("$WORKSPACE_MOUNT") +ALL_MOUNT_SPECS+=("${WORKTREE_MOUNT_SPECS[@]}") +ALL_MOUNT_SPECS+=("${CLI_MOUNT_SPECS[@]}") +dedup_mount_specs "${ALL_MOUNT_SPECS[@]}" + +VOLUME_ARGS=() +for spec in "${DEDUPED_MOUNT_SPECS[@]}"; do + VOLUME_ARGS+=("-v" "$spec") +done + podman run --log-driver=none -it --rm \ --userns=keep-id:uid=1000,gid=1000 \ --name="$name" \ - -v "$CLAUDE_MOUNT" \ - -v "$HOME/.gitconfig:/tmp/.gitconfig:ro,z" \ - -v "$WORKSPACE_MOUNT" \ - "${WORKTREE_MOUNTS[@]}" \ + "${VOLUME_ARGS[@]}" \ -w "$WORKSPACE_DIR" \ -e CLAUDE_CONFIG_DIR="$CLAUDE_DIR" \ -e GIT_CONFIG_GLOBAL=/tmp/.gitconfig \ diff --git a/tests/yolo.bats b/tests/yolo.bats index b95964b..41da5e9 100644 --- a/tests/yolo.bats +++ b/tests/yolo.bats @@ -32,6 +32,30 @@ load 'test_helper/common' assert_output "/host:/container:Z" } +# ── dedup_mount_specs (function-level) ──────────────────────────── + +@test "dedup_mount_specs: keeps last occurrence per host path" { + load_yolo_functions + dedup_mount_specs "/a:/a:Z" "/b:/b:Z" "/a:/a:z" + [ "${#DEDUPED_MOUNT_SPECS[@]}" -eq 2 ] + [ "${DEDUPED_MOUNT_SPECS[0]}" = "/b:/b:Z" ] + [ "${DEDUPED_MOUNT_SPECS[1]}" = "/a:/a:z" ] +} + +@test "dedup_mount_specs: no duplicates preserves order and count" { + load_yolo_functions + dedup_mount_specs "/a:/a:z" "/b:/b:z" "/c:/c:z" + [ "${#DEDUPED_MOUNT_SPECS[@]}" -eq 3 ] + [ "${DEDUPED_MOUNT_SPECS[0]}" = "/a:/a:z" ] + [ "${DEDUPED_MOUNT_SPECS[2]}" = "/c:/c:z" ] +} + +@test "dedup_mount_specs: empty input gives empty output" { + load_yolo_functions + dedup_mount_specs + [ "${#DEDUPED_MOUNT_SPECS[@]}" -eq 0 ] +} + # ── CLI flags (end-to-end with mock podman) ─────────────────────── @test "--help: prints usage and exits 0" { @@ -135,6 +159,103 @@ EOF podman_args_contain "$TEST_HOME/data:$TEST_HOME/data:Z" } +@test "config: duplicate volumes across user + project are deduplicated" { + write_user_config << 'EOF' +YOLO_PODMAN_VOLUMES=("~/data") +EOF + write_project_config << 'EOF' +YOLO_PODMAN_VOLUMES=("~/data") +EOF + run_yolo + assert_success + local expanded="$TEST_HOME/data:$TEST_HOME/data:Z" + local count + count=$(get_podman_args | grep -cFx -- "$expanded") + [ "$count" -eq 1 ] +} + +@test "config: duplicate volumes within a single config are deduplicated" { + write_project_config << 'EOF' +YOLO_PODMAN_VOLUMES=("~/data" "~/data") +EOF + run_yolo + assert_success + local expanded="$TEST_HOME/data:$TEST_HOME/data:Z" + local count + count=$(get_podman_args | grep -cFx -- "$expanded") + [ "$count" -eq 1 ] +} + +@test "config: volume matching CWD is dropped (workspace mount wins)" { + # Use unquoted heredoc so $TEST_REPO interpolates + write_user_config < "$TEST_REPO/.git" + + write_user_config <