Workflows
Writing workflows Partly available
This tutorial takes a workflow from the first line to a tested file. Read Workflows overview first for the model. Each worked example is a complete file that you save into your workflows folder.
The shape of a module#
Every workflow file is a Lua module with the same parts:
local M = {}
M.meta = {
name = "branch-from-ticket",
description = "Create a branch from a ticket key",
trigger = { on = "manual" },
concurrency = "queue",
inputs = { ticket = { type = "string", prompt = "Ticket key" } },
}
-- the work of the workflow goes here
end
return M
local M = {}creates the module table.M.metadescribes the workflow. GitMaster reads it when it loads the file, without runningexecute, so keep logic out of it.function M.execute(ctx, input)is the code that runs. GitMaster calls it with thectxobject and theinputvalues.return Mreturns the module to GitMaster.
The metadata#
| Field | Meaning |
|---|---|
name | The name of the workflow. |
description | One line that says what the workflow does. |
trigger | When the workflow runs. on names the trigger, such as manual, a phase such as before.restack, or an event such as stack.parent_moved. match narrows the trigger with the hook syntax, for example branch:feat/*. key adds a keyboard shortcut to a manual workflow. |
concurrency | What happens when the trigger fires during a run: queue, replace or skip. The default is queue. |
inputs | Values the user enters before the run. Each input has a type and a prompt, which the dialog shows. |
The concurrency values work this way:
| Value | When the trigger fires during a run |
|---|---|
queue | One run waits behind the active run. Further triggers merge into that waiting run. |
replace | The active run is cancelled if it has not reached an apply action yet. Otherwise the new run waits in the queue. |
skip | The trigger is discarded. |
For the full list of trigger names, see Triggers and phases.
What input contains#
input.inputsholds the values the user entered, by name. In the example above, the ticket key isinput.inputs.ticket.input.repois a read-only snapshot of the repository, withroot,branch,headandstaged_diff.input.triggerholds the payload of the trigger. In a stack event,input.trigger.parentnames the parent branch.
staged_diff is the diff text. A diff over 1 MiB is cut at a line break and ends with [diff truncated at 1 MiB], and input.repo.staged_diff_path is then the path of a file that holds the whole diff.
What execute returns#
What the function returns depends on the phase it runs in:
| Phase | What the return value means |
|---|---|
manual and events | Ignored. Finishing without an error means the run is done. |
before.<operation> (gate) | ctx.block(reason) ends the run as blocked and stops the operation. An error also stops the operation. |
transform.<field> | The returned value becomes the value of the field. Return nil for no suggestion. |
after.<operation> | Observed only. A failure produces a warning and nothing else. |
ctx.fail(reason) ends any run as failed, with the reason.
If you change a field while a transform runs, GitMaster does not overwrite your text. The returned value becomes a suggestion instead.
Calls to ctx#
Each call to ctx is an action of GitMaster. The action returns a result with an outcome. The outcome done means the action worked. Every other outcome, such as conflict, stale, rejected or timeout, raises an error. An error ends the run unless the script handles it.
ctx.try(fn) runs a function and returns the outcome instead of raising an error:
local r = ctx.try(function()
return ctx.restack.apply({ plan_id = plan.plan_id })
end)
if r.outcome == "conflict" then
-- the script decides what happens next
end
These rules also apply:
ctx.notify(text)writes a message into the run's record, marked as coming from the script.- A call to a
ctxfunction that does not exist is an error. The message lists the valid names at that level, so you can correct the call from the message. - Each
ctxcall is one entry in the run's record, with its resolved arguments, its result and its outcome.
Running programs with ctx.exec#
ctx.exec(argv, options) runs a program that you have installed on your computer.
argvis a list. The first element is the program, and the rest are its arguments. GitMaster never passes the list to a shell. Each element stays one argument, even when it contains spaces, quotes or semicolons, so text from the repository cannot add flags or commands.stdinsends text to the program's standard input. Use it for large text, such as a diff. Do not put large text inargv.timeoutsets a limit, such as"30s". When the limit passes, GitMaster stops the process and the outcome istimeout.- The result has
text, which is the standard output with the surrounding whitespace removed, andexit_code. A non-zero exit code gives the outcomefailed.
Output from a program is untrusted. A program can print anything, so treat text as data. Use it for suggestions that a person accepts, and check it before you act on it.
local r = ctx.exec({ "ticket-branch-name", input.inputs.ticket }, { timeout = "30s" })
The sandbox limits the script. It does not limit the program that exec starts. See Approvals and security.
Approvals from the script#
ctx.approve(summary) shows the summary to you and pauses the run until you answer. It returns true or false.
if ctx.approve("Create branch " .. r.text .. "?") then
ctx.branch.create({ name = r.text, checkout = true })
end
The script can ask for approval, but GitMaster decides what needs approval, and the script cannot skip those rules. After any ctx.exec in a run, every later apply action asks you again, and the dialog shows the resolved arguments. See Approvals and security.
Waiting for an event#
ctx.wait(event, { timeout = "24h" }) pauses the run until the named event happens. It returns resumed, aborted or timeout. A wait lasts only while GitMaster is open. Closing the app ends the run.
Loops and control flow#
Conditions, loops and functions are ordinary Lua. A loop must end. GitMaster stops any run that exceeds its limits, so a loop that never ends is stopped rather than left running. The transform example below uses a bounded retry loop.
Limits and what the sandbox removes#
Each run has limits on:
- the number of instructions;
- total time;
- memory;
- string size;
- stack depth;
- the number of
ctxcalls.
A run that exceeds a limit ends as failed, with the reason. Cancel always works, even when the script is in a loop or waiting.
The sandbox removes these from the environment: the libraries io, os, debug and package; the functions require, load, loadstring, loadfile, dofile, print, warn, collectgarbage, getfenv, setfenv and newproxy; and string.dump. The random numbers are not available either: math.random and math.randomseed are removed. Use ctx.now() and ctx.random() instead. Both calls are recorded in the run.
The global environment is read-only, and each run starts with a fresh one, so nothing one run sets affects the next run.
Worked examples#
Branch from a ticket#
This is a manual workflow with one input. Run it from the command palette, with Run Workflow….
local M = {}
M.meta = {
name = "branch-from-ticket",
description = "Create a branch from a ticket key",
trigger = { on = "manual" },
concurrency = "queue",
inputs = { ticket = { type = "string", prompt = "Ticket key" } },
}
local r = ctx.exec({ "ticket-branch-name", input.inputs.ticket }, { timeout = "30s" })
if ctx.approve("Create branch " .. r.text .. "?") then
ctx.branch.create({ name = r.text, checkout = true })
end
end
return M
The workflow runs a naming program with the ticket key, and the program's output becomes the branch name. It asks you to confirm the name. The branch is created and checked out only when you approve. Rejecting leaves the repository unchanged.
Block restack on a protected branch#
This gate runs before every restack.
local M = {}
M.meta = { name = "block-restack-on-protected", trigger = { on = "before.restack" } }
local root = input.trigger.root
if root == "main" or root == "release" then
ctx.block("Restack on " .. root .. " is blocked")
end
end
return M
When the root of the restack is main or release, the script blocks the restack, and the reason is the message you see. In any other case the script finishes without blocking, and the restack goes ahead.
Commit message from an LLM#
This transform runs when GitMaster builds the commit message.
local M = {}
M.meta = { name = "commit-message-with-llm", trigger = { on = "transform.commit_message" } }
for _ = 1, 3 do
local r = ctx.exec({ "llm-cli", "-p", "Write a conventional commit message for this diff. Reply with the message only." },
{ stdin = input.repo.staged_diff, timeout = "60s" })
if #r.text > 0 then return r.text end
end
end
return M
The staged diff goes to the command-line LLM on standard input. The script returns the first non-empty answer, and it tries up to three times. If every try is empty, the function returns nothing, which means no suggestion. Replace llm-cli with the tool you use.
Restack children when the parent moves#
This event runs when the parent branch of a stack gets new commits. It uses concurrency = "replace", so a newer event replaces this run if the run has not reached its apply step yet.
local M = {}
M.meta = { name = "restack-children-on-parent-move", trigger = { on = "stack.parent_moved" }, concurrency = "replace" }
local plan = ctx.restack.plan({ root = input.trigger.branch })
local r = ctx.try(function() return ctx.restack.apply({ plan_id = plan.plan_id }) end)
if r.outcome == "conflict" then
local w = ctx.wait("restack.resumed", { timeout = "24h" })
if w.outcome ~= "resumed" then return ctx.fail("conflict not resolved") end
elseif r.outcome ~= "done" then
return ctx.fail("restack " .. r.outcome)
end
ctx.notify("Restacked " .. input.trigger.branch .. " onto " .. input.trigger.parent)
end
return M
The script plans a restack of the branch whose parent moved, and of the branches under it. It always applies the plan, even when it moves no branch, because a plan whose only branch conflicts moves nothing and the conflict is reported by the apply. It applies the plan inside ctx.try, so a conflict does not raise an error. On a conflict, it waits up to 24 hours for the restack to be resumed. If the wait ends in anything other than resumed, the run fails with a reason. Any other outcome also fails the run. On success, it sends a notification.
Check, dry-run and test while you write#
Three command-line commands help you test a file without the app's UI. They run only when you start them, so they are not a server.
| Command | What it does |
|---|---|
gitmaster workflow check <file> | Compiles the file, validates M.meta, and checks the trigger and the inputs. Each error prints as file:line: message. Add --json for machine-readable output. The exit code is non-zero on an error. |
gitmaster workflow dry-run <file> [--input k=v] [--repo <path>] | Runs the script with a simulated ctx. Reads are real. Other actions are recorded, not run. Prints the list of calls. |
gitmaster workflow test <file> [fixture] | Runs the script against a fake ctx that returns canned responses from a Lua fixture. Checks the sequence of calls and the returned value. |
The command line never applies a change. Real effects run only from the app, with you as the actor.
The app also writes generated reference files into a .api folder inside your workflows folder. They list every ctx function, with its arguments and outcomes. Point your editor at them to get completion for ctx. See Workflow API.
The script editor#
The Script tab of the Workflows page edits the workflow file. Type in the text, then Save writes the file. Dry Run walks the text in the editor, so you can test a change before you save it.
Highlighting#
Keywords, strings, numbers and comments have their own colors, and ctx is shown as the context object. continue and export are coloured as keywords, as in Luau. type is not, because it is also a function of Lua.
Completion#
After ctx., the editor offers every function of ctx, with its signature and what the reference says about it. After ctx.branch., it offers the calls of that group. Inside the argument table of a call, after { or ,, it offers the keys of that table with their types, and it marks the required ones. Nothing is offered inside a string or a comment. The list comes from the same catalog as the sandbox, so it matches the reference in the .api folder.
Problems#
The problems of the check appear under the editor, each with its line. Click a problem to move the caret to its line.
Related pages#
- Workflows overview: the model, where files live, and the Runs tab.
- Triggers and phases: the events, operations and phases a workflow can run on.
- Approvals and security: review, approvals and the sandbox.
- Workflow API: every
ctxfunction, with its arguments and outcomes.