0037. PR mode's file-finding and byte-preserving YAML edit, as pkg/prmode

Status: Superseded by ADR 0101 · Date: 2026-10-26 · Design section: GitOps and Argo CD (phase 8, "PR mode")

Decided: built pkg/prmode's ClassifySource/Locate/Patch/Diff -- finding a tracked CR's file via its Application's source and editing one field with go-yaml's yaml.Node, never a PR host API -- as its own host-independent, fully unit-testable unit, separate from opening a PR

Context

architecture.md's phase-8 "PR mode" paragraph does three things in one breath: find the file, edit it preserving formatting, and open a PR whose description carries an impact preview. The first two are pure, local, host-independent operations with no external dependency once a checked-out repository exists; the third needs a GitHub/GitLab/Gitea-Forgejo API call, a network connection, and an impact-preview backend another agent is building concurrently. Mixing them would drag the exhaustively-testable half (formatting preservation under every YAML shape a real manifest uses) into the half that cannot be tested without a live host. This unit is only the first two.

Decision: pkg/prmode, not internal/cli

Both the CLI and the UI need this (architecture.md: "the UI and CLI don't write it"), so it is a pkg/ package -- the same reasoning pkg/gitops and pkg/upgrade already follow -- not something built inside internal/cli that the UI backend could not import.

Decision: yaml.Node round-tripping, not unmarshal-to-struct-then-marshal

The task's own constraint is correct and was verified, not assumed: a plain unmarshal into a Go struct and marshal back loses comments outright, reorders keys to the struct's field order, and normalizes quote style. go.yaml.in/yaml/v4 (already this project's own YAML dependency -- internal/cli/bundlefile.go, pkg/gitops -- a fork-successor of gopkg.in/yaml.v3 with the same yaml.Node API) decodes a document into a node tree whose comments, key order, anchors/aliases, quote style and block/flow list style survive a decode-mutate-encode round trip, provided the encoder's indent width is set to match the document's own -- verified empirically before writing any package code: yaml.NewEncoder(...).SetIndent(n) defaults to a width that does not match every input, and the first version tried (no SetIndent call) silently widened a 2-space document to the library's own default. detectIndent (pkg/prmode/patch.go) measures the document's own first indented line rather than assuming one width.

One limitation surfaced by the same verification and is not worked around: a blank line with no comment attached to it, between two sibling keys inside the document actually being edited, is dropped by go-yaml's own Node round trip (blank lines are only preserved when attached to a comment). TestPatch_BlankLineBetweenKeysIsDropped (pkg/prmode/patch_test.go) records this as a known, intentional fact rather than leaving it to be rediscovered as a bug later. It does not affect any document Patch does not touch, nor any comment-attached blank line, nor any blank line in a document it does touch that is not between two keys both re-encoded.

Decision: only the matched document is ever re-rendered

A file can hold more than one ----separated document. splitDocuments (pkg/prmode/yamldoc.go) partitions a file into raw byte segments on a column-0 --- line -- safe without a full YAML lexer, because block scalar content is always indented past the key that introduces it, so a column-0 --- can never be anything but a real document-stream marker. (shortcut: this does not hold inside an open flow collection spanning multiple lines, a shape real Kubernetes manifests do not use.) Patch re-encodes only the one segment whose apiVersion/kind/metadata.name/metadata.namespace match Target; every other segment's bytes, and every separator line, are copied into the result unchanged -- not reused by convention, but never decoded at all. This is also what makes "only the right document changes when a file holds more than one CR" true by construction, not by a careful encoder setting that might regress.

Decision: editing is single-field, mapping-keys-only

FieldUpdate.Path is a sequence of mapping keys; setNode has no list-index support. architecture.md's "edit the right node" never asks for list-indexed paths, and every field PR mode might plausibly edit today (TalosCluster.spec.* and its siblings) is nested maps. Adding index support speculatively is left as a documented shortcut in setNode's own comment, not built.

Decision: Helm/Jsonnet classification reads raw unstructured fields, not pkg/gitops.ApplicationSource alone

pkg/gitops.ApplicationSource (built for the write side, Ichor's own skeleton) has no plugin field at all, because Ichor's own Applications never use a Config Management Plugin. Decoding an externally-defined, possibly-Jsonnet-via-CMP Application straight into that struct would make a plugin source indistinguishable from a plain path source -- both would show a non-empty Path and an empty Chart. ClassifySource (pkg/prmode/source.go) checks for spec.source.plugin's presence directly on the unstructured object before ever decoding into ApplicationSource, so a plugin source is caught as SourcePlugin (diff-only) rather than misclassified as editable. spec.sources (plural, multi-source Applications) is also read directly and treated as unsupported outright: architecture.md's "find the file via the Application's source" describes one source, and guessing which of several holds a given CR risks editing the wrong repository.

Decision: Diff is a two-string unified diff, nothing more

For SourceHelm/SourcePlugin, architecture.md asks for "a diff to apply by hand" -- but there is no single file to diff against for a templated or plugin-rendered CR, only its current and about-to-be-applied YAML renderings, which the caller already has (from the cluster, not from a file this package would have to locate). Diff (pkg/prmode/diff.go) is therefore a thin wrapper over github.com/pmezard/go-difflib (already an indirect dependency; promoted to direct here rather than hand-rolling a diff algorithm) taking two YAML strings and a display name, with no filesystem or source-resolution logic of its own.

Consequences

  • Not built here: opening a PR against any host (GitHub, GitLab, Gitea/Forgejo) -- a later unit's interface, deliberately not designed yet, per architecture.md's own phase-8 framing and this unit's own instructions. The UI following a PR through merge/sync/apply, the impact preview text in a PR's description, strict mode, and override are likewise untouched.
  • Locate takes an already-checked-out directory; cloning or fetching a repository from ApplicationSource.RepoURL/TargetRevision is the host-specific unit's job, not this package's.
  • internal/cli/gitops_tracking.go's existing isTracked (Argo CD via argocd-cm, Flux via the flux-client-side-apply managed-fields signal) is reused as-is for "is this CR tracked" -- this unit adds no second tracking check. Resolving which Application tracks a CR (so its spec.source can be read) for the Flux case is not built: Flux has no single Application-shaped object with a .spec.source the way Argo CD does (ownership lives in a Kustomization's .status.inventory, pointing at a separate GitRepository), and architecture.md's own "find the file via the Application's source" wording is Argo CD vocabulary. ClassifySource takes an Argo CD Application directly; wiring "CR -> owning Application" (via the tracking annotation/label's own value) and the Flux equivalent are both left to the caller/a later unit.

Search the docs

Search pages and headings