Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions e2e_git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,36 @@ func TestE2EGitCleanState(t *testing.T) {
gitCmdQuiet(t, repo.mountPath, "diff", "--cached", "--quiet")
}

func TestE2EGitStatusDetectsSameSizeRewriteAfterMtimeRestore(t *testing.T) {
repo := newMountedE2ERepo(t)

readmePath := filepath.Join(repo.mountPath, "README.md")
assertGitStatus(t, repo.mountPath, map[string]string{})
st, err := os.Stat(readmePath)
if err != nil {
t.Fatal(err)
}
indexedMtime := st.ModTime()

updated := []byte(readFileEventually(t, readmePath))
if len(updated) == 0 {
t.Fatal("README.md is empty")
}
if updated[0] == 'x' {
updated[0] = 'y'
} else {
updated[0] = 'x'
}
if err := os.WriteFile(readmePath, updated, 0o644); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(readmePath, indexedMtime, indexedMtime); err != nil {
t.Fatal(err)
}

assertGitStatus(t, repo.mountPath, map[string]string{"README.md": " M"})
}

func TestE2EGitStatusPorcelain(t *testing.T) {
repo := newMountedE2ERepo(t)

Expand Down
48 changes: 26 additions & 22 deletions internal/fusefs/fuse_unix.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ func (fs *ArtifactFuse) LookUpInode(_ context.Context, op *fuseops.LookUpInodeOp
return nil
}

mode, size, typ, mtime, err := fs.resolver.Getattr(childPath)
mode, size, typ, mtime, ctime, err := fs.resolver.Getattr(childPath)
if err != nil {
if errors.Is(err, iofs.ErrNotExist) {
return syscall.ENOENT
Expand All @@ -195,7 +195,7 @@ func (fs *ArtifactFuse) LookUpInode(_ context.Context, op *fuseops.LookUpInodeOp
fs.mu.Unlock()

op.Entry.Child = ref.ID
op.Entry.Attributes = inodeAttrs(mode, uint64(size), typ, mtime)
op.Entry.Attributes = inodeAttrs(mode, uint64(size), typ, mtime, ctime)
setChildEntryExpiry(&op.Entry, time.Second)
return nil
}
Expand All @@ -212,11 +212,11 @@ func (fs *ArtifactFuse) GetInodeAttributes(_ context.Context, op *fuseops.GetIno
return nil
}

mode, size, typ, mtime, err := fs.resolver.Getattr(ref.Path)
mode, size, typ, mtime, ctime, err := fs.resolver.Getattr(ref.Path)
if err != nil {
return syscall.ENOENT
}
op.Attributes = inodeAttrs(mode, uint64(size), typ, mtime)
op.Attributes = inodeAttrs(mode, uint64(size), typ, mtime, ctime)
op.AttributesExpiration = attrExpiry(time.Second)
return nil
}
Expand All @@ -233,17 +233,18 @@ func (fs *ArtifactFuse) SetInodeAttributes(ctx context.Context, op *fuseops.SetI
}
// Handle mtime updates (e.g., from touch)
if op.Mtime != nil {
fs.engine.SetMtime(ctx, ref.Path, *op.Mtime)
if err := fs.engine.SetMtime(ctx, ref.Path, *op.Mtime); err != nil {
if errors.Is(err, iofs.ErrInvalid) {
return syscall.ENOTSUP
}
return syscall.EIO
}
}
mode, size, typ, mtime, err := fs.resolver.Getattr(ref.Path)
mode, size, typ, mtime, ctime, err := fs.resolver.Getattr(ref.Path)
if err != nil {
return syscall.EIO
}
// If caller set mtime, return that instead of the stored value
if op.Mtime != nil {
mtime = *op.Mtime
}
op.Attributes = inodeAttrs(mode, uint64(size), typ, mtime)
op.Attributes = inodeAttrs(mode, uint64(size), typ, mtime, ctime)
op.AttributesExpiration = attrExpiry(time.Second)
return nil
}
Expand Down Expand Up @@ -403,7 +404,8 @@ func (fs *ArtifactFuse) CreateFile(ctx context.Context, op *fuseops.CreateFileOp
fs.mu.Unlock()

op.Entry.Child = ref.ID
op.Entry.Attributes = inodeAttrs(uint32(op.Mode), 0, "file", time.Now())
now := time.Now()
op.Entry.Attributes = inodeAttrs(uint32(op.Mode), 0, "file", now, now)
setChildEntryExpiry(&op.Entry, time.Second)
op.Handle = handle
return nil
Expand All @@ -422,7 +424,8 @@ func (fs *ArtifactFuse) MkDir(ctx context.Context, op *fuseops.MkDirOp) error {
fs.mu.Unlock()

op.Entry.Child = ref.ID
op.Entry.Attributes = inodeAttrs(uint32(op.Mode)|uint32(os.ModeDir), 4096, "dir", time.Now())
now := time.Now()
op.Entry.Attributes = inodeAttrs(uint32(op.Mode)|uint32(os.ModeDir), 4096, "dir", now, now)
setChildEntryExpiry(&op.Entry, time.Second)
return nil
}
Expand Down Expand Up @@ -464,6 +467,9 @@ func (fs *ArtifactFuse) Rename(ctx context.Context, op *fuseops.RenameOp) error
oldPath := cleanChildPath(oldParent.Path, op.OldName)
newPath := cleanChildPath(newParent.Path, op.NewName)
if err := fs.engine.Rename(ctx, oldPath, newPath); err != nil {
if errors.Is(err, iofs.ErrInvalid) {
return syscall.ENOTSUP
}
return syscall.EIO
}
return nil
Expand Down Expand Up @@ -587,15 +593,8 @@ func TryUnmount(mountPoint string) error {
return err
}

func inodeAttrs(mode uint32, size uint64, typ string, mtime time.Time) fuseops.InodeAttributes {
func inodeAttrs(mode uint32, size uint64, typ string, mtime time.Time, ctime time.Time) fuseops.InodeAttributes {
m := os.FileMode(mode & 0o777)
if m == 0 {
if typ == "dir" {
m = 0o755
} else {
m = 0o644
}
}
switch typ {
case "dir":
m |= os.ModeDir
Expand All @@ -611,7 +610,9 @@ func inodeAttrs(mode uint32, size uint64, typ string, mtime time.Time) fuseops.I
Mode: m,
Uid: uint32(os.Getuid()),
Gid: uint32(os.Getgid()),
Atime: mtime,
Mtime: mtime,
Ctime: ctime,
}
}

Expand All @@ -630,12 +631,15 @@ func setChildEntryExpiry(entry *fuseops.ChildInodeEntry, ttl time.Duration) {
}

func (fs *ArtifactFuse) gitFileAttrs() fuseops.InodeAttributes {
now := time.Now()
return fuseops.InodeAttributes{
Size: uint64(len(fs.gitfileContent)),
Mode: 0o644,
Nlink: 1,
Uid: uint32(os.Getuid()),
Gid: uint32(os.Getgid()),
Mtime: time.Now(),
Atime: now,
Mtime: now,
Ctime: now,
}
}
48 changes: 48 additions & 0 deletions internal/fusefs/fuse_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
//go:build !windows

package fusefs

import (
"testing"
"time"
)

func TestInodeAttrsPreservesSeparateTimes(t *testing.T) {
mtime := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
ctime := time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC)

attr := inodeAttrs(0o644, 12, "file", mtime, ctime)
if !attr.Atime.Equal(mtime) {
t.Fatalf("atime = %v, want %v", attr.Atime, mtime)
}
if !attr.Mtime.Equal(mtime) {
t.Fatalf("mtime = %v, want %v", attr.Mtime, mtime)
}
if !attr.Ctime.Equal(ctime) {
t.Fatalf("ctime = %v, want %v", attr.Ctime, ctime)
}
}

func TestInodeAttrsPreservesExplicitZeroDirMode(t *testing.T) {
now := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)

attr := inodeAttrs(0, 4096, "dir", now, now)
if attr.Mode.Perm() != 0 {
t.Fatalf("mode perms = %#o, want 0", attr.Mode.Perm())
}
if !attr.Mode.IsDir() {
t.Fatalf("expected directory mode, got %#o", attr.Mode)
}
}

func TestGitFileAttrsUsesOneTimestamp(t *testing.T) {
fs := &ArtifactFuse{gitfileContent: []byte("gitdir: /tmp/repo/.git\n")}

attr := fs.gitFileAttrs()
if attr.Mtime.IsZero() || attr.Atime.IsZero() || attr.Ctime.IsZero() {
t.Fatalf("expected non-zero times: atime=%v mtime=%v ctime=%v", attr.Atime, attr.Mtime, attr.Ctime)
}
if !attr.Atime.Equal(attr.Mtime) || !attr.Ctime.Equal(attr.Mtime) {
t.Fatalf("expected .git attrs to use one timestamp: atime=%v mtime=%v ctime=%v", attr.Atime, attr.Mtime, attr.Ctime)
}
}
12 changes: 8 additions & 4 deletions internal/fusefs/merged.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,15 +73,16 @@ func (r *Resolver) Lookup(parent, name string) (ResolvedNode, error) {
return r.ResolvePath(p)
}

func (r *Resolver) Getattr(path string) (mode uint32, size int64, nodeType string, mtime time.Time, err error) {
func (r *Resolver) Getattr(path string) (mode uint32, size int64, nodeType string, mtime time.Time, ctime time.Time, err error) {
n, err := r.ResolvePath(path)
if err != nil {
return 0, 0, "", time.Time{}, err
return 0, 0, "", time.Time{}, time.Time{}, err
}
if n.FromOverlay {
typ := n.Overlay.NodeType()
mt := time.Unix(0, n.Overlay.MtimeUnixNs)
return n.Overlay.Mode, n.Overlay.SizeBytes, typ, mt, nil
ct := time.Unix(0, n.Overlay.CtimeUnixNs)
return n.Overlay.Mode, n.Overlay.SizeBytes, typ, mt, ct, nil
}
mode = normalizeMode(n.Base.Mode, n.Base.Type)
// Base files use the HEAD commit timestamp for mtime so tools like
Expand All @@ -91,7 +92,7 @@ func (r *Resolver) Getattr(path string) (mode uint32, size int64, nodeType strin
ct = r.Generation() // fallback: commit time unavailable
}
mt := time.Unix(ct, 0)
return mode, n.Base.SizeBytes, n.Base.Type, mt, nil
return mode, n.Base.SizeBytes, n.Base.Type, mt, mt, nil
}

// normalizeMode ensures sane permission bits. Git tree entries have mode 040000
Expand Down Expand Up @@ -141,6 +142,9 @@ func (r *Resolver) ReaddirTyped(ctx context.Context, path string) ([]ReaddirEntr
ovEntries, err := r.Overlay.ListByPrefix(ctx, path)
if err == nil {
for _, e := range ovEntries {
if e.Path == path {
continue
}
name, ok := childName(path, e.Path)
if !ok {
continue
Expand Down
Loading
Loading