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 <