@@ -2,8 +2,11 @@ package engine_test
22
33import (
44 "context"
5+ "encoding/json"
6+ "os"
57 "os/exec"
68 "path/filepath"
9+ "strings"
710 "testing"
811 "time"
912
@@ -157,3 +160,196 @@ func TestStaleness_CommitMoved(t *testing.T) {
157160 t .Error ("commit moved should make Stale() true" )
158161 }
159162}
163+
164+ // writeForeignGlobalReceipt writes ~/.enola/receipt.json describing a repo that is
165+ // NOT in the loaded graph — exactly what a second enola server in another agent
166+ // terminal leaves behind, since WriteGlobalReceipt rebuilds `repos` from its own
167+ // graph rather than merging. generatedAt is deliberately old and the commit is
168+ // deliberately wrong, so that adopting this file produces a visibly wrong verdict.
169+ //
170+ // The three staleness tests above set HOME to an empty temp dir specifically so
171+ // LoadGlobalReceipt MISSES; this helper is what exercises the branch where it hits.
172+ func writeForeignGlobalReceipt (t * testing.T , home , foreignRepoPath , generatedAt string ) {
173+ t .Helper ()
174+ receipt := map [string ]any {
175+ "generated_at" : generatedAt ,
176+ "enola_version" : "dev" ,
177+ "snapshot_id" : "sha256:sibling-terminal" ,
178+ "fact_count" : 4 ,
179+ "repos" : []map [string ]any {{
180+ "label" : "foreign-sibling" ,
181+ "path" : foreignRepoPath ,
182+ "git" : map [string ]any {
183+ "ref" : "main" ,
184+ "commit" : "0000000000000000000000000000000000000000" ,
185+ "dirty" : false ,
186+ },
187+ "added_at" : generatedAt ,
188+ "fact_count" : 4 ,
189+ }},
190+ }
191+ data , err := json .Marshal (receipt )
192+ if err != nil {
193+ t .Fatal (err )
194+ }
195+ dir := filepath .Join (home , ".enola" )
196+ if err := os .MkdirAll (dir , 0o755 ); err != nil {
197+ t .Fatal (err )
198+ }
199+ if err := os .WriteFile (filepath .Join (dir , "receipt.json" ), data , 0o644 ); err != nil {
200+ t .Fatal (err )
201+ }
202+ }
203+
204+ // initGitRepo creates a git repo with one commit and returns its HEAD.
205+ func initGitRepo (t * testing.T , repo string ) string {
206+ t .Helper ()
207+ git := func (args ... string ) {
208+ t .Helper ()
209+ cmd := exec .Command ("git" , append ([]string {"-C" , repo }, args ... )... )
210+ if out , err := cmd .CombinedOutput (); err != nil {
211+ t .Fatalf ("git %v: %v\n %s" , args , err , out )
212+ }
213+ }
214+ git ("init" )
215+ git ("config" , "user.email" , "t@example.com" )
216+ git ("config" , "user.name" , "t" )
217+ writeFile (t , filepath .Join (repo , "f.txt" ), "hello\n " )
218+ git ("add" , "." )
219+ git ("commit" , "-m" , "init" )
220+ out , err := exec .Command ("git" , "-C" , repo , "rev-parse" , "HEAD" ).Output ()
221+ if err != nil {
222+ t .Fatal (err )
223+ }
224+ return strings .TrimSpace (string (out ))
225+ }
226+
227+ // TestStaleness_IgnoresForeignGlobalReceipt is the regression for the machine-wide
228+ // receipt cross-talk. ~/.enola/receipt.json is shared by every enola process on the
229+ // machine, so it routinely describes a DIFFERENT graph than the one this server
230+ // loaded. Staleness must judge the loaded graph, never whatever the file names.
231+ //
232+ // Observed before the fix: a graph snapshotted 13 seconds earlier over a clean tree
233+ // reported "graph is 5d old; <foreign> (commit moved)".
234+ func TestStaleness_IgnoresForeignGlobalReceipt (t * testing.T ) {
235+ if _ , err := exec .LookPath ("git" ); err != nil {
236+ t .Skip ("git not available" )
237+ }
238+ home := t .TempDir ()
239+ t .Setenv ("HOME" , home )
240+
241+ loaded := t .TempDir ()
242+ head := initGitRepo (t , loaded )
243+ foreign := t .TempDir ()
244+ initGitRepo (t , foreign )
245+
246+ now := time .Date (2026 , 7 , 20 , 12 , 0 , 0 , 0 , time .UTC )
247+ // The foreign receipt is 5 days old and names a commit that matches nothing.
248+ writeForeignGlobalReceipt (t , home , foreign , now .Add (- 5 * 24 * time .Hour ).Format (time .RFC3339 ))
249+
250+ cfg := config .Default ()
251+ eng , err := engine .New (cfg )
252+ if err != nil {
253+ t .Fatal (err )
254+ }
255+ // The loaded graph is fresh (1h) and its recorded commit is current HEAD.
256+ eng .SetSnapshot (& facts.Snapshot {Meta : facts.SnapshotMeta {
257+ RepoPath : loaded ,
258+ GeneratedAt : now .Add (- 1 * time .Hour ).Format (time .RFC3339 ),
259+ Git : & facts.GitInfo {Ref : "main" , Commit : head },
260+ }})
261+
262+ st := eng .Staleness (24 * time .Hour , now )
263+ if st .TooOld {
264+ t .Errorf ("age was taken from the foreign receipt: TooOld=true for a 1h-old loaded snapshot (Age=%s)" , st .Age )
265+ }
266+ for _ , c := range st .Changed {
267+ if c .Label == "foreign-sibling" {
268+ t .Errorf ("reported a repo that is not in the loaded graph: %+v" , c )
269+ }
270+ }
271+ if st .Stale () {
272+ t .Errorf ("fresh, unchanged loaded graph reported stale: TooOld=%v Changed=%+v" , st .TooOld , st .Changed )
273+ }
274+ }
275+
276+ // TestStaleness_ReportsLoadedRepoDespiteForeignReceipt is the other direction: the
277+ // foreign receipt must not MASK real staleness in the loaded graph. Before the fix
278+ // stalenessEntries returned the receipt's repo list verbatim, so the loaded repo was
279+ // never git-checked at all and a moved HEAD went unreported.
280+ func TestStaleness_ReportsLoadedRepoDespiteForeignReceipt (t * testing.T ) {
281+ if _ , err := exec .LookPath ("git" ); err != nil {
282+ t .Skip ("git not available" )
283+ }
284+ home := t .TempDir ()
285+ t .Setenv ("HOME" , home )
286+
287+ loaded := t .TempDir ()
288+ initGitRepo (t , loaded )
289+ foreign := t .TempDir ()
290+ initGitRepo (t , foreign )
291+
292+ now := time .Date (2026 , 7 , 20 , 12 , 0 , 0 , 0 , time .UTC )
293+ writeForeignGlobalReceipt (t , home , foreign , now .Add (- 5 * 24 * time .Hour ).Format (time .RFC3339 ))
294+
295+ cfg := config .Default ()
296+ eng , err := engine .New (cfg )
297+ if err != nil {
298+ t .Fatal (err )
299+ }
300+ // Fresh by age, but the recorded commit does not match the loaded repo's HEAD.
301+ eng .SetSnapshot (& facts.Snapshot {Meta : facts.SnapshotMeta {
302+ RepoPath : loaded ,
303+ GeneratedAt : now .Add (- 1 * time .Hour ).Format (time .RFC3339 ),
304+ Git : & facts.GitInfo {Ref : "main" , Commit : "0000000000000000000000000000000000000000" },
305+ }})
306+
307+ st := eng .Staleness (24 * time .Hour , now )
308+ if len (st .Changed ) != 1 {
309+ t .Fatalf ("want exactly one Changed entry for the loaded repo, got %+v" , st .Changed )
310+ }
311+ if got := st .Changed [0 ].Label ; got != filepath .Base (loaded ) {
312+ t .Errorf ("Changed names %q, want the loaded repo %q" , got , filepath .Base (loaded ))
313+ }
314+ if st .Changed [0 ].Reason != "commit moved" {
315+ t .Errorf ("got Reason=%q, want \" commit moved\" " , st .Changed [0 ].Reason )
316+ }
317+ }
318+
319+ // TestStaleness_AgeFromLoadedSnapshotNotForeignReceipt isolates the age signal from
320+ // the git signal: same commit on both sides, so only the timestamp can differ. The
321+ // loaded graph is 1h old and the foreign receipt is 5 days old.
322+ func TestStaleness_AgeFromLoadedSnapshotNotForeignReceipt (t * testing.T ) {
323+ if _ , err := exec .LookPath ("git" ); err != nil {
324+ t .Skip ("git not available" )
325+ }
326+ home := t .TempDir ()
327+ t .Setenv ("HOME" , home )
328+
329+ loaded := t .TempDir ()
330+ head := initGitRepo (t , loaded )
331+
332+ now := time .Date (2026 , 7 , 20 , 12 , 0 , 0 , 0 , time .UTC )
333+ // Foreign receipt points at the LOADED repo path but carries a 5-day-old
334+ // timestamp, so only the age source distinguishes pass from fail.
335+ writeForeignGlobalReceipt (t , home , loaded , now .Add (- 5 * 24 * time .Hour ).Format (time .RFC3339 ))
336+
337+ cfg := config .Default ()
338+ eng , err := engine .New (cfg )
339+ if err != nil {
340+ t .Fatal (err )
341+ }
342+ eng .SetSnapshot (& facts.Snapshot {Meta : facts.SnapshotMeta {
343+ RepoPath : loaded ,
344+ GeneratedAt : now .Add (- 1 * time .Hour ).Format (time .RFC3339 ),
345+ Git : & facts.GitInfo {Ref : "main" , Commit : head },
346+ }})
347+
348+ st := eng .Staleness (24 * time .Hour , now )
349+ if st .TooOld {
350+ t .Errorf ("age came from the receipt (5d), not the loaded snapshot (1h): Age=%s" , st .Age )
351+ }
352+ if want := 1 * time .Hour ; st .Age != want {
353+ t .Errorf ("Age=%s, want %s (the loaded snapshot's own generated_at)" , st .Age , want )
354+ }
355+ }
0 commit comments