Handle git lfs checkout merge conflict option file paths correctly - #6136
Merged
Merged
Conversation
In commit 5c11ffc of our "main" development branch and commit 8b4aede of our "release-3.7" branch we revised the SmudgeToFile() method of the GitFilter structure in our "lfs" package so it removes any existing directory entry at the path where it intends to create a file before attempting to create that file. Among other advantages, this change ensures that we will not write through a symbolic link which exists in place of the file we intend to create, and so forms part of our remediation of the vulnerabilities reported as CVE-2025-26625. When we made this change we updated several of the tests in our t/t-checkout.sh and t/t-pull.sh shell test scripts, including the "checkout: conflicts" test, which validates the behaviour of the "git lfs checkout" command's --to option. In particular, we revised this test so that it now confirms that any pre-existing symbolic links at the file path specified with the --to option are removed before a new file is written at the given path. However, on Windows these checks are only useful if true symbolic link support is enabled. In an earlier pair of commits, commit 7a86d13 of our "main" branch and commit 1b483db of our "release-3.7" branch, we expanded the "checkout: conflicts" test and introduced a check which confirms that the "git lfs checkout" command will traverse a symbolic link in the path provided with the --to option so long as it appears in place of a directory in the path and links to an existing directory. On Windows, though, we only perform this check when the has_native_symlinks() test helper function returns a successful (i.e., zero) exit code. When we further expanded the "checkout: conflicts" test in the later commits 5c11ffc and 8b4aede, though, we overlooked this conditional block. As a result, our checks that symbolic links are removed when they appear in place of the file identified by the --to option always run on Windows, even when symbolic links are merely simulated by the MSYS2 environment using deep copies of their target locations. We therefore now adjust the "checkout: conflicts" test so that all of its checks of the "git lfs checkout" command's handling of symbolic links in the path specified by the --to option only run on Windows when such links are actually supported by the operating system and MSYS2 environment.
The --to option of the "git lfs checkout" command requires a file path argument which specifies where the command should write the contents of the object associated with a Git LFS pointer file. The command also requires another file path argument which identifies the pointer within the repository whose object contents should be retrieved, along with a second option that specifies which stage of a merge conflict should be referenced when reading the pointer's data. The final file path argument, which indicates the pointer file within the repository whose object contents are to be retrieved, may be provided by the user as a relative path from their current working directory. This path is then converted into a path relative to the root of the repository by the rootedPaths() function, which calls the Convert() method of the currentToRepoPatternConverter structure in our "lfs" package. Technically, because only a single file path is expected and not a file path pattern, the Convert() method of the currentToRepoPathConverter structure should be used instead, so we will address that concern in a subsequent commit in this PR. After conversion to a path relative to the root of the repository, the pointer file path is used for the value of the "Name" element of a WrappedPointer structure from our "lfs" package. This structure is then passed to the RunToPath() method of the singleCheckout structure in our "commands" package as its "p" parameter. The file path argument of the --to option, meanwhile, is converted to an absolute path and passed to the RunToPath() method as its "path" argument. In commit a318987 of our "release-3.7" branch and commit e735de5 of our "main" development branch we introduced the conversion to an absolute path at the same that we also revised the newSingleCheckout() function, which initializes a singleCheckout structure, to change the current working directory to the root of the current working tree, if one is defined. Without this change, if the user supplied a relative path as the argument of the --to option, the "git lfs checkout" command would not create an output file in the location the user expects. The RunToPath() method passes both of its parameters to the SmudgeToFile() method of the GitFilter structure from our "lfs" package, and in particular passes its "path" parameter as the "filename" parameter of the SmudgeToFile() method. That method attempts to create and open a new file at the location specified by the "filename" parameter, and if it encounters any errors while doing so, includes the file path from its "filename" in its error messages, which is appropriate since that is the path of the file the method is trying to create. However, the SmudgeToFile() method then passes its "filename" parameter to the Smudge() method as its "workingfile" parameter, which was originally intended to only contain a path corresponding to the Git LFS pointer file represented by the "ptr" parameter. In almost all circumstances, this is still the case, except when a --to option is supplied to a "git lfs checkout" command. In that one instance, there is a discrepancy between the file path in the "workingfile" parameter and the path of the pointer file represented by the "ptr" parameter. Even when the "git lfs checkout" command is run with a --to option, this discrepancy will go unnoticed so long as there are no Git LFS pointer extension programs configured. If such a program is configured, though, then the file path it receives as a command-line argument will be the one passed to the Smudge() method in its "workingfile" argument, and so will not correspond to the Git LFS object whose contents are piped to the program. The Smudge() method invokes the readLocalFile() method of the GitFilter structure and passes its "workingfile" parameter, which the readLocalFile() method uses populate the "fileName" element of a new "pipeRequest" structure. This structure is then passed to the pipeExtensions() function, which substitutes the value of the "fileName" element in place of the "%f" specifier in the given Git LFS pointer extension program's configured command line. As described above, in recent commits we adjusted the "git lfs checkout" command as well as the "git lfs pull" command so that they change the current working directory to the root of the working tree, if a working tree is present. At the same time, we also revised these two commands so that the file paths they pass to the SmudgeToFile() method in its "filename" argument are relative to the root of the repository, with one exception. That exceptional case occurs when the file path is derived from the argument of the "git lfs checkout" command's --to option, which at present we pass as an absolute path rather than a relative one. To resolve this discrepancy in our treatment of the file paths we pass from the RunToPath() method through the SmudgeToFile() method to the Smudge() method, we will need to ensure that the "workingfile" parameter of that method always represents the path from the root of the repository to the pointer file whose contents are processed by the method. At present, the RunToPath() method passes only the embedded Pointer structure from its "p" WrappedPointer structure parameter to the SmudgeToFile() method. This means the "Name" field of the WrappedPointer structure, which always contains the pointer file's path within the repository, is not available within the SmudgeToFile() method. Instead, that method passes its "filename" parameter to the Smudge() method as its "workingfile" parameter. As described above, the file path in the "filename" parameter normally corresponds with the path of the pointer file represented by the "ptr" parameter, but not when the "git lfs checkout" command is run with the --to option. To make sure that the pointer file's path is always available in the SmudgeToFile() method so that the path can in turn be passed to the Smudge() method, we simply change the SmudgeToFile() method's "ptr" parameter from a Pointer structure to a WrappedPointer structure. We can then use that structure's "Name" field for the value which the method passes to the Smudge() method in its "workingfile" parameter. This change ensures that even when the "git lfs checkout" command is run with a --to option, the Smudge() method receives a file path which corresponds to the Git LFS pointer it is processing. That file path will then be the one passed to any Git LFS pointer extension programs in place of the "%f" specifier from their command line configurations. We also update the "checkout: pointer extension with conflict" test, which we added to our t/t-checkout.sh test script in commit 9726d5c of our "release-3.7" branch and commit 4b25800 of our "main" development branch, and then revised in several subsequent commits in each branch. This test configures our "lfstest-caseinverterextension" test utility as a pointer extension, and at present checks that the file path argument the utility logs is an absolute path version of the argument specified with the "git lfs checkout" command's --to option. (The test originally checked that the logged file path was identical to the --to option's argument, until we altered the "git lfs checkout" command to convert that argument into an absolute path.) Since the file path command-line argument passed to a pointer extension program like our "lfstest-caseinverterextension" test utility is now always the path of the pointer file whose object's contents are piped to the program, and that path is always relative to the root of the repository, we update the "checkout: pointer extension with conflict" test so that it now checks for such a path in all cases. This aligns the test with the "checkout: conflicts" test, in which the "git lfs checkout" command is also run with the --to option, but without any configured Git LFS pointer extensions. Finally, we take the opportunity to correct a minor typo in the comments at the start of the "lfstest-caseinverterextension" test helper program.
Since the "git lfs checkout" command was introduced in PR git-lfs#527, it has accepted zero or more command-line arguments which are expected to be file path patterns. If any such arguments are supplied, they are used to filter the set of Git LFS files that will be processed by the command. When the command is run in a subdirectory within a repository's working tree, we expect that the user will provide path patterns which are expressed relative to their current working directory. However, as noted in commit 760c7d7 of PR git-lfs#527, we need to convert these path patterns so they are relative to the root of the repository before we can use them to filter the file list returned by Git. (This list is either generated by a "git ls-files" command, if the installed version of Git is 2.42.0 or higher, or by a "git ls-tree" command.) In PR git-lfs#1771 we refactored the logic we use to perform these relative path conversions and created the rootedPath() helper function in our "commands" package. This function iterated over the set of command-line "pathspec" arguments and converted each using the Convert() method of the currentToRepoPathConverter structure in our "lfs" package. Later, in commit 56abb71 of PR git-lfs#4556, we revised the rootedPaths() function to instead call the Convert() method of the new currentToRepoPatternConverter structure in our "lfs" package. This structure's conversion method first calls the Convert() method of a currentToRepoPathConverter structure, but then checks whether the result references a directory or not, and if so, adjusts the returned value to conform to Git's pattern-matching rules, as defined in the gitignore(5) manual page. While this treatment of path pattern arguments is correct for most use cases, it is not valid when the "git lfs checkout" command's --to option is specified by the user, but the rootedPaths() function is nevertheless used to process the command's final file path argument in this case. In a subsequent commit in this PR we will address this problem, but first we rename the rootedPaths() function to rootedPathPatterns() and make the same change to the equivalent names of the local variables, so as to clearly identify the purpose of the function and its expected input and output.
The --to option of the "git lfs checkout" command requires a file path argument which specifies where the command should write the contents of the object associated with a Git LFS pointer file. The command also requires another file path argument which identifies the pointer within the repository whose object contents should be retrieved, along with a second option that specifies which stage of a merge conflict should be referenced when reading the pointer's data. This second option must be one of --theirs, --ours, or --base. The final file path argument, which indicates the pointer file within the repository whose object contents are to be retrieved, may be provided by the user as a relative path from their current working directory. This path is then converted into a path relative to the root of the repository by the rootedPathPatterns() function, which calls the Convert() method of the currentToRepoPatternConverter structure in our "lfs" package. However, because only a single file path is expected and not a file path pattern, the Convert() method of the currentToRepoPathConverter structure should be used instead. The Convert() method of the currentToRepoPatternConverter structure is intended to process file path patterns and to make sure they adhere to Git's pattern-matching rules, as defined in the gitignore(5) manual page. Paths which resolve to directories have "/" appended by the currentToRepoPatternConverter structure's Convert() method so that they will match the directory and all of its contents. Any leading "./" path component is then removed, and if this results in an empty pattern, then "**" is returned so that the current directory and all of its contents will be matched by the pattern. As noted above, though, when the --to option is used along with one of the --theirs, --ours, or --base options, neither of the two required file path arguments should be a file path pattern, and in particular the second file path argument should uniquely identify the Git LFS file that is the source of a merge conflict. Since this second file path may be specified as a path relative to the current working directory, the "git lfs checkout" command needs to convert it to be relative to the root of the repository, but without treating it as a file path pattern. We therefore revise the command so that when the --to option is given, the file path argument which identifies a Git LFS file in the repository is processed using the Convert() method of the currentToRepoPathConverter structure rather than the Convert() method of the currentToRepoPatternConverter structure. We then add a check to the "checkout: conflicts" test in our t/t-checkout.sh test script which verifies that our changes have the intended effect. To do this, we run the "git lfs checkout" command with the --to option, and for the file path parameter of the --ours option we provide a simple "." path. Previously, this would have been converted into "**", but now we should see just the original "." character in the error message returned from the "git rev-parse" command. (The "git lfs checkout" command uses this Git command to determine the SHA of the merge stage containing the file path by prepending the stage's numeric identifier to the path. This presumes, of course, that the user has encountered a merge conflict on a Git LFS file and is now using the "git lfs checkout" command's --to option to help resolve the conflict.)
chrisd8088
force-pushed
the
fix-checkout-path-handling
branch
from
November 2, 2025 22:33
eb2e04c to
1c5f883
Compare
larsxschneider
approved these changes
Nov 10, 2025
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
In PR #3296 we enhanced the
git lfs checkoutcommand with a set of options which may be used to examine the state of Git LFS files after a merge conflict. This PR corrects two minor defects in our handling of the file paths which users must provide when they use thegit lfs checkoutcommand for this purpose.This PR will be most easily reviewed on a commit-by-commit basis with whitespace differences ignored.
Background
When users wish to examine the state of conflicting Git LFS files in the midst of a merge, they are expected to run the
git lfs checkoutcommand with the--tooption and exactly one of the--theirs,--ours, or--baseoptions. The--tooption requires a file path parameter, and so do each of the other three options.The
--tooption's file path parameter specifies the location into which the command will write its output, which will be the content of the Git LFS file identified by the other file path argument and by the merge stage indicated through the choice of one of the--theirs,--ours, or--baseoptions. The--tooption's file path parameter may refer to any location in the filesystem, while the other file path must identify a single Git LFS file in the repository. For example, after a merge conflict on a Git LFS file, a user might invoke the following commands:Pointer Extension Log Paths
At present, we inappropriately pass the file path argument of the
--tooption (after converting it to an absolute path) to any configured Git LFS pointer extension programs as if it were the path to the Git LFS file in the repository. Pointer extension programs are expected not to use this path for any purpose other than logging, so this is not a serious problem, but it is nevertheless incorrect.To resolve this issue, we make sure to pass the path of the Git LFS file identified by the second file path argument, after it has been converted into a path relative to the root of the repository, as the
workingfileparameter of theSmudge()method of ourGitFilterstructure type, whereas we previously passed the--tooption's file path argument in theworkingfileparameter.The value of the
workingfileparameter is used in various error messages and will also be substituted in place of the%fspecifier in the command lines configured for Git LFS pointer extension programs, in the same manner as Git itself passes file paths in place of the%fspecifier when it invokes filter programs such as Git LFS.Merge Conflict Paths, Not Patterns
At present, we treat the single file path argument which follows the
--theirs,--ours, or--baseoptions as a file path pattern and so may perform unnecessary conversions as a result.When used without any of these options (and without the
--tooption), thegit lfs checkoutcommand normally accepts zero or more file path patterns as optional arguments. The command takes steps to ensure that if any of these arguments are simply paths to directories without trailing/or/**pattern-matching components, per thegitignore(5)format, the command still functions as the user expects and checks out all the Git LFS files in the given directories and their subdirectories.However, when one of the
--theirs,--ours, or--baseoptions is provided, the required argument which follows must be a Git LFS file's path and not a file path pattern. We therefore revise thegit lfs checkoutcommand so that when we convert this single file path argument from a path relative to the current working directory into a path relative to the root of the repository, we use theConvert()method of ourcurrentToRepoPathConverterstructure type and not theConvert()method of ourcurrentToRepoPatternConverterstructure type, since the latter method applies the additional path pattern conversions that we want to avoid.Other Revisions
In addition to the two bug fixes described above, we also rename the
rootedPaths()helper function used by thegit lfs checkoutcommand torootedPathPatterns()so that it more clearly expresses in the function's intent.As well, we adjust one section of our
checkout: conflictsshell test so that we skip trying to validate thegit lfs checkoutcommand's handling of symbolic links in the file path argument of the--tooption unless the operating system and environment fully support symbolic links, which may not always be the case on Windows.