Add condition field to custom command prompts (#5364)

When building multi-step custom command forms, some prompts are only
relevant depending on earlier answers. Without conditional logic, users
must dismiss irrelevant prompts manually.

Prompts now accept a `condition` field with a template expression
evaluated against prior form values. Skipped prompts default to an empty
string.
This commit is contained in:
Stefan Haller 2026-04-06 19:17:54 +02:00 committed by GitHub
commit 7f5250a64a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 300 additions and 0 deletions

View file

@ -102,6 +102,7 @@ These fields are applicable to all prompts.
| type | One of 'input', 'confirm', 'menu', 'menuFromCommand' | yes |
| title | The title to display in the popup panel | no |
| key | Used to reference the entered value from within the custom command. E.g. a prompt with `key: 'Branch'` can be referred to as `{{.Form.Branch}}` in the command | yes |
| condition | A Go template expression; if it resolves to empty string or `false`, the prompt is skipped. See [Conditional prompts](#conditional-prompts) | no |
### Input
@ -319,6 +320,41 @@ Here's an example using a command but not specifying anything else: so each line
command: 'ls'
```
### Conditional prompts
Here's an example of a conditional prompt:
```yml
customCommands:
- key: 'a'
context: 'localBranches'
prompts:
- type: 'menu'
title: 'How do you want to create the branch?'
key: 'Method'
options:
- value: 'simple'
name: 'Simple'
description: 'just a branch name'
- value: 'prefix'
name: 'With prefix'
description: 'with a category prefix'
- type: 'menu'
title: 'Branch prefix'
key: 'Prefix'
condition: '{{ eq .Form.Method "prefix" }}'
options:
- value: 'feature/'
- value: 'hotfix/'
- value: 'release/'
- type: 'input'
title: 'Branch name'
key: 'Name'
command: "git checkout -b '{{.Form.Prefix}}{{.Form.Name}}'"
```
In this example the 'Branch prefix' menu only appears if the user chose 'With prefix'. Otherwise it is skipped and `.Form.Prefix` defaults to empty string.
## Placeholder values
Your commands can contain placeholder strings using Go's [template syntax](https://jan.newmarch.name/golang/template/chapter-template.html). The template syntax is pretty powerful, letting you do things like conditionals if you want, but for the most part you'll simply want to be accessing the fields on the following objects:

View file

@ -730,6 +730,9 @@ type CustomCommandPrompt struct {
// Like valueFormat but for the labels. If `labelFormat` is not specified, `valueFormat` is shown instead.
// Only for menuFromCommand prompts.
LabelFormat string `yaml:"labelFormat" jsonschema:"example={{ .branch | green }}"`
// A Go template expression evaluated against the current form state. If it resolves to empty string or 'false', the prompt is skipped.
Condition string `yaml:"condition" jsonschema:"example={{ eq .Form.Choice \"yes\" }}"`
}
type CustomCommandSuggestions struct {

View file

@ -107,12 +107,41 @@ func (self *HandlerCreator) call(customCommand config.CustomCommand) func() erro
default:
return errors.New("custom command prompt must have a type of 'input', 'menu', 'menuFromCommand', or 'confirm'")
}
if prompt.Condition != "" {
showPrompt := f
conditionTemplate := prompt.Condition
f = func() error {
resolved, err := resolveCondition(conditionTemplate, resolveTemplate)
if err != nil {
return err
}
if resolved {
return showPrompt()
}
if _, exists := form[prompt.Key]; !exists {
form[prompt.Key] = ""
}
return g()
}
}
}
return f()
}
}
func resolveCondition(condition string, resolveTemplate func(string) (string, error)) (bool, error) {
if strings.TrimSpace(condition) == "" {
return false, nil
}
resolved, err := resolveTemplate(condition)
if err != nil {
return false, err
}
return strings.TrimSpace(resolved) != "" && strings.TrimSpace(resolved) != "false", nil
}
func (self *HandlerCreator) inputPrompt(prompt *config.CustomCommandPrompt, wrappedF func(string) error) error {
findSuggestionsFn, err := self.generateFindSuggestionsFunc(prompt)
if err != nil {

View file

@ -0,0 +1,71 @@
package custom_commands
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ConditionalPromptFalseString = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Conditional prompt is skipped when condition is bare false or template false",
ExtraCmdArgs: []string{},
Skip: false,
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("blah")
},
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
{
Key: "a",
Context: "files",
Command: `echo "{{.Form.Choice}}" > result.txt`,
Prompts: []config.CustomCommandPrompt{
{
Key: "Choice",
Type: "menu",
Title: "Pick one",
Options: []config.CustomCommandMenuOption{
{
Name: "foo",
Description: "Foo",
Value: "FOO",
},
{
Name: "bar",
Description: "Bar",
Value: "BAR",
},
},
},
{
Key: "Skipped1",
Type: "input",
Title: "This is always skipped (false)",
Condition: `false`,
},
{
Key: "Skipped2",
Type: "input",
Title: "This is always skipped (template false)",
Condition: `{{ eq "a" "b" }}`,
},
},
},
}
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press("a")
t.ExpectPopup().Menu().Title(Equals("Pick one")).Select(Contains("foo")).Confirm()
// Both conditional prompts skipped, file created directly
t.Views().Files().
Focus().
Lines(
Contains("result.txt").IsSelected(),
)
t.FileSystem().FileContent("result.txt", Equals("FOO\n"))
},
})

View file

@ -0,0 +1,55 @@
package custom_commands
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ConditionalPromptFalseValue = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Entering literal false as form input does not incorrectly skip a conditional prompt",
ExtraCmdArgs: []string{},
Skip: false,
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("blah")
},
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
{
Key: "a",
Context: "files",
Command: `echo "{{.Form.Word}} {{.Form.Extra}}" > result.txt`,
Prompts: []config.CustomCommandPrompt{
{
Key: "Word",
Type: "input",
Title: "Enter a word",
},
{
Key: "Extra",
Type: "input",
Title: "Enter extra",
Condition: `{{ eq .Form.Word "false" }}`,
},
},
},
}
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
t.Views().Files().
IsFocused().
Press("a")
t.ExpectPopup().Prompt().Title(Equals("Enter a word")).Type("false").Confirm()
// Condition {{ eq .Form.Word "false" }} evaluates to true, so prompt should appear
t.ExpectPopup().Prompt().Title(Equals("Enter extra")).Type("baz").Confirm()
t.Views().Files().
Focus().
Lines(
Contains("result.txt").IsSelected(),
)
t.FileSystem().FileContent("result.txt", Equals("false baz\n"))
},
})

View file

@ -0,0 +1,96 @@
package custom_commands
import (
"github.com/jesseduffield/lazygit/pkg/config"
. "github.com/jesseduffield/lazygit/pkg/integration/components"
)
var ConditionalPrompts = NewIntegrationTest(NewIntegrationTestArgs{
Description: "Using a custom command with conditional prompts that are skipped based on form values",
ExtraCmdArgs: []string{},
Skip: false,
SetupRepo: func(shell *Shell) {
shell.EmptyCommit("initial commit")
},
SetupConfig: func(cfg *config.AppConfig) {
cfg.GetUserConfig().CustomCommands = []config.CustomCommand{
{
Key: "a",
Context: "files",
Command: `echo "{{.Form.Choice}}{{if .Form.Detail}} {{.Form.Detail}}{{end}}" > result.txt`,
Prompts: []config.CustomCommandPrompt{
{
Key: "Choice",
Type: "menu",
Title: "Choose an option",
Options: []config.CustomCommandMenuOption{
{
Name: "first",
Description: "First option",
Value: "FIRST",
Key: "1",
},
{
Name: "second",
Description: "Second option",
Value: "SECOND",
Key: "H",
},
},
},
{
Key: "Detail",
Type: "input",
Title: "Enter detail for second option",
Condition: `{{ eq .Form.Choice "SECOND" }}`,
},
},
},
}
},
Run: func(t *TestDriver, keys config.KeybindingConfig) {
// Test 1: Select "first" via key — conditional prompt should be skipped
t.Views().Files().
IsFocused().
Press("a")
t.ExpectPopup().Menu().
Title(Equals("Choose an option"))
t.Views().Menu().Press("1")
// Detail prompt should be skipped, file should be created directly
t.Views().Files().
Focus().
Lines(
Contains("result.txt").IsSelected(),
)
t.FileSystem().FileContent("result.txt", Equals("FIRST\n"))
// Test 2: Select "second" via key — conditional prompt should appear
t.Shell().DeleteFile("result.txt")
t.GlobalPress(keys.Files.RefreshFiles)
t.Views().Files().
IsEmpty().
IsFocused().
Press("a")
t.ExpectPopup().Menu().
Title(Equals("Choose an option"))
t.Views().Menu().Press("H")
// Detail prompt should appear because Choice == "SECOND"
t.ExpectPopup().Prompt().Title(Equals("Enter detail for second option")).Type("extra").Confirm()
t.Views().Files().
Focus().
Lines(
Contains("result.txt").IsSelected(),
)
t.FileSystem().FileContent("result.txt", Equals("SECOND extra\n"))
},
})

View file

@ -172,6 +172,9 @@ var tests = []*components.IntegrationTest{
custom_commands.AccessCommitProperties,
custom_commands.BasicCommand,
custom_commands.CheckForConflicts,
custom_commands.ConditionalPromptFalseString,
custom_commands.ConditionalPromptFalseValue,
custom_commands.ConditionalPrompts,
custom_commands.CustomCommandsSubmenu,
custom_commands.CustomCommandsSubmenuWithSpecialKeybindings,
custom_commands.FormPrompts,

View file

@ -235,6 +235,13 @@
"examples": [
"{{ .branch | green }}"
]
},
"condition": {
"type": "string",
"description": "A Go template expression evaluated against the current form state. If it resolves to empty string or 'false', the prompt is skipped.",
"examples": [
"{{ eq .Form.Choice \"yes\" }}"
]
}
},
"additionalProperties": false,