There's no handy built in solution for this, but it's easy to script: Git has a large tool-set that you can draw upon.
First, though, let's establish some background information. You're not sure how this all came about, so there's a good chance it will happen again, and this time you can nip it in the bud (and figure out what did it). If you want to jump straight to a potential solution, zip down to the bottom section.
Git stores commits. You probably already know that, but let's just write it down. :-) Each commit is uniquely identified by some big ugly hash ID, deadcab1feeddad2...
or whatever. These commits, once made, can never be changed. Each commit has some metadata—such as your name and email address and a time stamp, and your log message, and also stores the raw hash ID of its parent commit (or for merge commits, both/all parents).
Commits store files, as a snapshot of every file that was in Git's index at the time you ran git commit
. The index is the reason you have to keep running git add
. The files stored in the commit are in a special, Git-only, compressed form where they're frozen for as long as the commit exists.
When you check out some commit, Git extracts the entire snapshot into the index, so that the index now matches the commit.1 That "un-freezes" the files: once they are in the index, you can change them. But they are still in the special Git-only format. So now Git extracts the index into the work-tree, turning the files into their useful form.
Files in the work-tree have permissions. Files in the index have just one single permission-indicating bit, namely: Should this file be executable? For historical (and Linux) reasons, Git presents this as mode 100644
, "not executable", or mode 100755
, "executable", but it really only allows the one flag bit. The rest of the work-tree permissions come from whatever you have set for your system / yourself when you extract the files.
When you run git add
on a file, you are really telling Git: Copy the work-tree file into the index copy. This Git-ifies the data, making it ready to be frozen into a commit. It also sets the mode based on whether the file is currently executable.
Normally, git diff
will tell you if there's a mode-change from 100644 to 100755 or vice versa, so you can use git diff
to compare. Since there are three copies of each file—committed, indexed, and work-tree—you need two git diff
s to find all the changes:
- What's different between the commit and the index?
- What's different between the index and the work-tree?
You can run git diff --cached
or git diff --staged
to get the first set, and git diff
with no options at all to get the second set. (Note that if the executable mode has changed, Git will consider the file to be changed.) The git status
command runs both diffs, but summarizes them in a way that does not show the mode explicitly.
1This is not entirely accurate (see Checkout another branch when there are uncommitted changes on the current branch for details), but helps as the starting mental model.
Branch B has the intended file permissions for all of those files.
We also need a little detour here about the fact that branches don't mean that much in Git. (See also What exactly do we mean by "branch"?.)
In Git, a branch name—I'll use branch-A
and branch-B
here for the two names—simply identifies one specific commit.
I mentioned above that each commit has a unique hash ID. Multiple names can have the same hash ID, though. That's how new branches start out, in fact. Meanwhile each commit "points back" to its parent, which is how a new branch starts out:
A <-B <-C <--master
Here, there's just the one branch, master
. It has the hash ID of commit C
. This is the last commit contained within the branch (and also the last commit in the repository, in this case). Meanwhile commit C
remembers the hash ID of B
, which remembers the ID of A
. A
is the very first commit, so it has no parent and the action stops here.
To add a new commit to master
, Git freezes the index into a new snapshot, adds the log message and your name and so on for metadata, and sets the new commit's parent to C
. This produces the hash ID for new commit D
, which Git now writes into the name master
, giving:
A <-B <-C <-D <--master
Since the internal arrows can't change (they're part of commits!), we just need to remember that they always point backwards (leftwards in these drawings). So at some point we have some commit hash, let's call it H
for short, at which we create a name branch-A
:
...--H <-- branch-A
If we also create the name branch-B
now, we have both names pointing to H
:
...--H <-- branch-A, branch-B
This means that both branches contain the same commits (starting from H
and working backwards). The snapshot in H
will exactly match the snapshot in H
, of course, down to file modes as well as contents. So there can be no difference between the two commits.
Hence, if you see different permissions for files when checking out branch-A
vs when checking out branch-B
, they must point to different commits, otherwise the executable/not-executable flags on the files would match.
However, fixing the permissions in your work-tree (and index) will not change any existing commits. That's literally impossible: Git itself cannot do this. You can only make new commits. If you need to fix older commits, you must copy the ones with the wrong permissions to new commits whose main difference is that they have correct permissions:
...--H--I--J <-- branch-A
\
K--L <-- branch-B
The permissions are now wrong in commit J
, and correct in commit L
: but are they right or wrong in commit I
, which is also only on branch-A
? Do you want to make a new commit that's just like I
, except with the right permissions? If you do, you must make a replacement for J
as well, that's much like J
but uses I
's replacement as its parent.
If it's sufficient to just make a new commit on branch-A
that sets its files to the executable-mode taken from commit L
, that's relatively trivial. You will end up with:
...--H--I--J--M <-- branch-A
\
K--L <-- branch-B
where commit M
matches commit J
in everything except the stored executable bit for each such file.
Viewing the modes in any commit
To see the file names and their modes for any given commit, use git ls-tree
with the -r
(recursive) option. Simply identify the commit you want:
git ls-tree -r <hash>
The output is one line per committed file. Since Git stores only files, the default recursive listing does not include any intermediate sub-trees needed for Git's internal representation, and you'll get a listing of all blob
objects—these are Git's internal representation for files—except for two special cases: symbolic links and submodules (technically, gitlinks at this level). If you have neither of those, you can ignore the special cases.
The output will resemble this, which is the first part of the output on an actual Git repository (for Git itself):
100644 blob 12a89f95f993546888410613458c9385b16f0108 .clang-format
100644 blob 49b30516419c8dfe8c039ef368a3af984439ebcc .gitattributes
100644 blob 64e605a02b71c51e9f59c429b28961c3152039b9 .github/CONTRIBUTING.md
100644 blob adba13e5baf4603de72341068532e2c7d7d05f75 .github/PULL_REQUEST_TEMPLATE.md
The first field is the mode
string. The second is always blob
(except for those special cases that you probably don't have, but if you do, filter away any non-"blob" lines). The third is the internal hash ID for the file's snapshot, and the last field, after a literal ASCII tab character, gives the file's path name as seen in that snapshot.
To get the above, I ran:
git ls-tree -r HEAD
I could also have used git ls-tree -r master
or git ls-tree -r f84b9b09d40408cf91bbc500d9f190a7866c3e0f
: all of those identify commit f84b9b09d40408cf91bbc500d9f190a7866c3e0f
, which is the tip of the master
branch. In your case, you can use git ls-tree -r branch-B
to get all the mode and name items from the tip commit of branch-B
.
Changing work-tree modes and committing
At this point, then, you just need to have the current tip of branch-A
checked out. Then, from the modes and names, you can use whatever your computer's "change the mode of the file" command is to set the desired work-tree mode. git add
all the updated work-tree files to copy their new modes into the index, and run git commit
. If you are on a Linux system, for instance, you can use chmod
directly (note: this has a small flaw if you have files whose names contain white-space, as read
won't respect it properly unless you fiddle with IFS
):
git checkout branch-A
git ls-tree -r branch-B | while read mode ftype hash path; do
[ $ftype = blob ] && chmod "$path" $mode
done
git add -u
git commit
This will complain about any files in the commit that branch-B
names that do not exist in the commit extracted by git checkout branch-A
. If you have white-space-named files, you may get errors here as well, so be sure to check.
You can fancy this up as needed, but that's the essence. Note that if you want to rebuild existing commits, you can use an interactive git rebase
to do it one commit at a time.