diff mbox series

[v2,2/2] shallow.c: use '{commit,rollback}_shallow_file'

Message ID 296e70790d7a391d471554b0bc5a58e2a091ce88.1587601501.git.me@ttaylorr.com (mailing list archive)
State New, archived
Headers show
Series shallow.c: reset shallow-ness after updating | expand

Commit Message

Taylor Blau April 23, 2020, 12:25 a.m. UTC
In bd0b42aed3 (fetch-pack: do not take shallow lock unnecessarily,
2019-01-10), the author noted that 'is_repository_shallow' produces
visible side-effect(s) by setting 'is_shallow' and 'shallow_stat'.

This is a problem for e.g., fetching with '--update-shallow' in a
shallow repository with 'fetch.writeCommitGraph' enabled, since the
update to '.git/shallow' will cause Git to think that the repository
isn't shallow when it is, thereby circumventing the commit-graph
compatibility check.

This causes problems in shallow repositories with at least shallow refs
that have at least one ancestor (since the client won't have those
objects, and therefore can't take the reachability closure over commits
when writing a commit-graph).

Address this by introducing thin wrappers over 'commit_lock_file' and
'rollback_lock_file' for use specifically when the lock is held over
'.git/shallow'. These wrappers (appropriately called
'commit_shallow_file' and 'rollback_shallow_file') call into their
respective functions in 'lockfile.h', but additionally reset validity
checks used by the shallow machinery.

Replace each instance of 'commit_lock_file' and 'rollback_lock_file'
with 'commit_shallow_file' and 'rollback_shallow_file' when the lock
being held is over the '.git/shallow' file.

As a result, 'prune_shallow' can now only be called once (since
'check_shallow_file_for_update' will die after calling
'reset_repository_shallow'). But, this is OK since we only call
'prune_shallow' at most once per process.

Helped-by: Jonathan Tan <jonathantanmy@google.com>
Helped-by: Junio C Hamano <gitster@pobox.com>
Signed-off-by: Taylor Blau <me@ttaylorr.com>
---
 builtin/receive-pack.c   |  4 ++--
 commit.h                 |  2 ++
 fetch-pack.c             | 10 +++++-----
 shallow.c                | 30 +++++++++++++++++++++---------
 t/t5537-fetch-shallow.sh | 29 +++++++++++++++++++++++++++++
 5 files changed, 59 insertions(+), 16 deletions(-)

Comments

Jonathan Nieder April 23, 2020, 1:23 a.m. UTC | #1
Taylor Blau wrote:

> --- a/builtin/receive-pack.c
> +++ b/builtin/receive-pack.c
> @@ -872,12 +872,12 @@ static int update_shallow_ref(struct command *cmd, struct shallow_info *si)
>  	opt.env = tmp_objdir_env(tmp_objdir);
>  	setup_alternate_shallow(&shallow_lock, &opt.shallow_file, &extra);
>  	if (check_connected(command_singleton_iterator, cmd, &opt)) {
> -		rollback_lock_file(&shallow_lock);
> +		rollback_shallow_file(the_repository, &shallow_lock);

I like it.

I wonder, is there a way we can make it more difficult to accidentally
use rollback_lock_file where rollback_shallow_file is needed?  For
example, what if shallow_lock has a different type "struct
shallow_lock" so one would have to reach in to its lock_file member to
bypass the shallow_file interface?

[...]
>  		oid_array_clear(&extra);
>  		return -1;
>  	}
>  
> -	commit_lock_file(&shallow_lock);
> +	commit_shallow_file(the_repository, &shallow_lock);
>  
>  	/*
>  	 * Make sure setup_alternate_shallow() for the next ref does
> diff --git a/commit.h b/commit.h
> index 008a0fa4a0..ab91d21131 100644
> --- a/commit.h
> +++ b/commit.h
> @@ -249,6 +249,8 @@ struct oid_array;
>  struct ref;
>  int register_shallow(struct repository *r, const struct object_id *oid);
>  int unregister_shallow(const struct object_id *oid);
> +int commit_shallow_file(struct repository *r, struct lock_file *lk);
> +void rollback_shallow_file(struct repository *r, struct lock_file *lk);

optional: might make sense to put this near setup_alternate_shallow
for discoverability

Could this have an API doc comment?

[...]
> --- a/shallow.c
> +++ b/shallow.c
> @@ -40,13 +40,6 @@ int register_shallow(struct repository *r, const struct object_id *oid)
>  
>  int is_repository_shallow(struct repository *r)

Not about this patch: it might make sense to split out a shallow.h
header / API.

Thanks and hope that helps,
Jonathan
Jonathan Tan April 23, 2020, 6:09 p.m. UTC | #2
> Replace each instance of 'commit_lock_file' and 'rollback_lock_file'
> with 'commit_shallow_file' and 'rollback_shallow_file' when the lock
> being held is over the '.git/shallow' file.

I think Jonathan Nieder already covered 1/2 so I'll just close the loop
on this patch. There was one potential issue in that a previous version
of this patch also called reset_repository_shallow() in
setup_alternate_shallow(), but this version does not. But after looking
into it, this looks fine - setup_alternate_shallow() deals with a
passed-in alternate_shallow_file variable, which is different from the
r->parsed_objects->alternate_shallow_file that is_repository_shallow()
uses to set the global variables. (I might have confused the two during
earlier reviews.) Also, setup_alternate_shallow() is called either
before any shallow processing (empirically demonstrating that no
resetting is needed in this case, because it has been working), or right
before a commit or rollback of the lock file (so the global variables
are being reset anyway, so we do not need to call
reset_repository_shallow()).

So,
Reviewed-by: Jonathan Tan <jonathantanmy@google.com>
Junio C Hamano April 23, 2020, 8:40 p.m. UTC | #3
Jonathan Tan <jonathantanmy@google.com> writes:

>> Replace each instance of 'commit_lock_file' and 'rollback_lock_file'
>> with 'commit_shallow_file' and 'rollback_shallow_file' when the lock
>> being held is over the '.git/shallow' file.
>
> I think Jonathan Nieder already covered 1/2 so I'll just close the loop
> on this patch. There was one potential issue in that a previous version
> of this patch also called reset_repository_shallow() in
> setup_alternate_shallow(), but this version does not. But after looking
> into it, this looks fine - setup_alternate_shallow() deals with a
> passed-in alternate_shallow_file variable, which is different from the
> r->parsed_objects->alternate_shallow_file that is_repository_shallow()
> uses to set the global variables. (I might have confused the two during
> earlier reviews.) Also, setup_alternate_shallow() is called either
> before any shallow processing (empirically demonstrating that no
> resetting is needed in this case, because it has been working), or right
> before a commit or rollback of the lock file (so the global variables
> are being reset anyway, so we do not need to call
> reset_repository_shallow()).
>
> So,
> Reviewed-by: Jonathan Tan <jonathantanmy@google.com>

Thanks for a review.

And of course, thanks Taylor.  Will queue.
Taylor Blau April 24, 2020, 5:13 p.m. UTC | #4
On Thu, Apr 23, 2020 at 01:40:47PM -0700, Junio C Hamano wrote:
> Jonathan Tan <jonathantanmy@google.com> writes:
>
> >> Replace each instance of 'commit_lock_file' and 'rollback_lock_file'
> >> with 'commit_shallow_file' and 'rollback_shallow_file' when the lock
> >> being held is over the '.git/shallow' file.
> >
> > I think Jonathan Nieder already covered 1/2 so I'll just close the loop
> > on this patch. There was one potential issue in that a previous version
> > of this patch also called reset_repository_shallow() in
> > setup_alternate_shallow(), but this version does not. But after looking
> > into it, this looks fine - setup_alternate_shallow() deals with a
> > passed-in alternate_shallow_file variable, which is different from the
> > r->parsed_objects->alternate_shallow_file that is_repository_shallow()
> > uses to set the global variables. (I might have confused the two during
> > earlier reviews.) Also, setup_alternate_shallow() is called either
> > before any shallow processing (empirically demonstrating that no
> > resetting is needed in this case, because it has been working), or right
> > before a commit or rollback of the lock file (so the global variables
> > are being reset anyway, so we do not need to call
> > reset_repository_shallow()).
> >
> > So,
> > Reviewed-by: Jonathan Tan <jonathantanmy@google.com>
>
> Thanks for a review.
>
> And of course, thanks Taylor.  Will queue.

Thanks, both. I'll send some more patches on top to introduce a
'shallow_lock' type.

Thanks,
Taylor
Jonathan Nieder June 3, 2020, 3:42 a.m. UTC | #5
Hi,

Taylor Blau wrote:

> Signed-off-by: Taylor Blau <me@ttaylorr.com>
> ---
>  builtin/receive-pack.c   |  4 ++--
>  commit.h                 |  2 ++
>  fetch-pack.c             | 10 +++++-----
>  shallow.c                | 30 +++++++++++++++++++++---------
>  t/t5537-fetch-shallow.sh | 29 +++++++++++++++++++++++++++++
>  5 files changed, 59 insertions(+), 16 deletions(-)

I haven't investigated the cause yet, but I've run into an interesting
bug that bisects to this commit.  Jay Conrod (cc-ed) reports:

| I believe this is also the cause of Go toolchain test failures we've
| been seeing. Go uses git to fetch dependencies.
|
| The problem we're seeing can be reproduced with the script below. It
| should print "success". Instead, the git merge-base command fails
| because the commit 7303f77963648d5f1ec5e55eccfad8e14035866c
| (origin/master) has no history.

-- 8< --
#!/bin/bash

set -euxo pipefail
if [ -d legacytest ]; then
  echo "legacytest directory already exists" >&2
  exit 1
fi
mkdir legacytest
cd legacytest
git init --bare
git config protocol.version 2
git config fetch.writeCommitGraph true
git remote add origin -- https://github.com/rsc/legacytest
git fetch -f --depth=1 origin refs/heads/master:refs/heads/master
git fetch -f origin 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*'
git fetch --unshallow -f origin
git merge-base --is-ancestor -- v2.0.0 7303f77963648d5f1ec5e55eccfad8e14035866c
echo success
-- >8 --

The fetch.writeCommitGraph part is interesting.  When does a commit
graph file get written in this sequence of operations?  In an
unshallow operation, does the usual guard against writing a commit
graph in a shallow repo get missed?

"rm -fr objects/info/commit-graphs" recovers the full history in the
repo, so this is not a case of writing the wrong shallows --- it's
only a commit graph issue.

I'll take a closer look, but thought I'd give others a chance to look
to in case there's something obvious. :)

Thanks,
Jonathan
Taylor Blau June 3, 2020, 4:52 a.m. UTC | #6
Hi Jonathan,

On Tue, Jun 02, 2020 at 08:42:13PM -0700, Jonathan Nieder wrote:
> Hi,
>
> Taylor Blau wrote:
>
> > Signed-off-by: Taylor Blau <me@ttaylorr.com>
> > ---
> >  builtin/receive-pack.c   |  4 ++--
> >  commit.h                 |  2 ++
> >  fetch-pack.c             | 10 +++++-----
> >  shallow.c                | 30 +++++++++++++++++++++---------
> >  t/t5537-fetch-shallow.sh | 29 +++++++++++++++++++++++++++++
> >  5 files changed, 59 insertions(+), 16 deletions(-)
>
> I haven't investigated the cause yet, but I've run into an interesting
> bug that bisects to this commit.  Jay Conrod (cc-ed) reports:
>
> | I believe this is also the cause of Go toolchain test failures we've
> | been seeing. Go uses git to fetch dependencies.
> |
> | The problem we're seeing can be reproduced with the script below. It
> | should print "success". Instead, the git merge-base command fails
> | because the commit 7303f77963648d5f1ec5e55eccfad8e14035866c
> | (origin/master) has no history.
>
> -- 8< --
> #!/bin/bash
>
> set -euxo pipefail
> if [ -d legacytest ]; then
>   echo "legacytest directory already exists" >&2
>   exit 1
> fi
> mkdir legacytest
> cd legacytest
> git init --bare
> git config protocol.version 2
> git config fetch.writeCommitGraph true
> git remote add origin -- https://github.com/rsc/legacytest
> git fetch -f --depth=1 origin refs/heads/master:refs/heads/master
> git fetch -f origin 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*'
> git fetch --unshallow -f origin
> git merge-base --is-ancestor -- v2.0.0 7303f77963648d5f1ec5e55eccfad8e14035866c
> echo success
> -- >8 --

Thanks to you and Jay for the report and reproduction script. Indeed, I
can reproduce this on the tip of master (which is equivalent to v2.27.0
at the time of writing).

> The fetch.writeCommitGraph part is interesting.  When does a commit
> graph file get written in this sequence of operations?  In an
> unshallow operation, does the usual guard against writing a commit
> graph in a shallow repo get missed?

The last 'git fetch' is the one that writes the commit-graph. You can
verify this by sticking a 'ls objects/info' after each 'git' invocation
in your script.

Here's where things get weird, though. Prior to this patch, Git would
pick up that the repository is shallow before unshallowing, but never
invalidate this fact after unshallowing. That means that once we got to
'write_commit_graph', we'd exit immediately since it appears as if the
repository is shallow.

In this patch, we don't do that anymore, since we rightly unset the fact
that we are (were) shallow.

In a debugger, I ran your script and a 'git commit-graph write --split
--reachable' side-by-side, and found an interesting discrepancy: some
commits (loaded from 'copy_oids_to_commits') *don't* have their parents
set when invoked from 'git fetch', but *do* when invoked as 'git
commit-graph write ...'.

I'm not an expert in the object cache, but my hunch is that when we
fetch these objects they're marked as parsed without having loaded their
parents. When we load them again via 'lookup_object', we get objects
that look parsed, but don't have parents where they otherwise should.

I'm going to CC Stolee to see if he has any thoughts on how to handle
this and/or if my idea is on the right track.

> "rm -fr objects/info/commit-graphs" recovers the full history in the
> repo, so this is not a case of writing the wrong shallows --- it's
> only a commit graph issue.
>
> I'll take a closer look, but thought I'd give others a chance to look
> to in case there's something obvious. :)
>
> Thanks,
> Jonathan

Thanks,
Taylor
Taylor Blau June 3, 2020, 5:16 a.m. UTC | #7
On Tue, Jun 02, 2020 at 10:52:48PM -0600, Taylor Blau wrote:
> Hi Jonathan,
>
> On Tue, Jun 02, 2020 at 08:42:13PM -0700, Jonathan Nieder wrote:
> > Hi,
> >
> > Taylor Blau wrote:
> >
> > > Signed-off-by: Taylor Blau <me@ttaylorr.com>
> > > ---
> > >  builtin/receive-pack.c   |  4 ++--
> > >  commit.h                 |  2 ++
> > >  fetch-pack.c             | 10 +++++-----
> > >  shallow.c                | 30 +++++++++++++++++++++---------
> > >  t/t5537-fetch-shallow.sh | 29 +++++++++++++++++++++++++++++
> > >  5 files changed, 59 insertions(+), 16 deletions(-)
> >
> > I haven't investigated the cause yet, but I've run into an interesting
> > bug that bisects to this commit.  Jay Conrod (cc-ed) reports:
> >
> > | I believe this is also the cause of Go toolchain test failures we've
> > | been seeing. Go uses git to fetch dependencies.
> > |
> > | The problem we're seeing can be reproduced with the script below. It
> > | should print "success". Instead, the git merge-base command fails
> > | because the commit 7303f77963648d5f1ec5e55eccfad8e14035866c
> > | (origin/master) has no history.
> >
> > -- 8< --
> > #!/bin/bash
> >
> > set -euxo pipefail
> > if [ -d legacytest ]; then
> >   echo "legacytest directory already exists" >&2
> >   exit 1
> > fi
> > mkdir legacytest
> > cd legacytest
> > git init --bare
> > git config protocol.version 2
> > git config fetch.writeCommitGraph true
> > git remote add origin -- https://github.com/rsc/legacytest
> > git fetch -f --depth=1 origin refs/heads/master:refs/heads/master
> > git fetch -f origin 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*'
> > git fetch --unshallow -f origin
> > git merge-base --is-ancestor -- v2.0.0 7303f77963648d5f1ec5e55eccfad8e14035866c
> > echo success
> > -- >8 --
>
> Thanks to you and Jay for the report and reproduction script. Indeed, I
> can reproduce this on the tip of master (which is equivalent to v2.27.0
> at the time of writing).
>
> > The fetch.writeCommitGraph part is interesting.  When does a commit
> > graph file get written in this sequence of operations?  In an
> > unshallow operation, does the usual guard against writing a commit
> > graph in a shallow repo get missed?
>
> The last 'git fetch' is the one that writes the commit-graph. You can
> verify this by sticking a 'ls objects/info' after each 'git' invocation
> in your script.
>
> Here's where things get weird, though. Prior to this patch, Git would
> pick up that the repository is shallow before unshallowing, but never
> invalidate this fact after unshallowing. That means that once we got to
> 'write_commit_graph', we'd exit immediately since it appears as if the
> repository is shallow.
>
> In this patch, we don't do that anymore, since we rightly unset the fact
> that we are (were) shallow.
>
> In a debugger, I ran your script and a 'git commit-graph write --split
> --reachable' side-by-side, and found an interesting discrepancy: some
> commits (loaded from 'copy_oids_to_commits') *don't* have their parents
> set when invoked from 'git fetch', but *do* when invoked as 'git
> commit-graph write ...'.
>
> I'm not an expert in the object cache, but my hunch is that when we
> fetch these objects they're marked as parsed without having loaded their
> parents. When we load them again via 'lookup_object', we get objects
> that look parsed, but don't have parents where they otherwise should.

Ah, this only sort of has to do with the object cache. In
'parse_commit_buffer()' we stop parsing parents in the case that the
repository is shallow (this goes back to 7f3140cd23 (git repack: keep
commits hidden by a graft, 2009-07-23)).

That makes me somewhat nervous. We're going to keep any objects opened
prior to unshallowing in the cache, along with their hidden parents. I
suspect that this is why Git has kept the shallow bit as sticky for so
long.

I'm not quite sure what to do here. I think that any of the following
would work:

  * Keep the shallow bit sticky, at least for fetch.writeCommitGraph
    (i.e., pretend as if fetch.writecommitgraph=0 in the case of
    '--unshallow').

  * Dump the object cache upon un-shallowing, forcing us to re-discover
    the parents when they are no longer hidden behind a graft.

The latter seems like the most complete feasible fix. The former should
work fine to address this case, but I wonder if there are other
call-sites that are affected by this behavior. My hunch is that this is
a unique case, since it requires going from shallow to unshallow in the
same process.

I have yet to create a smaller test case, but the following should be
sufficient to dump the cache of parsed objects upon shallowing or
un-shallowing:

diff --git a/shallow.c b/shallow.c
index b826de9b67..06db857f53 100644
--- a/shallow.c
+++ b/shallow.c
@@ -90,6 +90,9 @@ static void reset_repository_shallow(struct repository *r)
 {
 	r->parsed_objects->is_shallow = -1;
 	stat_validity_clear(r->parsed_objects->shallow_stat);
+
+	parsed_object_pool_clear(r->parsed_objects);
+	r->parsed_objects = parsed_object_pool_new();
 }

 int commit_shallow_file(struct repository *r, struct shallow_lock *lk)

Is this something we want to go forward with? Are there some
far-reaching implications that I'm missing?

> I'm going to CC Stolee to see if he has any thoughts on how to handle
> this and/or if my idea is on the right track.
>
> > "rm -fr objects/info/commit-graphs" recovers the full history in the
> > repo, so this is not a case of writing the wrong shallows --- it's
> > only a commit graph issue.
> >
> > I'll take a closer look, but thought I'd give others a chance to look
> > to in case there's something obvious. :)
> >
> > Thanks,
> > Jonathan
>
> Thanks,
> Taylor

Thanks,
Taylor
Derrick Stolee June 3, 2020, 1:08 p.m. UTC | #8
On 6/3/2020 1:16 AM, Taylor Blau wrote:
> On Tue, Jun 02, 2020 at 10:52:48PM -0600, Taylor Blau wrote:
>> Hi Jonathan,
>>
>> On Tue, Jun 02, 2020 at 08:42:13PM -0700, Jonathan Nieder wrote:
>>> Hi,
>>>
>>> Taylor Blau wrote:
>>>
>>>> Signed-off-by: Taylor Blau <me@ttaylorr.com>
>>>> ---
>>>>  builtin/receive-pack.c   |  4 ++--
>>>>  commit.h                 |  2 ++
>>>>  fetch-pack.c             | 10 +++++-----
>>>>  shallow.c                | 30 +++++++++++++++++++++---------
>>>>  t/t5537-fetch-shallow.sh | 29 +++++++++++++++++++++++++++++
>>>>  5 files changed, 59 insertions(+), 16 deletions(-)
>>>
>>> I haven't investigated the cause yet, but I've run into an interesting
>>> bug that bisects to this commit.  Jay Conrod (cc-ed) reports:
>>>
>>> | I believe this is also the cause of Go toolchain test failures we've
>>> | been seeing. Go uses git to fetch dependencies.
>>> |
>>> | The problem we're seeing can be reproduced with the script below. It
>>> | should print "success". Instead, the git merge-base command fails
>>> | because the commit 7303f77963648d5f1ec5e55eccfad8e14035866c
>>> | (origin/master) has no history.
>>>
>>> -- 8< --
>>> #!/bin/bash
>>>
>>> set -euxo pipefail
>>> if [ -d legacytest ]; then
>>>   echo "legacytest directory already exists" >&2
>>>   exit 1
>>> fi
>>> mkdir legacytest
>>> cd legacytest
>>> git init --bare
>>> git config protocol.version 2
>>> git config fetch.writeCommitGraph true
>>> git remote add origin -- https://github.com/rsc/legacytest
>>> git fetch -f --depth=1 origin refs/heads/master:refs/heads/master
>>> git fetch -f origin 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*'
>>> git fetch --unshallow -f origin
>>> git merge-base --is-ancestor -- v2.0.0 7303f77963648d5f1ec5e55eccfad8e14035866c
>>> echo success
>>> -- >8 --
>>
>> Thanks to you and Jay for the report and reproduction script. Indeed, I
>> can reproduce this on the tip of master (which is equivalent to v2.27.0
>> at the time of writing).
>>
>>> The fetch.writeCommitGraph part is interesting.  When does a commit
>>> graph file get written in this sequence of operations?  In an
>>> unshallow operation, does the usual guard against writing a commit
>>> graph in a shallow repo get missed?
>>
>> The last 'git fetch' is the one that writes the commit-graph. You can
>> verify this by sticking a 'ls objects/info' after each 'git' invocation
>> in your script.
>>
>> Here's where things get weird, though. Prior to this patch, Git would
>> pick up that the repository is shallow before unshallowing, but never
>> invalidate this fact after unshallowing. That means that once we got to
>> 'write_commit_graph', we'd exit immediately since it appears as if the
>> repository is shallow.
>>
>> In this patch, we don't do that anymore, since we rightly unset the fact
>> that we are (were) shallow.
>>
>> In a debugger, I ran your script and a 'git commit-graph write --split
>> --reachable' side-by-side, and found an interesting discrepancy: some
>> commits (loaded from 'copy_oids_to_commits') *don't* have their parents
>> set when invoked from 'git fetch', but *do* when invoked as 'git
>> commit-graph write ...'.
>>
>> I'm not an expert in the object cache, but my hunch is that when we
>> fetch these objects they're marked as parsed without having loaded their
>> parents. When we load them again via 'lookup_object', we get objects
>> that look parsed, but don't have parents where they otherwise should.
> 
> Ah, this only sort of has to do with the object cache. In
> 'parse_commit_buffer()' we stop parsing parents in the case that the
> repository is shallow (this goes back to 7f3140cd23 (git repack: keep
> commits hidden by a graft, 2009-07-23)).
> 
> That makes me somewhat nervous. We're going to keep any objects opened
> prior to unshallowing in the cache, along with their hidden parents. I
> suspect that this is why Git has kept the shallow bit as sticky for so
> long.
> 
> I'm not quite sure what to do here. I think that any of the following
> would work:
> 
>   * Keep the shallow bit sticky, at least for fetch.writeCommitGraph
>     (i.e., pretend as if fetch.writecommitgraph=0 in the case of
>     '--unshallow').

I'm in favor of this option, if possible. Anything that alters the
commit history in-memory at any point in the Git process is unsafe to
combine with a commit-graph read _or_ write. I'm sorry that the guards
in commit_graph_compatible() are not enough here.

>   * Dump the object cache upon un-shallowing, forcing us to re-discover
>     the parents when they are no longer hidden behind a graft.
> 
> The latter seems like the most complete feasible fix. The former should
> work fine to address this case, but I wonder if there are other
> call-sites that are affected by this behavior. My hunch is that this is
> a unique case, since it requires going from shallow to unshallow in the
> same process.

The latter would solve issues that could arise outside of the commit-graph
space. But it also presents an opportunity for another gap if someone edits
the shallow logic without putting in the proper guards.

To be extra safe, I'd be in favor of adding an "if (grafts_ever_existed)"
condition in commit_graph_compatible() based on a global that is assigned
a non-zero value whenever grafts are loaded at any point in the process,
mostly because it would be easy to guarantee that it is safe. It could
even be localized to the repository struct.
 
> I have yet to create a smaller test case, but the following should be
> sufficient to dump the cache of parsed objects upon shallowing or
> un-shallowing:
> 
> diff --git a/shallow.c b/shallow.c
> index b826de9b67..06db857f53 100644
> --- a/shallow.c
> +++ b/shallow.c
> @@ -90,6 +90,9 @@ static void reset_repository_shallow(struct repository *r)
>  {
>  	r->parsed_objects->is_shallow = -1;
>  	stat_validity_clear(r->parsed_objects->shallow_stat);
> +
> +	parsed_object_pool_clear(r->parsed_objects);
> +	r->parsed_objects = parsed_object_pool_new();
>  }
> 
>  int commit_shallow_file(struct repository *r, struct shallow_lock *lk)
> 
> Is this something we want to go forward with? Are there some
> far-reaching implications that I'm missing?

I'd like to see the extra-careful check, in addition to this one. This
is such a rarely-used and narrowly-tested case that we need to be really
really careful to avoid regressions.

Thanks,
-Stolee
Taylor Blau June 3, 2020, 7:26 p.m. UTC | #9
Hi Stolee,

On Wed, Jun 03, 2020 at 09:08:26AM -0400, Derrick Stolee wrote:
> On 6/3/2020 1:16 AM, Taylor Blau wrote:
> > On Tue, Jun 02, 2020 at 10:52:48PM -0600, Taylor Blau wrote:
> >> Hi Jonathan,
> >>
> >> On Tue, Jun 02, 2020 at 08:42:13PM -0700, Jonathan Nieder wrote:
> >>> Hi,
> >>>
> >>> Taylor Blau wrote:
> >>>
> >>>> Signed-off-by: Taylor Blau <me@ttaylorr.com>
> >>>> ---
> >>>>  builtin/receive-pack.c   |  4 ++--
> >>>>  commit.h                 |  2 ++
> >>>>  fetch-pack.c             | 10 +++++-----
> >>>>  shallow.c                | 30 +++++++++++++++++++++---------
> >>>>  t/t5537-fetch-shallow.sh | 29 +++++++++++++++++++++++++++++
> >>>>  5 files changed, 59 insertions(+), 16 deletions(-)
> >>>
> >>> I haven't investigated the cause yet, but I've run into an interesting
> >>> bug that bisects to this commit.  Jay Conrod (cc-ed) reports:
> >>>
> >>> | I believe this is also the cause of Go toolchain test failures we've
> >>> | been seeing. Go uses git to fetch dependencies.
> >>> |
> >>> | The problem we're seeing can be reproduced with the script below. It
> >>> | should print "success". Instead, the git merge-base command fails
> >>> | because the commit 7303f77963648d5f1ec5e55eccfad8e14035866c
> >>> | (origin/master) has no history.
> >>>
> >>> -- 8< --
> >>> #!/bin/bash
> >>>
> >>> set -euxo pipefail
> >>> if [ -d legacytest ]; then
> >>>   echo "legacytest directory already exists" >&2
> >>>   exit 1
> >>> fi
> >>> mkdir legacytest
> >>> cd legacytest
> >>> git init --bare
> >>> git config protocol.version 2
> >>> git config fetch.writeCommitGraph true
> >>> git remote add origin -- https://github.com/rsc/legacytest
> >>> git fetch -f --depth=1 origin refs/heads/master:refs/heads/master
> >>> git fetch -f origin 'refs/heads/*:refs/heads/*' 'refs/tags/*:refs/tags/*'
> >>> git fetch --unshallow -f origin
> >>> git merge-base --is-ancestor -- v2.0.0 7303f77963648d5f1ec5e55eccfad8e14035866c
> >>> echo success
> >>> -- >8 --
> >>
> >> Thanks to you and Jay for the report and reproduction script. Indeed, I
> >> can reproduce this on the tip of master (which is equivalent to v2.27.0
> >> at the time of writing).
> >>
> >>> The fetch.writeCommitGraph part is interesting.  When does a commit
> >>> graph file get written in this sequence of operations?  In an
> >>> unshallow operation, does the usual guard against writing a commit
> >>> graph in a shallow repo get missed?
> >>
> >> The last 'git fetch' is the one that writes the commit-graph. You can
> >> verify this by sticking a 'ls objects/info' after each 'git' invocation
> >> in your script.
> >>
> >> Here's where things get weird, though. Prior to this patch, Git would
> >> pick up that the repository is shallow before unshallowing, but never
> >> invalidate this fact after unshallowing. That means that once we got to
> >> 'write_commit_graph', we'd exit immediately since it appears as if the
> >> repository is shallow.
> >>
> >> In this patch, we don't do that anymore, since we rightly unset the fact
> >> that we are (were) shallow.
> >>
> >> In a debugger, I ran your script and a 'git commit-graph write --split
> >> --reachable' side-by-side, and found an interesting discrepancy: some
> >> commits (loaded from 'copy_oids_to_commits') *don't* have their parents
> >> set when invoked from 'git fetch', but *do* when invoked as 'git
> >> commit-graph write ...'.
> >>
> >> I'm not an expert in the object cache, but my hunch is that when we
> >> fetch these objects they're marked as parsed without having loaded their
> >> parents. When we load them again via 'lookup_object', we get objects
> >> that look parsed, but don't have parents where they otherwise should.
> >
> > Ah, this only sort of has to do with the object cache. In
> > 'parse_commit_buffer()' we stop parsing parents in the case that the
> > repository is shallow (this goes back to 7f3140cd23 (git repack: keep
> > commits hidden by a graft, 2009-07-23)).
> >
> > That makes me somewhat nervous. We're going to keep any objects opened
> > prior to unshallowing in the cache, along with their hidden parents. I
> > suspect that this is why Git has kept the shallow bit as sticky for so
> > long.
> >
> > I'm not quite sure what to do here. I think that any of the following
> > would work:
> >
> >   * Keep the shallow bit sticky, at least for fetch.writeCommitGraph
> >     (i.e., pretend as if fetch.writecommitgraph=0 in the case of
> >     '--unshallow').
>
> I'm in favor of this option, if possible. Anything that alters the
> commit history in-memory at any point in the Git process is unsafe to
> combine with a commit-graph read _or_ write. I'm sorry that the guards
> in commit_graph_compatible() are not enough here.
>
> >   * Dump the object cache upon un-shallowing, forcing us to re-discover
> >     the parents when they are no longer hidden behind a graft.
> >
> > The latter seems like the most complete feasible fix. The former should
> > work fine to address this case, but I wonder if there are other
> > call-sites that are affected by this behavior. My hunch is that this is
> > a unique case, since it requires going from shallow to unshallow in the
> > same process.
>
> The latter would solve issues that could arise outside of the commit-graph
> space. But it also presents an opportunity for another gap if someone edits
> the shallow logic without putting in the proper guards.
>
> To be extra safe, I'd be in favor of adding an "if (grafts_ever_existed)"
> condition in commit_graph_compatible() based on a global that is assigned
> a non-zero value whenever grafts are loaded at any point in the process,
> mostly because it would be easy to guarantee that it is safe. It could
> even be localized to the repository struct.
>
> > I have yet to create a smaller test case, but the following should be
> > sufficient to dump the cache of parsed objects upon shallowing or
> > un-shallowing:
> >
> > diff --git a/shallow.c b/shallow.c
> > index b826de9b67..06db857f53 100644
> > --- a/shallow.c
> > +++ b/shallow.c
> > @@ -90,6 +90,9 @@ static void reset_repository_shallow(struct repository *r)
> >  {
> >  	r->parsed_objects->is_shallow = -1;
> >  	stat_validity_clear(r->parsed_objects->shallow_stat);
> > +
> > +	parsed_object_pool_clear(r->parsed_objects);
> > +	r->parsed_objects = parsed_object_pool_new();
> >  }
> >
> >  int commit_shallow_file(struct repository *r, struct shallow_lock *lk)
> >
> > Is this something we want to go forward with? Are there some
> > far-reaching implications that I'm missing?
>
> I'd like to see the extra-careful check, in addition to this one. This
> is such a rarely-used and narrowly-tested case that we need to be really
> really careful to avoid regressions.

I'm a little confused at which suggestion you're in favor of ;-). For
clarity, are you suggesting that we add a new 'r->grafts_ever_existed'
bit in addition to doing a hard reset of the object pool?

> Thanks,
> -Stolee

Thanks,
Taylor
Jonathan Nieder June 3, 2020, 8:51 p.m. UTC | #10
Taylor Blau wrote:

> Ah, this only sort of has to do with the object cache. In
> 'parse_commit_buffer()' we stop parsing parents in the case that the
> repository is shallow (this goes back to 7f3140cd23 (git repack: keep
> commits hidden by a graft, 2009-07-23)).

Ah, good analysis.  (In fact, the behavior is older: it's from
5da5c8f4cf4 (Teach parse_commit_buffer about grafting., 2005-07-30).)
So this is additional "cached" data that needs to be invalidated by
reset_repository_shallow.

So the question is, what other information falls into that category?

[...]
> --- a/shallow.c
> +++ b/shallow.c
> @@ -90,6 +90,9 @@ static void reset_repository_shallow(struct repository *r)
>  {
>  	r->parsed_objects->is_shallow = -1;
>  	stat_validity_clear(r->parsed_objects->shallow_stat);
> +

(nit: the above two lines wouldn't be needed if r->parsed_objects is
being thrown away.)

> +	parsed_object_pool_clear(r->parsed_objects);
> +	r->parsed_objects = parsed_object_pool_new();
>  }

Shallows don't affect the ref store.  They only affect object walks.
So r->parsed_objects does seem like the only place that could be
affected.

That said, with this change I'd worry about use-after-free from any
existing references to objects in the pool.

Stepping back, what I think I would like to see is to *not* have
grafts and shallow state affect the in-memory persisted parsed
objects.  Instead, act as an overlay in accessors that traverse over
them.

Lacking that, I like the idea of a "dirty bit" that gets written as
soon as we have started lying in the parsed object pool.  Something
like this.  What do you think?

diff --git i/commit-graph.c w/commit-graph.c
index 2ff042fbf4f..84b49ce903b 100644
--- i/commit-graph.c
+++ w/commit-graph.c
@@ -149,7 +149,8 @@ static int commit_graph_compatible(struct repository *r)
 	}
 
 	prepare_commit_graft(r);
-	if (r->parsed_objects && r->parsed_objects->grafts_nr)
+	if (r->parsed_objects &&
+	    (r->parsed_objects->grafts_nr || r->parsed_objects->substituted_parent))
 		return 0;
 	if (is_repository_shallow(r))
 		return 0;
diff --git i/commit.c w/commit.c
index 87686a7055b..762f09e53ae 100644
--- i/commit.c
+++ w/commit.c
@@ -423,6 +423,8 @@ int parse_commit_buffer(struct repository *r, struct commit *item, const void *b
 	pptr = &item->parents;
 
 	graft = lookup_commit_graft(r, &item->object.oid);
+	if (graft)
+		r->parsed_objects->substituted_parent = 1;
 	while (bufptr + parent_entry_len < tail && !memcmp(bufptr, "parent ", 7)) {
 		struct commit *new_parent;
 
@@ -447,6 +449,7 @@ int parse_commit_buffer(struct repository *r, struct commit *item, const void *b
 	if (graft) {
 		int i;
 		struct commit *new_parent;
+
 		for (i = 0; i < graft->nr_parent; i++) {
 			new_parent = lookup_commit(r,
 						   &graft->parent[i]);
diff --git i/object.h w/object.h
index b22328b8383..db02fdcd6b2 100644
--- i/object.h
+++ w/object.h
@@ -26,6 +26,7 @@ struct parsed_object_pool {
 	char *alternate_shallow_file;
 
 	int commit_graft_prepared;
+	int substituted_parent;
 
 	struct buffer_slab *buffer_slab;
 };

Thanks,
Jonathan
Jonathan Nieder June 3, 2020, 9:23 p.m. UTC | #11
Hi,

Derrick Stolee wrote:
> On 6/3/2020 1:16 AM, Taylor Blau wrote:

>>   * Keep the shallow bit sticky, at least for fetch.writeCommitGraph
>>     (i.e., pretend as if fetch.writecommitgraph=0 in the case of
>>     '--unshallow').
>
> I'm in favor of this option, if possible. Anything that alters the
> commit history in-memory at any point in the Git process is unsafe to
> combine with a commit-graph read _or_ write. I'm sorry that the guards
> in commit_graph_compatible() are not enough here.

As described in [1], I agree --- this kind of "dirty bit" is a good
option and seems like the right thing to do here.

And I'm glad you said read _or_ write here.  I hadn't realized that
this check already applies for reads, and I'm very happy it does.

[...]
>>   * Dump the object cache upon un-shallowing, forcing us to re-discover
>>     the parents when they are no longer hidden behind a graft.
>>
>> The latter seems like the most complete feasible fix. The former should
>> work fine to address this case, but I wonder if there are other
>> call-sites that are affected by this behavior. My hunch is that this is
>> a unique case, since it requires going from shallow to unshallow in the
>> same process.
>
> The latter would solve issues that could arise outside of the commit-graph
> space. But it also presents an opportunity for another gap if someone edits
> the shallow logic without putting in the proper guards.

This, however, I don't agree with.

I'm a strong believer in having clear invariants --- without them,
code can only be understood empirically, and
https://ieeexplore.ieee.org/document/9068304 describes how painful of
a world that can be.

So because shallow is specifically about changing the set of parents
in objects used for traversal, I want to make sure that we as
reviewers will push back on any potential other new meaning of
shallow.  *If* we had a safe way to invalidate the object cache, it
would be sufficient and would be the right thing to do.  As described
in [1], we unfortunately don't have such a thing.

Okay, that's all an aside.  Now for the actual reason I'm replying:

I had been wondering why this wasn't caught at read time, but of
course --unshallow clears away the shallows so there was no way for
that to happen.  So what I wonder is, why isn't this caught by fsck?
Can "git fsck" run "git commit-graph verify" automatically and include
its result as part of what it reports?

Thanks,
Jonathan

[1] https://lore.kernel.org/git/20200603205151.GC253041@google.com/
Taylor Blau June 3, 2020, 10:14 p.m. UTC | #12
On Wed, Jun 03, 2020 at 01:51:51PM -0700, Jonathan Nieder wrote:
> Taylor Blau wrote:
>
> > Ah, this only sort of has to do with the object cache. In
> > 'parse_commit_buffer()' we stop parsing parents in the case that the
> > repository is shallow (this goes back to 7f3140cd23 (git repack: keep
> > commits hidden by a graft, 2009-07-23)).
>
> Ah, good analysis.  (In fact, the behavior is older: it's from
> 5da5c8f4cf4 (Teach parse_commit_buffer about grafting., 2005-07-30).)
> So this is additional "cached" data that needs to be invalidated by
> reset_repository_shallow.
>
> So the question is, what other information falls into that category?
>
> [...]
> > --- a/shallow.c
> > +++ b/shallow.c
> > @@ -90,6 +90,9 @@ static void reset_repository_shallow(struct repository *r)
> >  {
> >  	r->parsed_objects->is_shallow = -1;
> >  	stat_validity_clear(r->parsed_objects->shallow_stat);
> > +
>
> (nit: the above two lines wouldn't be needed if r->parsed_objects is
> being thrown away.)

Right, thanks. I don't think that it matters since you point out a
legitimate issue with dangling references, but serves me right for
working on this so late at night ;-).

> > +	parsed_object_pool_clear(r->parsed_objects);
> > +	r->parsed_objects = parsed_object_pool_new();
> >  }
>
> Shallows don't affect the ref store.  They only affect object walks.
> So r->parsed_objects does seem like the only place that could be
> affected.
>
> That said, with this change I'd worry about use-after-free from any
> existing references to objects in the pool.
>
> Stepping back, what I think I would like to see is to *not* have
> grafts and shallow state affect the in-memory persisted parsed
> objects.  Instead, act as an overlay in accessors that traverse over
> them.
>
> Lacking that, I like the idea of a "dirty bit" that gets written as
> soon as we have started lying in the parsed object pool.  Something
> like this.  What do you think?
>
> diff --git i/commit-graph.c w/commit-graph.c
> index 2ff042fbf4f..84b49ce903b 100644
> --- i/commit-graph.c
> +++ w/commit-graph.c
> @@ -149,7 +149,8 @@ static int commit_graph_compatible(struct repository *r)
>  	}
>
>  	prepare_commit_graft(r);
> -	if (r->parsed_objects && r->parsed_objects->grafts_nr)
> +	if (r->parsed_objects &&
> +	    (r->parsed_objects->grafts_nr || r->parsed_objects->substituted_parent))

This is a little tricky. Why would we set substituted_parent without
also incrementing grafts_nr? That seems like the real bug here: if we
incremented grafts_nr, then we would return a non-zero value from
'commit_graph_compatible' and rightly stop even without this sticky-bit.

I don't quite understand this myself. If it's an oversight, it's a
remarkably long-lived one. Do you have a better sense of this?

>  		return 0;
>  	if (is_repository_shallow(r))
>  		return 0;
> diff --git i/commit.c w/commit.c
> index 87686a7055b..762f09e53ae 100644
> --- i/commit.c
> +++ w/commit.c
> @@ -423,6 +423,8 @@ int parse_commit_buffer(struct repository *r, struct commit *item, const void *b
>  	pptr = &item->parents;
>
>  	graft = lookup_commit_graft(r, &item->object.oid);
> +	if (graft)
> +		r->parsed_objects->substituted_parent = 1;
>  	while (bufptr + parent_entry_len < tail && !memcmp(bufptr, "parent ", 7)) {
>  		struct commit *new_parent;
>
> @@ -447,6 +449,7 @@ int parse_commit_buffer(struct repository *r, struct commit *item, const void *b
>  	if (graft) {
>  		int i;
>  		struct commit *new_parent;
> +

Nit: unnecessary whitespace change, but I doubt it really matters much.

>  		for (i = 0; i < graft->nr_parent; i++) {
>  			new_parent = lookup_commit(r,
>  						   &graft->parent[i]);
> diff --git i/object.h w/object.h
> index b22328b8383..db02fdcd6b2 100644
> --- i/object.h
> +++ w/object.h
> @@ -26,6 +26,7 @@ struct parsed_object_pool {
>  	char *alternate_shallow_file;
>
>  	int commit_graft_prepared;
> +	int substituted_parent;
>
>  	struct buffer_slab *buffer_slab;
>  };
>
> Thanks,
> Jonathan

Thanks,
Taylor
Jonathan Nieder June 3, 2020, 11:06 p.m. UTC | #13
Taylor Blau wrote:
> On Wed, Jun 03, 2020 at 01:51:51PM -0700, Jonathan Nieder wrote:

>> --- i/commit-graph.c
>> +++ w/commit-graph.c
>> @@ -149,7 +149,8 @@ static int commit_graph_compatible(struct repository *r)
>>  	}
>>
>>  	prepare_commit_graft(r);
>> -	if (r->parsed_objects && r->parsed_objects->grafts_nr)
>> +	if (r->parsed_objects &&
>> +	    (r->parsed_objects->grafts_nr || r->parsed_objects->substituted_parent))
>
> This is a little tricky. Why would we set substituted_parent without
> also incrementing grafts_nr? That seems like the real bug here: if we
> incremented grafts_nr, then we would return a non-zero value from
> 'commit_graph_compatible' and rightly stop even without this sticky-bit.
> 
> I don't quite understand this myself. If it's an oversight, it's a
> remarkably long-lived one. Do you have a better sense of this?

The idea here is to check for two different things:

 1. Do we have grafts (either from a grafts file or from a shallow
    file)?  If so, this repository is not commit graph compatible
    because we could encounter one of them.

 2. Have cached parsed objects taken any history modifications into
    account?  If so, this in-memory state is not commit graph
    compatible because we could encounter one of them.

The check (1) might seem sufficient if the set of grafts is constant
for the lifetime of a git process.  But since 37b9dcabfc (shallow.c:
use '{commit,rollback}_shallow_file', 2020-04-22), it is not constant
for the process lifetime, so we need the check (2) as well.

We might want a similar check for replace refs as well some day, but
not today (there is not a way to remove entries from replace_map
during the life of a process).

I can try sending a proper patch with commit message and tests
tomorrow morning (or if someone else takes care of it, that's fine,
too).  Thanks, both, for your help --- it was nice seeing a clear
explanation of the cause already figured out and explained when I woke
up.

Regards,
Jonathan
Taylor Blau June 4, 2020, 5:45 p.m. UTC | #14
Hi Jonathan,

On Wed, Jun 03, 2020 at 04:06:11PM -0700, Jonathan Nieder wrote:
> Taylor Blau wrote:
> > On Wed, Jun 03, 2020 at 01:51:51PM -0700, Jonathan Nieder wrote:
>
> >> --- i/commit-graph.c
> >> +++ w/commit-graph.c
> >> @@ -149,7 +149,8 @@ static int commit_graph_compatible(struct repository *r)
> >>  	}
> >>
> >>  	prepare_commit_graft(r);
> >> -	if (r->parsed_objects && r->parsed_objects->grafts_nr)
> >> +	if (r->parsed_objects &&
> >> +	    (r->parsed_objects->grafts_nr || r->parsed_objects->substituted_parent))
> >
> > This is a little tricky. Why would we set substituted_parent without
> > also incrementing grafts_nr? That seems like the real bug here: if we
> > incremented grafts_nr, then we would return a non-zero value from
> > 'commit_graph_compatible' and rightly stop even without this sticky-bit.
> >
> > I don't quite understand this myself. If it's an oversight, it's a
> > remarkably long-lived one. Do you have a better sense of this?
>
> The idea here is to check for two different things:
>
>  1. Do we have grafts (either from a grafts file or from a shallow
>     file)?  If so, this repository is not commit graph compatible
>     because we could encounter one of them.
>
>  2. Have cached parsed objects taken any history modifications into
>     account?  If so, this in-memory state is not commit graph
>     compatible because we could encounter one of them.

Ah, breaking it up like this makes it clearer. The gist is that it is
possible to generate a situation which has (1) no grafts explicitly, but
(2) *does* have history modifications.

> The check (1) might seem sufficient if the set of grafts is constant
> for the lifetime of a git process.  But since 37b9dcabfc (shallow.c:
> use '{commit,rollback}_shallow_file', 2020-04-22), it is not constant
> for the process lifetime, so we need the check (2) as well.
>
> We might want a similar check for replace refs as well some day, but
> not today (there is not a way to remove entries from replace_map
> during the life of a process).

Right.

> I can try sending a proper patch with commit message and tests
> tomorrow morning (or if someone else takes care of it, that's fine,
> too).  Thanks, both, for your help --- it was nice seeing a clear
> explanation of the cause already figured out and explained when I woke
> up.

If you are interested, by all means -- I'd certainly be appreciative.
I'm on vacation next week, and so it may be better if you are able to
shepherd the patches through. Otherwise, I'd be happy to do it the week
after when I get back.

> Regards,
> Jonathan

Thanks,
Taylor
diff mbox series

Patch

diff --git a/builtin/receive-pack.c b/builtin/receive-pack.c
index 2cc18bbffd..652661fa99 100644
--- a/builtin/receive-pack.c
+++ b/builtin/receive-pack.c
@@ -872,12 +872,12 @@  static int update_shallow_ref(struct command *cmd, struct shallow_info *si)
 	opt.env = tmp_objdir_env(tmp_objdir);
 	setup_alternate_shallow(&shallow_lock, &opt.shallow_file, &extra);
 	if (check_connected(command_singleton_iterator, cmd, &opt)) {
-		rollback_lock_file(&shallow_lock);
+		rollback_shallow_file(the_repository, &shallow_lock);
 		oid_array_clear(&extra);
 		return -1;
 	}
 
-	commit_lock_file(&shallow_lock);
+	commit_shallow_file(the_repository, &shallow_lock);
 
 	/*
 	 * Make sure setup_alternate_shallow() for the next ref does
diff --git a/commit.h b/commit.h
index 008a0fa4a0..ab91d21131 100644
--- a/commit.h
+++ b/commit.h
@@ -249,6 +249,8 @@  struct oid_array;
 struct ref;
 int register_shallow(struct repository *r, const struct object_id *oid);
 int unregister_shallow(const struct object_id *oid);
+int commit_shallow_file(struct repository *r, struct lock_file *lk);
+void rollback_shallow_file(struct repository *r, struct lock_file *lk);
 int for_each_commit_graft(each_commit_graft_fn, void *);
 int is_repository_shallow(struct repository *r);
 struct commit_list *get_shallow_commits(struct object_array *heads,
diff --git a/fetch-pack.c b/fetch-pack.c
index 1734a573b0..a618f3b029 100644
--- a/fetch-pack.c
+++ b/fetch-pack.c
@@ -1629,9 +1629,9 @@  static void update_shallow(struct fetch_pack_args *args,
 	if (args->deepen && alternate_shallow_file) {
 		if (*alternate_shallow_file == '\0') { /* --unshallow */
 			unlink_or_warn(git_path_shallow(the_repository));
-			rollback_lock_file(&shallow_lock);
+			rollback_shallow_file(the_repository, &shallow_lock);
 		} else
-			commit_lock_file(&shallow_lock);
+			commit_shallow_file(the_repository, &shallow_lock);
 		alternate_shallow_file = NULL;
 		return;
 	}
@@ -1655,7 +1655,7 @@  static void update_shallow(struct fetch_pack_args *args,
 			setup_alternate_shallow(&shallow_lock,
 						&alternate_shallow_file,
 						&extra);
-			commit_lock_file(&shallow_lock);
+			commit_shallow_file(the_repository, &shallow_lock);
 			alternate_shallow_file = NULL;
 		}
 		oid_array_clear(&extra);
@@ -1693,7 +1693,7 @@  static void update_shallow(struct fetch_pack_args *args,
 		setup_alternate_shallow(&shallow_lock,
 					&alternate_shallow_file,
 					&extra);
-		commit_lock_file(&shallow_lock);
+		commit_shallow_file(the_repository, &shallow_lock);
 		oid_array_clear(&extra);
 		oid_array_clear(&ref);
 		alternate_shallow_file = NULL;
@@ -1785,7 +1785,7 @@  struct ref *fetch_pack(struct fetch_pack_args *args,
 			error(_("remote did not send all necessary objects"));
 			free_refs(ref_cpy);
 			ref_cpy = NULL;
-			rollback_lock_file(&shallow_lock);
+			rollback_shallow_file(the_repository, &shallow_lock);
 			goto cleanup;
 		}
 		args->connectivity_checked = 1;
diff --git a/shallow.c b/shallow.c
index 7fd04afed1..5010a6c732 100644
--- a/shallow.c
+++ b/shallow.c
@@ -40,13 +40,6 @@  int register_shallow(struct repository *r, const struct object_id *oid)
 
 int is_repository_shallow(struct repository *r)
 {
-	/*
-	 * NEEDSWORK: This function updates
-	 * r->parsed_objects->{is_shallow,shallow_stat} as a side effect but
-	 * there is no corresponding function to clear them when the shallow
-	 * file is updated.
-	 */
-
 	FILE *fp;
 	char buf[1024];
 	const char *path = r->parsed_objects->alternate_shallow_file;
@@ -79,6 +72,25 @@  int is_repository_shallow(struct repository *r)
 	return r->parsed_objects->is_shallow;
 }
 
+static void reset_repository_shallow(struct repository *r)
+{
+	r->parsed_objects->is_shallow = -1;
+	stat_validity_clear(r->parsed_objects->shallow_stat);
+}
+
+int commit_shallow_file(struct repository *r, struct lock_file *lk)
+{
+	int res = commit_lock_file(lk);
+	reset_repository_shallow(r);
+	return res;
+}
+
+void rollback_shallow_file(struct repository *r, struct lock_file *lk)
+{
+	rollback_lock_file(lk);
+	reset_repository_shallow(r);
+}
+
 /*
  * TODO: use "int" elemtype instead of "int *" when/if commit-slab
  * supports a "valid" flag.
@@ -410,10 +422,10 @@  void prune_shallow(unsigned options)
 		if (write_in_full(fd, sb.buf, sb.len) < 0)
 			die_errno("failed to write to %s",
 				  get_lock_file_path(&shallow_lock));
-		commit_lock_file(&shallow_lock);
+		commit_shallow_file(the_repository, &shallow_lock);
 	} else {
 		unlink(git_path_shallow(the_repository));
-		rollback_lock_file(&shallow_lock);
+		rollback_shallow_file(the_repository, &shallow_lock);
 	}
 	strbuf_release(&sb);
 }
diff --git a/t/t5537-fetch-shallow.sh b/t/t5537-fetch-shallow.sh
index d66b3656c0..a51c4b39d9 100755
--- a/t/t5537-fetch-shallow.sh
+++ b/t/t5537-fetch-shallow.sh
@@ -146,6 +146,35 @@  test_expect_success 'fetch --update-shallow' '
 	)
 '
 
+test_expect_success 'fetch --update-shallow (with fetch.writeCommitGraph)' '
+	(
+	cd shallow &&
+	git checkout master &&
+	commit 8 &&
+	git tag -m foo heavy-tag-for-graph HEAD^ &&
+	git tag light-tag-for-graph HEAD^:tracked
+	) &&
+	test_config -C notshallow fetch.writeCommitGraph true &&
+	(
+	cd notshallow &&
+	git fetch --update-shallow ../shallow/.git refs/heads/*:refs/remotes/shallow/* &&
+	git fsck &&
+	git for-each-ref --sort=refname --format="%(refname)" >actual.refs &&
+	cat <<-EOF >expect.refs &&
+	refs/remotes/shallow/master
+	refs/remotes/shallow/no-shallow
+	refs/tags/heavy-tag
+	refs/tags/heavy-tag-for-graph
+	refs/tags/light-tag
+	refs/tags/light-tag-for-graph
+	EOF
+	test_cmp expect.refs actual.refs &&
+	git log --format=%s shallow/master >actual &&
+	test_write_lines 8 7 6 5 4 3 >expect &&
+	test_cmp expect actual
+	)
+'
+
 test_expect_success POSIXPERM,SANITY 'shallow fetch from a read-only repo' '
 	cp -R .git read-only.git &&
 	test_when_finished "find read-only.git -type d -print | xargs chmod +w" &&