Every repository has one: a tracked file that everybody has to tweak slightly
on their own machine. A settings file, a sample configuration, a port number.
Deleting it is not an option, because it belongs to the repository. Committing
your edit is not an option either, because it concerns only your machine. And
that line shows up in every single git status.
The answer closest to hand is almost always one line:
git update-index --assume-unchanged ayar.env Right behind it comes its sibling, --skip-worktree. Both carry the same
promise, in one sentence: Git will now ignore your changes to this file.
The command's own manual page says something else entirely. The NOTES section
at the bottom of the git-update-index documentation states that users often
try to use these two bits for exactly this purpose, then adds that it does not
work as expected, because Git may still check working tree files against the
index during certain operations. The closing sentence is blunter still: in
general, Git does not provide a way to ignore changes to tracked files.
Which operations are "certain"? The NOTES section does not enumerate them. Two
hints sit elsewhere on the same page, and both foretell exactly what the lab
finds: the --assume-unchanged entry says Git will fail gracefully when it
needs to modify this file in the index, giving "when merging in a commit" as
its example. The "Skip-worktree bit" section says not all git commands pay
attention to the bit and some only partially support it, and that these
commands will sometimes write the files anyway in important cases such as
conflicts during a merge or rebase.
So the warning is in the documentation; it is just scattered. What is missing
is a table of which command does what.
So I filled in the table myself. Fourteen git commands, three index states —
no flag, --assume-unchanged, --skip-worktree — with a separate clone built
for every single cell, so no command could corrupt the starting state of the
next one. That is 42 runs; then the same 42 runs again on a second git
version, 84 cells in total.
The result: 6 of 13 commands genuinely ignore the local change. The other 7 do
not. Three of them roll your file back — and outside the matrix, in a genuine
three-way merge, there is a fourth: a command that says it aborted, on a file
that would have survived without the flag.
The lab
The setup is deliberately boring: a bare "remote," a clone of it, a tracked
file named ayar.env in the clone and a file named other.txt that nobody
touches. The remote changes ayar.env once and pushes, so that pull and
merge have something real to collide with. There is also a branch named
dal where the same file holds different content; switch goes there.
Every cell starts with these three steps:
git clone up.git wt && cd wt printf 'LOCAL=1\n' > ayar.env # uncommitted local change git update-index --assume-unchanged ayar.env # or --skip-worktree, or nothing Then a single command runs and three things get recorded: the command's exit
code, the letter git ls-files -v prints, and whether the content of
ayar.env is still LOCAL=1. That last one matters most, because the real
test of "did the bit do its job" is whether the file is still where you left
it.
Two environments:
| Environment | git version | Source |
|---|---|---|
| macOS 26.6.2 (Darwin 25.6) | 2.48.1 | Homebrew |
| Same machine | 2.54.0 | Apple Git-157 |
The difference between the 42 cells of the two versions: zero. The diff
output is empty. Every number in this article came out byte-identical on both
git versions, so there is a single table below. Those 84 cells are the main
matrix only; the deletion, sparse-checkout, recovery and three-way merge runs
each had their own setup.
The six commands that do ignore it
The bit genuinely works where commands report the state of the file:
| Command | No flag | --assume-unchanged | --skip-worktree |
|---|---|---|---|
git status --short | M ayar.env | no output | no output |
git diff --name-only | ayar.env | no output | no output |
git add -A | stages it | does not | does not |
git add ayar.env | stages it | does not (exit 0) | error, exit 1 |
git commit -am | commits | "nothing to commit" | "nothing to commit" |
git stash push | stashes it | "No local changes to save" | same |
In five of those six rows the two bits behave identically. That is why the
first impression is good: status is clean, diff is empty, and commit -a
will not ship your local setting to the repository by accident. These six rows
are the reason people say it works.
Two details deserve attention. First, with --skip-worktree set, trying to
git add the file by name gets you rejected in sparse-checkout language:
The following paths and/or pathspecs matched paths that exist outside of your sparse-checkout definition, so will not be updated in the index: ayar.env hint: If you intend to update such entries, try one of the following: hint: * Use the --sparse option. hint: * Disable or modify the sparsity rules. hint: Disable this message with "git config set advice.updateSparsePath false" Sparse checkout is not enabled in this repository. The moment Git sees the
skip-worktree bit, it treats the path as being outside the sparse definition;
the advice even has a name, advice.updateSparsePath. That is what the bit is
for in the first place: its primary purpose is to enable sparse checkouts,
i.e. working directories that hold only a subset of the paths. In the partial
clone article, where sparse-checkout turned out not to shrink the pack at all,
this bit was the mechanism behind the curtain; here it steps in front of it.
Second, the stash row looks like good news but is not. "No local changes to
save" means your change did not go into the stash. The next section shows
why that matters.
The four commands that do not care
The real question is what happens in commands that want to touch the file.
switch, merge, pull, rebase — all four exit 1 in all three states and
leave the file where it is. The message switch prints is byte-identical
across the three:
error: Your local changes to the following files would be overwritten by checkout: ayar.env Please commit your changes or stash them before you switch branches. Aborting The exact message you would get in a clone with no flag at all. And the
message prints the file's name: the information status was hiding gets
shouted back at you two lines later by switch. merge and pull print the
"overwritten by merge" variant of the same sentence; the three states do not
diverge there either.
rebase does diverge — it is the only one, and not in the bit's favour. In an
unflagged clone, rebase stops at its own pre-check:
error: cannot rebase: You have unstaged changes. error: Please commit or stash them. Set either bit and that pre-check passes, because it looks at the index diff
and the bit hides the diff. Rebase gets one step further and then falls over
while trying to detach HEAD:
error: Your local changes to the following files would be overwritten by checkout: ayar.env Please commit your changes or stash them before you switch branches. Aborting error: could not detach HEAD Exit code 1 again, file intact again. The bit's only achievement is moving the
failure one stage later.
One detail up front: in these rows merge and pull were fast-forwards — the
Updating: line in the output says so. If your branch has a commit of its
own, the merge becomes a genuine three-way merge and the table does not stay
the same there. That deserves its own section; it is coming up.
No single command is "missing" the bit here; these four go through the same
code path. The three-way merge machinery runs through verify_uptodate_1 in
unpack-trees.c, and that function explicitly lists both bits and says to
keep checking anyway. The comment in the source uses one word for the two bits
— «CE_VALID and CE_SKIP_WORKTREE cheat» — and goes on to say the entry must be
re-checked because the file may be overwritten. Right below it the file is inspected with lstat, and the result goes to
ie_match_stat() together with the flags CE_MATCH_IGNORE_VALID and
CE_MATCH_IGNORE_SKIP_WORKTREE: the comparison function is told, in so many
words, to ignore these two bits.
So Git is not failing to see the bit. It sees it and ignores it on purpose.
For these four commands there is no difference at all between flagged and
unflagged. This is the most honest row of the table: the bit does not even
take part.
The command that gets worse
The git reset --hard row is different. In an unflagged clone the command
works: exit 0, the local change is gone, the file takes the remote's content.
Expected behaviour; --hard says what it does.
Set either bit and the same command turns into this:
error: Entry 'ayar.env' not uptodate. Cannot merge. fatal: Could not reset index file to revision 'origin/main'. Exit code 128. Identical for both bits. A command that completes successfully
without the flag now dies with a fatal error because of it. The file survives
— but somebody typing reset --hard was not asking for the file to survive;
they were asking for a clean repository, and the repository is not clean. If
that line sits inside a script, the script stops.
The first message comes from the error-string table in unpack-trees.c:
ERROR_NOT_UPTODATE_FILE. The same table carries a warning reserved for
sparse paths — WARNING_SPARSE_NOT_UPTODATE_FILE, i.e. "not up to date; I
will not remove it from the working tree." The language Git keeps for these
two bits all points the same way: where the index and the working tree cannot
be trusted against each other, you get a harder failure or a refusal, not a
success.
The two commands that roll back your edit
The two most expensive cells in the table are here, and both are on the
--assume-unchanged side.
git update-index --assume-unchanged ayar.env git restore ayar.env # or: git checkout -- ayar.env Exit code 0. Nothing is printed. The content of the file is no longer
LOCAL=1; it is whatever the index holds. The local change is gone.
There is no recovery path either. In that same cell git stash list is empty
— because, as two sections ago showed, stash had looked at the flagged file
and said "No local changes to save." The reflog holds nothing either. The
change never entered the object database, so there is no copy left to bring
back.
git checkout -f main behaves the same way: exit 0 and ayar.env reset to
the index content. This time there are three lines on screen, but none of them
mentions the file — Already on 'main' and two lines about the branch being
one commit behind.
--skip-worktree behaves differently in these two cells. restore refuses:
error: pathspec 'ayar.env' did not match any file(s) known to git And checkout -f does not touch the file at all — exit 0 and the content
stays LOCAL=1. This is the one place in the table where --skip-worktree
actually protects your file at a point where --assume-unchanged walks past
without a word.
Here is the total. These are the three commands that exist to overwrite the
file in the working tree: reset --hard, restore, checkout -f. In an unflagged clone
all three roll the local change back. With --assume-unchanged, two of them
do. With --skip-worktree, none. So the bit that partially keeps the "ignore
it" promise is not the one that comes to mind first.
A third loss: a genuine merge
The merge row in the table above was a fast-forward; now it is that row's
turn. If your branch carries a commit of its own, the merge becomes a
three-way merge and the ort strategy takes over. The setup: a local commit
on other.txt, a remote commit touching ayar.env, and a flagged, modified
ayar.env in the working tree.
In all three states the merge aborts with the same error, this time with exit
code 2:
error: Your local changes to the following files would be overwritten by merge: ayar.env Please commit your changes or stash them before you merge. Aborting Merge with strategy ort failed. Now look at the file after that abort:
| Index state | exit | ayar.env content |
|---|---|---|
| no flag | 2 | LOCAL=1 |
--assume-unchanged | 2 | UPSTREAM=1 |
--skip-worktree | 2 | LOCAL=1 |
A command that says it aborted has rolled the local change back on the
--assume-unchanged side. There is no .git/MERGE_HEAD, so no half-finished
merge is sitting there either; the stash is empty again. Running that row
three times gives the same result all three times, and git 2.54.0 agrees. In
the same setup rebase does not do this: exit 1 in all three states and the
file stays put.
So the loss list does not end at restore and checkout -f. A merge that
says "I touched nothing, I aborted" can roll your file back too, as long as
assume-unchanged is set.
The full table
The sections above showed the table piece by piece; here it is whole. Each
cell holds the command's exit code and its outcome. In the first six rows the
outcome is what the command reported or staged; in the rows that can touch the
file, the cell holds the content afterwards (LOCAL=1 = local change intact,
UPSTREAM=* = rolled back). The last two columns summarise each bit's
verdict.
| Command | No flag | --assume-unchanged | --skip-worktree | AU | SW |
|---|---|---|---|---|---|
status --short | 0 · reports | 0 · no output | 0 · no output | ✓ | ✓ |
diff --name-only | 0 · reports | 0 · no output | 0 · no output | ✓ | ✓ |
add -A | 0 · stages | 0 · does not | 0 · does not | ✓ | ✓ |
add ayar.env | 0 · stages | 0 · does not | 1 · error | ✓ | ✗ |
commit -am | 0 · commits | 1 · "nothing to commit" | 1 · "nothing to commit" | ✓ | ✓ |
stash push | 0 · stashes | 0 · "no local changes" | 0 · same | ✓ | ✓ |
switch dal | 1 · LOCAL=1 | 1 · LOCAL=1 | 1 · LOCAL=1 | ✗ | ✗ |
merge origin/main | 1 · LOCAL=1 | 1 · LOCAL=1 | 1 · LOCAL=1 | ✗ | ✗ |
pull origin main | 1 · LOCAL=1 | 1 · LOCAL=1 | 1 · LOCAL=1 | ✗ | ✗ |
rebase origin/main | 1 · LOCAL=1 | 1 · LOCAL=1 | 1 · LOCAL=1 | ✗ | ✗ |
reset --hard | 0 · UPSTREAM=2 | 128 · LOCAL=1 | 128 · LOCAL=1 | ✗ | ✗ |
restore ayar.env | 0 · UPSTREAM=1 | 0 · UPSTREAM=1 | 1 · LOCAL=1 | ✗ | ✗ |
checkout -f main | 0 · UPSTREAM=1 | 0 · UPSTREAM=1 | 0 · LOCAL=1 | ✗ | ✓ |
merge (three-way) | 2 · LOCAL=1 | 2 · UPSTREAM=1 | 2 · LOCAL=1 | (✗) | (✗) |
clean -fdx | 0 · LOCAL=1 | 0 · LOCAL=1 | 0 · LOCAL=1 | — | — |
The three-way merge row sits outside the main matrix — it needs a different
setup, which the next section covers. It is not scored, which is why its marks
are in parentheses. It is here only so that a reader skimming the table does
not walk away thinking merge is harmless.
The criterion for the last two columns is a single question: did the command
treat the file as unchanged and carry on? Any row that rejects the path by
name, names the file in an error message, or writes over the file gets a ✗.
clean -fdx sits outside the scoring: it never touches tracked files in any
state, so the bits are irrelevant to it.
Of the thirteen scored rows, six are ✓ and seven are ✗ — and those two numbers
are the same for both bits. They part ways in exactly two cells: add (AU passes, SW rejects) and
ayar.envcheckout -f (AU rolls your file back,
SW protects it).
The two bits are not the same thing
git ls-files -v tells you which bit sits on which file, but you need the
letter dictionary. The git-ls-files documentation lists the -t letters:
H is a tracked file that is neither unmerged nor skip-worktree, S is a
tracked file that is skip-worktree. (The list holds other letters too — M,
R, C, U — and the scan below can turn them up.) The only job of -v is to lowercase that letter for files marked
assume-unchanged.
Four combinations:
| Index state | git ls-files -v |
|---|---|
| no flag | H ayar.env |
--assume-unchanged | h ayar.env |
--skip-worktree | S ayar.env |
| both at once | s ayar.env |
All four came out of the lab and line up exactly with the letter dictionary in
the documentation.
There is a small trap here. Passing both bits in a single
update-index invocation does not work:
git update-index --assume-unchanged --skip-worktree ayar.env git ls-files -v ayar.env # h ayar.env ← the skip-worktree bit was never set Reversing the option order changes nothing; both attempts land on h. The
reason is the update_one function in builtin/update-index.c: the
mark_valid_only branch is checked first, sets its bit and returns — the
skip-worktree branch is never reached. If you want both bits, you need two
separate invocations. And then, when you write --no-assume-unchanged, you
drop to the letter S; the other bit is still there.
What it says about deletion
The bits hide the "changed" signal, and they hide the "gone" signal just as
well. Delete the file from disk and look:
git update-index --skip-worktree ayar.env rm ayar.env git status --porcelain # no output git add -u # nothing gets staged git commit -am "deleted?" # "nothing to commit, working tree clean" git ls-tree --name-only HEAD ayar.env # ayar.env ← still in HEAD The documentation states this plainly: for skip-worktree paths, git add -u
will not stage a deletion, and git commit -a will not make a commit deleting
them either. The lab produces exactly that output. The --assume-unchanged
side is the same: status empty, add -u empty.
This also explains what the skip-worktree bit is really for — sparse checkout.
Not treating a missing file as an intentional deletion is the precondition for
keeping only part of the tree on disk.
And there is this: when sparse checkout is on and the file is present in the
working tree, Git clears the bit by itself. The documentation says so, and so
does the lab:
git update-index --skip-worktree ayar.env # S ayar.env git config core.sparseCheckout true printf '/*\n' > .git/info/sparse-checkout git read-tree -mu HEAD git ls-files -v ayar.env # H ayar.env ← bit gone Where the feeling of "it works" comes from
You can set the flag and go days without trouble. The reason is simple:
nothing breaks as long as the remote does not touch that file.
git update-index --assume-unchanged ayar.env # the remote changed other.txt and never touched ayar.env git pull --no-rebase origin main # exit 0, bit still h, ayar.env still LOCAL=1 A week goes by like that. Then somebody adds a line to ayar.env and pushes:
git pull --no-rebase origin main # error: Your local changes to the following files would be overwritten by merge: # ayar.env # exit 1 Same flag, same file, same command; the only difference is that the remote
touched that file. Switching branches is the same story: if ayar.env is not
different on the target branch, the switch goes through cleanly and the bit
stays put. That is how the flag can look like it "works" for months and then
blow up on the day that matters.
One more detail: the bit lives in your index, which makes it local. In a fresh
clone the same file starts out with the letter H. It does not travel to your
teammate's machine, and it does not reach CI. Sometimes that is good,
sometimes bad; just do not mistake it for a property of the repository.
How to undo it
--no-assume-unchanged removes the bit and your change becomes visible again
— if the file is still there. There is also --really-refresh, mentioned in
the documentation, which checks file state regardless of whether the bit is
set:
git update-index --really-refresh # ayar.env: needs update git ls-files -v ayar.env # H ayar.env ← bit cleared git status --porcelain # M ayar.env So if you are on a machine where "status is clean but something feels off,"
git ls-files -v | grep -v '^H ' answers the question in two seconds. Every
line showing a lowercase letter or an S is a state Git is deliberately not
showing you.
How many are on this machine
Because the bit is local and invisible, a bit someone set years ago may well
still be sitting there. The scan is one line:
git ls-files -v | grep -v '^H ' | grep -E '^[a-z] |^S |^s ' To prove that the line actually finds something, it needs a deliberate
positive first — two files, one bit each, same line:
h f S g The line works. One subtlety first: the bit is written to the index, not
the repository, so every linked worktree can carry its own. Under
~/IdeaProjects there are 110 git indexes — 59 main checkouts and 51 linked
worktrees. The scan ran in all 110: 0 flagged entries. Neither
assume-unchanged nor skip-worktree is in use on this machine. Zero is a result
too; for this machine the question "is an old bit breaking something" is now
closed. When you run it, remember to scan each tree in git worktree list
separately.
The bit does not even have to be set by hand. With core.ignoreStat enabled,
Git sets the assume-unchanged bit itself on tracked files it has updated
identically in both the index and the working tree; per the documentation the
setting is false by default and its real use case is systems where lstat()
is very slow. When it is on and you modify files outside of Git, you have to
stage them explicitly — Git will not normally detect those changes. So the
scan line above may come back non-empty even in a repository where nobody ever
typed update-index.
What the documentation itself recommends
The NOTES section of the git-update-index page also offers the alternative:
if the file is some sort of config file, let the repository carry a sample
config file that gets copied into the ignored name and modified. The
repository can even include a script that treats the sample as a template and
copies it automatically.
That path went through the lab too:
git rm --cached ayar.env mv ayar.env ayar.env.ornek printf 'ayar.env\n' > .gitignore git add -A && git commit -m "sample file + gitignore" cp ayar.env.ornek ayar.env && printf 'LOCAL=1\n' > ayar.env Results:
| Command | Result |
|---|---|
git status --porcelain | no output |
git pull (remote changed the sample) | exit 0; ayar.env is LOCAL=1, sample updated |
git reset --hard origin/main | exit 0; ayar.env is LOCAL=1 |
git clean -fdx -e ayar.env | file intact |
git clean -fdx | file deleted |
The first four rows deliver, in a four-line change, what the two bits failed
to deliver across 13 commands: the file is invisible, pull works,
reset --hard works and does not touch the file. The fifth row is the price:
the file is untracked now, so git clean -fdx deletes it. That is not wrong
behaviour — clean exists for precisely that — but if -fdx is muscle memory
for you, budget for adding -e or keeping the file in a directory you
protect.
There is a second price, and it lands on your teammates rather than on you.
Once the git rm --cached plus .gitignore commit is pushed, the two
possibilities on the other side measure out like this:
| Teammate's state | git pull | Outcome |
|---|---|---|
| clean working tree | exit 0 | ayar.env disappears from the working tree (Git sees it as a rename) |
| local edit in the file | exit 1 | "would be overwritten by merge" — blocked until they move the file |
Both are surprises. In the first nobody gets an error and the settings file
just goes away; in the second pull stops. Do not make this migration without
announcing it: a one-line note (cp ayar.env.ornek ayar.env) or the template
script the documentation mentions closes both cases.
A decision frame
It comes down to three questions:
- Does my edit to this file concern only me? Then the file should not be tracked. A sample file plus
.gitignoreis the only correct answer; the bits are not. - Do I want the file absent from my working tree entirely? That is sparse checkout; use
git sparse-checkout. The documentation now explicitly discourages the low-levelupdate-index/read-treepair for this job. - Is my filesystem genuinely slow at
lstat()? Per the documentation the assume-unchanged bit exists for exactly that — cutting scan cost in an environment like CIFS. But then you owe the other half of the contract: the promise you made not to change the file. If you change it, you clear the bit by hand.
Six of the thirteen commands in the table honour the flag, seven do not. But I
do not think the real lesson of this exercise is in the numbers. The real
lesson is that a tool's NOTES section is where it writes down the promise it is
not making. On this page that section is two paragraphs long and says "this
does not work that way." The one-line advice closest to hand sounds certain.
Closing the gap between them took an 84-cell matrix and a handful of separate
runs beside it — while reading those two paragraphs takes thirty seconds.
The shape of this is familiar: you set a hint flag, some consumers read it,
some do not, and nowhere is it written down which is which. The same thing
came out of the disk schedulers on the
ionice side,
where none never read the hint at all. Different field, same behaviour.
Git's index is not a cache; it is a contract. Try to amend the clauses with
bits and the other party keeps reading them anyway.
Official Sources
- git-update-index documentation —
--assume-unchanged,--skip-worktree, the "Skip-worktree bit" section and NOTES - git-update-index, kernel.org mirror — the upstream copy of the same page
- git-ls-files documentation — the
-tand-vletter dictionary (H,S, lowercase) - unpack-trees.c —
verify_uptodate_1,CE_MATCH_IGNORE_VALIDandERROR_NOT_UPTODATE_FILE - builtin/update-index.c — the early return of the flag branches inside
update_one - config/advice.adoc — the definition of the
advice.updateSparsePathhint



