package roadmapsyncpipeline import ( "context" "errors" "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/nomadcode/nomadcode-core/internal/authoring" "github.com/nomadcode/nomadcode-core/internal/roadmapsync" ) // CommitMarker tags a backfill commit as Core-authored so a later self-loop // check can recognize its own commit and treat the redelivery as a hint rather // than a fresh authoring trigger. It is the single source of truth for the // marker string; the gitosync scanner reads it back off the tip commit to set // its self-authored hint. The marker is only a hint: the durable idempotency // guarantee is the identity/step ledger, so a lost or absent marker never // causes a duplicate provider mutation. const CommitMarker = "NomadCode-Identity-Backfill: true" // ErrIdentityBackfillMismatch is returned when the target Milestone file // already carries a provider identity block that disagrees with the identity // being backfilled, so writing over it would silently rebind history. var ErrIdentityBackfillMismatch = errors.New("roadmapsyncpipeline: existing provider identity does not match backfill target") // GitBackfiller is the production Backfiller. It bases every backfill on the // scan-verified remote develop revision, never a stale local checkout: it // fetches the remote branch, injects the provider identity block into the // target Milestone content as read at that revision, stages only that file, // verifies the staged diff is scoped to the identity block, commits with the // Core marker on top of the verified revision, and pushes to the configured // remote/branch. The already-satisfied and mismatch decisions are made against // the pushed remote state, so a retry after a failed scope-check or push never // reports success until the identity block actually landed on the remote // branch. type GitBackfiller struct { RepoPath string RemoteName string Branch string } // NewGitBackfiller builds a GitBackfiller. RepoPath is required; RemoteName // and Branch default to "origin" and "develop" when empty. func NewGitBackfiller(repoPath, remoteName, branch string) (*GitBackfiller, error) { if strings.TrimSpace(repoPath) == "" { return nil, errors.New("roadmapsyncpipeline: backfiller repo path is required") } if strings.TrimSpace(remoteName) == "" { remoteName = "origin" } if strings.TrimSpace(branch) == "" { branch = "develop" } return &GitBackfiller{RepoPath: repoPath, RemoteName: remoteName, Branch: branch}, nil } // BackfillIdentity injects the provider identity block into the target // Milestone as read at the scan-verified remote develop revision, stages only // that path, verifies the staged diff adds nothing but the identity block, // commits with the Core marker on top of the verified revision, and pushes to // develop. It fetches the remote branch first so the already-satisfied and // mismatch decisions and the commit base all use the pushed remote state, and // refuses to touch a dirty checkout or a revision that has moved since the scan. func (b *GitBackfiller) BackfillIdentity(ctx context.Context, in BackfillInput) error { path := strings.TrimSpace(in.MarkdownPath) if path == "" { path = in.Identity.RoadmapMilestonePath } if path == "" { return errors.New("roadmapsyncpipeline: backfill markdown path is required") } remoteRef := b.RemoteName + "/" + b.Branch // Fetch the remote target branch so every decision below observes the pushed // develop state the scanner verified, never a stale local checkout or an // unpushed local commit. if err := b.run(ctx, "fetch", "--prune", b.RemoteName, b.Branch); err != nil { return err } remoteRev, err := b.output(ctx, "rev-parse", remoteRef) if err != nil { return err } remoteRev = strings.TrimSpace(remoteRev) if remoteRev == "" { return fmt.Errorf("roadmapsyncpipeline: empty remote revision for %s", remoteRef) } // If the caller passed the scan-verified revision, the remote ref must still // point at it. Otherwise develop moved since the scan and a fresh event // should re-drive the sync rather than committing on top of a revision the // scanner never checked. if expected := strings.TrimSpace(in.RoadmapRevision); expected != "" && expected != remoteRev { return fmt.Errorf("roadmapsyncpipeline: backfill target revision moved: %s is at %s, verified scan revision was %s", remoteRef, remoteRev, expected) } // Refuse to mutate a dirty checkout: an unrelated in-flight edit must never // ride along in a Core-authored backfill commit, and resetting the base to // the remote revision would otherwise silently discard that work. dirty, err := b.output(ctx, "status", "--porcelain") if err != nil { return err } if strings.TrimSpace(dirty) != "" { return fmt.Errorf("roadmapsyncpipeline: backfill checkout is dirty, refusing to mutate: %s", strings.TrimSpace(dirty)) } // Read the milestone content at the verified remote revision. The // already-satisfied and mismatch decisions are made against the pushed // remote state, so a retry after a failed push never reports success until // the identity block actually landed on the remote branch. remoteContent, err := b.output(ctx, "show", remoteRev+":"+path) if err != nil { return fmt.Errorf("roadmapsyncpipeline: read milestone at %s failed: %w", remoteRev, err) } existing, parseErr := roadmapsync.ParseMilestoneIdentity(path, remoteContent) if parseErr == nil { if identityMatches(existing, in.Identity) { // The identity block already exists on the remote develop branch; // nothing left to commit (e.g. a prior push landed but the ledger // mark failed). return nil } return ErrIdentityBackfillMismatch } if !errors.Is(parseErr, roadmapsync.ErrNoProviderIdentity) { return fmt.Errorf("roadmapsyncpipeline: parse milestone identity failed: %w", parseErr) } // Base the backfill commit on the verified remote revision, not the local // HEAD, so a checkout that is behind origin/develop still commits on top of // the revision the scanner verified. The dirty guard above already ensured // this force-reset discards no uncommitted work. if err := b.run(ctx, "checkout", "-B", b.Branch, remoteRev); err != nil { return err } block := authoring.RenderProviderIdentityBlock(in.Identity) fullPath := filepath.Join(b.RepoPath, path) if err := os.WriteFile(fullPath, []byte(remoteContent+block), 0644); err != nil { return b.cleanup(ctx, remoteRev, fmt.Errorf("roadmapsyncpipeline: write milestone file failed: %w", err)) } if err := b.run(ctx, "add", "--", path); err != nil { return b.cleanup(ctx, remoteRev, err) } if err := b.verifyScopedDiff(ctx, path, block); err != nil { return b.cleanup(ctx, remoteRev, err) } commitMsg := fmt.Sprintf("chore(roadmap): backfill provider identity for %s\n\n%s", in.Identity.MilestoneID(), CommitMarker) if err := b.run(ctx, "commit", "-m", commitMsg); err != nil { return b.cleanup(ctx, remoteRev, err) } if err := b.run(ctx, "push", b.RemoteName, "HEAD:"+b.Branch); err != nil { // Leave the local commit in place and surface the error: the ledger step // is only marked by the caller after this returns nil, so a failed push // never advances the ledger, and a retry re-bases on the freshly fetched // remote revision (checkout -B above) before pushing again. return err } return nil } // cleanup restores the checkout to rev after a pre-push failure so a later // retry re-bases from the remote revision instead of finding a staged or // half-written identity block it might mistake for progress. The original // cause is preserved; a cleanup failure is appended rather than hiding it. func (b *GitBackfiller) cleanup(ctx context.Context, rev string, cause error) error { if err := b.run(ctx, "reset", "--hard", rev); err != nil { return fmt.Errorf("%w; additionally backfill cleanup failed: %v", cause, err) } return cause } // identityMatches reports whether an existing identity block is the same // provider-side identity as the one being backfilled. It compares the full // provider key — provider, tenant, project, work item id — plus the canonical // milestone id, so a block that reuses the same work item under a different // milestone id is treated as a mismatch rather than silently accepted. func identityMatches(a, b roadmapsync.Identity) bool { return a.Provider == b.Provider && a.Tenant == b.Tenant && a.Project == b.Project && a.WorkItemID == b.WorkItemID && a.MilestoneID() == b.MilestoneID() } // verifyScopedDiff ensures the staged change touches only the target path and // that the path's diff is a pure addition matching the injected identity // block, so an unrelated dirty-workspace roadmap edit never rides along in a // Core-authored backfill commit. func (b *GitBackfiller) verifyScopedDiff(ctx context.Context, path, block string) error { staged, err := b.output(ctx, "diff", "--cached", "--name-only") if err != nil { return err } files := nonEmptyLines(staged) if len(files) != 1 || files[0] != path { return fmt.Errorf("roadmapsyncpipeline: backfill commit scope check failed: staged files=%v, want only %q", files, path) } diff, err := b.output(ctx, "diff", "--cached", "--", path) if err != nil { return err } return verifyIdentityOnlyDiff(diff, block) } // verifyIdentityOnlyDiff rejects a staged diff that removes any line or adds // a line outside the rendered identity block. func verifyIdentityOnlyDiff(diff, block string) error { blockLines := strings.Split(strings.TrimRight(block, "\n"), "\n") blockSet := make(map[string]bool, len(blockLines)) for _, l := range blockLines { blockSet[l] = true } for _, line := range strings.Split(diff, "\n") { switch { case strings.HasPrefix(line, "+++") || strings.HasPrefix(line, "---"): continue case strings.HasPrefix(line, "-"): return fmt.Errorf("roadmapsyncpipeline: backfill commit scope check failed: unexpected removed line %q", line) case strings.HasPrefix(line, "+"): added := line[1:] if !blockSet[added] { return fmt.Errorf("roadmapsyncpipeline: backfill commit scope check failed: unexpected added line %q", added) } } } return nil } func (b *GitBackfiller) run(ctx context.Context, args ...string) error { cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = b.RepoPath out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("roadmapsyncpipeline: git %s failed: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(out))) } return nil } func (b *GitBackfiller) output(ctx context.Context, args ...string) (string, error) { cmd := exec.CommandContext(ctx, "git", args...) cmd.Dir = b.RepoPath out, err := cmd.Output() if err != nil { return "", fmt.Errorf("roadmapsyncpipeline: git %s failed: %w", strings.Join(args, " "), err) } return string(out), nil } func nonEmptyLines(s string) []string { var out []string for _, l := range strings.Split(s, "\n") { l = strings.TrimSpace(l) if l != "" { out = append(out, l) } } return out }