diff --git a/AGENTS.md b/AGENTS.md index a5c3e9c..13c3ac3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,7 +27,7 @@ devcontainer features test . | Feature | Ver | Description | | ------- | --- | ----------- | -| `helpers4-common` | 1.2.0 | Bootstrap: `common.sh` (user detection, apt helpers, cloud-env detection) + automatic git-config self-heal on every attach — all features depend on this | +| `helpers4-common` | 1.2.1 | Bootstrap: `common.sh` (user detection, apt helpers, cloud-env detection) + automatic git-config self-heal on every attach — all features depend on this | | `essential-dev` | 1.2.4 | Git visualization, editor enhancements, Markdown | | `github-dev` | 1.0.6 | gh CLI, Copilot Chat, PR/Issues/Actions extensions | | `copilot-dev` | 1.0.4 | Copilot Chat + AI instructions (commits, PRs, code review) | diff --git a/src/helpers4-common/README.md b/src/helpers4-common/README.md index 7bf8d7f..3c72038 100644 --- a/src/helpers4-common/README.md +++ b/src/helpers4-common/README.md @@ -41,10 +41,10 @@ that only ever existed on the host. stale as installed tools move around): - `credential.helper` (including per-URL scopes), `gpg.program`, `gpg.ssh.program`, - `core.editor` — when the value shells out to an absolute path that doesn't resolve here, it's - rewritten to the bare command name once a same-named binary is found on `$PATH`. Bare, not a - freshly-resolved absolute path again: it never goes stale a second time even if the tool moves - on a future rebuild. + `core.editor`, `core.sshCommand` — when the value shells out to an absolute path that doesn't + resolve here, it's rewritten to the bare command name once a same-named binary is found on + `$PATH`. Bare, not a freshly-resolved absolute path again: it never goes stale a second time + even if the tool moves on a future rebuild. - `user.signingkey` (only when `gpg.format=ssh`) — if the file is missing, tries a same-basename file under `~/.ssh`/`~/.gnupg` first (covers a case like `dotfiles-sync` having already placed the real file under a different absolute path than the host's), then falls back to recovering @@ -54,6 +54,13 @@ stale as installed tools move around): Codespaces secrets and notes that Codespaces signs GPG-format commits natively via its own managed proxy, as an alternative. +**What it can only flag, never fix**: `core.hooksPath`, `core.excludesfile`, +`core.attributesfile`, and `include.path`/`includeIf.*.path` all point at a file or directory +that only ever existed on the host, with no `$PATH` search or forwarded-agent equivalent to fall +back to — a warning names the gap, nothing more. `git config --file` also never follows +includes, so an included file's own contents (if it even exists) stay invisible to every check +above; this can only confirm whether the included file itself is present. + Anything it can't fix itself is a warning, never a failure — it never blocks the attach. ## Usage (for feature authors) @@ -93,6 +100,12 @@ every dependent feature picks it up on its next install. ## Version History +- **v1.2.1**: The git-config self-heal now also warns (never fixes) about `core.hooksPath`, + `core.excludesfile`, `core.attributesfile`, and `include.path`/`includeIf.*.path` pointing at + a file or directory missing in this container — none of those have a `$PATH` search or + forwarded-agent equivalent to recover from, so this only names the gap. Fixed the + `core.editor`/`core.sshCommand` listing above, which had drifted from the actual shell-out key + list since `core.sshCommand` was added. - **v1.2.0**: Added `h4_ensure_volume_writable`, extracted from four features (`pnpm-store`, `playwright-dev`, `claude-dev`, `mistral-dev`) that each carried their own copy of the same named-volume ownership logic — including the subtler `--shared` case diff --git a/src/helpers4-common/devcontainer-feature.json b/src/helpers4-common/devcontainer-feature.json index 46df951..93d70ff 100644 --- a/src/helpers4-common/devcontainer-feature.json +++ b/src/helpers4-common/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "helpers4-common", - "version": "1.2.0", + "version": "1.2.1", "name": "helpers4 Common Library", "description": "Shared bootstrap for helpers4 features: the /usr/local/share/helpers4/common.sh library (user detection, home resolution, apt helpers via h4_ensure_packages), plus an automatic git-config self-heal on every attach that repairs host-specific paths a client's automatic ~/.gitconfig copy or SSH agent forwarding leaves broken (stale credential.helper/gpg.program paths, missing SSH-format signing keys).", "documentationURL": "https://github.com/helpers4/devcontainer/tree/main/src/helpers4-common", diff --git a/src/helpers4-common/git-config-self-heal.sh b/src/helpers4-common/git-config-self-heal.sh index b6bd06b..5368a1d 100644 --- a/src/helpers4-common/git-config-self-heal.sh +++ b/src/helpers4-common/git-config-self-heal.sh @@ -13,7 +13,9 @@ # `gh` at a revision-pinned path, or a gpg.format=ssh signingkey pointing at # a public key file that only ever existed on the host. This repairs both # classes of breakage, generically — no per-tool/per-feature knowledge baked -# in here, so it doesn't go stale as installed tools move around. +# in here, so it doesn't go stale as installed tools move around. A third +# class (core.hooksPath, core.excludesfile, core.attributesfile, include +# paths) can only be flagged, never fixed — see that section below for why. # # Best-effort and idempotent: safe to run on every attach, never fails the # attach, only ever warns when it can't fix something itself. @@ -170,6 +172,43 @@ if [ "${GPG_FORMAT}" = "ssh" ]; then fi fi +# ── Host-bound paths: flagged, never fixed ───────────────────────────────────── +# core.hooksPath, core.excludesfile, core.attributesfile, and include.path / +# includeIf.*.path all point at a file or directory that only ever existed on +# the host. Unlike a shell-out key (PATH-searchable) or an SSH signing key +# (derivable from the forwarded agent), there's nothing to search for or +# recover here — a missing hooks directory or gitignore file has no +# in-container equivalent to fall back to, so this can only name the gap. +# +# `git config --file` also never follows includes — an included file's own +# contents (if it even exists) are invisible to every _get() call in this +# script, above and below this point. This section can only confirm whether +# the included file itself is present, not inspect what it sets. +_warn_missing_path() { + local _key="$1" _path="$2" _kind="$3" + _path="${_path/#\~/${HOME}}" + if [ ! -e "${_path}" ]; then + echo " ⚠️ ${_key}=${_path} does not exist in this container (host-bound ${_kind}, cannot be derived automatically)" + WARNED=$((WARNED + 1)) + fi +} + +HOOKS_PATH="$(_get core.hooksPath)" +[ -n "${HOOKS_PATH}" ] && _warn_missing_path "core.hooksPath" "${HOOKS_PATH}" "hooks directory" + +EXCLUDES_FILE="$(_get core.excludesfile)" +[ -n "${EXCLUDES_FILE}" ] && _warn_missing_path "core.excludesfile" "${EXCLUDES_FILE}" "file" + +ATTRIBUTES_FILE="$(_get core.attributesfile)" +[ -n "${ATTRIBUTES_FILE}" ] && _warn_missing_path "core.attributesfile" "${ATTRIBUTES_FILE}" "file" + +while IFS= read -r line; do + key="${line%%=*}" + val="${line#*=}" + [ -z "${key}" ] && continue + _warn_missing_path "${key}" "${val}" "included config file" +done < <(git config --file "${GITCONFIG}" --get-regexp '^include\.path$|^includeif\..*\.path$' 2>/dev/null | sed 's/ /=/') + if [ "${FIXED}" -gt 0 ] || [ "${WARNED}" -gt 0 ]; then echo "helpers4: git config self-heal — ${FIXED} fixed, ${WARNED} still need attention" fi diff --git a/test/helpers4-common/test.sh b/test/helpers4-common/test.sh index 79b00d3..2385e86 100755 --- a/test/helpers4-common/test.sh +++ b/test/helpers4-common/test.sh @@ -116,6 +116,41 @@ else rm -rf "${TEST_HOME}" exit 1 fi + +# Test 9: a missing core.hooksPath / includeIf target gets warned about, not fixed — +# there's nothing to $PATH-search or derive from an agent for a missing directory or +# included file, and self-heal must still exit 0 (best-effort, never fails the attach). +git config --file "${TEST_HOME}/.gitconfig" core.hooksPath "/does/not/exist/hooks" +git config --file "${TEST_HOME}/.gitconfig" "includeIf.gitdir:/some/path/.path" "/does/not/exist/included.gitconfig" + +HOME="${TEST_HOME}" PATH="${TEST_BIN}:${PATH}" "${SELF_HEAL}" >/tmp/self-heal-test9.log 2>&1 +SELF_HEAL_EXIT=$? + +if [ "${SELF_HEAL_EXIT}" -ne 0 ]; then + echo "❌ FAIL: self-heal exited ${SELF_HEAL_EXIT} instead of 0 on an unfixable path" + cat /tmp/self-heal-test9.log + rm -rf "${TEST_HOME}" + exit 1 +fi + +if grep -qF "core.hooksPath=/does/not/exist/hooks does not exist" /tmp/self-heal-test9.log; then + echo "✅ PASS: self-heal warned about a missing core.hooksPath without failing" +else + echo "❌ FAIL: self-heal did not warn about the missing core.hooksPath" + cat /tmp/self-heal-test9.log + rm -rf "${TEST_HOME}" + exit 1 +fi + +if grep -qF "includeif.gitdir:/some/path/.path=/does/not/exist/included.gitconfig does not exist" /tmp/self-heal-test9.log; then + echo "✅ PASS: self-heal warned about a missing includeIf target without failing" +else + echo "❌ FAIL: self-heal did not warn about the missing includeIf target" + cat /tmp/self-heal-test9.log + rm -rf "${TEST_HOME}" + exit 1 +fi + rm -rf "${TEST_HOME}" echo ""