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
33 changes: 33 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
103 changes: 93 additions & 10 deletions bin/yolo
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 \
Expand Down
121 changes: 121 additions & 0 deletions tests/yolo.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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" {
Expand Down Expand Up @@ -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 <<EOF
YOLO_PODMAN_VOLUMES=("$TEST_REPO")
EOF
run_yolo
assert_success
# Workspace mount (lowercase :z) must still be present
podman_args_contain "$TEST_REPO:$TEST_REPO:z"
# Config-derived duplicate (uppercase :Z) must NOT be added
refute_podman_arg "$TEST_REPO:$TEST_REPO:Z"
# Exactly one -v entry for this host path
local count
count=$(get_podman_args | grep -cE "^${TEST_REPO}:${TEST_REPO}:[zZ]\$")
[ "$count" -eq 1 ]
}

@test "config: volume matching ~/.claude is dropped" {
write_user_config << 'EOF'
YOLO_PODMAN_VOLUMES=("~/.claude")
EOF
run_yolo
assert_success
# Default claude mount (lowercase :z) is present
podman_args_contain "$TEST_HOME/.claude:$TEST_HOME/.claude:z"
# Config-derived duplicate (uppercase :Z) is not
refute_podman_arg "$TEST_HOME/.claude:$TEST_HOME/.claude:Z"
}

@test "dedup: CLI -v overrides config for the same host path" {
write_user_config << 'EOF'
YOLO_PODMAN_VOLUMES=("~/data")
EOF
run_yolo -v "$TEST_HOME/data:/elsewhere:ro" --
assert_success
podman_args_contain "$TEST_HOME/data:/elsewhere:ro"
refute_podman_arg "$TEST_HOME/data:$TEST_HOME/data:Z"
}

@test "dedup: CLI -v on workspace path overrides default workspace mount" {
run_yolo -v "$TEST_REPO:/elsewhere:ro" --
assert_success
podman_args_contain "$TEST_REPO:/elsewhere:ro"
refute_podman_arg "$TEST_REPO:$TEST_REPO:z"
}

@test "dedup: config volume matching worktree original repo is dropped" {
# Build a fake git worktree: $TEST_REPO/.git is a file whose gitdir
# points to an "original" repo elsewhere under BATS_TEST_TMPDIR.
local tmpbase fake_orig
tmpbase=$(realpath "$BATS_TEST_TMPDIR")
fake_orig="$tmpbase/orig-repo"
mkdir -p "$fake_orig/.git/worktrees/test-wt"
rm -rf "$TEST_REPO/.git"
echo "gitdir: $fake_orig/.git/worktrees/test-wt" > "$TEST_REPO/.git"

write_user_config <<EOF
YOLO_PODMAN_VOLUMES=("$fake_orig")
EOF
run_yolo --worktree=bind
assert_success
# Worktree mount (lowercase :z) is present, config :Z is dropped
podman_args_contain "$fake_orig:$fake_orig:z"
refute_podman_arg "$fake_orig:$fake_orig:Z"
# Exactly one -v entry for this host path
local count
count=$(get_podman_args | grep -cE "^${fake_orig}:${fake_orig}:[zZ]\$")
[ "$count" -eq 1 ]
}

@test "config: scalar override — project USE_NVIDIA=0 overrides user USE_NVIDIA=1" {
write_user_config << 'EOF'
USE_NVIDIA=1
Expand Down
Loading