Repository navigation
filter-process: Ignore file size in working copy when cleaning - #6011
Conversation
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
|
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! |
|
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! |
|
@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 |
Yes, that's one of the issues, indeed. At the moment, the file size in the progress log (when enabled with Since noticing that I've been pulled off in another direction, but will return to this topic shortly, I very much hope! |
This comment was marked as spam.
This comment was marked as spam.
|
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! |
|
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 The Go HTTP client automatically inserts an 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 |
Thanks for keeping this on your radar. Keen to see it merged eventually :) |
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)
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)
|
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! |
|
Thanks for the update, very happy to see progress being made, can’t wait for your new release :) |
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.
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.
|
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 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 |
chrisd8088
left a comment
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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.
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.
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.
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.
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.

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).