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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ recording.jfr
# OS X
.DS_Store

# Orchard (generated, local-only)
orchard.code-workspace

# Jekyll / GitHub Pages
docs/_site/
docs/.jekyll-cache/
Expand Down
6 changes: 5 additions & 1 deletion Dockerfile.orchard
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
git \
make \
vim \
rlwrap \
ca-certificates \
gnupg \
unzip \
Expand Down Expand Up @@ -224,8 +225,11 @@ RUN useradd -m -s /bin/bash orchard \
&& mkdir -p /home/orchard/.cache \
&& mkdir -p /home/orchard/.config \
&& mkdir -p /home/orchard/.m2 \
&& mkdir -p /home/orchard/.claude \
&& mkdir -p /repos \
&& cp -r /root/.m2/repository /home/orchard/.m2/repository \
&& chown -R orchard:orchard /home/orchard
&& chown -R orchard:orchard /home/orchard \
&& chown orchard:orchard /repos
USER orchard
WORKDIR /workspace

Expand Down
84 changes: 81 additions & 3 deletions orchard.sh
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,20 @@ if [[ ! -f "$DOCKERFILE" ]]; then
exit 1
fi

# Rebuild if image doesn't exist or Dockerfile is newer than image
# Rebuild if image doesn't exist or Dockerfile is newer than image.
# -nt can't compare a file against a Docker timestamp string, so we extract
# epoch seconds from both sides and compare numerically.
NEEDS_BUILD=false
if ! docker image inspect "$IMAGE_NAME" &>/dev/null 2>&1; then
NEEDS_BUILD=true
elif [[ "$DOCKERFILE" -nt "$(docker image inspect "$IMAGE_NAME" --format '{{.Created}}' 2>/dev/null || echo '2000-01-01')" ]]; then
NEEDS_BUILD=true
else
DOCKERFILE_MTIME=$(stat -f %m "$DOCKERFILE")
IMAGE_CREATED=$(docker image inspect "$IMAGE_NAME" --format '{{.Created}}' 2>/dev/null)
# Strip fractional seconds and timezone suffix (handles both "…Z" and "….nnnZ" forms)
IMAGE_MTIME=$(date -j -f "%Y-%m-%dT%H:%M:%S" "${IMAGE_CREATED%%[.Z]*}" "+%s" 2>/dev/null || echo 0)
if [[ "$DOCKERFILE_MTIME" -gt "$IMAGE_MTIME" ]]; then
NEEDS_BUILD=true
fi
fi

if $NEEDS_BUILD; then
Expand Down Expand Up @@ -130,6 +138,72 @@ if [[ -z "${OPENAI_API_KEY:-}" ]] && [[ -f "${HOME}/.codex/auth.json" ]]; then
info "Mounting Codex auth from ~/.codex/auth.json"
fi

# ── Per-project persistent Claude volume ─────────────────────────────────────
# Each PWD gets its own named volume so ~/.claude (chat history, settings) is
# preserved across container sessions and compartmentalized per project.
# Volume name: orchard-claude-<basename>-<8-char hash of full path>
_vol_suffix=$(printf '%s' "$PROJECT_DIR" | md5 -q | cut -c1-8)
CLAUDE_VOLUME="orchard-claude-$(basename "$PROJECT_DIR")-${_vol_suffix}"
unset _vol_suffix
if ! docker volume inspect "$CLAUDE_VOLUME" &>/dev/null 2>&1; then
docker volume create "$CLAUDE_VOLUME" > /dev/null
# Docker creates new volume mount points as root:root. Fix ownership so the
# orchard user can write into it without needing elevated capabilities.
docker run --rm \
-v "${CLAUDE_VOLUME}:/home/orchard/.claude" \
--user root \
--entrypoint "" \
"$IMAGE_NAME" \
chown orchard:orchard /home/orchard/.claude
info "Created persistent Claude volume: ${CLAUDE_VOLUME}"
else
info "Using existing Claude volume: ${CLAUDE_VOLUME}"
fi

# Extra bind mounts injected by callers (e.g. orchardw.sh).
# ORCHARD_EXTRA_MOUNTS: newline-separated list of host paths (or "host:ignored"
# pairs for backwards compat). Each repo is mounted at /repos/<basename>.
EXTRA_MOUNTS=()
EXTRA_CONTAINER_PATHS=()
if [[ -n "${ORCHARD_EXTRA_MOUNTS:-}" ]]; then
while IFS= read -r _pair; do
[[ -z "$_pair" ]] && continue
_host="${_pair%%:*}"
if [[ -d "$_host" ]]; then
_container="/repos/$(basename "$_host")"
EXTRA_MOUNTS+=(-v "${_host}:${_container}")
EXTRA_CONTAINER_PATHS+=("$_container")
fi
done <<< "$ORCHARD_EXTRA_MOUNTS"
fi

# Generate orchard.code-workspace when extra repos are mounted so VS Code
# opens all roots automatically via "Dev Containers: Attach to Running Container".
WORKSPACE_FILE="${PROJECT_DIR}/orchard.code-workspace"
if [[ ${#EXTRA_CONTAINER_PATHS[@]} -gt 0 ]]; then
{
printf '{\n "folders": [\n { "path": "/workspace" }'
for _cpath in "${EXTRA_CONTAINER_PATHS[@]}"; do
printf ',\n { "path": "%s" }' "$_cpath"
done
printf '\n ]\n}\n'
} > "$WORKSPACE_FILE"
info "Generated orchard.code-workspace with ${#EXTRA_CONTAINER_PATHS[@]} extra repo(s)"
# Keep the generated file out of git
_GITIGNORE="${PROJECT_DIR}/.gitignore"
if [[ -f "$_GITIGNORE" ]] && ! grep -qxF 'orchard.code-workspace' "$_GITIGNORE"; then
echo 'orchard.code-workspace' >> "$_GITIGNORE"
info "Added orchard.code-workspace to .gitignore"
elif [[ ! -f "$_GITIGNORE" ]]; then
echo 'orchard.code-workspace' > "$_GITIGNORE"
fi
unset _GITIGNORE
unset _cpath
else
[[ -f "$WORKSPACE_FILE" ]] && rm -f "$WORKSPACE_FILE"
fi
unset WORKSPACE_FILE

docker run \
--rm \
-it \
Expand All @@ -138,6 +212,8 @@ docker run \
-v "${PROJECT_DIR}:/workspace" \
${CLAUDE_CONFIG_MOUNT[@]+"${CLAUDE_CONFIG_MOUNT[@]}"} \
${CODEX_AUTH_MOUNT[@]+"${CODEX_AUTH_MOUNT[@]}"} \
${EXTRA_MOUNTS[@]+"${EXTRA_MOUNTS[@]}"} \
-v "${CLAUDE_VOLUME}:/home/orchard/.claude" \
${CREDS_ENV_FILE:+--env-file "$CREDS_ENV_FILE"} \
--tmpfs /workspace/tooling/jdk-21.0.7+6:exec,uid=1000,gid=1000 \
--tmpfs /workspace/tooling/openjml:exec,uid=1000,gid=1000 \
Expand All @@ -147,5 +223,7 @@ docker run \
--cap-add DAC_OVERRIDE \
--cap-add FOWNER \
${AGENT_ENV[@]+"${AGENT_ENV[@]}"} \
-e "ORCHARD_PROJECT=$(basename "$PROJECT_DIR")" \
-e 'PROMPT_COMMAND=PS1="(\[\033[1;32m\]\u@\h\[\033[0m\])[\[\033[1;34m\]${ORCHARD_PROJECT}\[\033[0m\]] \w\$ "' \
"$IMAGE_NAME" \
"${@:-bash}"