Technology · PRACTICAL GUIDE
Git says dubious ownership? Audit the repository before changing safe.directory
Build a read-only evidence ledger for Git’s ownership warning before changing permissions or adding a safe.directory exception.

Git’s “detected dubious ownership” message is a security stop, not evidence that a repository is corrupt. Since Git 2.35.2, commands can refuse a repository owned by a different identity. The official safe.directory setting allows exceptions, but Git only honors it from protected configuration so an untrusted repository cannot bless itself.
Do not start with `safe.directory=*`. That value opts out of the ownership check for every repository visible to that Git installation. First record the exact Git executable, canonical path, current account, filesystem owner, mount context and existing protected safe.directory values. Then choose the narrowest justified change.
The audit below writes one JSON report outside the repository. It never runs Git inside the target, parses repository configuration, changes ownership or permissions, adds a trust entry, checks out files, runs hooks or contacts a remote.
1. Preserve the warning and the path exactly
Copy the complete error, including the path Git prints and any owner/current-user identities. Record which application raised it: a terminal, IDE, GUI client, CI runner, Git LFS process, container or service may invoke a different Git executable and user account.
Do not normalize the path by eye. Windows Git and WSL can reach the same files through different path syntax and protected configuration. Symlinks, linked worktrees, mounts and network shares can also make the typed path differ from the object Git evaluated.
2. Understand what safe.directory changes
Git documents safe.directory as a multi-valued list of repositories usable even when another user owns them. It is respected only in protected configuration. An empty value can reset inherited entries; a path ending in `/*` trusts repositories below it; `*` disables the ownership check entirely.
If a private copy unexpectedly belongs to an old SID, root or another account, correcting ownership may be right. If a repository is intentionally shared, one exact safe.directory entry can be reasonable after the path and owner are verified. A wildcard hides both cases.
3. Run the read-only audit from outside the repository
Save the program below as `audit_git_trust.mjs` in a neutral folder. The first argument is the repository; the optional second is a new report filename outside it. The program refuses to overwrite a report or put one inside the target.
Windows PowerShell: `node .\audit_git_trust.mjs "D:\Work\project" ".\git-trust-project.json"`. Linux or macOS: `node ./audit_git_trust.mjs "/mnt/work/project" "./git-trust-project.json"`. Node 24.19.0 was used for the seven synthetic tests; other Node versions were not verified.
```javascript #!/usr/bin/env node import {access, lstat, mkdir, open, readFile, realpath, stat} from 'node:fs/promises'; import {constants as fsConstants} from 'node:fs'; import {execFile} from 'node:child_process'; import {promisify} from 'node:util'; import {basename, delimiter, dirname, isAbsolute, join, resolve} from 'node:path'; import {tmpdir} from 'node:os'; const runFile = promisify(execFile); const fail = (code, detail = '') => { const error = new Error(detail ? `${code}: ${detail}` : code); error.code = code; throw error; }; const isObject = value => value !== null && typeof value === 'object' && !Array.isArray(value); const normalizePrefix = value => process.platform === 'win32' ? value.toLowerCase() : value; const isWithin = (parent, child) => { const p = normalizePrefix(resolve(parent)); const c = normalizePrefix(resolve(child)); return c === p || c.startsWith(p.endsWith('/') || p.endsWith('\\') ? p : p + (process.platform === 'win32' ? '\\' : '/')); }; async function existingExecutable(name) { const extensions = process.platform === 'win32' ? (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';') : ['']; for (const folder of (process.env.PATH || '').split(delimiter).filter(Boolean)) { for (const extension of extensions) { const candidate = join(folder, process.platform === 'win32' ? name + extension.toLowerCase() : name); try { await access(candidate, process.platform === 'win32' ? fsConstants.F_OK : fsConstants.X_OK); return await realpath(candidate).catch(() => resolve(candidate)); } catch {} } } return null; } async function command(executable, args, options = {}) { try { const result = await runFile(executable, args, { cwd: options.cwd || tmpdir(), encoding: 'utf8', timeout: 15_000, maxBuffer: 2 * 1024 * 1024, windowsHide: true, env: process.env }); return {ok: true, stdout: result.stdout, stderr: result.stderr}; } catch (error) { return { ok: false, code: typeof error.code === 'string' || typeof error.code === 'number' ? error.code : 'COMMAND_FAILED', stdout: typeof error.stdout === 'string' ? error.stdout : '', stderr: typeof error.stderr === 'string' ? error.stderr : '' }; } } async function inspectGitMarker(root) { const marker = join(root, '.git'); try { const info = await lstat(marker); if (info.isDirectory()) return {kind: 'directory', marker, adminPath: marker}; if (!info.isFile()) return {kind: 'unsupported-marker', marker}; const text = await readFile(marker, 'utf8'); const match = /^gitdir:\s*(.+?)\s*$/im.exec(text); if (!match) return {kind: 'unrecognized-file', marker}; const candidate = isAbsolute(match[1]) ? match[1] : resolve(root, match[1]); return {kind: 'gitdir-file', marker, declared: match[1], adminPath: await realpath(candidate).catch(() => resolve(candidate))}; } catch (error) { if (error.code !== 'ENOENT') throw error; } const [head, objects, refs] = await Promise.all([ stat(join(root, 'HEAD')).catch(() => null), stat(join(root, 'objects')).catch(() => null), stat(join(root, 'refs')).catch(() => null) ]); return head?.isFile() && objects?.isDirectory() && refs?.isDirectory() ? {kind: 'bare-repository', adminPath: root} : {kind: 'not-detected'}; } async function posixIdentity(targetStat) { const user = await command('id', ['-un']); const group = await command('id', ['-gn']); return { process: { uid: typeof process.getuid === 'function' ? process.getuid() : null, gid: typeof process.getgid === 'function' ? process.getgid() : null, user: user.ok ? user.stdout.trim() : null, group: group.ok ? group.stdout.trim() : null, sudoUid: process.env.SUDO_UID || null }, target: {uid: targetStat.uid, gid: targetStat.gid, mode: `0${(targetStat.mode & 0o7777).toString(8)}`} }; } async function windowsIdentity(target) { const script = "param([string]$p) $a=Get-Acl -LiteralPath $p; [pscustomobject]@{Owner=$a.Owner;Sddl=$a.Sddl}|ConvertTo-Json -Compress"; const result = await command('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script, target]); let acl = null; if (result.ok) { try { acl = JSON.parse(result.stdout); } catch { acl = {parseError: true, raw: result.stdout.trim()}; } } return { process: {user: process.env.USERNAME || null, userDomain: process.env.USERDOMAIN || null}, target: acl, aclRead: result.ok, aclError: result.ok ? null : (result.stderr.trim() || String(result.code)) }; } function decodeMountField(value) { return value.replaceAll('\\040', ' ').replaceAll('\\011', '\t').replaceAll('\\012', '\n').replaceAll('\\134', '\\'); } async function linuxMount(target) { try { const lines = (await readFile('/proc/self/mountinfo', 'utf8')).trim().split('\n'); const candidates = lines.map(line => { const parts = line.split(' '), separator = parts.indexOf('-'); if (separator < 6) return null; return { mountPoint: decodeMountField(parts[4]), mountOptions: parts[5], optionalFields: parts.slice(6, separator), filesystem: parts[separator + 1], source: decodeMountField(parts[separator + 2] || ''), superOptions: parts.slice(separator + 3).join(' ') }; }).filter(Boolean).filter(item => isWithin(item.mountPoint, target)); return candidates.sort((a, b) => b.mountPoint.length - a.mountPoint.length)[0] || null; } catch (error) { return {readError: error.code || error.message}; } } async function portableMount(target) { if (process.platform === 'linux') return linuxMount(target); if (process.platform === 'win32') { const root = /^[A-Za-z]:\\/.exec(target)?.[0]?.slice(0, 2) || null; if (!root) return {drive: null}; const script = "param([string]$d) Get-Volume -DriveLetter $d.TrimEnd(':') | Select DriveLetter,FileSystem,DriveType,HealthStatus | ConvertTo-Json -Compress"; const result = await command('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script, root]); return result.ok ? {drive: root, raw: result.stdout.trim()} : {drive: root, error: result.stderr.trim() || String(result.code)}; } const result = await command('df', ['-P', target]); return result.ok ? {raw: result.stdout.trim()} : {error: result.stderr.trim() || String(result.code)}; } async function protectedConfig(git) { if (!git) return {available: false, reason: 'git-not-found-in-path'}; const version = await command(git, ['--version']); const traced = await command(git, ['config', '--show-origin', '--show-scope', '--get-all', 'safe.directory']); const global = await command(git, ['config', '--global', '--get-all', 'safe.directory']); const system = await command(git, ['config', '--system', '--get-all', 'safe.directory']); return { available: version.ok, executable: git, version: version.stdout.trim() || null, safeDirectoryWithOrigin: traced.stdout.split(/\r?\n/).filter(Boolean), globalValues: global.stdout.split(/\r?\n/).filter(Boolean), systemValues: system.stdout.split(/\r?\n/).filter(Boolean), notes: [ !traced.ok && traced.code !== 1 ? `origin-read-failed:${traced.code}` : null, !global.ok && global.code !== 1 ? `global-read-failed:${global.code}` : null, !system.ok && system.code !== 1 ? `system-read-failed:${system.code}` : null ].filter(Boolean) }; } async function main() { if (process.argv.length < 3 || process.argv.length > 4) fail('USAGE', 'node audit_git_trust.mjs REPOSITORY_PATH [REPORT.json]'); const requestedPath = resolve(process.argv[2]); const output = resolve(process.argv[3] || `git-trust-audit-${Date.now()}.json`); const target = await realpath(requestedPath).catch(error => fail('TARGET_NOT_FOUND', error.code || error.message)); const targetStat = await stat(target); if (!targetStat.isDirectory()) fail('TARGET_NOT_DIRECTORY'); if (isWithin(target, output)) fail('OUTPUT_INSIDE_TARGET', 'write the report outside the repository being audited'); const git = await existingExecutable('git'); const report = { schemaVersion: 1, createdAt: new Date().toISOString(), readOnlyScope: { targetMutation: false, commandsExecutedInsideTarget: false, configWrites: false, ownershipOrPermissionWrites: false, repositoryOperations: false }, paths: {requested: requestedPath, canonical: target, targetName: basename(target), output}, marker: await inspectGitMarker(target), identity: process.platform === 'win32' ? await windowsIdentity(target) : await posixIdentity(targetStat), mount: await portableMount(target), git: await protectedConfig(git), interpretation: { warningMeaning: 'Git declined to trust a repository whose owner differs from the current execution identity; this is not proof of repository corruption.', minimumChangeRule: 'Verify path, identity and ownership first. Prefer correcting unintended ownership. For an intentionally shared repository, review one exact protected safe.directory entry instead of a wildcard.', limits: 'This ledger does not decide whether an owner or path is trustworthy and does not modify the repository, filesystem ownership, permissions or Git configuration.' } }; if (!isObject(report.marker) || !isObject(report.identity) || !isObject(report.git)) fail('REPORT_BUILD_FAILED'); await mkdir(dirname(output), {recursive: true}); const handle = await open(output, 'wx', 0o600).catch(error => fail(error.code === 'EEXIST' ? 'OUTPUT_EXISTS' : 'OUTPUT_OPEN_FAILED', error.code)); try { await handle.writeFile(JSON.stringify(report, null, 2) + '\n', 'utf8'); await handle.sync(); } finally { await handle.close(); } console.log(`Wrote read-only Git trust audit: ${output}`); } main().catch(error => { console.error(error.code || error.message || 'UNEXPECTED_ERROR'); process.exitCode = 1; }); ```
4. Read the report without treating it as a verdict
`paths` keeps requested and canonical filesystem paths. `marker` distinguishes an ordinary `.git` directory, a linked-worktree `gitdir:` file, a bare repository and no detected marker. This prevents trusting a convenient alias when Git evaluates another path.
`identity` compares the running account with ownership evidence. POSIX records UID/GID and user/group; Windows asks PowerShell for ACL owner and SDDL without changing them. A mismatch needs investigation and does not prove either identity is malicious.
`mount` records the closest Linux mount or a platform drive/filesystem result. `git` records the executable from PATH, its version and protected safe.directory values with origin/scope output when Git supplies it. No repository-local config is opened through Git.
5. Classify the cause before choosing a change
Use `wrong-owner` for a private copy unexpectedly owned by an obsolete account, root or an installer. Use `wrong-executable-or-scope` when a terminal, IDE, WSL, container or service reads different Git/config. Use `intentional-shared-repository` only after verifying the canonical path, owner arrangement, authorized writers, hooks and configuration.
Use `mount-or-share-identity` for SMB, NFS, removable drives and bind mounts whose presented owner differs from the local account. Use `unknown` whenever the ledger cannot establish path, owner or execution context.
Do not change ownership recursively until you know what shares the tree. Do not run Git with sudo as a routine workaround. Do not copy a wildcard command into global or system config.
6. Apply only the verified minimum-scope remedy
For unintended ownership on a private copy, use the operating system’s normal administration path to assign the intended owner. Atlassian documents `takeown` and `chown` examples, but account, group, inheritance and shared-storage policy are environment decisions. Preserve important work before a recursive change.
For an intentionally shared repository, add only the canonical path to protected safe.directory for the affected Git process. Re-run the audit if a different application or account raises the warning. The report deliberately does not generate or execute the write command.
For WSL, containers, CI and services, fix the context that actually runs Git. Windows Git config does not configure WSL Git; a developer’s global file does not configure a service account; a host setting may not enter a container. GitLab’s repository-check guidance shows how the wrong account can be the cause.
7. Verify the result and preserve rollback evidence
After an approved change, run the same audit to a new filename and compare executable, canonical path, identity, owner, mount and safe.directory origins. Keep before/after reports with the original error.
Run the smallest read-only Git command needed by the original workflow, such as `git status --short`, under the same application or account that failed. Success from another shell does not prove an IDE, service or share process is fixed.
If an exact exception hides the warning while the path or owner remains unexplained, remove it through the same protected scope and continue investigating. A disappearing warning proves only that the trust gate was satisfied.
8. Know the audit’s boundaries
The program does not validate objects, commits, hooks or remotes, prove a shared repository safe, or recover deleted work. Windows needs PowerShell and ACL reporting; mount detail depends on `/proc/self/mountinfo`, PowerShell or `df`.
Seven synthetic tests covered a normal worktree, `gitdir:` file, bare repository, symlink target, missing target, overwrite refusal and refusal to write inside the audited tree. Target hashes stayed unchanged. Tests used Linux and Node 24.19.0, not a live Windows SID, SMB/NFS server, WSL, container, IDE or CI account.
