// Package gitosync bridges Gito `branch.updated` wakeup events to the Core // Plane-origin Milestone creation sync. It is the layer that turns a wakeup // signal into a verified develop scan and an EnqueueRoadmapCreationSync call: // the scanner re-verifies the target branch revision from a local develop // checkout (never trusting the event's changed_files alone), and the bridge // matches each scanned Milestone to its Plane work item and enqueues a job. package gitosync import ( "context" "errors" "fmt" "strings" "github.com/nomadcode/nomadcode-core/internal/gitoevents" "github.com/nomadcode/nomadcode-core/internal/roadmapsync" "github.com/nomadcode/nomadcode-core/internal/roadmapsyncpipeline" ) // ErrNotReady is returned by the scanner when the local develop checkout's // remote revision does not yet match the event's `after`, so the pushed state // the event announced is not observable locally and nothing should be enqueued. var ErrNotReady = errors.New("gitosync: develop revision not ready") // ErrDuplicateActiveSlug is returned when the scan detects duplicate active // milestones for the same milestone ID (filename slug). var ErrDuplicateActiveSlug = errors.New("gitosync: duplicate active milestone slugs") // CommandRunner runs a git command and returns its stdout. It is the test seam // that keeps the scanner unit-testable without a real git checkout; production // wraps os/exec. trimming of the trailing newline is the caller's job. type CommandRunner interface { Run(ctx context.Context, dir string, name string, args ...string) (string, error) } // ScannerConfig configures a BranchRevisionScanner. DevelopRepoPath is the // local develop checkout the scan runs against; RemoteName is the git remote to // fetch and resolve (default origin); Branch is the develop branch name. type ScannerConfig struct { DevelopRepoPath string RemoteName string Branch string } func (c ScannerConfig) remote() string { if strings.TrimSpace(c.RemoteName) == "" { return "origin" } return c.RemoteName } func (c ScannerConfig) branch() string { if strings.TrimSpace(c.Branch) == "" { return gitoevents.DefaultBranch } return c.Branch } // ScannedMilestoneDoc pairs a scanned Milestone with the Markdown the scanner // read at the target revision, so the bridge can both match the provider // identity and project the develop content without re-reading git. type ScannedMilestoneDoc struct { Path string MilestoneID string Markdown string HasIdentity bool Identity roadmapsync.Identity } // ArchivedMilestoneCandidate records an archive Milestone path that shares the // same filename-slug lookup space but must not be reconciled as an active item. type ArchivedMilestoneCandidate struct { Path string MilestoneID string } // ScanOutput is the verified result of a develop scan: the provider-neutral // ScanResult the develop match gate consumes, the per-path active Milestone // Markdown the bridge projects, and archive Milestone candidates kept only for // future slug lookup/history diagnostics. // // SelfAuthored is a hint only: it is true when the scanned revision's tip commit // carries the Core identity-backfill marker, i.e. the wakeup was NomadCode's own // backfill commit looping back through Gito. It never gates what gets scanned or // enqueued — the full active Milestone index and the identity/step ledger remain // the source of truth — so a self-loop still converges through the ledger's // complete/no-op path even when this hint is absent or wrong. type ScanOutput struct { Result roadmapsync.ScanResult Docs []ScannedMilestoneDoc ArchiveCandidates []ArchivedMilestoneCandidate SelfAuthored bool } // BranchRevisionScanner re-verifies a Gito branch event against the local // develop checkout and produces a verified ScanResult. It never trusts the // event's changed_files as the final set; those are only a wakeup hint. type BranchRevisionScanner struct { runner CommandRunner cfg ScannerConfig } // NewBranchRevisionScanner builds a scanner. The runner and a non-empty develop // repo path are required. func NewBranchRevisionScanner(runner CommandRunner, cfg ScannerConfig) (*BranchRevisionScanner, error) { if runner == nil { return nil, errors.New("gitosync: command runner is nil") } if strings.TrimSpace(cfg.DevelopRepoPath) == "" { return nil, errors.New("gitosync: develop repo path is required") } return &BranchRevisionScanner{runner: runner, cfg: cfg}, nil } // Scan fetches the remote develop branch, verifies its revision matches the // event's `after`, indexes every active Milestone at that revision, and // reads/parses each active Milestone. It returns ErrNotReady when the local // remote revision does not yet match the event so a caller skips the enqueue // instead of acting on stale state. Active Milestone files without a provider // identity stay in Docs as missing-create candidates and remain in ChangedFiles // so the match gate's reason stays accurate. // // ev.ChangedFiles (the event's before..after change list) is deliberately not // read here: it is only a wakeup-relevance hint the caller may use for // logging, and the full active Milestone index from `git ls-tree` at the // verified revision is the sole source of truth for what gets scanned/enqueued. func (s *BranchRevisionScanner) Scan(ctx context.Context, ev gitoevents.BranchUpdatedEvent) (ScanOutput, error) { dir := s.cfg.DevelopRepoPath remote := s.cfg.remote() branch := s.cfg.branch() if _, err := s.runner.Run(ctx, dir, "git", "fetch", "--prune", remote, branch); err != nil { return ScanOutput{}, fmt.Errorf("gitosync: fetch failed: %w", err) } remoteRev, err := s.runner.Run(ctx, dir, "git", "rev-parse", remoteRef(remote, branch)) if err != nil { return ScanOutput{}, fmt.Errorf("gitosync: rev-parse failed: %w", err) } remoteRev = strings.TrimSpace(remoteRev) if remoteRev == "" || remoteRev != strings.TrimSpace(ev.After) { // The local remote-tracking ref has not caught up to the revision the // event announced; acting now would scan stale content. return ScanOutput{}, ErrNotReady } out, err := s.runner.Run(ctx, dir, "git", "ls-tree", "-r", "--name-only", remoteRev, "--", "agent-roadmap/phase", "agent-roadmap/archive") if err != nil { return ScanOutput{}, fmt.Errorf("gitosync: active milestone index failed: %w", err) } changed := milestonePaths(out) archiveCandidates := archiveMilestoneCandidates(out) // Verify that there are no duplicate active milestone slugs. slugToPaths := make(map[string][]string) for _, path := range changed { slug := roadmapsync.MilestoneSlug(path) if slug == "" { continue } slugToPaths[slug] = append(slugToPaths[slug], path) } var dups []string for slug, paths := range slugToPaths { if len(paths) > 1 { dups = append(dups, fmt.Sprintf("%s (%s)", slug, strings.Join(paths, ", "))) } } if len(dups) > 0 { return ScanOutput{}, fmt.Errorf("%w: %s", ErrDuplicateActiveSlug, strings.Join(dups, "; ")) } result := roadmapsync.ScanResult{ Revision: roadmapsync.Revision{ Branch: branch, Revision: remoteRev, }, ChangedFiles: changed, } var docs []ScannedMilestoneDoc for _, path := range changed { markdown, err := s.runner.Run(ctx, dir, "git", "show", ev.After+":"+path) if err != nil { return ScanOutput{}, fmt.Errorf("gitosync: show %q failed: %w", path, err) } identity, err := roadmapsync.ParseMilestoneIdentity(path, markdown) var hasIdentity bool var milestoneID string if err != nil { hasIdentity = false milestoneID = roadmapsync.MilestoneSlug(path) } else { hasIdentity = true milestoneID = identity.MilestoneID() scanned := roadmapsync.ScannedMilestone{Path: path, Identity: identity} result.ScannedMilestones = append(result.ScannedMilestones, scanned) } docs = append(docs, ScannedMilestoneDoc{ Path: path, MilestoneID: milestoneID, Markdown: markdown, HasIdentity: hasIdentity, Identity: identity, }) } // Read the tip commit message once for the self-authored hint. This is // best-effort diagnostics: a failure here must not fail an otherwise valid // scan, so a lookup error leaves the hint false rather than aborting. selfAuthored := false if msg, err := s.runner.Run(ctx, dir, "git", "show", "-s", "--format=%B", remoteRev); err == nil { selfAuthored = strings.Contains(msg, roadmapsyncpipeline.CommitMarker) } return ScanOutput{Result: result, Docs: docs, ArchiveCandidates: archiveCandidates, SelfAuthored: selfAuthored}, nil } func remoteRef(remote, branch string) string { return fmt.Sprintf("refs/remotes/%s/%s", remote, branch) } // milestonePaths keeps only the lines that look like roadmap Milestone // documents, normalizing each to a cleaned repo-relative path. func milestonePaths(diffOutput string) []string { var out []string for _, line := range strings.Split(diffOutput, "\n") { path := strings.TrimSpace(line) if path == "" { continue } if isMilestonePath(path) { out = append(out, path) } } return out } func archiveMilestoneCandidates(treeOutput string) []ArchivedMilestoneCandidate { var out []ArchivedMilestoneCandidate for _, line := range strings.Split(treeOutput, "\n") { path := strings.TrimSpace(line) if path == "" || !isArchiveMilestonePath(path) { continue } id := roadmapsync.MilestoneSlug(path) if id == "" { continue } out = append(out, ArchivedMilestoneCandidate{Path: path, MilestoneID: id}) } return out } // isMilestonePath reports whether a path matches // agent-roadmap/phase//milestones/.md. func isMilestonePath(p string) bool { if !strings.HasSuffix(p, ".md") { return false } parts := strings.Split(strings.TrimPrefix(p, "./"), "/") if len(parts) != 5 { return false } return parts[0] == "agent-roadmap" && parts[1] == "phase" && parts[3] == "milestones" } // isArchiveMilestonePath reports whether a path matches an archived Milestone // snapshot under agent-roadmap/archive/phase//milestones/.md. func isArchiveMilestonePath(p string) bool { if !strings.HasSuffix(p, ".md") { return false } parts := strings.Split(strings.TrimPrefix(p, "./"), "/") if len(parts) != 6 { return false } return parts[0] == "agent-roadmap" && parts[1] == "archive" && parts[2] == "phase" && parts[4] == "milestones" }