Skip to content

feat(apply): add --since/--force for git-history-based deletion sync - #253

Merged
nicolastakashi merged 42 commits into
mainfrom
feat/sync-action
Aug 27, 2026
Merged

feat(apply): add --since/--force for git-history-based deletion sync#253
nicolastakashi merged 42 commits into
mainfrom
feat/sync-action

Conversation

@mmanciop

@mmanciop mmanciop commented Aug 17, 2026

Copy link
Copy Markdown
Member

Summary

  • Adds apply --since <ref> and apply --force, both experimental: deletes assets whose definition existed at <ref> (via internal/git, a thin os/exec wrapper over git plumbing) but is no longer present in -f's current contents, matched by identifier (id/origin), never by file path — including PrometheusRule's CRD-shared-identifier vs. per-alert-name distinction.
  • --dry-run --since previews the deletion plan alongside the existing create/update preview, merged into one per-file listing sorted by identifier; a deleted asset's display name is resolved from git history rather than left as a bare id.
  • Adds agent-mode JSON output for --dry-run: [{"path": ..., "changes": [{"op": "apply"|"delete", "name": ..., "originOrId": ...}]}].
  • Adds a test/e2e tier (testcontainers-go): the real dash0 binary invoking the real git binary across a process boundary, since --since shells out to git rather than using a Go git library.
  • Adds test/roundtrip/test_apply_since_{roundtrip,idempotency,ref_edge_cases}.sh, run and passing against a real Dash0 dev environment.
  • Documents --since/--force end to end: docs/commands.md, README.md, the Docker/git limitation, the GitHub Actions invocation pattern, the internal/skill agent-skill bundle, and a new "flag-level promotion" section in docs/promoting-commands-to-stable.md (this is the first flag-level experimental gate in the CLI).
  • Includes the OpenSpec proposal/design/specs/tasks for this change (openspec/changes/add-diff-and-since-flag/) and the follow-on asset-synch GitHub Action proposal (not yet implemented).

Both --since and --force remain gated behind --experimental/-X. dash0 diff (a separate preview command planned for this same change) is not part of this PR — see Section 5 of the tasks doc.

Found while testing / writing docs

  • PrometheusRule CRDs with multiple alerts sharing one dash0.com/id silently lose all but the last alert #254: a PrometheusRule CRD with 2+ alerting rules sharing one dash0.com/id never produces more than one live check rule via ordinary create/apply — each alert's PUT overwrites the previous one under the shared id. Predates this branch (root cause is in the sibling dash0-api-client-go's CRD-to-check-rule conversion) but means --since's alerting-rule-partial-removal scenario (1.6/4.7 in the tasks doc) has no real-world-reachable roundtrip coverage — only the mock-server-based unit/integration tests exercise it.
  • A --dry-run --since bug where a -f target that's a subdirectory of the repo (not the repo root) grouped a deletion under its repo-root-relative path while that file's surviving documents grouped under their -f-target-relative path — inconsistent prefixing in the same listing. Fixed, with regression tests.
  • A pre-existing inaccuracy in docs/commands.md's plain apply -f assets/ example: real output prefixes each line with the path relative to -f's own target, not the invoking shell's directory (dashboard.yaml: ..., not assets/dashboard.yaml: ...). Fixed alongside the --since docs.

Known follow-ups (tracked in openspec/changes/add-diff-and-since-flag/tasks.md)

  • 1.9: release the pending dash0-api-client-go change and drop the go.mod replace directive it's currently consumed through — CI will not go green until this lands, since the replace directive points at a local sibling checkout.
  • 4.16: the alerting-rule deletion path resolves a check rule by exact composed-name match with no ambiguity guard.
  • 4.17: an ID-only spam filter's deletion only warns instead of hard-failing, unlike the otherwise-identical no-identifier case.
  • PrometheusRule CRDs with multiple alerts sharing one dash0.com/id silently lose all but the last alert #254 (see above).
  • dash0 diff itself (Section 5) — not part of this PR.

Test plan

  • make build
  • make test (unit + integration)
  • make lint
  • make skill-validate
  • Manual dry-run smoke tests across 5 scenarios (whole-file deletion, multi-document partial deletion, PrometheusRule alert partial removal, non-ancestor ref, all-zeros sentinel) — text and agent-mode JSON output
  • test_apply_since_roundtrip.sh, test_apply_since_idempotency.sh, test_apply_since_ref_edge_cases.sh — run individually against a real Dash0 dev environment, all pass, all cleaned up
  • make test-e2e — not run in this session (requires Docker)
  • Full make test-roundtrip (every asset type, not just --since's new scripts) — not run in this session

Comment thread internal/apply/since.go
mmanciop added a commit that referenced this pull request Aug 25, 2026
Comment thread go.mod Outdated
Comment thread internal/apply/apply.go
Comment thread internal/apply/dryrun.go
Comment thread internal/apply/dryrun.go Outdated
Comment thread internal/apply/dryrun.go
Comment thread internal/git/plumbing.go Outdated
Comment thread internal/git/snapshot.go Outdated
Comment thread internal/git/snapshot.go
Comment thread internal/git/snapshot.go Outdated
Comment thread test/e2e/setup_test.go Outdated
KindDisplayName had no case for the normalized kind "team", so any
caller relying on it for a team asset printed the raw lowercase "team"
instead of a proper display name -- inconsistent with every other kind,
whose display name is a distinct, human-readable string.
…mmand

RequireExperimentalFlag lets a stable command keep one flag behind
--experimental/-X, for cases where the flag itself needs more time to
prove out before the rest of the command's behavior is committed to.
FindNonHiddenYAMLFiles centralizes the hidden-file/YAML-extension
filtering apply's directory scan already did, as a fs.WalkDirFunc
callers pass to their own filepath.WalkDir call. Kept as a
walk-callback rather than a self-contained walker so the WalkDir
invocation stays visible at each call site.
Adds internal/git, a thin wrapper over git plumbing (rev-parse,
merge-base, cat-file, ls-tree) that classifies a --since ref (empty,
all-zeros sentinel, resolved ancestor, resolved non-ancestor,
unresolvable) and diffs asset identifiers between that ref and the
current -f contents to compute a deletion plan -- by identity
(id/origin), never by file path, including PrometheusRule's
CRD-shared-identifier / per-alert-name distinction.

apply --since wires this into the existing create/update flow:
ref-resolution errors, a confirmation prompt for a non-ancestor ref
(bypassable with the new --force), per-asset deletion confirmation,
and a non-zero exit when a deletion is declined.

Both are gated behind --experimental/-X for now (--since via the new
RequireExperimentalFlag), pending real-world testing before promotion.

Includes the openspec proposal/design/specs/tasks for this change and
the follow-on asset-synch GitHub Action, and a go.mod replace pointing
at a local dash0-api-client-go checkout for the identifier-extraction
helpers (ExtractIdentifier/ExtractPrometheusAlertNames) this depends
on, pending their release.
… e2e harness

Replaces the originally-planned zipped-repo fixture design with a
declarative GitRepoFixture YAML format (internal/testutil/gitscenario.go)
describing commit history directly -- readable and diffable in review,
with no separate generation step. Validated against a new JSON Schema
(git_repo_fixture.schema.json) in gitscenario_test.go.

Adds the testcontainers-go-based end-to-end tier (test/e2e) that runs the
real dash0 binary against a real git binary inside a container, the one
gap unit and integration tests can't cover for --since, which shells out
to git rather than using a Go git library. Wired into a new `make
test-e2e` target and CI job.

Renames internal/testutil/fixtures/apply's readme.txt placeholders to
.gitkeep with explanatory comments, matching the project's convention.
Adds four checklist items for gaps a ce-doc-review pass found in the
--since implementation: releasing the pending dash0-api-client-go
dependency and dropping the go.mod replace directive (1.9), the
check-rule name-collision risk in the alerting-rule deletion lookup
(4.16), hardening the ID-only spam-filter deletion path to fail
instead of warn (4.17), and documenting dash0 diff's exit-code
CI-consumption pattern once that command exists (8.8). Tracking only;
no code changes.
Merges --dry-run's validation preview with --since's deletion preview
into one per-file listing (previously two separately-headed sections
that repeated a file's path when it had both a surviving and a removed
document), sorted by id/origin within each file. Every line now reads
"Apply|Delete <Kind> "<name>" (<id>)", including for a deleted asset --
its name is resolved by re-reading the asset's content from git
history at --since's ref (internal/git's ReadFileAtRef), falling back
to a "<name>" placeholder only if that lookup fails. Extends the same
name lookup to the real (non-dry-run) per-asset deletion confirmation
prompt and success/decline messages.

Adds agent-mode JSON output for --dry-run: an array of
{path, changes: [{op, name, originOrId}]}, covering the plain,
--since-merged, and single-file-target cases uniformly. Factors the
row-building/grouping logic previously duplicated between apply.go and
since.go into a shared buildDryRunRows so text and JSON rendering
cannot drift from each other.
…ironment

Adds three roundtrip scripts run against a real Dash0 environment, closing
the one test tier --since previously only had mock-server/e2e-container
coverage for: whole-file and multi-document partial deletion, apply
--since idempotency (second run against the new baseline reports no
changes), and the all-zeros-sentinel / non-ancestor-ref edge cases
(including the --force confirmation bypass). Registers all three in
run_all.sh's API_TESTS list.

Deliberately does not cover PrometheusRule alerting-rule partial removal:
verified against the real API that a CRD with 2+ alerts sharing one
dash0.com/id never produces more than one live check rule via create/apply
(each alert's PUT overwrites the previous one under the shared id) --
filed as #254, since it's a pre-existing bug in the sibling
dash0-api-client-go's CRD conversion, not something --since introduced,
but it does mean that scenario has no real-world-reachable coverage
beyond the existing mock-server-based tests.
…bdirectory -f target

gitutil.Deletion.Path (from git ls-tree) is always repo-root-relative,
while assetDocument.filePath is always relative to the -f target itself.
When -f points at a subdirectory of the repo rather than the repo root,
these two bases diverge: a deletion candidate's path (e.g.
"dashboards/removed.yaml") no longer matches its file's surviving
documents' path ("keep.yaml"), so the merged --dry-run listing grouped
them under different, inconsistently-prefixed entries instead of one
entry per file.

Threads the --since target's scope (already computed in
computeDeletionPlan for the git-side pathspec) through to the dry-run
renderer, stripping it from each deletion path before grouping so both
sides use the same basis. Found while writing documentation examples for
apply --since and noticing the discrepancy against real output.
apply --since/--force shipped in this branch with no documentation at
all beyond an incidental spam-filter note. Adds:

- docs/commands.md: --since/--force in the apply flags table and usage
  line, a dedicated `apply --since` (experimental) subsection covering
  identity/deletion semantics, the merged --dry-run preview (including
  its agent-mode JSON shape), per-asset confirmation and --force, the
  non-zero exit on a declined deletion, the ref-resolution error
  messages (empty/all-zeros/unresolvable), the non-ancestor warning,
  the no-identifier hard-fail, PrometheusRule alerting-rule deletion,
  the git/Docker requirement, and a GitHub Actions invocation example;
  plus a "Common workflows" entry and a taxonomy-intro mention.
- README.md: a --since example in "Applying assets", cross-referencing
  the full reference.
- docs/installation.md + README.md: a note that the Docker image (built
  FROM scratch) has no git, so --since is unavailable from it.
- docs/promoting-commands-to-stable.md: a new "Flag-level promotion"
  section (apply --since is the first flag-level experimental gate in
  this CLI, so the existing whole-command guide didn't cover it).
- internal/skill/gen bundle regenerated (make skill-bundle) to pick up
  the docs/commands.md changes; SKILL.md's apply summary and workflow
  list hand-updated to match.

Also fixes a pre-existing inaccuracy noticed while writing these
examples: apply's per-file output prefixes each line with the file's
path relative to -f's own target, not the invoking shell's directory,
so `dash0 apply -f assets/` prints "dashboard.yaml: ...", not
"assets/dashboard.yaml: ...".

Marks tasks.md's Section 8 items done for the --since/--force scope;
the diff-specific portions of 8.1/8.2/8.3/8.6 and all of 8.8 remain
open until dash0 diff (Section 5) exists.
…leted

apply --since failed outright once every asset definition under -f's
target was removed: an empty (but surviving) directory hit
readDirectory's "no .yaml or .yml files found" error, and a fully
removed target directory failed even earlier at os.Stat, both before
computeDeletionPlan ever ran. Both cases are now treated as a
legitimate all-deletions run, reporting every asset found at --since's
ref as a deletion instead of erroring.

Also adds regression coverage (and a shared git-scenario fixture) for
renaming a subdirectory within the scanned scope, which must remain a
no-op since deletion detection is by identifier, never by path -- plus
the counterpart case where the -f target itself is renamed and left
pointed at the old path, which is correctly reported as a deletion.
…ule CRD

--since decided which of the check-rule/recording-rule endpoints to
call by trusting the CRD's content at --since's own ref -- a single
point in time, not a history of everything the identifier has ever
used. A CRD that had its record entry dropped (keeping the alert) in
an earlier commit, then had the whole file deleted later, showed
--since a ref where the file only ever had an alert: the recording
rule created back when the file was still mixed was silently orphaned
and permanently unrecoverable, since no later git state can prove it
ever existed once the file is gone.

Both endpoints are now always attempted, tolerating a 404 from
whichever the CRD never used, the same idempotent-delete pattern every
other kind already relies on. This reopens a narrow, previously-
guarded-against edge case -- an unrelated check rule and recording
rule that happen to share the same identifier by coincidence would
both be deleted together -- accepted as a tradeoff against silently
leaving real orphaned assets behind.

Removes the now-unused endpoint-tracking machinery this replaces:
internal/git's PrometheusRuleEndpoints type and
PrometheusRuleEndpointsByIdentifier snapshot field, and
internal/asset.PrometheusRuleEndpoints.
…t record is removed

A PrometheusRule CRD that keeps at least one alerting rule but drops
its last recording rule survives with the same identifier, so --since
never saw it as a whole-CRD deletion -- and applyPrometheusRule simply
stops calling ImportRecordingRule once the CRD has zero records left,
so the recording rule created earlier was never touched at all. Dash0
ended up permanently out of sync with git while --since reported "no
deletions" and exited 0: a false all-clear on a state that no longer
matched.

Recording rules get the same treatment alerting rules already had:
Diff now tracks recording-rule presence per identifier
(PrometheusRecordingRoleByIdentifier, populated via the new
asset.PrometheusRuleHasRecordingRule) and reports a true -> false
transition on a surviving CRD as a "recordingrule"-kind deletion,
dispatched through the same confirm-then-delete path as everything
else in the plan. Unlike alerting rules, this is a coarse
presence/absence signal rather than a per-item diff: Dash0 models a
CRD's recording rules as one server-side resource, not one per
`record:` entry, so there is no per-record identity to track the way
AlertsByName tracks alerts by composed name.

Inverts the existing prometheus-recording-partial-removal fixture and
its unit/integration/e2e tests, whose prior documented behavior
("plain update, not a deletion: there is no per-record identity to
diff") was the bug this fixes -- the "no identity to diff" reasoning
only ever justified skipping a per-record diff, not skipping deletion
of the recording-rule role entirely once it drops to zero.
…out --force

A 404 while --since was deleting a planned asset only counted as
"already deleted" (and the run kept going) when --force was passed --
otherwise it hard-failed the entire run, even though the asset being
gone already IS the state --since is trying to reach. This coupled
two unrelated decisions into one flag: whether to skip the
confirmation prompt, and whether a concurrent deletion (e.g. someone
removing the asset directly in the Dash0 UI between --since's plan
and its own delete call) is tolerable. A standalone `<kind> delete
--force` reasonably ties these together, since it acts on one asset
the caller named by hand; --since reconciles a whole scanned scope,
where one already-gone asset shouldn't fail every other deletion (and
every earlier create/update) in the same run.

deleteAssetByKindAndIdentifier, deletePrometheusRuleCRD, and
deleteCheckRuleByName no longer take a force parameter for this
purpose: a 404 (or, for deleteCheckRuleByName, not finding the check
rule by name at all) is now always treated as already-deleted,
independent of --force. --force keeps its existing, separate job in
applyDeletions of skipping the confirmation prompt itself.
--since's non-ancestor-ref warning (printed when the ref resolves but
isn't an ancestor of HEAD, e.g. after a force-push) could only be
accepted by passing --force -- which also silently skips every
per-asset deletion confirmation. These are two separate decisions:
accepting a doubtful ref is not the same as wanting the whole run
unattended. A CI job pointed at the wrong ref by --force alone had no
way to ask a human before deleting anything once past that warning.

--accept-non-ancestor-ref answers only the ref-acceptance question,
leaving every per-asset deletion prompt in place. --force keeps its
existing behavior unchanged (it still implies both, for backward
compatibility) since it's the documented way to run --since fully
unattended in CI/CD, where there's no terminal to answer any prompt
either way.
deleteAssetByKindAndIdentifier, deletePrometheusRuleCRD, and
deleteCheckRuleByName returned a plain error, so applyDeletions had no
way to tell "genuinely deleted just now" apart from "already gone,
tolerated" -- it printed "<kind> <name> deleted" unconditionally after
every successful call, even when the call itself had only printed
"<kind> <name> was already deleted" moments earlier. The two lines
contradicted each other and claimed a deletion that never happened,
which a CI log or audit trail would take at face value.

All three functions now return (alreadyDeleted bool, err error);
applyDeletions skips the "deleted" print when alreadyDeleted is true,
since IsAlreadyDeleted already printed its own line for that case.
…PrometheusRule CRDs

A PrometheusRule CRD's dash0.com/id label names the CRD, not any one
alert -- but ParseAsPrometheusAlertRules stamps that same label onto
every alerting rule's converted check rule identically. Since a
non-empty id always upserts via PUT (create-or-*replace*), a CRD with
two or more alerts silently overwrote its own check rules on every
apply: only the last alert in document order ended up with a real
check rule server-side, while the CLI reported success for all of
them. --since's per-alert deletion (AlertsByName, which resolves a
removed alert to a check rule by its exact composed name) could then
delete the one physical resource backing whichever alert currently
matched that name -- which, in the common case, was the resource the
CRD's *surviving* alert also depended on.

composePrometheusRuleNames now derives a distinct id per alert when a
CRD has more than one -- the CRD's own label plus a slug of the
alert's composed name -- so each alert gets its own upsert target. A
single-alert CRD is unaffected: its one check rule keeps the label
verbatim, exactly as before. The derivation is stable across repeated
applies of unchanged content and across reordering the CRD's rules,
preserving the upsert idempotency a single-alert CRD already had.

Root cause lives in dash0-api-client-go's ParseAsPrometheusAlertRules
(a separate module); this works around it entirely within dash0-cli,
since deriving the id after the fact needs no changes to that library
or to --since's own name-based deletion logic, which already resolves
by exact composed name rather than by id.

Documents the migration implication in docs/commands.md: re-applying
an existing multi-alert CRD under this fix leaves an orphaned
duplicate at the CRD's literal dash0.com/id (whichever alert applied
last under the old behavior), since nothing targets that literal id
directly anymore -- delete it by hand once the new per-alert check
rules look correct.
mmanciop and others added 22 commits August 27, 2026 13:39
…n JSON

Agent mode's --dry-run JSON gave an approving agent strictly less
context than the text output it mirrors: the text renderer's
`Delete View "Team Logs" (team-logs)` names the asset's kind, but the
JSON change entry carried only {op, name, originOrId} -- no kind, and
no mention of which --since ref determined the deletion. An agent
deciding whether to approve a deletion needs to know what kind of
asset is about to be removed (a view is a very different risk than a
production dashboard) at least as much as a human glancing at the
text output already does.

Adds `kind` (the asset's display kind, matching the text renderer)
to every change, and `since` (the --since ref) to delete-op changes
only, since an apply-op row comes from -f's current contents, not
from any ref.
…y refs

Two --since error messages were misleading or hard to read:

- Running --since against a directory that was never a git
  repository produced three layers of wrapped errors: "--since '<ref>'
  requires <dir> to be inside a git repository: failed to determine
  repository root for <dir>: git rev-parse --show-toplevel: exit
  status 128 (stderr: fatal: not a git repository (or any of the
  parent directories): .git)". gitutil.IsNotAGitRepository recognizes
  git's own stable "not a git repository" wording and collapses this
  into one direct sentence for that specific, common case.

- A ref written as "<base>~N"/"<base>^N" that fails to resolve only
  because its own history has fewer than N+1 commits -- --since
  HEAD~1 against a fresh, single-commit repository, the first thing
  many people try when setting up a --since test or demo repo -- was
  told to check for a typo or a too-shallow clone. Neither applies:
  there is simply no earlier commit yet. Repo.ExplainUnresolvableRef
  recognizes this specific shape (best-effort; it doesn't attempt to
  parse the rest of git's revision syntax) and names the real reason
  instead. Any other unresolvable ref still gets the generic message,
  since git's own error text doesn't distinguish a typo from a
  too-shallow clone on its own.
…endering correctly

git -C <path-to-a-file> fails outright ("Not a directory"), so a single-file
--since target must be rehomed to its parent directory, not to the file
itself -- computeDeletionPlan had this inverted, breaking --since against
any single-file -f target. Also teach runApply to recover whether a target
that no longer exists on disk at all used to be a file or a directory (via
a new Repo.IsTreeAtRef helper reading git history at the --since ref),
instead of always guessing "directory" once the target is gone, which
misrendered a vanished single-file target's dry-run output as a multi-file
directory scan.
…tion and its use in --since deletion

A CRD with two or more alerting rules derives a distinct check-rule id per
alert (asset.DeriveAlertCheckRuleID) instead of sharing the CRD's literal
dash0.com/id, to avoid every alert upserting to the same check rule. Two
bugs followed from that:

- Nothing validated that two alerts' composed names could still collide
  once folded through slugify (e.g. "High CPU" and "High_CPU" both become
  "high-cpu"), silently reopening the exact overwrite the derivation exists
  to prevent. Both composePrometheusRuleNames (the actual dispatch path)
  and apply's own pre-flight validation now reject such a CRD before any
  API call.
- --since's whole-CRD deletion path only ever targeted the CRD's literal
  identifier, which is never where a multi-alert CRD's real check rules
  live once derivation kicks in -- so deleting a removed multi-alert CRD
  silently left every one of its check rules orphaned. git.Deletion now
  carries the CRD's alerting-rule list from the "before" snapshot, and
  deletePrometheusRuleCRD attempts each alert's own derived id individually
  when there is more than one.
…arest existing ancestor

nearestExistingAncestor used os.Lstat, so a dangling symlink at -f's own
target path counted as "existing" and computeDeletionPlan tried to resolve
it as a real git-trackable path and failed. The two sibling existence
checks for the same target -- runApply's os.Stat(flags.File) and
BuildSnapshotFromDisk's os.Stat(scope) -- both follow symlinks and already
treat a dangling symlink as gone, routing to the all-deletions-tolerant
path. Switching to os.Stat here makes all three checks agree.
…since's before snapshot

resolveDeletionNames re-read every deletion candidate's file via a fresh
git cat-file call to resolve its display name for --dry-run and confirmation
prompts, even though BuildSnapshotFromRef already read that exact content
moments earlier while building the "before" snapshot. Snapshot now retains
that content (RawContent, populated only on the ref side, where every
deletion candidate's content comes from), so resolveDeletionNames looks it
up from memory instead of shelling out to git a second time per file.
The replace directive pointed at a sibling checkout, so CI could not build.
The two symbols the sibling supplied, ExtractIdentifier and
PrometheusAlertName, exist only as local shims and are not in any released
version of the module, so bumping alone does not resolve it. Both are
CLI-specific -- ExtractIdentifier encodes this CLI's own asset identifier
table, and PrometheusAlertName.CheckRuleName composes the CLI's display name
-- so they now live in internal/asset.

go.mod requires the released v1.21.1.
buildDryRunRows's dp.plan.AlertsByName loop had no test at any tier. Add a
--dry-run analogue of TestApply_Since_PrometheusRuleAlertPartialDeletion in
both renderers: the text one asserts both rows group under the surviving
CRD's own file and that the delete line carries the detail suffix naming
that CRD, and the agent-mode one pins the JSON change entry.

Both fail when the loop is disabled.
renderDryRunText and renderDryRunJSON each carried a verbatim copy of the
flatten-and-sort the single-file -f target needs. Pull it into
flattenSorted and call it from both.

Behavior is unchanged.
--since renders deletions in the same listing as creates and updates, so
plain --dry-run changed shape too: row markers carry the operation instead
of an ordinal, rows sort by identifier rather than file position, and agent
mode emits the JSON array instead of text.
Every user-actionable --since error buried its guidance in a parenthetical,
so agent mode emitted it as one opaque "error" string with no "hint" field.
Move the guidance behind the "\nHint:" marker agentmode.PrintJSONError
splits on, matching internal/login/logout.go.

Covers all six: -f - (stdin), not-a-git-repository, the empty ref, the
all-zeros SHA, both unresolvable-ref cases, and the no-identifier failure.
Updates the docs' error examples and regenerates the skill bundle.
A whole-CRD deletion dispatched to the check-rule endpoint by identifier
alone, dropping the (kind, identifier) keying the plan uses everywhere else.
Two documents of different kinds sharing an identifier string are two plan
entries but one DELETE URL, so removing a PrometheusRule labelled
dash0.com/id: X deleted the check rule a surviving `kind: CheckRule` with
`id: X` had just been created from, in the same run.

computeDeletionPlan now records every check-rule id -f's current contents
still declare, and dispatch skips those with a warning instead. This
tolerates an orphaned check rule on an identifier collision: a leftover is
one manual delete away, a deleted asset the user declared is not. The guard
covers both directions, including a removed CheckRule whose id a surviving
single-alert CRD's check rule lives at.

Also fixes the mirror-image under-deletion: a multi-alert CRD's deletion
only attempted the derived ids, so a CRD applied before per-alert derivation
existed 404'd on all of them, reported "already deleted", exited 0, and
stranded its real check rule at the literal id forever. That id is now
attempted alongside the derived ones, which the per-id 404 tolerance already
makes free.
Removing one alert from a surviving PrometheusRule CRD is the one --since
deletion with no id to dispatch on: the CRD's identifier is shared by all
its alerts, so the check rule is resolved by its composed name instead. That
lookup listed the whole dataset and took the first exact name match, with no
ambiguity guard and no ownership check -- escaping both bounds every other
--since deletion has, so a same-named check rule managed by the UI, by
Terraform, or by the Kubernetes operator was deletable.

Build a name index once per run instead, and refuse to guess: one deletable
match is deleted, two error out naming both ids, and a match owned by
another system of record is skipped with a warning. The owner set is a
denylist because the CLI strips dash0.com/origin before sending, so its own
check rules read back as "api" or unset, and CrdSource's contract treats an
unknown value as "api" too -- an allowlist would refuse to delete the CLI's
own rules.

Building the index once also drops the listing count from one per removed
alert to one per run.
An alert's check-rule id depends on how many siblings it has: the CRD's
literal dash0.com/id for one alert, a derived id per alert for two or more.
An edit moving a CRD across that line therefore relocates alerts it never
touched, and apply only ever writes the new address. Dropping from two
alerts to one left the survivor live at both its old derived id and the
literal one; adding a second alert orphaned the rule at the literal id.
Either way one edit produced a duplicate and an orphan, and the resulting
pair of same-named check rules would later fail the by-name lookup as
ambiguous.

Diff now compares where a CRD's alerts lived at the ref against where they
live now, and deletes any address a survivor moved off. Comparing the two
address sets rather than testing the threshold directly keeps the
one-versus-many rule in a single place and covers the cases the threshold
test got wrong on its own, such as a recording-rule-only CRD gaining alerts.

That rule moves to asset.CheckRuleIDsOccupiedByCRD so internal/git and
internal/apply share one copy instead of each restating it.
git C-quotes any path holding a non-ASCII byte unless core.quotePath is
turned off ("a/caf\303\251.yaml"). filepath.Ext on that returns `.yaml"`,
so IsYAMLFile said no and ListYAMLFilesAtRef skipped the entry: the file
never entered the --since "before" snapshot and its deletion was never
detected, silently, with exit 0. Every existing test passed because the
fixtures and testrepo helper use ASCII names only.

Pass -z and split on NUL. That drops quoting entirely rather than relying
on a config setting, and also survives a path containing a newline, which
core.quotePath=false would not. The per-line TrimSpace goes with it: NUL
output has no padding to trim, so its only remaining effect would be to
corrupt a name that legitimately starts or ends with a space.
The two snapshots come from different sources: the ref side runs
`git ls-tree -r`, which enumerates the whole commit, while the disk side
walks only what is materialized. A sparse checkout (or a manual
skip-worktree bit) makes those disagree, so every tracked-but-absent asset
became a deletion candidate and --force deleted assets git still declares,
silently and with exit 0.

Detect the skip-worktree bit and refuse, naming sparse checkout as the
reason. Detection is per scope, not repo-wide, so a monorepo whose sparse
cone lies outside -f's target is unaffected; and it keys on the bit itself
rather than core.sparseCheckout, which covers a manual
`git update-index --skip-worktree` too.

Failing closed rather than intersecting the plan with `git ls-files`: quiet
narrowing on a path that deletes things is the wrong default, and the
intersect leaves creates wrong anyway, since assets outside the cone are
never applied either.
BuildSnapshotFromRef spawned one `git cat-file` per file, sequentially, and
process spawn (~9ms each) dominated the whole --since run: 21.8s for a
500-file tree, 88s for 2000. Read them through a 16-slot semaphore instead,
which cuts those to 2.0s and 7.8s.

16 is where the returns flatten (500 files: 1 slot 18.7s, 8 slots 3.0s,
16 slots 2.0s, 32 slots 1.9s, 64 slots 1.9s) -- spawning is syscall-bound,
not CPU-bound, so the useful limit sits above core count.

Only the reads are concurrent. ingestDocuments still runs sequentially in
ListYAMLFilesAtRef's order, so every snapshot mutation stays
single-threaded and the built Snapshot is identical to before. Errors go
into a position-indexed slice so the first failing path still wins
deterministically.
ExtractIdentifier fell back across fields, so it could return a value no
live asset is keyed on: a View carrying dash0.com/origin but no
dash0.com/id reported that origin, while ImportView upserts by
dash0.com/id alone and the CLI strips origin outbound. --since recorded the
asset, deleted by an identifier matching nothing, took the 404, and printed
"was already deleted" with exit 0. The asset was still live, and the
non-empty identifier had let it slip past the NoIdentifier hard-fail
written for exactly this case.

Seven kinds were affected: View, SyntheticCheck, PersesDashboard and
PrometheusRule fell back to origin; Dashboard and CheckRule to the
dash0.com/id label rather than dash0Extensions.id and the top-level id;
Dash0NotificationChannel to an id it has no user-settable field for. Each
now reads only what its Import helper upserts by, so a document with the
wrong field populated returns "" and reaches the hard-fail.

Teams and spam filters keep their fallback: both Import helpers genuinely
accept origin or id, teams via a GET preflight, which is what makes a
UI-downloaded team YAML reapply cleanly.

The hard-fail message named the two labels, which read as wrong for a
document that does carry one of them; it now names the kind's own upsert
field instead.
GOOS=linux is a cross-compile from macOS, where cgo defaults off, but
native on a linux CI runner, where it defaults on. The runner therefore
produced a binary dynamically linked against glibc and copied it into an
alpine (musl-only) image, where it cannot exec -- while the same code built
statically and passed locally, which is why the divergence went unnoticed.

Setting CGO_ENABLED=0 makes both hosts emit the same static binary. From
macOS the flag is a no-op: the two builds are byte-identical, same BuildID.
KindDisplayName gained a "team" case, so apply now prints
`Team "x" created` where the missing case previously fell through to the
raw `Dash0Team`. The declarative team roundtrip grepped for the old
fallback and failed on CI.

Recorded in the breaking changelog entry alongside --dry-run's output
change, since it is the same kind of user-visible shift.
@nicolastakashi
nicolastakashi merged commit 60d4a33 into main Aug 27, 2026
155 of 156 checks passed
@nicolastakashi
nicolastakashi deleted the feat/sync-action branch August 27, 2026 13:13
@github-actions github-actions Bot locked and limited conversation to collaborators Aug 27, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants