Skip to content

Commit 2de6dc8

Browse files
sunibclaude
andauthored
feat(api): add GitTarget spec.parentBranch, and create a new write branch on its parent's current tip (#407)
* test(git): reproduce a new write branch created on a stale parent Pins today's behavior before the fix: a warm worker that trusts its checkout of the parent creates the write branch on that checkout even after the parent moved, a no-diff live write creates the write branch anyway, and a branch somebody created concurrently is pushed over. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(git): check the parent before creating a write branch, and create none for a no-op A push that creates the write branch is rooted on the parent it was built from, but validatePushState compared that root only when the pushed branch already existed. A warm worker that trusted its checkout of the parent therefore created the branch on that checkout, however far the parent had moved. The root is now compared either way, and a branch somebody created since we based our work is reported as a moved remote instead of being pushed over; the worker replays onto it. A cycle that commits nothing no longer creates the write branch merely to record that nothing changed: the push settles as NoBranch once the parent is confirmed. The worker remembers the parent a local write branch was created from, so a no-op cycle that leaves HEAD on that local branch still roots the next cycle on the parent. The refresher follows the parent of an absent write branch from the same advertisement, so the folder it re-reads is the tree the next commit builds on. Publication does not depend on it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(api): add GitTarget spec.parentBranch, the branch a new write branch starts from spec.parentBranch names the branch the write branch is created from, and compared against while the remote does not carry it. Omitted keeps the remote's default branch. It is mutable and only read, so it need not be in allowedBranches. A configured parent must exist for the write branch to be created: a fetch that finds neither reports ParentBranchNotFound and writes nothing, so a typo never starts an orphan branch. Every GitTarget on one write branch must name the same parent, compared as written; the later one reports TargetConflict. A change keeps the branch worker and drops its base trust. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(git): rebuild retained writes on a parent change, and scope the parent checks Review fixes for spec.parentBranch: - A parent change now reaches writes retained during the push cooldown. They were planned against the old parent, and an invalidated base does not reach them, so the event loop takes the change and refetches and replays before the next commit or push. A missing new parent then refuses publication instead of creating the branch from the old one. - Parent conflicts apply whatever either target's path is. A target with a path its writer rejects still wires the shared worker and would set its parent; path validity now gates only the overlap check. - ParentBranchNotFound ignores an observation proved against another repository than the one the GitProvider names now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(api): make spec.parentBranch immutable, and report status.remote.parent spec.parentBranch decides what history a new write branch gets, so it is fixed at creation like branch and path, including adding or removing it. Delete and recreate loses nothing: the destination is unchanged. The worker keeps its parent-change handling for the narrow case where every target on a branch is replaced before the worker sweep. While the write branch is absent, status.remote.parent names the parent it would be created from and the commit it is at, observed together with the branch's absence by every fetch, refresh and push. With spec.parentBranch omitted, it shows which default branch the remote resolved; an empty commit means the parent is missing too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(api): report whether the parent was found, missing or unborn status.remote.parent.state is Found (the first commit builds on the parent's tip), Missing (spec.parentBranch is not on the remote either, so nothing is written) or Unborn (an empty repository and no parent configured, so the first commit has no history). Without it, a blocked target and a fresh repository both showed an empty commit, or no parent at all. The worker's observation carries the same state instead of a MissingParent name, so the readiness gate and the status read one fact. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(git): keep a parent change pending until the rebuild succeeds recoverRetainedWrites took the parent-change flag before the rebuild ran. A rebuild that failed before its reset, reading the GitProvider or the credentials, left nothing remembering that the retained writes sat on the old parent, so the retry published from it, or created the branch although the new parent was missing. Hand the change back when the rebuild fails. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: record where the parent-branch work goes next, and the open #404 follow-ups Four design pages, checked in so the direction is visible: - gittarget-parent-branch.md: the original plan, now implemented in this PR. - gittarget-parent-hardening.md: the hardening pass from the review of this PR. It keeps the fast path pull-free, and covers parent changes on a live worker, an empty repository that gains a ref, an unresolved default branch, and recovery that cannot strand work. - gittarget-parent-empty-repository.md: an empty repository's default branch. It assumes main first; asking the server needs a small go-git change, already tried in an experiment. - gittarget-parent-observation.md: keep the parent visible after the write branch exists. todo.md lists the follow-ups from the #404 review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: keep the parent-branch pages to what is true, and set the merge boundary for #407 - gittarget-parent-branch.md now records the contract as built (immutable parentBranch, status.remote.parent, the conflict rule) and the reasoning behind it. The executed plan, the handoff, the architecture.md edits already applied, and the superseded "mutable" decision are gone. - gittarget-parent-hardening.md states the merge boundary, makes the CommitRequest timeout fix a prerequisite, tracks recovery per (target, collection) until published, and keeps the fallback's backoff per worker. - gittarget-parent-empty-repository.md is deferred: get the server's evidence through go-git first, then decide the bootstrap policy. Assuming main is recorded as considered and rejected. - gittarget-parent-observation.md ships availability before ancestry, and authorizes no mutation. - The #404 follow-ups move from a root todo.md into docs/TODO.md, by priority. INDEX lists the four pages. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(commit): never fail a CommitRequest the worker still holds, and withdraw one before failing it The controller failed a request closed attachTimeout + maxDuration + 120s after creation (about 124s with defaults) while the worker kept the attach. A remote down for two minutes, or a parent branch recovery, therefore reported FinalizeFailed for a request the worker then committed anyway. Controller and worker now agree: - A request the worker holds (CollectingWindow or WaitingForPush) has no controller-side timeout; only the worker ends it. - An unheld request past the bound is withdrawn first. The withdraw rides the worker's FIFO behind every attach, cancels a request that is only waiting for a window (or was never registered), and leaves a tombstone so a late attach cannot register it again. For a request the worker holds it is a no-op, and the controller keeps waiting. - The controller fails the request only on the worker's answer, and reports the real outcome when the worker resolved it first. A GitTarget that never started a worker is named in the message. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(commit): withdraw a CommitRequest from the worker that holds it, and settle requests when a worker exits Review of 0474b34 found three ways a withdraw could report a request failed while a worker could still publish it, or never answer: - A missing GitTarget, or no manager entry, was read as "nothing holds the request". A worker is shared by every GitTarget on its branch, so it outlives a deleted one, and a retired worker is detached before it stops, so its last push runs unlisted. The manager now records which worker accepted each request's attach (commitRequestOwners), and the router asks that worker first. Only a request no worker holds falls back to the GitTarget. - The withdrawn outcome could be collected by its 15m TTL while an attach for it was still queued, and that attach then registered the request again. An outcome is now kept while any attach for it is queued, and past the safety window the controller only withdraws, never attaches. - A worker whose loop exited, including one that never started because its GitProvider could not be read, stayed listed while attaches and withdraws queued unanswered. On exit a worker now closes admission, drains its queue, gives back every request it never acted on, fails any it held, and answers a withdraw at once. The router regression test fails on the previous router with "the withdraw reported success while the worker still held the registration". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(git): scope base trust to the parent generation and admit a push only on its current root SetParentBranch ran on the controller goroutine, dropped trust once and raised a flag the event loop could undo or bypass: a push or fetch finishing after the change set trust again, the remembered new-branch parent then rooted the next write on the old parent, and a change landing between the rebuild and the push was pushed on the old root. - The parent configuration is one immutable {name, gen} snapshot. Every fetch loads it once and records what it actually used; nothing re-reads the name after the network call. - Trust is scoped to the generation the checkout was proven under, so a late setBaseTrusted(true) cannot vouch for a newer configuration, and the remembered new-branch parent counts only under the current one. - runPushCycle checks, under repoMu right before each push, that the root was chosen under the current generation; otherwise it replays first, with no extra connection. A push already admitted is not recalled: a branch it creates from the old parent stays, and an existing branch does not follow its parent. - A cycle rooted on an existing write branch is re-stamped without a fetch, because a parent cannot move it. SetParentBranch therefore no longer invalidates the base; the generation does. - A failed rebuild leaves the generations apart, which replaces the flag hand-back of dcbc8b8. Five of the seven new tests fail on the previous worker, including the review's no-op-push reproduction; the other two pin the in-flight rule. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(git): never orphan a branch in a repository that is not empty Two ways an omitted parent started the write branch with no history beside refs it ignored: - A cycle planned on an empty repository (root = zero) pushed after somebody created main: no check fired, because the missing-root check needs a non-zero root and the moved check compared the absent write branch with zero. validatePushState now refuses with RepositoryNotEmptyError when the root is zero, the write branch is not advertised and the advertisement carries other refs. It reads the advertisement the push already holds, and remoteMovedDuringPush treats it as contention, so the replay resolves the new default branch and re-plans from the retained writes. - SmartFetchFrom returned the "empty repository" answer for a repository with refs but no resolvable default branch (HEAD dangling, or only tags), and the caller made HEAD unborn. It now returns DefaultBranchUnresolvedError, handled like ErrParentBranchNotFound on the fetch, the replay and the refresh: no write is planned and the parent is observed Missing. "Empty" has one definition: no hash refs at all, tags included. Default-branch resolution follows go-git, which rewrites a hash-only HEAD before Remote.List returns; that is pinned by a test. An omitted parent can now stall: the observation carries ParentRequested, and parentBranchReadiness compares it with spec.parentBranch, with a message that says to fix HEAD or set the field. Canonical git advertises neither a HEAD that resolves to nothing nor its symref, so against it the message says the remote has no default branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(git): make recovery from a missing parent an obligation the worker drives While a parent branch was missing, every reconcile forced a recheck (restarting every stream of the target), indefinitely for a typo, and recovery still depended on an observation flip: the refresher skips a branch holding work, a failed push stops the push timer, refresh can be off, and a first attempt that fails transiently after the parent reappears stranded the work. The worker now latches an obligation the moment a cycle fails on the parent, and clears it only when the work is published: - retained writes stay retained until pushPending publishes them; - writes it had to drop are remembered per (GitTarget, collection) scope, and a scope clears only when a resync of it is published (pushed, or settled as a no-op), not on the resync reply. The worker drives it on its own deadline (10s, doubling, capped at 5m, one per worker): one advertisement while the parent is missing; once found, it retries the retained writes and raises a per-target SnapshotRequestSeq for the scopes still owed. Before the deadline a refresh tick costs nothing and a live write or resync fails without a fetch; reconciles never probe. A parent change makes the probe due at once. The controller forces one recheck per rise of the sequence (remembered in memory, like reconcile requests) instead of on every reconcile while the Ready reason is ParentBranchNotFound, and reports Ready=False/Reconciling with reason RecoveringParentBranch while the parent is back but work is owed. Ledger rows 1-12 are unchanged; new rows: standby no-op live write (1), standby first write after the parent moved (6, as row 6), parent probe while missing (1 advertisement, 0 fetches). e2e: a third context recovers from ParentBranchNotFound with no cluster edit after the parent is pushed. The refresher-timing step ("contention rose, refresh did not") is removed: with refresh on, a refresh can find B first; the worker tests prove publication alone finds a moved parent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(git): follow a switched default branch, and replay onto a write branch created during our upload R5. With spec.parentBranch omitted the parent is the default branch as last discovered, but the refresher compared the parent's hash only: a HEAD switched from main to trunk at the same commit was never followed, and no-op pushes kept recording main while refreshes recorded trunk. followParent now also compares the advertised parent branch with the one the checkout is rooted on, and follows a mismatch with one fetch. Publication still cannot see HEAD; that is documented. R6. When somebody created the write branch between our advertisement and our upload, the server refused the upload (Old was zero) as an untyped error, the fallback probed the root only, found the parent unmoved, and the work never replayed. On that path only, the fallback now reads both refs from one advertisement and treats a write branch that is now there as contention. Its ledger row measures 8 connections: the refused upload, one advertisement, the contention fetch, the push. Also rewords the "parent branch" vocabulary rule: a bare "parent" is fine where the subject is already a branch, such as status.remote.parent. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: fold the hardening pass into the parent-branch contract, and pin a held request through a long recovery The contract page now carries what shipped: the omitted-parent refusal for a repository that is not empty, recovery as a worker obligation, the push admission boundary, go-git's default-branch resolution, and the new ledger rows. The hardening plan is marked done and records where the build departed from it. TestParentRecovery_AHeldCommitRequestIsCommittedAfterALongRecovery runs the real event loop: a CommitRequest riding a write held back by a missing parent stays WaitingForPush through a recovery far longer than the controller's old bound, and resolves Committed on the parent's tip. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: describe the safety window as a withdrawal request, and fix the review nits - api-first-publication.md and commit-timing-surface.md: past the safety window the controller asks the worker to withdraw the request and reports the worker's answer, which can be the real outcome when the worker resolved it first. - configuration.md: the status.remote example shows an absent write branch, so the parent stanza it shows is one the contract can produce. - gittarget-parent-branch.md: drop the settled default-branch question from the open questions. - INDEX.md headings and TODO.md wording follow the style guide. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(git): keep a recovery obligation for work dropped in a second outage, and hold retained replay to the probe deadline Review of 93120ca reproduced two §4.4 defects: - P1. A recovery snapshot that had committed but not yet been pushed kept its publication credit when the parent vanished again and another write from the same scope was dropped. Publishing the older snapshot then cleared the newer obligation: cm1 landed, cm2 never did, and no new snapshot was requested. A dropped write now removes its scope's credit, so the scope stays open and the worker asks again. - P2. With retained writes waiting for a rebuild, every incoming write went through recoverRetainedWrites, which fetched without checking the probe hold: one connection per write before the deadline. It now returns the hold's error before any I/O, and the scheduled probe still drives the recovery. The reviewer's reproductions are the regression tests (both red before the fix); the probe-budget test gains a retained-writes case. The docs record the review, its resolution, and the follow-up order after #407 merges. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7f090ea commit 2de6dc8

64 files changed

Lines changed: 7326 additions & 275 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.coverage-baseline‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
79.0
1+
79.4

‎api/v1alpha3/gittarget_types.go‎

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import (
2828
// +kubebuilder:validation:XValidation:rule="!has(oldSelf.gitProviderRef) || self.gitProviderRef == oldSelf.gitProviderRef",message="spec.gitProviderRef is immutable; delete and recreate the GitTarget to change its destination"
2929
// +kubebuilder:validation:XValidation:rule="self.branch == oldSelf.branch",message="spec.branch is immutable; delete and recreate the GitTarget to change its destination"
3030
// +kubebuilder:validation:XValidation:rule="self.path == oldSelf.path",message="spec.path is immutable; delete and recreate the GitTarget to change its destination"
31+
// +kubebuilder:validation:XValidation:rule="has(self.parentBranch) == has(oldSelf.parentBranch) && (!has(self.parentBranch) || self.parentBranch == oldSelf.parentBranch)",message="spec.parentBranch is immutable; delete and recreate the GitTarget to change it"
3132
//
3233
// spec.clusterProviderRef names the SOURCE cluster a GitTarget mirrors FROM (see its field doc). It
3334
// is immutable — a folder's source cluster is part of what the folder means, like
@@ -49,6 +50,25 @@ type GitTargetSpec struct {
4950
// +kubebuilder:validation:MinLength=1
5051
Branch string `json:"branch"`
5152

53+
// Why "parent" and not base/source/revision: docs/definitions.md reserves those words, and the
54+
// field is flat because it is one fact. It is immutable like branch and path: it decides what
55+
// history a new write branch gets, and a parent changed under a worker holding writes is the
56+
// one case that needed its own replay. Delete and recreate is cheap, because the destination is
57+
// unchanged. It can never hold a commit or tag: a pinned starting point would be a sibling field.
58+
59+
// ParentBranch is the branch the write branch (spec.branch) is created from, and the branch the
60+
// target compares against while the write branch does not exist on the remote. Omitted: the
61+
// branch the remote's HEAD points at (its default branch), or a branch with no history when
62+
// the repository is empty. When set, it must exist on the remote for the write branch to be
63+
// created; otherwise the GitTarget reports ParentBranchNotFound and writes nothing. Once the
64+
// write branch exists, it is used as it is and the parent is not followed. It is only read, so
65+
// it does not need to be in the GitProvider's allowedBranches. All GitTargets on one GitProvider
66+
// and branch must name the same parent branch (omitted counts as its own value).
67+
// Immutable, including adding or removing it: delete and recreate the GitTarget to change it.
68+
// +optional
69+
// +kubebuilder:validation:MinLength=1
70+
ParentBranch string `json:"parentBranch,omitempty"`
71+
5272
// Path within the repository to write resources to, relative to the repository
5373
// root. Required and must be non-empty — there is no default, so a GitTarget can
5474
// never silently write to the repository root. To deliberately target the
@@ -457,6 +477,48 @@ type GitTargetRemoteStatus struct {
457477
// +optional
458478
// +kubebuilder:validation:Enum=Push;Fetch
459479
VerifiedBy string `json:"verifiedBy,omitempty"`
480+
481+
// Parent is what the write branch would be created from, observed with it: present only
482+
// while the remote does not carry the write branch. Once the branch exists its parent is not
483+
// followed, so this is omitted.
484+
// +optional
485+
Parent *GitTargetParentStatus `json:"parent,omitempty"`
486+
}
487+
488+
// GitTargetParentState is what a write branch that does not exist yet would be created from.
489+
// +kubebuilder:validation:Enum=Found;Missing;Unborn
490+
type GitTargetParentState string
491+
492+
const (
493+
// GitTargetParentFound means the parent branch exists; the first commit builds on its tip.
494+
GitTargetParentFound GitTargetParentState = "Found"
495+
// GitTargetParentMissing means the parent is not on the remote either, so the write branch
496+
// cannot be created; the GitTarget reports ParentBranchNotFound. The parent is spec.parentBranch,
497+
// or, when that is omitted, the default branch of a repository that is not empty but whose HEAD
498+
// names no branch it carries.
499+
GitTargetParentMissing GitTargetParentState = "Missing"
500+
// GitTargetParentUnborn means the repository is empty, with no refs at all (tags included), and
501+
// no parent is configured, so the first commit starts the write branch with no history.
502+
GitTargetParentUnborn GitTargetParentState = "Unborn"
503+
)
504+
505+
// GitTargetParentStatus is the parent branch of a write branch that does not exist yet.
506+
type GitTargetParentStatus struct {
507+
// State is what the write branch would be created from: Found (the parent's tip), Missing
508+
// (the parent is not on the remote; nothing is written) or Unborn (an empty repository, with no
509+
// refs at all, and no parent configured; the first commit has no history).
510+
// +required
511+
State GitTargetParentState `json:"state"`
512+
513+
// Branch is the parent branch: spec.parentBranch, or the branch the remote's HEAD names when
514+
// that is omitted. Empty when State is Unborn, and when the remote names no default branch.
515+
// +optional
516+
Branch string `json:"branch,omitempty"`
517+
518+
// Commit is the commit hash the parent branch is at on the remote: the commit the write
519+
// branch's first commit would build on. Set only when State is Found.
520+
// +optional
521+
Commit string `json:"commit,omitempty"`
460522
}
461523

462524
// Two rules for anything added here. A field earns its place only if a reader cannot get it from

‎api/v1alpha3/zz_generated.deepcopy.go‎

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎config/crd/bases/configbutler.ai_gittargets.yaml‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,19 @@ spec:
348348
- Ignore
349349
- PushEmptyCommit
350350
type: string
351+
parentBranch:
352+
description: |-
353+
ParentBranch is the branch the write branch (spec.branch) is created from, and the branch the
354+
target compares against while the write branch does not exist on the remote. Omitted: the
355+
branch the remote's HEAD points at (its default branch), or a branch with no history when
356+
the repository is empty. When set, it must exist on the remote for the write branch to be
357+
created; otherwise the GitTarget reports ParentBranchNotFound and writes nothing. Once the
358+
write branch exists, it is used as it is and the parent is not followed. It is only read, so
359+
it does not need to be in the GitProvider's allowedBranches. All GitTargets on one GitProvider
360+
and branch must name the same parent branch (omitted counts as its own value).
361+
Immutable, including adding or removing it: delete and recreate the GitTarget to change it.
362+
minLength: 1
363+
type: string
351364
path:
352365
description: |-
353366
Path within the repository to write resources to, relative to the repository
@@ -521,6 +534,10 @@ spec:
521534
- message: spec.path is immutable; delete and recreate the GitTarget to
522535
change its destination
523536
rule: self.path == oldSelf.path
537+
- message: spec.parentBranch is immutable; delete and recreate the GitTarget
538+
to change it
539+
rule: has(self.parentBranch) == has(oldSelf.parentBranch) && (!has(self.parentBranch)
540+
|| self.parentBranch == oldSelf.parentBranch)
524541
- message: spec.clusterProviderRef is immutable; delete and recreate the
525542
GitTarget to change the cluster it mirrors
526543
rule: self.clusterProviderRef == oldSelf.clusterProviderRef
@@ -682,6 +699,35 @@ spec:
682699
an old value there means the layout has not changed rather than that nothing has looked.
683700
format: date-time
684701
type: string
702+
parent:
703+
description: |-
704+
Parent is what the write branch would be created from, observed with it: present only
705+
while the remote does not carry the write branch. Once the branch exists its parent is not
706+
followed, so this is omitted.
707+
properties:
708+
branch:
709+
description: |-
710+
Branch is the parent branch: spec.parentBranch, or the branch the remote's HEAD names when
711+
that is omitted. Empty when State is Unborn, and when the remote names no default branch.
712+
type: string
713+
commit:
714+
description: |-
715+
Commit is the commit hash the parent branch is at on the remote: the commit the write
716+
branch's first commit would build on. Set only when State is Found.
717+
type: string
718+
state:
719+
description: |-
720+
State is what the write branch would be created from: Found (the parent's tip), Missing
721+
(the parent is not on the remote; nothing is written) or Unborn (an empty repository, with no
722+
refs at all, and no parent configured; the first commit has no history).
723+
enum:
724+
- Found
725+
- Missing
726+
- Unborn
727+
type: string
728+
required:
729+
- state
730+
type: object
685731
verifiedBy:
686732
description: |-
687733
VerifiedBy is what proved it: `Push` means the server accepted a ref update of ours, so

‎docs/INDEX.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,14 @@ decided), **design, decided** (decision made, not built), **partly built**, **bu
124124
**deferred** (parked, kept as a decision record). The label is the first thing in the page,
125125
so you never have to read a proposal to find out it already shipped.
126126

127-
### Open — 23 pages
127+
### Open (27 pages)
128128

129129
| Doc | Open question |
130130
|---|---|
131+
| [`gittarget-parent-branch.md`](design/gittarget-parent-branch.md) | **built in #407, not merged yet.** `spec.parentBranch`: a target stays on standby until a write needs a branch, then creates its write branch from the parent's tip as checked at publication. The contract as built, the defect it started from, prior art and naming. Open: parent per `GitProvider.status.branches` entry. |
132+
| [`gittarget-parent-hardening.md`](design/gittarget-parent-hardening.md) | **done in #407.** Parent changes on a live worker, an empty repository gaining a ref, an unresolved default branch, recovery that cannot strand work, and the refused upload, without adding a pull to the fast path; plus the two recovery fixes from the review at `93120caf`. Records the merge boundary and the next separate PRs. |
133+
| [`gittarget-parent-empty-repository.md`](design/gittarget-parent-empty-repository.md) | **deferred.** An empty repository's default branch: the server reports it over protocol v2 but go-git discards it (upstream fix first); then a separate policy decision on which branch the first commit may create. Assuming `main` was considered and rejected. |
134+
| [`gittarget-parent-observation.md`](design/gittarget-parent-observation.md) | **deferred.** Keep the parent visible after the write branch exists: availability first, ancestry only after measuring how often a shallow checkout can answer it. Informational; authorizes no branch mutation. |
131135
| [`gittarget-red-status-plan.md`](design/gittarget-red-status-plan.md) | **design, partly built.** The smaller alternative to Git write preflight: make a `GitTarget` turn red and explain why no commit was made, without moving Git checks into the admission webhook and without adding a second state source. Keeps the existing kstatus trio and domain conditions, ranks the most likely operator-facing failures, and has now shipped the first status-transition slice: refusal messages republish when details change, scoped refusals cannot be cleared by unrelated scope success, `BlockMessage()` carries stable issue kind/path/actor detail, and the unsupported-folder e2e covers Git-side break and recovery. |
132136
| [`open-asks-priority.md`](design/open-asks-priority.md) | **the work queue.** Merges three overlapping backlogs — the downstream consumer asks, the API-surface block left unbuilt by the status and configuration-model review, and the config-surface proposal (B1–B6) — into one ordered queue under four stated tests, and says where we deliberately do **not** do what was asked. The standing caveat narrowed once the layout model reversed: a Tier 2 entry belongs to postponed [#294](https://github.com/ConfigButler/gitops-reverser/issues/294) only if it breaks a `GitTarget` field, and everything else is independently schedulable. Makes one design call against what was asked: **delete Option C sibling inference** rather than ship an off-switch for it, because it let a human's edit to the repository change operator behavior with nothing in status recording the move. That deletion has shipped, and "what the deletion taught" records what building it found. **F9 is Tier 1**: the only item whose answer is unknown rather than whose work is unscheduled, and it gates planning the enum work |
133137
| [`proving-the-metric-surface.md`](design/proving-the-metric-surface.md) | **design, partly built.** What identity an operator needs to locate a failure, and what an e2e check has to do to be worth trusting. Written after a metric value that was declared, switched on, surfaced as a condition reason and counted turned out to have no producer at all, and the check added to catch that class then skipped whenever the counter read zero — so deleting every recording call left the suite green. Two rules follow: an assertion must not take its trigger from the metric under test, and no enum value may be reachable only through an injected fake. Carries a cardinality policy that separates bounded configuration identity from per-request identity, a table of which instrument families should own `source_cluster` directly rather than by join, and the PromQL reasoning for `increase()` versus `max_over_time` (neither applies mechanically: one cannot see an event before the first sample, the other cannot reconstruct totals across a reset). Steps 1–4 are built; step 5, diagnostics export, the `e2e_run` scrape label and a pinned-seed regression are not |
@@ -152,7 +156,7 @@ so you never have to read a proposal to find out it already shipped.
152156
| [`sensitive-resource-diagnostics-follow-up.md`](design/sensitive-resource-diagnostics-follow-up.md) | deferred diagnostics |
153157
| [`e2e-git-server-choice.md`](design/e2e-git-server-choice.md) | stay on Gitea or move to Forgejo — the `_csrf` pin is fixable in place on both, so the migration is now a preference call, not a fix; also why we adopt no SDK either way |
154158

155-
### Built, and kept here anyway — 6 pages
159+
### Built, and kept here anyway (6 pages)
156160

157161
These have shipped. They stay in `design/` under the exception above, because Go source
158162
cites them by path as the rationale for what the code does, and `finished/` declares

‎docs/TODO.md‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,58 @@
22

33
This file is meant to track the smaller current backlog, not historical notes.
44

5+
## Follow-ups from #404 (commit window surface)
6+
7+
PR #404 is merged; these need fix PRs against main, in this order. (Its CommitRequest timeout
8+
finding is fixed in #407: the controller no longer fails a request the worker holds.)
9+
10+
**Next, in one fix PR:**
11+
12+
- [ ] **A suspended target makes a `CommitEmpty` save report Ready=True with no commit.**
13+
`recordCommitRequest` returns `false, nil` on `errTargetSuspended`; the design table
14+
(`docs/design/commit-timing-surface.md`) says a suspended target is a failure. An attached window
15+
that changed nothing resolves `AlreadyPresent` the same way. Decide: fail the save, or change the
16+
table.
17+
- [ ] **The worker records a `CommitEmpty` save while the render-fidelity gate is closed.** Found by
18+
reading; reproduce first. `recordCommitRequest` never calls `normalWritesAllowed`, so a save whose events the
19+
gate dropped records "no writes were seen". The attached-window path fails the request instead.
20+
21+
**Before the next release** (docs only):
22+
23+
- [ ] `docs/UPGRADING.md`, the v1alpha3 window entry:
24+
- step 1: tell readers to suspend the Flux or Argo source of a GitOps-managed GitTarget (or remove
25+
the field there) until step 4; otherwise that tool re-applies the string `commit.window` and
26+
breaks LIST;
27+
- the Was/Is table needs a row for the chart value `quickstart.gitTarget.commit.window`, now an
28+
object;
29+
- "a removed `closeDelay` is not a hazard" is too strong: the API server prunes it silently for
30+
programmatic clients (see `duration_fields_admission_test.go`).
31+
- [ ] Descriptions that still say a save "closes the window now": the CommitRequest godoc and CRD
32+
description, `docs/configuration.md` (lines 14, 32, 123), and the GitTarget `Commit` godoc, which
33+
also omits the 1m `maxDuration` default. `docs/spec/commitrequest-design.md:126` still says
34+
`NoOpenWindow` and "close deadline".
35+
36+
**Later, non-blocking:**
37+
38+
- [ ] `expireWaitingCommitRequests` ranges over a map, so empty commits for saves that time out in
39+
the same pass land in random order; sort by `seq`.
40+
- [ ] `buildRequestRecordWrite` keeps only the username; carry the full `UserInfo` so the record has
41+
the same author identity as a window commit.
42+
- [ ] A record commit skips `requestTemplate`; fix the code or the doc comment that says it is phrased
43+
like every other commit.
44+
- [ ] Nits: a stale doc comment stacked on `commitWindowFor`; kstatus test names that still say
45+
"close-delay".
46+
547
## Current backlog
648

49+
- [ ] After #407 and the #404 fixes above, take the first
50+
[parent-observation step](design/gittarget-parent-observation.md#implementation-and-validation):
51+
retain parent availability and its timestamp after the write branch exists. Use existing
52+
advertisements, with no extra connections or changes to readiness or publication. Keep ancestry
53+
classification and empty-repository bootstrap policy in separate changes. The
54+
[merge review](design/gittarget-parent-hardening.md#merge-review-at-93120caf) records the two
55+
recovery fixes made before #407 merged.
56+
757
- [ ] Finish making `GitTarget` turn red quickly and explain why no commit was made.
858
The plan is in [gittarget-red-status-plan.md](design/gittarget-red-status-plan.md). This is the
959
replacement direction for Git write preflight: no admission webhook Git work, no extra state

0 commit comments

Comments
 (0)