CVE-2025-26625
Description
Git LFS is a Git extension for versioning large files. In Git LFS versions 0.5.2 through 3.7.0, when populating a Git repository's working tree with the contents of Git LFS objects, certain Git LFS commands may write to files visible outside the current Git working tree if symbolic or hard links exist which collide with the paths of files tracked by Git LFS. The git lfs checkout and git lfs pull commands do not check for symbolic links before writing to files in the working tree, allowing an attacker to craft a repository containing symbolic or hard links that cause Git LFS to write to arbitrary file system locations accessible to the user running these commands. As well, when the git lfs checkout and git lfs pull commands are run in a bare repository, they could write to files visible outside the repository. The vulnerability is fixed in version 3.7.1. As a workaround, support for symlinks in Git may be disabled by setting the core.symlinks configuration option to false, after which further clones and fetches will not create symbolic links. However, any symbolic or hard links in existing repositories will still provide the opportunity for Git LFS to write to their targets.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/git-lfs/git-lfsGo | >= 0.5.2, < 3.7.1 | 3.7.1 |
Affected products
1Patches
4b84b33847fe60cffe93176b8check for dir/symlink conflicts on checkout/pull
8 files changed · +829 −88
commands/command_checkout.go+6 −0 modified@@ -10,6 +10,7 @@ import ( "github.com/git-lfs/git-lfs/v3/git" "github.com/git-lfs/git-lfs/v3/lfs" "github.com/git-lfs/git-lfs/v3/tasklog" + "github.com/git-lfs/git-lfs/v3/tools" "github.com/git-lfs/git-lfs/v3/tq" "github.com/git-lfs/git-lfs/v3/tr" "github.com/spf13/cobra" @@ -110,6 +111,11 @@ func checkoutConflict(file string, stage git.IndexStage) { Exit(tr.Tr.Get("Could not convert %q to absolute path: %v", checkoutTo, err)) } + err = tools.MkdirAll(filepath.Dir(checkoutTo), cfg) + if err != nil { + Exit(tr.Tr.Get("Could not create path %q: %v", checkoutTo, err)) + } + // will chdir to root of working tree, if one exists singleCheckout := newSingleCheckout(cfg.Git, "") if singleCheckout.Skip() {
commands/pull.go+22 −2 modified@@ -12,6 +12,7 @@ import ( "github.com/git-lfs/git-lfs/v3/git" "github.com/git-lfs/git-lfs/v3/lfs" "github.com/git-lfs/git-lfs/v3/subprocess" + "github.com/git-lfs/git-lfs/v3/tools" "github.com/git-lfs/git-lfs/v3/tq" "github.com/git-lfs/git-lfs/v3/tr" ) @@ -75,8 +76,20 @@ func (c *singleCheckout) Run(p *lfs.WrappedPointer) { return } - // Check the content - either missing or still this pointer (not exist is ok) - filepointer, err := lfs.DecodePointerFromFile(p.Name) + dirWalker := tools.NewDirWalkerForFile("", p.Name, cfg) + err := dirWalker.Walk() + + var filepointer *lfs.Pointer + if err != nil { + if !os.IsNotExist(err) { + LoggedError(err, tr.Tr.Get("Checkout error trying to check path for %q: %s", p.Name, err)) + return + } + } else { + // Check the content - either missing or still this pointer (not exist is ok) + filepointer, err = lfs.DecodePointerFromFile(p.Name) + } + if err != nil { if os.IsNotExist(err) { output, err := git.DiffIndexWithPaths("HEAD", true, []string{p.Name}) @@ -106,6 +119,13 @@ func (c *singleCheckout) Run(p *lfs.WrappedPointer) { return } + if err != nil && os.IsNotExist(err) { + if err := dirWalker.WalkAndCreate(); err != nil { + LoggedError(err, tr.Tr.Get("Checkout error trying to create path for %q: %s", p.Name, err)) + return + } + } + if err := c.RunToPath(p, p.Name); err != nil { if errors.IsDownloadDeclinedError(err) { // acceptable error, data not local (fetch not run or include/exclude)
lfs/gitfilter_smudge.go+0 −2 modified@@ -16,8 +16,6 @@ import ( ) func (f *GitFilter) SmudgeToFile(filename string, ptr *Pointer, download bool, manifest tq.Manifest, cb tools.CopyCallback) error { - tools.MkdirAll(filepath.Dir(filename), f.cfg) - // When no pointer file exists on disk, we should use the permissions // defined for the file in Git, since the executable mode may be set. // However, to conform with our legacy behaviour, we do not do this
tools/dir_walker.go+107 −0 added@@ -0,0 +1,107 @@ +package tools + +import ( + "os" + "strings" + + "github.com/git-lfs/git-lfs/v3/errors" + "github.com/git-lfs/git-lfs/v3/tr" +) + +var ( + errInvalidDir = errors.New(tr.Tr.Get("invalid directory")) + errNotDir = errors.New(tr.Tr.Get("not a directory")) +) + +type DirWalker struct { + parentPath string + path string + config repositoryPermissionFetcher +} + +// The parentPath parameter is assumed to be a valid path to a directory +// in the filesystem. +// +// The filePath parameter must be a relative file path as provided by Git, +// with only the "/" character as a separator and no empty or "." or ".." +// path segments. Absolute paths are not supported. +func NewDirWalkerForFile(parentPath string, filePath string, config repositoryPermissionFetcher) *DirWalker { + var path string + i := strings.LastIndexByte(filePath, '/') + if i >= 0 { + path = filePath[0:i] + } + + return &DirWalker{ + parentPath: parentPath, + path: path, + config: config, + } +} + +// walk() checks each directory in a relative path, starting from the +// initial parent path, and optionally creates any missing directories +// in the path. +// +// If an existing file or something else other than a directory conflicts +// with a directory in the path, walk() returns an error. +// +// If the create option is false, walk() returns ErrNotExist when a +// directory is not found. +// +// Note that for performance reasons and to be consistent with Git's +// implementation, walk() does not guard against TOCTOU (time-of-check/ +// time-of-use) races, as the methods of the os.Root type do. +func (w *DirWalker) walk(create bool) error { + currentPath := w.parentPath + + n := len(w.path) + for n > 0 { + currentDir := w.path + nextDirIndex := n + i := strings.IndexByte(w.path, '/') + if i >= 0 { + currentDir = w.path[0:i] + nextDirIndex = i + 1 + } + + // These should never occur in Git paths. + if currentDir == "" || currentDir == "." || currentDir == ".." { + return errors.Join(errors.New(tr.Tr.Get("invalid directory %q in path: %q", currentDir, w.path)), errInvalidDir) + } + + if currentPath == "" { + currentPath = currentDir + } else { + currentPath += "/" + currentDir + } + + stat, err := os.Lstat(currentPath) + if err != nil { + if !os.IsNotExist(err) || !create { + return err + } + + err = Mkdir(currentPath, w.config) + if err != nil { + return err + } + } else if !stat.Mode().IsDir() { + return errors.Join(errors.New(tr.Tr.Get("not a directory: %q", currentPath)), errNotDir) + } + + w.parentPath = currentPath + w.path = w.path[nextDirIndex:] + n -= nextDirIndex + } + + return nil +} + +func (w *DirWalker) Walk() error { + return w.walk(false) +} + +func (w *DirWalker) WalkAndCreate() error { + return w.walk(true) +}
tools/dir_walker_test.go+473 −0 added@@ -0,0 +1,473 @@ +package tools + +import ( + "errors" + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type newDirWalkerForFileTestCase struct { + filePath string + expectedDirPath string +} + +func (c *newDirWalkerForFileTestCase) Assert(t *testing.T) { + w := NewDirWalkerForFile("", c.filePath, nil) + assert.Equal(t, c.expectedDirPath, w.path) +} + +func TestNewDirWalkerForFile(t *testing.T) { + for desc, c := range map[string]*newDirWalkerForFileTestCase{ + "filename only": {"foo.bin", ""}, + "path with one dir": {"abc/foo.bin", "abc"}, + "path with two dirs": {"abc/def/foo.bin", "abc/def"}, + "path with leading slash": {"/foo.bin", ""}, + "path with trailing slash": {"abc/", "abc"}, + "bare slash": {"/", ""}, + "empty path": {"", ""}, + } { + t.Run(desc, c.Assert) + } +} + +type dirWalkerTestConfig struct{} + +func (c *dirWalkerTestConfig) RepositoryPermissions(executable bool) os.FileMode { + return os.FileMode(0755) +} + +type dirWalkerWalkTestCase struct { + parentPath string + path string + create bool + + existsPath string + existsFile string + existsLink string + + expectedParentPath string + expectedPath string + expectedErr error + + walker *DirWalker +} + +func (c *dirWalkerWalkTestCase) prependParentPath(path string) string { + if path == "" { + return c.parentPath + } else if c.parentPath == "" { + return path + } else if path[0] == '/' { + return "/" + c.parentPath + path + } else { + return c.parentPath + "/" + path + } +} + +func (c *dirWalkerWalkTestCase) setupPaths(t *testing.T, parentPath string) error { + c.parentPath = parentPath + + if parentPath != "" { + if err := os.MkdirAll(parentPath, 0755); err != nil { + return fmt.Errorf("unable to create path: %w", err) + } + } + + if c.existsPath != "" { + c.existsPath = c.prependParentPath(c.existsPath) + if err := os.MkdirAll(c.existsPath, 0755); err != nil { + return fmt.Errorf("unable to create path: %w", err) + } + } + + if c.existsFile != "" { + c.existsFile = c.prependParentPath(c.existsFile) + f, err := os.Create(c.existsFile) + if err != nil { + return fmt.Errorf("unable to create file: %w", err) + } + f.Close() + } + + if c.existsLink != "" { + c.existsLink = c.prependParentPath(c.existsLink) + if err := os.Symlink(t.TempDir(), c.existsLink); err != nil { + return fmt.Errorf("unable to create symbolic link: %w", err) + } + } + + c.expectedParentPath = c.prependParentPath(c.expectedParentPath) + + return nil +} + +func (c *dirWalkerWalkTestCase) Assert(t *testing.T) { + c.walker.parentPath = c.parentPath + c.walker.path = c.path + + err := c.walker.walk(c.create) + + assert.Equal(t, c.expectedParentPath, c.walker.parentPath, "found path does not match") + assert.Equal(t, c.expectedPath, c.walker.path, "missing path does not match") + if c.expectedErr == nil { + assert.NoError(t, err) + } else { + assert.Error(t, err) + assert.True(t, errors.Is(err, c.expectedErr), "wrong error type") + } +} + +func TestDirWalkerWalk(t *testing.T) { + wd, err := os.Getwd() + require.NoError(t, err) + + defer os.Chdir(wd) + + for desc, c := range map[string]*dirWalkerWalkTestCase{ + "empty path": {}, + "one extant dir": { + path: "abc", + existsPath: "abc", + expectedParentPath: "abc", + }, + "one missing dir": { + path: "abc", + expectedPath: "abc", + expectedErr: os.ErrNotExist, + }, + "two extant dirs": { + path: "abc/def", + existsPath: "abc/def", + expectedParentPath: "abc/def", + }, + "two missing dirs": { + path: "abc/def", + expectedPath: "abc/def", + expectedErr: os.ErrNotExist, + }, + "three extant dirs": { + path: "abc/def/ghi", + existsPath: "abc/def/ghi", + expectedParentPath: "abc/def/ghi", + }, + "three missing dirs": { + path: "abc/def/ghi", + expectedPath: "abc/def/ghi", + expectedErr: os.ErrNotExist, + }, + "one extant dir and one missing dir": { + path: "abc/def", + existsPath: "abc", + expectedParentPath: "abc", + expectedPath: "def", + expectedErr: os.ErrNotExist, + }, + "one extant dir and two missing dirs": { + path: "abc/def/ghi", + existsPath: "abc", + expectedParentPath: "abc", + expectedPath: "def/ghi", + expectedErr: os.ErrNotExist, + }, + "two extant dirs and one missing dir": { + path: "abc/def/ghi", + existsPath: "abc/def", + expectedParentPath: "abc/def", + expectedPath: "ghi", + expectedErr: os.ErrNotExist, + }, + "one missing dir with trailing slash": { + path: "abc/", + expectedPath: "abc/", + expectedErr: os.ErrNotExist, + }, + "one extant dir with trailing slash": { + path: "abc/", + existsPath: "abc", + expectedParentPath: "abc", + }, + "two extant dirs with trailing slash": { + path: "abc/def/", + existsPath: "abc/def", + expectedParentPath: "abc/def", + }, + "one extant dir and one missing dir with trailing slash": { + path: "abc/def/", + existsPath: "abc", + expectedParentPath: "abc", + expectedPath: "def/", + expectedErr: os.ErrNotExist, + }, + "one conflicting file": { + path: "abc", + existsFile: "abc", + expectedPath: "abc", + expectedErr: errNotDir, + }, + "one extant dir and one conflicting file": { + path: "abc/def", + existsPath: "abc", + existsFile: "abc/def", + expectedParentPath: "abc", + expectedPath: "def", + expectedErr: errNotDir, + }, + "two extant dirs and one conflicting file": { + path: "abc/def/ghi", + existsPath: "abc/def", + existsFile: "abc/def/ghi", + expectedParentPath: "abc/def", + expectedPath: "ghi", + expectedErr: errNotDir, + }, + "one extant dir, one conflicting file, and one missing dir": { + path: "abc/def/ghi", + existsPath: "abc", + existsFile: "abc/def", + expectedParentPath: "abc", + expectedPath: "def/ghi", + expectedErr: errNotDir, + }, + "one conflicting symlink": { + path: "abc", + existsLink: "abc", + expectedPath: "abc", + expectedErr: errNotDir, + }, + "one extant dir and one conflicting symlink": { + path: "abc/def", + existsPath: "abc", + existsLink: "abc/def", + expectedParentPath: "abc", + expectedPath: "def", + expectedErr: errNotDir, + }, + "two extant dirs and one conflicting symlink": { + path: "abc/def/ghi", + existsPath: "abc/def", + existsLink: "abc/def/ghi", + expectedParentPath: "abc/def", + expectedPath: "ghi", + expectedErr: errNotDir, + }, + "one extant dir, one conflicting symlink, and one missing dir": { + path: "abc/def/ghi", + existsPath: "abc", + existsLink: "abc/def", + expectedParentPath: "abc", + expectedPath: "def/ghi", + expectedErr: errNotDir, + }, + "one extant dir (not modified)": { + path: "abc", + create: true, + existsPath: "abc", + expectedParentPath: "abc", + }, + "one created dir": { + path: "abc", + create: true, + expectedParentPath: "abc", + }, + "two extant dirs (not modified)": { + path: "abc/def", + create: true, + existsPath: "abc/def", + expectedParentPath: "abc/def", + }, + "two created dirs": { + path: "abc/def", + create: true, + expectedParentPath: "abc/def", + }, + "three extant dirs (not modified)": { + path: "abc/def/ghi", + create: true, + existsPath: "abc/def/ghi", + expectedParentPath: "abc/def/ghi", + }, + "three created dirs": { + path: "abc/def/ghi", + create: true, + expectedParentPath: "abc/def/ghi", + }, + "one extant dir and one created dir": { + path: "abc/def", + create: true, + existsPath: "abc", + expectedParentPath: "abc/def", + }, + "one extant dir and two created dirs": { + path: "abc/def/ghi", + create: true, + existsPath: "abc", + expectedParentPath: "abc/def/ghi", + }, + "two extant dirs and one created dir": { + path: "abc/def/ghi", + create: true, + existsPath: "abc/def", + expectedParentPath: "abc/def/ghi", + }, + "one created dir with trailing slash": { + path: "abc/", + create: true, + expectedParentPath: "abc", + }, + "one extant dir with trailing slash (not modified)": { + path: "abc/", + create: true, + existsPath: "abc", + expectedParentPath: "abc", + }, + "two extant dirs with trailing slash (not modified)": { + path: "abc/def/", + create: true, + existsPath: "abc/def", + expectedParentPath: "abc/def", + }, + "one extant dir and one created dir with trailing slash": { + path: "abc/def/", + create: true, + existsPath: "abc", + expectedParentPath: "abc/def", + }, + "one conflicting file (not modified)": { + path: "abc", + create: true, + existsFile: "abc", + expectedPath: "abc", + expectedErr: errNotDir, + }, + "one extant dir and one conflicting file (not modified)": { + path: "abc/def", + create: true, + existsPath: "abc", + existsFile: "abc/def", + expectedParentPath: "abc", + expectedPath: "def", + expectedErr: errNotDir, + }, + "two extant dirs and one conflicting file (not modified)": { + path: "abc/def/ghi", + create: true, + existsPath: "abc/def", + existsFile: "abc/def/ghi", + expectedParentPath: "abc/def", + expectedPath: "ghi", + expectedErr: errNotDir, + }, + "one extant dir, one conflicting file, and one missing dir (not modified)": { + path: "abc/def/ghi", + create: true, + existsPath: "abc", + existsFile: "abc/def", + expectedParentPath: "abc", + expectedPath: "def/ghi", + expectedErr: errNotDir, + }, + "one conflicting symlink (not modified)": { + path: "abc", + create: true, + existsLink: "abc", + expectedPath: "abc", + expectedErr: errNotDir, + }, + "one extant dir and one conflicting symlink (not modified)": { + path: "abc/def", + create: true, + existsPath: "abc", + existsLink: "abc/def", + expectedParentPath: "abc", + expectedPath: "def", + expectedErr: errNotDir, + }, + "two extant dirs and one conflicting symlink (not modified)": { + path: "abc/def/ghi", + create: true, + existsPath: "abc/def", + existsLink: "abc/def/ghi", + expectedParentPath: "abc/def", + expectedPath: "ghi", + expectedErr: errNotDir, + }, + "one extant dir, one conflicting symlink, and one missing dir (not modified)": { + path: "abc/def/ghi", + create: true, + existsPath: "abc", + existsLink: "abc/def", + expectedParentPath: "abc", + expectedPath: "def/ghi", + expectedErr: errNotDir, + }, + "invalid bare slash": { + path: "/", + expectedPath: "/", + expectedErr: errInvalidDir, + }, + "invalid multiple slashes": { + path: "abc//def", + existsPath: "abc", + expectedParentPath: "abc", + expectedPath: "/def", + expectedErr: errInvalidDir, + }, + "invalid leading slash": { + path: "/abc", + existsPath: "abc", + expectedPath: "/abc", + expectedErr: errInvalidDir, + }, + "invalid bare dot component": { + path: ".", + expectedPath: ".", + expectedErr: errInvalidDir, + }, + "invalid dot component": { + path: "abc/./def", + existsPath: "abc/def", + expectedParentPath: "abc", + expectedPath: "./def", + expectedErr: errInvalidDir, + }, + "invalid bare double-dot component": { + path: "..", + expectedPath: "..", + expectedErr: errInvalidDir, + }, + "invalid double-dot component": { + path: "abc/../def", + existsPath: "abc", + expectedParentPath: "abc", + expectedPath: "../def", + expectedErr: errInvalidDir, + }, + } { + if err := os.Chdir(t.TempDir()); err != nil { + t.Errorf("unable to change directory: %s", err) + } + + c.walker = &DirWalker{ + config: &dirWalkerTestConfig{}, + } + + if err := c.setupPaths(t, ""); err != nil { + t.Error(err) + continue + } + + t.Run(desc, c.Assert) + + // retest with parent path; note that this alters the test case + if err := c.setupPaths(t, "foo/bar"); err != nil { + t.Error(err) + continue + } + + t.Run(desc+" with parent path", c.Assert) + } +}
tools/filetools.go+9 −0 modified@@ -121,6 +121,15 @@ type repositoryPermissionFetcher interface { RepositoryPermissions(executable bool) os.FileMode } +// Mkdir makes a directory with the +// permissions specified by the core.sharedRepository setting. +func Mkdir(path string, config repositoryPermissionFetcher) error { + umask := 0777 & ^config.RepositoryPermissions(true) + return doWithUmask(int(umask), func() error { + return os.Mkdir(path, config.RepositoryPermissions(true)) + }) +} + // MkdirAll makes a directory and any intervening directories with the // permissions specified by the core.sharedRepository setting. func MkdirAll(path string, config repositoryPermissionFetcher) error {
t/t-checkout.sh+101 −43 modified@@ -191,13 +191,8 @@ begin_test "checkout: skip directory file conflicts" echo >&2 "fatal: expected checkout to succeed ..." exit 1 fi - if [ "$IS_WINDOWS" -eq 1 ]; then - grep 'could not check out "dir1/a\.dat": could not create working directory file' checkout.log - grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' checkout.log - else - grep 'Checkout error for "dir1/a\.dat": lstat' checkout.log - grep 'Checkout error for "dir2/dir3/dir4/a\.dat": lstat' checkout.log - fi + grep '"dir1/a\.dat": not a directory' checkout.log + grep '"dir2/dir3/dir4/a\.dat": not a directory' checkout.log [ -f "dir1" ] [ -f "dir2/dir3" ] @@ -209,13 +204,8 @@ begin_test "checkout: skip directory file conflicts" echo >&2 "fatal: expected checkout to succeed ..." exit 1 fi - if [ "$IS_WINDOWS" -eq 1 ]; then - grep 'could not check out "dir1/a\.dat": could not create working directory file' checkout.log - grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' checkout.log - else - grep 'Checkout error for "dir1/a\.dat": lstat' checkout.log - grep 'Checkout error for "dir2/dir3/dir4/a\.dat": lstat' checkout.log - fi + grep '"dir1/a\.dat": not a directory' checkout.log + grep '"dir2/dir3/dir4/a\.dat": not a directory' checkout.log popd [ -f "dir1" ] @@ -224,8 +214,6 @@ begin_test "checkout: skip directory file conflicts" ) end_test -# Note that the conditions validated by this test are at present limited, -# but will be expanded in the future. begin_test "checkout: skip directory symlink conflicts" ( set -e @@ -247,6 +235,64 @@ begin_test "checkout: skip directory symlink conflicts" git add .gitattributes dir1 dir2 git commit -m "initial commit" + # test with symlinks to directories + rm -rf dir1 dir2/dir3 ../link* + mkdir ../link1 ../link2 + ln -s ../link1 dir1 + ln -s ../../link2 dir2/dir3 + + git lfs checkout 2>&1 | tee checkout.log + if [ "0" -ne "${PIPESTATUS[0]}" ]; then + echo >&2 "fatal: expected checkout to succeed ..." + exit 1 + fi + grep '"dir1/a\.dat": not a directory' checkout.log + grep '"dir2/dir3/dir4/a\.dat": not a directory' checkout.log + [ -z "$(grep "is beyond a symbolic link" checkout.log)" ] + + [ -L "dir1" ] + [ -L "dir2/dir3" ] + [ ! -e "../link1/a.dat" ] + [ ! -e "../link2/dir4" ] + assert_clean_index + + rm -rf dir1 dir2/dir3 + mkdir link1 link2 + ln -s link1 dir1 + ln -s ../link2 dir2/dir3 + + git lfs checkout 2>&1 | tee checkout.log + if [ "0" -ne "${PIPESTATUS[0]}" ]; then + echo >&2 "fatal: expected checkout to succeed ..." + exit 1 + fi + grep '"dir1/a\.dat": not a directory' checkout.log + grep '"dir2/dir3/dir4/a\.dat": not a directory' checkout.log + [ -z "$(grep "is beyond a symbolic link" checkout.log)" ] + + [ -L "dir1" ] + [ -L "dir2/dir3" ] + [ ! -e "link1/a.dat" ] + [ ! -e "link2/dir4" ] + assert_clean_index + + pushd dir2 + git lfs checkout 2>&1 | tee checkout.log + if [ "0" -ne "${PIPESTATUS[0]}" ]; then + echo >&2 "fatal: expected checkout to succeed ..." + exit 1 + fi + grep '"dir1/a\.dat": not a directory' checkout.log + grep '"dir2/dir3/dir4/a\.dat": not a directory' checkout.log + [ -z "$(grep "is beyond a symbolic link" checkout.log)" ] + popd + + [ -L "dir1" ] + [ -L "dir2/dir3" ] + [ ! -e "link1/a.dat" ] + [ ! -e "link2/dir4" ] + assert_clean_index + # test with symlink to file and dangling symlink rm -rf dir1 dir2/dir3 ../link* touch ../link1 @@ -258,20 +304,16 @@ begin_test "checkout: skip directory symlink conflicts" echo >&2 "fatal: expected checkout to succeed ..." exit 1 fi - if [ "$IS_WINDOWS" -eq 1 ]; then - grep 'could not check out "dir1/a\.dat": could not create working directory file' checkout.log - else - grep 'Checkout error for "dir1/a\.dat": lstat' checkout.log - fi - grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' checkout.log + grep '"dir1/a\.dat": not a directory' checkout.log + grep '"dir2/dir3/dir4/a\.dat": not a directory' checkout.log [ -L "dir1" ] [ -L "dir2/dir3" ] [ -f "../link1" ] [ ! -e "../link2" ] assert_clean_index - rm -rf dir1 dir2/dir3 + rm -rf dir1 dir2/dir3 link* touch link1 ln -s link1 dir1 ln -s ../link2 dir2/dir3 @@ -281,12 +323,8 @@ begin_test "checkout: skip directory symlink conflicts" echo >&2 "fatal: expected checkout to succeed ..." exit 1 fi - if [ "$IS_WINDOWS" -eq 1 ]; then - grep 'could not check out "dir1/a\.dat": could not create working directory file' checkout.log - else - grep 'Checkout error for "dir1/a\.dat": lstat' checkout.log - fi - grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' checkout.log + grep '"dir1/a\.dat": not a directory' checkout.log + grep '"dir2/dir3/dir4/a\.dat": not a directory' checkout.log [ -L "dir1" ] [ -L "dir2/dir3" ] @@ -300,12 +338,8 @@ begin_test "checkout: skip directory symlink conflicts" echo >&2 "fatal: expected checkout to succeed ..." exit 1 fi - if [ "$IS_WINDOWS" -eq 1 ]; then - grep 'could not check out "dir1/a\.dat": could not create working directory file' checkout.log - else - grep 'Checkout error for "dir1/a\.dat": lstat' checkout.log - fi - grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' checkout.log + grep '"dir1/a\.dat": not a directory' checkout.log + grep '"dir2/dir3/dir4/a\.dat": not a directory' checkout.log popd [ -L "dir1" ] @@ -480,20 +514,26 @@ begin_test "checkout: skip case-based symlink conflicts" mkdir dir1 ln -s ../link1 A.dat ln -s ../../link2 dir1/a.dat + ln -s ../link3 DIR3 + ln -s ../../link4 dir1/dir2 - git add A.dat dir1 + git add A.dat dir1 DIR3 git commit -m "initial commit" - rm A.dat dir1/a.dat + rm A.dat dir1/* DIR3 echo "*.dat filter=lfs diff=lfs merge=lfs -text" >.gitattributes contents="a" contents_oid="$(calc_oid "$contents")" + mkdir dir3 dir1/DIR2 printf "%s" "$contents" >a.dat printf "%s" "$contents" >dir1/A.dat + printf "%s" "$contents" >dir3/a.dat + printf "%s" "$contents" >dir1/DIR2/a.dat - git -c core.ignoreCase=false add .gitattributes a.dat dir1/A.dat + git -c core.ignoreCase=false add .gitattributes a.dat dir1/A.dat \ + dir3/a.dat dir1/DIR2/a.dat git commit -m "case-conflicting commit" git push origin main @@ -512,25 +552,32 @@ begin_test "checkout: skip case-based symlink conflicts" assert_local_object "$contents_oid" 1 - rm -rf *.dat dir1 ../link* + rm -rf *.dat dir1 *3 ../link* + mkdir ../link3 ../link4 git lfs checkout 2>&1 | tee checkout.log if [ "0" -ne "${PIPESTATUS[0]}" ]; then echo >&2 "fatal: expected checkout to succeed ..." exit 1 fi - grep -q 'Checking out LFS objects: 100% (2/2), 2 B' checkout.log + grep -q 'Checking out LFS objects: 100% (4/4), 4 B' checkout.log [ -f "a.dat" ] [ "$contents" = "$(cat "a.dat")" ] [ -f "dir1/A.dat" ] [ "$contents" = "$(cat "dir1/A.dat")" ] + [ -f "dir3/a.dat" ] + [ "$contents" = "$(cat "dir3/a.dat")" ] + [ -f "dir1/DIR2/a.dat" ] + [ "$contents" = "$(cat "dir1/DIR2/a.dat")" ] [ ! -e "../link1" ] [ ! -e "../link2" ] + [ ! -e "../link3/a.dat" ] + [ ! -e "../link4/a.dat" ] assert_clean_index - rm -rf a.dat dir1/A.dat - git checkout -- A.dat dir1/a.dat + rm -rf a.dat dir1/A.dat dir3 dir1/DIR2 + git checkout -- A.dat dir1/a.dat DIR3 dir1/dir2 git lfs checkout 2>&1 | tee checkout.log if [ "0" -ne "${PIPESTATUS[0]}" ]; then @@ -539,11 +586,14 @@ begin_test "checkout: skip case-based symlink conflicts" fi if [ "$collision" -eq "0" ]; then # case-sensitive filesystem - grep -q 'Checking out LFS objects: 100% (2/2), 2 B' checkout.log + grep -q 'Checking out LFS objects: 100% (4/4), 4 B' checkout.log else # case-insensitive filesystem grep '"a\.dat": not a regular file' checkout.log grep '"dir1/A\.dat": not a regular file' checkout.log + grep '"dir3/a\.dat": not a directory' checkout.log + grep '"dir1/DIR2/a\.dat": not a directory' checkout.log + [ -z "$(grep "is beyond a symbolic link" checkout.log)" ] fi if [ "$collision" -eq "0" ]; then @@ -552,13 +602,21 @@ begin_test "checkout: skip case-based symlink conflicts" [ "$contents" = "$(cat "a.dat")" ] [ -f "dir1/A.dat" ] [ "$contents" = "$(cat "dir1/A.dat")" ] + [ -f "dir3/a.dat" ] + [ "$contents" = "$(cat "dir3/a.dat")" ] + [ -f "dir1/DIR2/a.dat" ] + [ "$contents" = "$(cat "dir1/DIR2/a.dat")" ] else # case-insensitive filesystem [ -L "a.dat" ] [ -L "dir1/A.dat" ] + [ -L "dir3" ] + [ -L "dir1/DIR2" ] fi [ ! -e "../link1" ] [ ! -e "../link2" ] + [ ! -e "../link3/a.dat" ] + [ ! -e "../link4/a.dat" ] assert_clean_index ) end_test
t/t-pull.sh+111 −41 modified@@ -265,13 +265,8 @@ begin_test "pull: skip directory file conflicts" echo >&2 "fatal: expected pull to succeed ..." exit 1 fi - if [ "$IS_WINDOWS" -eq 1 ]; then - grep 'could not check out "dir1/a\.dat": could not create working directory file' pull.log - grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' pull.log - else - grep 'Checkout error for "dir1/a\.dat": lstat' pull.log - grep 'Checkout error for "dir2/dir3/dir4/a\.dat": lstat' pull.log - fi + grep '"dir1/a\.dat": not a directory' pull.log + grep '"dir2/dir3/dir4/a\.dat": not a directory' pull.log assert_local_object "$contents_oid" 1 @@ -287,13 +282,8 @@ begin_test "pull: skip directory file conflicts" echo >&2 "fatal: expected pull to succeed ..." exit 1 fi - if [ "$IS_WINDOWS" -eq 1 ]; then - grep 'could not check out "dir1/a\.dat": could not create working directory file' pull.log - grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' pull.log - else - grep 'Checkout error for "dir1/a\.dat": lstat' pull.log - grep 'Checkout error for "dir2/dir3/dir4/a\.dat": lstat' pull.log - fi + grep '"dir1/a\.dat": not a directory' pull.log + grep '"dir2/dir3/dir4/a\.dat": not a directory' pull.log popd assert_local_object "$contents_oid" 1 @@ -304,8 +294,6 @@ begin_test "pull: skip directory file conflicts" ) end_test -# Note that the conditions validated by this test are at present limited, -# but will be expanded in the future. begin_test "pull: skip directory symlink conflicts" ( set -e @@ -336,7 +324,77 @@ begin_test "pull: skip directory symlink conflicts" cd "${reponame}-assert" refute_local_object "$contents_oid" 1 + # test with symlinks to directories + rm -rf dir1 dir2/dir3 ../link* + mkdir ../link1 ../link2 + ln -s ../link1 dir1 + ln -s ../../link2 dir2/dir3 + + git lfs pull 2>&1 | tee pull.log + if [ "0" -ne "${PIPESTATUS[0]}" ]; then + echo >&2 "fatal: expected pull to succeed ..." + exit 1 + fi + grep '"dir1/a\.dat": not a directory' pull.log + grep '"dir2/dir3/dir4/a\.dat": not a directory' pull.log + [ -z "$(grep "is beyond a symbolic link" pull.log)" ] + + assert_local_object "$contents_oid" 1 + + [ -L "dir1" ] + [ -L "dir2/dir3" ] + [ ! -e "../link1/a.dat" ] + [ ! -e "../link2/dir4" ] + assert_clean_index + + rm -rf .git/lfs/objects + + rm -rf dir1 dir2/dir3 + mkdir link1 link2 + ln -s link1 dir1 + ln -s ../link2 dir2/dir3 + + git lfs pull 2>&1 | tee pull.log + if [ "0" -ne "${PIPESTATUS[0]}" ]; then + echo >&2 "fatal: expected pull to succeed ..." + exit 1 + fi + grep '"dir1/a\.dat": not a directory' pull.log + grep '"dir2/dir3/dir4/a\.dat": not a directory' pull.log + [ -z "$(grep "is beyond a symbolic link" pull.log)" ] + + assert_local_object "$contents_oid" 1 + + [ -L "dir1" ] + [ -L "dir2/dir3" ] + [ ! -e "link1/a.dat" ] + [ ! -e "link2/dir4" ] + assert_clean_index + + rm -rf .git/lfs/objects + + pushd dir2 + git lfs pull 2>&1 | tee pull.log + if [ "0" -ne "${PIPESTATUS[0]}" ]; then + echo >&2 "fatal: expected pull to succeed ..." + exit 1 + fi + grep '"dir1/a\.dat": not a directory' pull.log + grep '"dir2/dir3/dir4/a\.dat": not a directory' pull.log + [ -z "$(grep "is beyond a symbolic link" pull.log)" ] + popd + + assert_local_object "$contents_oid" 1 + + [ -L "dir1" ] + [ -L "dir2/dir3" ] + [ ! -e "link1/a.dat" ] + [ ! -e "link2/dir4" ] + assert_clean_index + # test with symlink to file and dangling symlink + rm -rf .git/lfs/objects + rm -rf dir1 dir2/dir3 ../link* touch ../link1 ln -s ../link1 dir1 @@ -347,12 +405,8 @@ begin_test "pull: skip directory symlink conflicts" echo >&2 "fatal: expected pull to succeed ..." exit 1 fi - if [ "$IS_WINDOWS" -eq 1 ]; then - grep 'could not check out "dir1/a\.dat": could not create working directory file' pull.log - else - grep 'Checkout error for "dir1/a\.dat": lstat' pull.log - fi - grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' pull.log + grep '"dir1/a\.dat": not a directory' pull.log + grep '"dir2/dir3/dir4/a\.dat": not a directory' pull.log assert_local_object "$contents_oid" 1 @@ -364,7 +418,7 @@ begin_test "pull: skip directory symlink conflicts" rm -rf .git/lfs/objects - rm -rf dir1 dir2/dir3 + rm -rf dir1 dir2/dir3 link* touch link1 ln -s link1 dir1 ln -s ../link2 dir2/dir3 @@ -374,12 +428,8 @@ begin_test "pull: skip directory symlink conflicts" echo >&2 "fatal: expected pull to succeed ..." exit 1 fi - if [ "$IS_WINDOWS" -eq 1 ]; then - grep 'could not check out "dir1/a\.dat": could not create working directory file' pull.log - else - grep 'Checkout error for "dir1/a\.dat": lstat' pull.log - fi - grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' pull.log + grep '"dir1/a\.dat": not a directory' pull.log + grep '"dir2/dir3/dir4/a\.dat": not a directory' pull.log assert_local_object "$contents_oid" 1 @@ -397,12 +447,8 @@ begin_test "pull: skip directory symlink conflicts" echo >&2 "fatal: expected pull to succeed ..." exit 1 fi - if [ "$IS_WINDOWS" -eq 1 ]; then - grep 'could not check out "dir1/a\.dat": could not create working directory file' pull.log - else - grep 'Checkout error for "dir1/a\.dat": lstat' pull.log - fi - grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' pull.log + grep '"dir1/a\.dat": not a directory' pull.log + grep '"dir2/dir3/dir4/a\.dat": not a directory' pull.log popd assert_local_object "$contents_oid" 1 @@ -610,20 +656,26 @@ begin_test "pull: skip case-based symlink conflicts" mkdir dir1 ln -s ../link1 A.dat ln -s ../../link2 dir1/a.dat + ln -s ../link3 DIR3 + ln -s ../../link4 dir1/dir2 - git add A.dat dir1 + git add A.dat dir1 DIR3 git commit -m "initial commit" - rm A.dat dir1/a.dat + rm A.dat dir1/* DIR3 echo "*.dat filter=lfs diff=lfs merge=lfs -text" >.gitattributes contents="a" contents_oid="$(calc_oid "$contents")" + mkdir dir3 dir1/DIR2 printf "%s" "$contents" >a.dat printf "%s" "$contents" >dir1/A.dat + printf "%s" "$contents" >dir3/a.dat + printf "%s" "$contents" >dir1/DIR2/a.dat - git -c core.ignoreCase=false add .gitattributes a.dat dir1/A.dat + git -c core.ignoreCase=false add .gitattributes a.dat dir1/A.dat \ + dir3/a.dat dir1/DIR2/a.dat git commit -m "case-conflicting commit" git push origin main @@ -640,7 +692,8 @@ begin_test "pull: skip case-based symlink conflicts" cd "${reponame}-assert" refute_local_object "$contents_oid" 1 - rm -rf *.dat dir1 ../link* + rm -rf *.dat dir1 *3 ../link* + mkdir ../link3 ../link4 git lfs pull @@ -650,12 +703,18 @@ begin_test "pull: skip case-based symlink conflicts" [ "$contents" = "$(cat "a.dat")" ] [ -f "dir1/A.dat" ] [ "$contents" = "$(cat "dir1/A.dat")" ] + [ -f "dir3/a.dat" ] + [ "$contents" = "$(cat "dir3/a.dat")" ] + [ -f "dir1/DIR2/a.dat" ] + [ "$contents" = "$(cat "dir1/DIR2/a.dat")" ] [ ! -e "../link1" ] [ ! -e "../link2" ] + [ ! -e "../link3/a.dat" ] + [ ! -e "../link4/a.dat" ] assert_clean_index - rm -rf a.dat dir1/A.dat - git checkout -- A.dat dir1/a.dat + rm -rf a.dat dir1/A.dat dir3 dir1/DIR2 + git checkout -- A.dat dir1/a.dat DIR3 dir1/dir2 git lfs pull 2>&1 | tee pull.log if [ "0" -ne "${PIPESTATUS[0]}" ]; then @@ -666,6 +725,9 @@ begin_test "pull: skip case-based symlink conflicts" # case-insensitive filesystem grep '"a\.dat": not a regular file' pull.log grep '"dir1/A\.dat": not a regular file' pull.log + grep '"dir3/a\.dat": not a directory' pull.log + grep '"dir1/DIR2/a\.dat": not a directory' pull.log + [ -z "$(grep "is beyond a symbolic link" pull.log)" ] fi if [ "$collision" -eq "0" ]; then @@ -674,13 +736,21 @@ begin_test "pull: skip case-based symlink conflicts" [ "$contents" = "$(cat "a.dat")" ] [ -f "dir1/A.dat" ] [ "$contents" = "$(cat "dir1/A.dat")" ] + [ -f "dir3/a.dat" ] + [ "$contents" = "$(cat "dir3/a.dat")" ] + [ -f "dir1/DIR2/a.dat" ] + [ "$contents" = "$(cat "dir1/DIR2/a.dat")" ] else # case-insensitive filesystem [ -L "a.dat" ] [ -L "dir1/A.dat" ] + [ -L "dir3" ] + [ -L "dir1/DIR2" ] fi [ ! -e "../link1" ] [ ! -e "../link2" ] + [ ! -e "../link3/a.dat" ] + [ ! -e "../link4/a.dat" ] assert_clean_index ) end_test
d02bd13f02effix bare repo pull/checkout path handling bug
6 files changed · +176 −0
commands/command_checkout.go+9 −0 modified@@ -24,6 +24,15 @@ var ( func checkoutCommand(cmd *cobra.Command, args []string) { setupRepository() + // TODO: After suitable advance public notice, replace this block + // and the preceding call to setupRepository() with a single call to + // setupWorkingCopy(), which will perform the same check for a bare + // repository but will exit non-zero, as other commands already do. + if cfg.LocalWorkingDir() == "" { + Print(tr.Tr.Get("This operation must be run in a work tree.")) + os.Exit(0) + } + stage, err := whichCheckout() if err != nil { Exit(tr.Tr.Get("Error parsing args: %v", err))
commands/pull.go+6 −0 modified@@ -33,6 +33,7 @@ func newSingleCheckout(gitEnv config.Environment, remote string) abstractCheckou return &singleCheckout{ gitIndexer: &gitIndexer{}, + hasWorkTree: cfg.LocalWorkingDir() != "", pathConverter: pathConverter, manifest: nil, remote: remote, @@ -49,6 +50,7 @@ type abstractCheckout interface { type singleCheckout struct { gitIndexer *gitIndexer + hasWorkTree bool pathConverter lfs.PathConverter manifest tq.Manifest remote string @@ -66,6 +68,10 @@ func (c *singleCheckout) Skip() bool { } func (c *singleCheckout) Run(p *lfs.WrappedPointer) { + if !c.hasWorkTree { + return + } + cwdfilepath := c.pathConverter.Convert(p.Name) // Check the content - either missing or still this pointer (not exist is ok)
docs/man/git-lfs-checkout.adoc+3 −0 modified@@ -50,6 +50,9 @@ the `GIT_ATTR_SOURCE` environment variable may be set to `HEAD`, which will cause Git to only read attributes from `.gitattributes` files in `HEAD` and ignore those in the index or working tree. +In a bare repository, this command has no effect. In a future version, +this command may exit with an error if it is run in a bare repository. + == OPTIONS `--base`::
docs/man/git-lfs-pull.adoc+10 −0 modified@@ -36,6 +36,16 @@ the `GIT_ATTR_SOURCE` environment variable may be set to `HEAD`, which will cause Git to only read attributes from `.gitattributes` files in `HEAD` and ignore those in the index or working tree. +In a bare repository, if the installed Git version is at least 2.42.0, +this command will by default fetch Git LFS objects for files only if +they are present in the Git index and if they match a Git LFS filter +attribute from a local `gitattributes` file such as +`$GIT_DIR/info/attributes`. Any `.gitattributes` files in `HEAD` will +be ignored, unless the `GIT_ATTR_SOURCE` environment variable is set +to `HEAD`, and any `.gitattributes` files in the index or current +working tree will always be ignored. These constraints do not apply +with prior versions of Git. + == OPTIONS `-I <paths>`::
t/t-checkout.sh+17 −0 modified@@ -961,6 +961,23 @@ begin_test "checkout: GIT_WORK_TREE" ) end_test +begin_test "checkout: bare repository" +( + set -e + + reponame="checkout-bare" + git init --bare "$reponame" + cd "$reponame" + + git lfs checkout 2>&1 | tee checkout.log + if [ "0" -ne "${PIPESTATUS[0]}" ]; then + echo >&2 "fatal: expected checkout to succeed ..." + exit 1 + fi + [ "This operation must be run in a work tree." = "$(cat checkout.log)" ] +) +end_test + begin_test "checkout: sparse with partial clone and sparse index" ( set -e
t/t-pull.sh+131 −0 modified@@ -1117,6 +1117,137 @@ begin_test "pull with empty file doesn't modify mtime" ) end_test +begin_test "pull: bare repository" +( + set -e + + reponame="pull-bare" + setup_remote_repo "$reponame" + clone_repo "$reponame" "$reponame" + + git lfs track "*.dat" + + contents="a" + contents_oid="$(calc_oid "$contents")" + printf "%s" "$contents" >a.dat + + # The "git lfs pull" command should never check out files in a bare + # repository, either into a directory within the repository or one + # outside it. To verify this, we add a Git LFS pointer file whose path + # inside the repository is one which, if it were instead treated as an + # absolute filesystem path, corresponds to a writable directory. + # The "git lfs pull" command should not check out files into either + # this external directory or the bare repository. + external_dir="$TRASHDIR/${reponame}-external" + internal_dir="$(printf "%s" "$external_dir" | sed 's/^\/*//')" + mkdir -p "$internal_dir" + printf "%s" "$contents" >"$internal_dir/a.dat" + + git add .gitattributes a.dat "$internal_dir/a.dat" + git commit -m "initial commit" + + git push origin main + assert_server_object "$reponame" "$contents_oid" + + cd .. + git clone --bare "$GITSERVER/$reponame" "${reponame}-assert" + + cd "${reponame}-assert" + [ ! -e lfs ] + refute_local_object "$contents_oid" + + git lfs pull 2>&1 | tee pull.log + if [ "0" -ne "${PIPESTATUS[0]}" ]; then + echo >&2 "fatal: expected pull to succeed ..." + exit 1 + fi + + # When Git version 2.42.0 or higher is available, the "git lfs pull" + # command will use the "git ls-files" command rather than the + # "git ls-tree" command to list files. By default a bare repository + # lacks an index, so we expect no Git LFS objects to be fetched when + # "git ls-files" is used because Git v2.42.0 or higher is available. + gitversion="$(git version | cut -d" " -f3)" + set +e + compare_version "$gitversion" '2.42.0' + result=$? + set -e + if [ "$result" -eq "$VERSION_LOWER" ]; then + grep "Downloading LFS objects" pull.log + + assert_local_object "$contents_oid" 1 + else + grep -q "Downloading LFS objects" pull.log && exit 1 + + refute_local_object "$contents_oid" + fi + + [ ! -e "a.dat" ] + [ ! -e "$internal_dir/a.dat" ] + [ ! -e "$external_dir/a.dat" ] + + rm -rf lfs/objects + refute_local_object "$contents_oid" + + # When Git version 2.42.0 or higher is available, the "git lfs pull" + # command will use the "git ls-files" command rather than the + # "git ls-tree" command to list files. By default a bare repository + # lacks an index, so we expect no Git LFS objects to be fetched when + # "git ls-files" is used because Git v2.42.0 or higher is available. + # + # Therefore to verify that the "git lfs pull" command never checks out + # files in a bare repository, we first populate the index with Git LFS + # pointer files and then retry the command. + contents_git_oid="$(git ls-tree HEAD a.dat | awk '{ print $3 }')" + git update-index --add --cacheinfo 100644 "$contents_git_oid" a.dat + git update-index --add --cacheinfo 100644 "$contents_git_oid" "$internal_dir/a.dat" + + # When Git version 2.42.0 or higher is available, the "git lfs pull" + # command will use the "git ls-files" command rather than the + # "git ls-tree" command to list files, and does so by passing an + # "attr:filter=lfs" pathspec to the "git ls-files" command so it only + # lists files which match that filter attribute. + # + # In a bare repository, however, the "git ls-files" command will not read + # attributes from ".gitattributes" files in the index, so by default it + # will not list any Git LFS pointer files even if those files and the + # corresponding ".gitattributes" files have been added to the index and + # the pointer files would otherwise match the "attr:filter=lfs" pathspec. + # + # Therefore, instead of adding the ".gitattributes" file to the index, we + # copy it to "info/attributes" so that the pathspec filter will match our + # pointer file index entries and they will be listed by the "git ls-files" + # command. This allows us to verify that with Git v2.42.0 or higher, the + # "git lfs pull" command will fetch the objects for these pointer files + # in the index when the command is run in a bare repository. + # + # Note that with older versions of Git, the "git lfs pull" command will + # use the "git ls-tree" command to list the files in the tree referenced + # by HEAD. The Git LFS objects for any well-formed pointer files found in + # that list will then be fetched (unless local copies already exist), + # regardless of whether the pointer files actually match a "filter=lfs" + # attribute in any ".gitattributes" file in the index, the tree + # referenced by HEAD, or the current work tree. + if [ "$result" -ne "$VERSION_LOWER" ]; then + mkdir -p info + git show HEAD:.gitattributes >info/attributes + fi + + git lfs pull 2>&1 | tee pull.log + if [ "0" -ne "${PIPESTATUS[0]}" ]; then + echo >&2 "fatal: expected pull to succeed ..." + exit 1 + fi + grep "Downloading LFS objects" pull.log + + assert_local_object "$contents_oid" 1 + + [ ! -e "a.dat" ] + [ ! -e "$internal_dir/a.dat" ] + [ ! -e "$external_dir/a.dat" ] +) +end_test + begin_test "pull with partial clone and sparse checkout and index" ( set -e
5c11ffce9a4fdocs,lfs,t: create new files on checkout and pull
4 files changed · +165 −14
docs/man/git-lfs-checkout.adoc+3 −1 modified@@ -30,7 +30,9 @@ to a merge, this option checks out one of the three stages a conflicting Git LFS object into a separate file (which can be outside of the work tree). This can make using diff tools to inspect and resolve merges easier. A single Git LFS object's file path must be provided in -`<conflict-obj-path>`. +`<conflict-obj-path>`. If `<file>` already exists, whether as a regular +file, symbolic link, or directory, it will be removed and replaced, unless +it is a non-empty directory or otherwise cannot be deleted. If the installed Git version is at least 2.42.0, this command will by default check out Git LFS objects for files
lfs/gitfilter_smudge.go+13 −13 modified@@ -18,31 +18,31 @@ import ( func (f *GitFilter) SmudgeToFile(filename string, ptr *Pointer, download bool, manifest tq.Manifest, cb tools.CopyCallback) error { tools.MkdirAll(filepath.Dir(filename), f.cfg) - if stat, _ := os.Stat(filename); stat != nil { + // When no pointer file exists on disk, we should use the permissions + // defined for the file in Git, since the executable mode may be set. + // However, to conform with our legacy behaviour, we do not do this + // at present. + var mode os.FileMode = 0666 + if stat, _ := os.Lstat(filename); stat != nil && stat.Mode().IsRegular() { if ptr.Size == 0 && stat.Size() == 0 { return nil } - if stat.Mode()&0200 == 0 { - if err := os.Chmod(filename, stat.Mode()|0200); err != nil { - return errors.Wrap(err, - tr.Tr.Get("Could not restore write permission")) - } - - // When we're done, return the file back to its normal - // permission bits. - defer os.Chmod(filename, stat.Mode()) - } + mode = stat.Mode().Perm() } abs, err := filepath.Abs(filename) if err != nil { return errors.New(tr.Tr.Get("could not produce absolute path for %q", filename)) } - file, err := os.Create(abs) + if err := os.Remove(abs); err != nil && !os.IsNotExist(err) { + return errors.Wrap(err, tr.Tr.Get("could not remove working directory file %q", filename)) + } + + file, err := os.OpenFile(abs, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) if err != nil { - return errors.New(tr.Tr.Get("could not create working directory file: %v", err)) + return errors.Wrap(err, tr.Tr.Get("could not create working directory file %q", filename)) } defer file.Close() if _, err := f.Smudge(file, ptr, filename, download, manifest, cb); err != nil {
t/t-checkout.sh+88 −0 modified@@ -605,6 +605,64 @@ begin_test "checkout: skip changed files" ) end_test +begin_test "checkout: break hard links to existing files" +( + set -e + + reponame="checkout-break-file-hardlinks" + setup_remote_repo "$reponame" + clone_repo "$reponame" "$reponame" + + git lfs track "*.dat" + + contents="a" + contents_oid="$(calc_oid "$contents")" + mkdir -p dir1/dir2/dir3 + printf "%s" "$contents" >a.dat + printf "%s" "$contents" >dir1/dir2/dir3/a.dat + + git add .gitattributes a.dat dir1 + git commit -m "initial commit" + + git push origin main + assert_server_object "$reponame" "$contents_oid" + + cd .. + GIT_LFS_SKIP_SMUDGE=1 git clone "$GITSERVER/$reponame" "${reponame}-assert" + + cd "${reponame}-assert" + git lfs fetch origin main + + assert_local_object "$contents_oid" 1 + + rm -f a.dat dir1/dir2/dir3/a.dat ../link + pointer="$(git cat-file -p ":a.dat")" + echo "$pointer" >../link + ln ../link a.dat + ln ../link dir1/dir2/dir3/a.dat + + git lfs checkout + + [ "$contents" = "$(cat a.dat)" ] + [ "$contents" = "$(cat dir1/dir2/dir3/a.dat)" ] + [ "$pointer" = "$(cat ../link)" ] + assert_clean_status + + rm a.dat dir1/dir2/dir3/a.dat + ln ../link a.dat + ln ../link dir1/dir2/dir3/a.dat + + pushd dir1/dir2 + git lfs checkout + popd + + [ "$contents" = "$(cat a.dat)" ] + [ "$contents" = "$(cat dir1/dir2/dir3/a.dat)" ] + [ "$pointer" = "$(cat ../link)" ] + assert_clean_status +) +end_test + begin_test "checkout: without clean filter" ( set -e @@ -842,6 +900,36 @@ begin_test "checkout: conflicts" echo "abc123" | cmp - "$abs_assert_dir/link1/dir2/theirs.txt" } + rm -f base.txt link1 ../ours.txt ../link2 + ln -s link1 base.txt + ln -s link2 ../ours.txt + + git lfs checkout --to base.txt --base file1.dat + git lfs checkout --to ../ours.txt --ours file1.dat + + [ ! -L "base.txt" ] + [ ! -L "../ours.txt" ] + [ ! -e "link1" ] + [ ! -e "../link2" ] + echo "file1.dat" | cmp - base.txt + echo "def456" | cmp - ../ours.txt + + rm -f base.txt link1 ../ours.txt ../link2 + printf "link1" >link1 + printf "link2" >../link2 + ln link1 base.txt + ln ../link2 ../ours.txt + + git lfs checkout --to base.txt --base file1.dat + git lfs checkout --to ../ours.txt --ours file1.dat + + [ -f "link1" ] + [ -f "../link2" ] + [ "link1" = "$(cat link1)" ] + [ "link2" = "$(cat ../link2)" ] + echo "file1.dat" | cmp - base.txt + echo "def456" | cmp - ../ours.txt + git lfs checkout --to base.txt --ours other.txt 2>&1 | tee output.txt grep 'Could not find decoder pointer for object' output.txt popd > /dev/null
t/t-pull.sh+61 −0 modified@@ -743,6 +743,67 @@ begin_test "pull: skip changed files" ) end_test +begin_test "pull: break hard links to existing files" +( + set -e + + reponame="pull-break-file-hardlinks" + setup_remote_repo "$reponame" + clone_repo "$reponame" "$reponame" + + git lfs track "*.dat" + + contents="a" + contents_oid="$(calc_oid "$contents")" + mkdir -p dir1/dir2/dir3 + printf "%s" "$contents" >a.dat + printf "%s" "$contents" >dir1/dir2/dir3/a.dat + + git add .gitattributes a.dat dir1 + git commit -m "initial commit" + + git push origin main + assert_server_object "$reponame" "$contents_oid" + + cd .. + GIT_LFS_SKIP_SMUDGE=1 git clone "$GITSERVER/$reponame" "${reponame}-assert" + + cd "${reponame}-assert" + refute_local_object "$contents_oid" 1 + + rm -f a.dat dir1/dir2/dir3/a.dat ../link + pointer="$(git cat-file -p ":a.dat")" + echo "$pointer" >../link + ln ../link a.dat + ln ../link dir1/dir2/dir3/a.dat + + git lfs pull + assert_local_object "$contents_oid" 1 + + [ "$contents" = "$(cat a.dat)" ] + [ "$contents" = "$(cat dir1/dir2/dir3/a.dat)" ] + [ "$pointer" = "$(cat ../link)" ] + assert_clean_status + + rm a.dat dir1/dir2/dir3/a.dat + ln ../link a.dat + ln ../link dir1/dir2/dir3/a.dat + + rm -rf .git/lfs/objects + + pushd dir1/dir2 + git lfs pull + popd + + assert_local_object "$contents_oid" 1 + + [ "$contents" = "$(cat a.dat)" ] + [ "$contents" = "$(cat dir1/dir2/dir3/a.dat)" ] + [ "$pointer" = "$(cat ../link)" ] + assert_clean_status +) +end_test + begin_test "pull without clean filter" ( set -e
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-6pvw-g552-53c5ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-26625ghsaADVISORY
- cve.mitre.org/cgi-bin/cvename.cgighsaWEB
- github.com/git-lfs/git-lfs/commit/0cffe93176b870055c9dadbb3cc9a4a440e98396nvdWEB
- github.com/git-lfs/git-lfs/commit/5c11ffce9a4f095ff356bc781e2a031abb46c1a8nvdWEB
- github.com/git-lfs/git-lfs/commit/d02bd13f02ef76f6807581cd6b34709069cb3615nvdWEB
- github.com/git-lfs/git-lfs/releases/tag/v3.7.1nvdWEB
- github.com/git-lfs/git-lfs/security/advisories/GHSA-6pvw-g552-53c5nvdWEB
News mentions
0No linked articles in our index yet.