diff --git a/.gitignore b/.gitignore index 71c1aec..c64f16f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1 @@ -git-lfs-v3.6.1.tar.gz +git-lfs-v3.7.1.tar.gz diff --git a/0001-Use-constant-format-string.patch b/0001-Use-constant-format-string.patch deleted file mode 100644 index 0beaf27..0000000 --- a/0001-Use-constant-format-string.patch +++ /dev/null @@ -1,757 +0,0 @@ -From 867aa58c816627cbe95f0e38de68daf8cf7cf730 Mon Sep 17 00:00:00 2001 -From: =?UTF-8?q?Ond=C5=99ej=20Poho=C5=99elsk=C3=BD?= -Date: Thu, 7 Aug 2025 13:55:34 +0200 -Subject: [PATCH] Use constant format string - -Resolves build failure with Golang-1.24< ---- - commands/command_clean.go | 2 +- - commands/command_dedup.go | 2 +- - commands/command_fsck.go | 4 ++-- - commands/command_logs.go | 6 +++--- - commands/command_migrate.go | 2 +- - commands/command_migrate_export.go | 4 ++-- - commands/command_migrate_import.go | 20 ++++++++++---------- - commands/command_migrate_info.go | 6 +++--- - commands/command_pointer.go | 2 +- - commands/command_smudge.go | 4 ++-- - creds/creds.go | 10 +++++----- - errors/errors.go | 2 +- - errors/types.go | 2 +- - git/githistory/rewriter_test.go | 2 +- - lfs/diff_index_scanner.go | 4 ++-- - lfs/gitfilter_smudge.go | 12 ++++++------ - lfshttp/certs.go | 12 ++++++------ - lfshttp/errors.go | 7 ++++++- - lfshttp/lfshttp.go | 2 +- - lfshttp/standalone/standalone.go | 8 ++++---- - ssh/connection.go | 2 +- - t/git-lfs-test-server-api/main.go | 2 +- - tasklog/simple_task.go | 2 +- - tools/filetools.go | 4 ++-- - tq/basic_download.go | 4 ++-- - tq/basic_upload.go | 4 ++-- - tq/custom.go | 2 +- - tq/ssh.go | 6 +++--- - tq/transfer_queue.go | 4 ++-- - tq/tus_upload.go | 4 ++-- - 30 files changed, 76 insertions(+), 71 deletions(-) - -diff --git a/commands/command_clean.go b/commands/command_clean.go -index 6b02d23c..8fa12d9b 100644 ---- a/commands/command_clean.go -+++ b/commands/command_clean.go -@@ -82,7 +82,7 @@ func clean(gf *lfs.GitFilter, to io.Writer, from io.Reader, fileName string, fil - Panic(err, tr.Tr.Get("Unable to move %s to %s", tmpfile, mediafile)) - } - -- Debug(tr.Tr.Get("Writing %s", mediafile)) -+ Debug("%s", tr.Tr.Get("Writing %s", mediafile)) - } - - _, err = lfs.EncodePointer(to, cleaned.Pointer) -diff --git a/commands/command_dedup.go b/commands/command_dedup.go -index 4d9688f2..2babff45 100644 ---- a/commands/command_dedup.go -+++ b/commands/command_dedup.go -@@ -129,7 +129,7 @@ func dedup(p *lfs.WrappedPointer) (success bool, err error) { - if ok, err := tools.CloneFileByPath(dstFile, srcFile); err != nil { - return false, err - } else if !ok { -- return false, errors.Errorf(tr.Tr.Get("unknown clone file error")) -+ return false, errors.Errorf("%s", tr.Tr.Get("unknown clone file error")) - } - - // Recover original state -diff --git a/commands/command_fsck.go b/commands/command_fsck.go -index c2332ef8..63756854 100644 ---- a/commands/command_fsck.go -+++ b/commands/command_fsck.go -@@ -170,7 +170,7 @@ func doFsckPointers(include, exclude string) []corruptPointer { - var corruptPointers []corruptPointer - gitscanner := lfs.NewGitScanner(cfg, func(p *lfs.WrappedPointer, err error) { - if p != nil { -- Debug(tr.Tr.Get("Examining %v (%v)", p.Oid, p.Name)) -+ Debug("%s", tr.Tr.Get("Examining %v (%v)", p.Oid, p.Name)) - if !p.Canonical { - cp := corruptPointer{ - blobOid: p.Sha1, -@@ -214,7 +214,7 @@ func doFsckPointers(include, exclude string) []corruptPointer { - func fsckPointer(name, oid string, size int64) (bool, error) { - path := cfg.Filesystem().ObjectPathname(oid) - -- Debug(tr.Tr.Get("Examining %v (%v)", name, path)) -+ Debug("%s", tr.Tr.Get("Examining %v (%v)", name, path)) - - f, err := os.Open(path) - if pErr, pOk := err.(*os.PathError); pOk { -diff --git a/commands/command_logs.go b/commands/command_logs.go -index 89d738fe..aa781dfa 100644 ---- a/commands/command_logs.go -+++ b/commands/command_logs.go -@@ -37,7 +37,7 @@ func logsShowCommand(cmd *cobra.Command, args []string) { - Exit(tr.Tr.Get("Error reading log: %s", name)) - } - -- Debug(tr.Tr.Get("Reading log: %s", name)) -+ Debug("%s", tr.Tr.Get("Reading log: %s", name)) - os.Stdout.Write(by) - } - -@@ -51,8 +51,8 @@ func logsClearCommand(cmd *cobra.Command, args []string) { - } - - func logsBoomtownCommand(cmd *cobra.Command, args []string) { -- Debug(tr.Tr.Get("Sample debug message")) -- err := errors.Wrapf(errors.New(tr.Tr.Get("Sample wrapped error message")), tr.Tr.Get("Sample error message")) -+ Debug("%s", tr.Tr.Get("Sample debug message")) -+ err := errors.Wrapf(errors.New(tr.Tr.Get("Sample wrapped error message")), "%s", tr.Tr.Get("Sample error message")) - Panic(err, tr.Tr.Get("Sample panic message")) - } - -diff --git a/commands/command_migrate.go b/commands/command_migrate.go -index e638d0bf..b16680b5 100644 ---- a/commands/command_migrate.go -+++ b/commands/command_migrate.go -@@ -319,7 +319,7 @@ func currentRefToMigrate() (*git.Ref, error) { - if current.Type == git.RefTypeOther || - current.Type == git.RefTypeRemoteBranch { - -- return nil, errors.Errorf(tr.Tr.Get("Cannot migrate non-local ref: %s", current.Name)) -+ return nil, errors.Errorf("%s", tr.Tr.Get("Cannot migrate non-local ref: %s", current.Name)) - } - return current, nil - } -diff --git a/commands/command_migrate_export.go b/commands/command_migrate_export.go -index 16c58653..484134b8 100644 ---- a/commands/command_migrate_export.go -+++ b/commands/command_migrate_export.go -@@ -35,7 +35,7 @@ func migrateExportCommand(cmd *cobra.Command, args []string) { - - filter := rewriter.Filter() - if len(filter.Include()) <= 0 { -- ExitWithError(errors.Errorf(tr.Tr.Get("One or more files must be specified with --include"))) -+ ExitWithError(errors.Errorf("%s", tr.Tr.Get("One or more files must be specified with --include"))) - } - - tracked := trackedFromExportFilter(filter) -@@ -116,7 +116,7 @@ func migrateExportCommand(cmd *cobra.Command, args []string) { - } - remoteURL := getAPIClient().Endpoints.RemoteEndpoint("download", remote).Url - if remoteURL == "" && cmd.Flag("remote").Changed { -- ExitWithError(errors.Errorf(tr.Tr.Get("Invalid remote %s provided", remote))) -+ ExitWithError(errors.Errorf("%s", tr.Tr.Get("Invalid remote %s provided", remote))) - } - - // If we have a valid remote, pre-download all objects using the Transfer Queue -diff --git a/commands/command_migrate_import.go b/commands/command_migrate_import.go -index 8b44b415..b03301f9 100644 ---- a/commands/command_migrate_import.go -+++ b/commands/command_migrate_import.go -@@ -44,11 +44,11 @@ func migrateImportCommand(cmd *cobra.Command, args []string) { - - if migrateNoRewrite { - if migrateFixup { -- ExitWithError(errors.Errorf(tr.Tr.Get("--no-rewrite and --fixup cannot be combined"))) -+ ExitWithError(errors.Errorf("%s", tr.Tr.Get("--no-rewrite and --fixup cannot be combined"))) - } - - if len(args) == 0 { -- ExitWithError(errors.Errorf(tr.Tr.Get("Expected one or more files with --no-rewrite"))) -+ ExitWithError(errors.Errorf("%s", tr.Tr.Get("Expected one or more files with --no-rewrite"))) - } - - ref, err := git.CurrentRef() -@@ -66,21 +66,21 @@ func migrateImportCommand(cmd *cobra.Command, args []string) { - - filter := git.GetAttributeFilter(cfg.LocalWorkingDir(), cfg.LocalGitDir()) - if len(filter.Include()) == 0 { -- ExitWithError(errors.Errorf(tr.Tr.Get("No Git LFS filters found in '.gitattributes'"))) -+ ExitWithError(errors.Errorf("%s", tr.Tr.Get("No Git LFS filters found in '.gitattributes'"))) - } - - gf := lfs.NewGitFilter(cfg) - - for _, file := range args { - if !filter.Allows(file) { -- ExitWithError(errors.Errorf(tr.Tr.Get("File %s did not match any Git LFS filters in '.gitattributes'", file))) -+ ExitWithError(errors.Errorf("%s", tr.Tr.Get("File %s did not match any Git LFS filters in '.gitattributes'", file))) - } - } - - for _, file := range args { - root, err = rewriteTree(gf, db, root, file) - if err != nil { -- ExitWithError(errors.Wrapf(err, tr.Tr.Get("Could not rewrite %q", file))) -+ ExitWithError(errors.Wrapf(err, "%s", tr.Tr.Get("Could not rewrite %q", file))) - } - } - -@@ -124,7 +124,7 @@ func migrateImportCommand(cmd *cobra.Command, args []string) { - if migrateFixup { - include, exclude := getIncludeExcludeArgs(cmd) - if include != nil || exclude != nil { -- ExitWithError(errors.Errorf(tr.Tr.Get("Cannot use --fixup with --include, --exclude"))) -+ ExitWithError(errors.Errorf("%s", tr.Tr.Get("Cannot use --fixup with --include, --exclude"))) - } - } - -@@ -142,7 +142,7 @@ func migrateImportCommand(cmd *cobra.Command, args []string) { - if above > 0 { - include, exclude := getIncludeExcludeArgs(cmd) - if include != nil || exclude != nil || migrateFixup { -- ExitWithError(errors.Errorf(tr.Tr.Get("Cannot use --above with --include, --exclude, --fixup"))) -+ ExitWithError(errors.Errorf("%s", tr.Tr.Get("Cannot use --above with --include, --exclude, --fixup"))) - } - } - -@@ -395,7 +395,7 @@ func rewriteTree(gf *lfs.GitFilter, db *gitobj.ObjectDatabase, root []byte, path - // Try to replace this blob with a Git LFS pointer. - index := findEntry(tree, splits[0]) - if index < 0 { -- return nil, errors.Errorf(tr.Tr.Get("unable to find entry %s in tree", splits[0])) -+ return nil, errors.Errorf("%s", tr.Tr.Get("unable to find entry %s in tree", splits[0])) - } - - blobEntry := tree.Entries[index] -@@ -433,7 +433,7 @@ func rewriteTree(gf *lfs.GitFilter, db *gitobj.ObjectDatabase, root []byte, path - - index := findEntry(tree, head) - if index < 0 { -- return nil, errors.Errorf(tr.Tr.Get("unable to find entry %s in tree", head)) -+ return nil, errors.Errorf("%s", tr.Tr.Get("unable to find entry %s in tree", head)) - } - - subtreeEntry := tree.Entries[index] -@@ -455,7 +455,7 @@ func rewriteTree(gf *lfs.GitFilter, db *gitobj.ObjectDatabase, root []byte, path - return db.WriteTree(tree) - - default: -- return nil, errors.Errorf(tr.Tr.Get("error parsing path %s", path)) -+ return nil, errors.Errorf("%s", tr.Tr.Get("error parsing path %s", path)) - } - } - -diff --git a/commands/command_migrate_info.go b/commands/command_migrate_info.go -index bcbc18b2..c0368a6e 100644 ---- a/commands/command_migrate_info.go -+++ b/commands/command_migrate_info.go -@@ -96,17 +96,17 @@ func migrateInfoCommand(cmd *cobra.Command, args []string) { - case "ignore": - migrateInfoPointersMode = migrateInfoPointersIgnore - default: -- ExitWithError(errors.Errorf(tr.Tr.Get("Unsupported --pointers option value"))) -+ ExitWithError(errors.Errorf("%s", tr.Tr.Get("Unsupported --pointers option value"))) - } - } - - if migrateFixup { - include, exclude := getIncludeExcludeArgs(cmd) - if include != nil || exclude != nil { -- ExitWithError(errors.Errorf(tr.Tr.Get("Cannot use --fixup with --include, --exclude"))) -+ ExitWithError(errors.Errorf("%s", tr.Tr.Get("Cannot use --fixup with --include, --exclude"))) - } - if pointers.Changed && migrateInfoPointersMode != migrateInfoPointersIgnore { -- ExitWithError(errors.Errorf(tr.Tr.Get("Cannot use --fixup with --pointers=%s", pointers.Value.String()))) -+ ExitWithError(errors.Errorf("%s", tr.Tr.Get("Cannot use --fixup with --pointers=%s", pointers.Value.String()))) - } - migrateInfoPointersMode = migrateInfoPointersIgnore - } -diff --git a/commands/command_pointer.go b/commands/command_pointer.go -index a2a57da6..2177408d 100644 ---- a/commands/command_pointer.go -+++ b/commands/command_pointer.go -@@ -129,7 +129,7 @@ func pointerCommand(cmd *cobra.Command, args []string) { - os.Exit(1) - } - -- fmt.Fprintf(os.Stderr, buf.String()) -+ fmt.Fprintf(os.Stderr, "%s", buf.String()) - if comparing { - compareOid, err = git.HashObject(bytes.NewReader(buf.Bytes())) - if err != nil { -diff --git a/commands/command_smudge.go b/commands/command_smudge.go -index 0117b996..2b6297c8 100644 ---- a/commands/command_smudge.go -+++ b/commands/command_smudge.go -@@ -45,7 +45,7 @@ func delayedSmudge(gf *lfs.GitFilter, s *git.FilterProcessScanner, to io.Writer, - - if n != 0 { - return 0, false, nil, errors.NewNotAPointerError(errors.Errorf( -- tr.Tr.Get("Unable to parse pointer at: %q", filename), -+ "%s", tr.Tr.Get("Unable to parse pointer at: %q", filename), - )) - } - return 0, false, nil, nil -@@ -108,7 +108,7 @@ func smudge(gf *lfs.GitFilter, to io.Writer, from io.Reader, filename string, sk - - if n != 0 { - return 0, errors.NewNotAPointerError(errors.Errorf( -- tr.Tr.Get("Unable to parse pointer at: %q", filename), -+ "%s", tr.Tr.Get("Unable to parse pointer at: %q", filename), - )) - } - return 0, nil -diff --git a/creds/creds.go b/creds/creds.go -index 784c1b5f..d869dbca 100644 ---- a/creds/creds.go -+++ b/creds/creds.go -@@ -66,13 +66,13 @@ func (c Creds) buffer(protectProtocol bool) (*bytes.Buffer, error) { - for k, v := range c { - for _, item := range v { - if strings.Contains(item, "\n") { -- return nil, errors.Errorf(tr.Tr.Get("credential value for %s contains newline: %q", k, item)) -+ return nil, errors.Errorf("%s", tr.Tr.Get("credential value for %s contains newline: %q", k, item)) - } - if protectProtocol && strings.Contains(item, "\r") { -- return nil, errors.Errorf(tr.Tr.Get("credential value for %s contains carriage return: %q\nIf this is intended, set `credential.protectProtocol=false`", k, item)) -+ return nil, errors.Errorf("%s", tr.Tr.Get("credential value for %s contains carriage return: %q\nIf this is intended, set `credential.protectProtocol=false`", k, item)) - } - if strings.Contains(item, string(rune(0))) { -- return nil, errors.Errorf(tr.Tr.Get("credential value for %s contains null byte: %q", k, item)) -+ return nil, errors.Errorf("%s", tr.Tr.Get("credential value for %s contains null byte: %q", k, item)) - } - - buf.Write([]byte(k)) -@@ -249,7 +249,7 @@ func (a *AskPassCredentialHelper) getValue(what Creds, valueType credValueType, - case credValueTypePassword: - valueString = "password" - default: -- return "", errors.Errorf(tr.Tr.Get("Invalid Credential type queried from AskPass")) -+ return "", errors.Errorf("%s", tr.Tr.Get("Invalid Credential type queried from AskPass")) - } - - // Return the existing credential if it was already provided, otherwise -@@ -274,7 +274,7 @@ func (a *AskPassCredentialHelper) getFromProgram(valueType credValueType, u *url - case credValueTypePassword: - valueString = "Password" - default: -- return "", errors.Errorf(tr.Tr.Get("Invalid Credential type queried from AskPass")) -+ return "", errors.Errorf("%s", tr.Tr.Get("Invalid Credential type queried from AskPass")) - } - - // 'cmd' will run the GIT_ASKPASS (or core.askpass) command prompting -diff --git a/errors/errors.go b/errors/errors.go -index dae33a81..c77b5e93 100644 ---- a/errors/errors.go -+++ b/errors/errors.go -@@ -114,7 +114,7 @@ func Combine(errs []error) error { - } - buf.WriteString(err.Error()) - } -- return fmt.Errorf(buf.String()) -+ return fmt.Errorf("%s", buf.String()) - } - - func Cause(err error) error { -diff --git a/errors/types.go b/errors/types.go -index 041e4f39..b869b4ea 100644 ---- a/errors/types.go -+++ b/errors/types.go -@@ -386,7 +386,7 @@ func (e badPointerKeyError) BadPointerKeyError() bool { - } - - func NewBadPointerKeyError(expected, actual string) error { -- err := Errorf(tr.Tr.Get("Expected key %s, got %s", expected, actual)) -+ err := Errorf("%s", tr.Tr.Get("Expected key %s, got %s", expected, actual)) - return badPointerKeyError{expected, actual, newWrappedError(err, tr.Tr.Get("pointer parsing"))} - } - -diff --git a/git/githistory/rewriter_test.go b/git/githistory/rewriter_test.go -index 98ce635c..b401e5f3 100644 ---- a/git/githistory/rewriter_test.go -+++ b/git/githistory/rewriter_test.go -@@ -377,7 +377,7 @@ func TestHistoryRewriterCallbacksSubtrees(t *testing.T) { - } - - func TestHistoryRewriterTreePreCallbackPropagatesErrors(t *testing.T) { -- expected := errors.Errorf("my error") -+ expected := errors.Errorf("%s", "my error") - - db := DatabaseFromFixture(t, "linear-history.git") - r := NewRewriter(db) -diff --git a/lfs/diff_index_scanner.go b/lfs/diff_index_scanner.go -index 7ceaf4df..d7029423 100644 ---- a/lfs/diff_index_scanner.go -+++ b/lfs/diff_index_scanner.go -@@ -185,12 +185,12 @@ func (s *DiffIndexScanner) scan(line string) (*DiffIndexEntry, error) { - - parts := strings.Split(line, "\t") - if len(parts) < 2 { -- return nil, errors.Errorf(tr.Tr.Get("invalid line: %s", line)) -+ return nil, errors.Errorf("%s", tr.Tr.Get("invalid line: %s", line)) - } - - desc := strings.Fields(parts[0]) - if len(desc) < 5 { -- return nil, errors.Errorf(tr.Tr.Get("invalid description: %s", parts[0])) -+ return nil, errors.Errorf("%s", tr.Tr.Get("invalid description: %s", parts[0])) - } - - entry := &DiffIndexEntry{ -diff --git a/lfs/gitfilter_smudge.go b/lfs/gitfilter_smudge.go -index 830e83c4..58049da1 100644 ---- a/lfs/gitfilter_smudge.go -+++ b/lfs/gitfilter_smudge.go -@@ -132,7 +132,7 @@ func (f *GitFilter) downloadFile(writer io.Writer, ptr *Pointer, workingfile, me - } - } - -- return 0, errors.Wrapf(multiErr, tr.Tr.Get("Error downloading %s (%s)", workingfile, ptr.Oid)) -+ return 0, errors.Wrapf(multiErr, "%s", tr.Tr.Get("Error downloading %s (%s)", workingfile, ptr.Oid)) - } - - return f.readLocalFile(writer, ptr, mediafile, workingfile, nil) -@@ -163,7 +163,7 @@ func (f *GitFilter) downloadFileFallBack(writer io.Writer, ptr *Pointer, working - multiErr = e - } - } -- wrappedError := errors.Wrapf(multiErr, tr.Tr.Get("Error downloading %s (%s)", workingfile, ptr.Oid)) -+ wrappedError := errors.Wrapf(multiErr, "%s", tr.Tr.Get("Error downloading %s (%s)", workingfile, ptr.Oid)) - if index >= len(remotes)-1 { - return 0, wrappedError - } else { -@@ -176,13 +176,13 @@ func (f *GitFilter) downloadFileFallBack(writer io.Writer, ptr *Pointer, working - return f.readLocalFile(writer, ptr, mediafile, workingfile, nil) - } - } -- return 0, errors.Wrapf(errors.New("No known remotes"), tr.Tr.Get("Error downloading %s (%s)", workingfile, ptr.Oid)) -+ return 0, errors.Wrapf(errors.New("No known remotes"), "%s", tr.Tr.Get("Error downloading %s (%s)", workingfile, ptr.Oid)) - } - - func (f *GitFilter) readLocalFile(writer io.Writer, ptr *Pointer, mediafile string, workingfile string, cb tools.CopyCallback) (int64, error) { - reader, err := tools.RobustOpen(mediafile) - if err != nil { -- return 0, errors.Wrapf(err, tr.Tr.Get("error opening media file")) -+ return 0, errors.Wrapf(err, "%s", tr.Tr.Get("error opening media file")) - } - defer reader.Close() - -@@ -250,14 +250,14 @@ func (f *GitFilter) readLocalFile(writer io.Writer, ptr *Pointer, mediafile stri - // setup reader - reader, err = os.Open(response.file.Name()) - if err != nil { -- return 0, errors.Wrapf(err, tr.Tr.Get("Error opening smudged file: %s", err)) -+ return 0, errors.Wrapf(err, "%s", tr.Tr.Get("Error opening smudged file: %s", err)) - } - defer reader.Close() - } - - n, err := tools.CopyWithCallback(writer, reader, ptr.Size, cb) - if err != nil { -- return n, errors.Wrapf(err, tr.Tr.Get("Error reading from media file: %s", err)) -+ return n, errors.Wrapf(err, "%s", tr.Tr.Get("Error reading from media file: %s", err)) - } - - return n, nil -diff --git a/lfshttp/certs.go b/lfshttp/certs.go -index c193aa59..68abcb42 100644 ---- a/lfshttp/certs.go -+++ b/lfshttp/certs.go -@@ -76,23 +76,23 @@ func getClientCertForHost(c *Client, host string) (*tls.Certificate, error) { - - hostSslKey, err := tools.ExpandPath(hostSslKey, false) - if err != nil { -- return nil, errors.Wrapf(err, tr.Tr.Get("Error resolving key path %q", hostSslKey)) -+ return nil, errors.Wrapf(err, "%s", tr.Tr.Get("Error resolving key path %q", hostSslKey)) - } - - hostSslCert, err = tools.ExpandPath(hostSslCert, false) - if err != nil { -- return nil, errors.Wrapf(err, tr.Tr.Get("Error resolving cert path %q", hostSslCert)) -+ return nil, errors.Wrapf(err, "%s", tr.Tr.Get("Error resolving cert path %q", hostSslCert)) - } - - cert, err := os.ReadFile(hostSslCert) - if err != nil { - tracerx.Printf("Error reading client cert file %q: %v", hostSslCert, err) -- return nil, errors.Wrapf(err, tr.Tr.Get("Error reading client cert file %q", hostSslCert)) -+ return nil, errors.Wrapf(err, "%s", tr.Tr.Get("Error reading client cert file %q", hostSslCert)) - } - key, err := os.ReadFile(hostSslKey) - if err != nil { - tracerx.Printf("Error reading client key file %q: %v", hostSslKey, err) -- return nil, errors.Wrapf(err, tr.Tr.Get("Error reading client key file %q", hostSslKey)) -+ return nil, errors.Wrapf(err, "%s", tr.Tr.Get("Error reading client key file %q", hostSslKey)) - } - - block, _ := pem.Decode(key) -@@ -103,14 +103,14 @@ func getClientCertForHost(c *Client, host string) (*tls.Certificate, error) { - key, err = decryptPEMBlock(c, block, hostSslKey, key) - if err != nil { - tracerx.Printf("Unable to decrypt client key file %q: %v", hostSslKey, err) -- return nil, errors.Wrapf(err, tr.Tr.Get("Error reading client key file %q (not a PKCS#1 file?)", hostSslKey)) -+ return nil, errors.Wrapf(err, "%s", tr.Tr.Get("Error reading client key file %q (not a PKCS#1 file?)", hostSslKey)) - } - } - - certobj, err := tls.X509KeyPair(cert, key) - if err != nil { - tracerx.Printf("Error reading client cert/key %v", err) -- return nil, errors.Wrapf(err, tr.Tr.Get("Error reading client cert/key")) -+ return nil, errors.Wrapf(err, "%s", tr.Tr.Get("Error reading client cert/key")) - } - return &certobj, nil - } -diff --git a/lfshttp/errors.go b/lfshttp/errors.go -index 15340061..bc1e13d6 100644 ---- a/lfshttp/errors.go -+++ b/lfshttp/errors.go -@@ -124,5 +124,10 @@ func defaultError(res *http.Response) error { - msgFmt = tr.Tr.Get("Server error %%s from HTTP %d", res.StatusCode) - } - -- return errors.Errorf(fmt.Sprintf(msgFmt), res.Request.URL) -+ // Preserve exact original behavior but avoid format string warnings -+ // Original was: errors.Errorf(fmt.Sprintf(msgFmt), res.Request.URL) -+ // This is equivalent but avoids variable format strings -+ urlStr := fmt.Sprintf("%v", res.Request.URL) // Convert URL to string safely -+ finalMsg := strings.ReplaceAll(msgFmt, "%%s", urlStr) // Replace %%s with actual URL -+ return errors.New(finalMsg) - } -diff --git a/lfshttp/lfshttp.go b/lfshttp/lfshttp.go -index fa89714a..913442db 100644 ---- a/lfshttp/lfshttp.go -+++ b/lfshttp/lfshttp.go -@@ -84,7 +84,7 @@ func DecodeJSON(res *http.Response, obj interface{}) error { - res.Body.Close() - - if err != nil { -- return errors.Wrapf(err, tr.Tr.Get("Unable to parse HTTP response for %s %s", res.Request.Method, res.Request.URL)) -+ return errors.Wrapf(err, "%s", tr.Tr.Get("Unable to parse HTTP response for %s %s", res.Request.Method, res.Request.URL)) - } - - return nil -diff --git a/lfshttp/standalone/standalone.go b/lfshttp/standalone/standalone.go -index 22a5902e..05b7a4b1 100644 ---- a/lfshttp/standalone/standalone.go -+++ b/lfshttp/standalone/standalone.go -@@ -256,7 +256,7 @@ func (h *fileHandler) upload(oid string, size int64, path string) (string, strin - func (h *fileHandler) download(oid string, size int64) (string, string, error) { - if !h.remoteConfig.LFSObjectExists(oid, size) { - tracerx.Printf("missing object in %q (%s)", h.remotePath, oid) -- return oid, "", errors.Errorf(tr.Tr.Get("remote missing object %s", oid)) -+ return oid, "", errors.Errorf("%s", tr.Tr.Get("remote missing object %s", oid)) - } - - src, err := h.remoteConfig.Filesystem().ObjectPath(oid) -@@ -290,13 +290,13 @@ func ProcessStandaloneData(cfg *config.Configuration, input *os.File, output *os - for scanner.Scan() { - var msg inputMessage - if err := json.NewDecoder(strings.NewReader(scanner.Text())).Decode(&msg); err != nil { -- return errors.Wrapf(err, tr.Tr.Get("error decoding JSON")) -+ return errors.Wrapf(err, "%s", tr.Tr.Get("error decoding JSON")) - } - if handler == nil { - var err error - handler, err = newHandler(cfg, output, &msg) - if err != nil { -- err := errors.Wrapf(err, tr.Tr.Get("error creating handler")) -+ err := errors.Wrapf(err, "%s", tr.Tr.Get("error creating handler")) - errMsg := outputErrorMessage{ - Error: errorMessage{ - Message: err.Error(), -@@ -314,7 +314,7 @@ func ProcessStandaloneData(cfg *config.Configuration, input *os.File, output *os - os.RemoveAll(handler.tempdir) - } - if err := scanner.Err(); err != nil { -- return errors.Wrapf(err, tr.Tr.Get("error reading input")) -+ return errors.Wrapf(err, "%s", tr.Tr.Get("error reading input")) - } - return nil - } -diff --git a/ssh/connection.go b/ssh/connection.go -index 83a4530c..f08165ae 100644 ---- a/ssh/connection.go -+++ b/ssh/connection.go -@@ -80,7 +80,7 @@ func startConnection(id int, osEnv config.Environment, gitEnv config.Environment - r.Close() - w.Close() - cmd.Wait() -- err = errors.Combine([]error{err, fmt.Errorf(tr.Tr.Get("Failed to connect to remote SSH server: %s", cmd.Stderr))}) -+ err = errors.Combine([]error{err, fmt.Errorf("%s", tr.Tr.Get("Failed to connect to remote SSH server: %s", cmd.Stderr))}) - tracerx.Printf("pure SSH connection unsuccessful (#%d)", id) - } else { - tracerx.Printf("pure SSH connection successful (#%d)", id) -diff --git a/t/git-lfs-test-server-api/main.go b/t/git-lfs-test-server-api/main.go -index f897fd6e..6408fbdd 100644 ---- a/t/git-lfs-test-server-api/main.go -+++ b/t/git-lfs-test-server-api/main.go -@@ -74,7 +74,7 @@ func testServerApi(cmd *cobra.Command, args []string) { - - manifest, err := buildManifest(repo) - if err != nil { -- exit("error building tq.Manifest: " + err.Error()) -+ exit("error building tq.Manifest: %s", err.Error()) - } - - var oidsExist, oidsMissing []TestObject -diff --git a/tasklog/simple_task.go b/tasklog/simple_task.go -index 207c5838..fb9c0988 100644 ---- a/tasklog/simple_task.go -+++ b/tasklog/simple_task.go -@@ -28,7 +28,7 @@ func NewSimpleTask() *SimpleTask { - - // Log logs a string with no formatting verbs. - func (s *SimpleTask) Log(str string) { -- s.Logf(str) -+ s.Logf("%s", str) - } - - // Logf logs some formatted string, which is interpreted according to the rules -diff --git a/tools/filetools.go b/tools/filetools.go -index be97a800..05f56822 100644 ---- a/tools/filetools.go -+++ b/tools/filetools.go -@@ -181,14 +181,14 @@ func ExpandPath(path string, expand bool) (string, error) { - } - - if err != nil { -- return "", errors.Wrapf(err, tr.Tr.Get("could not find user %s", username)) -+ return "", errors.Wrapf(err, "%s", tr.Tr.Get("could not find user %s", username)) - } - - homedir := who.HomeDir - if expand { - homedir, err = filepath.EvalSymlinks(homedir) - if err != nil { -- return "", errors.Wrapf(err, tr.Tr.Get("cannot eval symlinks for %s", homedir)) -+ return "", errors.Wrapf(err, "%s", tr.Tr.Get("cannot eval symlinks for %s", homedir)) - } - } - return filepath.Join(homedir, path[len(username)+1:]), nil -diff --git a/tq/basic_download.go b/tq/basic_download.go -index 767ebaee..c2aae578 100644 ---- a/tq/basic_download.go -+++ b/tq/basic_download.go -@@ -118,7 +118,7 @@ func (a *basicDownloadAdapter) download(t *Transfer, cb ProgressCallback, authOk - return err - } - if rel == nil { -- return errors.Errorf(tr.Tr.Get("Object %s not found on the server.", t.Oid)) -+ return errors.Errorf("%s", tr.Tr.Get("Object %s not found on the server.", t.Oid)) - } - - req, err := a.newHTTPRequest("GET", rel) -@@ -243,7 +243,7 @@ func (a *basicDownloadAdapter) download(t *Transfer, cb ProgressCallback, authOk - } - written, err := tools.CopyWithCallback(dlFile, hasher, res.ContentLength, ccb) - if err != nil { -- return errors.Wrapf(err, tr.Tr.Get("cannot write data to temporary file %q", dlfilename)) -+ return errors.Wrapf(err, "%s", tr.Tr.Get("cannot write data to temporary file %q", dlfilename)) - } - - if actual := hasher.Hash(); actual != t.Oid { -diff --git a/tq/basic_upload.go b/tq/basic_upload.go -index 669a2015..b90278ad 100644 ---- a/tq/basic_upload.go -+++ b/tq/basic_upload.go -@@ -47,7 +47,7 @@ func (a *basicUploadAdapter) DoTransfer(ctx interface{}, t *Transfer, cb Progres - return err - } - if rel == nil { -- return errors.Errorf(tr.Tr.Get("No upload action for object: %s", t.Oid)) -+ return errors.Errorf("%s", tr.Tr.Get("No upload action for object: %s", t.Oid)) - } - - req, err := a.newHTTPRequest("PUT", rel) -@@ -142,7 +142,7 @@ func (a *basicUploadAdapter) DoTransfer(ctx interface{}, t *Transfer, cb Progres - } - - if res.StatusCode > 299 { -- return errors.Wrapf(nil, tr.Tr.Get("Invalid status for %s %s: %d", -+ return errors.Wrapf(nil, "%s", tr.Tr.Get("Invalid status for %s %s: %d", - req.Method, - strings.SplitN(req.URL.String(), "?", 2)[0], - res.StatusCode, -diff --git a/tq/custom.go b/tq/custom.go -index 060e77db..f479d3d7 100644 ---- a/tq/custom.go -+++ b/tq/custom.go -@@ -272,7 +272,7 @@ func (a *customAdapter) DoTransfer(ctx interface{}, t *Transfer, cb ProgressCall - return err - } - if rel == nil && !a.standalone { -- return errors.Errorf(tr.Tr.Get("Object %s not found on the server.", t.Oid)) -+ return errors.Errorf("%s", tr.Tr.Get("Object %s not found on the server.", t.Oid)) - } - var req *customAdapterTransferRequest - if a.direction == Upload { -diff --git a/tq/ssh.go b/tq/ssh.go -index 79693495..235644ef 100644 ---- a/tq/ssh.go -+++ b/tq/ssh.go -@@ -194,7 +194,7 @@ func (a *SSHAdapter) download(t *Transfer, workerNum int, cb ProgressCallback) e - return err - } - if rel == nil { -- return errors.Errorf(tr.Tr.Get("No download action for object: %s", t.Oid)) -+ return errors.Errorf("%s", tr.Tr.Get("No download action for object: %s", t.Oid)) - } - // Reserve a temporary filename. We need to make sure nobody operates on the file simultaneously with us. - f, err := tools.TempFile(a.tempDir(), t.Oid, a.fs) -@@ -267,7 +267,7 @@ func (a *SSHAdapter) doDownload(t *Transfer, workerNum int, f *os.File, cb Progr - hasher := tools.NewHashingReader(data) - written, err := tools.CopyWithCallback(f, hasher, t.Size, ccb) - if err != nil { -- return errors.Wrapf(err, tr.Tr.Get("cannot write data to temporary file %q", dlfilename)) -+ return errors.Wrapf(err, "%s", tr.Tr.Get("cannot write data to temporary file %q", dlfilename)) - } - - if actual := hasher.Hash(); actual != t.Oid { -@@ -346,7 +346,7 @@ func (a *SSHAdapter) upload(t *Transfer, workerNum int, cb ProgressCallback) err - return err - } - if rel == nil { -- return errors.Errorf(tr.Tr.Get("No upload action for object: %s", t.Oid)) -+ return errors.Errorf("%s", tr.Tr.Get("No upload action for object: %s", t.Oid)) - } - - f, err := os.OpenFile(t.Path, os.O_RDONLY, 0644) -diff --git a/tq/transfer_queue.go b/tq/transfer_queue.go -index ad779d34..26effe13 100644 ---- a/tq/transfer_queue.go -+++ b/tq/transfer_queue.go -@@ -648,7 +648,7 @@ func (q *TransferQueue) enqueueAndCollectRetriesFor(batch batch) (batch, error) - // Transfer object, then we give up on the - // transfer by telling the progress meter to - // skip the number of bytes in "o". -- q.errorc <- errors.Errorf(tr.Tr.Get("[%v] The server returned an unknown OID.", o.Oid)) -+ q.errorc <- errors.Errorf("%s", tr.Tr.Get("[%v] The server returned an unknown OID.", o.Oid)) - - q.Skip(o.Size) - q.wait.Done() -@@ -748,7 +748,7 @@ func (q *TransferQueue) partitionTransfers(transfers []*Transfer) (present []*Tr - var err error - - if t.Size < 0 { -- err = errors.Errorf(tr.Tr.Get("object %q has invalid size (got: %d)", t.Oid, t.Size)) -+ err = errors.Errorf("%s", tr.Tr.Get("object %q has invalid size (got: %d)", t.Oid, t.Size)) - } else { - fd, serr := os.Stat(t.Path) - if serr != nil { -diff --git a/tq/tus_upload.go b/tq/tus_upload.go -index 273cb7fc..27f47542 100644 ---- a/tq/tus_upload.go -+++ b/tq/tus_upload.go -@@ -34,7 +34,7 @@ func (a *tusUploadAdapter) DoTransfer(ctx interface{}, t *Transfer, cb ProgressC - return err - } - if rel == nil { -- return errors.Errorf(tr.Tr.Get("No upload action for object: %s", t.Oid)) -+ return errors.Errorf("%s", tr.Tr.Get("No upload action for object: %s", t.Oid)) - } - - // Note not supporting the Creation extension since the batch API generates URLs -@@ -142,7 +142,7 @@ func (a *tusUploadAdapter) DoTransfer(ctx interface{}, t *Transfer, cb ProgressC - } - - if res.StatusCode > 299 { -- return errors.Wrapf(nil, tr.Tr.Get("Invalid status for %s %s: %d", -+ return errors.Wrapf(nil, "%s", tr.Tr.Get("Invalid status for %s %s: %d", - req.Method, - strings.SplitN(req.URL.String(), "?", 2)[0], - res.StatusCode, --- -2.50.1 - diff --git a/0001-commands-fix-format-string-type-mismatch-in-lockveri.patch b/0001-commands-fix-format-string-type-mismatch-in-lockveri.patch new file mode 100644 index 0000000..953c989 --- /dev/null +++ b/0001-commands-fix-format-string-type-mismatch-in-lockveri.patch @@ -0,0 +1,29 @@ +From b1620879b0e615e03c1e89aeafc33940a0ed4ba3 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Ond=C5=99ej=20Poho=C5=99elsk=C3=BD?= +Date: Thu, 9 Apr 2026 11:03:37 +0200 +Subject: [PATCH] commands: fix format string type mismatch in lockverifier + +The addLocks() method passes a *git.Ref value to a %q format verb in +a tr.Tr.Get() call, but %q expects a string. Newer versions of Go +recognize tr.Tr.Get() as a printf-like function and flag this as an +error during go vet. + +Pass ref.Refspec() instead, which returns the reference name as a +string. +--- + commands/lockverifier.go | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +diff --git a/commands/lockverifier.go b/commands/lockverifier.go +index d7d2d38682..96301937cc 100644 +--- a/commands/lockverifier.go ++++ b/commands/lockverifier.go +@@ -90,7 +90,7 @@ func (lv *lockVerifier) addLocks(ref *git.Ref, locks []locking.Lock, set map[str + for _, l := range locks { + if rl, ok := set[l.Path]; ok { + if err := rl.Add(ref, l); err != nil { +- Error(tr.Tr.Get("warning: error adding %q lock for ref %q: %+v", l.Path, ref, err)) ++ Error(tr.Tr.Get("warning: error adding %q lock for ref %q: %+v", l.Path, ref.Refspec(), err)) + } + } else { + set[l.Path] = lv.newRefLocks(ref, l) diff --git a/git-lfs-3.6.1-cve-2025-26625.patch b/git-lfs-3.6.1-cve-2025-26625.patch deleted file mode 100644 index 7433bb9..0000000 --- a/git-lfs-3.6.1-cve-2025-26625.patch +++ /dev/null @@ -1,8610 +0,0 @@ -From 449a7e88fd0c060b621218898e22b2a896548217 Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Sat, 19 Jul 2025 17:48:42 -0700 -Subject: [PATCH 01/21] t/t-{checkout,pull}.sh: indent pushd/popd blocks - -In PR #527 we introduced the "git lfs checkout" and "git lfs pull" -commands and added some initial tests of those commands to our -test suite, which we have subsequently expanded over time. Before -we adjust how these commands check and create files, we want to -further revise and expand their test suites to validate a broader -range of conditions. - -As a first step, we reformat the initial "checkout" and "pull" tests -in the t/t-checkout.sh and t/t-pull.sh test scripts, specifically the -blocks where these tests perform checks after changing the current -working directory. - -In the case of the "checkout" test in the t/t-checkout.sh test script, -we use the "pushd" and "popd" shell built-ins to change the current -working directory and then reverse that change. However, the -intervening commands are not indented, so we do that now to bring the -formatting of this test in line with many of our other tests which -also use these shell built-ins. - -In order to improve the consistency between our tests, we then update -the "pull" test in the t/t-pull.sh test script to also use the "pushd" -and "popd" shell built-ins around the "git lfs pull" commands we -want to run in a subdirectory of the test repository's working tree. - -We also take the opportunity to remove several unnecessary ls(1) commands -which are left over from development of the tests. ---- - t/t-checkout.sh | 14 +++++++------- - t/t-pull.sh | 11 ++++++----- - 2 files changed, 13 insertions(+), 12 deletions(-) - -diff --git a/t/t-checkout.sh b/t/t-checkout.sh -index dfa26633..72afb17c 100755 ---- a/t/t-checkout.sh -+++ b/t/t-checkout.sh -@@ -79,13 +79,13 @@ begin_test "checkout" - - echo "test subdir context" - pushd folder1 -- git lfs checkout nested.dat -- [ "$contents" = "$(cat nested.dat)" ] -- [ ! -f ../folder2/nested.dat ] -- # test '.' in current dir -- rm nested.dat -- git lfs checkout . 2>&1 | tee checkout.log -- [ "$contents" = "$(cat nested.dat)" ] -+ git lfs checkout nested.dat -+ [ "$contents" = "$(cat nested.dat)" ] -+ [ ! -f ../folder2/nested.dat ] -+ # test '.' in current dir -+ rm nested.dat -+ git lfs checkout . 2>&1 | tee checkout.log -+ [ "$contents" = "$(cat nested.dat)" ] - popd - - echo "test folder param" -diff --git a/t/t-pull.sh b/t/t-pull.sh -index 5f5b948b..8fdd4ee4 100755 ---- a/t/t-pull.sh -+++ b/t/t-pull.sh -@@ -34,7 +34,6 @@ begin_test "pull" - grep "create mode 100644 a.dat" commit.log - grep "create mode 100644 .gitattributes" commit.log - -- ls -al - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] - [ "dir" = "$(cat "dir/dir.dat")" ] -@@ -75,7 +74,6 @@ begin_test "pull" - rm -r a.dat á.dat dir # removing files makes the status dirty - rm -rf .git/lfs/objects - git lfs pull -- ls -al - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] - assert_local_object "$contents_oid" 1 -@@ -147,12 +145,15 @@ begin_test "pull" - assert_clean_status - - echo "lfs pull in subdir" -- cd dir -- git lfs pull -+ pushd dir -+ git lfs pull -+ popd - assert_clean_status - - echo "lfs pull in subdir with -I" -- git lfs pull -I "*.dat" -+ pushd dir -+ git lfs pull -I "*.dat" -+ popd - assert_clean_status - ) - end_test --- -2.51.1 - - -From 75bdb616230f122b35495c5383b1b9e415fee76a Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Mon, 21 Jul 2025 16:49:31 -0700 -Subject: [PATCH 02/21] t/t-{checkout,pull}.sh: expand initial tests - -In PR #527 we introduced the "git lfs checkout" and "git lfs pull" -commands and added some initial tests of those commands to our test -suite, which we have subsequently expanded over time. Before we adjust -how these commands check and creates files, we want to further revise -and expand their test suites to validate a broader range of conditions. - -In the initial "checkout" test of the t/t-checkout.sh test script we -expand two sets of tests, both those we perform within a subdirectory -of the current working tree, and those we perform with various "glob" -file pattern arguments. - -After changing the working directory to a subdirectory of the working -tree, at present the test suite only checks the behaviour of the -"git lfs checkout" command when passed either a single relative file -name and or the "." current directory name. We now add similar checks -using first a bare ".." parent directory name and then using a relative -path pattern to a sibling subdirectory combined with a trailing "/**" -component, following the gitignore(5) rules we support for pattern -matching. - -We also run the "git lfs checkout" command in the root of the working -tree and check that it behaves as expected when passed the name of -a subdirectory which already exists, and when that subdirectory's -name is followed by a trailing "/**" pattern component. - -When the "git lfs checkout" command internally converts its file path -pattern arguments into file path patterns that are relative to the root -of the repository, rather being relative to the current working directory, -it appends a trailing "/" character if the pattern is actually just the -path to a directory (or a symbolic link to a directory). This change is -implemented in the Convert() method of the currentToRepoPatternConverter -structure in our "lfs" package, and was added in commit -56abb7122a7aa62408d0a14e23907d88b431abdf of PR #4556 so as to ensure -that arguments which resolve to specific directories are properly -converted into patterns that match both the directories and all of -their contents. - -Note, though, when we run the "git lfs checkout" command with a "." -directory name argument while in a subdirectory of the working tree, -the internal addition of a trailing "/" character does not actually -affect the success of the command. This is because the Convert() -of the currentToRepoPatternConverter structure first creates a path -relative to the root of the repository, which necessarily includes -the subdirectory's name as its final component. When used as a -path pattern according to the gitignore(5) rules, then even without -a trailing "/" character the pattern will still match the subdirectory -and therefore its contents. - -On the other hand, the internal additional of a trailing "/" character -does affect the success of the "git lfs checkout" command when we -run the command in a subdirectory and provide a ".." argument. In this -case, the Convert() method first converts the path into one relative to -the root of the repository, which is simply the "." directory name, the -same as if the user had supplied a "." argument to a "git lfs checkout" -command run in the root of the working tree. - -Although "." is a valid file path, when treated as a pattern according -to the gitignore(5) rules, it does not work to match a directory and -all of its contents. To allow users to provide simple a "." argument -and have it work as expected, the Convert() method first appends the -"/" character, and then strips any leading "./" path component. If the -result is an empty path, it is replaced with the "**" pattern component -so the final pattern matches everything in the root of the repository. - -Thus our new check in the initial "checkout" test of the t/t-checkout.sh -script where we run the "git lfs checkout" command in a subdirectory and -pass a ".." argument, we validate the behaviour described above. The -check would fail unless the command converted the ".." argument into -a "." path relative to the root of the repository, and into into a -"./" path, and then into the "**" pattern component. - -In parallel with our changes to the t/t-checkout.sh test script, we -make a number of changes to the initial "pull" test of the t/t-pull.sh -test script to ensure that test performs all its checks under a common -set of conditions. We will further enlarge this set of conditions in -subsequent commits. - -First, we correct a typo in the refute_server_object() assertion call -that was added to the "pull" test in commit -7158e3bb633176a39743f0efff8e53abc100b6f5 of PR #2641. This commit -revised the "git lfs pull" and "git lfs checkout" commands so that the -paths they passed to the "git update-index" command were relative to -the current working directory. To confirm this new behaviour worked -as expected, the "pull" test was updated so it creates a third test -file named "dir/dir.dat", and some of the checks in the test were then -revised to reference this new file. However, the refute_server_object() -assertion which is intended to prove that the corresponding Git LFS -object does not exist on the test remote server passes an incorrect -variable name, so we fix that now. - -Because the "dir/dir.dat" file and corresponding local Git LFS object -file are not comprehensively referenced in all the checks of the -"git lfs pull" command, we add the appropriate assertions for it into -those checks. (As well, we revise several invocations of the rm(1) -command to align with others in the same test and elsewhere.) - -The same commit 7158e3bb633176a39743f0efff8e53abc100b6f5 also extended -the "pull" test to run the "git lfs pull" command with its -I option -under several conditions, but without removing the local Git LFS object -files and working tree files, or checking that they have been re-created -after the "git lfs pull" command is run. As a consequence, these checks -effectively only confirm that the command does not fail. - -To rectify this limitation we delete all the relevant local files -before each of these checks, and then verify that they have been -restored by the "git lfs pull" command. We also run the "git lfs pull" -command with a more restricted set of file path patterns to confirm that -files which do not match those patterns are not fetched from the remote -server; we then run the command again to fetch those files. Previously -our checks did not actually test the file path filtering capability of -the command. - -As well, we use a file path pattern which is relative to the root of -the repository, so that when we run the "git lfs pull" command in a -subdirectory of the current working tree and pass this pattern, our test -now ascertains that patterns specified with the -I option are correctly -matched with file paths starting from the root of the repository. -(For the moment we do not check that the "git lfs pull" command accepts -"glob" pattern components such as "**", but in a subsequent commit we -will add patterns of this type to the arguments we pass to the -I option.) - -Making these changes, however, exposes another concern which would cause -the "pull: with missing object" test to fail unless we adjust it as well. -This test appears later in the same t/t-pull.sh test script, and depends -on the repository clone created by the "pull" test in the local "clone" -test directory. (Ideally, our tests should not depend on each other in -this way, so that the failure of one test doesn't cause a series of -otherwise unrelated tests to also fail. For the moment, though, we -defer this issue to a future PR.) - -The "pull: with missing object" test was introduced in commit -68ac0f52f74c9a83e97c03a00fc0f45a6fc4a96b of PR #2237 and checks that if -a Git LFS object is missing both locally and on the remote server, the -"git lfs pull" command should report the missing object and return a -non-zero exit code, but should retrieve all other requested Git LFS -objects. To simulate this condition, the test removes the Git LFS object -corresponding to the "a.dat" test file in the local storage directories -and from the remote test server, and then runs the "git lfs pull" -command. - -However, the test does not remove the "a.dat" file from the working -tree, and as noted above, is dependent on the state of the cloned -repository left by the initial "pull" test. These two factors combine -to make the test vulnerable to transient failures, particularly now -that we have revised the "pull" test such that the final "git lfs pull" -commands in that test re-create all the files in the working tree, -except that we have also changed the "pull" test to drop the commit -which adds an empty file, as we explain below. - -In commit d97c785f7926777eafab70a1cbf872be9d9bf451 of PR #4654 the -"pull" test was revised to commit an empty file which matches the -Git LFS file patterns, and then confirm that the "git lfs pull" command -handles that case without trying to fetch the object. This check -was added before the final checks of the "git lfs pull" command, which -occur within a subdirectory of the working tree. As a result, those -checks used to run with the empty file as one of the Git LFS objects, -and so did the "pull: with missing object" test. - -In these instances, when "git lfs pull" runs, it finds the empty file -matches the defined Git LFS file patterns, and is considered to exist -locally by the LFSObjectExists() method of the Configuration structure -in our "config" package. Therefore the command passes the object's -pointer data to the Run() method of the singleCheckout structure, -which invokes the DecodePointerFromFile() function in our "lfs" package. -That function returns a successful result because it detects that the -pointer refers to a zero-byte object, and so the RunToPath() method -of the singleCheckout structure is executed, and then the file's path -is passed to the Add() method of the gitIndexer structure. That -method starts a "git update-index" command and writes the file's path -to the Git command on its standard input file descriptor. - -We start the "git update-index" command with the --refresh option, so -it reviews all of its cached index entries, including the one for the -"a.dat" file. If that file in the working tree has the same modification -timestamp as the Git index file (at least to within the same second, -assuming Git was not compiled with the USE_NSEC macro definition) -then Git considers the file to potentially be "racily clean", because -it's file size and modification timestamp are insufficient to determine -whether the file has changed since the index entry was cached. - -The "git update-index" command therefore reads the contents of the -"a.dat" file in the working tree. Because the file's path matches a Git -LFS file pattern, the data is streamed to the "git-lfs-filter-process" -command, which regenerates a new object file in the local storage -directories under ".git/lfs/objects". - -Unfortunately, the "pull: with missing object" test starts by removing -the local and remote copies of the "a.dat" file's Git LFS object file, -and expects that running the "git lfs pull" command will not result -in a new local copy being generated, as none can be fetched from the -remote test server. But if the "pull" test leaves the "a.dat" file -in the working tree with an identical timestamp as the Git index file, -and has also committed the "empty.dat" file to the Git history, -then as described above, the "git lfs pull" invokes "git update-index" -with the --refresh option, which finds the "a.dat" file and re-creates -the local Git LFS object file, and so the test will fail. - -Note that without the presence of an empty file in the commit history, -the "git lfs pull" command does not find any local Git LFS object files, -and so it calls the Run() method of the singleCheckout structure after -fetching the available objects from the remote server. The Run() method -then executes the DecodePointerFromFile() function, which returns an -error because the file in the working tree is not a raw Git LFS pointer, -and so the Run() method skips calling the RunToPath() method and -also does not pass the file's path to the "git update-index" command. -In fact, the "git update-index" command will not be started at all, -as there are no files in the working tree which could be considered to -contain raw Git LFS pointer data, like the empty file is. Since the -"git update-index" command does not run, the "git-lfs-filter-process" -command is also never invoked, and so a local Git LFS object file is -never re-created for the "a.dat" file, and the test succeeds. - -For this reason, we change the "pull" test so that it removes the -"empty.dat" file from the Git commit history after performing the -appropriate check of the "git lfs pull" command's behaviour with -regard to empty files. - -To further guard against a potential regression of our tests and -ensure the "pull: with missing object" test passes, we update that -test to remove the "a.dat" file from the working tree as well -as removing its Git LFS object files from both local and remote -storage. Without the "a.dat" file in the working tree, it now becomes -impossible for the local object file to be re-created under any -circumstances. - -Finally, we strengthen our check of the log message output by the -"git lfs pull" command so that we require a specific "does not exist" -message rather than just the raw OID of the missing Git LFS object. ---- - t/t-checkout.sh | 27 ++++++++++++++++++++++ - t/t-pull.sh | 61 +++++++++++++++++++++++++++++++++++++++++++++---- - 2 files changed, 83 insertions(+), 5 deletions(-) - -diff --git a/t/t-checkout.sh b/t/t-checkout.sh -index 72afb17c..da139173 100755 ---- a/t/t-checkout.sh -+++ b/t/t-checkout.sh -@@ -78,20 +78,47 @@ begin_test "checkout" - [ ! -f folder2/nested.dat ] - - echo "test subdir context" -+ rm file1.dat - pushd folder1 - git lfs checkout nested.dat - [ "$contents" = "$(cat nested.dat)" ] -+ [ ! -f ../file1.dat ] - [ ! -f ../folder2/nested.dat ] -+ - # test '.' in current dir - rm nested.dat - git lfs checkout . 2>&1 | tee checkout.log - [ "$contents" = "$(cat nested.dat)" ] -+ [ ! -f ../file1.dat ] -+ [ ! -f ../folder2/nested.dat ] -+ -+ # test '..' in current dir -+ git lfs checkout .. -+ [ "$contents" = "$(cat ../file1.dat)" ] -+ [ "$contents" = "$(cat ../folder2/nested.dat)" ] -+ -+ # test glob match with '..' in current dir -+ rm -rf ../folder2 -+ git lfs checkout '../folder2/**' -+ [ "$contents" = "$(cat ../folder2/nested.dat)" ] - popd - - echo "test folder param" -+ rm -rf folder2 - git lfs checkout folder2 - [ "$contents" = "$(cat folder2/nested.dat)" ] - -+ echo "test folder param with pre-existing directory" -+ rm -rf folder2 -+ mkdir folder2 -+ git lfs checkout folder2 -+ [ "$contents" = "$(cat folder2/nested.dat)" ] -+ -+ echo "test folder param with glob match" -+ rm -rf folder2 -+ git lfs checkout 'folder2/**' -+ [ "$contents" = "$(cat folder2/nested.dat)" ] -+ - echo "test '.' in current dir" - rm -rf file1.dat file2.dat file3.dat folder1/nested.dat folder2/nested.dat - git lfs checkout . -diff --git a/t/t-pull.sh b/t/t-pull.sh -index 8fdd4ee4..b6c828d6 100755 ---- a/t/t-pull.sh -+++ b/t/t-pull.sh -@@ -44,7 +44,7 @@ begin_test "pull" - - refute_server_object "$reponame" "$contents_oid" - refute_server_object "$reponame" "$contents2_oid" -- refute_server_object "$reponame" "$contents33oid" -+ refute_server_object "$reponame" "$contents3_oid" - - echo "initial push" - git push origin main 2>&1 | tee push.log -@@ -65,34 +65,40 @@ begin_test "pull" - - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] -+ [ "dir" = "$(cat "dir/dir.dat")" ] - - assert_local_object "$contents_oid" 1 - assert_local_object "$contents2_oid" 1 -+ assert_local_object "$contents3_oid" 3 - assert_clean_status - - echo "lfs pull" -- rm -r a.dat á.dat dir # removing files makes the status dirty -+ rm -rf a.dat á.dat dir # removing files makes the status dirty - rm -rf .git/lfs/objects - git lfs pull - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] -+ [ "dir" = "$(cat "dir/dir.dat")" ] - assert_local_object "$contents_oid" 1 - assert_local_object "$contents2_oid" 1 -+ assert_local_object "$contents3_oid" 3 - git lfs fsck - - echo "lfs pull with remote" -- rm -r a.dat á.dat dir -+ rm -rf a.dat á.dat dir - rm -rf .git/lfs/objects - git lfs pull origin - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] -+ [ "dir" = "$(cat "dir/dir.dat")" ] - assert_local_object "$contents_oid" 1 - assert_local_object "$contents2_oid" 1 -+ assert_local_object "$contents3_oid" 3 - assert_clean_status - git lfs fsck - - echo "lfs pull with local storage" -- rm a.dat á.dat -+ rm -rf a.dat á.dat dir - git lfs pull - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] -@@ -130,10 +136,28 @@ begin_test "pull" - - echo "lfs pull clean status" - git lfs pull -+ [ "a" = "$(cat a.dat)" ] -+ [ "A" = "$(cat "á.dat")" ] -+ [ "dir" = "$(cat "dir/dir.dat")" ] -+ assert_local_object "$contents_oid" 1 -+ assert_local_object "$contents2_oid" 1 -+ assert_local_object "$contents3_oid" 3 - assert_clean_status - - echo "lfs pull with -I" -+ rm -rf .git/lfs/objects -+ rm -rf a.dat "á.dat" "dir/dir.dat" -+ git lfs pull -I "a.*,dir/dir.*" -+ [ "a" = "$(cat a.dat)" ] -+ [ ! -e "á.dat" ] -+ [ "dir" = "$(cat "dir/dir.dat")" ] -+ assert_local_object "$contents_oid" 1 -+ refute_local_object "$contents2_oid" -+ assert_local_object "$contents3_oid" 3 -+ - git lfs pull -I "*.dat" -+ [ "A" = "$(cat "á.dat")" ] -+ assert_local_object "$contents2_oid" 1 - assert_clean_status - - echo "lfs pull with empty file" -@@ -144,16 +168,42 @@ begin_test "pull" - [ -z "$(cat empty.dat)" ] - assert_clean_status - -+ echo "resetting to test status" -+ git reset --hard HEAD^ -+ assert_clean_status -+ - echo "lfs pull in subdir" -+ rm -rf .git/lfs/objects -+ rm -rf a.dat "á.dat" "dir/dir.dat" - pushd dir - git lfs pull - popd -+ [ "a" = "$(cat a.dat)" ] -+ [ "A" = "$(cat "á.dat")" ] -+ [ "dir" = "$(cat "dir/dir.dat")" ] -+ assert_local_object "$contents_oid" 1 -+ assert_local_object "$contents2_oid" 1 -+ assert_local_object "$contents3_oid" 3 - assert_clean_status - - echo "lfs pull in subdir with -I" -+ rm -rf .git/lfs/objects -+ rm -rf a.dat "á.dat" "dir/dir.dat" -+ pushd dir -+ git lfs pull -I "á.*,dir/dir.dat" -+ popd -+ [ ! -e a.dat ] -+ [ "A" = "$(cat "á.dat")" ] -+ [ "dir" = "$(cat "dir/dir.dat")" ] -+ refute_local_object "$contents_oid" -+ assert_local_object "$contents2_oid" 1 -+ assert_local_object "$contents3_oid" 3 -+ - pushd dir - git lfs pull -I "*.dat" - popd -+ [ "a" = "$(cat a.dat)" ] -+ assert_local_object "$contents_oid" 1 - assert_clean_status - ) - end_test -@@ -330,6 +380,7 @@ begin_test "pull: with missing object" - # this clone is setup in the first test in this file - cd clone - rm -rf .git/lfs/objects -+ rm a.dat - - contents_oid=$(calc_oid "a") - reponame="$(basename "$0" ".sh")" -@@ -343,7 +394,7 @@ begin_test "pull: with missing object" - pull_exit="${PIPESTATUS[0]}" - [ "$pull_exit" != "0" ] - -- grep "$contents_oid" pull.log -+ grep "$contents_oid does not exist" pull.log - - contents2_oid=$(calc_oid "A") - assert_local_object "$contents2_oid" 1 --- -2.51.1 - - -From 500aff134ccee0ab0f94ff0db3b7b56e59af5201 Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Sat, 19 Jul 2025 17:51:21 -0700 -Subject: [PATCH 03/21] t: test index is updated on checkout and pull - -After our "git lfs checkout" and "git lfs pull" commands write the -contents of a Git LFS object into a file in the current working tree, -they pass the file's relative path to the "git update-index" command, -so the entries in Git's index for these files reflect their current -state. - -If we did not run the "git update-index" command, Git commands like -"git diff-index" would report the files we updated as having been -modified in the working tree. - -Notably, though, the "git status" command would normally not report the -files as modified, because it refreshes the Git index in the same way as -the "git update-index" command we run. We invoke the "git update-index" -command with the --refresh option, so it runs the refresh_index() -function, which is also executed by the "git status" command. - -The refresh_index() function checks the state of all the cached entries -in Git's index, and so it will detect that the files in the working -tree have been modified, read their content and pass it to the -"git lfs filter-process" command, which returns the same Git LFS -pointer data as should already be in the Git index. The end result -is that the index entries remain unchanged, but the cached modification -timestamps are updated. - -As a consequence, even if we never invoked the "git update-index" -command while running our "git lfs checkout" and "git lfs pull" commands, -a subsequent "git status" command would typically not show the working -tree files we update as modified. - -However, in our tests of the "git lfs pull" command, we rely exclusively -on the "git status" command to try to verify that the "git update-index" -command has been executed by the "git lfs pull" command. Moreover, the -assert_clean_status() test helper function we invoke to run the -"git status" command has a bug which means it always succeeds, even -if the "git status" command were to report that the current working -tree was not clean. - -We added the assert_clean_status() assertion function in commit -7158e3bb633176a39743f0efff8e53abc100b6f5 of PR #2641, along with -calls to that function in the initial "pull" test of what is now -our t/t-pull.sh test script. In the same commit we also revised the -"git lfs checkout" and "git lfs pull" commands so that they pass paths -relative to the current working directory to the "git update-index" -command, rather than paths relative to the root of the repository. - -This latter change resolved the problem reported in #2639, because -by passing file paths relative to the repository root instead of the -current working directory to the "git update-index" command, the -"git lfs checkout" and "git lfs pull" commands were leaving the Git -index and working tree in a confusing state. - -At the time when #2639 was reported, running "git lfs pull" in a -subdirectory might result in a subsequent "git status" command finding -modifications to both the Git index and the working tree. To check -that the problem was resolved, in PR #2641 we introduced the -assert_clean_status() test helper function, and expanded the "pull" -test to call this function after performing several "git lfs pull" -commands in subdirectories of the working tree. - -Unfortunately, the assert_clean_status() function does not behave as -an assertion, because it always returns a successful (i.e., zero) exit -code. The function tests whether the output from a "git status" command -includes the message "working tree clean", and if not, reports the -command's output along with that from a "git lfs status" command. -However, the function does not cause the current test's sub-shell to -stop execution and return a non-zero exit code to signal a test -failure condition. - -We address this problem now by adding a call to the "exit" shell built-in -command with a non-zero exit code at the end of the brace-delimited -group command which executes if the assert_clean_status() function -does not find the "working tree clean" message in the output from the -"git status" command. This change ensures that if Git determines that -the working tree is not clean, our assert_clean_status() function will -cause the test which called it to fail. - -As well, we update the assert_clean_status() function so that when -checking for the "working tree clean" message in the output of the -"git status" command, it will also accept the older "working directory -clean" message output by the "git status" command prior to Git v2.10.0. -This allows our assertion function to succeed and the "pull" test to pass -in our GitHub Actions CI jobs that run our test suite against Git v2.0.0, -the oldest Git version we currently support. - -As a further problem, though, checking that a "git status" command -finds a clean working tree is no longer sufficient to guarantee that -the "git lfs pull" command has actually called the "git update-index" -command with the appropriate file paths. To be certain that the Git LFS -client has invoked the "git update-index" command and passed valid file -paths, we also need to check the output of the "git diff-index" command, -and do so before we run the "git status" command, which will cause Git -to reset the file modification timestamps it has cached. - -We therefore define a new assert_clean_worktree() test helper function -which runs a "git diff-index HEAD" command and confirms no unexpected -changes are detected in the working tree, and we call this function at -the start of the assert_clean_status() function before the "git status" -command is executed. This ensures that we check the state of the working -tree before the "git status" command refreshes the file modification -timestamps Git has previously cached. As noted above, if we reversed -the order of these checks, the "git diff-index" command would always -return an empty list and so our check of the working tree's state would -be defeated. - -We also define a new assert_clean_worktree_with_exceptions() test helper -function, which acts like the assert_clean_worktree() function but filters -the output of the "git diff-index HEAD" command with an extended regular -expression pattern provided to the function as its only argument. This -allows us to use the assert_clean_worktree_with_exceptions() function in -instances where we expect certain files in the working tree to be absent -or modified and so we want to ignore them when they appear in the -"git diff-index" command's output. - -We then update the initial "pull" test in the t/t-pull.sh test script -so that it calls either the assert_clean_status() function or the -assert_clean_worktree_with_exceptions() function after each invocation -of the "git lfs pull" command, thereby making the checks in this test -as thorough and consistent with each other as possible. - -Finally, we update the initial "checkout" test in the t/t-checkout.sh -test script so that it makes use of the assert_clean_status() and -assert_clean_worktree_with_exceptions() functions in the same manner -as the "pull" test in the t/t-pull.sh script now does. Both tests -perform similar operations, and like the "git lfs pull" command, the -"git lfs checkout" command also invokes the "git update-index" command. -However, the "checkout" test was not updated in PR #2641 when the -assert_clean_status() was introduced and the "pull" test was updated -to call it. - -Since we want our "checkout" test to verify that the "git lfs checkout" -command successfully runs the "git update-index" command, we update the -test so that it mirrors the "pull" test and consistently uses the -assert_clean_worktree() and assert_clean_worktree_with_exceptions() -functions to check the state of the working tree. - -To ensure that the "checkout" test still passes, though, we also need to -adjust the test so that it removes any log files it creates because the -assert_clean_status() function now behaves as was originally intended and -will cause the calling test to fail if the working tree is not clean. ---- - t/t-checkout.sh | 18 +++++++++++++++++- - t/t-pull.sh | 3 +++ - t/testhelpers.sh | 15 ++++++++++++++- - 3 files changed, 34 insertions(+), 2 deletions(-) - -diff --git a/t/t-checkout.sh b/t/t-checkout.sh -index da139173..692fd3e4 100755 ---- a/t/t-checkout.sh -+++ b/t/t-checkout.sh -@@ -10,9 +10,11 @@ begin_test "checkout" - setup_remote_repo "$reponame" - - clone_repo "$reponame" repo -+ rm -f clone.log - - git lfs track "*.dat" 2>&1 | tee track.log - grep "Tracking \"\*.dat\"" track.log -+ rm -f track.log - - contents="something something" - contentsize=19 -@@ -50,12 +52,15 @@ begin_test "checkout" - grep "Checking out LFS objects: 100% (5/5), 95 B" checkout.log - grep 'accepting "file1.dat"' checkout.log - grep 'rejecting "file1.dat"' checkout.log && exit 1 -+ rm -f checkout.log -+ assert_clean_status - - git rm file1.dat - - echo "checkout should skip replacing files deleted in index" - git lfs checkout - [ ! -f file1.dat ] -+ assert_clean_worktree_with_exceptions "file1\.dat" - - git reset --hard - -@@ -69,6 +74,7 @@ begin_test "checkout" - [ ! -f file3.dat ] - [ ! -f folder1/nested.dat ] - [ ! -f folder2/nested.dat ] -+ assert_clean_worktree_with_exceptions "(file[13]|nested)\.dat" - - echo "quotes to avoid shell globbing" - git lfs checkout "file*.dat" -@@ -76,6 +82,7 @@ begin_test "checkout" - [ "$contents" = "$(cat file3.dat)" ] - [ ! -f folder1/nested.dat ] - [ ! -f folder2/nested.dat ] -+ assert_clean_worktree_with_exceptions "nested\.dat" - - echo "test subdir context" - rm file1.dat -@@ -84,40 +91,47 @@ begin_test "checkout" - [ "$contents" = "$(cat nested.dat)" ] - [ ! -f ../file1.dat ] - [ ! -f ../folder2/nested.dat ] -+ assert_clean_worktree_with_exceptions "(file1|folder2/nested)\.dat" - - # test '.' in current dir - rm nested.dat -- git lfs checkout . 2>&1 | tee checkout.log -+ git lfs checkout . - [ "$contents" = "$(cat nested.dat)" ] - [ ! -f ../file1.dat ] - [ ! -f ../folder2/nested.dat ] -+ assert_clean_worktree_with_exceptions "(file1|folder2/nested)\.dat" - - # test '..' in current dir - git lfs checkout .. - [ "$contents" = "$(cat ../file1.dat)" ] - [ "$contents" = "$(cat ../folder2/nested.dat)" ] -+ assert_clean_status - - # test glob match with '..' in current dir - rm -rf ../folder2 - git lfs checkout '../folder2/**' - [ "$contents" = "$(cat ../folder2/nested.dat)" ] -+ assert_clean_status - popd - - echo "test folder param" - rm -rf folder2 - git lfs checkout folder2 - [ "$contents" = "$(cat folder2/nested.dat)" ] -+ assert_clean_status - - echo "test folder param with pre-existing directory" - rm -rf folder2 - mkdir folder2 - git lfs checkout folder2 - [ "$contents" = "$(cat folder2/nested.dat)" ] -+ assert_clean_status - - echo "test folder param with glob match" - rm -rf folder2 - git lfs checkout 'folder2/**' - [ "$contents" = "$(cat folder2/nested.dat)" ] -+ assert_clean_status - - echo "test '.' in current dir" - rm -rf file1.dat file2.dat file3.dat folder1/nested.dat folder2/nested.dat -@@ -127,6 +141,7 @@ begin_test "checkout" - [ "$contents" = "$(cat file3.dat)" ] - [ "$contents" = "$(cat folder1/nested.dat)" ] - [ "$contents" = "$(cat folder2/nested.dat)" ] -+ assert_clean_status - - echo "test checkout with missing data doesn't fail" - git push origin main -@@ -138,6 +153,7 @@ begin_test "checkout" - [ "$(pointer $contents_oid $contentsize)" = "$(cat file3.dat)" ] - [ "$contents" = "$(cat folder1/nested.dat)" ] - [ "$contents" = "$(cat folder2/nested.dat)" ] -+ assert_clean_worktree_with_exceptions "file[123]\.dat" - ) - end_test - -diff --git a/t/t-pull.sh b/t/t-pull.sh -index b6c828d6..4090b7a0 100755 ---- a/t/t-pull.sh -+++ b/t/t-pull.sh -@@ -82,6 +82,7 @@ begin_test "pull" - assert_local_object "$contents_oid" 1 - assert_local_object "$contents2_oid" 1 - assert_local_object "$contents3_oid" 3 -+ assert_clean_status - git lfs fsck - - echo "lfs pull with remote" -@@ -154,6 +155,7 @@ begin_test "pull" - assert_local_object "$contents_oid" 1 - refute_local_object "$contents2_oid" - assert_local_object "$contents3_oid" 3 -+ assert_clean_worktree_with_exceptions '\\303\\241\.dat' - - git lfs pull -I "*.dat" - [ "A" = "$(cat "á.dat")" ] -@@ -198,6 +200,7 @@ begin_test "pull" - refute_local_object "$contents_oid" - assert_local_object "$contents2_oid" 1 - assert_local_object "$contents3_oid" 3 -+ assert_clean_worktree_with_exceptions "a\.dat" - - pushd dir - git lfs pull -I "*.dat" -diff --git a/t/testhelpers.sh b/t/testhelpers.sh -index df7bebdf..d7808e9a 100644 ---- a/t/testhelpers.sh -+++ b/t/testhelpers.sh -@@ -341,11 +341,24 @@ assert_hooks() { - [ -x "$git_root/hooks/pre-push" ] - } - -+assert_clean_worktree() { -+ [ -z "$(git diff-index HEAD)" ] -+} -+ -+assert_clean_worktree_with_exceptions() { -+ local exceptions="$1" -+ -+ [ -z "$(git diff-index HEAD | grep -v -E "$exceptions")" ] -+} -+ - assert_clean_status() { -+ assert_clean_worktree -+ - status="$(git status)" -- echo "$status" | grep "working tree clean" || { -+ echo "$status" | grep "working \(directory\|tree\) clean" || { - echo $status - git lfs status -+ exit 1 - } - } - --- -2.51.1 - - -From 5ca9378442272aeb8e6ff6809d7474fd7c2359a4 Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Mon, 12 May 2025 15:33:18 -0700 -Subject: [PATCH 04/21] t/t-checkout.sh: test exit code from checkout - -In commit 8e9fbf4e948c1992a456eadd44dcb45d54281a3b of PR #2626 we -updated several commands, including "git lfs checkout" and "git lfs pull", -to report an error if the Git LFS "clean" filter is not installed, and -we added tests to validate this new behaviour in the same commit. - -Two of these tests, the "clone (without clean filter)" and "pull without -clean filter" tests in our t/t-clone.sh and t/t-pull.sh test scripts, -respectively, perform the appropriate Git LFS command and log its output -to a file. They next confirm that the command's exit code was zero -by checking the first element of the PIPESTATUS shell array variable. -Next, they run a grep(1) command to confirm that the string "Git LFS is -not installed" appears the output captured from Git LFS command. - -However, the third of these tests, the "checkout: without clean filter" -test in our t/t-checkout.sh test script, reverses the order of its two -checks, which means that the value in the PIPESTATUS shell array variable -is only the exit code from the grep(1) command, and not the exit code -from the preceding "git lfs checkout" command. Since we expect the -"git lfs checkout" command to return a zero exit code (despite the -warning message), the test inadvertently passes because the grep(2) -command also runs successfully and returns zero. - -We therefore move the grep(2) command in the "checkout: without clean -filter" test to follow the check of the PIPESTATUS array variable, -matching the design of the other two related tests. ---- - t/t-checkout.sh | 2 +- - 1 file changed, 1 insertion(+), 1 deletion(-) - -diff --git a/t/t-checkout.sh b/t/t-checkout.sh -index 692fd3e4..e050456b 100755 ---- a/t/t-checkout.sh -+++ b/t/t-checkout.sh -@@ -178,11 +178,11 @@ begin_test "checkout: without clean filter" - ls -al - - git lfs checkout | tee checkout.txt -- grep "Git LFS is not installed" checkout.txt - if [ "0" -ne "${PIPESTATUS[0]}" ]; then - echo >&2 "fatal: expected checkout to succeed ..." - exit 1 - fi -+ grep "Git LFS is not installed" checkout.txt - - contentsize=19 - contents_oid=$(calc_oid "something something") --- -2.51.1 - - -From f0217cb817218899ed69578d4d0f4fd44a7c4919 Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Mon, 12 May 2025 16:01:59 -0700 -Subject: [PATCH 05/21] t: test checkout --to option with external paths - -In commit cf7f9678b3d2929425d0671c099c2ef5621c0929 of PR #3296 we -introduced support for the --to, --ours, --theirs, and --base options in -the "git lfs checkout" command, and added a "checkout: conflicts" test -to our t/t-checkout.sh test script to validate the behaviour of these -new options. - -This test checks that when the --to option is provided along with one -of the other options, the appropriate patch diff output is written to -the file specified with the --to option. - -However, at present, we only perform these checks using local file -names, although our git-lfs-checkout(1) manual page states that a file -external to the working tree may be specified with the --to option. - -We therefore revise our test to ensure that we run the "git lfs checkout" -command with --to option arguments specifying files outside of the -working tree, in one case using a relative path and in two other cases -an absolute path. With the absolute path check we also confirm that -the command will create any directories in the path that do not exist, -as well as traverse any symbolic links to directories so long as the -directories exist. (Note that if the filename component of the path -is a link to a directory, an error will occur when the Git LFS client -attempts to open it for writing, so we do not test this case.) - -We also perform these checks again after changing the current working -directory to a subdirectory of the work tree, this time using relative -paths with ".." path components to specify the file in the repository -for which a patch diff should be generated. By performing these checks -we verify that the "git lfs checkout" command supports relative paths -from a current working directory which is not the root of the work tree. -In a subsequent commit we will update the "git lfs checkout" command -so that it changes the current working directory before generating any -patch diff output, at which time these additional checks will help -demonstrate that our changes still support the use of paths relative -to the working directory in which the user originally runs the command. - -On Windows, true symbolic link support is not enabled by default and -not supported on all filesystems or by all versions of Windows. We -therefore only test the "git lfs checkout" command with a path for -the --to option which traverses a symbolic link if we can determine -that symbolic links can actually be created on the current Windows -system. To do this we introdce a new has_native_symlinks() test helper -function, which returns a successful exit code only if the current -system supports the creation of symbolic link. We expect to make -additional use of this helper function in subsequent commits. - -On Unix systems, our has_native_symlinks() always returns a successful -(i.e., zero) exit code. On Windows it first tries to enable native -symbolic link support in the Cygwin or MSYS2 environments, and then -returns a successful exit code only if a test symbolic link is actually -created by the ln(2) command. This Unix command is emulated in the -MSYS2 and Cygwin environments, which are in turn used by the Git Bash -environment in which we run our test suite on Windows. To check whether -a true Windows symbolic link has been created, we check the results of -a query made with the Windows "fsutil reparsepoint" command. See, -for reference: - - https://cygwin.com/cygwin-ug-net/using.html#pathnames-symlinks - https://www.msys2.org/docs/symlinks/ - https://learn.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development - https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/fsutil-reparsepoint - -Fortunately, the GitHub Actions Windows runners we use to run our -CI test suite have Developer Mode enabled, and so true symbolic links -may be created on these systems. - -Finally, we adjust the order in which we check the contents of the -files output by the "git lfs checkout" commands so as to match the -order in which we run those commands. ---- - t/t-checkout.sh | 38 +++++++++++++++++++++++++++++++++----- - t/testhelpers.sh | 35 +++++++++++++++++++++++++++++++++++ - 2 files changed, 68 insertions(+), 5 deletions(-) - -diff --git a/t/t-checkout.sh b/t/t-checkout.sh -index e050456b..b65f71b4 100755 ---- a/t/t-checkout.sh -+++ b/t/t-checkout.sh -@@ -284,13 +284,42 @@ begin_test "checkout: conflicts" - # This will cause a conflict. - git merge first && exit 1 - -+ abs_assert_dir="$(canonical_path "$TRASHDIR/${reponame}-assert")" -+ abs_theirs_file="$abs_assert_dir/dir1/dir2/theirs.txt" -+ -+ rm -rf "$abs_assert_dir" -+ - git lfs checkout --to base.txt --base file1.dat -- git lfs checkout --to ours.txt --ours file1.dat -- git lfs checkout --to theirs.txt --theirs file1.dat -+ git lfs checkout --to ../ours.txt --ours file1.dat -+ git lfs checkout --to "$abs_theirs_file" --theirs file1.dat - - echo "file1.dat" | cmp - base.txt -- echo "abc123" | cmp - theirs.txt -- echo "def456" | cmp - ours.txt -+ echo "def456" | cmp - ../ours.txt -+ echo "abc123" | cmp - "$abs_theirs_file" -+ -+ rm -rf base.txt ../ours.txt "$abs_assert_dir" -+ mkdir -p dir1/dir2 -+ -+ pushd dir1/dir2 -+ git lfs checkout --to base.txt --base ../../file1.dat -+ git lfs checkout --to ../../../ours.txt --ours ../../file1.dat -+ git lfs checkout --to "$abs_theirs_file" --theirs ../../file1.dat -+ popd -+ -+ echo "file1.dat" | cmp - dir1/dir2/base.txt -+ echo "def456" | cmp - ../ours.txt -+ echo "abc123" | cmp - "$abs_theirs_file" -+ -+ has_native_symlinks && { -+ rm -rf "$abs_assert_dir" -+ mkdir -p "$abs_assert_dir/link1" -+ ln -s link1 "$abs_assert_dir/dir1" -+ -+ git lfs checkout --to "$abs_theirs_file" --theirs file1.dat -+ -+ [ -L "$abs_assert_dir/dir1" ] -+ echo "abc123" | cmp - "$abs_assert_dir/link1/dir2/theirs.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 -@@ -298,7 +327,6 @@ begin_test "checkout: conflicts" - ) - end_test - -- - begin_test "checkout: GIT_WORK_TREE" - ( - set -e -diff --git a/t/testhelpers.sh b/t/testhelpers.sh -index d7808e9a..5bee4187 100644 ---- a/t/testhelpers.sh -+++ b/t/testhelpers.sh -@@ -860,6 +860,41 @@ has_test_dir() { - fi - } - -+has_native_symlinks() { -+ if [ -z "$NATIVE_SYMLINKS" ]; then -+ if [ "$IS_WINDOWS" -eq 1 ]; then -+ # On Windows, we need to enable native symlink support in Cygwin or MSYS2, -+ # without falling back to default Cygwin symlink emulation. If this mode -+ # is not available, we should skip our tests with symbolic links. -+ # -+ # https://cygwin.com/cygwin-ug-net/using.html#pathnames-symlinks -+ # https://www.msys2.org/docs/symlinks/ -+ # https://learn.microsoft.com/en-us/windows/apps/get-started/enable-your-device-for-development -+ export CYGWIN="winsymlinks:nativestrict${CYGWIN:+ $CYGWIN}" -+ export MSYS="winsymlinks:nativestrict${MSYS:+ $MSYS}" -+ -+ touch testfile.tmp -+ ln -s testfile.tmp testlink.tmp -+ -+ if [ $(fsutil reparsepoint query testlink.tmp | grep -c "Tag value: Symbolic Link") -eq 0 ]; then -+ NATIVE_SYMLINKS=0 -+ else -+ NATIVE_SYMLINKS=1 -+ fi -+ -+ rm -f testfile.tmp testlink.tmp -+ else -+ NATIVE_SYMLINKS=1 -+ fi -+ fi -+ -+ if [ "$NATIVE_SYMLINKS" -ne 1 ]; then -+ return 1 -+ else -+ return 0 -+ fi -+} -+ - add_symlink() { - local src=$1 - local dest=$2 --- -2.51.1 - - -From e70a36caf8b5f72f59f36f4984cbd4978198bf3b Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Tue, 5 Aug 2025 20:32:55 -0700 -Subject: [PATCH 06/21] t: add shell tests with pointer extension program - -In PR #486 we introduced support for Git LFS pointer extensions, along -with some related tests in our Go test suite and the t/t-ext.sh shell -test script, which validates that pointer extensions are reported as -expected by the "git lfs ext" command. (Note that Git LFS pointer -extension support is still technically considered experimental, -according to our documentation.) - -However, we do not have any tests which exercise a complete Git LFS -pointer extension configuration including an extension program which -transforms the content of files tracked as Git LFS objects. - -We therefore add a new lfstest-caseinverterextension test utility -program and make use of it in a set of shell tests of our "git lfs clean", -"git lfs smudge", and "git lfs filter-process" commands, as well as -in tests of our "git lfs checkout" and "git lfs pull" commands. All -of these commands execute the lfstest-caseinverterextension utility -and confirm that it runs as expected. - -Note that at present, when we execute a pointer extension program within -one of our "git lfs clean", "git lfs smudge", and "git lfs filter-process" -commands, our commands have typically inherited their execution -environment from Git. When Git runs one of these commands to perform -a "clean" or "smudge" filter operation, it has already changed the current -working directory to be the root of the repository's working tree. The -setup_git_directory_gently() function in Git is normally run shortly -after starting, and if it detects that the current working directory -is within a Git work tree, it changes the current working directory to -the root of that work tree: - - https://github.com/git/git/blob/v2.50.1/setup.c#L1758-L1760 - -The file paths Git then passes to our filter commands are always relative -to the root of the repository. Git passes these file paths in place of -the "%f" command-line specifier from the filter configuration options, and -for long-running filter processes such as our "git lfs filter-process" -command, Git passes an equivalent file path as the value of the "pathname" -keys it sends to the filter process. The gitattributes(5) manual page -notes that files may not actually exist at these file paths, or may have -different contents than the ones Git pipes to the filter process, and so -filter programs should not attempt to access files at these paths: - - https://github.com/git/git/blob/v2.50.1/Documentation/gitattributes.adoc?plain=1#L503-L507 - -Likewise, when the Git LFS client invokes a Git LFS pointer extension -program, it passes a file path in place of any "%f" command-line -specifier in the extension configuration options, while piping the -actual file contents to the program on the standard input file -descriptor. If the client itself has been run by Git as a filter program, -then the file path will be relative to the root of the repository, and -the current working directory will be the root of the repository's -working tree. - -When the Git LFS client is not run by Git as a filter program but executed -directly via the "git lfs checkout" or "git lfs pull" commands, however, -we do not change the current working directory before invoking pointer -extension programs, and pass file paths that are relative to the current -working directory rather than the repository root. One exception is -when the --to option of the "git lfs checkout" command is specified, -in which case we pass the file path argument of that option to any -pointer extension programs instead of a path to the pointer file whose -contents they are processing. - -Like Git filter programs, Git LFS pointer extension program should not -expect to access an actual file at the paths passed in place of the "%f" -command-line specifiers. At present, though, we do not make this explicit -in our documentation. - -In our new tests of the pointer extension support of our "git lfs checkout" -and "git lfs pull" commands, we specifically check that the file paths -logged by the lfstest-caseinverterextension test utility are relative -to the current working directory, except when the --to option of the -"git lfs checkout" command is specified, in which case that option's -argument is passed to the test utility. We include comments noting that -both of these behaviours differ from that seen when an extension program -is run by a Git LFS filter command executed by Git. - -In subsequent commits we expect to adjust how our "git lfs checkout" and -"git lfs pull" commands handle file paths, including by changing the -current working directory to the root of the current working tree before -writing files into that work tree. As a consequence, these commands will -pass only paths relative to the root of the repository to the -SmudgeToFile() method of the GitFilter structure in our "lfs" package. - -As such, we will also necessarily change the file paths passed to Git LFS -pointer extensions so they are also always relative to the root of the -repository, and when we invoke pointer extension programs, the current -working directory will already be set to the root of the current work -tree. - -At the same time we will then revise our "checkout: pointer extension", -"checkout: pointer extension with conflict", and "pull: pointer extension" -tests so they verify that the file paths received by the -lfstest-caseinverterextension test utility are relative to the root of -the repository, and will update the utility itself so it checks that -its current working directory is the root of a Git work tree. - -Co-authored-by: Lars Schneider ---- - t/Makefile | 1 + - t/cmd/lfstest-caseinverterextension.go | 85 +++++++++++++++++ - t/t-checkout.sh | 124 +++++++++++++++++++++++++ - t/t-clean.sh | 21 +++++ - t/t-filter-process.sh | 48 ++++++++++ - t/t-pull.sh | 60 ++++++++++++ - t/t-smudge.sh | 29 ++++++ - t/testhelpers.sh | 21 +++++ - 8 files changed, 389 insertions(+) - create mode 100644 t/cmd/lfstest-caseinverterextension.go - -diff --git a/t/Makefile b/t/Makefile -index 26a496eb..b10b6e36 100644 ---- a/t/Makefile -+++ b/t/Makefile -@@ -24,6 +24,7 @@ TEST_CMDS += ../bin/lfs-askpass$X - TEST_CMDS += ../bin/lfs-ssh-echo$X - TEST_CMDS += ../bin/lfs-ssh-proxy-test$X - TEST_CMDS += ../bin/lfstest-badpathcheck$X -+TEST_CMDS += ../bin/lfstest-caseinverterextension$X - TEST_CMDS += ../bin/lfstest-count-tests$X - TEST_CMDS += ../bin/lfstest-customadapter$X - TEST_CMDS += ../bin/lfstest-genrandom$X -diff --git a/t/cmd/lfstest-caseinverterextension.go b/t/cmd/lfstest-caseinverterextension.go -new file mode 100644 -index 00000000..d9f4929d ---- /dev/null -+++ b/t/cmd/lfstest-caseinverterextension.go -@@ -0,0 +1,85 @@ -+//go:build testtools -+// +build testtools -+ -+// A simple Git LFS pointer extension that translates lower case characters -+// to upper case characters and vise versa. This is used in the Git LFS -+// integration tests. -+ -+package main -+ -+import ( -+ "bufio" -+ "fmt" -+ "io" -+ "os" -+ "strings" -+ "unicode" -+) -+ -+func main() { -+ log := openLog() -+ -+ if len(os.Args) != 4 || (os.Args[1] != "clean" && os.Args[1] != "smudge") || os.Args[2] != "--" { -+ logErrorAndExit(log, "invalid arguments: %s", strings.Join(os.Args, " ")) -+ } -+ -+ if log != nil { -+ fmt.Fprintf(log, "%s: %s\n", os.Args[1], os.Args[3]) -+ } -+ -+ reader := bufio.NewReader(os.Stdin) -+ var err error -+ for { -+ var r rune -+ r, _, err = reader.ReadRune() -+ if err != nil { -+ if err == io.EOF { -+ err = nil -+ } -+ break -+ } -+ -+ if unicode.IsLower(r) { -+ r = unicode.ToUpper(r) -+ } else if unicode.IsUpper(r) { -+ r = unicode.ToLower(r) -+ } -+ -+ os.Stdout.WriteString(string(r)) -+ } -+ -+ if err != nil { -+ logErrorAndExit(log, "unable to read stdin: %s", err) -+ } -+ -+ if log != nil { -+ log.Close() -+ } -+ os.Exit(0) -+} -+ -+func openLog() *os.File { -+ logPath := os.Getenv("LFSTEST_EXT_LOG") -+ if logPath == "" { -+ return nil -+ } -+ -+ log, err := os.OpenFile(logPath, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0644) -+ if err != nil { -+ logErrorAndExit(nil, "unable to open log %q: %s", logPath, err) -+ } -+ -+ return log -+} -+ -+func logErrorAndExit(log *os.File, format string, vals ...interface{}) { -+ msg := fmt.Sprintf(format, vals...) -+ fmt.Fprintln(os.Stderr, msg) -+ -+ if log != nil { -+ fmt.Fprintln(log, msg) -+ log.Close() -+ } -+ -+ os.Exit(1) -+} -diff --git a/t/t-checkout.sh b/t/t-checkout.sh -index b65f71b4..4b60d8aa 100755 ---- a/t/t-checkout.sh -+++ b/t/t-checkout.sh -@@ -432,3 +432,127 @@ begin_test "checkout: sparse with partial clone and sparse index" - [ ! -e "out-dir/c.dat" ] - ) - end_test -+ -+begin_test "checkout: pointer extension" -+( -+ set -e -+ -+ reponame="checkout-pointer-extension" -+ setup_remote_repo "$reponame" -+ clone_repo "$reponame" "$reponame" -+ -+ git lfs track "*.dat" -+ -+ setup_case_inverter_extension -+ -+ contents="abc" -+ inverted_contents_oid="$(calc_oid "$(invert_case "$contents")")" -+ mkdir dir1 -+ printf "%s" "$contents" >dir1/abc.dat -+ -+ git add .gitattributes dir1 -+ git commit -m "initial commit" -+ -+ assert_local_object "$inverted_contents_oid" 3 -+ -+ rm -rf dir1 "$LFSTEST_EXT_LOG" -+ git lfs checkout -+ -+ [ "$contents" = "$(cat "dir1/abc.dat")" ] -+ grep "smudge: dir1/abc.dat" "$LFSTEST_EXT_LOG" -+ -+ rm -rf dir1 "$LFSTEST_EXT_LOG" -+ mkdir dir2 -+ -+ pushd dir2 -+ git lfs checkout -+ popd -+ -+ [ "$contents" = "$(cat "dir1/abc.dat")" ] -+ -+ # Note that at present we expect "git lfs checkout" to run the extension -+ # program in the current working directory rather than the repository root, -+ # as would occur if it was run within a smudge filter operation started -+ # by Git. -+ grep "smudge: ../dir1/abc.dat" "$LFSTEST_EXT_LOG" -+) -+end_test -+ -+begin_test "checkout: pointer extension with conflict" -+( -+ set -e -+ -+ reponame="checkout-pointer-extension-conflict" -+ setup_remote_repo "$reponame" -+ clone_repo "$reponame" "$reponame" -+ -+ git lfs track "*.dat" -+ -+ setup_case_inverter_extension -+ -+ contents="abc" -+ inverted_contents_oid="$(calc_oid "$(invert_case "$contents")")" -+ mkdir dir1 -+ printf "%s" "$contents" >dir1/abc.dat -+ -+ git add .gitattributes dir1 -+ git commit -m "initial commit" -+ -+ assert_local_object "$inverted_contents_oid" 3 -+ -+ git checkout -b theirs -+ contents_theirs="Abc" -+ printf "%s" "$contents_theirs" >dir1/abc.dat -+ git add dir1 -+ git commit -m "theirs" -+ -+ git checkout main -+ contents_ours="aBc" -+ printf "%s" "$contents_ours" >dir1/abc.dat -+ git add dir1 -+ git commit -m "ours" -+ -+ git merge theirs && exit 1 -+ -+ rm -f "$LFSTEST_EXT_LOG" -+ -+ git lfs checkout --to base.txt --base dir1/abc.dat -+ -+ printf "%s" "$contents" | cmp - base.txt -+ -+ # Note that at present we expect "git lfs checkout" to pass the argument -+ # from its --to option to the extension program instead of the pointer's -+ # file path. -+ grep "smudge: base.txt" "$LFSTEST_EXT_LOG" -+ -+ rm -f "$LFSTEST_EXT_LOG" -+ -+ pushd dir1 -+ git lfs checkout --to ../ours.txt --ours abc.dat -+ popd -+ -+ printf "%s" "$contents_ours" | cmp - ours.txt -+ -+ # Note that at present we expect "git lfs checkout" to pass the argument -+ # from its --to option to the extension program instead of the pointer's -+ # file path. -+ grep "smudge: ../ours.txt" "$LFSTEST_EXT_LOG" -+ -+ abs_assert_dir="$TRASHDIR/${reponame}-assert" -+ abs_theirs_file="$(canonical_path "$abs_assert_dir/dir1/dir2/theirs.txt")" -+ -+ rm -rf "$abs_assert_dir" "$LFSTEST_EXT_LOG" -+ mkdir dir2 -+ -+ pushd dir2 -+ git lfs checkout --to "$abs_theirs_file" --theirs ../dir1/abc.dat -+ popd -+ -+ printf "%s" "$contents_theirs" | cmp - "$abs_theirs_file" -+ -+ # Note that at present we expect "git lfs checkout" to pass the argument -+ # from its --to option to the extension program instead of the pointer's -+ # file path. -+ grep "smudge: $(escape_path "$abs_theirs_file")" "$LFSTEST_EXT_LOG" -+) -+end_test -diff --git a/t/t-clean.sh b/t/t-clean.sh -index 2ecb68a6..28bbbf8f 100755 ---- a/t/t-clean.sh -+++ b/t/t-clean.sh -@@ -57,6 +57,27 @@ This is my test pointer. There are many like it, but this one is mine.\n" | git - ) - end_test - -+begin_test "clean with pointer extension" -+( -+ set -e -+ clean_setup "pointer-extension" -+ -+ setup_case_inverter_extension -+ -+ contents="$(printf "%s\n%s" "abc" "def")" -+ contents_oid="$(calc_oid "$contents")" -+ inverted_contents_oid="$(calc_oid "$(invert_case "$contents")")" -+ printf "%s" "$contents" | git lfs clean -- "dir1/abc.dat" | tee clean.log -+ -+ pointer="$(case_inverter_extension_pointer "$contents_oid" "$inverted_contents_oid" 7)" -+ -+ assert_local_object "$inverted_contents_oid" 7 -+ -+ [ "$pointer" = "$(cat clean.log)" ] -+ grep "clean: dir1/abc.dat" "$LFSTEST_EXT_LOG" -+) -+end_test -+ - begin_test "clean stdin" - ( - set -e -diff --git a/t/t-filter-process.sh b/t/t-filter-process.sh -index 882db218..19beaae7 100755 ---- a/t/t-filter-process.sh -+++ b/t/t-filter-process.sh -@@ -142,6 +142,54 @@ begin_test "filter process: adding a file" - ) - end_test - -+begin_test "filter-process: pointer extension" -+( -+ set -e -+ -+ reponame="filter-process-pointer-extension" -+ git init "$reponame" -+ cd "$reponame" -+ -+ # Git will choose "filter.lfs.process" over "filter.lfs.clean" and -+ # "filter.lfs.smudge". -+ git config --global --unset filter.lfs.clean -+ git config --global --unset filter.lfs.smudge -+ -+ setup_case_inverter_extension -+ -+ git lfs track "*.dat" -+ -+ contents="$(printf "%s\n%s" "abc" "def")" -+ contents_oid="$(calc_oid "$contents")" -+ inverted_contents_oid="$(calc_oid "$(invert_case "$contents")")" -+ mkdir dir1 -+ printf "%s" "$contents" >dir1/abc.dat -+ git add .gitattributes dir1 -+ git commit -m "initial commit" -+ -+ pointer="$(case_inverter_extension_pointer "$contents_oid" "$inverted_contents_oid" 7)" -+ [ "$pointer" = "$(git cat-file -p ":dir1/abc.dat")" ] -+ grep "clean: dir1/abc.dat" "$LFSTEST_EXT_LOG" -+ -+ assert_local_object "$inverted_contents_oid" 7 -+ -+ rm -rf dir1 "$LFSTEST_EXT_LOG" -+ git checkout -- . -+ [ "$contents" = "$(cat "dir1/abc.dat")" ] -+ grep "smudge: dir1/abc.dat" "$LFSTEST_EXT_LOG" -+ -+ rm -rf dir1 "$LFSTEST_EXT_LOG" -+ mkdir dir2 -+ -+ pushd dir2 -+ git checkout -- .. -+ popd -+ -+ [ "$contents" = "$(cat "dir1/abc.dat")" ] -+ grep "smudge: dir1/abc.dat" "$LFSTEST_EXT_LOG" -+) -+end_test -+ - # https://github.com/git-lfs/git-lfs/issues/1697 - begin_test "filter process: add a file with 1024 bytes" - ( -diff --git a/t/t-pull.sh b/t/t-pull.sh -index 4090b7a0..d37305f6 100755 ---- a/t/t-pull.sh -+++ b/t/t-pull.sh -@@ -514,3 +514,63 @@ begin_test "pull with partial clone and sparse checkout and index" - refute_local_object "$contents3_oid" - ) - end_test -+ -+begin_test "pull: pointer extension" -+( -+ set -e -+ -+ reponame="pull-pointer-extension" -+ setup_remote_repo "$reponame" -+ clone_repo "$reponame" "$reponame" -+ -+ git lfs track "*.dat" -+ -+ setup_case_inverter_extension -+ -+ contents="abc" -+ inverted_contents_oid="$(calc_oid "$(invert_case "$contents")")" -+ mkdir dir1 -+ printf "%s" "$contents" >dir1/abc.dat -+ -+ git add .gitattributes dir1 -+ git commit -m "initial commit" -+ -+ git push origin main -+ assert_server_object "$reponame" "$inverted_contents_oid" -+ -+ cd .. -+ GIT_LFS_SKIP_SMUDGE=1 git clone "$GITSERVER/$reponame" "${reponame}-assert" -+ -+ cd "${reponame}-assert" -+ refute_local_object "$inverted_contents_oid" -+ -+ setup_case_inverter_extension -+ -+ rm -rf dir1 "$LFSTEST_EXT_LOG" -+ git lfs pull -+ -+ assert_local_object "$inverted_contents_oid" 3 -+ -+ [ "$contents" = "$(cat "dir1/abc.dat")" ] -+ grep "smudge: dir1/abc.dat" "$LFSTEST_EXT_LOG" -+ -+ rm -rf .git/lfs/objects -+ -+ rm -rf dir1 "$LFSTEST_EXT_LOG" -+ mkdir dir2 -+ -+ pushd dir2 -+ git lfs pull -+ popd -+ -+ [ "$contents" = "$(cat "dir1/abc.dat")" ] -+ -+ # Note that at present we expect "git lfs pull" to run the extension -+ # program in the current working directory rather than the repository root, -+ # as would occur if it was run within a smudge filter operation started -+ # by Git. -+ grep "smudge: ../dir1/abc.dat" "$LFSTEST_EXT_LOG" -+ -+ assert_local_object "$inverted_contents_oid" 3 -+) -+end_test -diff --git a/t/t-smudge.sh b/t/t-smudge.sh -index 0297709d..468ada26 100755 ---- a/t/t-smudge.sh -+++ b/t/t-smudge.sh -@@ -61,6 +61,35 @@ begin_test "smudge with invalid pointer" - ) - end_test - -+begin_test "smudge with pointer extension" -+( -+ set -e -+ -+ reponame="smudge-pointer-extension" -+ git init "$reponame" -+ cd "$reponame" -+ -+ setup_case_inverter_extension -+ -+ git lfs track "*.dat" -+ -+ contents="$(printf "%s\n%s" "abc" "def")" -+ contents_oid="$(calc_oid "$contents")" -+ inverted_contents_oid="$(calc_oid "$(invert_case "$contents")")" -+ mkdir dir1 -+ printf "%s" "$contents" >dir1/abc.dat -+ git add .gitattributes dir1 -+ -+ pointer="$(case_inverter_extension_pointer "$contents_oid" "$inverted_contents_oid" 7)" -+ -+ assert_local_object "$inverted_contents_oid" 7 -+ -+ # smudge works even though it hasn't been pushed, by reading from .git/lfs/objects -+ [ "$contents" = "$(echo "$pointer" | git lfs smudge -- "dir1/abc.dat")" ] -+ grep "smudge: dir1/abc.dat" "$LFSTEST_EXT_LOG" -+) -+end_test -+ - begin_test "smudge include/exclude" - ( - set -e -diff --git a/t/testhelpers.sh b/t/testhelpers.sh -index 5bee4187..61310900 100644 ---- a/t/testhelpers.sh -+++ b/t/testhelpers.sh -@@ -906,6 +906,27 @@ add_symlink() { - git checkout -- "$dest" - } - -+setup_case_inverter_extension() { -+ export LFSTEST_EXT_LOG="$TRASHDIR/caseinverterextension.log" -+ -+ git config lfs.extension.caseinverter.clean \ -+ "lfstest-caseinverterextension clean -- %f" -+ git config lfs.extension.caseinverter.smudge \ -+ "lfstest-caseinverterextension smudge -- %f" -+ git config lfs.extension.caseinverter.priority 0 -+} -+ -+case_inverter_extension_pointer() { -+ local ext_oid_line="ext-0-caseinverter sha256:$1" -+ local base_pointer="$(pointer "$2" "$3")" -+ -+ printf "%s" "$base_pointer" | sed "s/^oid /$ext_oid_line\noid /" -+} -+ -+invert_case() { -+ printf "%s" "$1" | tr "[:lower:][:upper:]" "[:upper:][:lower:]" -+} -+ - urlify() { - if [ "$IS_WINDOWS" -eq 1 ] - then --- -2.51.1 - - -From 7c4f9b4d1b33fa64186a942606876b74d66800f6 Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Mon, 12 May 2025 19:31:54 -0700 -Subject: [PATCH 07/21] t/t-{checkout,pull}.sh: test multiply-nested dirs - -In PR #527 we introduced the "git lfs checkout" and "git lfs pull" -commands and added some initial tests of those commands to our -test suite, starting with the "checkout" test in what is now our -t/t-checkout.sh test script. Since that test first appeared in commit -1d05552bde17ac5ce464b37da53fa8b0214f9c1a, it has validated the -behaviour of the "git lfs checkout" command with a number of -example files tracked as Git LFS objects, two of which are located -within subdirectories. - -We later added a similar test of the "git lfs pull" command in commit -096b6da385fc99bc54655beb6e9fae3a2880da34 of the same PR, but without any -files tracked as Git LFS objects that were not at the top level of the -test repository. We eventually expanded this test to include one such -file in commit 7158e3bb633176a39743f0efff8e53abc100b6f5 of PR #2641. - -However, none of the tests in our t/t-checkout.sh or t/t-pull.sh test -scripts exercise the relevant Git LFS commands with files that are -contained within multiple levels of subdirectories. - -Before we adjust how our "git lfs checkout" and "git lfs pull" commands -create subdirectories, we first revise our "checkout" and "pull" tests -to create files within multiple levels of subdirectories, and to confirm -that commands recreate the full set of subdirectories when they do not -exist. - -Note that in our "pull" test in particular we now check that -the -I option accepts arguments containing "**" pattern components, -matching the checks we added to our "checkout" test in a previous commit. ---- - t/t-checkout.sh | 51 ++++++++++++++++++++++----------------- - t/t-pull.sh | 64 +++++++++++++++++++++++++++++++------------------ - 2 files changed, 70 insertions(+), 45 deletions(-) - -diff --git a/t/t-checkout.sh b/t/t-checkout.sh -index 4b60d8aa..50c66d54 100755 ---- a/t/t-checkout.sh -+++ b/t/t-checkout.sh -@@ -24,10 +24,10 @@ begin_test "checkout" - printf "%s" "$contents" > file1.dat - printf "%s" "$contents" > file2.dat - printf "%s" "$contents" > file3.dat -- mkdir folder1 folder2 -+ mkdir -p folder1 folder2/folder3/folder4 - printf "%s" "$contents" > folder1/nested.dat -- printf "%s" "$contents" > folder2/nested.dat -- git add file1.dat file2.dat file3.dat folder1/nested.dat folder2/nested.dat -+ printf "%s" "$contents" > folder2/folder3/folder4/nested.dat -+ git add file1.dat file2.dat file3.dat folder1 folder2 - git add .gitattributes - git commit -m "add files" - -@@ -35,12 +35,12 @@ begin_test "checkout" - [ "$contents" = "$(cat file2.dat)" ] - [ "$contents" = "$(cat file3.dat)" ] - [ "$contents" = "$(cat folder1/nested.dat)" ] -- [ "$contents" = "$(cat folder2/nested.dat)" ] -+ [ "$contents" = "$(cat folder2/folder3/folder4/nested.dat)" ] - - assert_pointer "main" "file1.dat" "$contents_oid" $contentsize - - # Remove the working directory -- rm -rf file1.dat file2.dat file3.dat folder1/nested.dat folder2/nested.dat -+ rm -rf file1.dat file2.dat file3.dat folder1/nested.dat folder2 - - echo "checkout should replace all" - GIT_TRACE=1 git lfs checkout 2>&1 | tee checkout.log -@@ -48,7 +48,7 @@ begin_test "checkout" - [ "$contents" = "$(cat file2.dat)" ] - [ "$contents" = "$(cat file3.dat)" ] - [ "$contents" = "$(cat folder1/nested.dat)" ] -- [ "$contents" = "$(cat folder2/nested.dat)" ] -+ [ "$contents" = "$(cat folder2/folder3/folder4/nested.dat)" ] - grep "Checking out LFS objects: 100% (5/5), 95 B" checkout.log - grep 'accepting "file1.dat"' checkout.log - grep 'rejecting "file1.dat"' checkout.log && exit 1 -@@ -65,7 +65,7 @@ begin_test "checkout" - git reset --hard - - # Remove the working directory -- rm -rf file1.dat file2.dat file3.dat folder1/nested.dat folder2/nested.dat -+ rm -rf file1.dat file2.dat file3.dat folder1/nested.dat folder2 - - echo "checkout with filters" - git lfs checkout file2.dat -@@ -73,7 +73,7 @@ begin_test "checkout" - [ ! -f file1.dat ] - [ ! -f file3.dat ] - [ ! -f folder1/nested.dat ] -- [ ! -f folder2/nested.dat ] -+ [ ! -e folder2 ] - assert_clean_worktree_with_exceptions "(file[13]|nested)\.dat" - - echo "quotes to avoid shell globbing" -@@ -81,7 +81,7 @@ begin_test "checkout" - [ "$contents" = "$(cat file1.dat)" ] - [ "$contents" = "$(cat file3.dat)" ] - [ ! -f folder1/nested.dat ] -- [ ! -f folder2/nested.dat ] -+ [ ! -e folder2 ] - assert_clean_worktree_with_exceptions "nested\.dat" - - echo "test subdir context" -@@ -90,57 +90,64 @@ begin_test "checkout" - git lfs checkout nested.dat - [ "$contents" = "$(cat nested.dat)" ] - [ ! -f ../file1.dat ] -- [ ! -f ../folder2/nested.dat ] -- assert_clean_worktree_with_exceptions "(file1|folder2/nested)\.dat" -+ [ ! -e ../folder2 ] -+ assert_clean_worktree_with_exceptions "(file1|folder4/nested)\.dat" - - # test '.' in current dir - rm nested.dat - git lfs checkout . - [ "$contents" = "$(cat nested.dat)" ] - [ ! -f ../file1.dat ] -- [ ! -f ../folder2/nested.dat ] -- assert_clean_worktree_with_exceptions "(file1|folder2/nested)\.dat" -+ [ ! -e ../folder2 ] -+ assert_clean_worktree_with_exceptions "(file1|folder4/nested)\.dat" - - # test '..' in current dir - git lfs checkout .. - [ "$contents" = "$(cat ../file1.dat)" ] -- [ "$contents" = "$(cat ../folder2/nested.dat)" ] -+ [ "$contents" = "$(cat ../folder2/folder3/folder4/nested.dat)" ] - assert_clean_status - - # test glob match with '..' in current dir - rm -rf ../folder2 - git lfs checkout '../folder2/**' -- [ "$contents" = "$(cat ../folder2/nested.dat)" ] -+ [ "$contents" = "$(cat ../folder2/folder3/folder4/nested.dat)" ] - assert_clean_status - popd - - echo "test folder param" - rm -rf folder2 - git lfs checkout folder2 -- [ "$contents" = "$(cat folder2/nested.dat)" ] -+ [ "$contents" = "$(cat folder2/folder3/folder4/nested.dat)" ] - assert_clean_status - - echo "test folder param with pre-existing directory" - rm -rf folder2 - mkdir folder2 - git lfs checkout folder2 -- [ "$contents" = "$(cat folder2/nested.dat)" ] -+ [ "$contents" = "$(cat folder2/folder3/folder4/nested.dat)" ] - assert_clean_status - - echo "test folder param with glob match" - rm -rf folder2 - git lfs checkout 'folder2/**' -- [ "$contents" = "$(cat folder2/nested.dat)" ] -+ [ "$contents" = "$(cat folder2/folder3/folder4/nested.dat)" ] - assert_clean_status - - echo "test '.' in current dir" -- rm -rf file1.dat file2.dat file3.dat folder1/nested.dat folder2/nested.dat -+ rm -rf file1.dat file2.dat file3.dat folder1 folder2 - git lfs checkout . - [ "$contents" = "$(cat file1.dat)" ] - [ "$contents" = "$(cat file2.dat)" ] - [ "$contents" = "$(cat file3.dat)" ] - [ "$contents" = "$(cat folder1/nested.dat)" ] -- [ "$contents" = "$(cat folder2/nested.dat)" ] -+ [ "$contents" = "$(cat folder2/folder3/folder4/nested.dat)" ] -+ assert_clean_status -+ -+ echo "test pre-existing directories" -+ rm -rf folder1/nested.dat folder2/folder3/folder4 -+ git lfs checkout -+ [ "$contents" = "$(cat folder1/nested.dat)" ] -+ [ "$contents" = "$(cat folder2/folder3/folder4/nested.dat)" ] - assert_clean_status - - echo "test checkout with missing data doesn't fail" -@@ -152,7 +159,7 @@ begin_test "checkout" - [ "$(pointer $contents_oid $contentsize)" = "$(cat file2.dat)" ] - [ "$(pointer $contents_oid $contentsize)" = "$(cat file3.dat)" ] - [ "$contents" = "$(cat folder1/nested.dat)" ] -- [ "$contents" = "$(cat folder2/nested.dat)" ] -+ [ "$contents" = "$(cat folder2/folder3/folder4/nested.dat)" ] - assert_clean_worktree_with_exceptions "file[123]\.dat" - ) - end_test -@@ -190,7 +197,7 @@ begin_test "checkout: without clean filter" - [ "$(pointer $contents_oid $contentsize)" = "$(cat file2.dat)" ] - [ "$(pointer $contents_oid $contentsize)" = "$(cat file3.dat)" ] - [ "$(pointer $contents_oid $contentsize)" = "$(cat folder1/nested.dat)" ] -- [ "$(pointer $contents_oid $contentsize)" = "$(cat folder2/nested.dat)" ] -+ [ "$(pointer $contents_oid $contentsize)" = "$(cat folder2/folder3/folder4/nested.dat)" ] - ) - end_test - -diff --git a/t/t-pull.sh b/t/t-pull.sh -index d37305f6..a6179e62 100755 ---- a/t/t-pull.sh -+++ b/t/t-pull.sh -@@ -22,25 +22,27 @@ begin_test "pull" - contents3="dir" - contents3_oid=$(calc_oid "$contents3") - -- mkdir dir -+ mkdir -p dir1 dir2/dir3/dir4 - echo "*.log" > .gitignore - printf "%s" "$contents" > a.dat - printf "%s" "$contents2" > á.dat -- printf "%s" "$contents3" > dir/dir.dat -+ printf "%s" "$contents3" > dir1/dir.dat -+ printf "%s" "$contents3" > dir2/dir3/dir4/dir.dat - git add . - git commit -m "add files" 2>&1 | tee commit.log - grep "main (root-commit)" commit.log -- grep "5 files changed" commit.log -+ grep "6 files changed" commit.log - grep "create mode 100644 a.dat" commit.log - grep "create mode 100644 .gitattributes" commit.log - - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] -- [ "dir" = "$(cat "dir/dir.dat")" ] -+ [ "dir" = "$(cat "dir1/dir.dat")" ] -+ [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] - - assert_pointer "main" "a.dat" "$contents_oid" 1 - assert_pointer "main" "á.dat" "$contents2_oid" 1 -- assert_pointer "main" "dir/dir.dat" "$contents3_oid" 3 -+ assert_pointer "main" "dir1/dir.dat" "$contents3_oid" 3 - - refute_server_object "$reponame" "$contents_oid" - refute_server_object "$reponame" "$contents2_oid" -@@ -65,7 +67,8 @@ begin_test "pull" - - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] -- [ "dir" = "$(cat "dir/dir.dat")" ] -+ [ "dir" = "$(cat "dir1/dir.dat")" ] -+ [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] - - assert_local_object "$contents_oid" 1 - assert_local_object "$contents2_oid" 1 -@@ -73,12 +76,13 @@ begin_test "pull" - assert_clean_status - - echo "lfs pull" -- rm -rf a.dat á.dat dir # removing files makes the status dirty -+ rm -rf a.dat á.dat dir1 dir2 # removing files makes the status dirty - rm -rf .git/lfs/objects - git lfs pull - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] -- [ "dir" = "$(cat "dir/dir.dat")" ] -+ [ "dir" = "$(cat "dir1/dir.dat")" ] -+ [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] - assert_local_object "$contents_oid" 1 - assert_local_object "$contents2_oid" 1 - assert_local_object "$contents3_oid" 3 -@@ -86,12 +90,13 @@ begin_test "pull" - git lfs fsck - - echo "lfs pull with remote" -- rm -rf a.dat á.dat dir -+ rm -rf a.dat á.dat dir1 dir2 - rm -rf .git/lfs/objects - git lfs pull origin - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] -- [ "dir" = "$(cat "dir/dir.dat")" ] -+ [ "dir" = "$(cat "dir1/dir.dat")" ] -+ [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] - assert_local_object "$contents_oid" 1 - assert_local_object "$contents2_oid" 1 - assert_local_object "$contents3_oid" 3 -@@ -99,10 +104,19 @@ begin_test "pull" - git lfs fsck - - echo "lfs pull with local storage" -- rm -rf a.dat á.dat dir -+ rm -rf a.dat á.dat dir1 dir2 - git lfs pull - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] -+ [ "dir" = "$(cat "dir1/dir.dat")" ] -+ [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] -+ assert_clean_status -+ -+ echo "test pre-existing directories" -+ rm -rf dir1/dir.dat dir2/dir3/dir4 -+ git lfs pull -+ [ "dir" = "$(cat "dir1/dir.dat")" ] -+ [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] - assert_clean_status - - echo "lfs pull with include/exclude filters in gitconfig" -@@ -139,7 +153,8 @@ begin_test "pull" - git lfs pull - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] -- [ "dir" = "$(cat "dir/dir.dat")" ] -+ [ "dir" = "$(cat "dir1/dir.dat")" ] -+ [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] - assert_local_object "$contents_oid" 1 - assert_local_object "$contents2_oid" 1 - assert_local_object "$contents3_oid" 3 -@@ -147,11 +162,12 @@ begin_test "pull" - - echo "lfs pull with -I" - rm -rf .git/lfs/objects -- rm -rf a.dat "á.dat" "dir/dir.dat" -- git lfs pull -I "a.*,dir/dir.*" -+ rm -rf a.dat "á.dat" "dir1/dir.dat" dir2 -+ git lfs pull -I "a.*,dir1/dir.*,dir2/**" - [ "a" = "$(cat a.dat)" ] - [ ! -e "á.dat" ] -- [ "dir" = "$(cat "dir/dir.dat")" ] -+ [ "dir" = "$(cat "dir1/dir.dat")" ] -+ [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] - assert_local_object "$contents_oid" 1 - refute_local_object "$contents2_oid" - assert_local_object "$contents3_oid" 3 -@@ -176,13 +192,14 @@ begin_test "pull" - - echo "lfs pull in subdir" - rm -rf .git/lfs/objects -- rm -rf a.dat "á.dat" "dir/dir.dat" -- pushd dir -+ rm -rf a.dat "á.dat" "dir1/dir.dat" dir2 -+ pushd dir1 - git lfs pull - popd - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] -- [ "dir" = "$(cat "dir/dir.dat")" ] -+ [ "dir" = "$(cat "dir1/dir.dat")" ] -+ [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] - assert_local_object "$contents_oid" 1 - assert_local_object "$contents2_oid" 1 - assert_local_object "$contents3_oid" 3 -@@ -190,19 +207,20 @@ begin_test "pull" - - echo "lfs pull in subdir with -I" - rm -rf .git/lfs/objects -- rm -rf a.dat "á.dat" "dir/dir.dat" -- pushd dir -- git lfs pull -I "á.*,dir/dir.dat" -+ rm -rf a.dat "á.dat" "dir1/dir.dat" dir2 -+ pushd dir1 -+ git lfs pull -I "á.*,dir1/dir.dat,dir2/**" - popd - [ ! -e a.dat ] - [ "A" = "$(cat "á.dat")" ] -- [ "dir" = "$(cat "dir/dir.dat")" ] -+ [ "dir" = "$(cat "dir1/dir.dat")" ] -+ [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] - refute_local_object "$contents_oid" - assert_local_object "$contents2_oid" 1 - assert_local_object "$contents3_oid" 3 - assert_clean_worktree_with_exceptions "a\.dat" - -- pushd dir -+ pushd dir1 - git lfs pull -I "*.dat" - popd - [ "a" = "$(cat a.dat)" ] --- -2.51.1 - - -From 3ad130cf20c079fd85d1261af12f7bd714b33617 Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Wed, 24 Sep 2025 15:31:14 -0700 -Subject: [PATCH 08/21] t: test some symlink conflicts on checkout/pull - -Our "git lfs checkout" and "git lfs pull" commands, at present, -follow any extant symbolic links when they populate the current working -tree with files containing the content of Git LFS objects, even if -the symbolic links point to locations outside of the working tree. -This vulnerability has been assigned the identifier CVE-2025-26625. - -Although the "git lfs checkout" and "git lfs pull" commands currently -follow symbolic links, under certain circumstances the commands may still -report errors and avoid writing to files through the links, depending -on the nature of the links' targets. - -In subsequent commits we will adjust how these commands check and create -files in order to address the vulnerability described above. Before we -make these changes, though, we first add two pairs of new tests to -validate the limited set of conditions under which the commands already -avoid writing through symbolic links. As we later adjust the behaviour -of the "git lfs checkout" and "git lfs pull" commands we will update and -expand our new tests in tandem to help confirm that our revisions to -the commands are effective. - -The first pair of new tests we add are the "checkout: skip directory -symlink conflicts" and "pull: skip directory symlink conflicts" tests, -for the "git lfs checkout" and "git lfs pull" commands, respectively. -In their initial form, these tests check that the commands report errors -and avoid writing through symbolic links which appear in place of -directories in the paths between the root of the working tree and the -locations where the commands intend to create Git LFS files, but only -when the targets of the links either do not exist or are not directories. - -The second pair of new tests we add are the "checkout: skip file symlink -conflicts" and "pull: skip file symlink conflicts" tests. In their -initial form, these tests check that the "git lfs checkout" and "git lfs -pull" commands report errors and avoid writing through symbolic links -which exist at the locations where the commands intend to create files, -but only when the targets of the links exist and are directories rather -than regular files. - -All of our new tests use the assert_clean_index() test helper function -we introduced in a previous commit to confirm that in the specific -circumstances described above, the "git lfs checkout" and "git lfs pull" -commands do not update the Git index entries for the Git LFS files -whose paths now contain symbolic links in the working tree. - -Note that we only run our new tests on Windows if the current system -supports the creation of true symbolic links, which we determine using -a new skip_if_symlinks_unsupported() test helper function that simply -terminates the calling tests with a zero (i.e., successful) exit code -unless the has_native_symlinks() test helper function returns a zero -exit code. The has_native_symlinks() function, which we added in a prior -commit, checks the results of a query made with the Windows "fsutil -reparsepoint" command after the appropriate MSYS2 and Cygwin environment -variables are set to try to enable true symbolic link support in Windows. ---- - t/t-checkout.sh | 208 ++++++++++++++ - t/t-pull.sh | 722 ++++++++++++++++++++++++++++++++++++++--------- - t/testhelpers.sh | 4 + - 3 files changed, 794 insertions(+), 140 deletions(-) - -diff --git a/t/t-checkout.sh b/t/t-checkout.sh -index 50c66d54..aaeabd59 100755 ---- a/t/t-checkout.sh -+++ b/t/t-checkout.sh -@@ -164,6 +164,214 @@ begin_test "checkout" - ) - end_test - -+begin_test "checkout: skip directory file conflicts" -+( -+ set -e -+ -+ reponame="checkout-skip-dir-file-conflicts" -+ setup_remote_repo "$reponame" -+ clone_repo "$reponame" "$reponame" -+ -+ git lfs track "*.dat" -+ -+ contents="a" -+ contents_oid="$(calc_oid "$contents")" -+ mkdir -p dir1 dir2/dir3/dir4 -+ printf "%s" "$contents" >dir1/a.dat -+ printf "%s" "$contents" >dir2/dir3/dir4/a.dat -+ -+ git add .gitattributes dir1 dir2 -+ git commit -m "initial commit" -+ -+ rm -rf dir1 dir2/dir3 -+ touch dir1 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 -+ 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: stat dir1/a\.dat' checkout.log -+ grep 'Checkout error: stat dir2/dir3/dir4/a\.dat' checkout.log -+ fi -+ -+ [ -f "dir1" ] -+ [ -f "dir2/dir3" ] -+ 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 -+ 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: stat \.\./dir1/a\.dat' checkout.log -+ grep 'Checkout error: stat dir3/dir4/a\.dat' checkout.log -+ fi -+ popd -+ -+ [ -f "dir1" ] -+ [ -f "dir2/dir3" ] -+ assert_clean_index -+) -+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 -+ -+ skip_if_symlinks_unsupported -+ -+ reponame="checkout-skip-dir-symlink-conflicts" -+ setup_remote_repo "$reponame" -+ clone_repo "$reponame" "$reponame" -+ -+ git lfs track "*.dat" -+ -+ contents="a" -+ contents_oid="$(calc_oid "$contents")" -+ mkdir -p dir1 dir2/dir3/dir4 -+ printf "%s" "$contents" >dir1/a.dat -+ printf "%s" "$contents" >dir2/dir3/dir4/a.dat -+ -+ git add .gitattributes dir1 dir2 -+ git commit -m "initial commit" -+ -+ # test with symlink to file and dangling symlink -+ rm -rf dir1 dir2/dir3 ../link* -+ touch ../link1 -+ 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 -+ 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: stat dir1/a\.dat' checkout.log -+ fi -+ grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' checkout.log -+ -+ [ -L "dir1" ] -+ [ -L "dir2/dir3" ] -+ [ -f "../link1" ] -+ [ ! -e "../link2" ] -+ assert_clean_index -+ -+ rm -rf dir1 dir2/dir3 -+ touch link1 -+ 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 -+ 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: stat dir1/a\.dat' checkout.log -+ fi -+ grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' checkout.log -+ -+ [ -L "dir1" ] -+ [ -L "dir2/dir3" ] -+ [ -f "link1" ] -+ [ ! -e "link2" ] -+ 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 -+ 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: stat \.\./dir1/a\.dat' checkout.log -+ fi -+ grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' checkout.log -+ popd -+ -+ [ -L "dir1" ] -+ [ -L "dir2/dir3" ] -+ [ -f "link1" ] -+ [ ! -e "link2" ] -+ assert_clean_index -+) -+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 file symlink conflicts" -+( -+ set -e -+ -+ skip_if_symlinks_unsupported -+ -+ reponame="checkout-skip-file-symlink-conflicts" -+ setup_remote_repo "$reponame" -+ clone_repo "$reponame" "$reponame" -+ -+ git lfs track "*.dat" -+ -+ contents="a" -+ contents_oid="$(calc_oid "$contents")" -+ printf "%s" "$contents" >a.dat -+ -+ git add .gitattributes a.dat -+ git commit -m "initial commit" -+ -+ # test with symlink to directory -+ rm -rf a.dat ../link1 -+ mkdir ../link1 -+ ln -s ../link1 a.dat -+ -+ # Note that we do not try to check the "git lfs checkout" command's error -+ # output since it depends on both the OS and filesystem in use, as these -+ # affect how the linked directory's size is reported. -+ git lfs checkout -+ -+ [ -L "a.dat" ] -+ [ -d "../link1" ] -+ assert_clean_index -+ -+ rm a.dat -+ mkdir link1 -+ ln -s link1 a.dat -+ -+ git lfs checkout -+ -+ [ -L "a.dat" ] -+ [ -d "link1" ] -+ assert_clean_index -+ -+ mkdir -p dir1/dir2 -+ pushd dir1/dir2 -+ git lfs checkout -+ popd -+ -+ [ -L "a.dat" ] -+ [ -d "link1" ] -+ assert_clean_index -+) -+end_test -+ - begin_test "checkout: without clean filter" - ( - set -e -diff --git a/t/t-pull.sh b/t/t-pull.sh -index a6179e62..04e2f8d9 100755 ---- a/t/t-pull.sh -+++ b/t/t-pull.sh -@@ -22,31 +22,30 @@ begin_test "pull" - contents3="dir" - contents3_oid=$(calc_oid "$contents3") - -- mkdir -p dir1 dir2/dir3/dir4 -+ mkdir dir - echo "*.log" > .gitignore - printf "%s" "$contents" > a.dat - printf "%s" "$contents2" > á.dat -- printf "%s" "$contents3" > dir1/dir.dat -- printf "%s" "$contents3" > dir2/dir3/dir4/dir.dat -+ printf "%s" "$contents3" > dir/dir.dat - git add . - git commit -m "add files" 2>&1 | tee commit.log - grep "main (root-commit)" commit.log -- grep "6 files changed" commit.log -+ grep "5 files changed" commit.log - grep "create mode 100644 a.dat" commit.log - grep "create mode 100644 .gitattributes" commit.log - -+ ls -al - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] -- [ "dir" = "$(cat "dir1/dir.dat")" ] -- [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] -+ [ "dir" = "$(cat "dir/dir.dat")" ] - - assert_pointer "main" "a.dat" "$contents_oid" 1 - assert_pointer "main" "á.dat" "$contents2_oid" 1 -- assert_pointer "main" "dir1/dir.dat" "$contents3_oid" 3 -+ assert_pointer "main" "dir/dir.dat" "$contents3_oid" 3 - - refute_server_object "$reponame" "$contents_oid" - refute_server_object "$reponame" "$contents2_oid" -- refute_server_object "$reponame" "$contents3_oid" -+ refute_server_object "$reponame" "$contents33oid" - - echo "initial push" - git push origin main 2>&1 | tee push.log -@@ -67,56 +66,38 @@ begin_test "pull" - - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] -- [ "dir" = "$(cat "dir1/dir.dat")" ] -- [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] - - assert_local_object "$contents_oid" 1 - assert_local_object "$contents2_oid" 1 -- assert_local_object "$contents3_oid" 3 - assert_clean_status - - echo "lfs pull" -- rm -rf a.dat á.dat dir1 dir2 # removing files makes the status dirty -+ rm -r a.dat á.dat dir # removing files makes the status dirty - rm -rf .git/lfs/objects - git lfs pull -+ ls -al - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] -- [ "dir" = "$(cat "dir1/dir.dat")" ] -- [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] - assert_local_object "$contents_oid" 1 - assert_local_object "$contents2_oid" 1 -- assert_local_object "$contents3_oid" 3 -- assert_clean_status - git lfs fsck - - echo "lfs pull with remote" -- rm -rf a.dat á.dat dir1 dir2 -+ rm -r a.dat á.dat dir - rm -rf .git/lfs/objects - git lfs pull origin - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] -- [ "dir" = "$(cat "dir1/dir.dat")" ] -- [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] - assert_local_object "$contents_oid" 1 - assert_local_object "$contents2_oid" 1 -- assert_local_object "$contents3_oid" 3 - assert_clean_status - git lfs fsck - - echo "lfs pull with local storage" -- rm -rf a.dat á.dat dir1 dir2 -+ rm a.dat á.dat - git lfs pull - [ "a" = "$(cat a.dat)" ] - [ "A" = "$(cat "á.dat")" ] -- [ "dir" = "$(cat "dir1/dir.dat")" ] -- [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] -- assert_clean_status -- -- echo "test pre-existing directories" -- rm -rf dir1/dir.dat dir2/dir3/dir4 -- git lfs pull -- [ "dir" = "$(cat "dir1/dir.dat")" ] -- [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] - assert_clean_status - - echo "lfs pull with include/exclude filters in gitconfig" -@@ -151,31 +132,10 @@ begin_test "pull" - - echo "lfs pull clean status" - git lfs pull -- [ "a" = "$(cat a.dat)" ] -- [ "A" = "$(cat "á.dat")" ] -- [ "dir" = "$(cat "dir1/dir.dat")" ] -- [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] -- assert_local_object "$contents_oid" 1 -- assert_local_object "$contents2_oid" 1 -- assert_local_object "$contents3_oid" 3 - assert_clean_status - - echo "lfs pull with -I" -- rm -rf .git/lfs/objects -- rm -rf a.dat "á.dat" "dir1/dir.dat" dir2 -- git lfs pull -I "a.*,dir1/dir.*,dir2/**" -- [ "a" = "$(cat a.dat)" ] -- [ ! -e "á.dat" ] -- [ "dir" = "$(cat "dir1/dir.dat")" ] -- [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] -- assert_local_object "$contents_oid" 1 -- refute_local_object "$contents2_oid" -- assert_local_object "$contents3_oid" 3 -- assert_clean_worktree_with_exceptions '\\303\\241\.dat' -- - git lfs pull -I "*.dat" -- [ "A" = "$(cat "á.dat")" ] -- assert_local_object "$contents2_oid" 1 - assert_clean_status - - echo "lfs pull with empty file" -@@ -186,45 +146,588 @@ begin_test "pull" - [ -z "$(cat empty.dat)" ] - assert_clean_status - -- echo "resetting to test status" -- git reset --hard HEAD^ -+ echo "lfs pull in subdir" -+ cd dir -+ git lfs pull - assert_clean_status - -- echo "lfs pull in subdir" -+ echo "lfs pull in subdir with -I" -+ git lfs pull -I "*.dat" -+ assert_clean_status -+) -+end_test -+ -+begin_test "pull: skip directory file conflicts" -+( -+ set -e -+ -+ reponame="pull-skip-dir-file-conflicts" -+ setup_remote_repo "$reponame" -+ clone_repo "$reponame" "$reponame" -+ -+ git lfs track "*.dat" -+ -+ contents="a" -+ contents_oid="$(calc_oid "$contents")" -+ mkdir -p dir1 dir2/dir3/dir4 -+ printf "%s" "$contents" >dir1/a.dat -+ printf "%s" "$contents" >dir2/dir3/dir4/a.dat -+ -+ git add .gitattributes dir1 dir2 -+ 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 -rf dir1 dir2/dir3 -+ touch dir1 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 -+ 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 -+ -+ assert_local_object "$contents_oid" 1 -+ -+ [ -f "dir1" ] -+ [ -f "dir2/dir3" ] -+ 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 -+ 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 -+ popd -+ -+ assert_local_object "$contents_oid" 1 -+ -+ [ -f "dir1" ] -+ [ -f "dir2/dir3" ] -+ assert_clean_index -+) -+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 -+ -+ skip_if_symlinks_unsupported -+ -+ reponame="pull-skip-dir-symlink-conflicts" -+ setup_remote_repo "$reponame" -+ clone_repo "$reponame" "$reponame" -+ -+ git lfs track "*.dat" -+ -+ contents="a" -+ contents_oid="$(calc_oid "$contents")" -+ mkdir -p dir1 dir2/dir3/dir4 -+ printf "%s" "$contents" >dir1/a.dat -+ printf "%s" "$contents" >dir2/dir3/dir4/a.dat -+ -+ git add .gitattributes dir1 dir2 -+ 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 -+ -+ # test with symlink to file and dangling symlink -+ rm -rf dir1 dir2/dir3 ../link* -+ touch ../link1 -+ 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 -+ 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 -+ -+ assert_local_object "$contents_oid" 1 -+ -+ [ -L "dir1" ] -+ [ -L "dir2/dir3" ] -+ [ -f "../link1" ] -+ [ ! -e "../link2" ] -+ assert_clean_index -+ -+ rm -rf .git/lfs/objects -+ -+ rm -rf dir1 dir2/dir3 -+ touch link1 -+ 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 -+ 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 -+ -+ assert_local_object "$contents_oid" 1 -+ -+ [ -L "dir1" ] -+ [ -L "dir2/dir3" ] -+ [ -f "link1" ] -+ [ ! -e "link2" ] -+ 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 -+ 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 -+ popd -+ -+ assert_local_object "$contents_oid" 1 -+ -+ [ -L "dir1" ] -+ [ -L "dir2/dir3" ] -+ [ -f "link1" ] -+ [ ! -e "link2" ] -+ assert_clean_index -+) -+end_test -+ -+begin_test "pull: skip file symlink conflicts" -+( -+ set -e -+ -+ skip_if_symlinks_unsupported -+ -+ reponame="pull-skip-file-symlink-conflicts" -+ 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 -+ -+ # test with symlinks to pointer files -+ rm -rf a.dat dir1/dir2/dir3/a.dat ../link* -+ contents_pointer="$(git cat-file -p ":a.dat")" -+ printf "%s" "$contents_pointer" >../link1 -+ printf "%s" "$contents_pointer" >../link2 -+ ln -s ../link1 a.dat -+ ln -s ../../../../link2 dir1/dir2/dir3/a.dat -+ -+ 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 '"a\.dat": not a regular file' pull.log -+ grep '"dir1/dir2/dir3/a\.dat": not a regular file' pull.log -+ -+ assert_local_object "$contents_oid" 1 -+ -+ [ -L "a.dat" ] -+ [ -L "dir1/dir2/dir3/a.dat" ] -+ [ -f "../link1" ] -+ [ "$contents_pointer" = "$(cat ../link1)" ] -+ [ -f "../link2" ] -+ [ "$contents_pointer" = "$(cat ../link2)" ] -+ assert_clean_index -+ -+ rm -rf .git/lfs/objects -+ -+ rm -rf a.dat dir1/dir2/dir3/a.dat link* -+ printf "%s" "$contents_pointer" >link1 -+ printf "%s" "$contents_pointer" >link2 -+ ln -s link1 a.dat -+ ln -s ../../../link2 dir1/dir2/dir3/a.dat -+ -+ 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 '"a\.dat": not a regular file' pull.log -+ grep '"dir1/dir2/dir3/a\.dat": not a regular file' pull.log -+ -+ assert_local_object "$contents_oid" 1 -+ -+ [ -L "a.dat" ] -+ [ -L "dir1/dir2/dir3/a.dat" ] -+ [ -f "link1" ] -+ [ "$contents_pointer" = "$(cat link1)" ] -+ [ -f "link2" ] -+ [ "$contents_pointer" = "$(cat link2)" ] -+ assert_clean_index -+ -+ rm -rf .git/lfs/objects -+ -+ pushd dir1/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 '"a\.dat": not a regular file' pull.log -+ grep '"dir1/dir2/dir3/a\.dat": not a regular file' pull.log -+ popd -+ -+ assert_local_object "$contents_oid" 1 -+ -+ [ -L "a.dat" ] -+ [ -L "dir1/dir2/dir3/a.dat" ] -+ [ -f "link1" ] -+ [ "$contents_pointer" = "$(cat link1)" ] -+ [ -f "link2" ] -+ [ "$contents_pointer" = "$(cat link2)" ] -+ assert_clean_index -+ -+ # test with symlink to directory and dangling symlink -+ rm -rf .git/lfs/objects -+ -+ rm -rf a.dat dir1/dir2/dir3/a.dat ../link* -+ mkdir ../link1 -+ ln -s ../link1 a.dat -+ ln -s ../../../../link2 dir1/dir2/dir3/a.dat -+ -+ 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 '"a\.dat": not a regular file' pull.log -+ grep '"dir1/dir2/dir3/a\.dat": not a regular file' pull.log -+ -+ assert_local_object "$contents_oid" 1 -+ -+ [ -L "a.dat" ] -+ [ -L "dir1/dir2/dir3/a.dat" ] -+ [ -d "../link1" ] -+ [ ! -e "../link2" ] -+ assert_clean_index -+ -+ rm -rf .git/lfs/objects -+ -+ rm -rf a.dat dir1/dir2/dir3/a.dat link* -+ mkdir link1 -+ ln -s link1 a.dat -+ ln -s ../../../link2 dir1/dir2/dir3/a.dat -+ -+ 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 '"a\.dat": not a regular file' pull.log -+ grep '"dir1/dir2/dir3/a\.dat": not a regular file' pull.log -+ -+ assert_local_object "$contents_oid" 1 -+ -+ [ -L "a.dat" ] -+ [ -L "dir1/dir2/dir3/a.dat" ] -+ [ -d "link1" ] -+ [ ! -e "link2" ] -+ assert_clean_index -+ -+ rm -rf .git/lfs/objects -+ -+ pushd dir1/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 '"a\.dat": not a regular file' pull.log -+ grep '"dir1/dir2/dir3/a\.dat": not a regular file' pull.log -+ popd -+ -+ assert_local_object "$contents_oid" 1 -+ -+ [ -L "a.dat" ] -+ [ -L "dir1/dir2/dir3/a.dat" ] -+ [ -d "link1" ] -+ [ ! -e "link2" ] -+ assert_clean_index -+) -+end_test -+ -+# This test applies to case-preserving but case-insensitive filesystems, -+# such as APFS and NTFS when in their default configurations. -+# On case-sensitive filesystems this test has no particular value and -+# should always pass. -+begin_test "pull: skip case-based symlink conflicts" -+( -+ set -e -+ -+ skip_if_symlinks_unsupported -+ -+ # Only test with Git version 2.20.0 as it introduced detection of -+ # case-insensitive filesystems to the "git clone" command, which the -+ # test depends on to determine the filesystem type. -+ ensure_git_version_isnt "$VERSION_LOWER" "2.20.0" -+ -+ reponame="pull-skip-case-symlink-conflicts" -+ setup_remote_repo "$reponame" -+ clone_repo "$reponame" "$reponame" -+ -+ mkdir dir1 -+ ln -s ../link1 A.dat -+ ln -s ../../link2 dir1/a.dat -+ -+ git add A.dat dir1 -+ git commit -m "initial commit" -+ -+ rm A.dat dir1/a.dat -+ -+ echo "*.dat filter=lfs diff=lfs merge=lfs -text" >.gitattributes -+ -+ contents="a" -+ contents_oid="$(calc_oid "$contents")" -+ printf "%s" "$contents" >a.dat -+ printf "%s" "$contents" >dir1/A.dat -+ -+ git -c core.ignoreCase=false add .gitattributes a.dat dir1/A.dat -+ git commit -m "case-conflicting commit" -+ -+ git push origin main -+ assert_server_object "$reponame" "$contents_oid" -+ -+ cd .. -+ GIT_LFS_SKIP_SMUDGE=1 git clone "$GITSERVER/$reponame" "${reponame}-assert" 2>&1 | tee clone.log -+ if [ "0" -ne "${PIPESTATUS[0]}" ]; then -+ echo >&2 "fatal: expected clone to succeed ..." -+ exit 1 -+ fi -+ collision="$(grep -c "collided" clone.log)" || true -+ -+ cd "${reponame}-assert" -+ refute_local_object "$contents_oid" 1 -+ -+ rm -rf *.dat dir1 ../link* -+ -+ git lfs pull -+ -+ assert_local_object "$contents_oid" 1 -+ -+ [ -f "a.dat" ] -+ [ "$contents" = "$(cat "a.dat")" ] -+ [ -f "dir1/A.dat" ] -+ [ "$contents" = "$(cat "dir1/A.dat")" ] -+ [ ! -e "../link1" ] -+ [ ! -e "../link2" ] -+ assert_clean_index -+ -+ rm -rf a.dat dir1/A.dat -+ git checkout -- A.dat dir1/a.dat -+ -+ git lfs pull 2>&1 | tee pull.log -+ if [ "0" -ne "${PIPESTATUS[0]}" ]; then -+ echo >&2 "fatal: expected pull to succeed ..." -+ exit 1 -+ fi -+ if [ "$collision" -gt "0" ]; then -+ # case-insensitive filesystem -+ grep '"a\.dat": not a regular file' pull.log -+ grep '"dir1/A\.dat": not a regular file' pull.log -+ fi -+ -+ if [ "$collision" -eq "0" ]; then -+ # case-sensitive filesystem -+ [ -f "a.dat" ] -+ [ "$contents" = "$(cat "a.dat")" ] -+ [ -f "dir1/A.dat" ] -+ [ "$contents" = "$(cat "dir1/A.dat")" ] -+ else -+ # case-insensitive filesystem -+ [ -L "a.dat" ] -+ [ -L "dir1/A.dat" ] -+ fi -+ [ ! -e "../link1" ] -+ [ ! -e "../link2" ] -+ assert_clean_index -+) -+end_test -+ -+begin_test "pull: skip changed files" -+( -+ set -e -+ -+ reponame="pull-skip-changed-files" -+ setup_remote_repo "$reponame" -+ clone_repo "$reponame" "$reponame" -+ -+ git lfs track "*.dat" -+ -+ contents="a" -+ contents_oid="$(calc_oid "$contents")" -+ printf "%s" "$contents" >a.dat -+ -+ git add .gitattributes a.dat -+ 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 -+ -+ contents_new="$contents +extra" -+ printf "%s" "$contents_new" >a.dat -+ -+ git lfs pull -+ assert_local_object "$contents_oid" 1 -+ -+ [ "$contents_new" = "$(cat a.dat)" ] -+ assert_clean_index -+ -+ rm a.dat -+ mkdir a.dat -+ -+ rm -rf .git/lfs/objects -+ git lfs pull -+ assert_local_object "$contents_oid" 1 -+ -+ [ -d "a.dat" ] -+ assert_clean_index -+ - rm -rf .git/lfs/objects -- rm -rf a.dat "á.dat" "dir1/dir.dat" dir2 -- pushd dir1 -+ -+ pushd a.dat - git lfs pull - popd -- [ "a" = "$(cat a.dat)" ] -- [ "A" = "$(cat "á.dat")" ] -- [ "dir" = "$(cat "dir1/dir.dat")" ] -- [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] -+ - assert_local_object "$contents_oid" 1 -- assert_local_object "$contents2_oid" 1 -- assert_local_object "$contents3_oid" 3 -+ -+ [ -d "a.dat" ] -+ assert_clean_index -+) -+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 - -- echo "lfs pull in subdir with -I" -+ rm a.dat dir1/dir2/dir3/a.dat -+ ln ../link a.dat -+ ln ../link dir1/dir2/dir3/a.dat -+ - rm -rf .git/lfs/objects -- rm -rf a.dat "á.dat" "dir1/dir.dat" dir2 -- pushd dir1 -- git lfs pull -I "á.*,dir1/dir.dat,dir2/**" -- popd -- [ ! -e a.dat ] -- [ "A" = "$(cat "á.dat")" ] -- [ "dir" = "$(cat "dir1/dir.dat")" ] -- [ "dir" = "$(cat "dir2/dir3/dir4/dir.dat")" ] -- refute_local_object "$contents_oid" -- assert_local_object "$contents2_oid" 1 -- assert_local_object "$contents3_oid" 3 -- assert_clean_worktree_with_exceptions "a\.dat" - -- pushd dir1 -- git lfs pull -I "*.dat" -+ pushd dir1/dir2 -+ git lfs pull - popd -- [ "a" = "$(cat a.dat)" ] -+ - 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 -@@ -401,7 +904,6 @@ begin_test "pull: with missing object" - # this clone is setup in the first test in this file - cd clone - rm -rf .git/lfs/objects -- rm a.dat - - contents_oid=$(calc_oid "a") - reponame="$(basename "$0" ".sh")" -@@ -415,7 +917,7 @@ begin_test "pull: with missing object" - pull_exit="${PIPESTATUS[0]}" - [ "$pull_exit" != "0" ] - -- grep "$contents_oid does not exist" pull.log -+ grep "$contents_oid" pull.log - - contents2_oid=$(calc_oid "A") - assert_local_object "$contents2_oid" 1 -@@ -532,63 +1034,3 @@ begin_test "pull with partial clone and sparse checkout and index" - refute_local_object "$contents3_oid" - ) - end_test -- --begin_test "pull: pointer extension" --( -- set -e -- -- reponame="pull-pointer-extension" -- setup_remote_repo "$reponame" -- clone_repo "$reponame" "$reponame" -- -- git lfs track "*.dat" -- -- setup_case_inverter_extension -- -- contents="abc" -- inverted_contents_oid="$(calc_oid "$(invert_case "$contents")")" -- mkdir dir1 -- printf "%s" "$contents" >dir1/abc.dat -- -- git add .gitattributes dir1 -- git commit -m "initial commit" -- -- git push origin main -- assert_server_object "$reponame" "$inverted_contents_oid" -- -- cd .. -- GIT_LFS_SKIP_SMUDGE=1 git clone "$GITSERVER/$reponame" "${reponame}-assert" -- -- cd "${reponame}-assert" -- refute_local_object "$inverted_contents_oid" -- -- setup_case_inverter_extension -- -- rm -rf dir1 "$LFSTEST_EXT_LOG" -- git lfs pull -- -- assert_local_object "$inverted_contents_oid" 3 -- -- [ "$contents" = "$(cat "dir1/abc.dat")" ] -- grep "smudge: dir1/abc.dat" "$LFSTEST_EXT_LOG" -- -- rm -rf .git/lfs/objects -- -- rm -rf dir1 "$LFSTEST_EXT_LOG" -- mkdir dir2 -- -- pushd dir2 -- git lfs pull -- popd -- -- [ "$contents" = "$(cat "dir1/abc.dat")" ] -- -- # Note that at present we expect "git lfs pull" to run the extension -- # program in the current working directory rather than the repository root, -- # as would occur if it was run within a smudge filter operation started -- # by Git. -- grep "smudge: ../dir1/abc.dat" "$LFSTEST_EXT_LOG" -- -- assert_local_object "$inverted_contents_oid" 3 --) --end_test -diff --git a/t/testhelpers.sh b/t/testhelpers.sh -index 61310900..a92ff66d 100644 ---- a/t/testhelpers.sh -+++ b/t/testhelpers.sh -@@ -895,6 +895,10 @@ has_native_symlinks() { - fi - } - -+skip_if_symlinks_unsupported() { -+ has_native_symlinks || exit 0 -+} -+ - add_symlink() { - local src=$1 - local dest=$2 --- -2.51.1 - - -From 204e9f798f3693b8ab92234533bcbddf9dfd4594 Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Sun, 18 May 2025 18:59:40 -0700 -Subject: [PATCH 09/21] t/t-{checkout,pull}.sh: read-only test for pull - -In commit 686bda3722f12293f345240532f666b6a0961bb2 of PR #3120 we -revised the SmudgeToFile() method of the GitFilter structure in our -"lfs" package to try to update to read-only files while preserving -their read-only permissions. - -The SmudgeToFile() method was changed so it starts by calling the -Stat() function of the Go standard library's "os" package to read -the file's current permissions, and then calls the Chmod() function -to add write permissions if the file does not have them yet. If that -succeeds, a deferred call to the Chmod() function is registered which -will remove write permissions from the file when our method returns. - -This change was made specifically to handle the case which may occur -when our "lockable" Git attribute applies to a given path, the file -at that path contains only a Git LFS pointer and not the corresponding -object data, and the user has not used the "git lfs lock" command to -acquire a lock on the file, so the file has read-only permissions. - -In the same commit, we also added a new test to what is now our -t/t-checkout.sh test script to validate the new behaviour of the -"git lfs checkout" command. However, we did not add an equivalent -test of the "git lfs pull" command to its test script, although this -command should exhibit the same behaviour when handling read-only -files as the "git lfs checkout" command. - -As we expect to alter how the SmudgeToFile() method and its callers -check and create files in subsequent commits, we first add a test -to the t/t-pull.sh test script which verifies the "git lfs pull" -command's treatment of read-only files. - -We also update the existing test in the t/t-checkout.sh test script -to correct a typo in the test's name, to add an "a" symbol to the -chmod(1) command's argument to clarify that it removes write permissions -for all users, and to use the same naming scheme as a number of our -other tests for the directory into which the test repository is cloned. ---- - t/t-checkout.sh | 52 +++++++++++++++++++++++++++++++-- - t/t-pull.sh | 76 +++++++++++++++++++++++++++++++++++++++++++++++++ - 2 files changed, 125 insertions(+), 3 deletions(-) - -diff --git a/t/t-checkout.sh b/t/t-checkout.sh -index aaeabd59..49d952c4 100755 ---- a/t/t-checkout.sh -+++ b/t/t-checkout.sh -@@ -425,7 +425,53 @@ begin_test "checkout: outside git repository" - ) - end_test - --begin_test "checkout: write-only file" -+begin_test "checkout: read-only directory" -+( -+ set -e -+ -+ skip_if_root_or_admin "$test_description" -+ -+ reponame="checkout-read-only" -+ git init "$reponame" -+ cd "$reponame" -+ -+ git lfs track "*.bin" -+ -+ contents="a" -+ contents_oid=$(calc_oid "$contents") -+ mkdir dir -+ printf "%s" "$contents" > dir/a.bin -+ -+ git add .gitattributes dir/a.bin -+ git commit -m "add dir/a.bin" -+ -+ rm dir/a.bin -+ -+ if [ "$IS_WINDOWS" -eq 1 ]; then -+ icacls dir /inheritance:r -+ icacls dir /grant:r Everyone:R -+ else -+ chmod a-w dir -+ fi -+ git lfs checkout 2>&1 | tee checkout.log -+ # Note that although the checkout command should log an error, at present -+ # we still expect a zero exit code. -+ if [ "0" -ne "${PIPESTATUS[0]}" ]; then -+ echo >&2 "fatal: expected 'git lfs checkout' to succeed ..." -+ exit 1 -+ fi -+ -+ assert_local_object "$contents_oid" 1 -+ -+ [ ! -e dir/a.bin ] -+ -+ grep 'could not check out "dir/a.bin"' checkout.log -+ grep 'could not create working directory file' checkout.log -+ grep 'permission denied' checkout.log -+) -+end_test -+ -+begin_test "checkout: read-only file" - ( - set -e - -@@ -435,9 +481,9 @@ begin_test "checkout: write-only file" - setup_remote_repo_with_file "$reponame" "$filename" - - pushd "$TRASHDIR" > /dev/null -- GIT_LFS_SKIP_SMUDGE=1 clone_repo "$reponame" "${reponame}_checkout" -+ GIT_LFS_SKIP_SMUDGE=1 clone_repo "$reponame" "${reponame}-assert" - -- chmod -w "$filename" -+ chmod a-w "$filename" - - refute_file_writeable "$filename" - assert_pointer "refs/heads/main" "$filename" "$(calc_oid "$filename\n")" 6 -diff --git a/t/t-pull.sh b/t/t-pull.sh -index 04e2f8d9..755dc063 100755 ---- a/t/t-pull.sh -+++ b/t/t-pull.sh -@@ -941,6 +941,82 @@ begin_test "pull: outside git repository" - ) - end_test - -+begin_test "pull: read-only directory" -+( -+ set -e -+ -+ skip_if_root_or_admin "$test_description" -+ -+ reponame="pull-read-only" -+ setup_remote_repo "$reponame" -+ clone_repo "$reponame" "$reponame" -+ -+ git lfs track "*.bin" -+ -+ contents="a" -+ contents_oid=$(calc_oid "$contents") -+ mkdir dir -+ printf "%s" "$contents" > dir/a.bin -+ -+ git add .gitattributes dir/a.bin -+ git commit -m "add dir/a.bin" -+ -+ git push origin main -+ -+ assert_server_object "$reponame" "$contents_oid" -+ -+ rm dir/a.bin -+ delete_local_object "$contents_oid" -+ -+ if [ "$IS_WINDOWS" -eq 1 ]; then -+ icacls dir /inheritance:r -+ icacls dir /grant:r Everyone:R -+ else -+ chmod a-w dir -+ fi -+ git lfs pull 2>&1 | tee pull.log -+ # Note that although the pull command should log an error, at present -+ # we still expect a zero exit code. -+ if [ "0" -ne "${PIPESTATUS[0]}" ]; then -+ echo >&2 "fatal: expected 'git lfs pull' to succeed ..." -+ exit 1 -+ fi -+ -+ assert_local_object "$contents_oid" 1 -+ -+ [ ! -e dir/a.bin ] -+ -+ grep 'could not check out "dir/a.bin"' pull.log -+ grep 'could not create working directory file' pull.log -+ grep 'permission denied' pull.log -+) -+end_test -+ -+begin_test "pull: read-only file" -+( -+ set -e -+ -+ reponame="pull-locked" -+ filename="a.txt" -+ -+ setup_remote_repo_with_file "$reponame" "$filename" -+ -+ pushd "$TRASHDIR" > /dev/null -+ GIT_LFS_SKIP_SMUDGE=1 clone_repo "$reponame" "${reponame}-assert" -+ -+ chmod a-w "$filename" -+ -+ refute_file_writeable "$filename" -+ assert_pointer "refs/heads/main" "$filename" "$(calc_oid "$filename\n")" 6 -+ -+ git lfs pull -+ -+ refute_file_writeable "$filename" -+ [ "$filename" = "$(cat "$filename")" ] -+ popd > /dev/null -+) -+end_test -+ - begin_test "pull with empty file doesn't modify mtime" - ( - set -e --- -2.51.1 - - -From 364476cc97f6e6529aea75f2982e6f474b0f755a Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Sun, 18 May 2025 22:59:02 -0700 -Subject: [PATCH 10/21] t/t-checkout.sh: add empty file mtime test - -In commit 4c782ba772ed0137a69bd3a61b74c2cfb3805fd2 of PR #5491 we -revised the SmudgeToFile() method of the GitFilter structure in our -"lfs" package to try to avoid altering the modification timestamps -of empty files when they correspond to empty files in the Git reference -being checked out by the "git lfs pull" command. - -The SmudgeToFile() method was changed so that if the Stat() function -from the Go standard library finds a file with a size of zero, and the -Git LFS pointer passed to the SmudgeToFile() method also has a size of -zero, the method takes no further action and simply returns. - -In the same commit we also added a new test to the t/t-pull.sh test -script which validates the new behaviour of the "git lfs pull" command. -However, we did not add an equivalent test of the "git lfs checkout" -command, although this command should exhibit the same behaviour when -handling empty files as the "git lfs pull" command. - -As we expect to alter how the SmudgeToFile() method and its callers -check and create files in subsequent commits, we first add a test to -the t/t-checkout.sh test script which verifies the "git lfs checkout" -command's treatment of empty files. ---- - t/t-checkout.sh | 27 +++++++++++++++++++++++++++ - 1 file changed, 27 insertions(+) - -diff --git a/t/t-checkout.sh b/t/t-checkout.sh -index 49d952c4..09ab3beb 100755 ---- a/t/t-checkout.sh -+++ b/t/t-checkout.sh -@@ -497,6 +497,33 @@ begin_test "checkout: read-only file" - ) - end_test - -+begin_test "checkout with empty file doesn't modify mtime" -+( -+ set -e -+ git init checkout-empty-file -+ cd checkout-empty-file -+ -+ git lfs track "*.bin" -+ git add . -+ git commit -m 'gitattributes' -+ printf abc > abc.bin -+ git add . -+ git commit -m 'abc' -+ -+ touch foo.bin -+ lfstest-nanomtime foo.bin >foo.mtime -+ -+ # This isn't necessary, but it takes a few cycles to make sure that our -+ # timestamp changes. -+ git add foo.bin -+ git commit -m 'foo' -+ -+ git lfs checkout -+ lfstest-nanomtime foo.bin >foo.mtime2 -+ diff -u foo.mtime foo.mtime2 -+) -+end_test -+ - begin_test "checkout: conflicts" - ( - set -e --- -2.51.1 - - -From 88f7cb9b96c449f62f6708d4146eeefe3df10201 Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Mon, 6 Oct 2025 12:25:45 -0700 -Subject: [PATCH 11/21] docs,t: expand checkout and pull sparse cone tests - -In commit 5aa7be5ce77c53ec6a72ada9b4504aabf4eb6b7b of PR #5796 we added -tests of the sparse checkout support provided by our "git lfs checkout" -and "git lfs pull" commands, which makes use of the "git ls-files" -command and the --sparse option that was introduced for that command -in Git v2.35.0. - -In practice, the "git lfs checkout" and "git lfs pull" commands require -Git v2.42.0 or higher to be available before they invoke "git ls-files", -and otherwise fall back to using the "git ls-tree" command. We require -at least Git v2.42.0 because that version introduced support for the -"objecttype" field name in the "git ls-files" command's --format option -and we depend on that field to be able to mimic the output format of -the "git ls-tree" command with the "git ls-files" command. We noted -these details in commit beae1146bda7e9fa712c3c73dacbcf9ed5067fd9 of -PR #5699, when we revised the runScanLFSFiles() function in our "lfs" -package to choose between the use of "git ls-files" and "git ls-tree". - -One difference between the "git ls-files" and "git ls-tree" commands, -however, is that the former lists the files in the Git index (since we -always pass the --cached option) while the latter lists the files in -the Git tree associated with a given reference, which in the case of our -"git lfs checkout" and "git lfs pull" commands is always the current -"HEAD" symbolic reference. - -As a consequence, as discussed in issue #6004, if certain files are absent -from the current working tree and Git index as the result of a partial -clone or sparse checkout, the behaviour of the "git lfs checkout" and -"git lfs pull" commands varies depending on the installed version of Git. - -If Git v2.42.0 or higher is installed, the "git lfs checkout" and -"git lfs pull" commands invoke the "git ls-files" command and provide -an "attr:filter=lfs" pathspec so the Git command will filter out files -which do not match a Git LFS filter attribute. However, in order to -be reported, Git LFS pointer files must exist in the Git index; if -they only appear in the working tree or the Git tree associated with -the "HEAD" reference, they will be ignored. - -(Note that in a non-bare repository, the "git ls-files" command will only -match the "attr:filter=lfs" pathspec against attributes defined in -".gitattributes" files in the index or working tree, plus any local files -such as the "$GIT_DIR/info/attributes" file. Any ".gitattributes" files -that are present only in the Git tree associated with the "HEAD" reference -will not be consulted. In a bare repository, meanwhile, the "git ls-files" -command will by default not match the pathspec against attributes defined -in ".gitattributes" files at all, regardless of whether such files exist -in the index or in the tree referenced by "HEAD".) - -If a version of Git older than v2.42.0 is installed and so the -"git ls-tree" command is invoked instead of the "git ls-files" command, -then a full list of the files in the tree-ish referenced by "HEAD" is -returned. The "git lfs checkout" and "git lfs pull" commands will then -attempt to check out the Git LFS objects associated with all the Git LFS -pointer files found in this list. In the case of the "git lfs pull" -command, it will also try to fetch those objects if they are not already -present in the local storage directories. - -(Note, though, that when the "git lfs checkout" and "git lfs pull" commands -retrieve a list of files using the "git ls-tree" command, they do not -check whether the pointer files they find in that list actually match -Git LFS filter attributes in any ".gitattributes" or other Git attributes -files. So a user may remove all the ".gitattributes" files from their -working tree and index, commit those changes to "HEAD", and the Git LFS -commands will still attempt to check out objects for any files found in -the "HEAD" commit's tree that can be parsed as valid pointers. When the -"git ls-files" command is used instead of the "git ls-tree" command to -retrieve a file list, this legacy behaviour does not occur, because the -"attr:filter=lfs" pathspec requires that the "git ls-files" command -only return a list of files which match at least one Git LFS filter -attribute.) - -In subsequent commits we will alter how the "git lfs checkout" and -"git lfs pull" commands operate within bare repositories and how they -handle file paths, including by changing the current working directory -to the root of the current working tree, if one is present. Of necessity, -our tests and documentation will also be expanded to reflect the variable -behaviour of the "git lfs pull" command in particular, since its effects -in a bare repository depend in part on the installed version of Git. - -Before we make these changes, we first revise our existing tests of -the "git lfs checkout" and "git lfs pull" commands with partial clones -and sparse checkouts so that the tests confirm the key differences in -behaviour when the installed version of Git is v2.42.0 or higher. -Our tests now demonstrate that with an older version of Git, objects -will be fetched (in the case of the "git lfs pull" command) and checked -out for all Git LFS files, including those outside the configured -sparse cone. - -We also update the manual pages for these commands to include an -explanation of how their operation varies depending on the installed -version of Git, how this may affect repositories with partial clones -and sparse checkouts, and the options available to users if they find -the "git lfs checkout" and "git lfs pull" commands appear to be ignoring -certain files. - -As well, we edit the initial section in our git-lfs-pull(1) manual page -where we incorrectly state that the command is always equivalent -to running "git lfs fetch" followed by "git lfs checkout", and fix the -formatting of the example commands provided in this section. - -When we converted our manual page source files from the Ronn format to -AsciiDoc in commit 0c66dcf15d988802255c3133cd5ab8105cbf0ef7 of PR #5054, -the two example commands in this section were accidentally merged onto -a single line, and the "" option for the "git lfs fetch" command -was elided. - -We therefore restore the original version of these two example commands -and add leading shell prompt indicators to further clarify that the -example includes two separate commands. ---- - docs/man/git-lfs-checkout.adoc | 18 ++++++++++++- - docs/man/git-lfs-pull.adoc | 27 ++++++++++++++++--- - t/t-checkout.sh | 48 ++++++++++++++++++++++++++-------- - t/t-pull.sh | 38 +++++++++++++++++++++------ - 4 files changed, 107 insertions(+), 24 deletions(-) - -diff --git a/docs/man/git-lfs-checkout.adoc b/docs/man/git-lfs-checkout.adoc -index 214198b5..38c2e3be 100644 ---- a/docs/man/git-lfs-checkout.adoc -+++ b/docs/man/git-lfs-checkout.adoc -@@ -32,6 +32,22 @@ 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 - ``. - -+If the installed Git version is at least 2.42.0, -+this command will by default check out 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 `.gitattributes` file that is present in either -+the index or the current working tree (or, as is always the case, if -+they match a Git LFS filter attribute in a local `gitattributes` file -+such as `$GIT_DIR/info/attributes`). These constraints do not apply -+with prior versions of Git. -+ -+In a repository with a partial clone or sparse checkout, it is therefore -+advisable to check out all `.gitattributes` files from `HEAD` before -+using this command, if Git v2.42.0 or later is installed. Alternatively, -+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. -+ - == OPTIONS - - `--base`:: -@@ -84,6 +100,6 @@ $ git merge --continue - - == SEE ALSO - --git-lfs-fetch(1), git-lfs-pull(1), gitignore(5). -+git-lfs-fetch(1), git-lfs-pull(1), gitattributes(5), gitignore(5). - - Part of the git-lfs(1) suite. -diff --git a/docs/man/git-lfs-pull.adoc b/docs/man/git-lfs-pull.adoc -index 7d4539de..5d3fd5dd 100644 ---- a/docs/man/git-lfs-pull.adoc -+++ b/docs/man/git-lfs-pull.adoc -@@ -13,9 +13,28 @@ git-lfs-pull - Download all Git LFS files for current ref & checkout - Download Git LFS objects for the currently checked out ref, and update - the working copy with the downloaded content if required. - --This is equivalent to running the following 2 commands: -- --git lfs fetch [options] [] git lfs checkout -+This is generally equivalent to running the following two commands: -+ -+.... -+$ git lfs fetch [options] [] -+$ git lfs checkout -+.... -+ -+If the installed Git version is at least 2.42.0, in a non-bare repository -+this command will by default fetch and check out 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 `.gitattributes` file that is present in either -+the index or the current working tree (or, as is always the case, if -+they match a Git LFS filter attribute in a local `gitattributes` file -+such as `$GIT_DIR/info/attributes`). These constraints do not apply -+with prior versions of Git. -+ -+In a repository with a partial clone or sparse checkout, it is therefore -+advisable to check out all `.gitattributes` files from `HEAD` before -+using this command, if Git v2.42.0 or later is installed. Alternatively, -+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. - - == OPTIONS - -@@ -52,6 +71,6 @@ you're tracking first, or origin otherwise. - - == SEE ALSO - --git-lfs-fetch(1), git-lfs-checkout(1), gitignore(5). -+git-lfs-fetch(1), git-lfs-checkout(1), gitattributes(5), gitignore(5). - - Part of the git-lfs(1) suite. -diff --git a/t/t-checkout.sh b/t/t-checkout.sh -index 09ab3beb..55bafb09 100755 ---- a/t/t-checkout.sh -+++ b/t/t-checkout.sh -@@ -644,10 +644,11 @@ begin_test "checkout: sparse with partial clone and sparse index" - ( - set -e - -- # Only test with Git version 2.42.0 as it introduced support for the -- # "objecttype" format option to the "git ls-files" command, which our -- # code requires. -- ensure_git_version_isnt "$VERSION_LOWER" "2.42.0" -+ # Only test with Git version 2.25.0 as it introduced the -+ # "git sparse-checkout" command. (Note that this test also requires -+ # that the "git rev-list" command support the "tree:0" filter, which -+ # was introduced with Git version 2.20.0.) -+ ensure_git_version_isnt "$VERSION_LOWER" "2.25.0" - - reponame="checkout-sparse" - setup_remote_repo "$reponame" -@@ -694,30 +695,55 @@ begin_test "checkout: sparse with partial clone and sparse index" - assert_local_object "$contents2_oid" 1 - refute_local_object "$contents3_oid" - -- # Git LFS objects associated with files outside of the sparse cone -- # should be ignored entirely, rather than just skipped. - 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 'Skipped checkout for "out-dir/c.dat"' checkout.log && exit 1 -+ -+ # When Git version 2.42.0 or higher is available, the "git lfs checkout" -+ # command will use the "git ls-files" command rather than the -+ # "git ls-tree" command to list files. Git v2.42.0 introduced support -+ # in the "git ls-files" command for the "objecttype" format option and -+ # so Git LFS can use this command to avoid checking out objects outside -+ # the sparse cone. Otherwise, all Git LFS objects will be checked out. -+ gitversion="$(git version | cut -d" " -f3)" -+ set +e -+ compare_version "$gitversion" '2.42.0' -+ result=$? -+ set -e -+ if [ "$result" -eq "$VERSION_LOWER" ]; then -+ grep 'Skipped checkout for "out-dir/c.dat"' checkout.log -+ -+ [ -f "out-dir/c.dat" ] -+ [ "$(pointer $contents3_oid 1)" = "$(cat "out-dir/c.dat")" ] -+ else -+ grep -q 'Skipped checkout for "out-dir/c.dat"' checkout.log && exit 1 -+ -+ [ ! -e "out-dir/c.dat" ] -+ fi - - # Fetch all Git LFS objects, including those outside the sparse cone. - git lfs fetch origin main - - assert_local_object "$contents3_oid" 1 - -- # Git LFS objects associated with files outside of the sparse cone -- # should not be checked out. - 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% (3/3), 3 B' checkout.log && exit 1 - -- [ ! -e "out-dir/c.dat" ] -+ if [ "$result" -eq "$VERSION_LOWER" ]; then -+ grep 'Checking out LFS objects: 100% (3/3), 3 B' checkout.log -+ -+ [ -f "out-dir/c.dat" ] -+ [ "$contents3" = "$(cat "out-dir/c.dat")" ] -+ else -+ grep -q 'Checking out LFS objects: 100% (3/3), 3 B' checkout.log && exit 1 -+ -+ [ ! -e "out-dir/c.dat" ] -+ fi - ) - end_test - -diff --git a/t/t-pull.sh b/t/t-pull.sh -index 755dc063..4e942183 100755 ---- a/t/t-pull.sh -+++ b/t/t-pull.sh -@@ -1048,10 +1048,11 @@ begin_test "pull with partial clone and sparse checkout and index" - ( - set -e - -- # Only test with Git version 2.42.0 as it introduced support for the -- # "objecttype" format option to the "git ls-files" command, which our -- # code requires. -- ensure_git_version_isnt "$VERSION_LOWER" "2.42.0" -+ # Only test with Git version 2.25.0 as it introduced the -+ # "git sparse-checkout" command. (Note that this test also requires -+ # that the "git rev-list" command support the "tree:0" filter, which -+ # was introduced with Git version 2.20.0.) -+ ensure_git_version_isnt "$VERSION_LOWER" "2.25.0" - - reponame="pull-sparse" - setup_remote_repo "$reponame" -@@ -1098,15 +1099,36 @@ begin_test "pull with partial clone and sparse checkout and index" - assert_local_object "$contents2_oid" 1 - refute_local_object "$contents3_oid" - -- # Git LFS objects associated with files outside of the sparse cone -- # should not be pulled. - 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 -q "Downloading LFS objects" pull.log && exit 1 - -- refute_local_object "$contents3_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. Git v2.42.0 introduced support -+ # in the "git ls-files" command for the "objecttype" format option and -+ # so Git LFS can use this command to avoid pulling objects outside -+ # the sparse cone. Otherwise, all Git LFS objects will be pulled. -+ 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 -+ -+ [ -f "out-dir/c.dat" ] -+ [ "$contents3" = "$(cat "out-dir/c.dat")" ] -+ -+ assert_local_object "$contents3_oid" 1 -+ else -+ grep -q "Downloading LFS objects" pull.log && exit 1 -+ -+ [ ! -e "out-dir" ] -+ -+ refute_local_object "$contents3_oid" -+ fi - ) - end_test --- -2.51.1 - - -From 8596415dfa84522478a7e0a768afd4c93c002b7d Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Sun, 10 Aug 2025 16:23:59 -0700 -Subject: [PATCH 12/21] commands,t: log rooted paths on checkout and pull - -Our "git lfs checkout" and "git lfs pull" commands call the -DecodePointerFromFile() function from our "lfs" package to check whether -a file is present at a given path in the repository's current working -tree, and if so, whether that file contains a valid Git LFS pointer. - -The DecodePointerFromFile() function is called by the Run() method of -the singleCheckout structure in our "commands" package when these -"git lfs checkout" and "git lfs pull" commands are executed. If the -DecodePointerFromFile() function returns an error, the Run() method -behaves differently depending on the type of error. - -If the error is not one for which the Run() method performs any special -action, the method logs the error with a simple "Checkout error" message, -along with a relative path to the file from the current working directory. - -In subsequent commits we expect to adjust how our "git lfs checkout" and -"git lfs pull" commands handle file paths, including by changing the -current working directory to the root of the current working tree before -writing files into that work tree. As a consequence, these commands will -pass only paths relative to the root of the repository to the -SmudgeToFile() method of the GitFilter structure in our "lfs" package. -We will also enhance these commands to test for symbolic links in the -path between the root of the work tree and a given file, and will -report these links using a new log message format. - -Before we make these changes, we first revise the format of the log -messages and file paths that are output when the DecodePointerFromFile() -function returns an error which the Run() method does not handle with -any special action. Specifically, we now report a file path which is -relative to the root of the repository and not relative to the current -working directory. - -On Unix systems, these log messages are the ones reported when an -ancestor component of a file's path is found to be a file and not a -directory, and the Stat() method of the "os" package in the Go -standard library returns an ENOTDIR error number. - -As noted above, in subsequent commits we will enhance the "git lfs -checkout" and "git lfs pull" commands to detect similar conditions -involving symbolic links. We would like the log messages and file -paths output by the commands under all these types of conditions to be -as consistent as possible, which is why we first update our existing -log messages to the format we expect to use in the future. ---- - commands/pull.go | 2 +- - t/t-checkout.sh | 14 +++++++------- - t/t-pull.sh | 14 +++++++------- - 3 files changed, 15 insertions(+), 15 deletions(-) - -diff --git a/commands/pull.go b/commands/pull.go -index 9c64798c..74c4b2e6 100644 ---- a/commands/pull.go -+++ b/commands/pull.go -@@ -88,7 +88,7 @@ func (c *singleCheckout) Run(p *lfs.WrappedPointer) { - return - } - -- LoggedError(err, tr.Tr.Get("Checkout error: %s", err)) -+ LoggedError(err, tr.Tr.Get("Checkout error for %q: %s", p.Name, err)) - return - } - } -diff --git a/t/t-checkout.sh b/t/t-checkout.sh -index 55bafb09..f70c8f73 100755 ---- a/t/t-checkout.sh -+++ b/t/t-checkout.sh -@@ -195,8 +195,8 @@ begin_test "checkout: skip directory file conflicts" - 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: stat dir1/a\.dat' checkout.log -- grep 'Checkout error: stat dir2/dir3/dir4/a\.dat' checkout.log -+ grep 'Checkout error for "dir1/a\.dat": stat' checkout.log -+ grep 'Checkout error for "dir2/dir3/dir4/a\.dat": stat' checkout.log - fi - - [ -f "dir1" ] -@@ -213,8 +213,8 @@ begin_test "checkout: skip directory file conflicts" - 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: stat \.\./dir1/a\.dat' checkout.log -- grep 'Checkout error: stat dir3/dir4/a\.dat' checkout.log -+ grep 'Checkout error for "dir1/a\.dat": stat' checkout.log -+ grep 'Checkout error for "dir2/dir3/dir4/a\.dat": stat' checkout.log - fi - popd - -@@ -261,7 +261,7 @@ begin_test "checkout: skip directory symlink conflicts" - 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: stat dir1/a\.dat' checkout.log -+ grep 'Checkout error for "dir1/a\.dat": stat' checkout.log - fi - grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' checkout.log - -@@ -284,7 +284,7 @@ begin_test "checkout: skip directory symlink conflicts" - 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: stat dir1/a\.dat' checkout.log -+ grep 'Checkout error for "dir1/a\.dat": stat' checkout.log - fi - grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' checkout.log - -@@ -303,7 +303,7 @@ begin_test "checkout: skip directory symlink conflicts" - 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: stat \.\./dir1/a\.dat' checkout.log -+ grep 'Checkout error for "dir1/a\.dat": stat' checkout.log - fi - grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' checkout.log - popd -diff --git a/t/t-pull.sh b/t/t-pull.sh -index 4e942183..8a7cf062 100755 ---- a/t/t-pull.sh -+++ b/t/t-pull.sh -@@ -197,8 +197,8 @@ begin_test "pull: skip directory file conflicts" - 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 -+ grep 'Checkout error for "dir1/a\.dat": stat' pull.log -+ grep 'Checkout error for "dir2/dir3/dir4/a\.dat": stat' pull.log - fi - - assert_local_object "$contents_oid" 1 -@@ -219,8 +219,8 @@ begin_test "pull: skip directory file conflicts" - 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 -+ grep 'Checkout error for "dir1/a\.dat": stat' pull.log -+ grep 'Checkout error for "dir2/dir3/dir4/a\.dat": stat' pull.log - fi - popd - -@@ -278,7 +278,7 @@ begin_test "pull: skip directory symlink conflicts" - 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 -+ grep 'Checkout error for "dir1/a\.dat": stat' pull.log - fi - grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' pull.log - -@@ -305,7 +305,7 @@ begin_test "pull: skip directory symlink conflicts" - 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 -+ grep 'Checkout error for "dir1/a\.dat": stat' pull.log - fi - grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' pull.log - -@@ -328,7 +328,7 @@ begin_test "pull: skip directory symlink conflicts" - 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 -+ grep 'Checkout error for "dir1/a\.dat": stat' pull.log - fi - grep 'could not check out "dir2/dir3/dir4/a\.dat": could not create working directory file' pull.log - popd --- -2.51.1 - - -From 21c7641a7a9b5ed8cf38852ac8c993cc4a470845 Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Sun, 10 Aug 2025 17:21:23 -0700 -Subject: [PATCH 13/21] check for file/symlink conflicts on checkout/pull - -Our "git lfs checkout" and "git lfs pull" commands, at present, -follow any extant symbolic links when they populate the current working -tree with files containing the content of Git LFS objects, even if -the symbolic links point to locations outside of the working tree. -This vulnerability has been assigned the identifier CVE-2025-26625. - -To partially address this vulnerability, we adjust the -DecodePointerFromBlob() function in our "lfs" package to use the Lstat() -function from the "os" package in the Go standard library instead of -the Stat() function. This ensures that the DecodePointerFromBlob() -function checks whether an irregular file or other directory entry -already exists at the location where the "git lfs checkout" and "git lfs -pull" commands intend to create or update a file. - -We then update a number of the tests that we added to the t/t-checkout.sh -and t/t-pull.sh test scripts in previous commits, and now also add -another pair of new tests to those scripts. - -First, we revise the "checkout: skip directory file conflicts", -"pull: skip directory file conflicts", "checkout: skip directory symlink -conflicts", and "pull: skip directory symlink conflicts" tests so that -when they run on Unix systems, they now expect the name of the lstat(2) -system call to appear in the log messages output by the "git lfs checkout" -and "git lfs pull" commands. Previously, these tests expected the name -of the stat(2) system call to appear in the commands' log messages. - -Next, we expand and revise the "checkout: skip file symlink conflicts" -and "pull: skip file symlink conflicts" tests so they confirm that the -respective commands try to avoid writing through symbolic links which -exist in the working tree at the locations where the commands intend to -create or update files, regardless of the nature of the links' targets. -In their initial form, these tests could only check the case where the -targets of the symbolic links were directories, but now they can also -check the commands' behaviour both when the links' targets do not exist -and when the targets are files which contain Git LFS pointers identical -to those of the corresponding paths in the Git repository. Previously, -in such cases the commands would create or update files at the locations -of the targets of the symbolic links. - -We then add two new tests, named "checkout: skip case-based symlink -conflicts" and "pull: skip case-based symlink conflicts", which confirm -that the respective commands do not write through symbolic links which -exist in the working tree at the locations where the commands intend to -create or update files, after those links are created by Git due to -filename conflicts on case-insensitive filesystems. Like the other -tests with symbolic links, we only run these new tests on Windows if -the current system supports the creation of true symbolic links. - -In both our new and revised tests we run the "git lfs checkout" and -"git lfs pull" commands at several directory levels in the working tree, -in order to exercise the ability for these commands to be run in any -subdirectory, a behaviour we have supported since PR #2641. We also -confirm that the commands do not add the paths of the symbolic links to -the Git index as they previously did because the commands assumed they -had updated regular files at those locations. - -Note that while our new check in the DecodePointerFromFile() function -avoids cases where a symbolic link already exists the working tree -before we try to create or update a file at the same location, this -check does not entirely prevent TOCTOU (time-of-check/time-of-use) -races where a symbolic link might be created immediately after we check -for its existence and before we attempt to create or open a file. - -In a subsequent commit we will address these concerns, at least in -part, by changing the SmudgeToFile() method of the GitFilter structure -in our "lfs" package to remove any existing file or link and always -create a new file with the O_EXCL flag. This should help ensure we only -ever create a new file and never write through a symlink that was added -immediately after the DecodePointerFromBlob() function ran. - -Finally, note that other than the "git lfs checkout" and "git lfs pull" -commands, the only other caller of the DecodePointerFromBlob() function -is the "git lfs merge-driver" command, which is guaranteed by the context -in which it runs to always open regular, temporary files created by Git. -For this reason, we do not need to expand the test suite for the -"git lfs merge-driver" command to check how it handles pre-existing -symbolic links. ---- - lfs/pointer.go | 6 +- - t/t-checkout.sh | 231 +++++++++++++++++++++++++++++++++++++++++++----- - t/t-pull.sh | 14 +-- - 3 files changed, 222 insertions(+), 29 deletions(-) - mode change 100755 => 100644 t/t-pull.sh - -diff --git a/lfs/pointer.go b/lfs/pointer.go -index c96b3373..8e88533d 100644 ---- a/lfs/pointer.go -+++ b/lfs/pointer.go -@@ -99,11 +99,13 @@ func DecodePointerFromBlob(b *gitobj.Blob) (*Pointer, error) { - - func DecodePointerFromFile(file string) (*Pointer, error) { - // Check size before reading -- stat, err := os.Stat(file) -+ stat, err := os.Lstat(file) - if err != nil { - return nil, err - } -- if stat.Size() >= blobSizeCutoff { -+ if !stat.Mode().IsRegular() { -+ return nil, errors.New(tr.Tr.Get("not a regular file: %q", file)) -+ } else if stat.Size() >= blobSizeCutoff { - return nil, errors.NewNotAPointerError(errors.New(tr.Tr.Get("file size exceeds Git LFS pointer size cutoff"))) - } - f, err := os.OpenFile(file, os.O_RDONLY, 0644) -diff --git a/t/t-checkout.sh b/t/t-checkout.sh -index f70c8f73..53e64a19 100755 ---- a/t/t-checkout.sh -+++ b/t/t-checkout.sh -@@ -195,8 +195,8 @@ begin_test "checkout: skip directory file conflicts" - 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": stat' checkout.log -- grep 'Checkout error for "dir2/dir3/dir4/a\.dat": stat' checkout.log -+ grep 'Checkout error for "dir1/a\.dat": lstat' checkout.log -+ grep 'Checkout error for "dir2/dir3/dir4/a\.dat": lstat' checkout.log - fi - - [ -f "dir1" ] -@@ -213,8 +213,8 @@ begin_test "checkout: skip directory file conflicts" - 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": stat' checkout.log -- grep 'Checkout error for "dir2/dir3/dir4/a\.dat": stat' checkout.log -+ grep 'Checkout error for "dir1/a\.dat": lstat' checkout.log -+ grep 'Checkout error for "dir2/dir3/dir4/a\.dat": lstat' checkout.log - fi - popd - -@@ -261,7 +261,7 @@ begin_test "checkout: skip directory symlink conflicts" - 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": stat' checkout.log -+ 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 - -@@ -284,7 +284,7 @@ begin_test "checkout: skip directory symlink conflicts" - 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": stat' checkout.log -+ 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 - -@@ -303,7 +303,7 @@ begin_test "checkout: skip directory symlink conflicts" - 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": stat' checkout.log -+ 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 - popd -@@ -316,8 +316,6 @@ begin_test "checkout: skip directory symlink 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 file symlink conflicts" - ( - set -e -@@ -332,42 +330,235 @@ begin_test "checkout: skip file symlink conflicts" - - 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 -+ git add .gitattributes a.dat dir1 - git commit -m "initial commit" - -- # test with symlink to directory -- rm -rf a.dat ../link1 -+ # test with symlinks to pointer files -+ rm -rf a.dat dir1/dir2/dir3/a.dat ../link* -+ contents_pointer="$(git cat-file -p ":a.dat")" -+ printf "%s" "$contents_pointer" >../link1 -+ printf "%s" "$contents_pointer" >../link2 -+ ln -s ../link1 a.dat -+ ln -s ../../../../link2 dir1/dir2/dir3/a.dat -+ -+ 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 '"a\.dat": not a regular file' checkout.log -+ grep '"dir1/dir2/dir3/a\.dat": not a regular file' checkout.log -+ -+ [ -L "a.dat" ] -+ [ -L "dir1/dir2/dir3/a.dat" ] -+ [ -f "../link1" ] -+ [ "$contents_pointer" = "$(cat ../link1)" ] -+ [ -f "../link2" ] -+ [ "$contents_pointer" = "$(cat ../link2)" ] -+ assert_clean_index -+ -+ rm -rf a.dat dir1/dir2/dir3/a.dat link* -+ printf "%s" "$contents_pointer" >link1 -+ printf "%s" "$contents_pointer" >link2 -+ ln -s link1 a.dat -+ ln -s ../../../link2 dir1/dir2/dir3/a.dat -+ -+ 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 '"a\.dat": not a regular file' checkout.log -+ grep '"dir1/dir2/dir3/a\.dat": not a regular file' checkout.log -+ -+ [ -L "a.dat" ] -+ [ -L "dir1/dir2/dir3/a.dat" ] -+ [ -f "link1" ] -+ [ "$contents_pointer" = "$(cat link1)" ] -+ [ -f "link2" ] -+ [ "$contents_pointer" = "$(cat link2)" ] -+ assert_clean_index -+ -+ pushd dir1/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 '"a\.dat": not a regular file' checkout.log -+ grep '"dir1/dir2/dir3/a\.dat": not a regular file' checkout.log -+ popd -+ -+ [ -L "a.dat" ] -+ [ -L "dir1/dir2/dir3/a.dat" ] -+ [ -f "link1" ] -+ [ "$contents_pointer" = "$(cat link1)" ] -+ [ -f "link2" ] -+ [ "$contents_pointer" = "$(cat link2)" ] -+ assert_clean_index -+ -+ # test with symlink to directory and dangling symlink -+ rm -rf a.dat dir1/dir2/dir3/a.dat ../link* - mkdir ../link1 - ln -s ../link1 a.dat -+ ln -s ../../../../link2 dir1/dir2/dir3/a.dat - -- # Note that we do not try to check the "git lfs checkout" command's error -- # output since it depends on both the OS and filesystem in use, as these -- # affect how the linked directory's size is reported. -- git lfs checkout -+ 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 '"a\.dat": not a regular file' checkout.log -+ grep '"dir1/dir2/dir3/a\.dat": not a regular file' checkout.log - - [ -L "a.dat" ] -+ [ -L "dir1/dir2/dir3/a.dat" ] - [ -d "../link1" ] -+ [ ! -e "../link2" ] - assert_clean_index - -- rm a.dat -+ rm -rf a.dat dir1/dir2/dir3/a.dat link* - mkdir link1 - ln -s link1 a.dat -+ ln -s ../../../link2 dir1/dir2/dir3/a.dat - -- git lfs checkout -+ 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 '"a\.dat": not a regular file' checkout.log -+ grep '"dir1/dir2/dir3/a\.dat": not a regular file' checkout.log - - [ -L "a.dat" ] -+ [ -L "dir1/dir2/dir3/a.dat" ] - [ -d "link1" ] -+ [ ! -e "link2" ] - assert_clean_index - -- mkdir -p dir1/dir2 - pushd dir1/dir2 -- git lfs checkout -+ 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 '"a\.dat": not a regular file' checkout.log -+ grep '"dir1/dir2/dir3/a\.dat": not a regular file' checkout.log - popd - - [ -L "a.dat" ] -+ [ -L "dir1/dir2/dir3/a.dat" ] - [ -d "link1" ] -+ [ ! -e "link2" ] -+ assert_clean_index -+) -+end_test -+ -+# This test applies to case-preserving but case-insensitive filesystems, -+# such as APFS and NTFS when in their default configurations. -+# On case-sensitive filesystems this test has no particular value and -+# should always pass. -+begin_test "checkout: skip case-based symlink conflicts" -+( -+ set -e -+ -+ skip_if_symlinks_unsupported -+ -+ # Only test with Git version 2.20.0 as it introduced detection of -+ # case-insensitive filesystems to the "git clone" command, which the -+ # test depends on to determine the filesystem type. -+ ensure_git_version_isnt "$VERSION_LOWER" "2.20.0" -+ -+ reponame="checkout-skip-case-symlink-conflicts" -+ setup_remote_repo "$reponame" -+ clone_repo "$reponame" "$reponame" -+ -+ mkdir dir1 -+ ln -s ../link1 A.dat -+ ln -s ../../link2 dir1/a.dat -+ -+ git add A.dat dir1 -+ git commit -m "initial commit" -+ -+ rm A.dat dir1/a.dat -+ -+ echo "*.dat filter=lfs diff=lfs merge=lfs -text" >.gitattributes -+ -+ contents="a" -+ contents_oid="$(calc_oid "$contents")" -+ printf "%s" "$contents" >a.dat -+ printf "%s" "$contents" >dir1/A.dat -+ -+ git -c core.ignoreCase=false add .gitattributes a.dat dir1/A.dat -+ git commit -m "case-conflicting commit" -+ -+ git push origin main -+ assert_server_object "$reponame" "$contents_oid" -+ -+ cd .. -+ GIT_LFS_SKIP_SMUDGE=1 git clone "$GITSERVER/$reponame" "${reponame}-assert" 2>&1 | tee clone.log -+ if [ "0" -ne "${PIPESTATUS[0]}" ]; then -+ echo >&2 "fatal: expected clone to succeed ..." -+ exit 1 -+ fi -+ collision="$(grep -c "collided" clone.log)" || true -+ -+ cd "${reponame}-assert" -+ git lfs fetch origin main -+ -+ assert_local_object "$contents_oid" 1 -+ -+ rm -rf *.dat dir1 ../link* -+ -+ 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 -+ -+ [ -f "a.dat" ] -+ [ "$contents" = "$(cat "a.dat")" ] -+ [ -f "dir1/A.dat" ] -+ [ "$contents" = "$(cat "dir1/A.dat")" ] -+ [ ! -e "../link1" ] -+ [ ! -e "../link2" ] -+ assert_clean_index -+ -+ rm -rf a.dat dir1/A.dat -+ git checkout -- A.dat dir1/a.dat -+ -+ git lfs checkout 2>&1 | tee checkout.log -+ if [ "0" -ne "${PIPESTATUS[0]}" ]; then -+ echo >&2 "fatal: expected checkout to succeed ..." -+ exit 1 -+ fi -+ if [ "$collision" -eq "0" ]; then -+ # case-sensitive filesystem -+ grep -q 'Checking out LFS objects: 100% (2/2), 2 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 -+ fi -+ -+ if [ "$collision" -eq "0" ]; then -+ # case-sensitive filesystem -+ [ -f "a.dat" ] -+ [ "$contents" = "$(cat "a.dat")" ] -+ [ -f "dir1/A.dat" ] -+ [ "$contents" = "$(cat "dir1/A.dat")" ] -+ else -+ # case-insensitive filesystem -+ [ -L "a.dat" ] -+ [ -L "dir1/A.dat" ] -+ fi -+ [ ! -e "../link1" ] -+ [ ! -e "../link2" ] - assert_clean_index - ) - end_test -diff --git a/t/t-pull.sh b/t/t-pull.sh -old mode 100755 -new mode 100644 -index 8a7cf062..4e942183 ---- a/t/t-pull.sh -+++ b/t/t-pull.sh -@@ -197,8 +197,8 @@ begin_test "pull: skip directory file conflicts" - 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": stat' pull.log -- grep 'Checkout error for "dir2/dir3/dir4/a\.dat": stat' pull.log -+ grep 'Checkout error for "dir1/a\.dat": lstat' pull.log -+ grep 'Checkout error for "dir2/dir3/dir4/a\.dat": lstat' pull.log - fi - - assert_local_object "$contents_oid" 1 -@@ -219,8 +219,8 @@ begin_test "pull: skip directory file conflicts" - 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": stat' pull.log -- grep 'Checkout error for "dir2/dir3/dir4/a\.dat": stat' pull.log -+ grep 'Checkout error for "dir1/a\.dat": lstat' pull.log -+ grep 'Checkout error for "dir2/dir3/dir4/a\.dat": lstat' pull.log - fi - popd - -@@ -278,7 +278,7 @@ begin_test "pull: skip directory symlink conflicts" - 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": stat' pull.log -+ 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 - -@@ -305,7 +305,7 @@ begin_test "pull: skip directory symlink conflicts" - 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": stat' pull.log -+ 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 - -@@ -328,7 +328,7 @@ begin_test "pull: skip directory symlink conflicts" - 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": stat' pull.log -+ 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 - popd --- -2.51.1 - - -From 634efc5a7ea71ff870e2e48273c41cda6505d86e Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Thu, 15 May 2025 23:42:40 -0700 -Subject: [PATCH 14/21] docs,lfs,t: create new files on checkout and pull - -Our "git lfs checkout" and "git lfs pull" commands, at present, -follow any extant symbolic links when they populate the current working -tree with files containing the content of Git LFS objects, even if -the symbolic links point to locations outside of the working tree. -This vulnerability has been assigned the identifier CVE-2025-26625. - -In a previous commit we partially addressed this vulnerability by -adjusting the DecodePointerFromBlob() function in our "lfs" package to -check whether an irregular file or other directory entry exists at the -location where the commands intend to create or update a file. - -While this change handles cases where a symbolic link already exists -the working tree before we try to create or update a file at the same -location, it does not entirely prevent TOCTOU (time-of-check/time-of-use) -races where a symbolic link might be created immediately after we check -for its existence and before we attempt to create or open a file. - -One reason is that the "git lfs checkout" and "git lfs pull" commands -use the Create() function from the Go standard library's "os" package -to create or open the files they intend to populate with the contents -of Git LFS objects. This function follows symbolic links when -determining whether it should create a new file or truncate an existing -one. If the last segment of the path passed to the function is a -symbolic link, the link will be dereferenced, and a new file will be -created at the link's target path or, if a file already exists at that -target path, then that file will be opened and truncated. - -Further, because the Create() function opens and truncates any existing -file it finds, if that file is hard-linked to one or more other -paths, then once the file is closed the new content our commands have -written into it will be visible through all of those paths, regardless -of whether they reside inside or outside the Git working tree. - -Our "git lfs checkout" and "git lfs pull" commands have exhibited these -behaviours since they were first implemented in PR #527. That PR -added a PointerSmudgeToFile() function to the "lfs" package, which was -later refactored by PR #2687 into the SmudgeToFile() method of the -GitFilter structure in the current version of our "lfs" package. The -original PointerSmudgeToFile() function made use of the "os" package's -Create() function to create a new file or truncate an existing one, -and the contemporary SmudgeToFile() method follows suit. - -For performance and compatibility reasons, Git does not try to -completely eliminate all TOCTOU races involving symbolic links, and -for similar reasons we do not expect to prevent every possible race -which might allow the Git LFS client to unintentionally write through -a symbolic link. We do, though, intend to limit the chances of this -occurring as far as we reasonably can. - -Therefore, to address the problems with symbolic and hard links described -above, we revise the SmudgeToFile() method so that it first removes any -existing file at the path it is given, and if that succeeds, then attempts -to atomically create a new file, reporting an error if that cannot be done -because a file or other directory entry already exists at the same path. - -Specifically, we use the OpenFile() function from the "os" package -instead of the Create() function, and we pass both the O_CREATE and -O_EXCL flags to guarantee that the function either creates a new file -or returns an error. Before calling OpenFile() we first call the -"os" package's Remove() function and report an error if it fails for -any reason other than that there is no file found at the given path. - -This approach mirrors that taken by Git when it updates files in the -working tree. In particular, when the "git checkout" command is -asked to update a specific pathspec (e.g., with a command such as -"git checkout -- file.txt"), the checkout_entry_ca() function first -calls the unlink(2) system call, and then the create_file() function -invokes the open(2) system call with the O_CREATE and O_EXCL flags: - - https://github.com/git/git/blob/cb96e1697ad6e54d11fc920c95f82977f8e438f8/entry.c#L552-L578 - https://github.com/git/git/blob/cb96e1697ad6e54d11fc920c95f82977f8e438f8/entry.c#L88-L89 - -Note that Git is actually more aggressive than the Git LFS client -in how it handles conflicting content when checking out specific -paths. For instance, if it finds a directory in place of a file it -intends to write, its remove_subtree() function will be used to try -to recursively remove the directory and all of its contents. - -By constrast, while our SmudgeToFile() method will now remove existing -files (whether regular or irregular), symbolic links, and empty -directories which conflict with the file it intends to create, the -function will will not remove non-empty directories. - -Moreover, the SmudgeToFile() method will only take this action if one -of these types of directory entries has been created in the brief -time interval since the DecodePointerFromBlob() function was called, -since we use that function to determine whether to proceed to call -the SmudgeToFile() method. - -The sole caller of the SmudgeToFile() method is the RunToPath() -method of the singleCheckout structure in our "commands" package, -which is used only by the "git lfs checkout" and "git lfs pull" -commands. Except when "git lfs checkout" is called with a --to -option, the RunToPath() method is only called from the Run() method -of the same singleCheckout structure. That method first invokes -the DecodePointerFromBlob() function, and proceeds to call the -SmudgeToFile() method only if no regular file was found, or if -a regular file was found and its contained a valid Git LFS pointer -whose ID matches that of the corresponding object. - -For this reason, we are guaranteed that when the SmudgeToFile() method -is called, the path it is passed is either one provided by the user -with the --to option of the "git lfs checkout" command, or has just -been checked by the DecodePointerFromBlob() function. In either case -we can be confident that it is reasonable to delete anything which now -exists at that location. Note, too, that prior to the changes in this -commit, any regular file or file referenced by a final symbolic link -in the path would be truncated and overwritten regardless of its contents -by the SmudgeToFile() method, so removing any directory entry (except -for non-empty subdirectories) we find and creating a new file is not -substantially different in this respect. - -However, there are several key advantages to our new approach. First, -we can now be certain we will never dereference a final symbolic link -in the given path and write to the link's target. Note, though, that -we do still traverse symbolic links when they are found in place of -directories in path segments other than the final segment. We will -partially address this concern in a subsequent commit, with the same -caveats that apply to Git's handling of symbolic links in non-terminal -path segments. - -Second, by always creating a new file we can be certain the content -we write will not be visible through hard links to an existing file. -We therefore add a pair of new tests to our t/t-checkout.sh and -t/t-pull.sh test suite which exercise the "git lfs checkout" and -"git lfs pull" commands and confirm that they replace existing files -with multiple hard links and effectively break those links. Both of -our new tests use the assert_clean_status() test helper function to -confirm that the "git lfs checkout" and "git lfs pull" commands continue -to update the Git index entries for any Git LFS files they recreate in -the working tree. - -We also expand the checks performed by the "checkout: conflicts" test -in our t/t-checkout.sh test script to check that symbolic links as well -as hard links are broken by our changes to the SmudgeToFile() method. -We are able to use this test for this purpose because it runs the -"git lfs checkout" command with the --to option, which means the -Run() method of the singleCheckout structure is not used and the -RunToPath() method is called directly. In turn, that implies that -the DecodePointerFromBlob() function is never invoked, so the command -does not simply detect the symbolic link in that function and therefore -skip making a call to the SmudgeToFile() method, as occurs in our -"checkout: skip file symlink conflicts" and "pull: skip file symlink -conflicts" tests. Instead, the RunToPath() method calls the -SmudgeToFile() method, which then removes the symbolic link and -creates a new file in its place. Hence we can use this test to confirm -that our changes are effective in breaking symbolic links as well as -hard links. - -And third, our new approach means we can eliminate two calls to the -"os" package's Chmod() function, which were added to the SmudgeToFile() -method in commit 686bda3722f12293f345240532f666b6a0961bb2 of PR #3120 -in order to handle pointer files to which our "lockable" Git attribute -applies, but which the user has not yet locked, and so the pointer files -have read-only permissions we want to retain while also replacing the -file's contents with the corresponding Git LFS object data. - -We do not need to call the Chmod() function before invoking the -Remove() function, because that function should be able to delete any -existing file, even one with read-only permissions, so long as the -parent directory permits changes to its list of entries. - -Note that our previous implementation might succeed even if the -parent directory did not allow changes to its list of entries, and -our new implementation will not. This does imply a partial change -in the behaviour of the Git LFS client when directories in the -working tree are themselves marked read-only. However, neither our -old or new implementations could succeed in creating new files within -such directories. Moreover, we expect Git working trees to normally -have read-write directory permissions, since many regular Git commands -will not function otherwise. We therefore consider the altered -behaviour of the Git LFS client to be an acceptable change given -that it will remediate several security concerns. - -We also do not need to call the Chmod() function at the end of the -SmudgeToFile() method, because we instead pass the file permissions -we want directly to the OpenFile() function. - -In the case where an existing file is found, prior to our deletion of -that file, we read its permissions with the Lstat() function of the -"os" package, and then pass those permissions to the OpenFile() -function. If a symbolic link or some other type of directory entry -is found, though, we ignore its permissions and use a default setting -of 0666 instead. (On Unix systems, the current "umask" setting will -then be applied to whatever permissions we pass to the OpenFile() -function.) - -While the use of a default permissions mode of 0666 matches that used -by the Create() function of the "os" package, and so aligns with the -legacy behaviour of the Git LFS client, this is not actually the ideal -implementation. Rather, we should respect the mode defined for the -file in Git, which may have the executable mode set. For now, though, -we leave this as improvement for a future PR, and just include a -comment to remind us of this oversight in our implementation. - -Finally, because the "git lfs checkout" command will now attempt to -remove and replace the file or other directory entry it finds at the -path supplied with the --to option, we update our git-lfs-checkout(1) -manual page to reflect this new behaviour. ---- - docs/man/git-lfs-checkout.adoc | 4 +- - lfs/gitfilter_smudge.go | 26 +++---- - t/t-checkout.sh | 130 +++++++++++++++++++++++++++++++++ - 3 files changed, 146 insertions(+), 14 deletions(-) - -diff --git a/docs/man/git-lfs-checkout.adoc b/docs/man/git-lfs-checkout.adoc -index 38c2e3be..926027b4 100644 ---- a/docs/man/git-lfs-checkout.adoc -+++ b/docs/man/git-lfs-checkout.adoc -@@ -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 --``. -+``. If `` 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 -diff --git a/lfs/gitfilter_smudge.go b/lfs/gitfilter_smudge.go -index 58049da1..8717362b 100644 ---- a/lfs/gitfilter_smudge.go -+++ b/lfs/gitfilter_smudge.go -@@ -18,21 +18,17 @@ 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) -@@ -40,9 +36,13 @@ func (f *GitFilter) SmudgeToFile(filename string, ptr *Pointer, download bool, m - 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 { -diff --git a/t/t-checkout.sh b/t/t-checkout.sh -index 53e64a19..695bf442 100755 ---- a/t/t-checkout.sh -+++ b/t/t-checkout.sh -@@ -563,6 +563,106 @@ begin_test "checkout: skip case-based symlink conflicts" - ) - end_test - -+begin_test "checkout: skip changed files" -+( -+ set -e -+ -+ reponame="checkout-skip-changed-files" -+ setup_remote_repo "$reponame" -+ clone_repo "$reponame" "$reponame" -+ -+ git lfs track "*.dat" -+ -+ contents="a" -+ contents_oid="$(calc_oid "$contents")" -+ printf "%s" "$contents" >a.dat -+ -+ git add .gitattributes a.dat -+ git commit -m "initial commit" -+ -+ contents_new="$contents +extra" -+ printf "%s" "$contents_new" >a.dat -+ -+ git lfs checkout -+ -+ [ "$contents_new" = "$(cat a.dat)" ] -+ assert_clean_index -+ -+ rm a.dat -+ mkdir a.dat -+ -+ git lfs checkout -+ -+ [ -d "a.dat" ] -+ assert_clean_index -+ -+ pushd a.dat -+ git lfs checkout -+ popd -+ -+ [ -d "a.dat" ] -+ assert_clean_index -+) -+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 -@@ -800,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 --- -2.51.1 - - -From 60eda1f16eca8b9a722570623cde89f7890cb86a Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Wed, 13 Aug 2025 00:24:02 -0700 -Subject: [PATCH 15/21] fix bare repo pull/checkout path handling bug - -Our "git lfs checkout" and "git lfs pull" commands may both, at present, -be executed in a bare repository, although the former has no utility in -a bare repository, and the latter often performs no actions, but can be -used to fetch Git LFS objects in a bare repository. - -The "git lfs checkout" and "git lfs pull" commands are the only commands -which make use of the methods of the singleCheckout structure in our -"commands" package, and in a subsequent commit we will update these methods -so they change the current working directory to the root of the current -working tree, so long as one exists. - -Before we make these revisions, though, we first need to guarantee that -the singleCheckout structure's methods correctly handle the case where no -current work tree is defined, such as in a bare repository when the -GIT_WORK_TREE environment variable has not been set. - -When no working tree is defined, the "git lfs pull" command should perform -no action other than fetching objects, since there is no work tree into -which the command should write any Git LFS file content. - -For the same reason, the "git lfs checkout" should have no effect when -no work tree is defined, since the command's only purpose is to check -out Git LFS file content into a working tree. - -Unfortunately, both the "git lfs checkout" and "git lfs pull" commands -may, under unusual circumstances, try to check out Git LFS files by -writing their object data into files either inside or outside a -bare repository. - -In bare repositories, when the "git lfs checkout" and "git lfs pull" -commands try to determine whether to check out a Git LFS file into the -(non-existent) working tree, they incorrectly treat the path to a file -from the root of the repository as if it were instead an absolute path -starting from the root of the current filesystem. For instance, given -the path "foo/bar.bin" to a Git LFS file in a repository, the commands -will instead treat this path as if it were the path "/foo/bar.bin". - -Normally, no file will exist at this location, so the "git lfs checkout" -and "git lfs pull" commands then check the Git index to try to determine -whether the user has staged the file for deletion. Since bare -repositories typically have no index, the commands will assume the user -has intentionally removed the file, and skip any further processing for -the file. - -If the user has added an index entry for the file, though, the commands -will assume the file should be re-created in the (non-existent) working -tree with the content of the object referenced by the Git LFS pointer -stored in Git's version of the file. Taking the file's path from the -root of the repository as if it was an absolute path, the commands will -try to create any missing directories in that path, and then try to -either create a new file or truncate an existing one before writing -the Git LFS object content into the file. - -An alternative sequence of events which leads to the same result may -occur in the extremely unlikely case that a file already exists at -the location specified by the incorrectly-determined absolute path, -and that the file contains a Git LFS pointer with the same object ID -as that of the given file in the repository. In other words, using -the same example file paths as above, this means a "/foo/bar.bin" file -would have to already exist and contain the same raw Git LFS pointer -data as the "foo/bar.bin" file in the Git repository. Should this -happen, the "git lfs checkout" and "git lfs pull" commands would assume -the file should be overwritten with the contents of the corresponding -Git LFS object. - -Of course, even if the "git lfs checkout" and "git lfs pull" commands -try to create or overwrite a file at the path they are incorrectly -treating as an absolute path, the current user may not have sufficient -permissions to permit the necessary filesystem operations to complete. - -Regardless, the Git LFS client should not try to read or write files -outside of the current repository unless specifically requested to do -so with an argument such as the --to option of the "git lfs checkout" -command. - -In conjunction with our remediation of the vulnerability assigned the -identifier CVE-2025-26625, we therefore revise the "git lfs checkout" -and "git lfs pull" commands now to ensure they will never treat paths -relative to the root of the current repository as if they were absolute -filesystem paths. - -We also adjust the "git lfs checkout" command so that it generates the -same error message as commands like "git lfs status" when no working -tree is defined, and exits immediately afterwards. This change will -make clear to our users why the "git lfs checkout" command has no effect -in a bare repository, while also simplifying our test requirements as we -do not have to verify the command's behaviour in a bare repository beyond -checking that it exits with the appropriate warning message. - -The specific problem addressed in this commit is the result of the -joining an empty path, which signals the lack of a current working -tree, to a file's path from the root of the repository, and adding -a file separator character between the two strings. This occurs -within the Convert() method of the repoToCurrentPathConverter structure -type from our "lfs" package. - -In a subsequent commit we will be able to remove the -repoToCurrentPathConverter structure and its methods entirely, when we -revise the "git lfs checkout" and "git lfs pull" commands to change -the current working directory to the root of the current working tree. - -In this commit, however, we simply alter the commands so that they -never call the structure's Convert() method if no working tree exists. - -First, we add a "hasWorkTree" element to the singleCheckout structure -type in our "commands" package, and when we initialize a new structure -in the newSingleCheckout() function, we set the "hasWorkTree" element's -value to "true" only if the LocalWorkingDir() method of the Configuration -structure type from our "config" package returns a non-empty path. - -The LocalWorkingDir() method returns the absolute path to the root of -the current working tree, or an empty path if no working tree is defined, -as determined by the GitAndRootDirs() function in our "git" package. -The GitAndRootDirs() function runs the "git rev-parse" command with the ---show-toplevel option, and then interprets that command's output and -exit code so that if no working tree is defined, an empty path is -returned instead of a path to the work tree's root directory. - -Second, we update the Run() method of the singleCheckout structure -so that it returns immediately unless the "hasWorkTree" element is -set to a "true" value, meaning a work tree exists and it is safe to -create and write files within that directory tree. - -To verify these changes work as we expect, we introduce a new "pull: bare -repository" test to our t/t-pull.sh test script, and in this test we -specifically add a Git LFS pointer file to the test repository at a path -that, if treated as an absolute path instead of a path from the root of -the repository, could be created by the current test process. After the -test clones the repository, it adds this file's path to the index, runs -the "git lfs pull" command, and then checks that no file is created either -inside the bare repository or, most importantly, outside the repository. - -(The test also ensures that a Git LFS filter attribute is defined in -the "$GIT_DIR/info/attributes" file, which guarantees that regardless -of which Git version is installed, the "git lfs pull" command will find -our new Git LFS pointer file in the repository's contents and process it. -We describe the issues pertaining to the need to use a local Git -attributes file instead of a ".gitattributes" file further below.) - -Without our changes to the singleCheckout structure and its methods -in this commit, the revised "pull: bare repository" test will fail, so -we can be confident that it validates that our remediation is effective. - -As for the "git lfs checkout" command, we alter its main checkoutCommand() -function so that after calling the setupRepository() function, the -checkoutCommand() function checks whether a path to the current working -tree has been found, and if not, outputs a warning message and stops -execution of the command. This new check is modelled on that performed -by the requireWorkingCopy() function, but causes the command to return a -zero (i.e., successful) exit code rather than a non-zero one. - -Our new check relies on the functions invoked by the setupRepository() -function to have already called the GitAndRootDirs() function in our -"git" package. That function runs the "git rev-parse" command with the ---show-toplevel option, and then interprets the command's output and exit -code so that if no current work tree is present, an empty path will be -returned by the LocalWorkingDir() method of our "config" package's -Configuration structure instead of a path to the work tree's root -directory. - -It would be more straightforward for us to revise the checkoutCommand() -function to simply call the setupWorkingCopy() function rather than the -setupRepository() function, because the setupWorkingCopy() function calls -the requireWorkingCopy() function and so would enforce the presence of -a working tree in the same manner as we employ in other commands such as -the "git lfs status" and "git lfs track" commands. - -However, this implementation would result in a backwards-incompatible -change to the behaviour the "git lfs checkout" command when it is run in a -bare repository, which could result in the unexpected failure of automated -CI jobs, for instance. Although the use of the "git lfs checkout" command -in a bare repository has no purpose, we defer the simpler implementation -to a future release, and for now ensure that the command still returns -a zero exit code when run in a bare repository. - -We do, though, update our git-lfs-checkout(1) manual page to clarify that -the command requires a working tree, and that in the future the command -may exit with an error when run in a bare repository. We also add a new -"checkout: bare repository" test to our t/t-checkout.sh test script, which -just verifies that the command generates the expected error message and -returns a zero exit code when it is run in a bare repository. - -Both the "git lfs checkout" and "git lfs pull" commands currently exhibit -the erroneous behaviour addressed by this commit because the singleCheckout -structure's Run() method relies on the Convert() method of the -repoToCurrentPathConverter structure type to rewrite file paths -relative to the root of the repository into paths relative to the -current working directory, and this method returns invalid paths when -no working tree is defined, as is the case in a bare repository. - -The Run() method is executed, either directly or indirectly, for -each Git LFS pointer file path found by the ScanLFSFiles() method of -the GitScanner structure in our "lfs" package. This method retrieves -a list of files from Git, and for each one that corresponds to a Git -LFS pointer, the method invokes an anonymous function which in turn -causes the Run() method to be performed. - -In the case of the "git lfs pull" command, if a local copy of the object -associated with a Git LFS pointer is found, the Run() method is invoked -directly within the anonymous function, and otherwise it is invoked by -a goroutine for each object whose data is successfully retrieved from -the Git LFS remote by the transfer queue. - -In the case of the "git lfs checkout" command, the anonymous function -called by the ScanLFSFiles() method appends each Git LFS pointer to a -slice, and then the Run() method is invoked for each pointer in the -slice after the scan through the list of files is complete. - -To retrieve a list of files from Git, the runScanLFSFiles() function, -which is called by the ScanLFSFiles() method, uses one of two Git -commands. If the installed version of Git is 2.42.0 or higher, the -"git ls-files" command is executed, and otherwise the "git ls-tree" -command is used. This difference accounts for one of the reasons -why the "git lfs pull" command, in particular, may perform no action -when run within a bare repository. - -Specifically, as noted in issue #6004, the "git ls-files" command lists -the files in the Git index, while the "git ls-tree" command lists the -files in the Git tree associated with a given reference, which in the -case of our "git lfs checkout" and "git lfs pull" commands is always -the current "HEAD" symbolic reference. - -If the installed version of Git is older than v2.42.0, when our commands -run the "git ls-tree" command they will receive a list of files from -the Git tree associated with the "HEAD" reference, and will process -any Git LFS pointers found in that list. (Note that pointer files -will be processed even if they no longer match any Git LFS filter -attributes; for instance, if there are no ".gitattributes" files in -the index or in the Git tree associated with the "HEAD" reference, -and no local Git attributes files.) - -If the installed version of Git is at least v2.42.0, our commands -run the "git ls-files" command instead of the "git ls-tree" command. -For two separate reasons, in a bare repository the "git ls-files" -command will often return an empty list, so our "git lfs checkout" -and "git lfs pull" commands will take no further action. - -The more obvious reason is that by default, Git creates bare -repositories without an index, so unless the user has explicitly -added entries to the index for Git LFS pointer files, no results -will be returned by the "git ls-files" command. - -The less obvious reason is due to the "attr:filter=lfs" pathspec -our commands pass to the "git ls-files" command, which causes the -command to only return paths for files which match a Git LFS filter -attribute definition. However, in a bare repository Git's internal -read_attr() function by default ignores all ".gitattributes" files -found in either the index or the tree associated with the "HEAD" -reference: - - https://github.com/git/git/blob/v2.50.1/attr.c#L851-L867 - -Since there is no working tree in a bare repository, this means all -".gitattributes" files are ignored by default, and because users -typically define Git LFS attributes in those files, the "git ls-files" -command will not match any files even if entries for Git LFS pointer -files have been added to the index. Users would have to specifically -set the GIT_ATTR_SOURCE environment variable to a reference like "HEAD" -or add Git LFS filter attributes to a local Git attributes file such -as the "$GIT_DIR/info/attributes" file in order for the "git ls-files" -command to match pointer files in the index to the "attr:filter=lfs" -pathspec and return a non-empty list. - -Regardless of the source, though, if Git LFS pointers are identified from -the list of files returned by Git, the "git lfs pull" command will fetch -the objects referenced by those pointers unless the objects already exist -in the local storage directories under "lfs/objects". (Note that in a -bare repository, the usual leading ".git" directory is not necessary.) - -As objects are fetched by the transfer queue, the separate goroutine -started by the "git lfs pull" command passes their pointer data to the -Run() method of the singleCheckout structure, one pointer at a time. - -In a "git lfs checkout" command, by contrast, no objects are fetched, -and the Run() method is instead invoked directly by the command's main -function for each Git LFS pointer file path collected during the -execution of the ScanLFSFiles() method. - -As described above, the Run() method begins by converting the file path -of the Git LFS pointer provided in its "p" parameter into a file path -relative to the current working directory using the Convert() method of -the repoToCurrentPathConverter structure type. - -We initialize a structure of that type in the newSingleCheckout() -function by calling the NewRepoToCurrentPathConverter() function. -That function uses an internal function named pathConverterArgs() to set -the new structure's "repoDir" element to the file path returned by the -LocalWorkingDir() method of the Configuration structure type, which as -mentioned above will be an empty path if no current work tree is defined. - -When the repoToCurrentPathConverter structure type's Convert() method -is called, it first joins the structure's "repoDir" element to the -file path provided in the method's "p" parameter using a local wrapper -function around the Join() function from the Go standard library's -"strings" package, rather than the Join() function from the -"path/filepath" package. (This change was made in commit -fd69029c76e3898fc7c81ac2e8705174c4ebf2b5 of PR #2875, presumably to -make more efficient the handling of file paths which we expect to -always be defined.) - -In a bare repository, however, the "repoDir" element contains an empty -path, so the result of joining it with a file path relative to the root -of the repository using the Join() function from the "strings" package -is the same file path but with a leading "/" character prepended to it, -in effect creating an invalid absolute path from the root of the -filesystem. Note that if the Join() function from the "path/filepath" -package was used instead, it ignores empty parameters, so the file path -would be returned unchanged. - -The Convert() method then passes this invalid absolute path to the -Rel() function of the "path/filepath" package of the Go standard library, -along with the absolute path to the current working directory. We expect -this call to return a relative path from the current working directory -to the location within the current Git work tree where a file should -be created or updated with the contents of a Git LFS object. - -In a bare repository, though, what is returned by the Convert() method -to the Run() method is a relative path from the current working directory -to a location constructed by treating a Git LFS pointer's path within -the repository as if it was a path descending from the root of the -current filesystem. For instance, given the path "foo/bar" of a -Git LFS pointer within the repository, and current working directory -of "/path/to/bare/repo", the Convert() method would return the path -"../../../../foo/bar". - -After this path is returned to the Run() method, it is passed to the -DecodePointerFromFile() function in our "lfs" package, which checks -whether a file exists at the given location, and if so, reads it and -checks whether it contains a valid Git LFS pointer. - -In the large majority of cases, of course, files will not exist in -the locations identifed by the invalid paths that the Convert() method -generates when the "git lfs checkout" or "git lfs pull" commands are -executed in a bare repository. Hence the DecodePointerFromFile() -function will return an error which the IsNotExist() function of the -"os" package considers equivalent to an ErrNotExist error. The Run() -method will then execute a "git diff-index" command to determine whether -the user has intentionally removed the file from the Git index, and -will pass the original file path (the one relative to the root of the -repository) to that command. - -If the installed version of Git is older than v2.42.0, and the bare -repository has no index, as is normally the case in such repositories, -the "git diff-index" command's output will indicate that the file does -not exist in the index and so the Run() method will return without -taking further action. - -On the other hand, if the installed version of Git is 2.42.0 or higher, -then the index must include an entry for the original file path (the -one relative to the root of the repository), since otherwise the -"git ls-files" command would not have listed the file at all and the -Run() method would never have been called. Thus the "git diff-index" -command will also list the file as present in the index, and so the -Run() method will proceed on the assumption that the file is just -missing in the (non-existent) working tree and should be created, even -though there is no actual work tree. - -Even if a version of Git older than 2.42.0 is installed, though, -the user may have created an index entry for the file, in which case -the Run() method will likewise proceed because the "git diff-index" -command's output will indicate that the file is present in the index. - -It is also possible, although extremely unlikely, that the -DecodePointerFromFile() function finds a file at the incorrectly- -generated path it was given, and is able to open it and parse it as a -valid Git LFS pointer. The Run() method will then check to see if the -pointer's ID matches that of the pointer under consideration. If it -does not, the method will return without taking action, but if it does, -it will proceed on the assumption that the pointer file should be -overwritten with the contents of the associated object file. - -In summary, in a bare repository the singleCheckout structure's Run() -method will only proceed under one of two conditions: either a Git index -entry exists for the file path under consideration, which is unlikely -to be the case in a bare repository since the index is typically empty, -or a Git LFS pointer file with the expected object ID happens to exist -at the absolute path derived by prepending a file separator to the -file's path within the repository, which is even more unlikely. - -Should one of these circumstances occur, though, the Run() method -will invoke the RunToPath() method of the singleCheckout structure, -which will in turn call the SmudgeToFile() method of the GitFilter -structure in our "lfs" package. That method will attempt to create -or truncate a file at the incorrect path, and then write the contents -of a Git LFS object into the file. - -With the changes in this commit, however, this incorrect behaviour -should no longer occur under any circumstances. ---- - commands/command_checkout.go | 9 +++ - commands/pull.go | 6 ++ - docs/man/git-lfs-checkout.adoc | 3 + - docs/man/git-lfs-pull.adoc | 10 +++ - t/t-checkout.sh | 17 +++++ - t/t-pull.sh | 131 +++++++++++++++++++++++++++++++++ - 6 files changed, 176 insertions(+) - -diff --git a/commands/command_checkout.go b/commands/command_checkout.go -index 6bf9534c..71ecef9c 100644 ---- a/commands/command_checkout.go -+++ b/commands/command_checkout.go -@@ -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)) -diff --git a/commands/pull.go b/commands/pull.go -index 74c4b2e6..c2c533b8 100644 ---- a/commands/pull.go -+++ b/commands/pull.go -@@ -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) -diff --git a/docs/man/git-lfs-checkout.adoc b/docs/man/git-lfs-checkout.adoc -index 926027b4..d2719751 100644 ---- a/docs/man/git-lfs-checkout.adoc -+++ b/docs/man/git-lfs-checkout.adoc -@@ -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`:: -diff --git a/docs/man/git-lfs-pull.adoc b/docs/man/git-lfs-pull.adoc -index 5d3fd5dd..21d1f927 100644 ---- a/docs/man/git-lfs-pull.adoc -+++ b/docs/man/git-lfs-pull.adoc -@@ -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 `:: -diff --git a/t/t-checkout.sh b/t/t-checkout.sh -index 695bf442..ebb89f30 100755 ---- a/t/t-checkout.sh -+++ b/t/t-checkout.sh -@@ -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 -diff --git a/t/t-pull.sh b/t/t-pull.sh -index 4e942183..5588dbfd 100644 ---- a/t/t-pull.sh -+++ b/t/t-pull.sh -@@ -1044,6 +1044,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 --- -2.51.1 - - -From eb0c9a577343f1517b3e77e3803607b8d6f0494b Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Mon, 18 Aug 2025 13:09:27 -0700 -Subject: [PATCH 16/21] commands,lfs,t: always chdir on checkout and pull - -Our "git lfs checkout" and "git lfs pull" commands retrieve a list of -Git LFS pointer files from the ScanLFSFiles() method of the GitScanner -structure type in our "lfs" package, and for each file, invoke the Run() -method of the singleCheckout structure type in our "commands" package. - -For a given Git LFS pointer file, the Run() method determines whether -or not to write the contents of the object referenced by the pointer -into a file in the working tree at the appropriate path. - -Because the user may execute the "git lfs checkout" and "git lfs pull" -commands from any location within a Git repository, the Run() method -tries to convert the file path of pointer, which is always relative -to the root of the repository, into a path relative to the current -working directory. To do this, it calls the Convert() method of the -repoToCurrentPathConverter structure type in our "lfs" package, which -first prepends the absolute path to the root of the current working -tree, and then generates a relative path to that location from the -current working directory. - -The repoToCurrentPathConverter structure and its methods were refactored -in commit 68efd0536a2eb3e0514ee4d2da7ab9c7a084fcb8 of PR #1771 from the -original ConvertRepoFilesRelativeToCwd() function. That function was -added in commit 760c7d75e1cb4ea95f2a80f0ecf6d45620d56624 of PR #527, -the same PR which introduced the "git lfs checkout" and "git lfs pull" -commands. - -After calling the Convert() method to generate a path relative to the -current working directory, the Run() method passes that path to -several other functions and methods, while also using the original -input path (the one relative to the root of the repository) in other -function calls and error messages. - -Because the Convert() method assumes that a current working tree is -defined (and that the current working directory is within this tree), -it will return invalid paths when these conditions are not true, such -as when the user is working in a bare repository. In a prior commit -we therefore added a check to the Run() method so that it will not -execute the Convert() method when the no working tree is defined, which -resolved a bug whereby under unusual conditions the "git lfs checkout" -and "git lfs pull" commands could write to a file outside a bare -repository. (In another prior commit we then also updated the -"git lfs checkout" command so that it will exit immediately when run -in a bare repository.) - -The Run() method now checks the state of a "hasWorkTree" element in the -singleCheckout structure and returns without taking further action if -the element's value is "false". When we initialize a new singleCheckout -structure in the newSingleCheckout() function of our "commands" package, -we set the value of the "hasWorkTree" element to "true" only if the -the LocalWorkingDir() method of the Configuration structure type from -our "config" package returns a non-empty path. - -The LocalWorkingDir() method returns the absolute path to the root of -the current working tree, or an empty path if no working tree is defined, -as determined by the GitAndRootDirs() function in our "git" package. -The GitAndRootDirs() function runs the "git rev-parse" command with the ---show-toplevel option, and then interprets that command's output and -exit code so that if no working tree is defined, an empty path is -returned instead of a path to the work tree's root directory. - -If a working tree exists and so the "hasWorkTree" element is "true", -the Run() method will proceed to invoke the Convert() method and then -pass the resultant path, which is relative to the current working -directory, to the DecodePointerFromFile() function from our "lfs" -package, and then to the RunToPath() method of the singleCheckout -structure, which passes it to the SmudgeToFile() method of the GitFilter -structure in our "lfs" package. The Run() method later also passes the -path to the Add() method of the gitIndexer structure in our "commands" -package, which writes the path to a "git update-index" command on its -standard input file descriptor. - -In a prior commit we updated the SmudgeToFile() method so that it -always creates a new file, rather than writing Git LFS object data into -an existing file, which ensures that the method will not write through -a symbolic link which exists in the place of the final filename component -of a given Git LFS pointer's file path. - -In subsequent commits we will next revise the Run() method and add new -methods for the singleCheckout structure so that we check each ancestor -component of a Git LFS pointer's file path to verify that none of the -directory components of the path are symbolic links. If a symbolic link -is found, we will report it in a new error message log format, and the -RunToPath() method will then not be invoked, nor will the gitIndexer -structure's Add() method. - -With our current design, performing these checks for symbolic links, -which must be made on each path component from the root of the current -working tree to the parent directory of a given file, is complicated -by the fact that the current working directory may be located anywhere -within the work tree. We either have to prepend zero or more ".." -path components to reach the root of the working tree, or construct an -absolute path to the root of the tree and then prepend that path to -each Git LFS pointer's file path within the repository. - -To simplify both the future implementation of our checks for symbolic -links in file paths and the overall design of the Run() method, we -first adopt the approach taken by Git, which is to change the current -working directory to the root of the working tree, if one exists, before -checking for symbolic links and creating files in the work tree: - - https://github.com/git/git/blob/v2.50.1/setup.c#L1759-L1760 - https://github.com/git/git/blob/v2.50.1/symlinks.c#L63-L193 - -Git runs its setup_git_directory_gently() function shortly after -starting, and when it detects that the current working directory is -within a work tree, it changes the working directory to that root of -that work tree. - -Since we only need to change the working directory once, we revise the -newSingleCheckout() function so it attempts to do this if a working -tree was detected and it has therefore set the "hasWorkTree" flag to -"true". If the Chdir() function from the "os" package in the Go -standard library returns an error, the newSingleCheckout() function -reports the error and sets the "hasWorkTree" flag to "false" so that -the Run() method will always return immediately and never try to read -or write any files. - -Note that when the Chdir() function returns an error, we explicitly -do not cause the current Git LFS command to exit, because we want -the command to continue even if it is unable to read or write files -in the current working tree. In the case of the "git lfs checkout" -command, the command may have been invoked with the --to option, in -which case it should write its output to the file specified by the -user rather than into a Git LFS file within the working tree. In the -case of the "git lfs pull" command, the command should try to fetch -any Git LFS objects that not present in the local storage directories -even if their contents can not be written into files in the working -tree. In either case, we do not want the newSingleCheckout() function -to cause the commands to exit prematurely, even if an error occurs. - -Also note that we do not need to keep a record of the original current -working directory and avoid deleting that directory, because a change -we made in a previous commit to the DecodePointerFromFile() function -ensures that we detect whether the file path passed to the Run() method -is a directory, and if so, returns an error. Therefore, if the user -has created a directory in place of a Git LFS file, and set that -directory as the working directory, we will not remove it when trying -to check out that file. The "checkout: skip changed files" and -"pull: skip changed files" tests we added in a previous commit to our -t/t-checkout.sh and t/t-pull.sh test scripts include checks which -verify this behaviour by running the respective commands from within -a directory which has replaced a Git LFS file in the working tree. - -Our revisions to the newSingleCheckout() function mean that the Run() -method will only proceed if a working tree is defined and the current -working directory is the root of that tree. One key consequence of -this change is that the method no longer need to construct a path -relative to the current working directory, as it can simply use the -path provided by Git, which is stored in the "Name" element of the -WrappedPointer structure passed to the Run() method as its sole -parameter, named "p". - -As a result, the Run() method can use the "Name" element of its "p" -parameter in all in the instances where it previously used the -"cwdfilepath" variable which stored the result of the call to the -Convert() method of the repoToCurrentPathConverter structure. - -Further, because the Run() method was the only caller of the Convert() -method, and the singleCheckout structure's "pathConverter" element -was the only instance of a repoToCurrentPathConverter structure in our -codebase, we can now remove that structure type and all of its methods -from the "lfs" package. - -We make two alterations in this commit to the initial steps performed by -the "git lfs checkout" command so that it continues to support the use -of command-line arguments that are specified as file paths relative to -the directory in which the command is run. - -First, in the checkoutCommand() function we now call the rootedPaths() -function before calling the newSingleCheckout() function, since the -newSingleCheckout() function now changes the current working directory. -By calling the rootedPaths() function first, it can convert any file -path pattern arguments provided by the user that are relative to the -initial working directory into path patterns relative to the root -of the repository at a time before the newSingleCheckout() function -changes the current working directory. - -In a prior commit we added checks to the initial "checkout" test in our -t/t-checkout.sh test script which run the "git lfs checkout" command in -a subdirectory of the working tree and pass relative path arguments like -".." and "../folder2/**", and then verify that the command updates the -appropriate files in the work tree. These checks now serve to confirm -that our revisions to the operation of the "git lfs checkout" command in -this commit do not cause any regression in the command's support for -relative file path pattern arguments, regardless of the whether the -command is run in the root of the working tree or in one of its -subdirectories. - -Second, if the --to option is specified, we invoke the Abs() function -from the "path/filepath" package on its argument to generate an absolute -path, which we then pass to the RunToPath() method of the singleCheckout -structure as its "path" parameter, instead of passing the original -command-line argument. This allows the newSingleCheckout() function -to change the working directory without causing problems if the user -supplies a relative path argument with the --to option. Otherwise, -we would have to convert the provided path from one which was relative -to the original working directory into one which was relative to the -root of the working tree, which might even point outside of the work -tree since the user is free to supply a path to any location in their -system. Given this, using an absolute path is our simplest approach -to handling the --to option's argument. - -The checks we added in a prior commit to the "checkout: conflicts" test -in our t/t-checkout.sh test script now help verify that the "git lfs -checkout" command continues to supports the use of relative paths with -the --to option and that when this option is supplied an output file is -written to the same location as before, even if the command is run in -a subdirectory of the working tree. - -In addition to the foregoing, by altering the "git lfs checkout" and -"git lfs pull" commands to change the current working directory to the -root of the work tree before they begin processing any Git LFS files, -we gain one further benefit with regard to how we handle Git LFS pointer -extension programs. If such programs are configured, we invoke them -while performing "clean" and "smudge" operations, including the "smudge" -operations initiated by the SmudgeToFile() method when it is invoked by -the "git lfs checkout" and "git lfs pull" commands. - -We first introduced support for pointer extension programs in PR #486, -at which time we modelled their configuration on that of Git's own -"clean" and "smudge" filters. In particular, Git provides filter -programs with the path to the file they are processing in place of any -"%f" specifiers in the command lines specified by the "filter.*.clean" -and "filter.*.smudge" configuration entries. For long-running filter -programs configured using "filter.*.process" entries, Git sends the path -to each file they process as the value of a "pathname" key in the stream -of data piped to the programs, using the protocol designed for these -types of filter programs. - -In all cases, the file paths provided by Git are relative to the root -of the repository, not to the user's current working directory at the time -the initial Git command was started. Moreover, Git changes the current -working directory to the root of the working tree before invoking any -filter processes, so the file paths it passes to the processes correspond -with the files Git will read or write in the working tree. However, -the gitattributes(5) manual page notes that files may not actually exist -at these file paths, or may have different contents than the ones Git -pipes to the filter process, and so filter programs should not attempt -to access files at these paths: - - https://github.com/git/git/blob/v2.50.1/Documentation/gitattributes.adoc?plain=1#L503-L507 - -The Smudge() method of the GitFilter structure in our "lfs" package is -used by both of our "git lfs smudge" and "git lfs filter-process" -commands, and is responsible for writing the contents of a Git LFS object -as a data stream to its "writer" parameter. This output data is then -piped back to the Git process which executed the Git LFS filter command. -In such a context, the "workingfile" parameter of the Smudge() process -contains a file path provided by Git, either in place of a "%f" -command-line specifier or as the value of a "pathname" key, per the -long-running filter protocol. - -As the Git documentation states, files may not exist at these file -paths, or may have different content than the filter would expect, so -our Smudge() method is careful to only use the file paths passed to it -in its "workingfile" parameter for informational and error logging -purposes. Likewise, all the methods and functions to which the Smudge() -method passes this parameter also only use it for logging purposes, -or at least that is our intention. - -One particular use of this "workingfile" parameter's value pertains to -our support for Git LFS pointer extensions. Like Git, the Git LFS client -will pass a file path in place of a "%f" command-line specifier if one -is found in the configuration setting for a pointer extension program. -(The actual contents of the pointer file, however, will be piped to the -extension program on its standard input file descriptor.) - -When our Smudge() method invokes the readLocalFile() method of the -GitFilter structure, it passes its "workingfile" parameter. If Git -has supplied this path to a "git lfs smudge" or "git lfs filter-process" -command, the path will be relative to the root of the repository. Should -any Git LFS pointer extensions be configured, the readLocalFile() method -will use its "workingfile" parameter to populate the "fileName" elements -of new "pipeRequest" structures, which are then passed one at a time to -the pipeExtensions() function. That function executes the given -extension program and substitutes the "%f" specifier in the program's -configured command line with the value from the "fileName" element -of the "pipeRequest" structure. - -When the Git LFS client is not run by Git as a filter program but -executed directly via the "git lfs checkout" or "git lfs pull" commands, -however, we previously did not change the current working directory -before invoking pointer extension programs. We also substituted for -the "%f" specifier file paths that were relative to the current working -directory (unless an absolute file path was specified by the user as -the argument of the "git lfs checkout" command's --to option). - -Like Git filter programs, Git LFS pointer extension programs should not -expect to access an actual file at the paths passed in place of the "%f" -command-line specifiers. At present, though, we do not make this -explicit. - -To confirm that our changes in this commit function as expected when -Git LFS pointer extension programs are configured, we update the -lfstest-caseinverterextension test utility we added in a prior commit -so that it now reports an error and exits if it does not find a ".git" -directory in its current working directory, which would imply it is not -executing within the top-level directory of a work tree. - -We also update our "checkout: pointer extension" and "pull: pointer -extension" tests so they check that the paths received and logged by -the lfstest-caseinverterextension test utility are relative to the -root of the repository even if the "git lfs checkout" or "git lfs pull" -command is executed in a subdirectory within the working tree. - -However, we update our "checkout: pointer extension with conflict" -test so that it checks that the paths received and logged by the -lfstest-caseinverterextension test utility are absolute paths, because -now always convert the file path argument of the "git lfs checkout" -command's --to option into an absolute path before passing it to -the RunToPath() method. This is the only use case in which the -RunToPath() method is invoked directly and not by the Run() method, -and thus the only instance in which the file paths of the RunToPath() -method's "path" parameter does not correspond in any way with the -file path of the given Git LFS pointer file. This exceptional -behaviour dates from the introduction of the --to option in commit -cf7f9678b3d2929425d0671c099c2ef5621c0929 of PR #3296, and we will -address this issue in a subsequent PR. ---- - commands/command_checkout.go | 14 ++++++- - commands/command_pull.go | 2 + - commands/pull.go | 41 +++++++++---------- - lfs/util.go | 36 ----------------- - t/cmd/lfstest-caseinverterextension.go | 13 +++++- - t/t-checkout.sh | 18 ++++----- - t/t-pull.sh | 55 ++++++++++++++++++++++++++ - 7 files changed, 109 insertions(+), 70 deletions(-) - -diff --git a/commands/command_checkout.go b/commands/command_checkout.go -index 71ecef9c..fbdeae58 100644 ---- a/commands/command_checkout.go -+++ b/commands/command_checkout.go -@@ -3,6 +3,7 @@ package commands - import ( - "fmt" - "os" -+ "path/filepath" - - "github.com/git-lfs/git-lfs/v3/errors" - "github.com/git-lfs/git-lfs/v3/filepathfilter" -@@ -38,11 +39,13 @@ func checkoutCommand(cmd *cobra.Command, args []string) { - Exit(tr.Tr.Get("Error parsing args: %v", err)) - } - -+ rootedPaths := rootedPaths(args) -+ - if checkoutTo != "" && stage != git.IndexStageDefault { - if len(args) != 1 { - Exit(tr.Tr.Get("--to requires exactly one Git LFS object file path")) - } -- checkoutConflict(rootedPaths(args)[0], stage) -+ checkoutConflict(rootedPaths[0], stage) - return - } else if checkoutTo != "" || stage != git.IndexStageDefault { - Exit(tr.Tr.Get("--to and exactly one of --theirs, --ours, and --base must be used together")) -@@ -53,6 +56,7 @@ func checkoutCommand(cmd *cobra.Command, args []string) { - Panic(err, tr.Tr.Get("Could not checkout")) - } - -+ // will chdir to root of working tree, if one exists - singleCheckout := newSingleCheckout(cfg.Git, "") - if singleCheckout.Skip() { - fmt.Println(tr.Tr.Get("Cannot checkout LFS objects, Git LFS is not installed.")) -@@ -80,7 +84,7 @@ func checkoutCommand(cmd *cobra.Command, args []string) { - pointers = append(pointers, p) - }) - -- chgitscanner.Filter = filepathfilter.New(rootedPaths(args), nil, filepathfilter.GitIgnore) -+ chgitscanner.Filter = filepathfilter.New(rootedPaths, nil, filepathfilter.GitIgnore) - - if err := chgitscanner.ScanLFSFiles(ref.Sha, nil); err != nil { - ExitWithError(err) -@@ -101,6 +105,12 @@ func checkoutCommand(cmd *cobra.Command, args []string) { - } - - func checkoutConflict(file string, stage git.IndexStage) { -+ checkoutTo, err := filepath.Abs(checkoutTo) -+ if err != nil { -+ Exit(tr.Tr.Get("Could not convert %q to absolute path: %v", checkoutTo, err)) -+ } -+ -+ // will chdir to root of working tree, if one exists - singleCheckout := newSingleCheckout(cfg.Git, "") - if singleCheckout.Skip() { - fmt.Println(tr.Tr.Get("Cannot checkout LFS objects, Git LFS is not installed.")) -diff --git a/commands/command_pull.go b/commands/command_pull.go -index cbfdc0e7..ea9c23c3 100644 ---- a/commands/command_pull.go -+++ b/commands/command_pull.go -@@ -46,6 +46,8 @@ func pull(filter *filepathfilter.Filter) { - meter.Logger = meter.LoggerFromEnv(cfg.Os) - logger.Enqueue(meter) - remote := cfg.Remote() -+ -+ // will chdir to root of working tree, if one exists - singleCheckout := newSingleCheckout(cfg.Git, remote) - q := newDownloadQueue(singleCheckout.Manifest(), remote, tq.WithProgress(meter)) - gitscanner := lfs.NewGitScanner(cfg, func(p *lfs.WrappedPointer, err error) { -diff --git a/commands/pull.go b/commands/pull.go -index c2c533b8..41827ace 100644 ---- a/commands/pull.go -+++ b/commands/pull.go -@@ -18,25 +18,29 @@ import ( - - // Handles the process of checking out a single file, and updating the git - // index. -+// Note that the current working directory will be changed to the root -+// of the working tree, unless no work tree exists (e.g., the repository -+// is bare and GIT_WORK_TREE is not defined), in which case Run() is a no-op. - func newSingleCheckout(gitEnv config.Environment, remote string) abstractCheckout { - clean, ok := gitEnv.Get("filter.lfs.clean") - if !ok || len(clean) == 0 { - return &noOpCheckout{remote: remote} - } - -- // Get a converter from repo-relative to cwd-relative -- // Since writing data & calling git update-index must be relative to cwd -- pathConverter, err := lfs.NewRepoToCurrentPathConverter(cfg) -- if err != nil { -- Panic(err, tr.Tr.Get("Could not convert file paths")) -+ workingDir := cfg.LocalWorkingDir() -+ hasWorkTree := workingDir != "" -+ if hasWorkTree { -+ if err := os.Chdir(workingDir); err != nil { -+ FullError(errors.Wrap(err, tr.Tr.Get("Checkout error trying to change directory: %s", workingDir))) -+ hasWorkTree = false -+ } - } - - return &singleCheckout{ -- gitIndexer: &gitIndexer{}, -- hasWorkTree: cfg.LocalWorkingDir() != "", -- pathConverter: pathConverter, -- manifest: nil, -- remote: remote, -+ gitIndexer: &gitIndexer{}, -+ hasWorkTree: hasWorkTree, -+ manifest: nil, -+ remote: remote, - } - } - -@@ -49,11 +53,10 @@ type abstractCheckout interface { - } - - type singleCheckout struct { -- gitIndexer *gitIndexer -- hasWorkTree bool -- pathConverter lfs.PathConverter -- manifest tq.Manifest -- remote string -+ gitIndexer *gitIndexer -+ hasWorkTree bool -+ manifest tq.Manifest -+ remote string - } - - func (c *singleCheckout) Manifest() tq.Manifest { -@@ -72,10 +75,8 @@ func (c *singleCheckout) Run(p *lfs.WrappedPointer) { - return - } - -- cwdfilepath := c.pathConverter.Convert(p.Name) -- - // Check the content - either missing or still this pointer (not exist is ok) -- filepointer, err := lfs.DecodePointerFromFile(cwdfilepath) -+ filepointer, err := lfs.DecodePointerFromFile(p.Name) - if err != nil { - if os.IsNotExist(err) { - output, err := git.DiffIndexWithPaths("HEAD", true, []string{p.Name}) -@@ -105,7 +106,7 @@ func (c *singleCheckout) Run(p *lfs.WrappedPointer) { - return - } - -- if err := c.RunToPath(p, cwdfilepath); err != nil { -+ if err := c.RunToPath(p, p.Name); err != nil { - if errors.IsDownloadDeclinedError(err) { - // acceptable error, data not local (fetch not run or include/exclude) - Error(tr.Tr.Get("Skipped checkout for %q, content not local. Use fetch to download.", p.Name)) -@@ -116,7 +117,7 @@ func (c *singleCheckout) Run(p *lfs.WrappedPointer) { - } - - // errors are only returned when the gitIndexer is starting a new cmd -- if err := c.gitIndexer.Add(cwdfilepath); err != nil { -+ if err := c.gitIndexer.Add(p.Name); err != nil { - Panic(err, tr.Tr.Get("Could not update the index")) - } - } -diff --git a/lfs/util.go b/lfs/util.go -index 76a49681..5fe05c7d 100644 ---- a/lfs/util.go -+++ b/lfs/util.go -@@ -99,42 +99,6 @@ type PathConverter interface { - Convert(string) string - } - --// Convert filenames expressed relative to the root of the repo relative to the --// current working dir. Useful when needing to calling git with results from a rooted command, --// but the user is in a subdir of their repo --func NewRepoToCurrentPathConverter(cfg *config.Configuration) (PathConverter, error) { -- r, c, p, err := pathConverterArgs(cfg) -- if err != nil { -- return nil, err -- } -- -- return &repoToCurrentPathConverter{ -- repoDir: r, -- currDir: c, -- passthrough: p, -- }, nil --} -- --type repoToCurrentPathConverter struct { -- repoDir string -- currDir string -- passthrough bool --} -- --func (p *repoToCurrentPathConverter) Convert(filename string) string { -- if p.passthrough { -- return filename -- } -- -- abs := join(p.repoDir, filename) -- rel, err := filepath.Rel(p.currDir, abs) -- if err != nil { -- // Use absolute file instead -- return abs -- } -- return filepath.ToSlash(rel) --} -- - // Convert filenames expressed relative to the current directory to be - // relative to the repo root. Useful when calling git with arguments that requires them - // to be rooted but the user is in a subdir of their repo & expects to use relative args -diff --git a/t/cmd/lfstest-caseinverterextension.go b/t/cmd/lfstest-caseinverterextension.go -index d9f4929d..9cf9f63b 100644 ---- a/t/cmd/lfstest-caseinverterextension.go -+++ b/t/cmd/lfstest-caseinverterextension.go -@@ -16,6 +16,8 @@ import ( - "unicode" - ) - -+var gitDir = ".git" -+ - func main() { - log := openLog() - -@@ -23,12 +25,21 @@ func main() { - logErrorAndExit(log, "invalid arguments: %s", strings.Join(os.Args, " ")) - } - -+ stat, err := os.Stat(".git") -+ if os.IsNotExist(err) { -+ logErrorAndExit(log, "%q directory not found", gitDir) -+ } else if err != nil { -+ logErrorAndExit(log, "unable to check %q directory: %s", gitDir, err) -+ } else if !stat.Mode().IsDir() { -+ logErrorAndExit(log, "%q is not a directory", gitDir) -+ } -+ - if log != nil { - fmt.Fprintf(log, "%s: %s\n", os.Args[1], os.Args[3]) - } - - reader := bufio.NewReader(os.Stdin) -- var err error -+ err = nil - for { - var r rune - r, _, err = reader.ReadRune() -diff --git a/t/t-checkout.sh b/t/t-checkout.sh -index ebb89f30..f7c362c4 100755 ---- a/t/t-checkout.sh -+++ b/t/t-checkout.sh -@@ -1121,12 +1121,7 @@ begin_test "checkout: pointer extension" - popd - - [ "$contents" = "$(cat "dir1/abc.dat")" ] -- -- # Note that at present we expect "git lfs checkout" to run the extension -- # program in the current working directory rather than the repository root, -- # as would occur if it was run within a smudge filter operation started -- # by Git. -- grep "smudge: ../dir1/abc.dat" "$LFSTEST_EXT_LOG" -+ grep "smudge: dir1/abc.dat" "$LFSTEST_EXT_LOG" - ) - end_test - -@@ -1174,8 +1169,9 @@ begin_test "checkout: pointer extension with conflict" - - # Note that at present we expect "git lfs checkout" to pass the argument - # from its --to option to the extension program instead of the pointer's -- # file path. -- grep "smudge: base.txt" "$LFSTEST_EXT_LOG" -+ # file path, after converting the argument into an absolute path. -+ abs_curr_dir="$TRASHDIR/$reponame" -+ grep "smudge: $(canonical_path_escaped "$abs_curr_dir/base.txt")" "$LFSTEST_EXT_LOG" - - rm -f "$LFSTEST_EXT_LOG" - -@@ -1187,8 +1183,8 @@ begin_test "checkout: pointer extension with conflict" - - # Note that at present we expect "git lfs checkout" to pass the argument - # from its --to option to the extension program instead of the pointer's -- # file path. -- grep "smudge: ../ours.txt" "$LFSTEST_EXT_LOG" -+ # file path, after converting the argument into an absolute path. -+ grep "smudge: $(canonical_path_escaped "$abs_curr_dir/ours.txt")" "$LFSTEST_EXT_LOG" - - abs_assert_dir="$TRASHDIR/${reponame}-assert" - abs_theirs_file="$(canonical_path "$abs_assert_dir/dir1/dir2/theirs.txt")" -@@ -1204,7 +1200,7 @@ begin_test "checkout: pointer extension with conflict" - - # Note that at present we expect "git lfs checkout" to pass the argument - # from its --to option to the extension program instead of the pointer's -- # file path. -+ # file path, after converting the argument into an absolute path. - grep "smudge: $(escape_path "$abs_theirs_file")" "$LFSTEST_EXT_LOG" - ) - end_test -diff --git a/t/t-pull.sh b/t/t-pull.sh -index 5588dbfd..af71dc79 100644 ---- a/t/t-pull.sh -+++ b/t/t-pull.sh -@@ -1263,3 +1263,58 @@ begin_test "pull with partial clone and sparse checkout and index" - fi - ) - end_test -+ -+begin_test "pull: pointer extension" -+( -+ set -e -+ -+ reponame="pull-pointer-extension" -+ setup_remote_repo "$reponame" -+ clone_repo "$reponame" "$reponame" -+ -+ git lfs track "*.dat" -+ -+ setup_case_inverter_extension -+ -+ contents="abc" -+ inverted_contents_oid="$(calc_oid "$(invert_case "$contents")")" -+ mkdir dir1 -+ printf "%s" "$contents" >dir1/abc.dat -+ -+ git add .gitattributes dir1 -+ git commit -m "initial commit" -+ -+ git push origin main -+ assert_server_object "$reponame" "$inverted_contents_oid" -+ -+ cd .. -+ GIT_LFS_SKIP_SMUDGE=1 git clone "$GITSERVER/$reponame" "${reponame}-assert" -+ -+ cd "${reponame}-assert" -+ refute_local_object "$inverted_contents_oid" -+ -+ setup_case_inverter_extension -+ -+ rm -rf dir1 "$LFSTEST_EXT_LOG" -+ git lfs pull -+ -+ assert_local_object "$inverted_contents_oid" 3 -+ -+ [ "$contents" = "$(cat "dir1/abc.dat")" ] -+ grep "smudge: dir1/abc.dat" "$LFSTEST_EXT_LOG" -+ -+ rm -rf .git/lfs/objects -+ -+ rm -rf dir1 "$LFSTEST_EXT_LOG" -+ mkdir dir2 -+ -+ pushd dir2 -+ git lfs pull -+ popd -+ -+ [ "$contents" = "$(cat "dir1/abc.dat")" ] -+ grep "smudge: dir1/abc.dat" "$LFSTEST_EXT_LOG" -+ -+ assert_local_object "$inverted_contents_oid" 3 -+) -+end_test --- -2.51.1 - - -From 56f33aca2cad74d512297e0d143b0ad207980ba7 Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Sun, 24 Aug 2025 21:17:41 -0700 -Subject: [PATCH 17/21] check for dir/symlink conflicts on checkout/pull - -Our "git lfs checkout" and "git lfs pull" commands, at present, -follow any extant symbolic links when they populate the current working -tree with files containing the content of Git LFS objects, even if -the symbolic links point to locations outside of the working tree. -This vulnerability has been assigned the identifier CVE-2025-26625. - -In previous commits we partially addressed this vulnerability by -ensuring that the "git lfs checkout" and "git lfs pull" commands remove -any file or symbolic link which already exists at the location where -they intend to write the contents of a Git LFS file, and by checking for -symbolic links at these locations first in the DecodePointerFromBlob() -function of the "lfs" package. - -However, these changes still allow for the possibility that a symbolic -link exists in place of a directory in the path between the root of -the working tree and the location where the commands intend to create -a file. At present, the "git lfs checkout" and "git lfs pull" commands -will not detect such links, and so may be induced to write to a location -outside of the working tree. - -To address this issue, revise the "git lfs checkout" and "git lfs pull" -commands so they check each path component from the root of the working -tree to a Git LFS file. If any are missing, a directory is created, and -if any already exist but are not directories, the commands report an -error and do not try to create the Git LFS file or write to it. - -In our implementation of these checks, we adopt a similar approach to -the one used by Git, which also tries to avoid accidentally traversing -symbolic links when updating the files in a working tree. For -performance and compatibility reasons, though, Git does not try to -completely eliminate all TOCTOU (time-of-check/time-of-use) races -involving symbolic links. - -Likewise, we do not aim to prevent every possible race which might -allow the Git LFS client to unintentionally write through a symbolic -link. Instead, we try to limit the chances of this occurring as far as -we reasonably can, while avoiding significant performance penalties. - -One difference between our approach and that taken by Git is that -when the we check whether a directory exists and find something other -than a directory, we do not try to remove it. This design choice -retains compatibility with the legacy behaviour of the Git LFS client, -which simply invoked the MkdirAll() function of the "os" package in -the Go standard library. That function returns an error if any of the -directories in the given path do not already exist and cannot be created, -and the "git lfs checkout" and "git lfs pull" commands would just report -that error rather than attempt to resolve it by removing anything. - -Another difference between the way Git checks for directory path -conflicts and the implementation we introduce in this commit is that -Git retains the results of its checks in a simple single-entry cache -while we repeat our checks for each new Git LFS file we process. We -can add caching logic in the future if we find it valuable, but we -would require a more complex and thread-safe cache than Git's due to -our use of multiple goroutines in the "git lfs pull" command, and initial -testing indicates that the performance gains would be relatively limited. - -When the "git checkout" command runs, the checkout_entry_ca() function -performs the necessary changes in the working tree in order to be able -to write a copy of a given file at its expected location. This function -invokes the create_directories() function to ensure that all of the -directories between the root of the working tree and the file are present -before the file is created. If the create_directories() function detects -a conflict in place of any directory, such as a file or symbolic link, -it tries to remove the conflicting entry and then create a new directory -in its place. - -As noted above, though, Git does not re-check every directory entry in -a file's path in all cases, and also does not try to avoid TOCTOU races -in the checks it does perform. The create_directories() function relies -on the has_dirs_only_path() function to report whether a path consists -of only directories, and that function ultimately invokes the -lstat_cache_matchlen() function to determine whether Git believes this -to be the case or not: - - https://github.com/git/git/blob/v2.50.1/entry.c#L582 - https://github.com/git/git/blob/v2.50.1/entry.c#L41-L42 - https://github.com/git/git/blob/v2.50.1/symlinks.c#L257 - https://github.com/git/git/blob/v2.50.1/symlinks.c#L276-L278 - https://github.com/git/git/blob/v2.50.1/symlinks.c#L199-L200 - https://github.com/git/git/blob/v2.50.1/symlinks.c#L63-L193 - -The lstat_cache_matchlen() function accepts a path from the root of the -repository as its "name" parameter, and for each component of the path -for which the function does not have any cached information, it uses -the lstat(2) POSIX system call to test whether that path component exists -and if it is a directory or not. The final result is then retained in -the function's single-entry cache. - -The use of a cache with only a single entry is viable for Git because in -almost all cases, it processes files in sorted order. Thus it can make -use of the cached lstat(2) information about the directory "abc" from the -path "abc/bar.txt" when checking the path of "abc/foo.txt", for instance. - -The use of cache in this function, though, is one of the reasons Git is -not immune to TOCTOU races involving symbolic links. If a directory is -replaced with a symbolic link after the lstat_cache_matchlen() function -has checked the path, the lstat_cache_matchlen() function will assume -another file with the same leading path components can be created without -re-checking for symbolic links, and Git will traverse the new symbolic -link when writing the file, even if it leads to a location outside of -the working tree. - -Git also has to be careful to reset the cache whenever it removes any -of the directories in the cached path, as may occur when Git processes -files that are not in sorted order and their paths conflict with each -other due to case-insensitivity or case-folding on the part of the -filesystem. This type of situation was described in commit -git/git@684dd4c2b414bcf648505e74498a608f28de4592, which added logic to -ensure the cache is cleared under these conditions as part of the -remediation for the vulnerability identified as CVE-2021-21300. - -Further, Git would also need to consistently use the openat(2) family of -POSIX system calls in conjunction with their O_NOFOLLOW flags, or their -equivalent on Windows, in order to guarantee that a given path consists -solely of directories and no symbolic links. As noted in commit -git/git@f4aa8c8bb11dae6e769cd930565173808cbb69c8 in relation to the -vulnerability identified as CVE-2024-32004, on Windows this type of -implementation would require the use of the relatively expensive -NtCreateFile() system call (and its FILE_OPEN_REPARSE_POINT flag): - - https://pubs.opengroup.org/onlinepubs/9699919799/functions/open.html - https://pubs.opengroup.org/onlinepubs/9699919799/functions/fstatat.html - https://www.man7.org/linux/man-pages/man2/openat.2.html - https://www.man7.org/linux/man-pages/man2/stat.2.html - https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntcreatefile - -Beginning with version 1.24.0, Go introduced a Root structure type in -the "os" package of the standard library, with a set of methods which -explicitly enforces file path boundaries, using the openat(2) family -of system calls where they are available, and the NtCreateFile() system -call on Windows. Go v1.25.0 expanded the set of methods in the Root -type, and in particular added a MkdirAll() method which mirrors -the regular MkdirAll() function in the "os" package, but checks that -none of the components in a path are symbolic links to locations -outside a given initial "root" path. The development of the Root -type and its API was tracked in golang/go#67002. - -One minor caveat with the MkdirAll() method of the Root structure type -is that it allows symbolic links to exist in a path, so long as they do -not resolve to location outside the path that was initially passed to -the OpenRoot() function. We would prefer to avoid these types of "local" -symbolic links as well when they conflict with a directory we expect -to exist, so the Root type's MkdirAll() method would not suffice for -our purposes. - -A more important challenge with the Root structure type is that -consistent use of its methods would result in a noticeable increase in -the execution time of our commands when processing even moderate numbers -of Git LFS files. Each of the type's methods, including Lstat(), -Mkdir(), OpenFile(), and Remove(), traverses the directories in its -path parameter and checks that none are symbolic links to locations -outside the path initially passed to the OpenRoot() function. Each -method's cost therefore scales with the number of directories in its -path parameter; i.e., given "m" method calls and "n" directories in a path, -the number of system calls scales as O(m*n). For this reason, the Go -documentation states that: - - "Root operations on filenames containing many directory components can - be much more expensive than the equivalent non-Root operation." - - https://go.dev/blog/osroot#performance - -We verified this performance penalty in tests of a modified "git lfs -checkout" command which checks for symbolic links in each Git LFS file's -path within the repository by calling the methods of the Root structure -type. We also tested the implementation from this commit, and we report -those results in more detail below. In brief, even without a cache like -the one in Git's lstat_cache_matchlen() function, the technique we -introduce in this commit adds a modest overhead, while the use of the -Root structure's methods significantly increased the command's runtime. - -Our preferred technique relies on several enhancements we made in -previous commits to the "git lfs checkout" and "git lfs pull" commands. -These commands retrieve a list of Git LFS pointer files from the -ScanLFSFiles() method of the GitScanner structure type in our "lfs" -package, and for each file, invoke the Run() method of the -singleCheckout structure type in our "commands" package. The Run() -method then determines whether or not to write the contents of the -object referenced by the pointer into a file in the working tree at -the appropriate path. - -In prior commits we revised the newSingleCheckout() function to verify -whether a working tree exists when it initializes a new singleCheckout -structure, and if a tree is present, to change the current working -directory to the root of the tree. We also adjusted the Run() method -so that it returns immediately without taking action if no working -tree was found by the newSingleCheckout() function. - -We now introduce a new DirWalker structure type in our "tools" package, -with Walk() and WalkAndCreate() methods which check that each component -of a given path is a directory, and return an error if a conflict is -found. If a directory is missing, the Walk() method will return an -error, while the WalkAndCreate() method will try to create the directory. -Both methods are simple wrappers around the internal walk() method, -whose "create" parameter indicates whether the method should try to -create missing directories or not. - -To initialize a DirWalker structure we define a NewDirWalkerForFile() -function, which requires three parameters. The first is an initial -parent path which should be specified as a path relative to the current -working directory, and which is stored in the "parentPath" element of -the new DirWalker structure. The second parameter is a file path which -should be specified as a path relative to the parent path. If the parent -path is empty, the file path is understood to be relative to the current -working directory. The third parameter must be a structure with a -RepositoryPermissions() method which conforms to the -repositoryPermissionFetcher interface type from our "tools" package. - -The NewDirWalkerForFile() function removes the final filename path -segment from its second "filePath" parameter in order to populate the -new DirWalker structure's "path" element with leading directories in the -file's path, if any. If the "filePath" parameter contains a bare -filename, because the file resides at the root of the repository, then -the "path" element is set to an empty path. Note that we do not use the -Dir() function from the "path/filepath" package in the Go standard -library to remove the filename from the "filePath" parameter because -that function returns a "." path when a path has no leading directory -components, and because it replaces the "/" separator with the "\" -separator on Windows, which we do not want to do in this context. - -When the DirWalker structure's walk() method is called, it assumes that -the path identified by the structure's "parentPath" element exists within -the current working directory, and then checks each of the directories -in the "path" element until either an error is returned or all the -directories have been checked. If a directory does not exist, the -walk() method returns an ErrNotExist error unless the "create" -parameter is set to "true", in which case the walk() method will try -to create the missing directory. If a conflict is found in the place -of a directory, such as a pre-existing file or symbolic link with the -same name, then the walk() method returns a custom errNotDir error. - -Assuming that the newSingleCheckout() function found an extant working -tree and was able to change the current working directory to the root -of the tree, the singleCheckout structure's Run() method creates a new -DirWalker structure and calls its Walk() method to determine which -directories in the given Git LFS pointer file's path already exist, -without at first trying to create any new directories. Since the current -working directory is the root of the work tree, the Run() method passes -an empty path to the NewDirWalkerForFile() function as its "parentPath" -parameter, and the pointer file's path as the "filePath" parameter. - -The pointer file paths processed by the Run() method are guaranteed to -be those supplied by Git, since they are the paths returned by the -ScanLFSFiles() method of the GitScanner structure, which reads the -paths from the output of either a "git ls-files" or "git ls-tree" -command. As such, we expect these paths to always use forward slash -characters as separators, to always be relative paths and not absolute -paths, and to never contain empty path components or "." or ".." path -components. For safety, the DirWalker structure's walk() method rejects -any path which contains any of these path components and returns an -error in such a case. - -If the call to the Walk() method returns an error, the Run() method -checks whether the error was due to a missing directory or some other -issue. If an ErrNotExist error from the "os" package was returned, this -indicates that at least one directory in the current Git LFS pointer -file's path does not exist, in which case the Run() method skips calling -the DecodePointerFromFile() function from our "lfs" package, since there -is no value in trying to read a non-existent file's contents to see if -it contains a raw Git LFS pointer. If some other type of error was -returned, the Run() method logs the error and returns without proceeding -further, and if no error was returned, then all the file's ancestor -directories were found, so the Run() method does call the -DecodePointerFromFile() function in that case. - -The Run() method then proceeds to check the results from the -DecodePointerFromFile() function, if it was called at all. This logic -remains unchanged, but can take advantage of the fact that an ErrNotExist -error from the call to the Walk() method implies that no pointer file -exists. When this type of error is returned by either the Walk() method -or the DecodePointerFromFile() function, the Run() method then calls the -DiffIndexWithPaths() function in our "git" package to check if the user -has intentionally removed the file from Git's index, in which case no -further action should be taken. - -If an ErrNotExist error was returned by either the Walk() method or the -DecodePointerFromFile() function, and the user has not removed the file -from Git's index, then the Run() method calls the DirWalker structure's -WalkAndCreate() method in order to create any directories in the file's -path which are missing. For this call, the internal walk() method of -the DirWalker structure continues where the previous invocation left off, -based on the values of the internal "parentPath" and "path" elements of -the structure. - -The previous invocation of the walk() method by the Walk() method will -have set the structure's "parentPath" element to contain the leading -directories in the file's path that were found to exist, and set the -"path" element to contain just those directories which need to be -created. Note that either of these paths may be empty, since there may -be no missing directories, or all the directories in the file's path may -be missing, or the file may be located in the top-level directory. - -To verify that the DirWalker structure's internal walk() method handles -all of these potential conditions, along with various types of directory -conflicts such as pre-existing files or symbolic links, we add a -TestDirWalkerWalk() Go test function and define a large number of valid -and invalid test cases for this function. The test function then -exercises the walk() method in all the defined test cases, both with an -empty parent path and with a non-empty parent path. - -When the Run() method calls the DirWalker's WalkAndCreate() method, this -passes a "true" value to the walk() method for its "create" parameter, so -any directories that are missing will be created. This means that when -the Run() method then calls the RunToPath() method, and it invokes the -SmudgeToFile() method of the GitFilter structure in our "lfs" package, -that method no longer needs to try to create any directories. We -therefore remove the call to the MkdirAll() function in our "tools" -package from the SmudgeToFile() method. - -However, the MkdirAll() function in our "tools" package is designed to -enforce any umask settings defined by Git's "core.sharedRepository" -configuration option, which is why the SmudgeToFile() method did not -simply invoke the MkdirAll() function from the "os" package. Since -we want to retain support for this Git configuration option, we add -a Mkdir() function to our "tools" package which mirrors the MkdirAll() -function, with the only difference being that it wraps the Mkdir() -function from the "os" package rather than the MkdirAll() function. -We then call the new function in the walk() method instead of calling -the Mkdir() function from the "os" package directly. - -There is one use case where we still need to use the MkdirAll() -function from our "tools" package, though. When the "git lfs checkout" -command is run with its --to option, the RunToPath() method of the -singleCheckout structure is invoked directly. The file path specified -as the parameter of the --to option is converted to an absolute path -and passed to the RunToPath() method so that the contents of the -Git LFS object identified by the other command-line parameters are -written to a file at the given path. - -Since the Run() method does not execute in this case, the WalkAndCreate() -method is not called and therefore will not create any directories that -might be missing in the path specified by the --to option, and neither -will the SmudgeToFile() method, because it no longer calls the MkdirAll() -function from our "tools" package. To ensure that we still support the -use of the --to option with an arbitrary file path parameter, we now call -the "tools" package's MkdirAll() function in the checkoutConflict() -function of the "git lfs checkout" command immediately after we convert -the --to option's parameter into an absolute file path. - -In previous commits we expanded the checks in the "checkout: conflicts" -test in our t/t-checkout.sh test script so it will validate the use -of the "git lfs checkout" command's --to option in a wide range of -conditions, including with file path parameters to locations with -ancestor directories that do not exist. As a consequence, we can be -confident that the test validates that our changes in this commit do -not introduce a regression in our support of the --to option of the -"git lfs checkout" command. - -On the other hand, we do require additional shell tests to thoroughly -validate the effectiveness of our revisions to the methods of the -singleCheckout structure. Since we expect the "git lfs checkout" and -"git lfs pull" commands to now try to detect when symbolic links exist -in place of the directories in the paths to Git LFS files in a work tree, -even if the targets of those links are themselves directories, we expand -the "checkout: skip directory symlink conflicts" and "pull: skip -directory symlink conflicts" tests that we added to our t/t-checkout.sh -and t/t-pull.sh test scripts in a prior commit. - -Previously, these two tests verified that the "git lfs checkout" and -"git lfs pull" commands would skip attempting to write out the contents of -Git LFS objects into files in the work tree if the files' paths conflicted -with pre-existing symbolic links, but only when the targets of the links -were not directories. The tests now also specifically check the commands' -behaviour when the targets of the links are directories, since before -our changes in this commit the commands would traverse these links and -create or update files and subdirectories within the target directories. -Note, though, that we do not check this behaviour under TOCTOU race -conditions, because we do not expect the commands to avoid traversing -symbolic links in those cases, as described above. - -We also expand the "checkout: skip case-based symlink conflicts" and -"pull: skip case-based symlink conflicts" tests we added in a previous -commit. These tests now also check that when when the directories in -Git LFS file paths conflict with symbolic links as a result of -case-insensitivity on the part of a filesystem, the "git lfs checkout" -and "git lfs pull" commands detect the conflicts and report errors -instead of trying to populate the Git LFS files with their objects' -contents. - -In both these two tests and the "checkout: skip directory symlink -conflicts" and "pull: skip directory symlink conflicts" tests, we make -an additional check to confirm that when symbolic links to directories -exist in place of regular directories in the paths to Git LFS files, the -Git error message "is beyond a symbolic link" does not appear in the -output of the "git lfs checkout" and "git lfs pull" commands. This -message would indicate that the Git LFS commands attempted to refresh -the Git index using the "git update-index" command for a file whose path -contains a symbolic link to a directory in place of a regular directory. -As the "git lfs checkout" and "git lfs pull" commands should now detect -such symbolic links (so long as there is no TOCTOU race), these Git error -messages should not appear in the commands' output. - -Finally, we adjust the "checkout: skip directory file conflicts" and -"pull: skip directory file conflicts" tests we added in another prior -commit. These tests check that the "git lfs checkout" and "git lfs pull" -commands detect when a regular file exists in the place of a directory -in a Git LFS file's path. Our changes in this commit do not alter -that fundamental behaviour, but they do result in a more consistent -error message from the commands when a regular file exists in place -of a directory. - -Previously, when a file conflicted with a directory in a Git LFS file's -path, the output of the "git lfs checkout" and "git lfs pull" commands -differed between Unix and Windows systems due to a difference in the -error returned by the Lstat() function call performed in the -DecodePointerFromFile() function. On Unix systems, this error -encapsulates an ENOTDIR error number, which the IsNotExist() function -of the "os" package does not consider equivalent to an ErrNotExist -error. On these systems, the Run() method would therefore report the -error immediately after calling the DecodePointerFromFile() function -and then return without taking further action. - -On Windows systems, however, the same circumstances caused the Lstat() -function to return an ErrNotExist error, due to the implementation of -the Lstat() function in the Go standard library, which maps the Windows -ERROR_FILE_NOT_FOUND error number to an ErrNotExist error. As a result, -the Run() method would proceed to call the RunToPath() method, which -invoked the SmudgeToFile() method. When that method called the OpenFile() -function from the "os" package to try to create the Git LFS file, though, -an error would occur, and this was then the error whose message would be -logged by the by the "git lfs checkout" and "git lfs pull" commands. - -Now that the DecodePointerFromFile() function is only called by the Run() -method if its invocation of the DirWalker structure's Walk() method does -not return an error, the "git lfs checkout" and "git lfs pull" commands -will report the same error message on both Unix and Windows systems if -the Walk() method encounters a regular file in place of a directory. -To account for this change, we update our "checkout: skip directory file -conflicts" and "pull: skip directory file conflicts" tests so they expect -the same error message on all systems. - -In addition to these changes to our regular Go and shell test suites, -we also evaluated the impact of our changes in this commit to the -speed of the "git lfs checkout" and "git lfs pull" commands under -moderate workloads. Our performance testing focused on the "git lfs -checkout" command since we are not concerned with the time required -to fetch Git LFS objects from a remote server. - -For our principal test scenario, we created 10,000 small Git LFS files, -with each file containing roughly 10 bytes of data only, so that the -time required to write out the Git LFS object data of each file was -minimal. - -Because the cost of checking for symbolic links in the paths to Git LFS -files will scale with the number of files and the number of path -components, we chose a distribution of our test files with the intent -that it would emulate a relatively normal repository and not a -pathological use case. For example, if we placed all the Git LFS files -at the root of the repository, we would not exercise our new checks -for symbolic links at all. For our principal test repository, we -therefore distributed the Git LFS files in groups of 100 into 100 -subdirectories, with 5 ancestor directories between these each of -these subdirectories and the root of the repository. - -In a completely empty working tree, the runtime of the "git lfs checkout" -command is heavily dominated by the cost of repeatedly spawning the -"git diff-index" command, which we execute once for each file we find -to be missing from the work tree. (Improving this behaviour so that -the "git diff-index" command could be invoked with multiple file paths -would be a valuable enhancement we might want to explore in the future.) - -So as to better evaluate the performance impact of our changes in this -commit, we usually populated our working tree with raw Git LFS pointer -files, as might occur after running "git clone" with the -GIT_LFS_SKIP_SMUDGE environment variable set to a value equivalent -to "true". This avoids the cost of executing the "git diff-index" -command, which can otherwise result in a tenfold increase in the -runtime of the "git lfs checkout" command. - -For the majority of our tests, we utilized a Linux system with 16 cores -running at 2.10 GHz and a 5.15 kernel version. We also repeated our -tests on macOS and Windows systems, with similar results. The times -reported below are from the Linux system tests. - -In our primary test scenario, with 10,000 small Git LFS files in groups -of 100 with 6 levels of subdirectories for each group, the impact of -checking of each directory in the files' paths amounted to a 15% increase -in the average runtime of the "git lfs checkout" command compared to the -3.7.0 version of the Git LFS client. The v3.7.0 client's average -runtime was 3.89s and the average runtime with this commit's changes -was 4.46s. - -We also experimented with the inclusion of a simple lock-free single-entry -cache in the walk() function, similar to the cache implemented by Git in -its lstat_cache_matchlen() function. This reduced the average runtime -of the "git lfs checkout" command in the same scenario described above -to 4.23s, an 8% increase over the v3.7.0 client's average runtime. - -Our test scenario represented the ideal conditions for this simple -cache, however. The "git lfs checkout" command processes files -sequentially in the order returned by the "git ls-files" command (or -the "git ls-tree" command, if the installed version of Git is older -than v2.42.0), and so we could avoid the need for any locks around -our cache, or use a more complex multiple-entry cache. - -The "git lfs pull" command, though, invokes the Run() method of the -singleCheckout structure from two separate goroutines, one of which -receives its list of Git LFS pointer files from the transfer queue as -their corresponding objects' data is downloaded. A functional cache -implementation would consequently require locks to avoid contention -between parallel invocations of the walk() method by separate goroutines, -which would somewhat diminish any potential performance gains. - -A single-entry cache might also prove to be ineffective with the -"git lfs pull" command, since some files would be processed immediately -if their objects were present in the local Git LFS storage directories, -while others would be processed as their objects were downloaded, which -might occur in a significantly different order than the sort order of -the pointers' file paths. Instead of a single-entry cache, we could -use a simple map of unbounded size, or an LRU (Least-Recently Used) -cache with a bounded number of elements. - -However, if we do choose to add a cache in the future, it should not -expose us to the type of vulnerability which the Git project reported -in CVE-2021-21300. That issue resulted partly from the use of a -single-entry cache and an incorrect assumption that files would always -be processed in sorted order, but the key difference between Git and -Git LFS in this regard is that Git tries to conform the working tree -to have the contents it expects, and Git LFS does not. - -During a "git checkout" command, Git will try to remove directory entries -such as files and symbolic links which conflict with the file paths Git -intends to create. Thus, when Git encountered files whose paths -conflicted on a case-insensitive filesystem, if these files were -processed out of the usual sorted order, Git might cache one file -path, then remove it from the filesystem but not the cache, and then -assume the file path still existed based on the contents of the cache. -Git LFS should not be vulnerable to this type of problem because it -does not try to remove entries which conflict with the ancestor -directories in a Git LFS file's path. - -Overall, though, the performance of the "git lfs checkout" command with -the changes from this commit but without any form of caching appears to -be acceptable, so we do not implement a cache in the DirWalker structure's -methods at this time. We can always revisit this decision in the future, -of course. - -As well as testing our changes from this commit (both with and without -a simple cache), we also tested an experimental version of the -"git lfs checkout" command which used the methods of the Root structure -type from the "os" package. As described above, these methods are -designed to ensure that they never operate on files outside a given -initial "root" file path. - -On our Linux test system, the average runtime of the "git lfs checkout" -command, when all filesystem operations were converted to use the -methods of the Root type, was 6.57s in our primary test scenario, a 69% -increase over the average runtime of the command when using the 3.7.0 -version of Git LFS client, and a 47% increase over the average runtime -of the command when using the changes from this commit. (Those average -runtimes were 3.89s and 4.46s, respectively.) - -On a GitHub Actions runner with Windows Server 2025, the average runtime -of the "git lfs checkout" command when all its filesystem operations -used the Root type's methods was 28.83s, a 63% increase over the average -runtime of the command when using the 3.7.0 version of the client, and -a 39% increase over the average runtime of the command when using the -changes from this commit. (Those average runtimes were 17.70s and 20.70s, -respectively.) - -Intriguingly, on a GitHub Actions runner with macOS 15.5 (Sequoia), the -average runtime of the "git lfs checkout" command with the changes from -this commit was 5.81s, 5% faster than the 6.14s average runtime when -using the 3.7.0 version of the Git LFS client. The average runtime of -the command when all filesystem operations used the Root type's methods, -however, was 11.73s, a 91% increase compared to the runtime of the -command with the 3.7.0 version of the client and a 102% increase -compared to the runtime of the command with the changes from this commit. - - | v3.7.0 | DirWalker | os.Root - --------+-----------+-----------+----------- - Linux | 3.89s | 4.46s | 6.57s - macOS | 6.14s | 5.81s | 11.73s - Windows | 17.70s | 20.70s | 28.83s - -As we explained above, these performance impacts are the primary reason -why we avoid the use of the Root interface and its methods and prefer to -check for symbolic links in a more efficient manner, even if that allows -for the possibility that we cannot detect some race conditions. ---- - commands/command_checkout.go | 6 + - commands/pull.go | 24 +- - lfs/gitfilter_smudge.go | 2 - - t/t-checkout.sh | 144 +++++++---- - t/t-pull.sh | 152 ++++++++--- - tools/dir_walker.go | 107 ++++++++ - tools/dir_walker_test.go | 473 +++++++++++++++++++++++++++++++++++ - tools/filetools.go | 9 + - 8 files changed, 829 insertions(+), 88 deletions(-) - create mode 100644 tools/dir_walker.go - create mode 100644 tools/dir_walker_test.go - -diff --git a/commands/command_checkout.go b/commands/command_checkout.go -index fbdeae58..88e8acee 100644 ---- a/commands/command_checkout.go -+++ b/commands/command_checkout.go -@@ -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() { -diff --git a/commands/pull.go b/commands/pull.go -index 41827ace..0896fc63 100644 ---- a/commands/pull.go -+++ b/commands/pull.go -@@ -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) -diff --git a/lfs/gitfilter_smudge.go b/lfs/gitfilter_smudge.go -index 8717362b..59e80bc9 100644 ---- a/lfs/gitfilter_smudge.go -+++ b/lfs/gitfilter_smudge.go -@@ -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 -diff --git a/t/t-checkout.sh b/t/t-checkout.sh -index f7c362c4..79eb2016 100755 ---- a/t/t-checkout.sh -+++ b/t/t-checkout.sh -@@ -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,12 +304,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" ] -@@ -271,7 +313,7 @@ begin_test "checkout: skip directory symlink conflicts" - [ ! -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 -diff --git a/t/t-pull.sh b/t/t-pull.sh -index af71dc79..515074d1 100644 ---- a/t/t-pull.sh -+++ b/t/t-pull.sh -@@ -193,13 +193,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 - -@@ -215,13 +210,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 -@@ -232,8 +222,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 -@@ -264,7 +252,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 -@@ -275,12 +333,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 - -@@ -292,7 +346,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 -@@ -302,12 +356,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 - -@@ -325,12 +375,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 -@@ -538,20 +584,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 -@@ -568,7 +620,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 - -@@ -578,12 +631,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 -@@ -594,6 +653,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 -@@ -602,13 +664,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 -diff --git a/tools/dir_walker.go b/tools/dir_walker.go -new file mode 100644 -index 00000000..7b7c71bf ---- /dev/null -+++ b/tools/dir_walker.go -@@ -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) -+} -diff --git a/tools/dir_walker_test.go b/tools/dir_walker_test.go -new file mode 100644 -index 00000000..33cd140f ---- /dev/null -+++ b/tools/dir_walker_test.go -@@ -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) -+ } -+} -diff --git a/tools/filetools.go b/tools/filetools.go -index 05f56822..7c14151f 100644 ---- a/tools/filetools.go -+++ b/tools/filetools.go -@@ -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 { --- -2.51.1 - - -From 131a96178072221c9887203b2c1b76272039c73b Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Thu, 27 Feb 2025 19:14:33 -0800 -Subject: [PATCH 18/21] errors,locking,ssh: use standard error joining - -As of Go 1.24, if the Go version number in the "go.mod" file is set to -1.24 or higher, the "go vet" command now reports misuses of non-constant -strings as format strings. In previous commits in this PR we have now -resolved all but one of the instances where we provided a non-constant -string as a format string. The remaining instance is our use of the -Errorf() function of the "fmt" package from the Go standard library -at the end of the Combine() function in our "errors" package. - -The Combine() function was added to our custom "errors" package in -commit 08e3e5b40ed629cdd0ce24f2498207f5a8f89a39 of PR #1870, originally -for use in the "locking" package. This function merges multiple -errors into a single error by concatenating their error messages -with a newline separator character between each original message. - -In Go 1.20 the Join() function was added to the "errors" package -of the Go standard library, and it performs the same concatenation -of error messages as our Combine() function, including the use of a -newline character as a separator between the original messages, except -that it delays the concatenation until the Error() method is called. - -To resolve the remaining case where we pass a non-constant string as -a format string, we remove the Combine() function from our "errors" -package and replace it with a Join() function that simply invokes -the Join() function of the standard library's "errors" package, -which we can expect to be defined as we currently require the use of -at least Go 1.21 (per the Go version specified in our "go.mod" file). - -To make this change, we alias the standard library's "errors" package -as "goerrors", following the pattern established in our "lfshttp" -package where we use that alias as well. - -We then revise the two callers of our Combine() function to make -use of the new Join() function instead. One of these callers is the -startConnection() function in our "ssh" package, which simply joins -two errors, and the other caller is the FixLockableFileWriteFlags() -method of the Client structure in our "locking" package, which -collects zero or more errors while iterating over a list of files -and repeatedly calling another method. - -Because the Join() functions accept variadic arguments rather than -an array of errors, we revise these callers to pass the errors -they intend to concatenate as direct arguments rather than in an -array. For the FixLockableFileWriteFlags() method this means that -as it iterates over its list of files, if any errors occur we -immediately call the Join() function to add them to the error value -we return at the end of the function, whereas previously we appended -the errors to an array, and then called the Combine() function after -the loop exited to concatenate any errors in the array into the -function's final error return value. ---- - errors/errors.go | 17 +++-------------- - locking/lockable.go | 6 +++--- - ssh/connection.go | 3 +-- - 3 files changed, 7 insertions(+), 19 deletions(-) - -diff --git a/errors/errors.go b/errors/errors.go -index c77b5e93..3e9922e0 100644 ---- a/errors/errors.go -+++ b/errors/errors.go -@@ -50,7 +50,7 @@ package errors - // docs for more info: https://godoc.org/github.com/pkg/errors - - import ( -- "bytes" -+ goerrors "errors" - "fmt" - - "github.com/pkg/errors" -@@ -102,19 +102,8 @@ func StackTrace(err error) []string { - return nil - } - --func Combine(errs []error) error { -- if len(errs) == 0 { -- return nil -- } -- -- var buf bytes.Buffer -- for i, err := range errs { -- if i > 0 { -- buf.WriteString("\n") -- } -- buf.WriteString(err.Error()) -- } -- return fmt.Errorf("%s", buf.String()) -+func Join(errs ...error) error { -+ return goerrors.Join(errs...) - } - - func Cause(err error) error { -diff --git a/locking/lockable.go b/locking/lockable.go -index 2248e521..a6545770 100644 ---- a/locking/lockable.go -+++ b/locking/lockable.go -@@ -142,15 +142,15 @@ func (c *Client) FixLockableFileWriteFlags(files []string) error { - return nil - } - -- var errs []error -+ var multiErr error - for _, f := range files { - err := c.fixSingleFileWriteFlags(f, c.getLockableFilter(), nil) - if err != nil { -- errs = append(errs, err) -+ multiErr = errors.Join(multiErr, err) - } - } - -- return errors.Combine(errs) -+ return multiErr - } - - // fixSingleFileWriteFlags fixes write flags on a single file -diff --git a/ssh/connection.go b/ssh/connection.go -index f08165ae..33fa6314 100644 ---- a/ssh/connection.go -+++ b/ssh/connection.go -@@ -2,7 +2,6 @@ package ssh - - import ( - "bytes" -- "fmt" - "sync" - - "github.com/git-lfs/git-lfs/v3/config" -@@ -80,7 +79,7 @@ func startConnection(id int, osEnv config.Environment, gitEnv config.Environment - r.Close() - w.Close() - cmd.Wait() -- err = errors.Combine([]error{err, fmt.Errorf("%s", tr.Tr.Get("Failed to connect to remote SSH server: %s", cmd.Stderr))}) -+ err = errors.Join(err, errors.New(tr.Tr.Get("Failed to connect to remote SSH server: %s", cmd.Stderr))) - tracerx.Printf("pure SSH connection unsuccessful (#%d)", id) - } else { - tracerx.Printf("pure SSH connection successful (#%d)", id) --- -2.51.1 - - -From 3e77dd9e226dee16fa968417d2f9588d374ce0fe Mon Sep 17 00:00:00 2001 -From: Chris Darroch -Date: Thu, 27 Feb 2025 19:23:46 -0800 -Subject: [PATCH 19/21] commands,creds,lfs,tools: use error join function - -In a previous commit in this PR we replaced the Combine() function -in our custom "errors" package with a Join() function that passes -its arguments through to the Join() function of the Go standard -library's "errors" package. Both these Join() functions and our -earlier Combine() function serve to concatenate multiple error -values into a single error value, with the individual error messages -separated by newline characters when the error is converted to a -string value for output. - -In a number of instances we perform the same concatenation of error -messages using loops and assembling the final error message by -explicitly appending each individual error message along with the -newline separator characters. - -We can now simplify all these instances where we need to concatenate -error messages by making use of the Join() function in our "errors" -package. In almost all of these cases, this means we are able to -eliminate the conditional logic used to check if a newline separator -character is required because more than one error has been encountered. -(The exception is in the Fill() method of the CredentialHelpers -structure of our "creds" package where we previously concatenated -the error messages using the Join() method of the "strings" package -from the Go standard library.) - -Note that we could also have used the Combine() function from our -"errors" package to simplify these use cases; however, the Join() -function is more convenient in most instances because it accepts -variadic arguments rather than requiring an array of errors as input. ---- - commands/command_fetch.go | 20 ++++---------------- - commands/uploader.go | 7 +------ - creds/creds.go | 10 +++++----- - lfs/gitfilter_smudge.go | 21 ++------------------- - lfs/scanner_git_test.go | 8 ++------ - tools/channels.go | 17 ++++++----------- - 6 files changed, 20 insertions(+), 63 deletions(-) - -diff --git a/commands/command_fetch.go b/commands/command_fetch.go -index 4bbafc6c..6d0b1541 100644 ---- a/commands/command_fetch.go -+++ b/commands/command_fetch.go -@@ -1,10 +1,10 @@ - package commands - - import ( -- "fmt" - "os" - "time" - -+ "github.com/git-lfs/git-lfs/v3/errors" - "github.com/git-lfs/git-lfs/v3/filepathfilter" - "github.com/git-lfs/git-lfs/v3/git" - "github.com/git-lfs/git-lfs/v3/lfs" -@@ -121,11 +121,7 @@ func pointersToFetchForRef(ref string, filter *filepathfilter.Filter) ([]*lfs.Wr - var multiErr error - tempgitscanner := lfs.NewGitScanner(cfg, func(p *lfs.WrappedPointer, err error) { - if err != nil { -- if multiErr != nil { -- multiErr = fmt.Errorf("%v\n%v", multiErr, err) -- } else { -- multiErr = err -- } -+ multiErr = errors.Join(multiErr, err) - return - } - -@@ -164,11 +160,7 @@ func pointersToFetchForRefs(refs []string) ([]*lfs.WrappedPointer, error) { - var numObjs int64 - tempgitscanner := lfs.NewGitScanner(cfg, func(p *lfs.WrappedPointer, err error) { - if err != nil { -- if multiErr != nil { -- multiErr = fmt.Errorf("%v\n%v", multiErr, err) -- } else { -- multiErr = err -- } -+ multiErr = errors.Join(multiErr, err) - return - } - -@@ -299,11 +291,7 @@ func scanAll() []*lfs.WrappedPointer { - var numObjs int64 - tempgitscanner := lfs.NewGitScanner(cfg, func(p *lfs.WrappedPointer, err error) { - if err != nil { -- if multiErr != nil { -- multiErr = fmt.Errorf("%v\n%v", multiErr, err) -- } else { -- multiErr = err -- } -+ multiErr = errors.Join(multiErr, err) - return - } - -diff --git a/commands/uploader.go b/commands/uploader.go -index 1ad8bb6a..2d94c95f 100644 ---- a/commands/uploader.go -+++ b/commands/uploader.go -@@ -1,7 +1,6 @@ - package commands - - import ( -- "fmt" - "io" - "net/url" - "os" -@@ -139,11 +138,7 @@ func (c *uploadContext) addScannerError(err error) { - c.errMu.Lock() - defer c.errMu.Unlock() - -- if c.scannerErr != nil { -- c.scannerErr = fmt.Errorf("%v\n%v", c.scannerErr, err) -- } else { -- c.scannerErr = err -- } -+ c.scannerErr = errors.Join(c.scannerErr, err) - } - - func (c *uploadContext) buildGitScanner() *lfs.GitScanner { -diff --git a/creds/creds.go b/creds/creds.go -index d869dbca..e2b9c829 100644 ---- a/creds/creds.go -+++ b/creds/creds.go -@@ -500,7 +500,7 @@ var credHelperNoOp = errors.New("no-op!") - // helpers are added to the skip list, and never attempted again for the - // lifetime of the current Git LFS command. - func (s *CredentialHelpers) Fill(what Creds) (Creds, error) { -- errs := make([]string, 0, len(s.helpers)) -+ var multiErr error - for i, h := range s.helpers { - if s.skipped(i) { - continue -@@ -511,7 +511,7 @@ func (s *CredentialHelpers) Fill(what Creds) (Creds, error) { - if err != credHelperNoOp { - s.skip(i) - tracerx.Printf("credential fill error: %s", err) -- errs = append(errs, err.Error()) -+ multiErr = errors.Join(multiErr, err) - } - continue - } -@@ -521,11 +521,11 @@ func (s *CredentialHelpers) Fill(what Creds) (Creds, error) { - } - } - -- if len(errs) > 0 { -- return nil, errors.New(tr.Tr.Get("credential fill errors:\n%s", strings.Join(errs, "\n"))) -+ if multiErr != nil { -+ multiErr = errors.Join(errors.New(tr.Tr.Get("credential fill errors:")), multiErr) - } - -- return nil, nil -+ return nil, multiErr - } - - // Reject implements CredentialHelper.Reject and rejects the given Creds "what" -diff --git a/lfs/gitfilter_smudge.go b/lfs/gitfilter_smudge.go -index 59e80bc9..a066e8ca 100644 ---- a/lfs/gitfilter_smudge.go -+++ b/lfs/gitfilter_smudge.go -@@ -121,16 +121,7 @@ func (f *GitFilter) downloadFile(writer io.Writer, ptr *Pointer, workingfile, me - q.Wait() - - if errs := q.Errors(); len(errs) > 0 { -- var multiErr error -- for _, e := range errs { -- if multiErr != nil { -- multiErr = fmt.Errorf("%v\n%v", multiErr, e) -- } else { -- multiErr = e -- } -- } -- -- return 0, errors.Wrapf(multiErr, "%s", tr.Tr.Get("Error downloading %s (%s)", workingfile, ptr.Oid)) -+ return 0, errors.Wrap(errors.Join(errs...), tr.Tr.Get("Error downloading %s (%s)", workingfile, ptr.Oid)) - } - - return f.readLocalFile(writer, ptr, mediafile, workingfile, nil) -@@ -153,15 +144,7 @@ func (f *GitFilter) downloadFileFallBack(writer io.Writer, ptr *Pointer, working - q.Wait() - - if errs := q.Errors(); len(errs) > 0 { -- var multiErr error -- for _, e := range errs { -- if multiErr != nil { -- multiErr = fmt.Errorf("%v\n%v", multiErr, e) -- } else { -- multiErr = e -- } -- } -- wrappedError := errors.Wrapf(multiErr, "%s", tr.Tr.Get("Error downloading %s (%s)", workingfile, ptr.Oid)) -+ wrappedError := errors.Wrap(errors.Join(errs...), tr.Tr.Get("Error downloading %s (%s)", workingfile, ptr.Oid)) - if index >= len(remotes)-1 { - return 0, wrappedError - } else { -diff --git a/lfs/scanner_git_test.go b/lfs/scanner_git_test.go -index 386f51e8..6fc0e43d 100644 ---- a/lfs/scanner_git_test.go -+++ b/lfs/scanner_git_test.go -@@ -5,12 +5,12 @@ package lfs_test // to avoid import cycles - // which avoids import cycles with testutils - - import ( -- "fmt" - "sort" - "testing" - "time" - - "github.com/git-lfs/git-lfs/v3/config" -+ "github.com/git-lfs/git-lfs/v3/errors" - . "github.com/git-lfs/git-lfs/v3/lfs" - test "github.com/git-lfs/git-lfs/v3/t/cmd/util" - "github.com/stretchr/testify/assert" -@@ -94,11 +94,7 @@ func scanUnpushed(remoteName string) ([]*WrappedPointer, error) { - - gitscanner := NewGitScanner(config.New(), func(p *WrappedPointer, err error) { - if err != nil { -- if multiErr != nil { -- multiErr = fmt.Errorf("%v\n%v", multiErr, err) -- } else { -- multiErr = err -- } -+ multiErr = errors.Join(multiErr, err) - return - } - -diff --git a/tools/channels.go b/tools/channels.go -index f5a2c0c9..bcc6310a 100644 ---- a/tools/channels.go -+++ b/tools/channels.go -@@ -1,6 +1,6 @@ - package tools - --import "fmt" -+import "github.com/git-lfs/git-lfs/v3/errors" - - // Interface for all types of wrapper around a channel of results and an error channel - // Implementors will expose a type-specific channel for results -@@ -17,18 +17,13 @@ type BaseChannelWrapper struct { - } - - func (w *BaseChannelWrapper) Wait() error { -- var err error -- for e := range w.errorChan { -- if err != nil { -- // Combine in case multiple errors -- err = fmt.Errorf("%v\n%v", err, e) -- -- } else { -- err = e -- } -+ var multiErr error -+ for err := range w.errorChan { -+ // Combine in case multiple errors -+ multiErr = errors.Join(multiErr, err) - } - -- return err -+ return multiErr - } - - func NewBaseChannelWrapper(errChan <-chan error) *BaseChannelWrapper { --- -2.51.1 - - -From d586cdb131a8a1ff8062f3f9fdfae0ab836fb876 Mon Sep 17 00:00:00 2001 -From: =?UTF-8?q?Ond=C5=99ej=20Poho=C5=99elsk=C3=BD?= -Date: Tue, 18 Nov 2025 12:24:11 +0100 -Subject: [PATCH 20/21] Backport assert_clean_index() and - skip_if_root_or_admin() - -Backport test functions into t/testhelpers.sh from: -91fc2280b5ea0c75d178c9fe289b428615c4f5f0 -3d8f497ea50ef550d15907bcc91868e608e56c14 ---- - t/testhelpers.sh | 29 +++++++++++++++++++++++++++++ - 1 file changed, 29 insertions(+) - -diff --git a/t/testhelpers.sh b/t/testhelpers.sh -index a92ff66d..802c0251 100644 ---- a/t/testhelpers.sh -+++ b/t/testhelpers.sh -@@ -341,6 +341,10 @@ assert_hooks() { - [ -x "$git_root/hooks/pre-push" ] - } - -+assert_clean_index() { -+ [ -z "$(git diff-index --cached HEAD)" ] -+} -+ - assert_clean_worktree() { - [ -z "$(git diff-index HEAD)" ] - } -@@ -682,6 +686,31 @@ tap_show_plan() { - printf "1..%i\n" "$tests" - } - -+skip_if_root_or_admin() { -+ local test_description="$1" -+ -+ if [ "$IS_WINDOWS" -eq 1 ]; then -+ # The sfc.exe (System File Checker) command should be available on all -+ # modern Windows systems, and when run without arguments, returns help -+ # text, but only when the user has Administrator privileges. By checking -+ # the help text, if any, for the /SCANNOW (i.e., "scan now") option -+ # common to all versions of the command, we can determine if the -+ # current user has Administrator privileges. -+ # -+ # Adapted from: https://stackoverflow.com/a/58846650 -+ # https://stackoverflow.com/a/21295806 -+ SFC=$(sfc | tr -d '\0' | grep "SCANNOW") -+ if [ -n "$SFC" ]; then -+ printf "skip: '%s' test requires non-administrator privileges\n" \ -+ "$test_description" -+ exit 0 -+ fi -+ elif [ "$EUID" -eq 0 ]; then -+ printf "skip: '%s' test requires non-root user\n" "$test_description" -+ exit 0 -+ fi -+} -+ - ensure_git_version_isnt() { - local expectedComparison=$1 - local version=$2 --- -2.51.1 - - -From e1c066a30e8e7fbf919c8afe37c98e2c57af053d Mon Sep 17 00:00:00 2001 -From: "brian m. carlson" -Date: Tue, 23 Jan 2024 14:10:03 +0000 -Subject: [PATCH 21/21] pull: improve error message on failing checkout - -If the user is having a problem checking out a file, it would be helpful -to know why. Let's include the reason that the error is occurring so -they can learn what the cause is and report it helpfully if there's a -problem. ---- - commands/pull.go | 2 +- - 1 file changed, 1 insertion(+), 1 deletion(-) - -diff --git a/commands/pull.go b/commands/pull.go -index 0896fc63..a89bb6a7 100644 ---- a/commands/pull.go -+++ b/commands/pull.go -@@ -131,7 +131,7 @@ func (c *singleCheckout) Run(p *lfs.WrappedPointer) { - // acceptable error, data not local (fetch not run or include/exclude) - Error(tr.Tr.Get("Skipped checkout for %q, content not local. Use fetch to download.", p.Name)) - } else { -- FullError(errors.New(tr.Tr.Get("could not check out %q", p.Name))) -+ FullError(errors.Wrap(err, tr.Tr.Get("could not check out %q", p.Name))) - } - return - } --- -2.51.1 - diff --git a/git-lfs.spec b/git-lfs.spec index f20cc18..7b1c676 100644 --- a/git-lfs.spec +++ b/git-lfs.spec @@ -2,7 +2,7 @@ ## (rpmautospec version 0.8.3) ## RPMAUTOSPEC: autorelease, autochangelog %define autorelease(e:s:pb:n) %{?-p:0.}%{lua: - release_number = 8; + release_number = 4; base_release_number = tonumber(rpm.expand("%{?-b*}%{!?-b:1}")); print(release_number + base_release_number - 1); }%{?-e:.%{-e*}}%{?-s:.%{-s*}}%{!?-n:%{?dist}} @@ -12,7 +12,7 @@ # https://github.com/git-lfs/git-lfs %global goipath github.com/git-lfs/git-lfs/v3 -Version: 3.6.1 +Version: 3.7.1 %gometa @@ -35,19 +35,8 @@ URL: https://git-lfs.github.io/ Source0: https://github.com/%{name}/%{name}/releases/download/v%{version}/%{name}-v%{version}.tar.gz Source1: README.Fedora -# Without this patch, Git-LFS fails to build with Go 1.24< -# Based on https://github.com/git-lfs/git-lfs/pull/5998 -# Added logic that preserves original behavior of error messages and avoids the format string test failures -Patch0: 0001-Use-constant-format-string.patch -# Fixes CVE-2025-26625 -# Backports all the commits from: -# https://github.com/git-lfs/git-lfs/compare/v3.7.0...v3.7.1 -# With added test functions from: -# 91fc2280b5ea0c75d178c9fe289b428615c4f5f0 -# 3d8f497ea50ef550d15907bcc91868e608e56c14 -# And changes from: -# b490f91cfe5c53e67753feec18d62958decd3be9 -Patch1: git-lfs-3.6.1-cve-2025-26625.patch +# https://github.com/git-lfs/git-lfs/pull/6237 +Patch0: 0001-commands-fix-format-string-type-mismatch-in-lockveri.patch # Generated provides by vendor2provides.py # https://src.fedoraproject.org/rpms/syncthing/blob/603e4e03a92a7d704d199629dd85304018e8279d/f/vendor2provides.py @@ -56,9 +45,10 @@ Provides: bundled(golang(github.com/avast/retry-go)) = 2.4.2+incompatible Provides: bundled(golang(github.com/davecgh/go-spew)) = 1.1.1 Provides: bundled(golang(github.com/dpotapov/go-spnego)) = 298b63a Provides: bundled(golang(github.com/git-lfs/gitobj/v2)) = 2.1.1 -Provides: bundled(golang(github.com/git-lfs/go-netrc)) = f0c862d +Provides: bundled(golang(github.com/git-lfs/go-netrc)) = ba0029b Provides: bundled(golang(github.com/git-lfs/pktline)) = 06e9096 Provides: bundled(golang(github.com/git-lfs/wildmatch/v2)) = 2.0.1 +Provides: bundled(golang(github.com/golang/groupcache)) = 2c02b82 Provides: bundled(golang(github.com/hashicorp/go-uuid)) = 1.0.2 Provides: bundled(golang(github.com/inconshreveable/mousetrap)) = 1.1.0 Provides: bundled(golang(github.com/jcmturner/aescts/v2)) = 2.0.0 @@ -81,11 +71,11 @@ Provides: bundled(golang(github.com/stretchr/testify)) = 1.6.1 Provides: bundled(golang(github.com/xeipuuv/gojsonpointer)) = 4e3ac27 Provides: bundled(golang(github.com/xeipuuv/gojsonreference)) = bd5ef7b Provides: bundled(golang(github.com/xeipuuv/gojsonschema)) = 6b67b3f -Provides: bundled(golang(golang.org/x/crypto)) = 0.21.0 -Provides: bundled(golang(golang.org/x/net)) = 0.23.0 -Provides: bundled(golang(golang.org/x/sync)) = 0.1.0 -Provides: bundled(golang(golang.org/x/sys)) = 0.18.0 -Provides: bundled(golang(golang.org/x/text)) = 0.14.0 +Provides: bundled(golang(golang.org/x/crypto)) = 0.36.0 +Provides: bundled(golang(golang.org/x/net)) = 0.38.0 +Provides: bundled(golang(golang.org/x/sync)) = 0.12.0 +Provides: bundled(golang(golang.org/x/sys)) = 0.31.0 +Provides: bundled(golang(golang.org/x/text)) = 0.23.0 Provides: bundled(golang(gopkg.in/yaml.v3)) = 3.0.1 @@ -199,20 +189,17 @@ PATH=%{buildroot}%{_bindir}:%{gobuilddir}/bin:$PATH \ %changelog ## START: Generated by rpmautospec -* Fri Mar 27 2026 Ondřej Pohořelský - 3.6.1-8 +* Tue Apr 28 2026 Ondřej Pohořelský - 3.7.1-4 - Rebuild with new Golang -* Mon Feb 16 2026 Ondřej Pohořelský - 3.6.1-7 +* Mon Feb 16 2026 Ondřej Pohořelský - 3.7.1-3 - Rebuild with new Golang -* Mon Feb 09 2026 Ondřej Pohořelský - 3.6.1-6 +* Thu Jan 22 2026 Ondřej Pohořelský - 3.7.1-2 - Rebuild with new Golang -* Mon Feb 09 2026 Ondřej Pohořelský - 3.6.1-5 -- Rebuild with new Golang - -* Mon Dec 01 2025 Ondřej Pohořelský - 3.6.1-4 -- Backport fix for CVE-2025-26625 +* Mon Jan 05 2026 Ondřej Pohořelský - 3.7.1-1 +- Update to 3.7.1 * Mon Aug 11 2025 Ondřej Pohořelský - 3.6.1-3 - Use constant format string diff --git a/sources b/sources index 013719d..e1aa810 100644 --- a/sources +++ b/sources @@ -1 +1 @@ -SHA512 (git-lfs-v3.6.1.tar.gz) = 128dbcb503e71ccfc3aa97f92e191e1bed7432d7677723b518c86d211ad9660736dce1ac6ae94a1f7709567b7211c61885730f95ecea09f4aa4172e1c710d167 +SHA512 (git-lfs-v3.7.1.tar.gz) = d8ffb262949c661b13cbfb733786701f2acc95b05f11d3d327eff585911b203ef6fc104d1060c57a62a7fb5117909e5e805d5fabbefb0da8c966227c528e2ea6