VYPR
High severityOSV Advisory· Published Oct 17, 2025· Updated Apr 15, 2026

CVE-2025-26625

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.

PackageAffected versionsPatched versions
github.com/git-lfs/git-lfsGo
>= 0.5.2, < 3.7.13.7.1

Affected products

1

Patches

4
0cffe93176b8

check for dir/symlink conflicts on checkout/pull

https://github.com/git-lfs/git-lfsChris DarrochAug 25, 2025via ghsa
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
    
d02bd13f02ef

fix bare repo pull/checkout path handling bug

https://github.com/git-lfs/git-lfsChris DarrochAug 13, 2025via ghsa
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
    
5c11ffce9a4f

docs,lfs,t: create new files on checkout and pull

https://github.com/git-lfs/git-lfsChris DarrochMay 16, 2025via ghsa
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

News mentions

0

No linked articles in our index yet.