Skip to content

Validate, diff, confirm and apply, in that order every time - #66

Merged
cjimti merged 1 commit into
mainfrom
feat/11-diff-apply-pipeline
Aug 7, 2026
Merged

Validate, diff, confirm and apply, in that order every time#66
cjimti merged 1 commit into
mainfrom
feat/11-diff-apply-pipeline

Conversation

@cjimti

@cjimti cjimti commented Aug 7, 2026

Copy link
Copy Markdown
Member

Closes #11. Closes #64.

What this does

Every change to a cluster goes through one pipeline (DESIGN.md §6.1), invoked on a manifest or a directory of them: server dry run → diff → a dialog that restates where it is aimed → apply. Delete takes the same shape, its dry run listing the objects that would go rather than the file that names them.

The preview is the operation minus the dry run, and that is held in code rather than promised in two doc comments. Validate and Apply build their argv through one function, and a test asserts the relationship: strip --dry-run=server from the validation and what remains must equal the apply exactly. A preview that is not the operation is worse than no preview, because it is a preview the user trusts. Same for the delete pair, and for --server-side — a diff computed under different apply semantics is a diff of a different command, so the project's setting reaches every step.

Protection is enforced in internal/app, before any process exists. A protected binding refuses to mutate unless the typed context matches the resolved context exactly — no trimming, no case folding. The resolved one is the point: in the layout scopes exist for, a file under prod/ is protected by a rule three directories up and targets a context the project as a whole does not, so checking the project default would ask for the wrong word and then accept it. The dialog collects the answer; it does not decide.

Five bindings rather than three. KubeValidate is KubeApply under a dry run and KubeDeletePreview is KubeDelete under the same, so each pair could have been one method and a boolean — which would put the difference between previewing a deletion and performing one behind a flag the frontend passes. Two methods is the price of that condition not existing anywhere.

Paths are confined before they leave the process. kubectl opens the file itself, outside the os.Root every other file operation here runs through, so watch.Resolve proves the target is inside the worktree by stat-ing through that same root. The runtime check is the half fs.ValidPath cannot do, and it is what refuses a symlink created after the static one passed.

Run log: per project, in memory, this session. Persistence is v2 and deliberately not started — an audit trail is a file with a format, a retention rule and a promise about its contents, and a log that persisted without those decisions is a record people rely on before anyone has decided what it means.

Also fixes #64 — the blame column. It used to empty itself on the first keystroke and stay empty until a save, on the reasoning that git blame answers in line numbers and one insertion moves every line under it. True about line numbers and only about line numbers: the attribution does not go stale when someone types. Entries are now anchored to positions and moved by CodeMirror's own change mapping, and an entry stands while its line still reads exactly as git measured it. Everywhere else the line is in no commit — the same thing git says about a line it finds in the working tree and nowhere else — and both are marked: uncommitted in the column, and a dark yellow band across the code.

The band does not belong to the column. "Which of these lines have I changed" is asked while looking at the file, and the answer cannot live fourteen characters to the left in a gutter most people keep closed. That decoupled the read from the toggle: a blame is taken for whatever file is open, one subprocess per file, refreshed on save. A file git cannot blame is an ordinary file to edit, so its refusal is only shown once the column is asked for.

DESIGN.md §7 specified the hiding behaviour, so it is amended rather than quietly overruled.

Two deviations, both deliberate

  • The apply's output is captured, not streamed. Diff → apply pipeline #11's scope says "streamed to a run log panel"; a kubeexec.Result arrives whole. Streaming needs a frame type on internal/stream, which is more than this ticket. The full captured output and the argv are shown.
  • kubectl delete outlives the 60s deadline. It waits on finalizers, so deleting a namespace reports as "timed out" — an error meaning no verdict — while the deletion was accepted. Filed as Cluster mutations share a 60s timeout that a delete routinely outlives #65 rather than papered over: it is a decision about what a partially-observed mutation should say, not a number to raise. The reasoning is beside the constant in internal/kubeexec.

How it was verified

make verify green: total coverage 96.4%, patch coverage 96.7% (floor 85%).

Acceptance criteria from #11:

  • Apply a new manifest, modify it, re-run — the diff shows exactly the change, the apply converges, the second run reports "no changes" as a sentence rather than an empty pane. Covered by usePipeline.test.ts and PipelineDialog.test.tsx; diffVerdict keeps exit 0 / 1 / >1 as three answers, because collapsing "there are changes" with "the command failed" puts a failure behind an Apply button.
  • Protected project: apply is impossible without typing the context name, checked exactly. Held on both sides — internal/app/pipeline_test.go runs the refusal table (nothing typed, wrong context, leading and trailing whitespace, wrong case, a prefix) and asserts twice over that no kubectl process was created, via a stub that would leave a marker if one had. pipeline.test.ts runs the same table against the frontend's copy of the rule, so the button can never be enabled on a looser condition than the backend's.
  • A validation failure blocks before any mutation, with stderr shown. The diff is not run either: a diff of manifests the cluster has already rejected reads as progress.
  • Delete dry-run lists the objects before anything is removed.

Acceptance criteria from #64: typing inside a line leaves every other line's entry in place; inserting a line above moves entries down with the text still naming the right commit; deleting a line does not hand its entry to the line that took its place; undo restores the names without a re-read; no commit's name is ever shown against a line whose text differs from what git measured; a file with no blame read shows an empty column rather than a column of uncommitted.

One thing that is not tested, and needs your eyes

The marks were coming apart on screen — the column naming one line and the band another. It was not the marks. Opening the column narrows the code by the width of the column, the code wraps, and every line that wraps at one width and not the other changes height. Nothing notices, which takes two things both being true: gutters are inserted into the scroller beside the content, so adding one narrows the text without resizing the element CodeMirror's ResizeObserver watches — and that observer ignores anything within 75ms of an update anyway, which a reconfiguration is. The gutters kept heights from the old width while the text laid out at the new one, and the two slid apart by a row per wrapped line. showBlame now calls view.requestMeasure(), which is the call CodeMirror's own resize handler makes.

jsdom lays nothing out, so there is no wrapping to remeasure and no test can cover it. What is covered is the invariant underneath: two tests assert the column and the band describe exactly the same lines through an edit, a re-read, a second edit elsewhere, and an insertion that moves everything below it.

Ratchets raised, with the argument in the diff

maxAppMethods 32 → 37, internal/app 900 → 1150 LOC, internal/watch 1250 → 1300 LOC and 22 → 23 exported. internal/app's new bindings share kube.go rather than taking a ninth file, which is what the package's file ceiling asks for and also what they are.

Checklist

  • One issue per PR; acceptance criteria of the linked issue are met — two, on request: Diff → apply pipeline #11 and the blame bug Blame column empties on the first keystroke #64 found while working on it
  • No new dependencies outside the license allowlist (MIT/BSD/Apache-2.0/ISC/0BSD) — no new dependencies
  • External binaries invoked only through the shared exec helper — every pipeline subcommand goes through Binding.argv, with the path inside --filename= so a value beginning with a dash cannot be read as a flag
  • Consistent with DESIGN.md (or the deviation is called out explicitly) — §6.1 implemented, §7 amended for Blame column empties on the first keystroke #64, two deviations called out above

Closes #11. Closes #64.

Every change to a cluster now goes through one pipeline (DESIGN.md
§6.1), invoked on a manifest or on a directory of them: server dry run,
diff, a dialog that restates where it is aimed, then the apply. Delete
takes the same shape, its dry run listing the objects that would go
rather than the file that names them.

The preview is the operation minus the dry run, and that is held in
code rather than promised in two doc comments: Validate and Apply build
their argv through one function, and a test asserts the relationship —
strip --dry-run=server from the validation and what remains must equal
the apply, byte for byte. A preview that is not the operation is worse
than no preview, because it is a preview the user trusts. The same is
true of the delete pair, and of --server-side: a diff computed under
different apply semantics is a diff of a different command, so the
project's setting reaches every step.

Protection is enforced in internal/app, before any process exists. A
protected binding refuses to mutate unless the typed context matches
the RESOLVED context exactly — no trimming, no case folding — and the
resolved one is the point: in the layout scopes exist for, a file under
prod/ is protected by a rule three directories up and targets a context
the project as a whole does not, so checking the project default would
ask for the wrong word and then accept it. The dialog collects the
answer; it does not decide. A guard living only in a dialog is a guard
the next caller gets to forget, and #14 is the next caller.

Five bindings rather than three. KubeValidate is KubeApply under a dry
run and KubeDeletePreview is KubeDelete under the same, so each pair
could have been one method and a boolean — which would put the
difference between previewing a deletion and performing one behind a
flag the frontend passes. Two methods is the price of that condition
not existing anywhere.

Paths are confined before they leave the process. kubectl opens the
file itself, outside the os.Root every other file operation in this
repository runs through, so watch.Resolve proves the target is inside
the worktree by stat-ing through that same root — the runtime check is
the half fs.ValidPath cannot do, and it is what refuses a symlink
created after the static one passed.

Run log. Per project, in memory, this session: what ran, where, and
what it said. Persistence is v2 and deliberately not started here — an
audit trail is a file with a format, a retention rule and a promise
about its contents, and a log that persisted without those decisions is
a record people rely on before anyone has decided what it means.

Known limit, filed rather than papered over: kubectl delete waits on
finalizers, so deleting a namespace outlives the 60s deadline and
reports as "timed out" — an error meaning no verdict — while the
deletion was accepted. That is #65, a decision about what a
partially-observed mutation should say, not a number to raise.

Not streamed. The issue asks for the apply's output streamed to the run
log; a Result arrives whole, and streaming needs a frame type on
internal/stream that is more than this ticket. The captured output and
the argv are shown.

---

The blame column (#64) used to empty itself on the first keystroke and
stay empty until a save, on the reasoning that git blame answers in
line numbers and one insertion moves every line under it. That is true
about line numbers and only about line numbers. The attribution does
not go stale when someone types: the line they edited is theirs now,
and the other four hundred are still whoever's they were.

So entries are anchored to positions and moved by CodeMirror's own
change mapping — the machinery that keeps a selection where it was left
— and an entry stands while its line still reads exactly as git
measured it. Everywhere else the line is in no commit, which is the
same thing git says about a line it finds in the working tree and
nowhere else, and both are marked: `uncommitted` in the column, and a
dark yellow band across the code.

The band is the mark that gets read, so it does not belong to the
column. "Which of these lines have I changed" is asked while looking at
the file, and the answer cannot live fourteen characters to the left in
a gutter most people keep closed. That decoupled the read from the
toggle: a blame is now taken for whatever file is open, one subprocess
per file, refreshed on save. A file git cannot blame is an ordinary
file to edit, so its refusal is only shown once the column is asked
for.

Both marks are built from one walk of the document into two range sets,
positioned by the document and diffed by the same machinery. Nothing in
the module reads the layout. The column had been built by CodeMirror's
per-line callback, which hands you a laid-out block and makes you find
the line yourself, with its own hand-written refresh predicate beside
the band's — two paths over one rule, and a lookup whose two inputs
have to be in step.

They came apart anyway, and not there. Opening the column narrows the
code by the width of the column; the code wraps; every line that wraps
at one width and not the other changes height. Nothing notices, and it
takes two things being true: gutters are inserted into the scroller
beside the content, so adding one narrows the text without resizing the
element the ResizeObserver watches — and that observer ignores anything
within 75ms of an update anyway, which a reconfiguration is. The
gutters kept heights from the old width while the text laid out at the
new one and the two slid apart by a row per wrapped line, which reads
as marks against the wrong lines and corrects itself on the scroll that
finally forces a measure. showBlame now asks for that measure, which is
the call CodeMirror's own resize handler makes.

DESIGN.md §7 specified the hiding behaviour, so it is amended rather
than quietly overruled.

---

Ratchets, with the argument in the diff. maxAppMethods 32 -> 37,
internal/app 900 -> 1150 LOC, internal/watch 1250 -> 1300 and 22 -> 23
exported. internal/app's new bindings share kube.go rather than taking
a ninth file, which is what the package's file ceiling asks for and
also what they are.
@codecov

codecov Bot commented Aug 7, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.83333% with 6 lines in your changes missing coverage. Please review.
✅ Project coverage is 94.72%. Comparing base (f03934a) to head (ebd18c6).

Files with missing lines Patch % Lines
internal/app/kube.go 94.80% 2 Missing and 2 partials ⚠️
internal/watch/fs.go 83.33% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #66      +/-   ##
==========================================
+ Coverage   94.64%   94.72%   +0.07%     
==========================================
  Files          41       42       +1     
  Lines        2037     2179     +142     
==========================================
+ Hits         1928     2064     +136     
- Misses         68       71       +3     
- Partials       41       44       +3     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@cjimti
cjimti merged commit 29c7f27 into main Aug 7, 2026
11 checks passed
@cjimti
cjimti deleted the feat/11-diff-apply-pipeline branch August 7, 2026 23:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Blame column empties on the first keystroke Diff → apply pipeline

1 participant