Augments whichever git the image already has (base image, common-utils or ghcr.io/devcontainers/features/git) for devcontainers whose checkout keeps its git dir outside the workspace: with one bind mount of the host's .git-dirs folder at /git-dirs (GIT_DIRS_ROOT), a /usr/local/bin/git wrapper maps a checkout's .git pointer holding a host path under .git-dirs (C:/Users//.git-dirs//) onto that mount for each call, so no repo declares GIT_DIR; with GIT_DIR and GIT_WORK_TREE in containerEnv (the per-repo form, still supported) it drops those two variables inside linked worktrees (Claude Code's .claude/worktrees/) and nested repositories, so they are not sent to the main checkout, and puts the real values back when Claude Code replaces GIT_DIR with a not-a-git-repository placeholder, and a postCreateCommand hook (git-setup) does the per-container setup every repo otherwise scripts by hand - the git dir mapping recorded, safe.directory and core.hooksPath - in a global git config (GIT_CONFIG_GLOBAL at /var/lib/devcontainer-features/git/gitconfig) kept in a named volume so identity and credential helper survive rebuilds.
"features": {
"ghcr.io/nabil-becker/devcontainer-features/git:1": {}
}| Options Id | Description | Type | Default Value |
|---|---|---|---|
| installTaskfile | Also install this Feature's Taskfile into /usr/local/share/go-task/includes.d so its tasks appear under the : namespace of a root Taskfile that includes /usr/local/share/go-task/features.yml (see the go-task Feature). | boolean | true |
| worktreeAware | Install the /usr/local/bin/git wrapper that unsets GIT_DIR and GIT_WORK_TREE when git runs inside a linked worktree or a nested repository (any directory whose nearest .git is not GIT_WORK_TREE). With false, no wrapper is installed and a previously installed one is removed. | boolean | true |
| safeDirectory | What git-setup marks as safe.directory in the global git config at post-create: 'workspace' = the workspace folder (its top level when mapped) and the git dir (GIT_DIR, or the one mapped from .git-dirs); '*' = every directory; empty = do nothing. Override per container with GIT_SETUP_SAFE_DIRECTORY in containerEnv. | string | workspace |
| hooksPath | When set, git-setup runs git config core.hooksPath <value> in the workspace repository at post-create (e.g. '.githooks'); empty leaves core.hooksPath alone. Override per container with GIT_SETUP_HOOKS_PATH in containerEnv. |
string | - |
| commitName | Commit identity name for agents and bots. When commitEmail is set, the git wrapper exports GIT_AUTHOR_NAME and GIT_COMMITTER_NAME with this value (default: the email) for every git invocation that has not set them, so no repository-local or global user.name decides who commits. Empty with commitEmail empty = feature off. | string | - |
| commitEmail | Commit identity email (e.g. 'bot@noreply.invalid'). When set, the git wrapper exports GIT_AUTHOR_EMAIL and GIT_COMMITTER_EMAIL with this value for every git invocation that has not set them: environment variables beat every config level, including a repository-local user.email in a shared git dir, and git commit -s signs off with it. Installs the wrapper even with worktreeAware=false. Empty = identity left alone. |
string | - |
| commitWhenEnv | Apply the commit identity only to git run with this environment variable set and non-empty, e.g. 'CLAUDECODE', which Claude Code sets on every process it starts (its tools, its subagents, headless runs): agents commit as commitName/commitEmail while a person's own terminal keeps their identity. Empty = the identity applies to every git invocation. | string | - |
On this collection's Windows hosts a checkout lives in OneDrive and its git
dir does not: the checkout's .git is a pointer file,
gitdir: C:/Users/<user>/.git-dirs/<owner>/<repo>, a path that means nothing
in the container. Every consuming repo adds the same line, and nothing else:
The wrapper then maps the pointer for each git call. When GIT_DIR is not
set (or is Claude Code's placeholder, below) and the nearest .git above the
directory git runs in is a file whose gitdir: value has a .git-dirs
segment (matched case-insensitively, \ and / alike, any drive or user
profile), it takes everything after that segment, joins it onto
GIT_DIRS_ROOT (default /git-dirs) and, when <root>/<rest>/HEAD exists,
exports GIT_DIR=<root>/<rest> and GIT_WORK_TREE=<checkout root> for that
call only. The folder names under .git-dirs need not match the checkout's
folder: the pointer says where it is. Left alone: a pointer that already
works (a Linux path, a linked worktree, .claude/worktrees/<name>), a nested
repository (its .git is a directory), init and clone, an explicit
--git-dir/--work-tree, and a GIT_WORK_TREE set on its own. A pointer
with no .git-dirs segment, one that climbs out with .., or a mapped
folder with no HEAD (the mount is missing) changes nothing: git fails as it
would without the wrapper. The mapping lives in
/usr/local/share/git-feature/git-dirs.sh, sourced by both the wrapper and
git-setup; it is one read of the pointer file and string operations, no
subprocess. Measured with the real git replaced by true (1,000 calls, three
runs): the wrapper adds about 0.1 ms in a repository that needs no mapping and
about 0.3 ms in a mapped checkout, against about 1 ms more for the per-repo
GIT_DIR form, which spawns two subshells per call.
At post-create, git-setup resolves the workspace's pointer the same way,
records the result for the placeholder restore, and adds the workspace's top
level (git rev-parse --show-toplevel, else the nearest folder with a
.git, else the workspace folder) and the mapped git dir to
safe.directory. When it cannot map, it prints one line and goes on:
git dir: NOT MOUNTED - <checkout>/.git points to <pointer>, expected at /git-dirs/<owner>/<repo>; add to devcontainer.json "mounts": [...]with the exact mount line, when the pointer is under.git-dirsbut the folder is not there (no mount, or a folder name that differs on the host);git dir: OUTSIDE .git-dirs - <checkout>/.git points to <pointer>, which the container cannot reach; keep the git dir under %USERPROFILE%\.git-dirs\<owner>\<repo> (update the pointer) and add ...when the git dir is somewhere else on the host.
The trade-off, plainly: the mount exposes every git dir under
%USERPROFILE%\.git-dirs to every container that declares it, read-write,
not only this repository's. A process in one devcontainer can read and change
the history, hooks and config of the others. That is the price of one
identical line instead of a per-repo path; the Operator accepted it for this
collection's hosts. A repo that must not see its neighbours keeps the
per-repo form below.
GIT_DIRS_ROOT is a plain variable, not a Feature option: the mount target
is written by the consuming repo in its own devcontainer.json, so the
override belongs next to it (containerEnv), takes effect without a rebuild,
and the wrapper reads no options file per call. Changing it defeats the
point of the one identical line; it exists for an image where /git-dirs is
taken and for tests.
Migrating from the per-repo mount: delete the repo's
source=.../.git-dirs/<owner>/<repo>,target=/git/<repo>.git mount and the
GIT_DIR and GIT_WORK_TREE entries from containerEnv, add the line above,
and rebuild (git 1.4.0 or later). Keep task devcontainer:homes in
initializeCommand: it creates %USERPROFILE%\.git-dirs when missing, so the
bind mount never fails on a fresh machine; HOST_GIT_DIR is optional now.
Nothing else changes: the per-repo form (GIT_DIR and GIT_WORK_TREE in
containerEnv) keeps working exactly as before and wins over the mapping.
Host safety: the git dir is shared with git on Windows, so nothing in the
container writes a container path into its config: the mapping only sets
environment variables for one call, init and clone are never mapped, and
git-setup writes safe.directory to the global config only
(core.hooksPath, when the option is set, is a relative path). The unit test
compares the git dir's config byte for byte before and after git-setup and
a run of wrapper calls. git worktree add does record the new worktree's
container path in <git dir>/worktrees/<name>/gitdir (outside config), as
it did with the per-repo form; git on Windows sees those worktrees as
prunable.
Limits: ${localEnv:USERPROFILE} is empty on a Linux or macOS host, so there
the line needs source=${localEnv:HOME}/.git-dirs; a checkout whose git dir
is a plain .git folder needs none of this.
When a checkout's real git dir lives outside the workspace (on this
collection's Windows hosts: outside OneDrive, bind-mounted into the
container) the checkout's .git is a pointer file holding a host path, so
devcontainer.json sets GIT_DIR and GIT_WORK_TREE in containerEnv.
Those two variables reach every process. Git inside a linked worktree
(Claude Code's agent worktrees under .claude/worktrees/<name>) or a nested
repository then silently runs against the main checkout, and a test fixture
that does git init / git commit in a temporary folder re-initialises the
real repository (rewriting its config with core.worktree and
core.filemode, which breaks git on the Windows host) and commits onto its
branch. The wrapper at /usr/local/bin/git scopes the variables to the main
checkout. It finds the directory git will run in ($PWD, moved by -C) and
the subcommand, then unsets both variables when:
- the subcommand is
initorclone(creating a repository means "here", never the main checkout: kept,clonewould check out intoGIT_WORK_TREEand a re-init would write a container path ascore.worktreeinto the shared git dir's config, breaking git on the host); - the directory is outside
GIT_WORK_TREE; - inside it, the nearest ancestor with a
.git(file or directory) is notGIT_WORK_TREEitself (a worktree or nested repo). The main checkout has a.gitpointer file too, so its root keeps usingGIT_DIR.
Everywhere else git runs unchanged, and without GIT_DIR the wrapper only
execs the real git. GIT_WRAPPER_REAL_GIT overrides the baked-in path to
the real git.
Claude Code does not pass an inherited GIT_DIR on (with either form). It reads <cwd>/.git
itself and, when that pointer holds a path it cannot use (the git dir lives
outside the workspace, so the pointer is a host path), runs git with
GIT_DIR=<somewhere>/not-a-git-repository; its subagent worktree isolation
then fails ("Failed to resolve base branch"). By the time the wrapper runs, the
placeholder has overwritten the real values, so git-setup records them at
post-create, from the container's GIT_DIR and GIT_WORK_TREE
(containerEnv) or from the mapped .git-dirs pointer, in
/var/lib/devcontainer-features/git/dirs.env (the Feature's state volume;
rewritten on every post-create, removed when there is nothing to record,
never written from a placeholder). Without the file the mapping still applies
to the placeholder: after the rules below, a GIT_DIR that is unset or still
the placeholder is mapped as above. The wrapper reads it only when
GIT_DIR ends in /not-a-git-repository, and when the directory git runs in
($PWD, moved by -C) is inside the main checkout's work tree but not inside
a linked worktree (<work tree>/.claude/worktrees/<name>), puts GIT_DIR and
GIT_WORK_TREE back and unsets GIT_COMMON_DIR; the rules above then apply as
usual (a nested repository still drops them). In a linked worktree, or outside
the work tree, nothing is restored. A consumer sets nothing new: GIT_DIR and
GIT_WORK_TREE in containerEnv are what it already has, and post-create has
to run once (rebuild or task git:setup). GIT_WRAPPER_DIRS_FILE overrides
the file's path. The decision is made on the directory, not on the inherited
GIT_WORK_TREE, because that is what git is about to run in.
With commitEmail (and commitName) set, the wrapper exports
GIT_AUTHOR_NAME/EMAIL and GIT_COMMITTER_NAME/EMAIL for every git it runs,
each only when the caller has not set it. Environment variables beat every
config level, so a repository-local user.email (for example the Operator's
address in a git dir shared with the host checkout) cannot leak into an
agent's commit, git commit -s signs off with the same identity, and nothing
has to touch the shared config or pass -c overrides. install.sh writes the
values to /usr/local/share/git-feature/identity.env, which the wrapper
sources (one file read per call). The wrapper is installed for this even with
worktreeAware: false. Empty (the default) leaves identity alone.
commitWhenEnv narrows it to agents: with a variable name set (for example
CLAUDECODE, which Claude Code sets on every process it starts: its tool
calls, its subagents and headless claude -p runs such as the devhost's
backs), the wrapper applies the identity only when that variable is set and
non-empty. A person's own terminal does not have it, so their commits keep the
repository's or their global identity, while every agent commit is the bot's.
Empty (the default) applies the identity to every git invocation, as before.
Why not the alternatives: containerEnv in a consumer's devcontainer.json
works but cannot be driven by a Feature option, so each repo would repeat four
variables; profile.d reaches only login shells, missing headless agents,
hooks and docker exec without a shell. The wrapper is on PATH for every
process. A caller that sets the variables itself (a human exporting their own
identity) keeps them.
The Feature's postCreateCommand hook runs git-setup as the remote user
from the workspace folder (task git:setup re-runs it; it is idempotent and
never fails post-create - a missing git-dir mount is reported instead):
- creates the folder of
GIT_CONFIG_GLOBAL(git creates the file, not its parent) and hands it to the remote user, through the shareddcf-persisthelper, which also reports what backs it (volume,bindorimage) and whether it is writable; - the git dir: records
GIT_DIR/GIT_WORK_TREEfromcontainerEnv, or maps the workspace's.git-dirspointer (above) and records that, or says in one line what mount is missing; safe.directory: withsafeDirectory: workspaceadds the workspace folder (its top level when mapped) and the git dir (GIT_DIR, or the mapped one) to the global config, each once;*adds*once; empty adds nothing;core.hooksPath: withhooksPathset (e.g..githooks) configures it in the workspace repository (withGIT_DIRset, inside that git dir), only when it differs;- reports which git runs (wrapper or not), whether
GIT_DIRis set and whether the workspace is a usable work tree.
GIT_SETUP_SAFE_DIRECTORY and GIT_SETUP_HOOKS_PATH in containerEnv
override the Feature options at run time, so a repo can change either
without rebuilding the image.
- Its own global git config: the Feature sets
GIT_CONFIG_GLOBAL=/var/lib/devcontainer-features/git/gitconfigincontainerEnvand mounts the named volumegit-config-${devcontainerId}on that folder, so identity (user.name,user.email) and the credential helper survive rebuilds (safe.directoryis re-added by the hook anyway). A storage reset of the container engine deletes the volume, and${devcontainerId}changes when the checkout moves, leaving the old one behind. For the host disk, bind-mount a host folder on the same target (a repo'smountsmerge after the Features', last source on a target wins):"source=${localEnv:USERPROFILE}/.devcontainer-homes/myrepo/git,target=/var/lib/devcontainer-features/git,type=bind", created before the first start by"initializeCommand": ["task", "devcontainer:homes", "NAME=myrepo", "--", "git"]. - A checkout whose git dir lives under
%USERPROFILE%\.git-dirs: the one.git-dirsmount (top of this page), created bytask devcontainer:homes. The older per-repo form still works: the git dir bind-mounted on its own, withGIT_DIR(the mount target) andGIT_WORK_TREE(${containerWorkspaceFolder}) incontainerEnv; the wrapper is what makes worktrees usable there. ghcr.io/devcontainers/features/gitor common-utils for git itself; this Feature lists both ininstallsAfter.- A checkout synced by OneDrive: mount a named volume over the worktrees
folder so the sync never sees a second copy of the repo
(
"source=myrepo-worktrees,target=${containerWorkspaceFolder}/.claude/worktrees,type=volume", and gitignore the folder). Each worktree's.gitpointer file is inside the volume, so the wrapper's rule works unchanged; after an engine reset the worktrees are gone but their commits are in the git dir, andgit worktree pruneclears the stale records.
That Feature installs git (from the distribution or built from source). This
one does not install git at all; it augments whichever git is present - a
wrapper in front of it and the per-container setup - and sits after it in the
install order. A git it finds at /usr/local/bin/git (the source-built one)
is moved to /usr/local/libexec/git-feature/git and the wrapper forwards to
it.
Note: This file was auto-generated from the devcontainer-feature.json. Add additional notes to a NOTES.md.