Git: fatal: detected dubious ownership in repository
Hi, it's BlueByte. You run git status in a repository that worked yesterday — inside a container, under sudo, or on a freshly mounted disk — and Git stops with fatal: detected dubious ownership in repository. Nothing is broken: your commits are intact, and Git is simply refusing a repository owned by a different user than the one running it. We'll walk through what the message means, why Git does this, how to see which mismatch you have, the fix per cause, and how to keep it from coming back.
What the message looks like, and its older wording
On Linux and macOS the output is a single fatal line plus a suggested command, and Git exits with status 128:
fatal: detected dubious ownership in repository at '/srv/app'
To add an exception for this directory, call:
git config --global --add safe.directory /srv/appGit for Windows also prints who owns the path and who you are, shaped like this:
'C:/work/app' is owned by:
BUILTIN/Administrators (S-1-5-32-544)
but the current user is:
WORKPC/alex (S-1-5-21-...)On Git 2.35.2 through 2.37 the same check prints an older wording: unsafe repository ('/srv/app' is owned by someone else). The detected dubious ownership text arrived in 2.38. Same check, same fix.
Why Git refuses a repository it doesn't own
The check came from CVE-2022-24765, fixed in Git 2.35.2 in April 2022. A repository's config can make Git run programs (hooks, core.fsmonitor, a pager), so a .git directory planted by another user on a shared machine could run code as you the moment you typed git status nearby. Since that release, Git compares the owner of the working tree and of the .git directory with the user running the command, and stops if they differ.
On Linux and macOS the comparison is by numeric user ID. Root is the special case: a root-owned repo passes, and under sudo Git also accepts the UID that sudo stored in SUDO_UID. Exceptions live in safe.directory, and Git reads that setting only from protected configuration — system, global, or the command line — never from the repository's own .git/config, because that is exactly the file it doesn't trust yet.
The usual ways the owner and the user drift apart
- A container or CI job running as root, or as a fixed UID, on a bind-mounted checkout owned by your host user.
- A repo cloned with
sudo git cloneand then used as a normal user, or the reverse. - A service account (
www-data,jenkins) running Git in a directory an administrator created. - On Windows, a repo on an external or network drive, or a folder created from an elevated prompt and owned by Administrators.
First, compare the owner with the user running Git
Ask the filesystem and Git:
stat -c '%U %u %n' /srv/app /srv/app/.git
id -un; id -u
git config --show-scope --get-all safe.directoryWhen I reproduced this with a repo owned by ubuntu (UID 1001) and ran Git as nobody, stat printed ubuntu 1001 for both paths while id -u said 65534 — a straight mismatch. An empty result from the last command means no exception is configured in any scope Git trusts.
Fix it by cause: own the repo, or trust it on purpose
If the files should belong to you, fix ownership rather than silencing the check:
sudo chown -R "$(id -u):$(id -g)" /srv/app
git -C /srv/app status
# On branch mainIf the mismatch is intentional (a shared repo, a CI container), add the exact path to the global config of the user who runs Git:
git config --global --add safe.directory /srv/app
git config --show-scope --get-all safe.directory
# global /srv/appFor a single command the command-line scope works too: git -c safe.directory=/srv/app status. Don't bother writing it into /srv/app/.git/config — I tried, and Git ignores it there, as the documentation says it will.
To trust every repository under a directory, Git 2.46 and later accept a trailing /*, as in safe.directory=/srv/*. Older Git treats it as a literal path that matches nothing — on my 2.43 box the error stayed — so check git --version first. The bare value * switches the check off entirely; keep that for throwaway containers where nobody else can write to the disk.
A worked example: the same repo, with and without sudo
A repo owned by ubuntu, run as root through sudo:
sudo git -C /srv/app status
# On branch main
sudo env -u SUDO_UID git -C /srv/app status
# fatal: detected dubious ownership in repository at '/srv/app'The first works because Git reads SUDO_UID and finds the owner's UID. Strip that variable — which is effectively what happens inside a container running as root, or after su - — and the same command fails. That is the pattern behind most CI reports: the job's Git runs as a user who never matched the checkout's owner. Run the container as the checkout's UID, or add git config --global --add safe.directory "$PWD" as a setup step.
Check it end to end, then keep it that way
Run git status followed by echo $?; branch information and 0 mean you're done. To keep it that way, keep clones owned by the account that uses them, avoid sudo git for routine work, bake the safe.directory line into container images or CI setup with the exact checkout path, and list paths one by one rather than reaching for *.
How it differs from permission errors and "not a git repository"
error: insufficient permission for adding an object to repository database .git/objects is a write failure: Git trusted the repo, then the operating system refused a write. fatal: not a git repository (or any of the parent directories): .git means Git never found a .git at all. Dubious ownership sits between the two: Git found the repository and deliberately declined to read it.
Next time you hit this, walk these checks back in order: compare stat with id -u, decide whether the owner or the user is the wrong one, and only then reach for safe.directory — with the exact path.
Related questions
Is it safe to set safe.directory to * and move on?
On a single-user machine or a throwaway container that nobody else can write to, the risk is small. On a shared server it removes the protection entirely: any .git directory another user creates above your working directory is read with your privileges again. Listing exact paths, or using a trailing /* on Git 2.46 and later, keeps the exception narrow.
I added the path and still get the error.
Check three things. The value must match the path Git printed in the message, so copy it from there rather than retyping it. It must be in a scope Git trusts — git config --show-scope --get-all safe.directory should show global, system or command. And it must be in the config of the user actually running Git: if the command runs under sudo or as a service account, your own ~/.gitconfig is not the one being read.
Why did this start right after I upgraded Git or Git for Windows?
The ownership check shipped in the April 2022 security releases, starting with 2.35.2, and every later version keeps it. Upgrading from an older Git turns it on, so repositories that were always owned by another account start failing the first time you touch them with the new version.
Should I chown the repo or add a safe.directory entry?
Change ownership when the files are really meant to be yours — a clone made with sudo by mistake is the classic case. Add a safe.directory entry when the split is deliberate, such as a repository shared by a team or a checkout bind-mounted into a container that must run as a different UID. The first fixes the cause; the second records a decision.
Does this affect Windows drives the same way?
Yes, with one difference in the message: Git for Windows prints the owning account and your account with their SIDs, which makes the cause obvious. Repos on external or network drives, or folders created from an elevated prompt and owned by Administrators, are the usual triggers. Copy the git config --global --add safe.directory line from the message as printed, since it already uses the path form Git compares against.
References
- Git documentation — git-config (safe.directory: multi-valued list of repositories trusted despite a different owner; honoured only in protected configuration; * opts out; a trailing /* allows every repository under a directory; root under sudo also accepts SUDO_UID)
- The GitHub Blog — Git security vulnerability announced (CVE-2022-24765: a .git directory planted above the working directory on shared machines; fixed in Git 2.35.2 by stopping discovery at an ownership change; safe.directory introduced for exceptions)
Haneul Seo
Infrastructure engineer · 10+ years running Linux fleets
More in this category
Kubernetes: Internal error occurred: failed calling webhook
An admission webhook sits in front of your write, the API server could not get an answer out of it, and failurePolicy: Fail turned that silence into a rejection. The tail of the message is the whole diagnosis: context deadline exceeded means the call went nowhere, no endpoints available means nothing is running, and an x509 line means the API server does not trust the webhook's certificate. Each has a different fix, and none of them is your manifest.
MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
A statement waited the full innodb_lock_wait_timeout for a row lock another transaction is still holding, and gave up. sys.innodb_lock_waits names the blocking session and hands you the KILL statement, and a blocking_query of NULL means the blocker is idle on an open transaction. The detail most retry loops get wrong: by default only the timed-out statement is rolled back, so your transaction is still open and still holds every lock it took earlier.
Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk
Reads keep working and every write is rejected, because the last background save failed and stop-writes-on-bgsave-error defaults to yes. The log names the real cause — no space, a dir the redis user can't write, a read-only mount at rename time, or fork failing with Cannot allocate memory. Fix the cause, run one BGSAVE, and writes come back on their own with no restart: rdb_last_bgsave_status flips from err to ok. Setting stop-writes-on-bgsave-error no restores writes instantly but leaves the snapshot broken, so treat it as a deliberate trade, not the fix.
Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (exit 134)
The V8 heap has its own ceiling, derived from system memory and the Node release, and it is often far below the RAM you have; when a build or server reaches it, V8 aborts with FATAL ERROR: Reached heap limit and exit code 134. Read the real limit with v8.getHeapStatistics().heap_size_limit, then raise it with --max-old-space-size (in MiB) or NODE_OPTIONS for a large workload, size it below the cgroup limit inside containers, and use --heapsnapshot-near-heap-limit to catch a leak in a long-running process. Exit 137 with no FATAL ERROR line is a container kill, not this.
Docker: "exec format error" when the container starts — wrong-platform image, no emulator, or a script with no shebang
The container exits on its first instruction with exec format error — the kernel's ENOEXEC, meaning the file exists but cannot be executed here. In practice that is an image built on one CPU architecture (an Apple-silicon Mac produces linux/arm64) and run on another (an x86_64 server) with no QEMU handler registered in binfmt_misc, or an entrypoint script whose first line is not a shebang. uname -m, docker image inspect and ls /proc/sys/fs/binfmt_misc tell the causes apart; the fix is an explicit docker buildx build --platform (or a manifest list for both), QEMU registration or --platform when you mean to emulate, and a #!/bin/sh line for the script.
curl: (60) SSL certificate problem: unable to get local issuer certificate
curl walked the certificate chain the server sent and reached a certificate whose issuer is not in the CA store it is reading, so it refused the connection with exit code 60. The issuer is missing for one of four reasons: the server sends only its leaf certificate without the intermediate (browsers hide this by fetching it themselves), a TLS-inspecting proxy re-signed the site with a company CA that the container or runner does not trust, curl is reading a different CA bundle than you think (CURL_CA_BUNDLE, SSL_CERT_FILE, a vendored curl), or the ca-certificates package is too old. curl -v shows which store was used and openssl s_client shows what the server sent; fix the side that is missing the link and confirm with -w '%{ssl_verify_result}'.