a73x

b563e86e

test(rewriter): deterministic concurrent-modification path

a73x   2026-04-29 05:51


diff --git a/internal/rewriter/rewriter.go b/internal/rewriter/rewriter.go
index 2fab15b..71e2ae6 100644
--- a/internal/rewriter/rewriter.go
+++ b/internal/rewriter/rewriter.go
@@ -8,6 +8,7 @@ import (
	"path/filepath"
	"regexp"
	"strings"
	"time"
)

var ErrConcurrentModification = errors.New("file modified concurrently")
@@ -84,21 +85,29 @@ func StampUUIDs(path string, stamps map[int]string) error {
		return err
	}

	// mtime check before atomic rename (deterministic test for this comes in Task 7)
	info2, err := os.Stat(path)
	return atomicReplace(path, tmpPath, info.Mode().Perm(), mtimeBefore)
}

// atomicReplace stat-checks path against mtimeBefore. If the file was modified
// concurrently, returns ErrConcurrentModification and removes tmpPath. Otherwise
// renames tmpPath over path. The mode argument is currently unused (the caller
// already chmods tmpPath before close), but is included to keep this helper's
// signature self-contained for direct testing.
func atomicReplace(path, tmpPath string, mode os.FileMode, mtimeBefore time.Time) error {
	info, err := os.Stat(path)
	if err != nil {
		os.Remove(tmpPath)
		return err
	}
	if !info2.ModTime().Equal(mtimeBefore) {
	if !info.ModTime().Equal(mtimeBefore) {
		os.Remove(tmpPath)
		return ErrConcurrentModification
	}

	if err := os.Rename(tmpPath, path); err != nil {
		os.Remove(tmpPath)
		return fmt.Errorf("rename: %w", err)
	}
	_ = mode // currently unused; reserved for future modes that need post-rename Chmod
	return nil
}

diff --git a/internal/rewriter/rewriter_test.go b/internal/rewriter/rewriter_test.go
index d60835d..b8bd2b0 100644
--- a/internal/rewriter/rewriter_test.go
+++ b/internal/rewriter/rewriter_test.go
@@ -1,10 +1,12 @@
package rewriter

import (
	"errors"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

func TestStampUUIDsInsertsTagAfterClaude(t *testing.T) {
@@ -63,3 +65,20 @@ func TestStampUUIDsBlockComment(t *testing.T) {
		t.Errorf("got:\n%s\nwant:\n%s", got, want)
	}
}

func TestAtomicReplaceAbortsOnStaleMtime(t *testing.T) {
	dir := t.TempDir()
	path := filepath.Join(dir, "foo.go")
	if err := os.WriteFile(path, []byte("hi\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	tmpPath := filepath.Join(dir, "tmp")
	if err := os.WriteFile(tmpPath, []byte("bye\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	stale := time.Now().Add(-time.Hour)
	err := atomicReplace(path, tmpPath, 0o644, stale)
	if !errors.Is(err, ErrConcurrentModification) {
		t.Fatalf("got %v, want ErrConcurrentModification", err)
	}
}