detached 5f10d2e

fix(snapshot): support darwin filesystem syscalls

commit
5f10d2ee28e3ce3d4069aaaf90998551ef9f1201
author
brice · · 2026-07-22 05:29 UTC
parent
6efb8f5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +65 -34
M go.mod +4 -1
@@ -2,7 +2,10 @@ module github.com/bnema/vev
22
33 go 1.26
44
5-require github.com/stretchr/testify v1.11.1
5+require (
6+ github.com/stretchr/testify v1.11.1
7+ golang.org/x/sys v0.47.0
8+)
69
710 require (
811 github.com/davecgh/go-spew v1.1.1 // indirect
@@ -2,7 +2,10 @@ module github.com/bnema/vev
2 2
3 go 1.263 go 1.26
4 4
5-require github.com/stretchr/testify v1.11.15+require (
6+ github.com/stretchr/testify v1.11.1
7+ golang.org/x/sys v0.47.0
8+)
6 9
7 require (10 require (
8 github.com/davecgh/go-spew v1.1.1 // indirect11 github.com/davecgh/go-spew v1.1.1 // indirect
M go.sum +2 -0 generated file: collapsed
M internal/adapters/snapshot/atomic.go +6 -26
@@ -7,9 +7,9 @@ import (
77 "os"
88 "path/filepath"
99 "syscall"
10- "unsafe"
1110
1211 "github.com/bnema/vev/pkg/safedir"
12+ "golang.org/x/sys/unix"
1313 )
1414
1515 func (r *Repository) ensureSession(key string) error {
@@ -67,7 +67,7 @@ func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) error {
6767 return err
6868 }
6969 created := false
70- mkdirErr := syscall.Mkdirat(parent, name, 0o700)
70+ mkdirErr := unix.Mkdirat(parent, name, 0o700)
7171 if mkdirErr == nil {
7272 created = true
7373 }
@@ -277,19 +277,7 @@ func (r *Repository) installImmutable(oldPath, newPath string) (err error) {
277277 defer func() {
278278 joinCloseError(&err, "close destination snapshot parent directory", r.closeDescriptor(newFD, "close destination snapshot parent directory"))
279279 }()
280- oldPtr, err := syscall.BytePtrFromString(oldName)
281- if err != nil {
282- return err
283- }
284- newPtr, err := syscall.BytePtrFromString(newName)
285- if err != nil {
286- return err
287- }
288- _, _, errno := syscall.Syscall6(syscall.SYS_LINKAT, uintptr(oldFD), uintptr(unsafe.Pointer(oldPtr)), uintptr(newFD), uintptr(unsafe.Pointer(newPtr)), 0, 0)
289- if errno != 0 {
290- return errno
291- }
292- return nil
280+ return unix.Linkat(oldFD, oldName, newFD, newName, 0)
293281 }
294282 func (r *Repository) rename(oldPath, newPath string) (err error) {
295283 if r.hooks.rename != nil {
@@ -311,7 +299,7 @@ func (r *Repository) rename(oldPath, newPath string) (err error) {
311299 defer func() {
312300 joinCloseError(&err, "close destination snapshot parent directory", r.closeDescriptor(newFD, "close destination snapshot parent directory"))
313301 }()
314- return syscall.Renameat(oldFD, oldName, newFD, newName)
302+ return unix.Renameat(oldFD, oldName, newFD, newName)
315303 }
316304 func (r *Repository) remove(path string) (err error) {
317305 if r.hooks.remove != nil {
@@ -326,18 +314,10 @@ func (r *Repository) remove(path string) (err error) {
326314 defer func() {
327315 joinCloseError(&err, "close snapshot parent directory", r.closeDescriptor(fd, "close snapshot parent directory"))
328316 }()
329- if err := syscall.Unlinkat(fd, name); !errors.Is(err, syscall.EISDIR) {
330- return err
331- }
332- namePtr, err := syscall.BytePtrFromString(name)
333- if err != nil {
317+ if err := unix.Unlinkat(fd, name, 0); !errors.Is(err, unix.EISDIR) {
334318 return err
335319 }
336- _, _, errno := syscall.Syscall(syscall.SYS_UNLINKAT, uintptr(fd), uintptr(unsafe.Pointer(namePtr)), uintptr(0x200)) // AT_REMOVEDIR
337- if errno != 0 {
338- return errno
339- }
340- return nil
320+ return unix.Unlinkat(fd, name, unix.AT_REMOVEDIR)
341321 }
342322 func (r *Repository) syncDirectory(dir string) error {
343323 if r.hooks.syncDirectory != nil {
@@ -7,9 +7,9 @@ import (
7 "os"7 "os"
8 "path/filepath"8 "path/filepath"
9 "syscall"9 "syscall"
10- "unsafe"
11 10
12 "github.com/bnema/vev/pkg/safedir"11 "github.com/bnema/vev/pkg/safedir"
12+ "golang.org/x/sys/unix"
13 )13 )
14 14
15 func (r *Repository) ensureSession(key string) error {15 func (r *Repository) ensureSession(key string) error {
@@ -67,7 +67,7 @@ func (r *Repository) ensurePrivateDirectoryPhase(dir, phase string) error {
67 return err67 return err
68 }68 }
69 created := false69 created := false
70- mkdirErr := syscall.Mkdirat(parent, name, 0o700)70+ mkdirErr := unix.Mkdirat(parent, name, 0o700)
71 if mkdirErr == nil {71 if mkdirErr == nil {
72 created = true72 created = true
73 }73 }
@@ -277,19 +277,7 @@ func (r *Repository) installImmutable(oldPath, newPath string) (err error) {
277 defer func() {277 defer func() {
278 joinCloseError(&err, "close destination snapshot parent directory", r.closeDescriptor(newFD, "close destination snapshot parent directory"))278 joinCloseError(&err, "close destination snapshot parent directory", r.closeDescriptor(newFD, "close destination snapshot parent directory"))
279 }()279 }()
280- oldPtr, err := syscall.BytePtrFromString(oldName)280+ return unix.Linkat(oldFD, oldName, newFD, newName, 0)
281- if err != nil {
282- return err
283- }
284- newPtr, err := syscall.BytePtrFromString(newName)
285- if err != nil {
286- return err
287- }
288- _, _, errno := syscall.Syscall6(syscall.SYS_LINKAT, uintptr(oldFD), uintptr(unsafe.Pointer(oldPtr)), uintptr(newFD), uintptr(unsafe.Pointer(newPtr)), 0, 0)
289- if errno != 0 {
290- return errno
291- }
292- return nil
293 }281 }
294 func (r *Repository) rename(oldPath, newPath string) (err error) {282 func (r *Repository) rename(oldPath, newPath string) (err error) {
295 if r.hooks.rename != nil {283 if r.hooks.rename != nil {
@@ -311,7 +299,7 @@ func (r *Repository) rename(oldPath, newPath string) (err error) {
311 defer func() {299 defer func() {
312 joinCloseError(&err, "close destination snapshot parent directory", r.closeDescriptor(newFD, "close destination snapshot parent directory"))300 joinCloseError(&err, "close destination snapshot parent directory", r.closeDescriptor(newFD, "close destination snapshot parent directory"))
313 }()301 }()
314- return syscall.Renameat(oldFD, oldName, newFD, newName)302+ return unix.Renameat(oldFD, oldName, newFD, newName)
315 }303 }
316 func (r *Repository) remove(path string) (err error) {304 func (r *Repository) remove(path string) (err error) {
317 if r.hooks.remove != nil {305 if r.hooks.remove != nil {
@@ -326,18 +314,10 @@ func (r *Repository) remove(path string) (err error) {
326 defer func() {314 defer func() {
327 joinCloseError(&err, "close snapshot parent directory", r.closeDescriptor(fd, "close snapshot parent directory"))315 joinCloseError(&err, "close snapshot parent directory", r.closeDescriptor(fd, "close snapshot parent directory"))
328 }()316 }()
329- if err := syscall.Unlinkat(fd, name); !errors.Is(err, syscall.EISDIR) {317+ if err := unix.Unlinkat(fd, name, 0); !errors.Is(err, unix.EISDIR) {
330- return err
331- }
332- namePtr, err := syscall.BytePtrFromString(name)
333- if err != nil {
334 return err318 return err
335 }319 }
336- _, _, errno := syscall.Syscall(syscall.SYS_UNLINKAT, uintptr(fd), uintptr(unsafe.Pointer(namePtr)), uintptr(0x200)) // AT_REMOVEDIR320+ return unix.Unlinkat(fd, name, unix.AT_REMOVEDIR)
337- if errno != 0 {
338- return errno
339- }
340- return nil
341 }321 }
342 func (r *Repository) syncDirectory(dir string) error {322 func (r *Repository) syncDirectory(dir string) error {
343 if r.hooks.syncDirectory != nil {323 if r.hooks.syncDirectory != nil {
A internal/adapters/snapshot/dirent_cookie_darwin.go +7 -0
@@ -0,0 +1,7 @@
1+//go:build darwin
2+
3+package snapshot
4+
5+import "syscall"
6+
7+func directoryCookie(dirent *syscall.Dirent) int64 { return int64(dirent.Seekoff) }
@@ -0,0 +1,7 @@
1+//go:build darwin
2+
3+package snapshot
4+
5+import "syscall"
6+
7+func directoryCookie(dirent *syscall.Dirent) int64 { return int64(dirent.Seekoff) }
A internal/adapters/snapshot/dirent_cookie_darwin_test.go +15 -0
@@ -0,0 +1,15 @@
1+//go:build darwin
2+
3+package snapshot
4+
5+import (
6+ "syscall"
7+ "testing"
8+)
9+
10+func TestDirectoryCookieUsesDarwinSeekOffset(t *testing.T) {
11+ dirent := syscall.Dirent{Seekoff: 42}
12+ if got := directoryCookie(&dirent); got != 42 {
13+ t.Fatalf("directoryCookie() = %d, want 42", got)
14+ }
15+}
@@ -0,0 +1,15 @@
1+//go:build darwin
2+
3+package snapshot
4+
5+import (
6+ "syscall"
7+ "testing"
8+)
9+
10+func TestDirectoryCookieUsesDarwinSeekOffset(t *testing.T) {
11+ dirent := syscall.Dirent{Seekoff: 42}
12+ if got := directoryCookie(&dirent); got != 42 {
13+ t.Fatalf("directoryCookie() = %d, want 42", got)
14+ }
15+}
A internal/adapters/snapshot/dirent_cookie_linux.go +7 -0
@@ -0,0 +1,7 @@
1+//go:build linux
2+
3+package snapshot
4+
5+import "syscall"
6+
7+func directoryCookie(dirent *syscall.Dirent) int64 { return dirent.Off }
@@ -0,0 +1,7 @@
1+//go:build linux
2+
3+package snapshot
4+
5+import "syscall"
6+
7+func directoryCookie(dirent *syscall.Dirent) int64 { return dirent.Off }
A internal/adapters/snapshot/dirent_cookie_linux_test.go +15 -0
@@ -0,0 +1,15 @@
1+//go:build linux
2+
3+package snapshot
4+
5+import (
6+ "syscall"
7+ "testing"
8+)
9+
10+func TestDirectoryCookieUsesLinuxOffset(t *testing.T) {
11+ dirent := syscall.Dirent{Off: 42}
12+ if got := directoryCookie(&dirent); got != 42 {
13+ t.Fatalf("directoryCookie() = %d, want 42", got)
14+ }
15+}
@@ -0,0 +1,15 @@
1+//go:build linux
2+
3+package snapshot
4+
5+import (
6+ "syscall"
7+ "testing"
8+)
9+
10+func TestDirectoryCookieUsesLinuxOffset(t *testing.T) {
11+ dirent := syscall.Dirent{Off: 42}
12+ if got := directoryCookie(&dirent); got != 42 {
13+ t.Fatalf("directoryCookie() = %d, want 42", got)
14+ }
15+}
M internal/adapters/snapshot/maintenance_cursor.go +4 -4
@@ -114,9 +114,9 @@ func maintenanceDirectoryError(operation string, err error) error {
114114 return fmt.Errorf("%s: %w", operation, safeFilesystemError(err))
115115 }
116116
117-// readMaintenanceDirent uses the Linux getdents seek cookie from each record.
118-// syscall.Dirent's Off field is the d_off member of linux_dirent64 and
119-// syscall.Seek is lseek(2), as defined by Go's syscall Linux sources.
117+// readMaintenanceDirent saves the OS-provided seek cookie from each record.
118+// The field differs by platform; directoryCookie abstracts that layout while
119+// syscall.Seek resumes the descriptor at the saved directory position.
120120 func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *maintenanceCursor) (entries []maintenanceDirEntry, done bool, err error) {
121121 file, err := r.openMaintenanceDirectory(dir)
122122 if err != nil {
@@ -162,7 +162,7 @@ func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *mainte
162162 }
163163 data = data[reclen:]
164164 // Advance past every raw record, including dot and disappeared entries.
165- cursor.offset = record.Off
165+ cursor.offset = directoryCookie(record)
166166 nameBytes := unsafe.Slice((*byte)(unsafe.Pointer(&record.Name[0])), reclen-nameOffset)
167167 if end := strings.IndexByte(string(nameBytes), 0); end >= 0 {
168168 nameBytes = nameBytes[:end]
@@ -114,9 +114,9 @@ func maintenanceDirectoryError(operation string, err error) error {
114 return fmt.Errorf("%s: %w", operation, safeFilesystemError(err))114 return fmt.Errorf("%s: %w", operation, safeFilesystemError(err))
115 }115 }
116 116
117-// readMaintenanceDirent uses the Linux getdents seek cookie from each record.117+// readMaintenanceDirent saves the OS-provided seek cookie from each record.
118-// syscall.Dirent's Off field is the d_off member of linux_dirent64 and118+// The field differs by platform; directoryCookie abstracts that layout while
119-// syscall.Seek is lseek(2), as defined by Go's syscall Linux sources.119+// syscall.Seek resumes the descriptor at the saved directory position.
120 func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *maintenanceCursor) (entries []maintenanceDirEntry, done bool, err error) {120 func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *maintenanceCursor) (entries []maintenanceDirEntry, done bool, err error) {
121 file, err := r.openMaintenanceDirectory(dir)121 file, err := r.openMaintenanceDirectory(dir)
122 if err != nil {122 if err != nil {
@@ -162,7 +162,7 @@ func (r *Repository) readMaintenanceDirent(dir string, limit int, cursor *mainte
162 }162 }
163 data = data[reclen:]163 data = data[reclen:]
164 // Advance past every raw record, including dot and disappeared entries.164 // Advance past every raw record, including dot and disappeared entries.
165- cursor.offset = record.Off165+ cursor.offset = directoryCookie(record)
166 nameBytes := unsafe.Slice((*byte)(unsafe.Pointer(&record.Name[0])), reclen-nameOffset)166 nameBytes := unsafe.Slice((*byte)(unsafe.Pointer(&record.Name[0])), reclen-nameOffset)
167 if end := strings.IndexByte(string(nameBytes), 0); end >= 0 {167 if end := strings.IndexByte(string(nameBytes), 0); end >= 0 {
168 nameBytes = nameBytes[:end]168 nameBytes = nameBytes[:end]
M internal/adapters/snapshot/safe_path.go +5 -3
@@ -9,6 +9,8 @@ import (
99 "path/filepath"
1010 "strings"
1111 "syscall"
12+
13+ "golang.org/x/sys/unix"
1214 )
1315
1416 // repositoryFD opens a repository-relative path one component at a time. Each
@@ -52,7 +54,7 @@ func (r *Repository) repositoryFD(path string, finalFlags int, mode uint32) (*os
5254 } else {
5355 flags = finalFlags | syscall.O_CLOEXEC | syscall.O_NOFOLLOW
5456 }
55- next, openErr := syscall.Openat(fd, part, flags, mode)
57+ next, openErr := unix.Openat(fd, part, flags, mode)
5658 closeErr := r.closeDescriptor(fd, "close snapshot path parent directory")
5759 if openErr != nil {
5860 err = openErr
@@ -94,7 +96,7 @@ func (r *Repository) repositoryParent(path string) (int, string, error) {
9496 joinCloseError(&err, "close snapshot parent directory", r.closeDescriptor(fd, "close snapshot parent directory"))
9597 return -1, "", err
9698 }
97- next, openErr := syscall.Openat(fd, part, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
99+ next, openErr := unix.Openat(fd, part, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
98100 closeErr := r.closeDescriptor(fd, "close snapshot parent directory")
99101 if openErr != nil {
100102 err = openErr
@@ -166,7 +168,7 @@ func (r *Repository) createTempAt(dir string) (temp *os.File, err error) {
166168 return nil, err
167169 }
168170 name := ".tmp-" + hex.EncodeToString(random[:])
169- tempFD, err := syscall.Openat(int(fd.Fd()), name, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600)
171+ tempFD, err := unix.Openat(int(fd.Fd()), name, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600)
170172 if errors.Is(err, syscall.EEXIST) {
171173 continue
172174 }
@@ -9,6 +9,8 @@ import (
9 "path/filepath"9 "path/filepath"
10 "strings"10 "strings"
11 "syscall"11 "syscall"
12+
13+ "golang.org/x/sys/unix"
12 )14 )
13 15
14 // repositoryFD opens a repository-relative path one component at a time. Each16 // repositoryFD opens a repository-relative path one component at a time. Each
@@ -52,7 +54,7 @@ func (r *Repository) repositoryFD(path string, finalFlags int, mode uint32) (*os
52 } else {54 } else {
53 flags = finalFlags | syscall.O_CLOEXEC | syscall.O_NOFOLLOW55 flags = finalFlags | syscall.O_CLOEXEC | syscall.O_NOFOLLOW
54 }56 }
55- next, openErr := syscall.Openat(fd, part, flags, mode)57+ next, openErr := unix.Openat(fd, part, flags, mode)
56 closeErr := r.closeDescriptor(fd, "close snapshot path parent directory")58 closeErr := r.closeDescriptor(fd, "close snapshot path parent directory")
57 if openErr != nil {59 if openErr != nil {
58 err = openErr60 err = openErr
@@ -94,7 +96,7 @@ func (r *Repository) repositoryParent(path string) (int, string, error) {
94 joinCloseError(&err, "close snapshot parent directory", r.closeDescriptor(fd, "close snapshot parent directory"))96 joinCloseError(&err, "close snapshot parent directory", r.closeDescriptor(fd, "close snapshot parent directory"))
95 return -1, "", err97 return -1, "", err
96 }98 }
97- next, openErr := syscall.Openat(fd, part, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)99+ next, openErr := unix.Openat(fd, part, syscall.O_RDONLY|syscall.O_DIRECTORY|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0)
98 closeErr := r.closeDescriptor(fd, "close snapshot parent directory")100 closeErr := r.closeDescriptor(fd, "close snapshot parent directory")
99 if openErr != nil {101 if openErr != nil {
100 err = openErr102 err = openErr
@@ -166,7 +168,7 @@ func (r *Repository) createTempAt(dir string) (temp *os.File, err error) {
166 return nil, err168 return nil, err
167 }169 }
168 name := ".tmp-" + hex.EncodeToString(random[:])170 name := ".tmp-" + hex.EncodeToString(random[:])
169- tempFD, err := syscall.Openat(int(fd.Fd()), name, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600)171+ tempFD, err := unix.Openat(int(fd.Fd()), name, syscall.O_WRONLY|syscall.O_CREAT|syscall.O_EXCL|syscall.O_NOFOLLOW|syscall.O_CLOEXEC, 0o600)
170 if errors.Is(err, syscall.EEXIST) {172 if errors.Is(err, syscall.EEXIST) {
171 continue173 continue
172 }174 }

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close