Andrew Mercer
on this page

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 x bit (which lets you enter them) is intact. Had you run chmod -R 0644 . instead, every directory would be drw-r--r-- and unenterable. In that case, fix directories first with find . -type d -exec chmod 755 {} +.
  • Symlinks were skipped. A virtualenv's bin/python is 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 0600 and 0644.
  • 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:

  • -z separates 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"), and chmod would 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 100755 entries are touched. Files stored as 100644 are 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 -- . or git 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 false makes the modified files disappear from git status, but it fixes nothing. Your scripts are still non-executable, and git will now silently ignore real mode changes too.
  • git add -A and 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 in content/ 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>