Skip to content
Merged
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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
21 changes: 17 additions & 4 deletions src/helpers4-common/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/helpers4-common/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
41 changes: 40 additions & 1 deletion src/helpers4-common/git-config-self-heal.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
35 changes: 35 additions & 0 deletions test/helpers4-common/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 ""
Expand Down
Loading