filter-process: Ignore file size in working copy when cleaning by cynix · Pull Request #6011 · git-lfs/git-lfs · GitHub
Skip to content

filter-process: Ignore file size in working copy when cleaning - #6011

Merged
chrisd8088 merged 13 commits into
git-lfs:mainfrom
cynix:fix_clean
Aug 5, 2026
Merged

chrisd8088 merged 13 commits into
git-lfs:mainfrom
cynix:fix_clean

Conversation

@cynix

@cynix cynix commented Mar 12, 2025 •

Copy link
Copy Markdown
Contributor

Sometimes when Git sends us the clean command, the target file in the
working copy does not have the correct size (e.g. it's empty). Since
Git will feed us the blob to clean via stdin, we have to read it all
anyway, especially if we're invoked via filter-process where leaving
any data unread will cause our Pktline's reader to not be positioned
correctly for parsing the next command. Don't trust the file size on
disk when determining how much to copy.

Resolves #4974 (specifically, the secondary problem reported in that issue).

@cynix
cynix requested a review from a team as a code owner March 12, 2025 13:21
@cynix cynix changed the title filter-process: Ignore file size in working copy when cleaning. filter-process: Ignore file size in working copy when cleaning. Fixes #4974 Mar 12, 2025
cynix added 2 commits March 13, 2025 00:58
Sometimes when Git sends us the clean command, the target file in the
working copy does not have the correct size (e.g. it's empty). Since
Git will feed us the blob to clean via stdin, we have to read it all
anyway, especially if we're invoked via filter-process where leaving
any data unread will cause our Pktline's reader to not be positioned
correctly for parsing the next command. Don't trust the file size on
disk when determining how much to copy.

@cynix cynix Mar 12, 2025 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know how to actually reproduce what Git is doing when it invokes our filter-process and send us clean commands with incorrect files in the working copy, so I'm using this util to simulate it. The GIT_TRACE_PACKET output looks sensible and matches what I see when the issue actually occurs. Let me know if you figure out a way to reproduce this natively.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a perfectly reasonable way to simulate the problem! Most likely some other process is involved which removes or alters the working copy file after Git opens it but before Git LFS can check its size, so this is a clever and effective way to simulate the issue for our test suite.

@chrisd8088

Copy link
Copy Markdown
Member

Hey, thanks very much for this PR and for the investigation into the problems reported #4974, which have proven challenging to replicate! We really appreciate this kind of contribution from our community of users.

I think this PR deserves a thoughtful review and so I hope to devote a solid chunk of time to it as soon as I can. My apologies in advance if that takes a week or two; I can assure you it's not because we're not interested in this potential fix for a longstanding issue!

@chrisd8088 chrisd8088 changed the title filter-process: Ignore file size in working copy when cleaning. Fixes #4974 filter-process: Ignore file size in working copy when cleaning Mar 22, 2025
@chrisd8088 chrisd8088 added the bug label Mar 22, 2025
@chrisd8088

Copy link
Copy Markdown
Member

Thank you again for this PR and for your patience, @cynix!

I've started working on a review, and will get that done as soon as possible so you can see my suggested revisions, which are for the most part quite minor, at least as regards the work in this PR at the moment.

However, there are some ancillary changes I think we'll need to make as a consequence of this PR, and so I may try to implement those in another PR which we could merge ahead of this one, or ask if I can contribute some commits to this PR (for which I'd need an invitation to collaborate on your repository).

My apologies for the delay in replying, and thanks again very much for this bug fix!

@cynix

cynix commented Mar 28, 2025

Copy link
Copy Markdown
Contributor Author

@chrisd8088 Thanks for the update. I've invited you as a collaborator, feel free to add commits as you see fit. I'm guessing you're thinking of finding a way to still report progress to the user even though we can't trust the file size? (e.g. use the file size on disk as a hint for progress display only, not for determining how much to copy, or something like that) :D

@chrisd8088

Copy link
Copy Markdown
Member

I'm guessing you're thinking of finding a way to still report progress to the user even though we can't trust the file size?

Yes, that's one of the issues, indeed. At the moment, the file size in the progress log (when enabled with GIT_LFS_PROGRESS) will have -1 as the denominator in the "number of bytes written so far" field, so I worked up some changes to provide an alternative (along with a bunch of tests), but then noticed a more subtle issue, which is that when total is -1, we inadvertently defeat the 200ms throttling of writes to the progress log, so we write to the log on every callback.

Since noticing that I've been pulled off in another direction, but will return to this topic shortly, I very much hope!

@jabidahscreationssystems

This comment was marked as spam.

@chrisd8088

Copy link
Copy Markdown
Member

Hey, I just wanted to note that although I haven't had time to circle back to this PR and the outstanding progress log issues, I will do so in time because I think this is a valuable change and I'd like to get it merged.

Thanks again for your contribution and your patience!

@chrisd8088

Copy link
Copy Markdown
Member

It's taken me much longer than I expected to circle back to this PR, but I have not forgotten about it!

For my own reference, I wanted to note that the progress log (when enabled with GIT_LFS_PROGRESS) will already have a -1 in the denominator of the bytes-received field if a server replies to an object transfer request with a Content-Encoding: gzip header and a gzipped response body.

The Go HTTP client automatically inserts an Accept-Encoding: gzip request header, as described in #6193, so a server is within its rights to reply with a gzipped response. When the net/http package from the Go standard library has inserted the Accept-Encoding: gzip request header on its own, and the server returns a gzipped response with a Content-Encoding: gzip header, the ContentLength value of the Response structure is set to -1, and we then just pass that on to our progress meter.

Under these conditions, we'll also bypass our throttling of progress log messages, as I noted above in #6011 (comment).

Enabling a progress log with GIT_LFS_PROGRESS="$TRASHDIR/progress.log" in our storage upload with compression test is sufficient to demonstrate this behaviour.

@cynix

cynix commented Feb 9, 2026

Copy link
Copy Markdown
Contributor Author

It's taken me much longer than I expected to circle back to this PR, but I have not forgotten about it!

Thanks for keeping this on your radar. Keen to see it merged eventually :)

chrisd8088 added a commit to chrisd8088/git-lfs that referenced this pull request Mar 2, 2026
In prior commits in this PR we introduced a new configuration option
named "lfs.transfer.httpDownloadEncoding", which may be set to either
of the values "gzip" or "zstd", with a default value of "gzip".

When our new option is not defined or is set to "gzip", the Git LFS
client simply follows its legacy behaviour when making object transfer
download requests over HTTP.  Specifically, the client should allow the
"net/http" package in the Go standard library to insert an
"Accept-Encoding: gzip" header into the requests, and if the server
chooses to respond with gzip-encoded data, the client should expect
the "net/http" package to automatically decode the body of the response.

By contrast, when our new option is set to the value "zstd", the
Git LFS client will explicitly add an "Accept-Encoding: zstd" header
when it makes an object transfer download HTTP request.  If the server
chooses to encode the body of its response, it should include a
"Content-Encoding: zstd" header which the client will recognize and
then attempt to decode the response body using the zstd algorithm.

In either case, the server may also send a Content-Length header, in
which case the value of that header will be the length of the encoded
content and not the length of the original, uncompressed data.

For this reason, when the "net/http" package handles gzip-encoded
response data, it returns a Response structure whose ContentLength
field is set to -1.  This informs any reader of the data stream in
the structure's Body field that the length of the stream is unknown:

  https://github.com/golang/go/blob/go1.25.7/src/net/http/response.go#L90-L92
  https://github.com/golang/go/blob/go1.25.7/src/net/http/transport.go#L2385

The same issue pertains when we handle a zstd-encoded response to an
object download request, except that the "net/http" package is not
responsible for decoding the data in the response body.  Instead, we
decode this data in the download() method of the basicDownloadAdapter
structure in our "tq" package.

Later in its operation, the download() method passes the Response
structure's ContentLength field to the CopyWithCallback() function
from our "tools" package.  Thus when handling a response with a
Content-Length header and a zstd-encoded body, we currently pass an
inaccurate value to the CopyWithCallback() function, one which
represents the length of the encoded object data and not its
uncompressed actual size.

We therefore update the download() to set the ContentLength field
to -1 after decoding a zstd-encoded response body, to match the
behaviour of the "net/http" package when it decodes a gzip-encoded
response body.

One consequence of this change will be visible if a user has set the
GIT_LFS_PROGRESS environment variable to an absolute file path.
In this case, the progress log messages we write to that path will
contain a misleading "-1" value as the denominator of the fraction
which indicates how much object data has been received so far.

However, this is a known issue which already occurs when a server
returns a gzip-encoded response body, and one which we expect to
address comprehensively in a future PR.  See, for reference:

  git-lfs#6011 (comment)
chrisd8088 added a commit to mzr/git-lfs that referenced this pull request Apr 9, 2026
In prior commits in this PR we introduced a new configuration option
named "lfs.transfer.httpDownloadEncoding", which may be set to either
of the values "gzip" or "zstd", with a default value of "gzip".

When our new option is not defined or is set to "gzip", the Git LFS
client simply follows its legacy behaviour when making object transfer
download requests over HTTP.  Specifically, the client should allow the
"net/http" package in the Go standard library to insert an
"Accept-Encoding: gzip" header into the requests, and if the server
chooses to respond with gzip-encoded data, the client should expect
the "net/http" package to automatically decode the body of the response.

By contrast, when our new option is set to the value "zstd", the
Git LFS client will explicitly add an "Accept-Encoding: zstd" header
when it makes an object transfer download HTTP request.  If the server
chooses to encode the body of its response, it should include a
"Content-Encoding: zstd" header which the client will recognize and
then attempt to decode the response body using the zstd algorithm.

In either case, the server may also send a Content-Length header, in
which case the value of that header will be the length of the encoded
content and not the length of the original, uncompressed data.

For this reason, when the "net/http" package handles gzip-encoded
response data, it returns a Response structure whose ContentLength
field is set to -1.  This informs any reader of the data stream in
the structure's Body field that the length of the stream is unknown:

  https://github.com/golang/go/blob/go1.25.7/src/net/http/response.go#L90-L92
  https://github.com/golang/go/blob/go1.25.7/src/net/http/transport.go#L2385

The same issue pertains when we handle a zstd-encoded response to an
object download request, except that the "net/http" package is not
responsible for decoding the data in the response body.  Instead, we
decode this data in the download() method of the basicDownloadAdapter
structure in our "tq" package.

Later in its operation, the download() method passes the Response
structure's ContentLength field to the CopyWithCallback() function
from our "tools" package.  Thus when handling a response with a
Content-Length header and a zstd-encoded body, we currently pass an
inaccurate value to the CopyWithCallback() function, one which
represents the length of the encoded object data and not its
uncompressed actual size.

We therefore update the download() to set the ContentLength field
to -1 after decoding a zstd-encoded response body, to match the
behaviour of the "net/http" package when it decodes a gzip-encoded
response body.

One consequence of this change will be visible if a user has set the
GIT_LFS_PROGRESS environment variable to an absolute file path.
In this case, the progress log messages we write to that path will
contain a misleading "-1" value as the denominator of the fraction
which indicates how much object data has been received so far.

However, this is a known issue which already occurs when a server
returns a gzip-encoded response body, and one which we expect to
address comprehensively in a future PR.  See, for reference:

  git-lfs#6011 (comment)
@chrisd8088

chrisd8088 commented Jun 23, 2026 •

Copy link
Copy Markdown
Member

Believe it or not, I've been able to spend some time focusing on the issues raised by this PR, with the intent that we might include this PR in our upcoming (and long-delayed) v3.8.0 release.

PR #6285 is the first of two separate PRs I'd like to merge ahead of this PR. The next PR will refactor our progress log callback mechanisms and expand the test suite for those.

Then I'll have just a handful of revisions for this PR, mostly to deal with a few specific details which I'll note in a final review. I can push up the changes myself, as I've already written the necessary commits.

Thanks again for the diagnosis and fix for this bug, and for your considerable patience!

@cynix

cynix commented Jun 24, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the update, very happy to see progress being made, can’t wait for your new release :)

chrisd8088 added a commit that referenced this pull request Jun 24, 2026
In PR #6011 we will add a test to our "t/t-filter-process.sh" shell
script with the intent that it should validate the revisions made in
the PR to our "clean" Git filter commands.  Specifically, the new
test creates the conditions described in this comment:

  #4974 (comment)

To reproduce these conditions, the test first needs to create a file
larger than the size of the buffer we use to detect and decode
Git LFS pointers.  The test then adds the file as a Git LFS object,
causing its contents to be streamed to our "clean" filter process.
However, the test also ensures that a file smaller than the size of
our pointer detection buffer is written to the same location as the
original file before our filter process actually executes.

Given these conditions, our "clean" filter process would at present
report an error, but the new test will demonstrate that the changes
made in PR #6011 will allow the filter to succeed.

However, to ensure that we do not accidentally introduce some version
of the same problem addressed by PR #6011 again in the future, the
new test must always create files that are both larger and smaller
than the size of the buffer the Git LFS client uses to read and
decode pointers.

If we change this buffer size value in the future, we will need to
remember to adjust our new test, or else it may silently become
ineffective.  The test might continue to pass, but it would no longer
accurately reproduce the conditions necessary to verify that our
filter program does not report an error if the size of a file appears
to drop below the size of our initial read buffer.

In fact, we already have a number of other tests in our shell suite
which depend on knowledge of the size of the read buffer, which at
present is 1024 bytes and is set by the "blobSizeCutoff" constant
in our "lfs" package.

Moreover, at least one of these existing tests, the "clean pseudo
pointer with extra data" test in our "t/t-clean.sh" script, no
longer creates test data of the appropriate size because its initial
version was written at a time when the size of the read buffer used
to decode Git LFS pointers was only 512 bytes, and the test has never
been appropriately revised.  (We will document the history of this
test's development in a subsequent commit in this PR, at which time
we will also update the test.)

In order to help avoid these types of issues with our shell tests,
we start by revising our "lfs" package so that the constant which
sets the size of our pointer read buffer is now exported with the
name BlobSizeCutoff.

In a subsequent commit in this PR we will then be able to add a
Go helper program to our test suite which outputs the value of this
constant.  This will allow us to also adjust all the tests which
implicitly depend on this value so they no longer create files
with sizes set by hard-coded values but instead scale the size of
the files to the current value of the BlobSizeCutoff constant.
chrisd8088 added a commit that referenced this pull request Jun 24, 2026
In PR #6011 we will add a test to our "t/t-filter-process.sh" shell
script with the intent that it should validate the revisions made in
the PR to our "clean" Git filter commands.  Specifically, the new
test creates the conditions described in this comment:

  #4974 (comment)

To reproduce these conditions, the test first needs to create a file
larger than the size of the buffer we use to detect and decode
Git LFS pointers.

We also have a number of other tests in our shell suite which depend
on knowledge of the size of the read buffer, which at present is 1024
bytes and is set by the "blobSizeCutoff" constant in our "lfs" package.

At least one of these existing tests, the "clean pseudo pointer with
extra data" test in our "t/t-clean.sh" script, no longer creates test
data of the appropriate size because its initial version was written
at a time when the size of the read buffer used to decode Git LFS
pointers was only 512 bytes, and the test has never been appropriately
revised.

In order to help avoid these types of issues with our shell tests,
in a previous commit in this PR we revised our "lfs" package so that
the constant which sets the size of our pointer read buffer is now
exported with the name BlobSizeCutoff.

We can now introduce a new Go helper program named "lfstest-getlimit"
which when run with the command-line option --max-pointer-size will
output the value of the BlobSizeCutoff constant.

Our helper utility also supports three other possible command-line
options.  When the --max-bufio-scan-token-size option is provided,
the program will return the value of the MaxScanTokenSize constant
from the "bufio" package in the Go standard library.  We will be
able to make use of this value in the "prune doesn't hang on long
lines in diff" and "prune doesn't hang on long lines in stash diff"
tests in our "t/t-prune.sh" script, as they are intended to exercise
our "git lfs prune" command with files larger than the maximum token
size supported by the "bufio" package's Scanner interface.

Alternatively, when our new helper utility is run with the option
--max-pktline-len, it will output the maximum length of a Git packet
line, as specified by the MaxPacketLength constant from our
"git-lfs/pktline" Go package.  We will be able to make use of this
value in the "filter process: hash-object --stdin --path does not
hang" test in our "t/t-filter-process.sh" script, as it is intended
to exercise our "clean" filter with a file larger than the maximum
length of a single Git packet line.

Lastly, when the --max-spool-mem-buffer-size option is provided to
our new helper program, it will output the value of the
MemoryBufferLimit constant from our "tools" package.  We will be
able to make use of this value in the "smudge with invalid pointer"
test in our "t/t-smudge.sh" script, as it is designed to exercise
our "smudge" filter with input data larger than the size of the
internal memory buffer used to spool data by the Spool() function
in our "tools" package.
@chrisd8088

Copy link
Copy Markdown
Member

PR #6288 is the second of two PRs I'd like to merge ahead of this one, as it resolves several problems with the progress log that would be much more apparent once we no longer try to guess the size of objects during clean operations by "peeking" at the corresponding size of the files in the working tree.

After that I just have a small set of tweaks for this PR, most importantly removing the block which would otherwise disable the progress log entirely during clean operations. I'll write up those commits and push them into this PR's branch once we've got PR #6288 in place.

@chrisd8088 chrisd8088 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you again for this PR, and my apologies for the amount of time it's taken for us to get it merged!

I've already written commits which address all of my notes and suggestions from this belated review, along with some other minor cleanup issues, and I'll push those up to this PR once we've merged PR #6288 into main and I can cleanly pull its changes into this PR's branch.

In other words, there should be no need for you to try to address any of my comments; they are just here for future reference.


The key hurdle, as you noted, is that by ignoring the file in the working tree which (usually!) corresponds to the object we're processing through our clean filter, that means we'll always pass a -1 down to the copyToTemp() method of our GitFilter structure, and so if we make no other changes, that method will always unset the callback function pointer we explicitly create for clean filter operations. As a result, we'd effectively disable progress logging during these operations, even when it should be enabled because the GIT_LFS_PROGRESS environment variable specifies a log file path.

We added the conditional block in the copyToTemp() method back in PR #3902 with intent that it would cause our clean filter operations to skip creating an object file in local storage when there was no need for it, for instance, if the invoking command was git hash-object rather than git add. However, I believe we were under the misapprehension that the callback function is responsible for creating an object file, when its only purpose is to write an entry in the progress log (if a log file has been specified with the GIT_LFS_PROGRESS environment variable).

So we can just remove this conditional block and allow the -1 values to be passed down to the callback function. However, doing so exposes another problem, which is that in PR #5504 we revised the anonymous callback function created by the CopyCallbackFile() method of the GitFilter structure so that it throttles its writes to the progress log, and we explicitly did so to reduce the progress log I/O overhead during clean filter operations.

To throttle its writes, the callback function skips writing an entry to the log if less than 200 ms have elapsed since the previous write for the same object and the written parameter is less than the total parameter. That logic ensures a final entry is written regardless of the amount of time elapsed since the previous write, but only if the total parameter is valid. If it's -1, though, the throttling logic will be defeated and every callback will result in a write, which would re-introduce the problem PR #5504 was meant to solve.

That problem is what led to the work in PR #6288, which in part ensures the throttling works in all cases, even when total is negative. That PR's changes also mean we won't write a literal -1 into the progress log anymore (something we already do, per my notes in #6011 (comment)), and will use a ????-style field instead.

There's also a lot of other cleanup that went into that PR, to deal with the fact that for historical reasons we actually have two functions which format identical messages for the progress log, and (separately) two structures which wrap io.Reader and io.ReadSeekCloser, respectively, and these are used in three different combinations to generate entries in the log.


We have two other commands besides git lfs clean and git lfs filter-process which also call the clean() function, the git lfs migrate import command and the git lfs merge-driver command. In the case of the git lfs migrate import command, we actually know the size of a given object for certain in the two instances where we call the clean() function, because we're reading a blob from repository's Git history.

However, in the case of the git lfs merge-driver command, we pipe the output of another command into a temporary file (the one identified by the %D specifier), and then make an os.Stat() call just to find the size of that file before passing it to the clean() function.

Since it's at least theoretically possible for the temporary file to change between the time we read its size and the time we open it, we should just skip the os.Stat() call and pass -1 to the clean() command in its fileSize parameter.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is a perfectly reasonable way to simulate the problem! Most likely some other process is involved which removes or alters the working copy file after Git opens it but before Git LFS can check its size, so this is a clever and effective way to simulate the issue for our test suite.

Comment thread t/cmd/lfstest-badlocalfile.go Outdated
Comment thread t/t-filter-process.sh Outdated
Comment thread t/t-filter-process.sh Outdated
Comment thread commands/command_clean.go Outdated
chrisd8088 added a commit that referenced this pull request Aug 5, 2026
In PR #5504 we modified the CopyCallbackFile() method of the
GitFilter structure in our "lfs" package so that the callback
function it creates only outputs a progress log message when
at least 200 milliseconds have elapsed since the last message
was written, unless the number of bytes read has now met or
exceeded the total size of the given Git LFS object.  (The
minimum time allowed between messages is specified by the
DefaultLoggingThrottle constant from our "tasklog" package.)

In commit 00f1e95 of PR #5504 we then
also introduced the TestCopyCallbackFileThrottle() test function to
verify that the callback function created by the CopyCallbackFile()
method obeyed the new limits on how often it should write to the
progress log.

At present, the callback function checks whether the number of
bytes read has met or exceeded the total size of the given object,
and if it has, unconditionally writes a message to the progress
log, on the assumption that no further callbacks will occur because
the final bytes of the object have now been read.

However, this assumes the total size of the object is known and
correct when the callback is utilized by the CopyWithCallback()
function of our "tools" package.

While this remains true at present for most of the situations in
which we pass a callback function to the CopyWithCallback() function,
these will be more limited in number after we make the change to our
"clean" filter commands proposed in PR #6011.

PR #6011 resolves a bug in our "git lfs filter-progress" and
"git lfs clean" commands whereby they can pass an incorrect
"totalSize" parameter to the CopyWithCallback() function.  The only
solution for this problem is for the commands to simply not attempt
to determine the total size of an object.

Absent other changes, the result of this bug fix would be that
even if a progress log file is specified by the GIT_LFS_PROGRESS
environment variable, no messages would be written to the log file
during "clean" filter operations.  This would occur because the
copyToTemp() method of the GitFilter structure in our "lfs" package
would find a negative value in its "fileSize" parameter and then
set its "cb" callback function parameter to nil.

We intend to address this issue in PR #6011, but before we do so,
in subsequent commits in this PR we will revise the callback
function created by the CopyCallbackFile() method to handle the
case where it is passed a negative value in its "total" parameter,
which indicates that the total size of the object is unknown.

Before we make those revisions to the CopyCallbackFile() method,
though, we first expand our test suite to exercise the callback
function created by the method under a wider range of conditions.

Specifically, we now refactor the TestCopyCallbackFileThrottle()
function so we will be able to easily expand it to simulate a
number of different test cases.  We define these test cases using
a new copyCallbackFileThrottleTestCase structure and an associated
copyCallbackFileThrottleTestMode enumerated value.

At present, the only test case we specify is one where the initial
total size of an object is both known and correct, but in future
commits in this PR we will add other test cases, including one
where the total size of an object is unknown and is therefore
represented by a negative value.

We alter the TestCopyCallbackFileThrottle() function itself to
iterate over one or more test cases and check each one using both
the EagerEOFByteReader and the DeferredEOFByteReader structures from
our "tools" package.  We use these structures' Read() methods to
simulate conditions where the method returns either an EOF error or
a nil error, respectively, at the same time as the final byte of an
input source is read.

For each test case and underlying implementation of the Reader
interface from the Go standard library's "io" package, the
TestCopyCallbackFileThrottle() function now calls the setup()
method of the copyCallbackFileThrottleTestCase structure, which
initializes the progress log file, mock time clock, callback
function, and input data source for a CallbackReader structure
from our "tools" package.

The test function then runs the Assert() method of the
copyCallbackFileThrottleTestCase structure to perform a series of
calls to the Read() method of the CallbackReader structure after
simulated periods of time have elapsed, and checks that the final
contents of the progress log file match the expected results for
the given test case.

In later commits in this PR we will expand the set of test cases run
by the TestCopyCallbackFileThrottle() function as we revise the
callback function created by the CopyCallbackFile() method and the
Read() methods of the BodyWithCallback and CallbackReader structures
in our "tools" package.
chrisd8088 added a commit that referenced this pull request Aug 5, 2026
In subsequent commits in this PR we expect to make several revisions
to the anonymous callback function created by the CopyCallbackFile()
method of the GitFilter structure in our "lfs" package.  In
particular, we intend to alter the function to more appropriately
handle the case where its "total" parameter is negative, which
indicates that the total size of the current object is unknown.

At present, when the callback function is passed a negative value in
its "total" parameter, it simply writes that value to the progress
log as the denominator of the fraction representing the amount of
the object's data processed so far.

As well, if the callback function is passed a negative value in
its "total" parameter, it will always write to the progress log
regardless of how much time has elapsed since the last message was
written.

In PR #5504 we modified the CopyCallbackFile() method of the
GitFilter structure in our "lfs" package so that the callback
function it creates only outputs a progress log message when
at least 200 milliseconds have elapsed since the last message
was written, unless the number of bytes read has now met or
exceeded the total size of the given Git LFS object.  (The
minimum time allowed between messages is specified by the
DefaultLoggingThrottle constant from our "tasklog" package.)

The callback function checks whether the number of bytes read has
met or exceeded the total size of the given object, and if it has,
unconditionally writes a message to the progress log, on the
assumption that no further callbacks will occur because the final
bytes of the object have now been read.

However, this assumes the total size of the object is known and
correct when the callback is utilized by the CopyWithCallback()
function of our "tools" package.  When the "total" parameter is
negative, though, the number of bytes read so far is always larger
than the total size, so a message is always written.

While it is currently rare for the value in the callback function's
"total" parameter to be negative, we expect it to become more common
in the future as a result of the bug fix to our "clean" filter
commands implemented by PR #6011.

Before we address this issue, we first refactor the callback function
created by the CopyCallbackFile() method to make the logic in the
function easier to modify and understand.  In particular, we separate
the multiple parts of the function's single conditional expression
into distinct steps.  We also revise the function so that it returns
immediately when the condition checked by one of these individual
steps is indicates that no log message should be written.
chrisd8088 added a commit that referenced this pull request Aug 5, 2026
In PR #5504 we modified the CopyCallbackFile() method of the
GitFilter structure in our "lfs" package so that the callback
function it creates only outputs a progress log message when
at least 200 milliseconds have elapsed since the last message
was written, unless the number of bytes read has now met or
exceeded the total size of the given Git LFS object.  (The
minimum time allowed between messages is specified by the
DefaultLoggingThrottle constant from our "tasklog" package.)

The callback function checks whether the number of bytes read has
met or exceeded the total size of the given object, and if it has,
unconditionally writes a message to the progress log, on the
assumption that no further callbacks will occur because the final
bytes of the object have now been read.

However, this assumes the total size of the object is known and
correct when the callback is utilized by the CopyWithCallback()
function of our "tools" package.  When the "total" parameter is
negative, though, the number of bytes read so far is always larger
than the total size, so a message is always written.

While it is currently rare for the value in the callback function's
"total" parameter to be negative, we expect it to become more common
in the future as a result of the bug fix to our "clean" filter
commands implemented by PR #6011.

PR #6011 resolves a bug in our "git lfs filter-progress" and
"git lfs clean" commands whereby they can pass an incorrect
"totalSize" parameter to the CopyWithCallback() function.  The only
solution for this problem is for the commands to simply not attempt
to determine the total size of an object.

Absent other changes, the result of this bug fix would be that
even if a progress log file is specified by the GIT_LFS_PROGRESS
environment variable, no messages would be written to the log file
during "clean" filter operations.  This would occur because the
copyToTemp() method of the GitFilter structure in our "lfs" package
would find a negative value in its "fileSize" parameter and then
set its "cb" callback function parameter to nil.

We intend to address this issue in PR #6011, but the consequence
when we do so will be that the callback function created by the
CopyCallbackFile() method will receive a negative value in its
"total" parameter during "clean" filter operations if a progress log
is configured with the GIT_LFS_PROGRESS environment variable.

At present, the callback function may already receive a negative
value in its "total" parameter, but only under a relatively unusual
combination of circumstances.  First, of course, a progress log must
be enabled with the GIT_LFS_PROGRESS environment variable.  Second,
the legacy "git lfs smudge" command must be used during a "smudge"
filter operation rather than the "git lfs filter-process" command;
this may occur if a version of Git older than v2.11.0 is in use.
Third, the "git lfs smudge" command must download an object that is
not available locally from a server which compresses the content
for transit and therefore sends a Content-Encoding header but no
Content-Length header.

Under these conditions, the lack of a Content-Length header means
the size of the HTTP response body will be represented with a value
of -1.  When our HTTP download adapter passes this negative value to
the CopyWithCallback() function, it will be used to initialize a new
CallbackReader structure from our "tools" package, and ultimately
passed to the callback function created by the CopyCallbackFile()
method.  Note that the callback function itself is passed from the
Smudge() method of the GitFilter structure to the same structure's
downloadFile() method, which creates a new transfer queue using
the WithProgressCallback() option function.

By contrast, when the "git lfs filter-process" command is in use,
it creates a transfer queue without a defined callback function.
As a result, objects that not are available locally are downloaded
and written to local storage without generating any progress log
messages.  When the Smudge() method is later invoked (because Git
re-sends a "smudge" command to our long-running filter process),
it runs the GitFilter structure's readLocalFile() method instead of
the downloadFile() method.  While the readLocalFile() method does
invoke the CopyWithCallback() function and passes a callback
function to it, at this point the size of the object can be
determined from the file in local storage, so the callback function
receives a non-negative value in its "total" parameter.

However, as described above, when the "git lfs smudge" command is
in use and a content-encoded download occurs, the callback function
created by the CopyCallbackFile() method will receive a negative
value in its "total" parameter, which means the function will write
to the progress log regardless of the amount of time that has
elapsed since the previous message was written.

Further, we expect the changes in PR #6011 to result in a wider
range of conditions under which the callback function will receive
a negative value in its "total" parameter.

We therefore now revise the callback function created by the
CopyCallbackFile() method so that it checks whether the value of
the "total" parameter is negative, and if so will not write a
progress log message unless a sufficient period of time has
elapsed since the previous log message was written.

To verify that our change is effective, we define a second test
case for the TestCopyCallbackFileThrottle() test function that
we refactored in a prior commit in this PR.  Our new test case
is named "initial total unknown" and simulates multiple invocations
of the callback function where the "total" parameter has a negative
value each time.  In this test case, we expect that the modifications
from this commit should ensure that the callback function only writes
one log message, after the test advances the mock time clock by the
full duration specified in the DefaultLoggingThrottle constant from
our "tasklog" package.

Note that the log message expected by our new "initial total unknown"
test case contains a "-1" in the denominator of the fraction
representing the amount of source data read so far.  We will refine
this field in a subsequent commit in this PR so that negative
"total" values are no longer reported in a literal manner.
chrisd8088 added a commit that referenced this pull request Aug 5, 2026
In PR #5504 we modified the CopyCallbackFile() method of the
GitFilter structure in our "lfs" package so that the callback
function it created only output a progress log message when
at least 200 milliseconds had elapsed since the last message
was written, unless the number of bytes read had met or exceeded
the total size of the given Git LFS object.  (The minimum time
allowed between messages was specified by the DefaultLoggingThrottle
constant from our "tasklog" package.)

This design depended on the assumption that the total size of the
given object was known and correct when the callback was utilized
by the CopyWithCallback() function of our "tools" package.  When
the "total" parameter was negative, though, a message would always
be written because the number of bytes read so far would always be
larger than the total size.

In a previous commit in this PR we adjusted the callback function
so that it now checks whether the value of the "total" parameter
is negative, and if so will not write a progress log message unless
a sufficient period of time has elapsed since the previous log
message was written.

However, one consequence of this adjustment is that when the "total"
parameter is negative, it is not guaranteed that a log message will
be written when the last callback is made after the final bytes of
the given object's data are read.  Further, if an object's data is
fully processed in less than 200 milliseconds, no progress log
message will be written for the object at all.

While it is currently rare for the value in the callback function's
"total" parameter to be negative, we expect it to become more common
in the future as a result of the bug fix to our "clean" filter
commands implemented by PR #6011.

PR #6011 resolves a bug in our "git lfs filter-progress" and
"git lfs clean" commands whereby they can pass an incorrect
"totalSize" parameter to the CopyWithCallback() function.  The only
solution for this problem is for the commands to simply not attempt
to determine the total size of an object.

Absent other changes, the result of this bug fix would be that
even if a progress log file is specified by the GIT_LFS_PROGRESS
environment variable, no messages would be written to the log file
during "clean" filter operations.  This would occur because the
copyToTemp() method of the GitFilter structure in our "lfs" package
would find a negative value in its "fileSize" parameter and then
set its "cb" callback function parameter to nil.

We intend to address this issue in PR #6011, but the consequence
when we do so will be that the callback function created by the
CopyCallbackFile() method will receive a negative value in its
"total" parameter during "clean" filter operations if a progress log
is configured with the GIT_LFS_PROGRESS environment variable.

At present, the callback function may already receive a negative
value in its "total" parameter, but only under a relatively unusual
combination of circumstances.  First, of course, a progress log must
be enabled with the GIT_LFS_PROGRESS environment variable.  Second,
the legacy "git lfs smudge" command must be used during a "smudge"
filter operation rather than the "git lfs filter-process" command;
this may occur if a version of Git older than v2.11.0 is in use.
Third, the "git lfs smudge" command must download an object that is
not available locally from a server which compresses the content
for transit and therefore sends a Content-Encoding header but no
Content-Length header.

Under these conditions, the lack of a Content-Length header means
the size of the HTTP response body will be represented with a value
of -1.  When our HTTP download adapter passes this negative value to
the CopyWithCallback() function, it will be used to initialize a new
CallbackReader structure from our "tools" package, and ultimately
passed to the callback function created by the CopyCallbackFile()
method.  Note that the callback function itself is passed from the
Smudge() method of the GitFilter structure to the same structure's
downloadFile() method, which creates a new transfer queue using
the WithProgressCallback() option function.

By contrast, when the "git lfs filter-process" command is in use,
it creates a transfer queue without a defined callback function.
As a result, objects that are not available locally are downloaded
and written to local storage without generating any progress log
messages.  When the Smudge() method is later invoked (because Git
re-sends a "smudge" command to our long-running filter process),
it runs the GitFilter structure's readLocalFile() method instead of
the downloadFile() method.  While the readLocalFile() method does
invoke the CopyWithCallback() function and passes a callback
function to it, at this point the size of the object can be
determined from the file in local storage, so the callback function
receives a non-negative value in its "total" parameter.

However, as described above, when the "git lfs smudge" command is
in use and a content-encoded download occurs, the callback function
created by the CopyCallbackFile() method will receive a negative
value in its "total" parameter, which means the function will write
to the progress log regardless of the amount of time that has
elapsed since the previous message was written.

Further, we expect the changes in PR #6011 to result in a wider
range of conditions under which the callback function will receive
a negative value in its "total" parameter.

We therefore now enhance both the callback function and the internal
implementation of the Read() method of the BodyWithCallback and
CallbackReader structures in our "tools" package so that a progress
log message will always be written when the final bytes of a given
object are read.

First, we update the doCallbackIfRequired() method of the
"callbackState" structure in our "tools" package so that it checks
if an EOF condition has been encountered, and if so, checks whether
the amount of data read so far exceeds the total size that was
provided initially for the given object.  Should both of these
conditions apply, the method resets the "totalSize" field of the
"callbackState" structure to the final value of the "readSize"
field, and then runs the doCallback() method to invoke the callback
function.

As a result of this change, a callback will be performed the first
time the EOF condition is encountered while processing an object
whose total size was initially unknown, and when this callback is
made, the callback function's "total" parameter will not have a
negative value but will have the same value as the function's
"written" parameter.

Next, we update the CopyCallbackFile() method of the GitFilter
structure in our "lfs" package so that the callback function the
method creates now checks whether the "total" parameter passed to
the function has a different value than it did the previous time
the function was invoked.  If this is the case, and if the current
value of the "total" is also equal to the non-negative value of the
"written" parameter, then the function writes a message to the
progress log regardless of the amount of time that has elapsed since
the previous message was written.

This change has the key consequence that if the callback function
is invoked one or more times with a negative value for its "total"
parameter, and is invoked a final time with matching non-negative
values for its "written" and "total" parameters, it will always
write a progress log message at that time.

When this change is combined with the revisions we have made to the
"callbackState" structure's doCallbackIfRequired() method in this
commit, the result is that if an object's total size is initially
unknown, when an EOF condition is encountered while reading the
object's data, then a final progress log message is guaranteed to
be written with the total size of the object represented as equal
to the actual number of bytes that were read.

To verify this new behaviour, we add or update a number of our tests,
starting with the TestCopyCallbackFileThrottle() test function in
our "lfs" package.  Most significantly, we update this function's
"initial total unknown" test case so that it now expects a final
log message in which the total size of the object is not negative
but matches the total number of bytes read.

Note that the first log message expected by our new "initial total
unknown" test case still contains a "-1" in the denominator of the
fraction representing the amount of source data read so far.  We will
refine this field in a subsequent commit in this PR so that negative
"total" parameter values are no longer reported in a literal manner.

We also add two new test cases for the TestCopyCallbackFileThrottle()
function, in order to simulate situations where the total size of
an object is initially either overestimated or underestimated.  In
both cases, a final callback should be made in which the "total"
parameter value has been adjusted to match the actual number of
bytes that have been read when the EOF condition is reached.

To simulate these additional test cases, we expand the setup() and
Assert() methods of the "copyCallbackFileThrottleTestCase" structure
we introduced to our "lfs/util_test.go" source file in a previous
commit in this PR.

For the particular test case where the total size of an object is
underestimated, the Assert() method now performs three extra calls to
the Read() method of the CallbackReader structure, with a simulated
delay between the first two of these calls.  The first of these calls
should never result in a log message being written, while the second
should produce a log message in which the number of bytes read so far
exceeds the total size of the object.  For both new test cases, when
the EOF condition is reached, a callback should be made which produces
a log message with the object's total size adjusted in the final log
message to match the actual number of bytes read.

To independently verify the new behaviour of the "callbackState"
structure's doCallbackIfRequired() method, we add a test function to
our "tools" package which also simulates situations in which the
total size of the object is either unknown, overestimated, or
underestimated.

The TestBothCallbackReadersInvokeCallbackOnEOFWhenTotalSizeIncorrect()
function confirms that the Read() methods of both the BodyWithCallback
and CallbackReader structures perform a final callback when the EOF
condition is reached and pass the callback function an updated value
for the total size of the object which matches the actual number of
bytes read.  Note that in a prior commit in this PR we refactored the
Read() methods of both of the BodyWithCallback and CallbackReader
structures so that these methods now just invoke the read() method of
a "callbackState" structure, which in turn executes the
doCallbackIfRequired() method, so we do not need to provide a test
of the doCallbackIfRequired() method itself.

In addition to these changed or new Go test functions, we also expand
our "t/t-progress.sh" shell script with two new tests, one of which
exercises the "git lfs smudge" command under the specific set of
conditions we described above.

Our new "progress log: unknown initial size (smudge)" shell test
forces the use of the "git lfs smudge" command by a "git clone"
command and specified a progress log file using the GIT_LFS_PROGRESS
environment variable.  As well, in the repository cloned by the
"git clone" command, the test establishes a Git LFS object with
the special key string "storage-download-encoding-gzip" as its
contents, which causes our "lfstest-gitserver" test utility program
to return the object's contents in an HTTP response with a
gzip-encoded body and without a Content-Length header.

The test then checks that the progress log file contains an entry
with the full size of the object reported in both the numerator and
denominator of the field which represents the amount of data
downloaded for the object.  Previously, all progress log entries
generated by the "git lfs smudge" command while downloading a
content-encoded object would have a "-1" in the field's denominator.

Note that any intermediate entries preceding the final log entry
will still have a negative value in this field, but we will address
that issue in a subsequent commit in this PR.

(Note also that we use the same "storage-download-encoding-gzip"
special object content key for a more complete test of the Git LFS
client's handling of gzip-encoded responses in the "batch storage
HTTP download with gzip encoding" test in our
"t/t-batch-storage-encoding.sh" shell script.)

The other new shell test in our "t/t-progress.sh" script is named
"progress log: unknown initial size (fetch/pull)", and operates in
a similar manner to the "progress log: unknown initial size (smudge)"
test except that it exercises the "git lfs fetch" and "git lfs pull"
commands rather than the "git lfs smudge" command.

As noted in the following comment on PR #6011, our "git lfs fetch"
and "git lfs pull" commands may write progress log entries with a "-1"
value in the denominator of the field which represents the amount of
data downloaded for the object, if the HTTP response to an object
download request is content-encoded and lacks a Content-Length header.

  #6011 (comment)

In these commands, a transfer queue is created using the
WithProgress() option function rather than the WithProgressCallback()
option function, so the "cb" field of the TransferQueue structure
from our "tq" package is nil.  However, both commands pass a Meter
structure from our "tq" package to the WithProgress() function,
so the TransferQueue structure's "m" field is non-nil.

As a result, when the TransferQueue structure's ensureAdapterBegun()
method is executed, the callback function it creates will execute
only the TransferBytes() method of the Meter structure, which in
turn does not immediately return without taking any action because
its "m" receiver parameter is non-nil.  By comparison, when the
WithProgressCallback() option function is used instead of the
WithProgress() option function, this same callback function runs
the TransferBytes() method, which returns immediately, and then
runs the callback function specified by the non-nil "cb" field.

The callback function created by the ensureAdapterBegun() method
is then passed to the Begin() method of the transfer adapter, and
ultimately passed to the CopyWithCallback() function of our "tools"
package by the "basicDownloadAdapter" structure's download() method.
(Note that this is also true for our "git lfs filter-process" and
"git lfs smudge" commands, not just for the "git lfs fetch"
and "git lfs pull" commands.)

As described above regarding the "git lfs smudge" command, the
CopyWithCallback() function initializes a new CallbackReader structure
and so the callback function passed to the CopyWithCallback() function
will ultimately be invoked as object data is read from the HTTP
response body.  With the "git lfs fetch" and "git lfs pull" commands,
though, this callback function only runs the TransferBytes() method
of the Meter structure, and that method does not return immediately
but runs the logBytes() method of the same structure.

For historical reasons which date back to the introduction of the
logBytes() method in commit 710862b
of PR #476, this method writes a line to the progress log file with
the identical format as the lines generated by the callback function
created by the CopyCallbackFile() method of the GitFilter structure.

However, unlike that anonymous callback function, the logBytes()
method does not limit its output based on the amount of time that
has elapsed since it was previously invoked.  When we revised the
CopyCallbackFile() method in PR #5504 to implement a mechanism
which throttled the frequency of its output to the progress log,
we did not also update the logBytes() method to do the same.

While we likely should refactor the logBytes() method and the
CopyCallbackFile() method's callback function to share a common
implementation, that change falls outside of the scope of this PR,
so we defer it to a future PR.

What this PR does accomplish, though, is to ensure that regardless
of their source, when progress log messages are written for an object
whose total size is initially unknown, a final callback will be made
after the EOF condition is encountered, and this will generate a
progress log message in which the total size has been adjusted to
match the actual number of bytes read.

For this reason, our "progress log: unknown initial size (fetch/pull)"
shell test exercises the "git lfs fetch" and "git lfs pull" commands
with a progress log file specified in the GIT_LFS_PROGRESS environment
variable and an HTTP object download response that lacks a
Content-Length header.  Like our other new shell test, this test
also uses the "storage-download-encoding-gzip" special object
content key to cause the "lfstest-gitserver" utility to gzip-encode
its response to an object download request.

The test then confirms that the progress logs generated by the
"git lfs fetch" and "git lfs pull" commands contain an entry with
the full size of the object reported in both the numerator and
denominator of the field which represents the amount of data
downloaded for the object.  Previously, all progress log entries
generated by these commands while downloading a content-encoded
object would have a "-1" in the field's denominator.

Note that any intermediate entries preceding the final log entry
will still have a negative value in this field, but we will address
that issue in a subsequent commit in this PR.
chrisd8088 added a commit that referenced this pull request Aug 5, 2026
As described in a previous commit in this PR, several of our Git LFS
commands may write progress log messages with a negative value in
the denominator of the fraction representing the amount of data
downloaded so far for an individual object.  Specifically, the
"git lfs fetch", "git lfs pull", and "git lfs smudge" commands
will write such log messages when a progress log is enabled with
the GIT_LFS_PROGRESS environment variable and the response to an
HTTP object download request is content-encoded and therefore lacks
a Content-Length header.

As described in a previous commit in this PR, for historical reasons
progress log messages are written either by the anonymous callback
function created by the CopyCallbackFile() method of the GitFilter
structure in our "lfs" package, or by the logBytes() method of the
Meter structure in our "tq" package.  However, the format of the
messages is identical regardless of which function or method
generates them.

When the logBytes() method was first introduced in commit
710862b of PR #476, it duplicated
the format of the progress log messages generated by the callback
function created by the CopyCallbackFile() method.  The current
progress log message format in that method dates from commit
36fa23d of PR #100, but the original
version of the method and its callback function themselves were added
in commit fd41906 of PR #97.

While we likely should refactor the logBytes() method and the
CopyCallbackFile() method's callback function to share a common
implementation, that change falls outside of the scope of this PR,
so we defer it to a future PR.

However, one of the changes we made in a prior commit in this PR
now ensures that if an object's total size is initially unknown,
when an EOF condition is encountered while reading the object's
data, then a final progress log message is guaranteed to be written
with the total size of the object represented as equal to the
actual number of bytes that were read.  Further, this final
progress log entry will be output regardless of whether the
CopyCallbackFile() method's callback function or the logBytes()
method is being used to write messages to the log file.

While this change affects the content of the last progress log
entry written for a given object, until the EOF condition is
reached, intermediate log messages may be generated which still
have a negative value in the denominator of the fraction
representing the amount of the object's data processed so far.

These negative values occur because the length of the data to be
read is initially unknown.  At present, the only circumstance in
which this is possible is when the HTTP response to an object
download request lacks a Content-Length header, as will be the
case when the response body is gzip-encoded, as we noted in the
following comment on PR #6011:

  #6011 (comment)

PR #6011 resolves a bug in our "git lfs filter-progress" and
"git lfs clean" commands whereby they can pass an incorrect
"totalSize" parameter to the CopyWithCallback() function.  The only
solution for this problem is for the commands to simply not attempt
to determine the total size of an object.  As a consequence, we
expect that our "git lfs filter-progress" and "git lfs clean" commands
will never know in advance the length of the object data they are
reading from Git, and it will become a more common occurrence for
the CopyCallbackFile() method's callback function and the logBytes()
method to receive a "total" parameter with a negative value.

We therefore now refine the format of the progress log when the
total size of an object is unknown by writing a series of question
mark characters ("?") in place of the denominator of the fraction
which represents the amount of the object's data processed so far.
We adjust the number of these question mark characters to match
the length of the numerator, e.g., "123/???" and "12345/?????".

To avoid implementing the necessary string formatting logic in
both the CopyCallbackFile() method's callback function and the
logBytes() method, we introduce a new FormatFraction() function
to our "tools" package and then substitute its output in place
of the appropriate fraction field in the log messages.

We then add a TestFormatFraction() Go test function which verifies
that the FormatFraction() function generates the expected output
under a range of conditions, including when the number of bytes
read so far exceeds the value provided as the total size of an
object, and when the total size of an object is unknown and should
be represented using various numbers of question mark characters.

Finally, we update the comments in several of the tests we added
to the "t/t-progress.sh" shell script in a prior commit of this PR,
and we revise the "initial total unknown" test case for our
TestCopyCallbackFileThrottle() test function.  In that test case,
we update the expected contents of the progress log because the
intermediate entry which precedes the final entry should now have
a series of question mark characters rather than a "-1" value in
the denominator of the fraction which represents the amount of the
object's data processed so far.
In a prior commit in this PR we revised the clean() function in our
"commands/command_clean.go" source file so that when it is passed
a non-empty "fileName" parameter, the function no longer tries to
determine the size of the file at the location given by that
parameter.

Previously, the function would invoke the Stat() function from the
Go standard library's "os" package to attempt to read the size of
the given file.  If that succeeded, and if the clean() function's
"fileSize" parameter was negative, the file size returned by the
Stat() function would be used in the subsequent call to the Clean()
method of the GitFilter structure in our "lfs" package.

Instead, the clean() function now just passes its "fileSize"
parameter directly to the Clean() method, without modification.
This change ensures that we do not pass an incorrect size to the
Clean() method, as might previously occur under the circumstances
reported in the secondary issue from git-lfs#4974.

Specifically, as described in the following comment, when Git streamed
a file's data to our "git lfs filter-process" command, if the
corresponding file on disk was altered before the command checked
its size, an error would be reported:

  git-lfs#4974 (comment)

Both the "git lfs filter-process" command and the "git lfs clean"
command pass negative values for the "fileSize" parameter when they
invoke the clean() function, since they do not receive file size
information from Git during "clean" filter operations.  Hence the
Clean() method now always receives a negative value in its "fileSize"
parameter during "clean" filter operations.

In the normal case, when no Git LFS pointer extension programs are
configured, the Clean() method invokes the GitFilter structure's
copyToTemp() method to read the object data piped from Git, check
if it already contains a valid Git LFS pointer, and otherwise
calculate the data's SHA-256 hash while spooling the data to a
temporary file.  To write the object data to the file, the
copyToTemp() method uses the CopyWithCallback() function from our
"tools" package.

The purpose of our CopyWithCallback() function is simply to copy data
from a source to a destination, while periodically invoking a callback
function to write progress messages to a log file, if one is defined
with the GIT_LFS_PROGRESS environment variable.  These progress log
entries include a field which represents the amount of an object's
data that has been transferred so far as a fraction whose denominator
is the expected total size of the object.

To populate the denominator of this field in the progress log
entries, the CallbackReader structure in our "tools" package is
initialized with a "totalSize" parameter, which will then be
passed to the provided callback function each time it is invoked.

Since the CopyWithCallback() function creates a CallbackReader
structure, it therefore requires both its own "totalSize" parameter
and a pointer to a callback function.  During a "clean" filter
operation, these parameters are passed down from the clean() function
to the GitFilter structure's Clean() method and then on to the
copyToTemp() method, which passes them to the CopyWithCallback()
function.  Note that the GitFilter structure's methods use the name
"fileSize" for the parameter which is ultimately used for the
CopyWithCallback() function's "totalSize" parameter.

In commit b0d669c of PR git-lfs#3902 we
added a check to the copyToTemp() method which sets the callback
function pointer to nil if the "fileSize" parameter is negative.

As noted above, we revised the clean() function in a prior commit
in this PR so that it no longer attempts to read the size of the
file identified by its "fileName" parameter.  Since both the
"git lfs clean" and "git lfs filter-process" commands pass a
negative value to the clean() function, that is the value which
all "clean" filter operations now pass to the copyToTemp() method.

As a result of our changes, the check for a negative "fileSize"
parameter value added to the copyToTemp() method in PR git-lfs#3902 will
always succeed during "clean" filter operations.  This effectively
disables all progress log output from these operations even though
the clean() function explicitly creates a callback function for
this purpose using the CopyCallbackFile() method of the GitFilter
structure.

The "progress log: file counts" test that we renamed and
updated in the recent PR git-lfs#6288 provides a demonstration of
this change in the behaviour of the Git LFS client.  In commit
4a4e524 of that PR we added to
the test a check of the number of entries with the operation name
"clean" that appear in a progress log after a "git add" command.
Since our change to the clean() function in the prior commit in
this PR, that check now finds no matching entries in the progress
log, and so the test currently fails.

To resolve this problem, we now simply remove the conditional block
in the copyToTemp() method which sets the callback function pointer
to nil if the "fileSize" parameter is negative.

Note, though, that when we introduced that conditional block in
PR git-lfs#3902, we did so with the intention that it would act as a
fallback protection against the problem reported in git-lfs#3884.  Prior
to the changes from PR git-lfs#3902, the "git hash-object" command would
fail when its --path option's argument matched a Git LFS filter
entry from a "gitattributes" file and the command's input was
smaller than the size of the read buffer the Git LFS client uses
when decoding pointers.

In commit b0d669c of PR git-lfs#3902 we
resolved this problem by ensuring that the Read() method of what
was then the "pktlineReader" structure in our "git" package properly
handled subsequent calls after reading a non-zero number of bytes
and also encountering the EOF condition.  (Note that we later moved
this structure into our separate "git-lfs/pktline" repository.)

In the same commit we also attempted to protect against the issue
from git-lfs#3884 with a separate change to the copyToTemp() method.
Specifically, we added the conditional block which sets the callback
function pointer to nil if the "fileSize" parameter is negative,
with the understanding that this would prevent the method from
writing a file to the working tree when the Git LFS client was
invoked by a "git hash-object" command.  As we explained in the
commit description, this change was meant to ensure that we
"don't process the callback to write the file in this case,
since we don't actually want to write into the working tree".

However, we apparently made this change under a misapprehension.
The callback function provided to the copyToTemp() method is not
responsible for writing to a file in the working tree.  In fact,
during a "clean" filter operation, no file will be written to the
working tree at all, including by Git.  (This stands in contrast
to a "smudge" filter operation, when Git does write a file to the
working tree.)

What does occur during a "clean" filter operation, though, is
that the copyToTemp() method creates a temporary file in the
local Git LFS storage directories and writes to the file using
the CopyWithCallback() function from our "tools" package.
This occurs regardless of whether a callback function is defined
or not.  The CopyWithCallback() function uses the Copy() function
from the Go standard library's "io" package to perform the actual
transfer of data.  The clean() function in our "commands" package
then moves the temporary file into a final location in the object
storage directory hierarchy within the local Git LFS storage
directory.  All of these steps are performed even if the "clean"
filter operation was invoked via a "git hash-object" command and
not a "git add" command.

As described further above, the only purpose of the callback function
that the CopyCallbackFile() method of the GitFilter structure creates
is to write messages to a progress log file, if one is specified with
the GIT_LFS_PROGRESS environment variable.  So the conditional block
that we added to the copyToTemp() method in commit
b0d669c only causes the progress
log to be disabled under some conditions, not to prevent a Git LFS
object file from being created.

Thus we can remove this conditional block entirely without concern
that we might introduce a regression and a re-introduction of the
problem reported in git-lfs#3884.

However, absent some of our other changes from PR git-lfs#6288, we would
cause a regression of another form, by re-introducing the issue
that was reported and resolved in PR git-lfs#5504.  That PR revised the
callback function created by the CopyCallbackFile() method so that
it would only write a message to the progress log if at least 200
milliseconds had elapsed since the previous message was written, or
if all the data for an object had been read and so the current
message would be the final entry for a given object.  We made this
change specifically to avoid excessive numbers of writes to the
progress log during "clean" filter operations.

In order to ensure that a final progress log message was written for
an object when its data had been fully read, the callback function
would check whether its "written" parameter was equal to or larger
than its "total" parameter, and if so, unconditionally write a
message even if less than 200 milliseconds had elapsed since the
previous message was written.  This logic depends on the "total"
parameter's value never being negative, though, because if it is
then the "written" parameter will always be larger and so every
message will be written, regardless of the amount of time that
has elapsed since the previous message was written.

On their own, our changes to the clean() function in our prior
commit in this PR would result in the callback function writing
every message to the progress log during "clean" filter operations,
which would re-introduce the problem that we tried to resolve in
PR git-lfs#5504.

Fortunately, though, we have recently addressed this issue in
PR git-lfs#6288 by refactoring both the callback function created by the
CopyCallbackFile() method and the internal implementation of the
Read() method of the CallbackReader structure.  The callback
function now avoids writing messages if insufficient time has
elapsed even when the "total" parameter's value is negative.

As well, when the callback function receives a negative value in
its "total" parameter, it represents the unknown total as a series
of question mark characters in the progress log entry, rather than
as a literal negative integer.

Further, the internal implementation of the CallbackReader
structure's Read() method will now change the value of the "total"
parameter to equal that of the "written" parameter when it invokes
the callback function for the final time for a given object.
This ensures that the final entry for an object whose size was
initially unknown will not contain question mark characters
(or a negative integer) but will instead have equal values in
the numerator and denominator of the field which represents the
amount of the object's data that has been processed.

To verify that these changes in PR git-lfs#6288 work in combination with
our changes from this PR to generate an entry in the progress log
during a "clean" filter operation, we now add a "progress log:
unknown initial size (clean)" test to our "t/t-progress.sh" shell
script.  We model this test on the "progress log: unknown initial
size (smudge)" and "progress log: unknown initial size (fetch/pull)"
tests that we added in commit 47d1e25
of PR git-lfs#6288.

Our new test performs a "git add" command and confirms that although
the "git lfs filter-process" command no longer attempts to determine
the size of an object by calling the Stat() function on the
corresponding file in the working tree, a final progress log entry
will be written where the total size of the object is correctly
reported.
In a prior commit in this PR we revised the clean() function in our
"commands/command_clean.go" source file so that when it is passed
a non-empty "fileName" parameter, the function no longer tries to
determine the size of the file at the location given by that
parameter.

Previously, the function would invoke the Stat() function from the
Go standard library's "os" package to attempt to read the size of
the given file.  If that succeeded, and if the clean() function's
"fileSize" parameter was negative, the file size returned by the
Stat() function would be used in the subsequent call to the Clean()
method of the GitFilter structure in our "lfs" package.

Instead, the clean() function now just passes its "fileSize"
parameter directly to the Clean() method, without modification.
This change ensures that we do not pass an incorrect size to the
Clean() method, as might previously occur under the circumstances
reported in the secondary issue from git-lfs#4974.

Specifically, as described in the following comment, when Git streamed
a file's data to our "git lfs filter-process" command, if the
corresponding file on disk was altered before the command checked
its size, an error would be reported:

  git-lfs#4974 (comment)

Both the "git lfs filter-process" command and the "git lfs clean"
command pass negative values for the "fileSize" parameter when they
invoke the clean() function, since they do not receive file size
information from Git during "clean" filter operations.

By contrast, the "git lfs migrate import" command passes non-negative
values for the "fileSize" parameter when it calls the clean()
function, because it is processing Git blobs from existing commits
in a repository's Git history, and therefore has direct access to
the sizes of the individual blobs.

One other Git LFS command also invokes the clean() function, the
"git lfs merge-driver" command.  When configured by a user, this
command may be called by Git during merge operations, and will be
passed the locations of several temporary files created by Git,
each of which represents a different revision of a file.  If the
command can automatically resolve any conflicts between these
revisions, it is expected to write its output back to a specific
temporary file.

The "git lfs merge-driver" command expects to handle merge conflicts
between Git LFS pointers, which Git cannot resolve using its own
merge machinery.  Thus if the command can resolve a conflict, it
must write a valid Git LFS pointer into the specified output file,
which in turn means the command must invoke the clean() function
to create the pointer data.  When it runs this function, it passes
a file descriptor to the output file as the "to" parameter, and
another file descriptor for a different, additional temporary file
as the "from" parameter.  This additional temporary file is not
one of the files created by Git, but is created internally by the
"git lfs merge-driver" command to hold the actual Git LFS object
data for which the command should generate a new pointer.

At present, the command opens a file descriptor for this internal
object data temporary file and passes it as the "from" parameter
to the clean() command, and also passes its path in the "fileName"
parameter.  As well, the command runs the Stat() function from the
"os" package on the file before opening it, in order to try to
determine the file's size, which is then passed to the clean()
function in its "fileSize" parameter.

In the context of a merge driver program, however, there is no
particular need to perform the Stat() call or determine the file's
size for the clean() function.  In fact, given that the size of
the file might hypothetically change between the time of the Stat()
call and the time the file is opened, it is actually preferable for
us not to pass a potentially incorrect file size to the clean()
function.

We can therefore simplify our "git lfs merge-driver" command by
removing the Stat() call entirely and just passing a negative value
for the "fileSize" parameter of the clean() function.  This will
still result in the file being read completely, because if the
"fileSize" value is negative, the copyToTemp() method of the
GitFilter structure in our "lfs" package will continue to read
from the "from" file descriptor until EOF is reached.

We also take the opportunity to update the code comments which precede
the clean() function in our "commands" package, in order to clarify
that when the function's "fileSize" parameter is negative, this merely
indicates that the length of input object data is unknown.
In a prior commit in this PR we introduced a new test helper utility
named "lfstest-badlocalfile", which pipes Git packet-line data from
its standard input to its standard output, and when a file name is
detected in the packet data, the utility replaces that file with
an empty file.

This helper program allows the "filter process: don't trust file
size on disk when cleaning" test in our "t/t-filter-process.sh" shell
script to reproduce the situation where a file's data is piped to the
"git lfs filter-process" command but the file's size on disk is
altered before the command processes the data.  We added this
test in the same prior commit in which we also introduced the
"lfstest-badlocalfile" utility.

After our new helper program detects a file name in the incoming Git
packet data, if Git has also specified that a "clean" filter
operation is in progress, the utility first renames the file at
that location with a ".orig" file extension.  The program then
attempts to create a file at the original location and truncates
its size to zero.

We can simplify this second step slightly by using the O_EXCL flag
from the Go standard library's "os" package to force the OpenFile()
function from the same package to fail unless it creates a new file
rather than opening an existing file.  Since a new file must by
definition be empty, we then no longer need to explicitly try to
truncate the file to zero bytes.

Note that in the context of our "filter process: don't trust file
size on disk when cleaning" test we expect our utility to always
be able to create a new file, so if this is not the case, we just
report a panic condition and halt the program immediately.
In a prior commit in this PR we revised our "clean" Git filter
commands to address the secondary issue described in git-lfs#4974, and
then in a subsequent commit we introduced a new test which
validates the changes we made to our filter commands.

Specifically, the "filter process: don't trust file size on disk
when cleaning" test in our "t/t-filter-process.sh" shell script
creates the conditions described in the following comment, and then
verifies that they no longer cause the "git lfs filter-process"
command to report an error:

  git-lfs#4974 (comment)

To reproduce the conditions described in the comment, the test
starts by creating a file larger than the size of the buffer we use
to detect and decode Git LFS pointers.  The test then adds the file
as a Git LFS object, causing its contents to be streamed to our
"clean" filter process.  However, the test also ensures that a file
smaller than the size of our pointer detection buffer is written to
the same location as the original file before our filter process
actually executes.

Since we first developed this test for this PR, in commit
9fe97b0 of PR git-lfs#6285 we introduced a
test utility program named "lfstest-getlimit" which can return the
value of the BlobSizeCutoff constant from our "lfs" package.  This
constant defines the size of the read buffer used to decode pointers
in the Git LFS client.

As a result, in commit ac2258e of the
same PR git-lfs#6285 we were able to rewrite a number of tests in our shell
test suite that previously relied on hard-coded values to create test
data of a sufficient size to exceed the Git LFS client's pointer
read buffer.

We can therefore now make the same change to our new "filter process:
don't trust file size on disk when cleaning" test, and replace the
use of a file created using a hard-coded size with one which is
dynamically sized to exceed the value returned by the "lfstest-getlimit"
when it is passed the --max-pointer-size option.  Note that the
present value of the BlobSizeCutoff constant is 1024, so our test
now just creates a file twice that size, which still guarantees it
will exceed the size of the internal pointer read buffer.

As well, we also update our new test so that it uses the head(1)
command to read a specific number of zero bytes from the "/dev/zero"
pseudo-device instead of using the dd(1) command, as we did
previously.  This change mirrors one we made to many of our other
similar tests in commit ac2258e
of PR git-lfs#6285, where we adopted the consistent use of the "head"
command to create files of specific sizes because the "dd" command
requires several somewhat obscure options, while the "head" command
only requires a single byte-count argument.
In a prior commit in this PR we revised our "clean" Git filter
commands to address the secondary issue described in git-lfs#4974, and
then in a subsequent commit we introduced a new test which
validates the changes we made to our filter commands.

Specifically, the "filter process: don't trust file size on disk
when cleaning" test in our "t/t-filter-process.sh" shell script
creates the conditions described in the following comment, and then
verifies that they no longer cause the "git lfs filter-process"
command to report an error:

  git-lfs#4974 (comment)

To reproduce the conditions described in the comment, the test
starts by creating a file larger than the size of the buffer we use
to detect and decode Git LFS pointers.  The test then adds the file
as a Git LFS object, causing its contents to be streamed to our
"clean" filter process.  However, the test also ensures that a file
smaller than the size of our pointer detection buffer is written to
the same location as the original file before our filter process
actually executes.

Since we first developed this test for this PR, in commit
6cc587c of PR git-lfs#6285 we revised a
number of the pre-existing tests in our "t/t-filter-process.sh" shell
script to better document their intent and to bring them into closer
alignment with each other.

We therefore now make several adjustments to our new "filter process:
don't trust file size on disk when cleaning" test so that it conforms
with the current implementation of the other tests in the same script.

We also expand the script to perform one additional check, and take
the opportunity to slightly rename the test to help clarify that it
expects the Git LFS client to entirely ignore any file in the working
tree which might (or might not) correspond to the source of the data
Git streams to the "git lfs filter-process" command.

First, we rename the test's "oid" shell variable to "contents_oid",
to match the naming scheme found in our other tests.

Next, we add a comment to explain how the "lfstest-badlocalfile"
helper utility that we introduced in a prior commit in this PR is
used by the test to replicate the original issue (i.e., the
secondary issue reported in git-lfs#4974).

As well, we capture the output of the "git add" command into a log
file and check that this file does not contain the error message
"unknown command", which was a symptom of the original problem.
Because our use of the tee(1) command to create the log file obscures
the exit code from the "git add" command, we also explicitly check
that command's exit code, following the idiom established by many
of our other shell tests.

Finally, we rename the test to "filter process: ignore file size on
disk when cleaning", and change the name of the repository the test
creates to match the new test name.
In a prior commit in this PR we revised our "clean" Git filter
commands to address the secondary issue described in git-lfs#4974, and
then in subsequent commits we introduced and refined a new test
which validates the changes we made to our filter commands.

Specifically, the "filter process: ignore file size on disk when
cleaning" test in our "t/t-filter-process.sh" shell script creates
the conditions described in the following comment, and then verifies
that they no longer cause the "git lfs filter-process" command to
report an error:

  git-lfs#4974 (comment)

(Note that when we first added our new test, we initially assigned
it the name "filter process: don't trust file size on disk when
cleaning", which we then changed slightly in a subsequent commit
in this PR.)

To reproduce the conditions described in the comment on issue git-lfs#4974,
the test starts by creating a file larger than the size of the
buffer we use to detect and decode Git LFS pointers.  The test then
adds the file as a Git LFS object, causing its contents to be
streamed to our "clean" filter process.  However, the test also
ensures that a file smaller than the size of our pointer detection
buffer is written to the same location as the original file before
our filter process actually executes.

Although we have two "clean" filter commands, the test only exercises
the "git lfs filter-process" command, which since Git v2.11.0 is the
preferred mechanism by which our "clean" filter is applied to
Git LFS files.

Users with an older version of Git or a non-standard configuration may
still utilize our "git lfs clean" command for the same purpose,
although it can only process a single file.  By contrast, the
"git lfs filter-process" command supports Git's long-running filter
protocol and can process multiple files asynchronously.

As the problem reported in the secondary issue from git-lfs#4974 applies
to both of the commands that implement our "clean" filter, we now
add a new test to our "t/t-clean.sh" script which reproduces the
same conditions as the "filter process: ignore file size on disk
when cleaning" test, but exercises the "git lfs clean" command
instead of the "git lfs filter-process" command.

One convenient simplification which is possible with this test is
that we do not require the use of the "lfstest-badlocalfile" test
helper utility to simulate the necessary conditions, as we do in
the corresponding test of the "git lfs filter-process" command.
This utility pipes Git packet-line data from its standard input to
its standard output, and when a file name is detected in the packet
data, the utility replaces that file with an empty file.  That allows
our test of the "git lfs filter-process" command to reproduce the
situation where a file's data is piped to the command but the file's
size on disk is altered before the command processes the data.

However, in the case of the "git lfs clean" command, since it
only processes a single file at a time, we can simply pipe the
data from another file to the command after first creating an
empty file whose name is passed to the command as its sole
argument.

Note that without the revisions we made to our "clean" Git filter
commands in earlier commits in this PR, the conditions replicated
by our new "clean: ignore file size on disk when cleaning" test
would cause the "git lfs clean" command to return an incorrect
Git LFS pointer, which would in turn cause the test to fail.

This would occur because the pointer's "size" field would be set
to 1024 bytes, as the command would only consume enough of its
input data to fill the pointer read buffer, whose length is set
by the BlobSizeCutoff constant from our "lfs" package.  Since the
input data's length would be incorrectly determined to be zero (due
to the empty file), the copyToTemp() method of the GitFilter
structure in our "lfs" package would skip reading any additional
input data, assuming it to be complete.  As a consequence, the
command would output a Git LFS pointer for a file of 1024 bytes,
regardless of the fact that more input data remained to be read.
@chrisd8088
chrisd8088 merged commit e8128e7 into git-lfs:main Aug 5, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Help triaging git-lfs checkout failure

4 participants