|
4 | 4 | "bytes" |
5 | 5 | "os" |
6 | 6 | "path/filepath" |
| 7 | + "slices" |
7 | 8 | "testing" |
8 | 9 | ) |
9 | 10 |
|
@@ -450,3 +451,139 @@ func TestLogCoreFingerprintDriftCopyFiles(t *testing.T) { |
450 | 451 | t.Errorf("expected RelDst detail in CopyFiles drift output, got: %s", out) |
451 | 452 | } |
452 | 453 | } |
| 454 | + |
| 455 | +// TestSkipFingerprintExcludesFromCoreHash locks in the fix for issue #682: |
| 456 | +// CopyEntry values marked SkipFingerprint must not influence CoreFingerprint |
| 457 | +// regardless of their ContentHash, Src, or RelDst values. |
| 458 | +func TestSkipFingerprintExcludesFromCoreHash(t *testing.T) { |
| 459 | + base := Config{Command: "claude"} |
| 460 | + skipA := Config{Command: "claude", CopyFiles: []CopyEntry{ |
| 461 | + { |
| 462 | + Src: "/tmp/worktree/.claude/skills", RelDst: ".claude/skills", |
| 463 | + Probed: true, ContentHash: "aaaa", SkipFingerprint: true, |
| 464 | + }, |
| 465 | + }} |
| 466 | + skipB := Config{Command: "claude", CopyFiles: []CopyEntry{ |
| 467 | + { |
| 468 | + Src: "/tmp/worktree/.claude/skills", RelDst: ".claude/skills", |
| 469 | + Probed: true, ContentHash: "bbbb", SkipFingerprint: true, |
| 470 | + }, |
| 471 | + }} |
| 472 | + skipEmpty := Config{Command: "claude", CopyFiles: []CopyEntry{ |
| 473 | + { |
| 474 | + Src: "/tmp/worktree/.claude/skills", RelDst: ".claude/skills", |
| 475 | + Probed: true, ContentHash: "", SkipFingerprint: true, |
| 476 | + }, |
| 477 | + }} |
| 478 | + |
| 479 | + baseH := CoreFingerprint(base) |
| 480 | + if got := CoreFingerprint(skipA); got != baseH { |
| 481 | + t.Errorf("skipped entry changed fingerprint: base=%s got=%s", baseH, got) |
| 482 | + } |
| 483 | + if got := CoreFingerprint(skipB); got != baseH { |
| 484 | + t.Errorf("skipped entry with different ContentHash changed fingerprint: base=%s got=%s", baseH, got) |
| 485 | + } |
| 486 | + if got := CoreFingerprint(skipEmpty); got != baseH { |
| 487 | + t.Errorf("skipped entry with empty ContentHash changed fingerprint: base=%s got=%s", baseH, got) |
| 488 | + } |
| 489 | + // Breakdown must also report the same CopyFiles field hash as the base. |
| 490 | + baseBD := CoreFingerprintBreakdown(base) |
| 491 | + skipABD := CoreFingerprintBreakdown(skipA) |
| 492 | + if baseBD["CopyFiles"] != skipABD["CopyFiles"] { |
| 493 | + t.Errorf("skipped entry changed breakdown: base=%s got=%s", baseBD["CopyFiles"], skipABD["CopyFiles"]) |
| 494 | + } |
| 495 | +} |
| 496 | + |
| 497 | +// TestSkipFingerprintIgnoredOnConfigDerivedEntries ensures the doc contract |
| 498 | +// is enforced: SkipFingerprint is only honored on probed entries. A |
| 499 | +// config-derived entry (Probed=false) with SkipFingerprint=true must still |
| 500 | +// contribute to CoreFingerprint so real user edits drive drain. |
| 501 | +func TestSkipFingerprintIgnoredOnConfigDerivedEntries(t *testing.T) { |
| 502 | + base := Config{Command: "claude", CopyFiles: []CopyEntry{ |
| 503 | + {Src: "/user/config.json", RelDst: "config.json"}, |
| 504 | + }} |
| 505 | + withSkip := Config{Command: "claude", CopyFiles: []CopyEntry{ |
| 506 | + {Src: "/user/config.json", RelDst: "config.json", SkipFingerprint: true}, |
| 507 | + }} |
| 508 | + if CoreFingerprint(base) != CoreFingerprint(withSkip) { |
| 509 | + t.Error("SkipFingerprint must be ignored on config-derived (Probed=false) entries") |
| 510 | + } |
| 511 | + // Changing the Src on a config-derived entry must still drive drift, |
| 512 | + // even if SkipFingerprint is set. |
| 513 | + edited := Config{Command: "claude", CopyFiles: []CopyEntry{ |
| 514 | + {Src: "/user/config-edited.json", RelDst: "config.json", SkipFingerprint: true}, |
| 515 | + }} |
| 516 | + if CoreFingerprint(withSkip) == CoreFingerprint(edited) { |
| 517 | + t.Error("config-derived Src change must drive drift even with SkipFingerprint=true") |
| 518 | + } |
| 519 | +} |
| 520 | + |
| 521 | +// TestSkipFingerprintStableUnderFilesystemChurn is the regression guard for |
| 522 | +// issue #682: a probed entry whose underlying directory is populated between |
| 523 | +// probes (simulating pre_start staging) must produce a stable CoreFingerprint |
| 524 | +// when marked SkipFingerprint, and a drifting one otherwise. |
| 525 | +func TestSkipFingerprintStableUnderFilesystemChurn(t *testing.T) { |
| 526 | + workDir := t.TempDir() |
| 527 | + skillsDir := filepath.Join(workDir, ".claude", "skills") |
| 528 | + if err := os.MkdirAll(skillsDir, 0o755); err != nil { |
| 529 | + t.Fatalf("MkdirAll: %v", err) |
| 530 | + } |
| 531 | + // Phase 1: empty skills dir — resembles template-resolve BEFORE pre_start |
| 532 | + // has finished populating the worktree. |
| 533 | + before := Config{Command: "claude", CopyFiles: []CopyEntry{{ |
| 534 | + Src: skillsDir, RelDst: ".claude/skills", |
| 535 | + Probed: true, ContentHash: HashPathContent(skillsDir), |
| 536 | + }}} |
| 537 | + // Phase 2: pre_start completes and drops a file in the worktree. |
| 538 | + if err := os.WriteFile(filepath.Join(skillsDir, "new.md"), []byte("content"), 0o644); err != nil { |
| 539 | + t.Fatalf("WriteFile: %v", err) |
| 540 | + } |
| 541 | + after := Config{Command: "claude", CopyFiles: []CopyEntry{{ |
| 542 | + Src: skillsDir, RelDst: ".claude/skills", |
| 543 | + Probed: true, ContentHash: HashPathContent(skillsDir), |
| 544 | + }}} |
| 545 | + |
| 546 | + // Without SkipFingerprint the hash MUST drift (this is the bug). |
| 547 | + beforeH := CoreFingerprint(before) |
| 548 | + afterH := CoreFingerprint(after) |
| 549 | + if beforeH == afterH { |
| 550 | + t.Fatal("test precondition: HashPathContent must observe filesystem change") |
| 551 | + } |
| 552 | + |
| 553 | + // With SkipFingerprint the same mutation must produce a stable hash. |
| 554 | + beforeSkip := before |
| 555 | + beforeSkip.CopyFiles = slices.Clone(before.CopyFiles) |
| 556 | + beforeSkip.CopyFiles[0].SkipFingerprint = true |
| 557 | + afterSkip := after |
| 558 | + afterSkip.CopyFiles = slices.Clone(after.CopyFiles) |
| 559 | + afterSkip.CopyFiles[0].SkipFingerprint = true |
| 560 | + |
| 561 | + if got1, got2 := CoreFingerprint(beforeSkip), CoreFingerprint(afterSkip); got1 != got2 { |
| 562 | + t.Errorf("SkipFingerprint did not stabilize CopyFiles hash under churn: before=%s after=%s", got1, got2) |
| 563 | + } |
| 564 | +} |
| 565 | + |
| 566 | +// TestLogCoreFingerprintDriftSkipsExcludedEntries ensures diagnostic output |
| 567 | +// does not leak skipped entries, which would otherwise confuse operators |
| 568 | +// debugging real drift. |
| 569 | +func TestLogCoreFingerprintDriftSkipsExcludedEntries(t *testing.T) { |
| 570 | + stored := map[string]string{ |
| 571 | + "CopyFiles": "oldhash", |
| 572 | + } |
| 573 | + current := Config{ |
| 574 | + Command: "claude", |
| 575 | + CopyFiles: []CopyEntry{ |
| 576 | + {RelDst: "real", ContentHash: "h1"}, |
| 577 | + {RelDst: "skipped-churn", Probed: true, ContentHash: "h2", SkipFingerprint: true}, |
| 578 | + }, |
| 579 | + } |
| 580 | + var buf bytes.Buffer |
| 581 | + LogCoreFingerprintDrift(&buf, "agent", stored, current) |
| 582 | + out := buf.String() |
| 583 | + if !bytes.Contains([]byte(out), []byte("real")) { |
| 584 | + t.Errorf("expected non-skipped RelDst in drift output, got: %s", out) |
| 585 | + } |
| 586 | + if bytes.Contains([]byte(out), []byte("skipped-churn")) { |
| 587 | + t.Errorf("skipped entry leaked into drift output: %s", out) |
| 588 | + } |
| 589 | +} |
0 commit comments