@@ -18,26 +18,27 @@ import (
1818// For dirty git repos (~100ms): returns commitSHA:dirtyHash.
1919// For non-git dirs: returns empty string and an error.
2020func RepoFingerprint (dir string ) (string , error ) {
21- commitSHA , err := gitOutput (dir , "rev-parse" , "HEAD" )
21+ commitSHA , err := gitOutputTrim (dir , "rev-parse" , "HEAD" )
2222 if err != nil {
2323 return "" , fmt .Errorf ("not a git repo: %w" , err )
2424 }
2525
26- dirty , err := gitOutput (dir , "status" , "--porcelain" , "--untracked-files=all" )
26+ statusOut , err := gitOutputRaw (dir , "status" , "--porcelain=v1" , "-z " , "--untracked-files=all" )
2727 if err != nil {
2828 return commitSHA , nil
2929 }
30- dirty = filterFingerprintStatus (dirty )
30+ dirtyEntries : = filterFingerprintStatus (parseGitStatusZ ( statusOut ) )
3131
32- if dirty == "" {
32+ if len ( dirtyEntries ) == 0 {
3333 return commitSHA , nil
3434 }
3535
3636 // Dirty: hash tracked changes plus untracked file contents. Generated
3737 // files are filtered because they are not uploaded for analysis.
3838 h := sha256 .New ()
39- fmt .Fprintf (h , "status\x00 %s\x00 " , dirty )
40- if err := hashDirtyFiles (h , dir , dirty ); err != nil {
39+ fmt .Fprint (h , "status\x00 " )
40+ writeStatusEntries (h , dirtyEntries )
41+ if err := hashDirtyFiles (h , dir , dirtyEntries ); err != nil {
4142 return commitSHA + ":dirty" , nil
4243 }
4344 if err := hashUntrackedFiles (h , dir ); err != nil {
@@ -47,106 +48,172 @@ func RepoFingerprint(dir string) (string, error) {
4748 return commitSHA + ":" + hex .EncodeToString (sum [:8 ]), nil
4849}
4950
50- func hashDirtyFiles (h io.Writer , dir , status string ) error {
51- for _ , line := range strings .Split (status , "\n " ) {
52- if line == "" || strings .HasPrefix (line , "?? " ) {
51+ type gitStatusEntry struct {
52+ code string
53+ paths []string
54+ }
55+
56+ func parseGitStatusZ (status string ) []gitStatusEntry {
57+ if status == "" {
58+ return nil
59+ }
60+ records := strings .Split (status , "\x00 " )
61+ entries := make ([]gitStatusEntry , 0 , len (records ))
62+ for i := 0 ; i < len (records ); i ++ {
63+ record := records [i ]
64+ if record == "" || len (record ) < 4 {
5365 continue
5466 }
55- rel := statusPath ( line )
56- if rel == "" || ignoreFingerprintPath ( rel ) {
57- continue
67+ entry := gitStatusEntry {
68+ code : record [: 2 ],
69+ paths : [] string { filepath . ToSlash ( record [ 3 :])},
5870 }
59- full := filepath .Join (dir , rel )
60- info , err := os .Lstat (full )
61- if os .IsNotExist (err ) {
62- fmt .Fprintf (h , "tracked\x00 %s\x00 deleted\x00 " , rel )
71+ if isRenameOrCopyStatus (entry .code ) && i + 1 < len (records ) && records [i + 1 ] != "" {
72+ entry .paths = append (entry .paths , filepath .ToSlash (records [i + 1 ]))
73+ i ++
74+ }
75+ entries = append (entries , entry )
76+ }
77+ return entries
78+ }
79+
80+ func filterFingerprintStatus (entries []gitStatusEntry ) []gitStatusEntry {
81+ kept := make ([]gitStatusEntry , 0 , len (entries ))
82+ for _ , entry := range entries {
83+ if ! statusEntryTouchesUploadablePath (entry ) {
6384 continue
6485 }
65- if err != nil {
66- return err
86+ kept = append (kept , entry )
87+ }
88+ sort .Slice (kept , func (i , j int ) bool {
89+ return statusEntryKey (kept [i ]) < statusEntryKey (kept [j ])
90+ })
91+ return kept
92+ }
93+
94+ func statusEntryTouchesUploadablePath (entry gitStatusEntry ) bool {
95+ for _ , path := range entry .paths {
96+ if path != "" && ! ignoreFingerprintPath (path ) {
97+ return true
98+ }
99+ }
100+ return false
101+ }
102+
103+ func statusEntryKey (entry gitStatusEntry ) string {
104+ return entry .code + "\x00 " + strings .Join (entry .paths , "\x00 " )
105+ }
106+
107+ func writeStatusEntries (h io.Writer , entries []gitStatusEntry ) {
108+ for _ , entry := range entries {
109+ fmt .Fprintf (h , "%s\x00 " , entry .code )
110+ for _ , path := range entry .paths {
111+ fmt .Fprintf (h , "%s\x00 " , path )
67112 }
68- if info .IsDir () || info .Mode ()& os .ModeSymlink != 0 {
113+ }
114+ }
115+
116+ func isRenameOrCopyStatus (code string ) bool {
117+ return strings .ContainsAny (code , "RC" )
118+ }
119+
120+ func isRenameStatus (code string ) bool {
121+ return strings .Contains (code , "R" )
122+ }
123+
124+ func hashDirtyFiles (h io.Writer , dir string , entries []gitStatusEntry ) error {
125+ for _ , entry := range entries {
126+ if entry .code == "??" || len (entry .paths ) == 0 {
69127 continue
70128 }
71- fmt .Fprintf (h , "tracked\x00 %s\x00 %d\x00 " , rel , info .Size ())
72- f , err := os .Open (full )
73- if err != nil {
74- return err
129+
130+ if isRenameOrCopyStatus (entry .code ) && len (entry .paths ) > 1 {
131+ newPath := entry .paths [0 ]
132+ oldPath := entry .paths [1 ]
133+ if isRenameStatus (entry .code ) && ! ignoreFingerprintPath (oldPath ) && oldPath != newPath {
134+ fmt .Fprintf (h , "tracked\x00 %s\x00 deleted\x00 " , oldPath )
135+ }
136+ if ! ignoreFingerprintPath (newPath ) {
137+ if err := hashFileState (h , dir , "tracked" , newPath , true ); err != nil {
138+ return err
139+ }
140+ }
141+ continue
75142 }
76- if _ , err := io .Copy (h , f ); err != nil {
77- f .Close ()
78- return err
143+
144+ rel := entry .paths [0 ]
145+ if rel == "" || ignoreFingerprintPath (rel ) {
146+ continue
79147 }
80- if err := f . Close ( ); err != nil {
148+ if err := hashFileState ( h , dir , "tracked" , rel , true ); err != nil {
81149 return err
82150 }
83- fmt .Fprint (h , "\x00 " )
84151 }
85152 return nil
86153}
87154
88155func hashUntrackedFiles (h io.Writer , dir string ) error {
89- out , err := gitOutput (dir , "ls-files" , "--others" , "--exclude-standard" )
156+ out , err := gitOutputRaw (dir , "ls-files" , "-z " , "--others" , "--exclude-standard" )
90157 if err != nil || out == "" {
91158 return err
92159 }
93- files := strings . Split (out , " \n " )
160+ files := splitNUL (out )
94161 sort .Strings (files )
95162 for _ , rel := range files {
96163 if rel == "" {
97164 continue
98165 }
166+ rel = filepath .ToSlash (rel )
99167 if ignoreFingerprintPath (rel ) {
100168 continue
101169 }
102- full := filepath .Join (dir , rel )
103- info , err := os .Lstat (full )
104- if err != nil || info .IsDir () || info .Mode ()& os .ModeSymlink != 0 {
105- continue
106- }
107- fmt .Fprintf (h , "untracked\x00 %s\x00 %d\x00 " , rel , info .Size ())
108- f , err := os .Open (full )
109- if err != nil {
110- return err
111- }
112- if _ , err := io .Copy (h , f ); err != nil {
113- f .Close ()
114- return err
115- }
116- if err := f .Close (); err != nil {
170+ if err := hashFileState (h , dir , "untracked" , rel , false ); err != nil {
117171 return err
118172 }
119- fmt .Fprint (h , "\x00 " )
120173 }
121174 return nil
122175}
123176
124- func filterFingerprintStatus (status string ) string {
125- var kept []string
126- for _ , line := range strings .Split (status , "\n " ) {
127- line = strings .TrimRight (line , "\r " )
128- if strings .TrimSpace (line ) == "" {
129- continue
130- }
131- path := statusPath (line )
132- if path == "" || ignoreFingerprintPath (path ) {
133- continue
134- }
135- kept = append (kept , line )
177+ func splitNUL (out string ) []string {
178+ if out == "" {
179+ return nil
136180 }
137- sort .Strings (kept )
138- return strings .Join (kept , "\n " )
181+ fields := strings .Split (out , "\x00 " )
182+ if len (fields ) > 0 && fields [len (fields )- 1 ] == "" {
183+ fields = fields [:len (fields )- 1 ]
184+ }
185+ return fields
139186}
140187
141- func statusPath (line string ) string {
142- if len (line ) < 4 {
143- return ""
188+ func hashFileState (h io.Writer , dir , kind , rel string , missingAsDeleted bool ) error {
189+ full := filepath .Join (dir , filepath .FromSlash (rel ))
190+ info , err := os .Lstat (full )
191+ if os .IsNotExist (err ) {
192+ if missingAsDeleted {
193+ fmt .Fprintf (h , "%s\x00 %s\x00 deleted\x00 " , kind , rel )
194+ }
195+ return nil
196+ }
197+ if err != nil {
198+ return err
144199 }
145- path := strings .TrimSpace (line [3 :])
146- if _ , after , ok := strings .Cut (path , " -> " ); ok {
147- path = after
200+ if info .IsDir () || info .Mode ()& os .ModeSymlink != 0 {
201+ return nil
148202 }
149- return filepath .ToSlash (path )
203+ fmt .Fprintf (h , "%s\x00 %s\x00 %d\x00 " , kind , rel , info .Size ())
204+ f , err := os .Open (full )
205+ if err != nil {
206+ return err
207+ }
208+ if _ , err := io .Copy (h , f ); err != nil {
209+ f .Close ()
210+ return err
211+ }
212+ if err := f .Close (); err != nil {
213+ return err
214+ }
215+ fmt .Fprint (h , "\x00 " )
216+ return nil
150217}
151218
152219func ignoreFingerprintPath (path string ) bool {
@@ -155,14 +222,54 @@ func ignoreFingerprintPath(path string) bool {
155222 if len (parts ) == 0 {
156223 return false
157224 }
158- if len (parts ) > 1 && strings .HasPrefix (parts [0 ], "." ) {
225+ for _ , part := range parts [:len (parts )- 1 ] {
226+ if strings .HasPrefix (part , "." ) || defaultUploadSkipDir (part ) {
227+ return true
228+ }
229+ }
230+ filename := parts [len (parts )- 1 ]
231+ return isGeneratedShardPath (path ) ||
232+ isSensitiveFingerprintPath (path ) ||
233+ defaultUploadSkipFile (filename ) ||
234+ defaultUploadSkipExtension (filename )
235+ }
236+
237+ func defaultUploadSkipDir (name string ) bool {
238+ // Keep this in lockstep with internal/shards/zip.go upload exclusions.
239+ // The cache package cannot import shards because shards already imports cache.
240+ switch name {
241+ case ".git" , "node_modules" , "vendor" , ".venv" , "venv" , "__pycache__" , "dist" , "build" ,
242+ ".next" , ".nuxt" , ".cache" , ".turbo" , "coverage" , ".nyc_output" , "__snapshots__" ,
243+ "docs-output" , ".terraform" :
159244 return true
245+ default :
246+ return false
160247 }
161- switch parts [0 ] {
162- case ".supermodel" , "docs-output" , "node_modules" , "vendor" , "dist" , "build" , "coverage" :
248+ }
249+
250+ func defaultUploadSkipFile (name string ) bool {
251+ // Keep this in lockstep with internal/shards/zip.go upload exclusions.
252+ switch name {
253+ case "package-lock.json" , "yarn.lock" , "pnpm-lock.yaml" , "bun.lockb" , "Gemfile.lock" ,
254+ "poetry.lock" , "go.sum" , "Cargo.lock" :
163255 return true
256+ default :
257+ return false
258+ }
259+ }
260+
261+ func defaultUploadSkipExtension (name string ) bool {
262+ // Keep this in lockstep with internal/shards/zip.go upload exclusions.
263+ ext := strings .ToLower (filepath .Ext (name ))
264+ switch ext {
265+ case ".map" , ".ico" , ".woff" , ".woff2" , ".ttf" , ".eot" , ".otf" , ".mp4" , ".mp3" ,
266+ ".wav" , ".png" , ".jpg" , ".jpeg" , ".gif" , ".webp" :
267+ return true
268+ default :
269+ return strings .HasSuffix (name , ".min.js" ) ||
270+ strings .HasSuffix (name , ".min.css" ) ||
271+ strings .HasSuffix (name , ".bundle.js" )
164272 }
165- return isGeneratedShardPath (path ) || isSensitiveFingerprintPath (path )
166273}
167274
168275func isSensitiveFingerprintPath (path string ) bool {
@@ -196,13 +303,21 @@ func isGeneratedShardPath(path string) bool {
196303 }
197304}
198305
199- // gitOutput runs a git command in dir and returns its trimmed stdout.
200- func gitOutput (dir string , args ... string ) (string , error ) {
306+ func gitOutputRaw (dir string , args ... string ) (string , error ) {
201307 cmd := exec .Command ("git" , append ([]string {"-C" , dir }, args ... )... )
202308 out , err := cmd .Output ()
203309 if err != nil {
204310 return "" , err
205311 }
312+ return string (out ), nil
313+ }
314+
315+ // gitOutputTrim runs a git command in dir and returns stdout without trailing whitespace.
316+ func gitOutputTrim (dir string , args ... string ) (string , error ) {
317+ out , err := gitOutputRaw (dir , args ... )
318+ if err != nil {
319+ return "" , err
320+ }
206321 return strings .TrimSpace (string (out )), nil
207322}
208323
0 commit comments