Restoring File Permissions with Git After a Recursive chmod¶
It's an easy mistake. You want every markdown file in content/docs to be 0644, but you run this from the repository root instead:
find . -type f -exec chmod 0644 {} \;
Every regular file in the repo, including scripts, manage.py, virtualenv entry points, git hooks and anything under .git/, loses its execute bit. Files that were private (0600) become readable by everyone. git status suddenly lists a pile of files as modified, even though you haven't changed a line in them.
If the directory is a git repository, this is recoverable. Git keeps a record of which tracked files are executable, so it can put them back exactly. This guide explains what git records, gives the recovery steps, shows how to fix the things git doesn't know about, and ends with the command you actually wanted.
TL;DR¶
Run from the repository root:
# 1. Restore execute bits on tracked files from git's record
git ls-files -s -z | while IFS= read -r -d '' entry; do
mode=${entry%% *}
path=${entry#*$'\t'}
[ "$mode" = 100755 ] && chmod 755 -- "$path"
done
# 2. Virtualenv scripts (untracked) lost +x too
find .venv/bin venv/bin -type f -exec chmod 755 {} + 2>/dev/null
# 3. Git hooks you actually use
find .git/hooks -type f ! -name '*.sample' -exec chmod 755 {} +
# 4. Secrets that are now world-readable
chmod 600 .env 2>/dev/null
# 5. Verify
git diff --summary | grep 'mode change' # should print nothing
git status
Next time, change permissions only where you meant to:
find content/docs -type f -name '*.md' -exec chmod 644 {} +
The rest of this page explains each step.
What the command actually did¶
find . -type f -exec chmod 0644 {} \;
| Part | Effect |
|---|---|
find . |
Walks everything under the current directory, including hidden directories like .git/, .venv/ and .env files |
-type f |
Matches regular files only. Directories and symlinks are left alone |
-exec chmod 0644 {} \; |
Runs one chmod per file, setting the mode to exactly rw-r--r-- |
Because of -type f:
- Directories are fine. Their
xbit (which lets you enter them) is intact. Had you runchmod -R 0644 .instead, every directory would bedrw-r--r--and unenterable. In that case, fix directories first withfind . -type d -exec chmod 755 {} +. - Symlinks were skipped. A virtualenv's
bin/pythonis usually a symlink to the system interpreter, so the interpreter itself wasn't touched.
0644 is an absolute mode, so it doesn't just remove execute bits. It also adds read permission for group and others. That means files that were 0600 (.env, keys, tokens) are now readable by any user on the machine.
Why git status shows unchanged files as modified¶
Git tracks a file's mode as part of its content snapshot. With core.fileMode set to true (the default on Linux), git compares each tracked file's execute bit with what's in the index. A script that was committed as executable and is now 0644 shows up as modified:
$ git diff manage.py
diff --git a/manage.py b/manage.py
old mode 100755
new mode 100644
There's no content diff, only a mode change. git diff --summary lists every such file:
$ git diff --summary
mode change 100755 => 100644 manage.py
mode change 100755 => 100644 bin/deploy.sh
What git records about permissions (and what it doesn't)¶
Git doesn't store Unix permissions. Each entry in a tree or in the index has one of a small set of modes:
| Mode | Meaning |
|---|---|
100644 |
Regular, non-executable file |
100755 |
Regular, executable file |
120000 |
Symbolic link |
160000 |
Gitlink (a submodule commit) |
040000 |
Tree (directory), in tree objects only |
So for a regular file, git stores one bit of information: executable or not. It doesn't record:
- Group and other permissions, or the difference between
0600and0644. - Ownership, setuid/setgid/sticky bits, ACLs or SELinux labels.
- Directory permissions.
- Anything about untracked or ignored files:
.venv/,.env,db.sqlite3, uploaded media,__pycache__/.
When git checks a file out, it writes 0666 or 0777 (non-executable or executable) and lets your umask trim it. With the usual umask 022, that gives 0644 and 0755. That's why restoring 755 on executable files and leaving everything else at 644 reproduces what a fresh clone would give you.
This also tells you the limits of the recovery: git can restore the execute bit on tracked files and nothing else. Everything else on this page is on you.
Step 1: Restore execute bits on tracked files¶
git ls-files -s prints the index (staging area), one line per tracked file:
100755 8f2c1e4b...a91 0 manage.py
100644 3b18e512...0c2 0 config/settings.py
100755 d41a7f09...b7e 0 bin/deploy.sh
The fields are <mode> <object id> <stage>, then a tab, then the path. The index still holds 100755 for the scripts you broke, because chmod changes the working tree, not the index. So the index is your backup.
The robust loop:
git ls-files -s -z | while IFS= read -r -d '' entry; do
mode=${entry%% *} # everything before the first space
path=${entry#*$'\t'} # everything after the tab
[ "$mode" = 100755 ] && chmod 755 -- "$path"
done
How it works:
-zseparates entries with NUL bytes and stops git from quoting unusual paths. Without it, a filename containing a tab, a newline, a double quote or non-ASCII characters is printed in C-style quotes ("caf\303\251.sh"), andchmodwould get the wrong name.IFS= read -r -d ''reads one NUL-terminated entry verbatim, with no backslash processing (-r) and no whitespace trimming (IFS=).${entry%% *}and${entry#*$'\t'}split off the mode and the path using bash parameter expansion, so spaces in paths are safe.chmod 755 --: the--stops a path that begins with-from being taken as an option.- Only
100755entries are touched. Files stored as100644are already correct, and symlinks (120000) and submodules (160000) are skipped.
The shorter version is fine if your paths are ordinary:
git ls-files -s | while read -r mode obj stage path; do
[ "$mode" = 100755 ] && chmod 755 -- "$path"
done
Run the loop from the repo root, because git ls-files prints paths relative to the current directory.
Alternative: reverse only the mode changes with git apply¶
This one-liner builds a reverse patch, keeps only the mode-change lines, and applies it:
git diff -p -R --no-ext-diff --no-color --diff-filter=M \
| grep -E '^(diff|(old|new) mode)' --color=never \
| git apply
It produces the same result, and only touches files whose mode actually changed. The loop is easier to read and reason about, so it's the one to keep in muscle memory.
What not to do¶
git checkout -- .orgit restore .would restore the modes, but they also throw away every uncommitted content change in the working tree. Only use them on a clean tree.git config core.fileMode falsemakes the modified files disappear fromgit status, but it fixes nothing. Your scripts are still non-executable, and git will now silently ignore real mode changes too.git add -Aand commit would record the broken modes in history, so the next clone or deploy gets non-executable scripts.
Step 2: Fix what git doesn't track¶
Git can't help with anything ignored or untracked. Work through these by hand.
Virtualenv¶
The console scripts in a virtualenv (pip, gunicorn, django-admin, activate helpers) are regular files, so they lost +x:
find .venv/bin venv/bin -type f -exec chmod 755 {} + 2>/dev/null
-type f skips the python symlinks, which were never touched. If the virtualenv still misbehaves, recreating it is the cleanest fix:
rm -rf .venv
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
Git hooks¶
Hooks in .git/hooks/ only run if they're executable, and git doesn't track them. Restore the ones you actually use and leave the .sample files alone:
find .git/hooks -type f ! -name '*.sample' -exec chmod 755 {} +
If your hooks are symlinks into the repo (for example .git/hooks/pre-commit -> ../../scripts/pre-commit), -type f won't match them. Their targets are tracked files, so step 1 already fixed them.
Secrets¶
Anything that was 0600 is now readable by other users. Lock it down again:
chmod 600 .env 2>/dev/null
Then look for anything else that should be private:
ls -la
find . -path ./.git -prune -o -type f \( -name '.env*' -o -name '*.key' -o -name '*.pem' -o -name '*secret*' \) -print
If a secret sat world-readable on a shared machine, consider rotating it.
Inside .git¶
The find also changed files under .git/, and none of it breaks git. Loose objects and packs are normally 0444 (read-only) and are now 0644. Git doesn't depend on that, but if you want them back the way git writes them:
find .git/objects -type f -exec chmod 444 {} +
Anything else that was executable but untracked¶
Look for shebang scripts that aren't executable:
git ls-files --others -z | xargs -0 grep -lIs '^#!' 2>/dev/null
With no exclude options, git ls-files --others lists every untracked file, ignored ones included. The grep keeps the ones whose first line is a shebang. Decide case by case which should be 755.
Step 3: Verify¶
git diff --summary | grep 'mode change'
This should print nothing. Then:
git status
The files that only had mode changes should have dropped off the list. Whatever remains has real content changes. Run git diff <file> on anything you don't recognize.
Spot-check a few files:
ls -l manage.py bin/ .env
stat -c '%a %n' manage.py .env
If the app is built into a container image, rebuild it after fixing permissions. A COPY'd entrypoint script keeps the mode it had on disk at build time, so an image built from the broken tree fails with permission denied at startup.
Step 4: Don't do it a third time¶
Scope the command to what you meant¶
The command you actually wanted only touches markdown files under the docs directory:
find content/docs -type f -name '*.md' -exec chmod 644 {} +
Two habits make this safer:
- Name the path explicitly instead of
., so the command does the same thing whichever directory you're in. - Filter by name (
-name '*.md'), so a script that ends up incontent/doesn't lose its execute bit.
{} + passes many files to each chmod call instead of starting one process per file the way \; does. On a large tree it's much faster.
Preview before you change anything¶
Swap the -exec for -print (or -ls) and check the list first:
find content/docs -type f -name '*.md' -print | head
find content/docs -type f -name '*.md' | wc -l
Normalize permissions without breaking executables¶
If what you really want is "files 644, directories 755, but keep executables executable", symbolic modes with a capital X do it in one command:
chmod -R u=rwX,go=rX content/docs
The capital X grants execute only to directories and to files that already have an execute bit. Directories get 755, regular files get 644, and scripts stay 755. Run against the whole repo, this is harmless, which can't be said of 0644.
| Command | Regular file | Script | Directory |
|---|---|---|---|
find . -type f -exec chmod 0644 {} + |
644 | 644 (broken) | unchanged |
chmod -R 0644 . |
644 | 644 (broken) | 644 (unenterable) |
chmod -R u=rwX,go=rX . |
644 | 755 | 755 |
Wrap it in a function¶
Put the safe version behind a name you'll remember, in ~/.bashrc:
fixdocperms() {
local root=/opt/web/django-docs/content/docs
find "$root" -type f -name '*.md' -exec chmod 644 {} +
find "$root" -type d -exec chmod 755 {} +
}
Using an absolute path means it does the same thing from any directory.
Catch bad modes before they're committed¶
A pre-commit hook can refuse commits that change a file's mode, so a mistake like this can't slip into history:
#!/usr/bin/env bash
# .git/hooks/pre-commit: block accidental mode changes
if git diff --cached --summary | grep -q 'mode change'; then
echo "pre-commit: staged mode changes found:" >&2
git diff --cached --summary | grep 'mode change' >&2
echo "Commit with --no-verify if this is intentional." >&2
exit 1
fi
Make a file executable on purpose¶
When you do want to change a tracked file's mode, change it in the index too, so it survives a fresh clone:
chmod +x scripts/new-tool.sh
git add scripts/new-tool.sh
Or set it in the index directly (also works on systems where core.fileMode is false, such as Windows):
git update-index --chmod=+x scripts/new-tool.sh
Quick reference¶
| Task | Command |
|---|---|
| See which files had their mode changed | git diff --summary \| grep 'mode change' |
| List tracked executables | git ls-files -s \| awk '$1 == "100755"' |
| Restore execute bits from the index | git ls-files -s -z loop (step 1) |
| Reverse only mode changes | git diff -p -R --diff-filter=M \| grep -E '^(diff\|(old\|new) mode)' \| git apply |
Fix directories after chmod -R |
find . -type d -exec chmod 755 {} + |
| Fix virtualenv scripts | find .venv/bin -type f -exec chmod 755 {} + |
| Fix hooks | find .git/hooks -type f ! -name '*.sample' -exec chmod 755 {} + |
| Lock down secrets | chmod 600 .env |
| Safe normalize | chmod -R u=rwX,go=rX <dir> |
| Make a tracked file executable | git update-index --chmod=+x <file> |