package gitosync import ( "context" "errors" "os" "os/exec" "path/filepath" "strings" "testing" "github.com/nomadcode/nomadcode-core/internal/authoring" "github.com/nomadcode/nomadcode-core/internal/gitoevents" "github.com/nomadcode/nomadcode-core/internal/roadmapsync" "github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline" ) const milestonePath = "agent-roadmap/phase/p1/milestones/m1.md" const archivedMilestonePath = "agent-roadmap/archive/phase/p1/milestones/old-m1.md" const milestoneMarkdownWithIdentity = `# Milestone m1 ## Provider identity - provider: plane - tenant: acme - project: proj-1 - work item id: wi-123 ` const milestoneMarkdownNoIdentity = `# Milestone m2 Just a body, no provider identity block. ` // fakeRunner returns scripted stdout per (args joined) command and records the // ordered sequence of commands it ran. type fakeRunner struct { responses map[string]string errs map[string]error calls []string } func newFakeRunner() *fakeRunner { return &fakeRunner{responses: map[string]string{}, errs: map[string]error{}} } func (f *fakeRunner) Run(_ context.Context, _ string, name string, args ...string) (string, error) { key := name + " " + strings.Join(args, " ") f.calls = append(f.calls, key) if err, ok := f.errs[key]; ok { return "", err } return f.responses[key], nil } func newScanEvent() gitoevents.BranchUpdatedEvent { return gitoevents.BranchUpdatedEvent{ RepoID: "nomadcode", Branch: "develop", Before: "aaaa", After: "bbbb", } } func newTestScanner(t *testing.T, runner CommandRunner) *BranchRevisionScanner { t.Helper() s, err := NewBranchRevisionScanner(runner, ScannerConfig{ DevelopRepoPath: "/repo", RemoteName: "origin", Branch: "develop", }) if err != nil { t.Fatalf("NewBranchRevisionScanner: %v", err) } return s } func TestScanRunsFetchRevParseActiveIndexShowInOrder(t *testing.T) { runner := newFakeRunner() runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb\n" runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = milestonePath + "\n" runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) if err != nil { t.Fatalf("Scan: %v", err) } wantOrder := []string{ "git fetch --prune origin develop", "git rev-parse refs/remotes/origin/develop", "git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive", "git show bbbb:" + milestonePath, "git show -s --format=%B bbbb", } if len(runner.calls) != len(wantOrder) { t.Fatalf("call count: got %d (%v), want %d", len(runner.calls), runner.calls, len(wantOrder)) } for i, want := range wantOrder { if runner.calls[i] != want { t.Fatalf("call[%d]: got %q, want %q", i, runner.calls[i], want) } } if out.Result.Revision.Revision != "bbbb" { t.Fatalf("revision: got %q, want bbbb", out.Result.Revision.Revision) } if len(out.Result.ScannedMilestones) != 1 || out.Result.ScannedMilestones[0].Path != milestonePath { t.Fatalf("scanned milestones: got %+v", out.Result.ScannedMilestones) } if got := out.Result.ScannedMilestones[0].Identity.WorkItemID; got != "wi-123" { t.Fatalf("identity work item id: got %q, want wi-123", got) } if len(out.Docs) != 1 || out.Docs[0].Markdown != milestoneMarkdownWithIdentity { t.Fatalf("docs: got %+v", out.Docs) } } func TestScanReturnsNotReadyOnRevisionMismatch(t *testing.T) { runner := newFakeRunner() // Remote develop has not caught up to the event's `after`. runner.responses["git rev-parse refs/remotes/origin/develop"] = "cccc\n" _, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) if !errors.Is(err, ErrNotReady) { t.Fatalf("Scan: got %v, want ErrNotReady", err) } // It must not diff/show once it knows the revision is stale. for _, c := range runner.calls { if strings.HasPrefix(c, "git ls-tree") || strings.HasPrefix(c, "git show") { t.Fatalf("unexpected command after mismatch: %q", c) } } } func TestScanPreservesMilestoneWithoutProviderIdentity(t *testing.T) { runner := newFakeRunner() runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb" runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = milestonePath runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownNoIdentity out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) if err != nil { t.Fatalf("Scan: %v", err) } // Kept in ChangedFiles (so the match gate's reason stays accurate) and preserved in Docs for missing-create. if len(out.Result.ChangedFiles) != 1 { t.Fatalf("changed files: got %v", out.Result.ChangedFiles) } if len(out.Result.ScannedMilestones) != 0 { t.Fatalf("scanned milestones: want none, got %+v", out.Result.ScannedMilestones) } if len(out.Docs) != 1 { t.Fatalf("docs: got %d, want 1", len(out.Docs)) } doc := out.Docs[0] if doc.Path != milestonePath || doc.MilestoneID != "m1" || doc.Markdown != milestoneMarkdownNoIdentity || doc.HasIdentity { t.Fatalf("doc mismatch: %+v", doc) } } func TestScanIndexesOnlyActiveMilestonesAndKeepsArchiveCandidates(t *testing.T) { runner := newFakeRunner() runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb" runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = "agent-roadmap/phase/p1/PHASE.md\n" + milestonePath + "\n" + archivedMilestonePath + "\n" runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) if err != nil { t.Fatalf("Scan: %v", err) } if len(out.Result.ChangedFiles) != 1 || out.Result.ChangedFiles[0] != milestonePath { t.Fatalf("changed files: got %v, want only the milestone path", out.Result.ChangedFiles) } if len(out.ArchiveCandidates) != 1 { t.Fatalf("archive candidates: got %+v, want one", out.ArchiveCandidates) } if out.ArchiveCandidates[0].Path != archivedMilestonePath || out.ArchiveCandidates[0].MilestoneID != "old-m1" { t.Fatalf("archive candidate mismatch: %+v", out.ArchiveCandidates[0]) } } func TestScanIndexesRemoteRevisionRegardlessOfBefore(t *testing.T) { runner := newFakeRunner() runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb" runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = "" ev := newScanEvent() ev.Before = "0000000000000000000000000000000000000000" if _, err := newTestScanner(t, runner).Scan(context.Background(), ev); err != nil { t.Fatalf("Scan: %v", err) } for _, c := range runner.calls { if strings.HasPrefix(c, "git diff") { t.Fatalf("active index must not use diff range, got %q", c) } if strings.HasPrefix(c, "git ls-tree") && !strings.Contains(c, "bbbb -- ") { t.Fatalf("expected ls-tree at remote revision, got %q", c) } } } func TestScanRegressionWithIdentityWriter(t *testing.T) { // 1. Create a temporary git repo to run the real LocalIdentityWriter on it. tmpDir, err := os.MkdirTemp("", "nomadcode-scanner-regression-*") if err != nil { t.Fatalf("failed to create temp dir: %v", err) } defer os.RemoveAll(tmpDir) // git init cmd := exec.Command("git", "init") cmd.Dir = tmpDir if err := cmd.Run(); err != nil { t.Fatalf("failed to git init: %v", err) } // git config user.name and user.email (just in case) cmd = exec.Command("git", "config", "user.name", "test") cmd.Dir = tmpDir _ = cmd.Run() cmd = exec.Command("git", "config", "user.email", "test@test.com") cmd.Dir = tmpDir _ = cmd.Run() milestoneRelPath := "agent-roadmap/phase/p1/milestones/m1.md" milestoneDir := filepath.Join(tmpDir, "agent-roadmap", "phase", "p1", "milestones") if err := os.MkdirAll(milestoneDir, 0755); err != nil { t.Fatalf("failed to create milestone dir: %v", err) } milestoneFullPath := filepath.Join(milestoneDir, "m1.md") // write identity-less milestone markdown if err := os.WriteFile(milestoneFullPath, []byte(milestoneMarkdownNoIdentity), 0644); err != nil { t.Fatalf("failed to write milestone file: %v", err) } // 2. Run LocalIdentityWriter to inject identity block. writer := authoring.NewLocalIdentityWriter() ident := roadmapsync.Identity{ Provider: "plane", WorkItemID: "wi-123", Tenant: "acme", Project: "proj-1", } res, err := writer.EnsureProviderIdentity(context.Background(), authoring.EnsureProviderIdentityInput{ WorkspacePath: tmpDir, Identity: ident, }) if err != nil { t.Fatalf("EnsureProviderIdentity failed: %v", err) } if len(res.InjectedPaths) != 1 || res.InjectedPaths[0] != milestoneRelPath { t.Fatalf("unexpected injected paths: %v", res.InjectedPaths) } // Read the modified markdown (which now has identity) injectedMarkdownBytes, err := os.ReadFile(milestoneFullPath) if err != nil { t.Fatalf("failed to read injected file: %v", err) } injectedMarkdown := string(injectedMarkdownBytes) // 3. Mock the runner with the injected markdown as the output of `git show` runner := newFakeRunner() runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb\n" runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = milestoneRelPath + "\n" runner.responses["git show bbbb:"+milestoneRelPath] = injectedMarkdown out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) if err != nil { t.Fatalf("Scan regression failed: %v", err) } // 4. Verify that the scanner now successfully parses it if len(out.Result.ScannedMilestones) != 1 || out.Result.ScannedMilestones[0].Path != milestoneRelPath { t.Fatalf("expected 1 scanned milestone, got %+v", out.Result.ScannedMilestones) } parsedIdent := out.Result.ScannedMilestones[0].Identity if parsedIdent.Provider != "plane" || parsedIdent.WorkItemID != "wi-123" || parsedIdent.Tenant != "acme" || parsedIdent.Project != "proj-1" { t.Errorf("unexpected parsed identity: %+v", parsedIdent) } if len(out.Docs) != 1 || out.Docs[0].Markdown != injectedMarkdown { t.Fatalf("expected 1 doc with injected markdown, got %+v", out.Docs) } } // TestScanChangedFilesHintDoesNotGateActiveIndexScan verifies that // ev.ChangedFiles (the wakeup hint) is not consulted to decide what gets // scanned: even when the hint names an unrelated file, or is empty, the full // active Milestone index at the verified revision is the source of truth. func TestScanChangedFilesHintDoesNotGateActiveIndexScan(t *testing.T) { runner := newFakeRunner() runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb" runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = milestonePath runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity ev := newScanEvent() ev.ChangedFiles = []gitoevents.ChangedFile{{Path: "README.md", ChangeType: "modified"}} out, err := newTestScanner(t, runner).Scan(context.Background(), ev) if err != nil { t.Fatalf("Scan: %v", err) } if len(out.Result.ScannedMilestones) != 1 || out.Result.ScannedMilestones[0].Path != milestonePath { t.Fatalf("expected active index milestone regardless of unrelated hint, got %+v", out.Result.ScannedMilestones) } for _, c := range runner.calls { if strings.HasPrefix(c, "git diff") { t.Fatalf("hint must not be used to build a diff range, got %q", c) } } } // TestScanFlagsSelfAuthoredBackfillCommitAsHintOnly verifies that a tip commit // carrying the Core identity-backfill marker sets ScanOutput.SelfAuthored, but // the hint does not change what gets scanned: the full active Milestone index at // the verified revision is still the source of truth (S05 — the self-loop // wakeup is recognized without bypassing the active scan). func TestScanFlagsSelfAuthoredBackfillCommitAsHintOnly(t *testing.T) { runner := newFakeRunner() runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb" runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = milestonePath runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity runner.responses["git show -s --format=%B bbbb"] = "chore(roadmap): backfill provider identity for m1\n\n" + roadmapsyncpipeline.CommitMarker + "\n" out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) if err != nil { t.Fatalf("Scan: %v", err) } if !out.SelfAuthored { t.Fatal("expected SelfAuthored hint for a Core backfill tip commit") } // The hint must not suppress the active-index scan. if len(out.Result.ScannedMilestones) != 1 || out.Result.ScannedMilestones[0].Path != milestonePath { t.Fatalf("self-authored hint must not bypass active scan, got %+v", out.Result.ScannedMilestones) } if len(out.Docs) != 1 { t.Fatalf("docs: got %d, want 1", len(out.Docs)) } } // TestScanDoesNotFlagSelfAuthoredForOrdinaryCommit verifies an ordinary tip // commit (no Core marker) leaves the hint false. func TestScanDoesNotFlagSelfAuthoredForOrdinaryCommit(t *testing.T) { runner := newFakeRunner() runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb" runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = milestonePath runner.responses["git show bbbb:"+milestonePath] = milestoneMarkdownWithIdentity runner.responses["git show -s --format=%B bbbb"] = "feat: an ordinary user push\n" out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) if err != nil { t.Fatalf("Scan: %v", err) } if out.SelfAuthored { t.Fatal("expected SelfAuthored to stay false for an ordinary commit") } } func TestScanReturnsErrorOnDuplicateActiveSlugs(t *testing.T) { runner := newFakeRunner() runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb" runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = "agent-roadmap/phase/p1/milestones/m1.md\n" + "agent-roadmap/phase/p2/milestones/m1.md\n" _, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) if !errors.Is(err, ErrDuplicateActiveSlug) { t.Fatalf("Scan: got %v, want ErrDuplicateActiveSlug", err) } } func TestScanAllowsSameSlugInActiveAndArchive(t *testing.T) { runner := newFakeRunner() runner.responses["git rev-parse refs/remotes/origin/develop"] = "bbbb" runner.responses["git ls-tree -r --name-only bbbb -- agent-roadmap/phase agent-roadmap/archive"] = "agent-roadmap/phase/p1/milestones/m1.md\n" + "agent-roadmap/archive/phase/p2/milestones/m1.md\n" runner.responses["git show bbbb:agent-roadmap/phase/p1/milestones/m1.md"] = milestoneMarkdownWithIdentity out, err := newTestScanner(t, runner).Scan(context.Background(), newScanEvent()) if err != nil { t.Fatalf("Scan: unexpected error: %v", err) } if len(out.Result.ChangedFiles) != 1 || out.Result.ChangedFiles[0] != "agent-roadmap/phase/p1/milestones/m1.md" { t.Fatalf("expected 1 active file, got %v", out.Result.ChangedFiles) } if len(out.ArchiveCandidates) != 1 || out.ArchiveCandidates[0].Path != "agent-roadmap/archive/phase/p2/milestones/m1.md" { t.Fatalf("expected 1 archive candidate, got %v", out.ArchiveCandidates) } }