a73x

acf62ebc

feat(rewriter): atomic UUID stamping with mode preservation

a73x   2026-04-29 05:49

Commit message
feat(rewriter): atomic UUID stamping with mode preservation

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

internal/rewriter/rewriter.go
Old New
@@ -0,0 +1,116 @@
1 package rewriter
2
3 import (
4 "bufio"
5 "errors"
6 "fmt"
7 "os"
8 "path/filepath"
9 "regexp"
10 "strings"
11 )
12
13 var ErrConcurrentModification = errors.New("file modified concurrently")
14
15 // stampRE matches "@claude" optionally followed by an existing "[fw-XXXXXXXX]" tag.
16 var stampRE = regexp.MustCompile(`@claude(?:\[fw-[0-9a-f]{8}\])?`)
17
18 // StampUUIDs rewrites the file at path, splicing "[fw-XXXXXXXX]" immediately
19 // after the first untagged "@claude" on each given line. stamps maps line
20 // number (1-indexed) → UUID (e.g. "fw-a1b2c3d4"). The write is atomic via
21 // tmpfile + rename. File mode is preserved.
22 //
23 // Aborts with ErrConcurrentModification if the file's mtime changed between
24 // read and rename (someone — likely the user's editor — wrote concurrently).
25 // In that case, the rewrite is dropped and the marker stays untagged for a
26 // retry on the next save event.
27 func StampUUIDs(path string, stamps map[int]string) error {
28 info, err := os.Stat(path)
29 if err != nil {
30 return err
31 }
32 mtimeBefore := info.ModTime()
33
34 in, err := os.Open(path)
35 if err != nil {
36 return err
37 }
38 defer in.Close()
39
40 dir := filepath.Dir(path)
41 tmp, err := os.CreateTemp(dir, ".filewatch-*.tmp")
42 if err != nil {
43 return err
44 }
45 tmpPath := tmp.Name()
46 cleanup := func() {
47 tmp.Close()
48 os.Remove(tmpPath)
49 }
50
51 sc := bufio.NewScanner(in)
52 sc.Buffer(make([]byte, 1024*1024), 1024*1024)
53 bw := bufio.NewWriter(tmp)
54 line := 0
55 for sc.Scan() {
56 line++
57 text := sc.Text()
58 if uuid, ok := stamps[line]; ok {
59 text = stampLine(text, uuid)
60 }
61 if _, err := bw.WriteString(text); err != nil {
62 cleanup()
63 return err
64 }
65 if _, err := bw.WriteString("\n"); err != nil {
66 cleanup()
67 return err
68 }
69 }
70 if err := sc.Err(); err != nil {
71 cleanup()
72 return err
73 }
74 if err := bw.Flush(); err != nil {
75 cleanup()
76 return err
77 }
78 if err := tmp.Chmod(info.Mode().Perm()); err != nil {
79 cleanup()
80 return err
81 }
82 if err := tmp.Close(); err != nil {
83 os.Remove(tmpPath)
84 return err
85 }
86
87 // mtime check before atomic rename (deterministic test for this comes in Task 7)
88 info2, err := os.Stat(path)
89 if err != nil {
90 os.Remove(tmpPath)
91 return err
92 }
93 if !info2.ModTime().Equal(mtimeBefore) {
94 os.Remove(tmpPath)
95 return ErrConcurrentModification
96 }
97
98 if err := os.Rename(tmpPath, path); err != nil {
99 os.Remove(tmpPath)
100 return fmt.Errorf("rename: %w", err)
101 }
102 return nil
103 }
104
105 // stampLine splices "[uuid]" after the first untagged "@claude" on the line.
106 // Already-tagged occurrences are left untouched.
107 func stampLine(text, uuid string) string {
108 done := false
109 return stampRE.ReplaceAllStringFunc(text, func(m string) string {
110 if done || strings.Contains(m, "[fw-") {
111 return m
112 }
113 done = true
114 return "@claude[" + uuid + "]"
115 })
116 }
internal/rewriter/rewriter_test.go
Old New
@@ -0,0 +1,65 @@
1 package rewriter
2
3 import (
4 "os"
5 "path/filepath"
6 "strings"
7 "testing"
8 )
9
10 func TestStampUUIDsInsertsTagAfterClaude(t *testing.T) {
11 dir := t.TempDir()
12 path := filepath.Join(dir, "foo.go")
13 original := "package foo\n\n// @claude write a test\nfunc Foo() {}\n"
14 if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
15 t.Fatal(err)
16 }
17
18 err := StampUUIDs(path, map[int]string{3: "fw-a1b2c3d4"})
19 if err != nil {
20 t.Fatalf("StampUUIDs: %v", err)
21 }
22
23 got, err := os.ReadFile(path)
24 if err != nil {
25 t.Fatal(err)
26 }
27 want := "package foo\n\n// @claude[fw-a1b2c3d4] write a test\nfunc Foo() {}\n"
28 if string(got) != want {
29 t.Errorf("got:\n%s\nwant:\n%s", got, want)
30 }
31 }
32
33 func TestStampUUIDsPreservesIndentation(t *testing.T) {
34 dir := t.TempDir()
35 path := filepath.Join(dir, "foo.go")
36 original := "func F() {\n // @claude refactor\n}\n"
37 if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
38 t.Fatal(err)
39 }
40
41 if err := StampUUIDs(path, map[int]string{2: "fw-deadbeef"}); err != nil {
42 t.Fatal(err)
43 }
44 got, _ := os.ReadFile(path)
45 if !strings.Contains(string(got), " // @claude[fw-deadbeef] refactor") {
46 t.Errorf("indentation not preserved:\n%s", got)
47 }
48 }
49
50 func TestStampUUIDsBlockComment(t *testing.T) {
51 dir := t.TempDir()
52 path := filepath.Join(dir, "x.html")
53 original := "<!-- @claude add aria -->\n<div></div>\n"
54 if err := os.WriteFile(path, []byte(original), 0o644); err != nil {
55 t.Fatal(err)
56 }
57 if err := StampUUIDs(path, map[int]string{1: "fw-12345678"}); err != nil {
58 t.Fatal(err)
59 }
60 got, _ := os.ReadFile(path)
61 want := "<!-- @claude[fw-12345678] add aria -->\n<div></div>\n"
62 if string(got) != want {
63 t.Errorf("got:\n%s\nwant:\n%s", got, want)
64 }
65 }