Skip to content
Open
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
26 changes: 3 additions & 23 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,16 @@ import (
"encoding/json"
"os"
"path/filepath"
"syscall"

"github.com/marcus/td/internal/models"
)

const configFile = ".todos/config.json"
const lockFile = ".todos/config.json.lock"

// withConfigLock serializes access to config.json using an OS file lock.
// Implemented per-platform in lock_unix.go (flock) and lock_windows.go (LockFileEx).

// Title validation defaults
const (
DefaultTitleMinLength = 15
Expand Down Expand Up @@ -75,28 +77,6 @@ func Save(baseDir string, cfg *models.Config) error {
return os.Rename(tmpName, configPath)
}

// withConfigLock serializes access to config.json using flock
func withConfigLock(baseDir string, fn func() error) error {
lockPath := filepath.Join(baseDir, lockFile)

if err := os.MkdirAll(filepath.Dir(lockPath), 0755); err != nil {
return err
}

f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
return err
}
defer f.Close()

if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
return err
}
defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }()

return fn()
}

// SetFocus sets the focused issue ID
func SetFocus(baseDir string, issueID string) error {
return withConfigLock(baseDir, func() error {
Expand Down
31 changes: 31 additions & 0 deletions internal/config/lock_unix.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
//go:build unix

package config

import (
"os"
"path/filepath"
"syscall"
)

// withConfigLock serializes access to config.json using flock.
func withConfigLock(baseDir string, fn func() error) error {
lockPath := filepath.Join(baseDir, lockFile)

if err := os.MkdirAll(filepath.Dir(lockPath), 0755); err != nil {
return err
}

f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
return err
}
defer f.Close()

if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil {
return err
}
defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }()

return fn()
}
46 changes: 46 additions & 0 deletions internal/config/lock_windows.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//go:build windows

package config

import (
"os"
"path/filepath"

"golang.org/x/sys/windows"
)

// withConfigLock serializes access to config.json using LockFileEx.
func withConfigLock(baseDir string, fn func() error) error {
lockPath := filepath.Join(baseDir, lockFile)

if err := os.MkdirAll(filepath.Dir(lockPath), 0755); err != nil {
return err
}

f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644)
if err != nil {
return err
}
defer f.Close()

// LockFileEx with LOCKFILE_EXCLUSIVE_LOCK (blocking) locks the first byte
// of the file, which is sufficient for whole-file mutual exclusion since
// every contender uses the same offset/length.
ol := new(windows.Overlapped)
if err := windows.LockFileEx(
windows.Handle(f.Fd()),
windows.LOCKFILE_EXCLUSIVE_LOCK,
0, // reserved
1, // lock 1 byte
0, // high bits of length
ol,
); err != nil {
return err
}
defer func() {
ul := new(windows.Overlapped)
_ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, ul)
}()

return fn()
}