Documentation
¶
Overview ¶
Package service is the one use-case layer both cmd/hoist and the TUI call — application logic that used to live only in package main, duplicated (and quietly diverging) between the CLI's flags-to-render path and the TUI's own wiring.
It imports internal/engine, internal/config and pkg/* freely, and constructs the same pkg/* clients (git, forge, argo, rollout, k8s, registry) either face needs. It never imports internal/app or any Bubble Tea / Lip Gloss package (charm.land/...): a screen may import service's own value types (AGENTS.md §4.8), but service itself must stay renderer-agnostic, or the CLI would start pulling in a terminal UI it never runs. internal/service/imports_test.go enforces this mechanically rather than by convention alone (AGENTS.md §10 meta-rule 5).
The package is built from a few pieces: Settings (what a run resolved from flags and config, once), Deps (constructor seams a caller injects, so a real invocation and a test share the same shape), Service (the lazy, memoized clients built from Deps), and the repo view (a single current read of the GitOps repo, from the operator's own clone or from a cached view of origin, replacing cmd/hoist's own boot-frozen equivalents).
Index ¶
- func AnyRealEdit(edits []gitops.Edit) bool
- func ArgoRolloutFor(deps Deps, rc config.RepoConfig, kubeOverride string) (argo.Argo, rollout.Rollout, error)
- func CheckRepoViewCurrent(ctx context.Context, g git.Git, cloneDir, base, viewedSHA string) error
- func Detail(o engine.Observation) string
- func EnsureArgoApps(s *engine.PromotionState, rc config.RepoConfig) error
- func IsProduction(env string, envs config.EnvsConfig) bool
- func KubeContextFor(rc config.RepoConfig, override string) string
- func RepoConfigFor(cfg *config.Config, repoFullName string) (config.RepoConfig, bool)
- func WarnDeployIntoProduction(pl *gitops.Plan, envs config.EnvsConfig)
- type ActEvent
- type AlreadyCurrentError
- type AmbiguousError
- type Deps
- type Drive
- type Driver
- type FileStore
- func (FileStore) Archive(id string) error
- func (FileStore) Claim(repoFullName, targetEnv, id string) (func(), error)
- func (FileStore) Delete(id string) error
- func (FileStore) List() ([]*engine.PromotionState, error)
- func (FileStore) ListArchived() ([]*engine.PromotionState, error)
- func (FileStore) Load(id string) (*engine.PromotionState, error)
- func (FileStore) Save(s *engine.PromotionState) error
- func (FileStore) WorktreeDir(id string) (string, error)
- type GCOpts
- type GCReport
- type Hooks
- type InFlightConflictError
- type ListOpts
- type Listed
- type Mode
- type NotFoundError
- type PlanRequest
- type PlannedChange
- type RepoMode
- type RepoView
- type Resolution
- type ResolveOptions
- type ResumeOpts
- type RunHooks
- type Service
- func (s *Service) Abandon(ctx context.Context, id string) ([]string, error)
- func (s *Service) AbandonWith(ctx context.Context, id string, h Hooks) ([]string, error)
- func (s *Service) Argo(kubeContext string) (argo.Argo, error)
- func (s *Service) ArgoRolloutFor(rc config.RepoConfig, kubeOverride string) (argo.Argo, rollout.Rollout, error)
- func (s *Service) CheckOverrides(r *gitops.Repo, from string, overrides map[string]image.Ref) error
- func (s *Service) CleanupLanded(ctx context.Context, st *engine.PromotionState) ([]string, error)
- func (s *Service) Cluster(kubeContext string) (k8s.Cluster, string, error)
- func (s *Service) Find(id string) (*engine.PromotionState, error)
- func (s *Service) FindInFlight(ctx context.Context, repoFullName, targetEnv, skipID string) (*engine.PromotionState, engine.StepStatus, error)
- func (s *Service) FindInFlightForEnv(ctx context.Context, env string, h Hooks) (*engine.PromotionState, error)
- func (s *Service) ForgeFor(ownerRepo string) (forge.Forge, error)
- func (s *Service) GC(ctx context.Context, o GCOpts) (GCReport, error)
- func (s *Service) Git() git.Git
- func (s *Service) List(ctx context.Context, o ListOpts) ([]Listed, error)
- func (s *Service) ListArchived(repoFullName string) ([]*engine.PromotionState, error)
- func (s *Service) LoadRepo(ctx context.Context, mode RepoMode) (RepoView, error)
- func (s *Service) Plan(ctx context.Context, req PlanRequest) (PlannedChange, error)
- func (s *Service) Preflight(target string, m Mode) error
- func (s *Service) RefreshRepo(ctx context.Context) (RepoView, error)
- func (s *Service) RegistryFor(imageRepo string) (registry.Registry, error)
- func (s *Service) Repo() RepoView
- func (s *Service) Resume(ctx context.Context, id string, o ResumeOpts) (Drive, error)
- func (s *Service) Rollout(kubeContext string) (rollout.Rollout, error)
- func (s *Service) Settings() Settings
- func (s *Service) StartPromotion(ctx context.Context, req StartRequest, h Hooks) (Drive, error)
- type Settings
- type StartRequest
- type StateStore
- type Tick
- type UnconfirmedError
- type UsageError
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
func AnyRealEdit ¶
AnyRealEdit reports whether edits contains at least one edit that is not a NoOp (the target already carries exactly the planned reference) — the all-NoOp fast-path guard StartPromotion and `hoist plan`'s own branch-id line (cmd/hoist/main.go's runPlan) share, so a plan whose every edit is already satisfied is recognized identically wherever this codebase asks the question. Exported (unlike this file's other helpers) because runPlan's own display-only use has no promotion to start at all — duplicating this one-line predicate there would be exactly the kind of second copy AGENTS.md §8's "layered checks" section warns drifts from the first. Moved from cmd/hoist/promote.go's anyRealEdit, unchanged.
func ArgoRolloutFor ¶
func ArgoRolloutFor(deps Deps, rc config.RepoConfig, kubeOverride string) (argo.Argo, rollout.Rollout, error)
ArgoRolloutFor builds the Argo and rollout clients for rc's own kube context via deps — what `hoist promotions`/`hoist resume` need to observe or drive a promotion that may belong to a DIFFERENT repos[] entry than the one a long-lived Service was built for, so the kube context has to be read from rc, not from a Settings field. kubeOverride is the operator's own --kube-context, applied through KubeContextFor exactly as it is for the currently selected repo — the one rule every face that lists or resumes promotions applies, so a state file from another repo is observed and driven against the cluster the operator named, not that repo's default.
A free function over Deps, not a Service method, because cmd/hoist's own callers (cmd/hoist's runPromotions/runResume) build one of these per promotion in a loop over possibly-different repos[] entries — exactly what buildArgoRolloutIn (cmd/hoist/resume.go) did before this move, unmemoized, since each call may target a different cluster context. A Service that already holds the right Settings can still get the same answer via its own ArgoRolloutFor method below.
func CheckRepoViewCurrent ¶
CheckRepoViewCurrent refuses a confirm whose plan (built from a view captured at viewedSHA, whatever moment refreshRepoView last ran to produce it — TUI boot or a since F5) no longer matches origin/<base>'s present tip. Deliberately NOT checkCloneCurrentForBase's per-file blob comparison: that function's whole shape assumes cloneDir's working tree tracks refs/heads/<base>, the local branch — true for an ordinary checkout, false by construction for a cached origin view, which is deliberately checked out from origin, never the local branch. What's left to verify is simpler: re-fetch origin/<base> and compare its SHA to viewedSHA.
viewedSHA takes the CALLER'S already-captured RepoView.SHA rather than re-reading viewDir's current on-disk HEAD: repoViewDir is keyed only by cloneDir, so a LATER LoadRepo(RepoFromOrigin) call (an F5 refresh) checks that same directory out to a new ref IN PLACE — re-deriving the "viewed" SHA from disk at check time would therefore always read whatever the most recent refresh just wrote, making every check trivially pass regardless of which view a plan was actually built from (the fail-open this replaces).
viewedSHA == "" (refreshRepoView itself failed and the caller fell back to the clone, or this is a clone-mode view) skips this check entirely — there is nothing cached to compare against.
func Detail ¶
func Detail(o engine.Observation) string
Detail picks whichever of Observation's message fields is set — Blocked takes precedence since it's the more actionable one when both could apply. Moved from cmd/hoist/promote.go's statusDetail, unchanged; exported because it is now shared by service's own inflight.go and by cmd/hoist's remaining (not-yet-moved) abandon.go/resume.go call sites.
func EnsureArgoApps ¶
func EnsureArgoApps(s *engine.PromotionState, rc config.RepoConfig) error
EnsureArgoApps repairs a state file written before M5 added PromotionState.ArgoApps: JSON decoding an older file leaves the field empty, and ArgoRefreshedStep/ArgoSyncedStep both take their `len(apps) == 0` "no Argo Application in this promotion's plan" success path on an empty ArgoApps — so an upgraded, already-in-flight promotion could be reported complete having never actually checked the Application it edited.
An empty ArgoApps alongside a non-empty Edits is unambiguous evidence of exactly that: engine.ArgoAppNames' own contract (see its doc comment) means a real call against a non-empty edit set can never itself return an empty, non-error slice — every edit's directory maps to exactly one family/Application, or the call errors naming the orphan edit. So this only ever fires for a state genuinely predating ArgoApps' introduction, never for a fresh, post-M5 promotion that legitimately touches no Argo Application (which also has no Edits at all, since BuildPlan produces edits only from what an env's own families declare).
s.ArgoApps is otherwise left untouched — state.go's own doc comment ("computed once ... then carried unchanged across every resume") still governs every other case, matching Edits/ CommitMessage/PRTitle/PRBody's own carried-not-recomputed treatment.
s.ArgoNamespace gets the identical treatment in the same pass, for the identical reason: a pre-M5 state file decodes it as the empty string too (the field didn't exist yet), and argoApplications() builds an argo.Application with whatever s.ArgoNamespace holds verbatim — an empty namespace fails Argo.Get's own input validation outright ("application needs a namespace"), rather than merely under-reporting like an empty ArgoApps does. Both fields are always set together at construction time for every post-M5 promotion (StartPromotion), so "ArgoApps is empty and Edits is not" is exactly as unambiguous a legacy signal for ArgoNamespace as it is for ArgoApps itself — this function's own name stays EnsureArgoApps since Applications remain the primary concern, but it closes both gaps a legacy state can have.
s.EditApps gets the same treatment as a third, independent gap (PR #182): a state file saved any time between M5 and EditApps' own introduction has a populated ArgoApps but a nil EditApps, since the two fields were computed together at construction from that point on but ArgoApps alone before it — so "ArgoApps non-empty" cannot stand in for "EditApps populated" the way it does for ArgoNamespace above, and this checks EditApps on its own terms, discovering the repo only once for whichever of the two repairs this state actually needs.
Moved unchanged from cmd/hoist/resume.go's ensureArgoApps, and exported because both List and Resume in this file call it, and a resume_test.go's own legacy-state regression coverage needs it directly too.
func IsProduction ¶
func IsProduction(env string, envs config.EnvsConfig) bool
IsProduction reports whether env is listed in envs.production (AGENTS.md §4.5): direct mode is never offered for it, whatever the source env. Moved from internal/app/plan/rows.go unchanged; it is a thin wrapper over config.EnvsConfig.IsProduction, kept as a function here (rather than inlined at every call site) so a screen deciding whether to offer direct mode and Plan's own deploy-into-production warning read the identical rule.
func KubeContextFor ¶
func KubeContextFor(rc config.RepoConfig, override string) string
KubeContextFor is the free-function form of the same precedence, for a caller that has not built a Settings at all (cmd/hoist's own test-only buildResolveFunc entry point) but still must not read rc.Kube.Context directly — every kube-context fallback in this codebase goes through this one function so the precedence can never drift between callers again.
func RepoConfigFor ¶
RepoConfigFor finds cfg's repos[] entry whose GitHub name matches repoFullName — how both `hoist promotions` and `hoist resume` locate the credential/CloneDir context a stored PromotionState doesn't carry a config reference for (state files are repo-agnostic beyond RepoFullName itself, on purpose — AGENTS.md §4.1: the state file is an index, not a second copy of config).
Moved from cmd/hoist/resume.go's repoConfigFor unchanged.
func WarnDeployIntoProduction ¶
func WarnDeployIntoProduction(pl *gitops.Plan, envs config.EnvsConfig)
WarnDeployIntoProduction attaches gitops.WarnProductionTarget to a deploy plan whose target env the operator's own config lists as production, and is the single place that decision is made: Plan (below) calls it for every deploy it builds, so the dry run, the confirm screen and the PR body all carry it — a warning only one surface shows is a warning the reviewer of a PR never sees. Informational only; production's real constraint is the PR — always — plus whichever approval mode the repo configures for that env: the comment is the default, but an explicit `approval: auto` is permitted (§4.5), so neither this helper nor the warning it attaches may claim a human comment is unconditional. Both are enforced in internal/engine.
A no-op for anything but a deploy: a promotion into production is what the paired-env config exists to describe, so saying it out loud there is noise, not news. Moved from internal/app/plan/rows.go unchanged.
Types ¶
type ActEvent ¶
type ActEvent struct {
Step engine.StepName
State engine.PromotionState
}
ActEvent is one step's Act about to run. State is a copy of the promotion's state at that instant — what the Act is about to work on, not what it will produce: the outcome is the History entry the Act's save records, and reaches a caller through Hooks.OnHistory.
type AlreadyCurrentError ¶
type AlreadyCurrentError struct {
SourceEnv, TargetEnv string
// Deploy is true for a deploy plan (SourceEnv is always "" there); Ref is the one reference
// that plan would have written, valid only when Deploy is true.
Deploy bool
Ref image.Ref
}
AlreadyCurrentError is StartPromotion's own no-op signal: every edit in the confirmed plan already carries what it would write, so there is nothing left to commit, push or open a PR for. It carries plain fields rather than one canonical message because the CLI's own wording (which names the deployed ref for a deploy) and the TUI's (which never has, for a deploy — see wiring.go's old buildStartPromotion) have never agreed word for word; unifying that text is out of scope here, so each caller still builds its own final line from these fields. Error() gives a reasonable default for a caller that only wants *a* message — the TUI's own notice line uses exactly it today.
func (*AlreadyCurrentError) Error ¶
func (e *AlreadyCurrentError) Error() string
type AmbiguousError ¶
AmbiguousError is FindInFlightForEnv's own "more than one in-flight promotion targets this env" — AGENTS.md invariant 5 says there should never legitimately be two, so this is always a caller error (name one by id) rather than something FindInFlightForEnv resolves on its own. IDs is sorted before Error() is ever called, so the message is deterministic.
func (*AmbiguousError) Error ¶
func (e *AmbiguousError) Error() string
type Deps ¶
type Deps struct {
Git func() git.Git
Forge func(ownerRepo string) (forge.Forge, error)
Argo func(kubeContext string) (argo.Argo, string, error)
Rollout func(kubeContext string) (rollout.Rollout, string, error)
Cluster func(kubeContext string) (k8s.Cluster, string, error)
Registry func(registry.AuthConfig) (registry.Registry, error)
Store StateStore
Now func() time.Time
// NoCache disables Service's own success-memoization of Forge/Argo/Rollout (service.go).
// Set only by a test that swaps a seam (newForge, newArgo, ...) BETWEEN calls against one
// long-lived Service instance — the TUI's own session-lifetime Service is exactly that
// shape, and a memoized client built from the seam's first value would otherwise survive
// the swap. Production code never sets this; see service.go's own doc comment for why
// memoizing at all is still correct there.
NoCache bool
}
Deps is the set of constructors a Service is built from — never the clients themselves. cmd/hoist's serviceDeps() wraps its own package-level test seams (newGit, newForge, ...) in closures that read the seam AT CALL TIME, so a test that reassigns one mid-test (about 108 do) still sees its own fake through any Service built before or after the reassignment; nothing here is a snapshot taken once.
None of these constructors ever see a secret value directly — that is still resolved inside pkg/k8s and pkg/registry exactly as it is today (AGENTS.md §4.10, R-002); Deps only decides which constructor runs, never what it reads.
type Drive ¶
type Drive interface {
ID() string
State() engine.PromotionState
Step(ctx context.Context) (Tick, error)
OverrideCINone()
Run(ctx context.Context, h RunHooks) error
}
Drive is what a caller — cmd/hoist's own commands and internal/app/flight's screen alike — actually needs from a promotion driver: one poll (Step), the CLI's own run-to-completion loop (Run), the state it is driving (State, ID), and the one operator override a promotion can pick up mid-flight (OverrideCINone). *Driver satisfies it; a test fakes it directly.
type Driver ¶
type Driver struct {
// contains filtered or unexported fields
}
Driver drives one promotion's steps to completion, one engine.DriveStatus walk per Step call (see DriveStatus's own doc comment for the double-observation problem this replaces: a caller that used to run engine.Drive and then engine.Status every poll — cmd/hoist's driveToCompletion and internal/app/flight's own DriveFunc both did — now makes one real walk against the world per poll, whether that poll is a CLI retry, a flight screen tick, or the operator's own R/c gesture). mu serializes every method that touches state: Step, Run (which only ever calls Step) and OverrideCINone, so a screen's manual retry can never race an automatic tick's Step call over the same *engine.PromotionState — the same one-in-flight-per-instance guarantee flight.Model's own busy flag gives at the UI layer, held here too so a caller with no busy flag of its own (session.Controller, which holds a Drive across screens) still gets it for free.
func NewDriver ¶
func NewDriver(steps []engine.Step, state *engine.PromotionState, save func(*engine.PromotionState) error, poll engine.PollIntervals) *Driver
NewDriver builds a Driver over steps, saving through save after every step DriveStatus actually acts on or stops at (exactly as engine.Drive itself already does — save is called from inside DriveStatus, never by Driver directly). state is driven in place: every Step call mutates *state and returns a snapshot of it, so a caller that also holds state directly (a resumed CLI command's own *engine.PromotionState) sees it advance the same way engine.Drive always mutated its own s argument in place.
Exported and taking steps/state/save/poll positionally rather than a settled Deps/Settings shape because internal/service.StartPromotion and Resume are what build a Driver in production, and are expected to unexport this constructor behind their own request/response types once every caller goes through them.
func (*Driver) OverrideCINone ¶
func (d *Driver) OverrideCINone()
OverrideCINone sets CINoneOverride on this Driver's own state — the TUI's `c` gesture and the CLI's `--override-ci-none` both land here. It takes effect on the NEXT Step call, whose own CIGreenStep.Observe reads it and whose own save persists it; OverrideCINone itself never saves, exactly as flight.Model.ApplyCINoneOverride never saved on its own before this change — only a Step (via engine.DriveStatus's own per-step save) does.
func (*Driver) Run ¶
Run drives this promotion to completion, calling Step repeatedly and sleeping Tick.Wait between calls (in Heartbeat-sized slices, so a long wait can still report it is alive) until Step reports Done, a *engine.BlockedError, a terminal (non-Retry) error, or ctx is done. This is cmd/hoist/drive.go's old driveToCompletion, unchanged in outward behavior: the actual waiting still lives here, in the caller's own loop, never inside a Step's Act (AGENTS.md invariant 4) — Run just calls Driver.Step instead of engine.Drive+engine.Status directly, so there is still exactly one walk against the world per poll.
func (*Driver) State ¶
func (d *Driver) State() engine.PromotionState
State returns a value copy of the promotion's current state, safe to hold or render without racing a Step call in flight.
func (*Driver) Step ¶
Step runs one poll iteration: a single engine.DriveStatus walk, and — only when that walk returns a genuine failure, never for Waiting or Blocked — a second engine.Status walk to recompute the full step list, matching exactly what cmd/hoist/wiring.go's old driveFuncFor did unconditionally (call engine.Drive, then always call engine.Status for the rows) on ITS OWN error path: a caller rendering rows after a failure sees precisely what it saw before this change. On every other outcome — done, waiting, blocked — DriveStatus's own statuses are the only walk; the second walk this replaces was pure waste there (AGENTS.md §9 entry 11).
The returned error is never ErrWaiting and never a *BlockedError — those are reported through Tick.Waiting and Tick.Blocked instead, exactly as flight.DriveFunc's own doc comment already described for the old two-call shape. A non-nil error here is always a genuine plumbing or terminal failure, classified by Tick.Retry (engine.Retryable) for whatever the caller's own retry loop needs.
type FileStore ¶
type FileStore struct{}
FileStore is StateStore over the real $XDG_STATE_HOME/hoist files engine/state.go and engine/claim.go already read and write — a thin wrapper, not a reimplementation: every method is exactly the existing engine call, so this changes no on-disk shape and needs no migration.
func (FileStore) Archive ¶
Archive moves id's live state file into the archive subdirectory, per engine.ArchiveState.
func (FileStore) Claim ¶
Claim atomically claims repoFullName/targetEnv for promotion id, per engine.ClaimInFlight.
func (FileStore) Delete ¶
Delete removes one promotion's state file by id, per engine.DeleteState. Idempotent: an already-absent file is not an error.
func (FileStore) List ¶
func (FileStore) List() ([]*engine.PromotionState, error)
List returns every live promotion state file, per engine.ListStates.
func (FileStore) ListArchived ¶
func (FileStore) ListArchived() ([]*engine.PromotionState, error)
ListArchived returns every archived promotion state file, per engine.ListArchivedStates.
func (FileStore) Load ¶
func (FileStore) Load(id string) (*engine.PromotionState, error)
Load reads one promotion's state file by id, per engine.LoadState. A missing file is not an error: it returns (nil, nil), same as engine.LoadState.
type GCOpts ¶
type GCOpts struct {
DryRun bool
}
GCOpts configures one GC call. DryRun makes every check and observation a real run makes and reports what it would remove, removing nothing. "Observation" includes what every landing check does: a fetch of origin's base branch into the clone's remote-tracking ref. A dry run removes nothing; it is not a run that touches nothing.
type GCReport ¶
GCReport is what GC did, as lines for a person: Removed is one line per thing removed (worded "would …" under DryRun); Kept is one line per thing GC looked at, could have been expected to remove, and deliberately left, with the reason; Failed is one line per thing it could not decide about or could not remove — an observation or a removal that errored — which is not a decision to keep it and is what makes `hoist gc` exit non-zero.
type Hooks ¶
type Hooks struct {
Progress func(string)
OnWaiting func()
OnAct func(ActEvent)
// OnHistory is called by the Driver for each History entry newly appended by a Step walk, at
// the moment the state save carrying it has landed — oldest first, once per entry — with a
// copy of the state as saved (so e.g. s.PR is populated when the pr-opened entry fires). It
// runs on the walking goroutine under the Driver's lock: it must not block and must not call
// back into the Driver. Entries already in the state when the Driver was built are not
// reported. This, not Progress, is how a step's outcome reaches a screen as it happens.
OnHistory func(e engine.HistoryEntry, s engine.PromotionState)
}
Hooks are optional, nil-safe callbacks StartPromotion invokes as it works through preflight — never anything a caller needs to poll for. Progress reports one short line per preflight stage, mirroring the CLI's own progress lines (runHooksForCLI) and the TUI's own preflight log (buildStartPromotion's report calls) — both now the identical text, since both go through this one function. It carries preflight lines only, never a step's outcome: those are PromotionState.History entries, and echoing them here put every event on the flight screen twice. OnWaiting is passed straight through to engine.StepsFor, for a later Step/Run call's own CommittedStep "waiting for signing approval" wait.
OnAct reports each step's Act as it is about to run (ActEvent) — the one signal that a commit or a push is under way, since History, and so OnHistory below, only hears of an Act once it has returned. Like OnHistory it runs on the walking goroutine under the Driver's lock, so it must not block or call back into the Driver. Only the CLI sets it. AbandonWith and FindInFlightForEnv take a Hooks too and use Progress alone.
type InFlightConflictError ¶
type InFlightConflictError struct {
Conflict *engine.PromotionState
Env string
Status engine.StepStatus
}
InFlightConflictError is AGENTS.md invariant 5's own refusal: another promotion, other than the one being started, already targets the same repo/env and has not yet re-observed as done. Error() is claimTarget's/FindInFlight's own wording, moved unchanged from cmd/hoist/promote.go's inFlightConflictError.
func (*InFlightConflictError) Error ¶
func (e *InFlightConflictError) Error() string
type ListOpts ¶
type ListOpts struct {
RepoFullName string
ArchiveDoneOlderThan time.Duration
Progress func(string)
}
ListOpts scopes and configures one List call: RepoFullName filters to one repo ("" lists every configured repo's promotions, exactly like `hoist promotions` with no --repo), and ArchiveDoneOlderThan is the retention window (state.retain) past which a Done promotion is moved to the archive as part of this same call. 0 (or negative) disables archiving for this call, and only for a caller that passes it directly: the session controller's ListOpts{} does, so the matrix never archives. The config cannot express "never archive" — Normalize makes state.retain 0 or unset mean config.DefaultStateRetain (30 days) and Validate rejects a negative one — so `hoist promotions` always passes a positive window. This comment used to say it matched `hoist promotions`' own "retain: 0 means never archive" reading of the config; there is no such reading (#248).
Progress, when set, hears one line before the re-observation starts, naming how many promotions it covers — the listing prints nothing of its own until every one is observed.
type Listed ¶
type Listed struct {
State engine.PromotionState
Done bool
Statuses []engine.StepStatus
Last engine.StepStatus
Err error
Unconfigured bool
// Archived is true when this same List call just archived this promotion (it was Done and
// older than ArchiveDoneOlderThan) — the CLI's own --archived listing needs this to avoid
// printing an archived-this-run promotion a second time (#181).
Archived bool
// ArchiveErr is set when archiving was attempted (Done, past the retention window) but
// failed — the promotion is still reported Done, with the archive failure alongside it,
// exactly as runPromotions always has.
ArchiveErr error
// Cleaned is one line per thing this same List call removed for a promotion it observed
// landed (its worktree, its local branch — CleanupLanded); CleanupErr is why that removal
// was refused or failed. Neither changes Done: a promotion is no less finished for still
// having a worktree.
Cleaned []string
CleanupErr error
}
Listed is one promotion's own re-observed standing, as `hoist promotions` and the matrix's in-flight pane both now build it from the same List call. Exactly one of Unconfigured/Err/ (Done or a populated Statuses) describes why: Unconfigured means State.RepoFullName names a repo no longer in the config file (nothing was even attempted); Err means a client could not be built, EnsureArgoApps failed, or the re-observation itself errored (already redacted — AGENTS.md §4.10); otherwise Statuses is engine.Status's own full per-step walk and Last is its final entry (the CLI only ever prints Last; the matrix's pane needs every entry to draw per-step glyphs. engine.Status is used here, not engine.ObserveAll, specifically so one walk serves both, and Last is derived from its own final entry rather than a second, separate ObserveAll call).
type Mode ¶
type Mode struct {
Direct bool
Confirmed bool
// OverrideCINone seeds the new (or resumed) PromotionState.CINoneOverride — the CLI's own
// --override-ci-none. The TUI's `c` gesture instead calls a running Drive's own
// OverrideCINone() mid-flight, never this: see Driver.OverrideCINone's own doc comment.
OverrideCINone bool
}
Mode is how the operator asked to run this promotion. Direct/Confirmed are the CLI's own --direct/--confirm-direct pair or the TUI's keypress-then-confirm gesture — either way, Confirmed only ever selects whether a NON-production direct request is honoured; engine.DirectCommitGateStep re-derives the production refusal from ProductionEnvs regardless of Confirmed (AGENTS.md §4.5), and so does Preflight's own call to that same step above.
type NotFoundError ¶
NotFoundError is Find's own "no such promotion" (ID set) and FindInFlightForEnv's own "no in-flight promotion targets this env" (Env set) — one type for both, since both are the same shape of answer ("nothing matched") to the two different questions `hoist resume` can be asked (by id, or by --env). Exactly one of ID/Env is ever set. Error() reproduces cmd/hoist/resume.go's own pre-move wording verbatim, so a caller printing it unchanged (the CLI renderer) prints exactly what it always has.
func (*NotFoundError) Error ¶
func (e *NotFoundError) Error() string
type PlanRequest ¶
type PlanRequest struct {
// Repo is the discovered GitOps repo to plan against; nil uses s.Repo().Repo (the
// service's own current view — LoadRepo/RefreshRepo).
Repo *gitops.Repo
// Source is the env to read digests from; "" iff Deploy is set (a deploy has no source
// env — image.Ref names the reference outright).
Source, Target string
// Deploy is set for `hoist deploy`/the TUI's tag-picker confirm: write this exact
// reference into every occurrence of its repo in Target, rather than resolving Source's
// running digest.
Deploy *image.Ref
// Overrides are the operator's own per-repo digest overrides (`--digest`, the plan
// screen's o dialog): each wins outright over whatever Source resolves to.
Overrides map[string]image.Ref
// Progress, when set, hears one line before digest resolution starts — the one part of
// planning that asks the cluster and the registry. Only the CLI sets it.
Progress func(string)
}
PlanRequest is everything Plan needs to build one gitops.Plan, for a promotion or a deploy — the one shape `hoist plan`, `hoist promote`, `hoist deploy` and both TUI paths (the plan screen's loadCmd, the matrix's deploy confirm) now build and hand to Plan, so a plan built for a confirm screen and a plan built for the CLI's own dry run can never silently diverge again.
type PlannedChange ¶
type PlannedChange struct {
Plan gitops.Plan
Repo *gitops.Repo
// View is the RepoView Plan actually planned against — s.Repo() at the moment Plan ran, when
// req.Repo pointed at that same *gitops.Repo (nil, or an explicit pointer that happens to be
// the service's own current view); a zero RepoView{Repo: r} otherwise (a caller-supplied repo
// Plan never fetched itself, e.g. the CLI's own gitops.Discover). Request below carries this
// into StartRequest.View so StartPromotion's freshness check runs against the view THIS plan
// was built from, never whatever s.Repo() happens to hold when the operator later confirms —
// fixing the fail-open gap where an F5 refresh landing between building the plan and pressing
// Enter let a stale plan's edits pass a freshness check run against the NEW view instead of
// the one that produced them.
//
// When s is in origin mode (cur.FromOrigin — LoadRepo(RepoFromOrigin) has run: TUI boot or a
// later F5), this field is ALWAYS a real FromOrigin view with a captured SHA: Plan itself
// fails closed above rather than ever return a zero or clone-mode View for such a caller.
// StartPromotion enforces the other half of that guarantee at its own end (structurally, not
// just by convention): a StartRequest.View that reaches it nil, or with FromOrigin false, or
// with an empty SHA, while the service's current view is itself origin-mode, is refused
// outright — a caller that lost this field on the way there (a screen that forgot to plumb
// it) gets an error naming the fix, never a silent downgrade to the CLI's looser
// checkCloneCurrentForBase check.
View RepoView
// Resolution is nil for a deploy (which never resolves a digest — the reference is
// caller-supplied) and for a promotion planned with digest sources: none.
Resolution *Resolution
// contains filtered or unexported fields
}
PlannedChange is what Plan returns: the built gitops.Plan (its Warnings already carry resolve.Warnings and, for a deploy into a production env, WarnDeployIntoProduction — never re-attached by the caller), the repo it was planned against, and the resolution section when one ran.
func (PlannedChange) Request ¶
func (p PlannedChange) Request(m Mode) StartRequest
Request builds this PlannedChange's own StartRequest for StartPromotion: the plan and repo exactly as Plan built them, plus this promotion's own resolved digests (fresh) so direct mode's fresh-base check reuses them rather than re-resolving. Only valid for the UNFILTERED plan Plan itself returned — a caller that ticks a subset of edits (the TUI's own confirm) builds its own gitops.Plan with those edits and its own StartRequest directly, passing Fresh: nil, since the resolution behind THIS PlannedChange no longer describes only what was kept.
type RepoMode ¶
type RepoMode int
RepoMode picks how LoadRepo reads the GitOps repo: from the operator's own clone directly (the CLI's own `gitops.Discover(RepoDir)`), or from a cached view of origin/<base> (the TUI's own repoview.go behavior, moved here unchanged).
const ( // RepoFromClone discovers directly from Settings.RepoDir, exactly what `hoist plan`/ // `hoist promote` have always done: a pure local disk read of the operator's own // checkout, no network needed. RepoFromClone RepoMode = iota // RepoFromOrigin fetches origin/Settings.Base into a cached worktree first (refreshRepoView // below) and discovers from THAT — the TUI's own answer to "what does origin actually look // like right now", read without ever touching the operator's own checkout or working tree // (AGENTS.md §4.6). Falls back to RepoFromClone's own local read when origin can't be // reached at all, since browsing the matrix must stay possible offline (principle 5). RepoFromOrigin )
type RepoView ¶
type RepoView struct {
Repo *gitops.Repo
Dir string
// FromOrigin is true when Dir is the cached origin view (refreshRepoView succeeded), false
// when LoadRepo fell back to the clone itself (RepoFromOrigin's fetch failed) or was asked
// for RepoFromClone directly.
FromOrigin bool
// SHA is origin/<base>'s tip at the moment THIS view was built (FromOrigin only; "" for a
// clone-mode view or a fallback). Dir names a cached directory under repoViewDir, keyed only
// by cloneDir — a LATER LoadRepo(RepoFromOrigin) call (an F5 refresh) checks that same
// directory out to a NEW ref in place, so a Dir string alone cannot tell an old view from a
// newer one once the refresh has run: CheckRepoViewCurrent must compare against the SHA this
// view actually captured, never re-derive one by reading Dir's current on-disk HEAD, or a
// refresh that lands between building a plan and confirming it would silently launder a
// stale plan by making the check compare origin's tip against itself.
SHA string
// Fallback is refreshRepoView's own error when RepoFromOrigin had to fall back to the
// clone. nil whenever FromOrigin is true, or for RepoFromClone.
Fallback error
}
RepoView is one snapshot of the GitOps repo: the discovered Repo, the directory it was discovered from, and — for RepoFromOrigin — whether that came from the cached origin view or fell back to the clone, and why.
type Resolution ¶
type Resolution struct {
Order []resolve.Source
AuthTried []registry.AuthSource // the configured chain, for the "all failed" label
KubeContext string // "" when no cluster was opened
AuthUsed string // "" when no credential source authenticated
Consulted bool // true when the registry was asked at all (win or lose)
Res map[string]resolve.Resolution
}
Resolution is one Plan call's resolution section: which sources and credential chain were asked, what actually authenticated, and every resolved repo — the same facts `hoist plan --dry-run` prints and the TUI plan screen renders (AGENTS.md §4.4/§4.10: by name only, never a value). The moved and renamed form of cmd/hoist's own resolutionReport; nil means "digest sources: none" throughout PlannedChange.
type ResolveOptions ¶
type ResolveOptions struct {
KubeContext string
Order []resolve.Source
Auth []registry.AuthSource // "" override; nil = per-repo config decides
ClusterSecret string // "" = per-repo config decides
OpRef string // "" = per-repo config decides
Registries []config.RegistryConfig
}
ResolveOptions is what the flags and the config agree digest resolution does — the moved form of cmd/hoist's own resolveOptions (F4). Auth, ClusterSecret and OpRef are only ever the *explicit* --registry-auth/--cluster-secret/--op-ref override, applied identically to every repo when given; when they are not given (the zero value), each image repo's own registries[] entry decides instead (registryEntryFor/entryAuthConfig, below).
func NewResolveOptions ¶
func NewResolveOptions(cfg *config.Config, rc *config.RepoConfig, digestSources, registryAuth, clusterSecret, opRef, kubeContext string) (ResolveOptions, error)
NewResolveOptions applies the precedence: a flag given wins outright, for every repo; else the selected repo's config (kube.context, digest_sources); registry credentials (auth, cluster, op) are resolved later, per image repo, in resolveImages — never here as one merged chain (F4). digestSources "none" turns resolution off (M1's plan). Moved from cmd/hoist's own resolutionOptions unchanged in behavior; kubeContext replaces resolveFlags.kubeContext since this package does not know that cmd-only type.
type ResumeOpts ¶
ResumeOpts is Resume's own per-call configuration: OverrideCINone is the CLI's --override-ci-none / the TUI's already-applied `c` gesture (a fresh CINoneOverride to seed onto the state before driving, distinct from Driver.OverrideCINone's own mid-flight version of the same flag); Hooks carries OnWaiting, passed to engine.StepsFor for the returned Drive, and OnHistory, installed on it. Hooks.Progress is preflight-only and Resume has no preflight, so it reports nothing here: a resumed promotion's events reach a caller as OnHistory calls (one per History entry, as its save lands), never as a second stream of progress text.
type RunHooks ¶
type RunHooks struct {
OnTick func(Tick)
OnRetry func(error)
Heartbeat time.Duration
OnHeartbeat func(Tick)
}
RunHooks are the CLI's own reporting hooks for Run — all nil-safe. OnTick fires once per poll that comes back Waiting (mirroring cmd/hoist/drive.go's old waitingReporter.report call on the same condition); OnRetry fires once per poll that came back a genuine, retryable error; OnHeartbeat fires during the sleep between polls, at most every Heartbeat, but only while the most recent poll was Waiting (a retryable-error sleep prints nothing further today, matching driveToCompletion exactly). Heartbeat <= 0 means "no heartbeat slicing" — the whole wait is one sleep.
type Service ¶
type Service struct {
// contains filtered or unexported fields
}
Service is the lazy, memoized client cache built over Deps. A CLI command builds one per invocation (its clients never outlive the command); the TUI builds exactly one for the whole session, which is why memoization has to be success-only (a client that failed to build stays worth retrying on the next call — a `hoist watch`-style boot failure should never wedge every later attempt for the rest of the session) and why Git/Cluster are never memoized at all: Git carries no per-target state to cache (git.Exec{} is stateless, and a test swaps newGit mid-run — abandon_test), and Cluster is opened fresh per call today (the drift column already does this; caching it would be a behavior change).
func New ¶
New builds a Service from settings and deps. Nothing here calls out to git, the forge or the cluster — every client is built lazily, on first use, through the matching provider method below.
func (*Service) Abandon ¶
Abandon retires promotion id for good: releases the state file, removes its worktree and its local branch from the operator's clone and, if it opened a PR, closes it and deletes its remote branch. The returned actions are one human-readable description per real action actually taken, in order (empty when nothing beyond the state file itself needed touching — a direct-mode promotion never opens a PR or pushes a branch to origin at all).
Re-observes first and refuses outright if the promotion has already landed (merged, for the PR path; pushed, for direct mode) — abandoning is not a rollback: a landed promotion needs `hoist deploy` or a fresh promotion to undo, never a state-file delete. The observation is the identical git/forge-only core FindInFlight/FindInFlightForEnv both use (engine.ObserveSteps with Argo/rollout both nil) — "has this promotion's own write happened" is exactly the question that answers, and it is also exactly the three-way intact/superseded/reverted judgment (AGENTS.md §4.1) DirectPushedStep/MergedStep already apply, so Abandon inherits it rather than re-deriving a second opinion.
`done` alone is not "has this landed": MergedStep.Observe (PR path) and DirectPushedStep (direct path) both mutate st in place with the real landed sha the moment their own merge/push is CONFIRMED — even when they go on to report Satisfied:false for something that only ever happens AFTER landing (the PR path's own "merged as X; branch not yet deleted" case: AGENTS.md §6.1 gotcha 7). st.LandedSHA() asks the direct, mode-agnostic question this actually needs answered; `done` stays as a second, independent gate (AGENTS.md §8 "layered checks").
No claim file is touched: a promotion with a state file at all already released its own claim the moment that file was first saved.
Moved unchanged in substance from cmd/hoist/abandon.go's abandonPromotion.
func (*Service) AbandonWith ¶
AbandonWith is Abandon with h.Progress hearing each phase as it starts: the re-observation, then each write. Abandon itself keeps its signature for app.Service, whose caller reports an abandon through the activity log instead.
func (*Service) Argo ¶
Argo returns an Argo client for kubeContext, memoized on success exactly as ForgeFor is.
func (*Service) ArgoRolloutFor ¶
func (s *Service) ArgoRolloutFor(rc config.RepoConfig, kubeOverride string) (argo.Argo, rollout.Rollout, error)
ArgoRolloutFor is the Service-method form of the free function above, using s's own Deps.
func (*Service) CheckOverrides ¶
CheckOverrides refuses a --digest override BuildPlan would never consult: one for a repo outside the promotable prefixes (BuildPlan iterates promotable repos only, so an override for a third-party image would be accepted and change nothing), or one for a repo that has no occurrence in the source env (a typo in the repo name would plan the source env's ref instead of the caller's). Either way -h promises the override is planned, so silence is wrong. An unknown source env and an empty prefix list are left for BuildPlan to report. Moved from cmd/hoist's own checkOverrides unchanged, reading s.settings.Promotable rather than taking it as a parameter.
func (*Service) CleanupLanded ¶
CleanupLanded removes promotion st's worktree and its local branch from the operator's clone once — and only once — st is observed landed: merged with its remote branch deleted (PR path), or pushed and still intact or superseded on the base (direct path). The returned lines are one human-readable description per thing actually removed; nil means there was nothing to do, whether because the promotion has not landed or because it was already clean.
It and cleanupObservedLanded (the same checks, ordered for a caller that polls: List, and the Drive returned by StartPromotion and Resume) are the only automatic cleanups. Both re-observe the landing step themselves rather than accept a caller's word, a recorded Phase or a stored landed sha (AGENTS.md §4.1). And because "landed" is itself only as good as the forge lookup behind it, it then checks the worktree and branch themselves hold nothing that is not on the remote (locallyRemovable, tipIsOnTheRemote) before removing either.
A *engine.CleanupRefusedError means the worktree or branch could not be shown to be hoist's to delete; everything is left in place.
func (*Service) Cluster ¶
Cluster returns a cluster client for kubeContext, and the kubeconfig context name it actually used (an empty kubeContext resolves to the kubeconfig's current context, which callers report by name, never an address — AGENTS.md §4.4/§4.10). Never memoized: the matrix's drift column already opens one per call, and every env it asks about in one pass shares the same kubeContext, so memoizing here would only save a connection this method does not itself hold open anyway.
func (*Service) Find ¶
func (s *Service) Find(id string) (*engine.PromotionState, error)
Find looks up one promotion's state file by id, directly (engine.StatePath(id) is deterministic from id, so this never has to scan every state the way FindInFlightForEnv does) — the one path `hoist resume <id>` and `hoist abandon <id>` both take to turn an operator-given id into a *engine.PromotionState.
engine.StatePath joins id straight into the promotions directory (`id+".json"`), with no containment check of its own — main's own ListStates-based lookup never took an operator-given string anywhere near a path join, so this join-by-id shortcut (moved here from cmd/hoist's own runResume/runAbandon) is a NEW path-traversal surface: an id containing a path separator or ".." reaches outside the promotions directory (`../../etc/passwd`), or sideways into the archive subdirectory (`archive/<real-id>`), letting `hoist resume`/`hoist abandon` act on an archived or aged-out promotion `hoist promotions` never lists as live. Find refuses any id containing a path separator or ".." outright, and — once a state file does load — refuses one whose own ID does not match id, so a file that happens to sit at the expected path but was never saved as this id (or was moved/archived since) is treated as not found rather than silently acted on.
func (*Service) FindInFlight ¶
func (s *Service) FindInFlight(ctx context.Context, repoFullName, targetEnv, skipID string) (*engine.PromotionState, engine.StepStatus, error)
FindInFlight looks for a promotion state other than skipID targeting repoFullName/targetEnv that engine.ObserveAll reports as not yet done — AGENTS.md §4.1's own re-observe rule, applied to "is there already a promotion running for this env" rather than trusted from the state file's own Phase or presence alone (invariant 5: one in-flight promotion per target env). found is nil when no conflicting in-flight promotion exists. An error re-observing a candidate is treated conservatively — reported rather than silently skipped — since a promotion this call can't verify is done must not be treated as safely finished.
Deliberately observes only the git/forge core (through Merged for the PR path, through the push for a direct one — engine.ObserveSteps picks by the state's own mode, since a direct state can never satisfy the PR path's steps and would otherwise be in flight forever), not the full engine.AllSteps/AllDirectSteps — a considered call, not an oversight. Invariant 5 exists to prevent exactly one thing: two promotions racing to create separate branches/PRs/merges for the same target env (a real git/forge conflict). That risk is fully retired the moment a merge (or a direct push) lands — a second promotion for the same env gets its own id, its own branch and its own PR (§4.1's deterministic id is keyed on the image set), so nothing about this promotion's own Argo refresh/sync or rollout convergence can still collide with it. See AGENTS.md §9 entry 11 for why this needs a genuinely three-way landed verdict (intact/ superseded/reverted), not a plain ancestry or blob check, to avoid wedging a real env for days (#165, #166) — that verdict lives in engine.observeLanded, called from inside the steps ObserveSteps builds here, not from this function itself.
Moved unchanged from cmd/hoist/drive.go's findInFlight — g and f are now built from Deps rather than taken as parameters: repoFullName is always the SAME forge identity every step list built from prev would use, and s.Git()/s.ForgeFor give exactly that without a caller having to already hold one.
The scan is made through a scanGit first — one listing of origin's branches and one fetch of each base, shared by every candidate — and its answer stands only if origin's branches are still where that snapshot had them once the scan is over (scanGit.unchanged). Otherwise, or if the pass failed, the scan is made again through the live git.Git. Either way every call asks origin afresh: the snapshot is built here and dropped on return.
func (*Service) FindInFlightForEnv ¶
func (s *Service) FindInFlightForEnv(ctx context.Context, env string, h Hooks) (*engine.PromotionState, error)
FindInFlightForEnv answers `hoist resume --env <target-env>`'s own question: which single, still-in-flight promotion (per AGENTS.md invariant 5, there should never legitimately be more than one) targets env. Every live state file targeting env is re-observed (AGENTS.md §4.1, never trusted from its own recorded Phase) through the git/forge-only core (Argo/rollout are both nil — the same core FindInFlight and abandon's own landed-check use: "is this promotion's own write still pending" does not need the cluster).
A candidate whose repo is no longer in the config file, whose forge/Argo-rollout clients cannot be built, or whose own re-observation errors, is never silently excluded — that would let a transient forge/git failure misleadingly read as "no in-flight promotion" (or resolve an otherwise-ambiguous set down to one candidate without ever confirming the choice was actually unambiguous). Every such candidate is instead collected into a *UnconfirmedError, returned once the scan finishes, naming every candidate it could not confirm.
Moved unchanged in substance from cmd/hoist/resume.go's own --env matching loop.
h.Progress hears one line before the scan starts.
func (*Service) ForgeFor ¶
ForgeFor returns a forge client for ownerRepo, memoized on success: once a forge for ownerRepo has been built, every later call for the same ownerRepo returns the same value without calling Deps.Forge again. A failed build is never cached, so the next call retries it (a lapsed `gh` login fixed mid-session should not need a restart).
func (*Service) GC ¶
GC removes what finished promotions leave on this machine, and nothing else:
- the worktree and local branch of every promotion with a live state file that is observed landed — the same CleanupLanded the listing and the drivers call, so this is where a promotion that landed before that cleanup existed gets tidied;
- orphaned worktrees: a directory under <CacheDir>/worktrees that no live state file names, left by a promotion abandoned or archived before cleanup existed, or whose state file was deleted by hand;
- registry cache entries not used for registryCacheMaxAge.
An orphan has no state file, so nothing can be re-observed about it; what stands in for that is evidence it is hoist's own worktree and holds nothing uncommitted: its name is a promotion id, it is a real directory (engine.PromotionWorktree), it is registered as a worktree of a configured clone on that id's own hoist/<env>/<id> branch, and `git status` shows nothing in it. Anything short of that is kept and named in the report. A commit that exists only on an orphan's branch is NOT a reason to keep it: that is exactly what an abandoned promotion leaves — a one-line-per-occurrence image edit that promoting again makes again, as a new commit. This is the one place hoist deletes a commit that may exist nowhere else, which is why orphans are removed only here, on an explicit command with a dry run, and never automatically. (Automatic cleanup of a landed promotion checks the opposite: service.tipIsOnTheRemote.)
The worktrees directory is read BEFORE the state files are listed: a promotion saves its state file before it creates its worktree, so every worktree seen in that first read already has its state file by the time the second read happens, and a promotion starting mid-GC can never be mistaken for an orphan.
func (*Service) Git ¶
Git returns a git client. Never memoized (see Service's own doc comment) — s.deps.Git is called every time.
func (*Service) List ¶
List re-observes every live promotion state file — `hoist promotions`' own listing and the matrix's in-flight pane both drive from this one call now (previously two separate re- observation loops, cmd/hoist/resume.go's runPromotions and cmd/hoist/wiring.go's observeForList, which could in principle disagree about the very same state file). Never reads a state file's own recorded Phase (AGENTS.md §4.1) and never returns early on one candidate's own error — a promotion that cannot be confirmed is reported as such (Listed.Err), never dropped.
func (*Service) ListArchived ¶
func (s *Service) ListArchived(repoFullName string) ([]*engine.PromotionState, error)
ListArchived returns every archived promotion state file (state.retain's own destination), optionally scoped to one repo — `hoist promotions --archived`'s own listing, called AFTER List so that a promotion List just archived (Listed.Archived) can be excluded from a caller's own combined rendering instead of appearing twice in the same invocation's output.
func (*Service) LoadRepo ¶
LoadRepo reads the GitOps repo per mode and stores the result as s's current view (Repo() below reads it back). Called at TUI boot and by RefreshRepo (F5); a CLI command that only ever discovers once may ignore the stored view and just use the returned RepoView.
func (*Service) Plan ¶
func (s *Service) Plan(ctx context.Context, req PlanRequest) (PlannedChange, error)
Plan builds one gitops.Plan: BuildDeployPlan for req.Deploy, else digest resolution (when s.Settings().Resolve.Order is non-empty) followed by BuildPlanWith — the exact sequence `hoist plan`/`hoist promote`/the TUI plan screen each used to run for themselves. Resolution errors are a whole-operation failure (AGENTS.md §4.10): a per-repo registry miss is a resolve.Resolution that failed to resolve, never an error return, so there is nothing left here to plan from without a resolution attempt that could not even run at all.
func (*Service) Preflight ¶
Preflight is StartPromotion's own early gate, called before any freshness check, claim or state save — this is the trust-boundary fix unifying on StartPromotion closes: the CLI has always run its identical checkDirectPreflight before ever building a worktree or a claim, but the TUI's own buildStartPromotion only ever reached engine.DirectCommitGateStep once Drive itself first ran the step — AFTER the claim and the initial state save. Calling the identical gate here, first, closes that gap for both faces without weakening engine.DirectCommitGateStep's own status as the SOLE enforcement point (AGENTS.md §4.5): deleting this call cannot make a production direct commit possible, because StepsFor's own direct step list still carries the same step, checked again the moment Drive first runs (AGENTS.md §8, layered checks — the deletion test). ProductionEnvs is always s.settings.ProductionEnvs(), unfiltered, exactly as engine.StepsFor's own doc comment demands.
For a non-direct request this only checks the forge identity every promotion eventually needs (repos[].github) — the same check the CLI's old runPromote/runDeploy made before ever calling their own buildPromotionForConfirm, and the TUI's old buildStartPromotion (cmd/hoist/wiring.go, both since removed) made separately for itself; unified here, in StartPromotion's one call to Preflight, so neither face can drift from the other's wording again.
func (*Service) RefreshRepo ¶
RefreshRepo is F5's own re-read of origin: RepoFromOrigin, so the matrix and a plan built moments later never disagree about which origin/<base> either was reading.
func (*Service) RegistryFor ¶
RegistryFor builds a registry.Registry client for one image repo, scoped to whichever registries[] entry covers it (or the default chain, for one none does) — the credential lookup M6's tag picker and commit history need for a direct call that skips Plan's own resolution entirely (AGENTS.md §4.10: F4's per-repo scoping applies here too, never a merged chain). Moved from cmd/hoist's own buildTagsFunc, which built this inline per call.
func (*Service) Repo ¶
Repo returns the current RepoView — whatever the most recent LoadRepo/RefreshRepo stored. The zero value (Repo == nil) means LoadRepo has never been called.
func (*Service) Resume ¶
Resume re-derives a drivable promotion from a persisted state file, honouring exactly the same carry-forward rules cmd/hoist/resume.go's runResume and cmd/hoist/wiring.go's buildInFlightFuncs.Resume both applied by hand (and had drifted on: the TUI passed no progress, no onWaiting, and the CLI's own ctx already wraps a deadline the TUI's call site never threaded through either — this is the one path both now take):
- CINone/CIGrace/CISettle/CIIgnore/Approval/Approvers/Collaborators are NEVER re-read from the current config file — PromotionState's own doc comment states the invariant that these are policy "as of when this promotion started", so a promotion never straddles two different policies mid-flight even if the operator edits the config file while it is in flight. Only o.OverrideCINone is a one-shot, explicit override for this call.
- ArgoNamespace IS re-read from the current config on every resume: it only names where a live Get for this env's Argo Applications lands, not a decision replayed against historical events, so re-reading it fails loudly (Application not found) rather than misjudging anything quietly if it moved.
- EnsureArgoApps repairs a legacy state file missing ArgoApps/ArgoNamespace/EditApps.
- engine.StepsFor is asked for the mode this promotion actually IS (a direct promotion never pushed its branch and has no PR, so driving it through the PR-path steps would push a branch and open one — the exact shape --direct exists to avoid); Confirmed is always true, because this promotion's own existence on disk is the confirmation (the state file only exists because the operator already passed --confirm-direct, or the TUI's own gesture, when it first started) — DirectCommitGateStep still re-derives the production refusal independently of Confirmed (AGENTS.md §4.5), so resuming can never reach an env the original run would have been refused.
func (*Service) Rollout ¶
Rollout returns a rollout client for kubeContext, memoized on success exactly as ForgeFor and Argo are.
func (*Service) StartPromotion ¶
StartPromotion takes a confirmed plan the rest of the way to a drivable promotion: preflight, freshness, the one-in-flight claim, the first durable state save and the claim's release, all in one place — the CLI's own runPromote/runDeploy and the TUI's own buildStartPromotion used to each assemble this sequence by hand, in two different orders, which is what let the TUI reach engine.DirectCommitGateStep only after its own claim and save, and let the two faces run the claim/save order and the preflight order differently. This function is the one, canonical order both faces now call through:
Preflight → freshness (by View) → [direct] fresh-base occurrence check → no-op (*AlreadyCurrentError) → forge identity → ArgoAppNames/EditApps → Argo/Rollout(KubeContext) → claimTarget → newState (Direct set) → Store.Save → release → Driver.
Two behaviour changes fall out of unifying on this order (accepted divergences, not defects):
- The claim is now released only after the state's first successful save lands (the TUI's own order) — the CLI used to release inside its save wrapper's own closure, which is observably the same EXCEPT when Drive's very first Observe fails before its own first save: a state file now remains in that case (Phase ""), blocking the env until `resume`/ `abandon` — already true for the TUI before this change.
- A direct no-op whose origin has an occurrence the local checkout has never seen is now refused with that specific error, on both faces, rather than the TUI's old "already current" (the fresh-base check now always runs before the no-op check, the CLI's own order).
type Settings ¶
type Settings struct {
RepoDir, AppsRoot string
Base string
Promotable []string
// KubeContext is the already-reconciled context for the CURRENTLY selected repo: the
// flag when given, else Repo's own kube.context, else "" (the kubeconfig's current
// context). Every cluster-touching call for THIS run reads this field directly — never
// re-derives it from Repo.Kube.Context itself, which is what collapses cmd/hoist's five
// duplicate fallbacks (resolution.go, deploy.go, restart.go, history.go, main.go) into
// the one place selectRepo already computes it (settingsFor, cmd/hoist).
KubeContext string
// KubeOverride is the flag alone, never a config fallback — what `hoist promotions`/
// `hoist resume --env` and the TUI's in-flight pane use to observe a promotion against a
// DIFFERENT repo's own state file, where Repo (below) does not name that other repo's
// config entry. KubeContextFor is how a caller applies it against some other RepoConfig.
KubeOverride string
// Repo is the selected repo's own config entry, nil when running on flags alone (no
// repos[] configured, or --repo named an unconfigured path). Config is the whole loaded
// file, needed for cfg.Registries and for repoConfigFor-style repo lookups by GitHub name
// when observing some other promotion's state.
Repo *config.RepoConfig
Config *config.Config
// Resolve is the digest-resolution chain for THIS run: order, registry auth, cluster
// secret, op ref (NewResolveOptions, resolve.go).
Resolve ResolveOptions
Poll engine.PollIntervals
Deadline time.Duration
Retain time.Duration
}
Settings is what one invocation resolved from flags and the config file, once, at the boundary — cmd/hoist's own settingsFor builds it from the effective flag/config precedence selectRepo already computes; the TUI shares the exact same struct rather than rebuilding an equivalent one, which is what let the two faces' kube-context fallback drift into five separate copies.
func (Settings) KubeContextFor ¶
func (s Settings) KubeContextFor(rc config.RepoConfig) string
KubeContextFor is the one precedence a caller applies when it holds some OTHER RepoConfig than the currently selected one (an in-flight promotion for a different repos[] entry): the operator's own --kube-context override wins outright, exactly as it does for the selected repo in settingsFor; otherwise that repo's own configured context.
func (Settings) ProductionEnvs ¶
ProductionEnvs is Repo.Envs.Production, unfiltered — flags-only runs (Repo nil) have no production list at all, which AGENTS.md §4.5 treats as "nothing is known to be production", never as "everything is", since a flags-only invocation has no config to have declared one.
type StartRequest ¶
type StartRequest struct {
Plan gitops.Plan
Mode Mode
// Repo is the discovered GitOps repo ArgoAppNames/EditApps read from. nil reads s.Repo().Repo
// AT CALL TIME, not when this request was built — this fixes FB-M1: the TUI's old
// buildStartPromotion closed over a boot-time *gitops.Repo that F5 never updated, so an Argo
// Application renamed after boot was still looked up under its stale name. The CLI passes its
// own freshly discovered repo here instead, since a one-shot process only ever discovers once
// anyway.
Repo *gitops.Repo
// View picks the freshness check: nil reads s.Repo() at call time (same reasoning as Repo
// above). A view whose FromOrigin is true is checked with CheckRepoViewCurrent (the TUI's own
// origin-cache check); everything else — including the CLI's own zero RepoView, since it
// never calls LoadRepo — is checked with checkCloneCurrentForBase (the local-disk check
// against a freshly fetched origin/<base>).
View *RepoView
// Fresh carries the digests/reasons this plan was actually resolved with, for direct mode's
// own fresh-base occurrence check (checkFreshBase). nil recovers them from Plan.Edits instead
// — the TUI's rule, unchanged: a filtered, ticked-down plan has no single resolution
// behind it any more.
Fresh *freshInputs
}
StartRequest is everything StartPromotion needs to take a confirmed plan the rest of the way: claim the target env, build (or resume) its PromotionState, save it and hand back a Drive.
type StateStore ¶
type StateStore interface {
List() ([]*engine.PromotionState, error)
ListArchived() ([]*engine.PromotionState, error)
Load(id string) (*engine.PromotionState, error)
Save(*engine.PromotionState) error
Delete(id string) error
Archive(id string) error
Claim(repoFullName, targetEnv, id string) (release func(), err error)
WorktreeDir(id string) (string, error)
}
StateStore is what a Service reads and writes promotion state through — the interface FileStore below implements over engine's own state.go/claim.go functions, and the one a test's fake can implement instead without touching $XDG_STATE_HOME.
type Tick ¶
type Tick struct {
State engine.PromotionState
Done bool
Statuses []engine.StepStatus
Waiting bool
Blocked *engine.BlockedError
Retry bool
Wait time.Duration
}
Tick is one poll iteration's result — the CLI's own retry/heartbeat loop and the flight screen's tick loop both drive off exactly this shape, so neither reimplements the other's retry/wait decision (AGENTS.md §9 entry 11's own lesson: a decision made twice, even correctly today, drifts). State is a value copy of the promotion's state as it stood the instant this Step call returned — safe for a caller to hold or render without racing a later Step call on the same Drive. Done and Statuses mirror engine.DriveStatus's own return shape exactly; Waiting and Blocked are the same distinction engine.Observation makes, surfaced here because Step's own err is never ErrWaiting or a *BlockedError — see Driver.Step's own doc comment for why. Retry and Wait are meaningful together: Retry is engine.Retryable(err) — true only when Step actually returned a genuine error and the failing step is one of engine.RetryableStep's five — and Wait is engine.PollInterval for whichever step is active (the failing step, when there was one, else the promotion's own current phase), always populated regardless of err so a caller scheduling the NEXT poll never has to re-derive it from statuses itself.
type UnconfirmedError ¶
UnconfirmedError is FindInFlightForEnv's own refusal to silently exclude a candidate it could not re-observe (a transient forge/git error, a repo removed from config since the promotion started): every candidate targeting Env that could not be confirmed either way is named in Errs (sorted before this error is built), rather than treated as "not in flight" — which could misleadingly report NotFoundError when the real reason is that the answer is simply unknown.
func (*UnconfirmedError) Error ¶
func (e *UnconfirmedError) Error() string
type UsageError ¶
type UsageError struct{ Msg string }
UsageError is a caller-fixable misconfiguration Preflight/StartPromotion refuse before ever touching git, the forge or the cluster — a missing repos[].github, or --direct without a configured repo at all (AGENTS.md §4.5/§8: never provide a fallback default for required configuration). Its own type, rather than a plain fmt.Errorf, so a caller (cmd/hoist) can map it to its own "usage" exit code without string-matching the message.
func (*UsageError) Error ¶
func (e *UsageError) Error() string