Skip to content

Handle git lfs checkout merge conflict option file paths correctly - #6136

Merged
chrisd8088 merged 4 commits into
git-lfs:mainfrom
chrisd8088:fix-checkout-path-handling
Nov 11, 2025
Merged

chrisd8088 merged 4 commits into
git-lfs:mainfrom
chrisd8088:fix-checkout-path-handling

Conversation

@chrisd8088

@chrisd8088 chrisd8088 commented Nov 2, 2025 •

Copy link
Copy Markdown
Member

In PR #3296 we enhanced the git lfs checkout command 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 the git lfs checkout command 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 checkout command with the --to option and exactly one of the --theirs, --ours, or --base options. The --to option requires a file path parameter, and so do each of the other three options.

The --to option'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 --base options. The --to option'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:

$ git lfs checkout --to /tmp/theirs.txt --theirs path/to/lfs/file.bin
$ git lfs checkout --to /tmp/ours.txt --ours path/to/lfs/file.bin

Pointer Extension Log Paths

At present, we inappropriately pass the file path argument of the --to option (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 workingfile parameter of the Smudge() method of our GitFilter structure type, whereas we previously passed the --to option's file path argument in the workingfile parameter.

The value of the workingfile parameter is used in various error messages and will also be substituted in place of the %f specifier 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 %f specifier 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 --base options as a file path pattern and so may perform unnecessary conversions as a result.

When used without any of these options (and without the --to option), the git lfs checkout command 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 the gitignore(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 --base options is provided, the required argument which follows must be a Git LFS file's path and not a file path pattern. We therefore revise the git lfs checkout command 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 the Convert() method of our currentToRepoPathConverter structure type and not the Convert() method of our currentToRepoPatternConverter structure 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 the git lfs checkout command to rootedPathPatterns() so that it more clearly expresses in the function's intent.

As well, we adjust one section of our checkout: conflicts shell test so that we skip trying to validate the git lfs checkout command's handling of symbolic links in the file path argument of the --to option unless the operating system and environment fully support symbolic links, which may not always be the case on Windows.

@chrisd8088
chrisd8088 requested a review from a team as a code owner November 2, 2025 07:34
@chrisd8088 chrisd8088 added the bug label Nov 2, 2025
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
chrisd8088 force-pushed the fix-checkout-path-handling branch from eb2e04c to 1c5f883 Compare November 2, 2025 22:33
@chrisd8088
chrisd8088 merged commit 63ed46e into git-lfs:main Nov 11, 2025
10 checks passed
@chrisd8088
chrisd8088 deleted the fix-checkout-path-handling branch November 11, 2025 04:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Sponsor
SponsoredKunjungi sekarang
Promo