From 7e151f5c96d3b0f2faf0c10699d5b8c5e32ca840 Mon Sep 17 00:00:00 2001 From: Chris Novakovic Date: Wed, 15 Apr 2026 14:31:21 +0100 Subject: [PATCH 1/2] Add `git_tags` built-in function `git_tags()` returns a list consisting of the tags that point at the repo's current commit. --- docs/config.html | 2 +- docs/lexicon.html | 15 ++++++++ rules/builtins.build_defs | 2 + src/core/config.go | 2 +- src/parse/asp/builtins.go | 1 + src/parse/asp/exec.go | 80 ++++++++++++++++++++++++++++++--------- 6 files changed, 83 insertions(+), 19 deletions(-) diff --git a/docs/config.html b/docs/config.html index 021d0798f4..c7f8cb8b92 100644 --- a/docs/config.html +++ b/docs/config.html @@ -367,7 +367,7 @@

GitFunctions

- Activates built-in functions git_branch, git_commit, git_show and git_state. If disabled they will not be usable at parse time. Defaults to true. + Activates built-in functions git_branch, git_commit, git_show, git_state and git_tags. If disabled they will not be usable at parse time. Defaults to true.

diff --git a/docs/lexicon.html b/docs/lexicon.html index d49528faac..50a3f3808c 100644 --- a/docs/lexicon.html +++ b/docs/lexicon.html @@ -1493,6 +1493,21 @@

+ +
+

+ git_tags +

+ + git_tags() + +

+ Returns the list of tags that point to the current commit by running + git tag -l --points-at HEAD. The returned list + is sorted in lexicographical order. If no tags point to the current + commit, an empty list is returned. +

+
diff --git a/rules/builtins.build_defs b/rules/builtins.build_defs index 1dd0dd8973..c9ea510f6b 100644 --- a/rules/builtins.build_defs +++ b/rules/builtins.build_defs @@ -244,6 +244,8 @@ def git_show(fmt:str) -> str: fail('Disabled in config') def git_state(clean_label:str="clean", dirty_label:str="dirty") -> str: fail('Disabled in config') +def git_tags() -> list: + fail("Disabled in config") def debug(args): pass diff --git a/src/core/config.go b/src/core/config.go index b6fc6e2602..d808f272aa 100644 --- a/src/core/config.go +++ b/src/core/config.go @@ -499,7 +499,7 @@ type Configuration struct { PreloadSubincludes []BuildLabel `help:"Subinclude targets to preload by the parser before loading any BUILD files.\nSubincludes can be slow so it's recommended to use PreloadBuildDefs where possible." example:"///pleasings//python:requirements"` BuildDefsDir []string `help:"Directory to look in when prompted for help topics that aren't known internally." example:"build_defs"` NumThreads int `help:"Number of parallel parse operations to run.\nIs overridden by the --num_threads command line flag." example:"6"` - GitFunctions bool `help:"Activates built-in functions git_branch, git_commit, git_show and git_state. If disabled they will not be usable at parse time."` + GitFunctions bool `help:"Activates built-in functions git_branch, git_commit, git_show, git_state and git_tags. If disabled they will not be usable at parse time."` } `help:"The [parse] section in the config contains settings specific to parsing files."` Display struct { UpdateTitle bool `help:"Updates the title bar of the shell window Please is running in as the build progresses. This isn't on by default because not everyone's shell is configured to reset it again after and we don't want to alter it forever."` diff --git a/src/parse/asp/builtins.go b/src/parse/asp/builtins.go index f6c2a2ef37..48fc6c6d34 100644 --- a/src/parse/asp/builtins.go +++ b/src/parse/asp/builtins.go @@ -125,6 +125,7 @@ func registerBuiltins(s *scope) { setNativeCode(s, "git_commit", execGitCommit) setNativeCode(s, "git_show", execGitShow) setNativeCode(s, "git_state", execGitState) + setNativeCode(s, "git_tags", execGitTags) } setLogCode(s, "debug", log.Debug) setLogCode(s, "info", log.Info) diff --git a/src/parse/asp/exec.go b/src/parse/asp/exec.go index e214cd3e0b..bdfca2ff55 100644 --- a/src/parse/asp/exec.go +++ b/src/parse/asp/exec.go @@ -43,17 +43,19 @@ func init() { execPromises = make(map[execKey]*execPromise, initCacheSize) } -// doExec fork/exec's a command and returns the output as a string. exec -// accepts either a string or a list of commands and arguments. The output from -// exec() is memoized by default to prevent side effects and aid in performance -// of duplicate calls to the same command with the same arguments (e.g. `git -// rev-parse --short HEAD`). The output from exec()'ed commands must be -// reproducible. If storeNegative is true, it is possible for success to return -// successfully and return an error (i.e. we're expecing a command to fail and -// want to cache the failure). +// doExec executes a command and returns what it outputted on stdout. cmdIn is the command to +// execute followed by the command's arguments, either as a list or a space-delimited string. If +// cacheOutput is true, the command's output is memoised to prevent side effects and improve the +// performance of duplicate calls to the same command with the same arguments (e.g. `git rev-parse +// --short HEAD`); to gain any benefit from this, the output from executed commands must be +// reproducible. If storeNegative is true, the command's output will be memoised (and doExec will +// return success = true) even if the command exits with a non-zero exit code; this is useful when +// a command is expected to fail. If outputAsList is true, pyObj will be a pyList consisting of one +// pyStr per line of the command's output (i.e. split on newlines); if outputAsList is false, pyObj +// will be the command's entire output in a single pyStr. // -// NOTE: Commands that rely on the current working directory must not be cached. -func doExec(s *scope, cmdIn pyObject, cacheOutput bool, storeNegative bool) (pyObj pyObject, success bool, err error) { +// NOTE: Commands that rely on the current working directory must not have their output cached. +func doExec(s *scope, cmdIn pyObject, cacheOutput, storeNegative, outputAsList bool) (pyObj pyObject, success bool, err error) { var argv []string if isType(cmdIn, "str") { argv = strings.Fields(string(cmdIn.(pyString))) @@ -97,7 +99,7 @@ func doExec(s *scope, cmdIn pyObject, cacheOutput bool, storeNegative bool) (pyO // since we're also returning an error, which tells the caller to // fallthrough their logic if a command returns with a non-zero exit code. outStr = execSetCachedOutput(key, argv, &execOut{out: outStr, success: false}) - return pyString(outStr), true, err + return parseExecOutput(outStr, outputAsList), true, err } return pyString(fmt.Sprintf("exec() unable to run command %q: %v", argv, err)), false, err @@ -107,7 +109,7 @@ func doExec(s *scope, cmdIn pyObject, cacheOutput bool, storeNegative bool) (pyO outStr = execSetCachedOutput(key, argv, &execOut{out: outStr, success: true}) } - return pyString(outStr), true, nil + return parseExecOutput(outStr, outputAsList), true, nil } // execFindCmd looks for a command using PATH and returns a cached abspath. @@ -174,6 +176,22 @@ func execGetCachedOutput(key execKey, args []string) (output *execOut, found boo return outputRaw.(*execOut), true } +// parseExecOutput returns the given command output as a pyList consisting of one pyStr per line if +// asList is true, or as a pyStr containing the entire command output if asList is false. +func parseExecOutput(output string, asList bool) pyObject { + if !asList { + return pyString(output) + } + // We don't know how many lines the command output consists of, but 8 seems like a reasonable + // initial upper limit, given that this function is only used to parse the output of git(1) + // commands. + ret := make([]pyObject, 0, 8) + for line := range strings.SplitSeq(output, "\n") { + ret = append(ret, pyString(line)) + } + return pyList(ret) +} + // execGitBranch returns the output of a git_branch() command. // // git_branch() returns the output of `git symbolic-ref -q --short HEAD` @@ -189,7 +207,7 @@ func execGitBranch(s *scope, args []pyObject) pyObject { cacheOutput := true storeNegative := true - gitSymRefResult, success, err := doExec(s, pyList(cmdIn), cacheOutput, storeNegative) + gitSymRefResult, success, err := doExec(s, pyList(cmdIn), cacheOutput, storeNegative, false) switch { case success && err == nil: return gitSymRefResult @@ -208,7 +226,7 @@ func execGitBranch(s *scope, args []pyObject) pyObject { cmdIn[2] = pyString("-q") cmdIn[3] = pyString("--format=%D") storeNegative = false - gitShowResult, success, err := doExec(s, pyList(cmdIn), cacheOutput, storeNegative) + gitShowResult, success, err := doExec(s, pyList(cmdIn), cacheOutput, storeNegative, false) if !success { // doExec returns a formatted error string return s.Error("exec() %q failed: %v", pyList(cmdIn).String(), err) @@ -236,7 +254,7 @@ func execGitCommit(s *scope, args []pyObject) pyObject { cacheOutput := true storeNegative := false // No error handling required since we don't want to retry - pyResult, success, err := doExec(s, pyList(cmdIn), cacheOutput, storeNegative) + pyResult, success, err := doExec(s, pyList(cmdIn), cacheOutput, storeNegative, false) if !success { return s.Error("git_commit() failed: %v", err) } @@ -293,7 +311,7 @@ func execGitShow(s *scope, args []pyObject) pyObject { cacheOutput := true storeNegative := false - pyResult, success, err := doExec(s, pyList(cmdIn), cacheOutput, storeNegative) + pyResult, success, err := doExec(s, pyList(cmdIn), cacheOutput, storeNegative, false) if !success { return s.Error("git_show() failed: %v", err) } @@ -315,7 +333,7 @@ func execGitState(s *scope, args []pyObject) pyObject { cacheOutput := true storeNegative := false - pyResult, success, err := doExec(s, pyList(cmdIn), cacheOutput, storeNegative) + pyResult, success, err := doExec(s, pyList(cmdIn), cacheOutput, storeNegative, false) if !success { return s.Error("git_state() failed: %v", err) } @@ -330,6 +348,34 @@ func execGitState(s *scope, args []pyObject) pyObject { return cleanLabel } +// execGitTags implements the built-in git_tags function, which returns the output of +// `git tag -l --sort=refname --points-at HEAD`. Tag names are returned in lexicographical order. +func execGitTags(s *scope, args []pyObject) pyObject { + cmdIn := []pyObject{ + pyString("git"), + pyString("tag"), + pyString("-l"), + pyString("--sort=refname"), + pyString("--points-at"), + pyString("HEAD"), + } + + // No special error handling required here - `git tag` exits with exit code 0 even if no tags + // point at HEAD. + pyResult, success, err := doExec(s, pyList(cmdIn), true, false, true) + if !success { + return s.Error("git_tags() failed: %v", err) + } + + // If no tags point at HEAD, return an empty list, rather than the list consisting of a single + // empty string element that doExec returned to us. + if list, _ := pyResult.(pyList); list.Len() == 1 && list.Item(0) == pyString("") { + return pyList{} + } + + return pyResult +} + // execMakeKey returns an execKey. func execMakeKey(args []string) execKey { return execKey{ From cc01afb34b1daf8115d0de33ddc0a0eedcfdb8b9 Mon Sep 17 00:00:00 2001 From: Chris Novakovic Date: Wed, 15 Apr 2026 17:01:51 +0100 Subject: [PATCH 2/2] Address comments --- src/parse/asp/exec.go | 44 +++++++++++++++++++++---------------------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/src/parse/asp/exec.go b/src/parse/asp/exec.go index bdfca2ff55..41861077c8 100644 --- a/src/parse/asp/exec.go +++ b/src/parse/asp/exec.go @@ -43,16 +43,23 @@ func init() { execPromises = make(map[execKey]*execPromise, initCacheSize) } -// doExec executes a command and returns what it outputted on stdout. cmdIn is the command to -// execute followed by the command's arguments, either as a list or a space-delimited string. If -// cacheOutput is true, the command's output is memoised to prevent side effects and improve the +// doExec executes a command and returns what it outputted on stdout. +// +// cmdIn is the command to execute followed by the command's arguments, either as a list or a +// space-delimited string. +// +// If cacheOutput is true, the command's output is memoised to prevent side effects and improve the // performance of duplicate calls to the same command with the same arguments (e.g. `git rev-parse // --short HEAD`); to gain any benefit from this, the output from executed commands must be -// reproducible. If storeNegative is true, the command's output will be memoised (and doExec will -// return success = true) even if the command exits with a non-zero exit code; this is useful when -// a command is expected to fail. If outputAsList is true, pyObj will be a pyList consisting of one -// pyStr per line of the command's output (i.e. split on newlines); if outputAsList is false, pyObj -// will be the command's entire output in a single pyStr. +// reproducible. +// +// If storeNegative is true, the command's output will be memoised (and doExec will return with +// success = true) even if the command exits with a non-zero exit code; this is useful when +// a command is expected to fail. +// +// If outputAsList is true, pyObj will be a pyList consisting of one pyStr per line of the command's +// output (i.e. split on newlines); if outputAsList is false, pyObj will be the command's entire +// output in a single pyStr. // // NOTE: Commands that rely on the current working directory must not have their output cached. func doExec(s *scope, cmdIn pyObject, cacheOutput, storeNegative, outputAsList bool) (pyObj pyObject, success bool, err error) { @@ -205,9 +212,7 @@ func execGitBranch(s *scope, args []pyObject) pyObject { } cmdIn = append(cmdIn, pyString("HEAD")) - cacheOutput := true - storeNegative := true - gitSymRefResult, success, err := doExec(s, pyList(cmdIn), cacheOutput, storeNegative, false) + gitSymRefResult, success, err := doExec(s, pyList(cmdIn), true, true, false) switch { case success && err == nil: return gitSymRefResult @@ -225,8 +230,7 @@ func execGitBranch(s *scope, args []pyObject) pyObject { cmdIn[1] = pyString("show") cmdIn[2] = pyString("-q") cmdIn[3] = pyString("--format=%D") - storeNegative = false - gitShowResult, success, err := doExec(s, pyList(cmdIn), cacheOutput, storeNegative, false) + gitShowResult, success, err := doExec(s, pyList(cmdIn), true, false, false) if !success { // doExec returns a formatted error string return s.Error("exec() %q failed: %v", pyList(cmdIn).String(), err) @@ -251,10 +255,8 @@ func execGitCommit(s *scope, args []pyObject) pyObject { pyString("HEAD"), } - cacheOutput := true - storeNegative := false // No error handling required since we don't want to retry - pyResult, success, err := doExec(s, pyList(cmdIn), cacheOutput, storeNegative, false) + pyResult, success, err := doExec(s, pyList(cmdIn), true, false, false) if !success { return s.Error("git_commit() failed: %v", err) } @@ -309,9 +311,7 @@ func execGitShow(s *scope, args []pyObject) pyObject { pyString(fmt.Sprintf("--format=%s", formatVerb)), } - cacheOutput := true - storeNegative := false - pyResult, success, err := doExec(s, pyList(cmdIn), cacheOutput, storeNegative, false) + pyResult, success, err := doExec(s, pyList(cmdIn), true, false, false) if !success { return s.Error("git_show() failed: %v", err) } @@ -331,9 +331,7 @@ func execGitState(s *scope, args []pyObject) pyObject { pyString("--porcelain"), } - cacheOutput := true - storeNegative := false - pyResult, success, err := doExec(s, pyList(cmdIn), cacheOutput, storeNegative, false) + pyResult, success, err := doExec(s, pyList(cmdIn), true, false, false) if !success { return s.Error("git_state() failed: %v", err) } @@ -369,7 +367,7 @@ func execGitTags(s *scope, args []pyObject) pyObject { // If no tags point at HEAD, return an empty list, rather than the list consisting of a single // empty string element that doExec returned to us. - if list, _ := pyResult.(pyList); list.Len() == 1 && list.Item(0) == pyString("") { + if list, ok := pyResult.(pyList); ok && list.Len() == 1 && list.Item(0) == pyString("") { return pyList{} }