Adding a worktree-mutating command
This content is for v0.28. Switch to the latest version for up-to-date documentation.
A mutation command is one that changes worktree state: it creates, removes, moves or
rewrites something, and therefore has questions to ask, safety refusals to honor, and
two bypass axes to expose. Every new one goes through internal/flow/ — the model in
flow-layer.md.
A read-only command (list, tree, resolve) needs none of this: parse flags, call
the service, hand the result to output/.
1. Declare the vocabulary in domain/
Section titled “1. Declare the vocabulary in domain/”Constants first, so nothing downstream invents a string:
domain.CmdSplitfor the command name,domain.FlagIntofor each new flag (internal/domain/constants.go).- Every user-visible label, description, recap field and skip reason. A step's prose
lives in
constants.go, not as a literal in the flow. - A sentinel in
internal/domain/errors.gofor each required selection that has no safe default, worded so it names the flag:ErrSplitTargetRequired. - If a surface will have to schedule it, an
OpKindconstant.
2. Put the decisions in rules/
Section titled “2. Put the decisions in rules/”Anything that is a decision over data — is this safe, which of these applies, what is
the default here — is a pure function in internal/rules/, taking domain types and
returning domain types. No I/O, no writing. It is then testable as a table and callable
from the flow, the service and any surface.
3. Write the flow package
Section titled “3. Write the flow package”internal/flow/split/split.go — the run:
type Request struct { Branch string Into string Force bool // the safety axis, if the command has refusals to lift}
type Outcome struct { Branch string Result domain.SplitResult Aborted bool}
type Presenter interface { flow.Presenter Split(Outcome) error // the typed conclusion, one per command}
type Params struct { Context flow.Context Request Request Prompter flow.Prompter Presenter Presenter}
func Run(params Params) (Outcome, error) { f := &splitFlow{ctx: params.Context, request: params.Request, prompter: params.Prompter, presenter: params.Presenter} return f.run()}Rules that are not negotiable:
- The package imports only
internal/service,internal/rules,internal/domainand the stdlib. Never cobra, bubbletea, lipgloss,internal/output,internal/tui,internal/configorinternal/commands. If you need something onlyinfra/has, add a thin wrapper inservice/— asworktree.FindByBranchdoes. Requestcarries no--yesand no--output.--forcedoes belong there.- Errors are returned. A user abort is
presenter.Notice(flow.AbortedNotice)followed byOutcome{Aborted: true}, nil. - Long work goes through
presenter.Stage; hook output throughpresenter.HookPhase; a line inside an ongoing phase throughpresenter.Status. The flow never prints.
internal/flow/split/steps.go — the questions:
const ( KeyBranch = "split.branch" KeyInto = "split.into" KeyRecap = "split.recap")
func (f *splitFlow) session() flow.Session { return flow.Session{ ErrLabel: domain.SplitWizardErrLabel, Presets: flow.NewAnswers(map[string]string{ KeyBranch: f.request.Branch, KeyInto: f.request.Into, }), Steps: []flow.Step{f.branchStep(), f.intoStep(), f.recapStep()}, }}For each step, decide its Resolve — that is the bypass taxonomy, and it is the
step's own business:
| The step is… | Resolve |
|---|---|
| a decision with a safe default | returns that Answer. Never destructive. |
| a required selection with no safe default | returns an error naming the flag, usually the domain sentinel |
| answerable only by a human | omitted entirely — flow.Unattended then refuses, naming Label and Flag |
And the rest of the fields:
Skip func(Answers) (skip bool, reason string)when the step can become irrelevant. The reason is user-visible; put it inconstants.go.Buildfor content derived from earlier answers,Loadwhen deriving it does I/O (plusLoadingMessage). Never do slow work inBuild.Summarizewhen the raw answer value is not what the user should read back.Flagso a refusal can name the flag that would have answered the step.Blockerson theStepContentof a step whose dangerous option is gated by safety refusals — one entry per refusal, never folded into the prose, so a surface can have them lifted one at a time.- The recap is always the last step and always unconditional. Its
Buildnames every part of the plan, including the parts a flag resolved — read the value fromAnswers, which returns presets too. A flag must never make a line disappear.
If a surface may run several of these at once, declare how:
func Operation() flow.Operation { return flow.Operation{Kind: domain.OpKindSplit, Mode: flow.ModeBlocking, TargetKey: KeyBranch}}4. Wire the command
Section titled “4. Wire the command”internal/commands/wt/split.go holds flag wiring and nothing else:
func runSplit(cmd *cobra.Command, args []string) error { into, _ := cmd.Flags().GetString(domain.FlagInto) force, _ := cmd.Flags().GetBool(domain.FlagForce) yes, _ := cmd.Flags().GetBool(domain.FlagYes) format, _ := cmd.Flags().GetString(domain.FlagOutput)
if format == domain.OutputJSON && !yes { return domain.ErrSplitJSONNeedsYes }
dir, err := os.Getwd() if err != nil { return fmt.Errorf("get working directory: %w", err) } config, err := shared.LoadConfig(cmd, dir) if err != nil { return err }
// The one place --yes is read: which Prompter gets installed. interactive := rules.IsHumanFormat(format) && !yes && term.IsTerminal(int(os.Stdin.Fd()))
_, err = splitflow.Run(splitflow.Params{ Context: flowContext(config), Request: splitflow.Request{Branch: branchName, Into: into, Force: force}, Prompter: flowPrompter(flowPrompterParams{Interactive: interactive}), Presenter: splitPresenter{cliPresenter: newPresenter(cmd, format)}, }) return err}Flag help strings are uniform:
--yes/-y— "Skip all prompts; resolve every decision from flags and safe defaults (…)"--force— "Lift safety refusals (…); still asks to confirm unless --yes"
Register the command in its parent group and give it a GroupID (domain.CmdGroup*),
or it renders under a stray "Additional Commands" heading.
5. Add the CLI presenter
Section titled “5. Add the CLI presenter”In internal/commands/wt/presenter.go, next to createPresenter and cleanPresenter:
type splitPresenter struct{ cliPresenter }
func (p splitPresenter) Split(outcome splitflow.Outcome) error { if p.format == domain.OutputJSON { return output.WriteSplitJSON(p.cmd.OutOrStdout(), outcome.Result) } output.Frame(p.cmd.OutOrStdout(), func() { output.FormatSplitResult(p.cmd.OutOrStdout(), /* … */) }) return nil}cliPresenter already implements Stage, HookPhase, Notice and Status — embed
it and add only the typed conclusion. The frame is applied exactly once, here; JSON
and machine output are never framed.
6. Test it
Section titled “6. Test it”- Flow tests in the flow package, with
flowtest.ScriptedPrompterandflowtest.Recorder. Assert on the answers that were asked (AskedKeys()), on theStepContenta step produced (the recap prose is user-visible behavior), and on the outcome. - Unattended tests with
flow.Unattended{}directly: each required selection refuses and names its flag, each defaulted decision lands on the safe value. - Integration tests at the Cobra level (
gittest.InitRepo+WTM_PROJECT_DIR/WTM_STATE_DIR) for the two axes end to end:--yeswithout the required flag errors,--forcealone still confirms,--output jsonrequires--yes.
7. Documentation
Section titled “7. Documentation”make docs— regeneratesdocs/, never hand-edited.- Add the command to the
README.mdoverview table, in the same group as the root--help. - Update the agent skill (
internal/commands/agents/assets/using-wtm/, the reference file of the command's topic) if the agent-facing surface changed (a new command, a new flag, a changed JSON shape, changed failure/abort semantics). - Run the
build-validatorsubagent. Step 6 fails the run ifinternal/flow/gained a forbidden import.
Optional: make it work in the dashboard
Section titled “Optional: make it work in the dashboard”Nothing in the flow changes. In internal/tui/dashboard/actions.go, add a startSplit
that checks busyReason, calls beginOp(splitflow.Operation()), and launches
splitflow.Run in a tea.Cmd with the dashboard's prompter and a presenter
embedding dashboard.presenter plus the typed conclusion. If the flow uses a step kind
the dashboard's modal cannot render, that is the only work left — and flowui will
refuse an unknown kind rather than guess, so you will hear about it immediately.