Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions free/ci/cmd/help_dispatch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package main

// Purpose: `nself ci <sub> --help` and `-h` print that subcommand's help and exit 0 before anything runs.
// Inputs: a matched key and the args after it; the v1.4 and v1.5 subcommand tables.
// Outputs: help text on stdout and exit code 0.
// Constraints: the dispatchers (run, runV15) call this before the handler and before the nodes
// --coordinator client, so a help request never starts a run or sends a request; a "--" terminator
// ends the scan (wantsHelp); a group word with no handler of its own (config, nodes pools) lists its
// children instead of falling through to the gate.

import (
"fmt"
"sort"
"strings"

"github.com/nself-org/plugins/free/ci/internal/surface"
)

// subHelp returns the help text of a registered key: the captured core text when there is one, else
// the usage line, the flag defaults of its V15 entry and the child commands under the key.
func subHelp(key string) string {
if txt, ok := surface.Help(key); ok {
return txt
}
var b strings.Builder
fmt.Fprintf(&b, "Usage: nself ci %s [flags]\n", key)
if e, ok := v15Subcommands[key]; ok && e.Flags != nil {
if fs := e.Flags(); fs != nil {
prev := fs.Output()
fs.SetOutput(&b)
b.WriteString("\nFlags:\n")
fs.PrintDefaults()
fs.SetOutput(prev)
}
}
if kids := childKeys(key); len(kids) > 0 {
b.WriteString("\nCommands:\n")
for _, k := range kids {
b.WriteString(" " + k + "\n")
}
}
return b.String()
}

// childKeys lists the registered keys one or more tokens below key, sorted and without duplicates.
func childKeys(key string) []string {
seen := map[string]bool{}
prefix := key + " "
for k := range subcommands {
if strings.HasPrefix(k, prefix) {
seen[k] = true
}
}
for k, e := range v15Subcommands {
if strings.HasPrefix(k, prefix) && !e.Hidden {
seen[k] = true
}
}
out := make([]string, 0, len(seen))
for k := range seen {
out = append(out, k)
}
sort.Strings(out)
return out
}

// helpForGroup prints the child list of a leading word that names a group (for example "nodes pools")
// but no command of its own, and reports whether args named one. Only called when wantsHelp(args).
func helpForGroup(args []string) bool {
var words []string
for _, a := range args {
if strings.HasPrefix(a, "-") {
break
}
words = append(words, a)
}
for n := len(words); n >= 1; n-- {
key := strings.Join(words[:n], " ")
if kids := childKeys(key); len(kids) > 0 {
fmt.Print("Usage: nself ci " + key + " <command> [flags]\n\nCommands:\n")
for _, k := range kids {
fmt.Println(" " + k)
}
return true
}
}
return false
}
93 changes: 93 additions & 0 deletions free/ci/cmd/help_dispatch_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package main

import (
"strings"
"testing"
)

// stubAllHandlers swaps every registered handler of both tables for one that records its key and
// returns 99, and restores the tables when the test ends. A help request that reaches a handler (or the
// gate or the bare run behind it) is then visible as a recorded key or a non-zero exit.
func stubAllHandlers(t *testing.T) *[]string {
t.Helper()
var ran []string
old14, old15 := subcommands, v15Subcommands
subcommands, v15Subcommands = map[string]handler{}, map[string]subcommandEntry{}
for k := range old14 {
key := "v14 " + k
subcommands[k] = func([]string) int { ran = append(ran, key); return 99 }
}
for k, e := range old15 {
key, entry := "v15 "+k, e
entry.Handler = func([]string) int { ran = append(ran, key); return 99 }
v15Subcommands[k] = entry
}
t.Cleanup(func() { subcommands, v15Subcommands = old14, old15 })
return &ran
}

// allKeys is every key of both tables, hidden entries included.
func allKeys() []string {
seen := map[string]bool{}
var out []string
for k := range subcommands {
seen[k] = true
}
for k := range v15Subcommands {
seen[k] = true
}
for k := range seen {
out = append(out, k)
}
return out
}

// Asking any registered subcommand for help prints help, exits 0 and runs nothing: not the handler, not
// the nodes --coordinator client (which would dial the URL), not the bare run or the gate. The table is
// built from the registries, so a new subcommand is covered the day it is registered.
func TestEverySubcommandHelpRunsNothing(t *testing.T) {
keys := allKeys()
if len(keys) < 40 {
t.Fatalf("only %d registered keys; the registries did not load", len(keys))
}
ran := stubAllHandlers(t)
t.Setenv("NSELF_CI_HOME", t.TempDir())
t.Chdir(t.TempDir())
for _, key := range keys {
for _, extra := range [][]string{
{"--help"}, {"-h"}, {"-help"},
{"--coordinator", "http://127.0.0.1:1", "--help"}, // must not dial
} {
args := append(strings.Fields(key), extra...)
for name, dispatch := range map[string]func([]string) int{"run": run, "runV15": runV15} {
var code int
out, errOut := captureStd(t, func() { code = dispatch(args) })
if code != 0 || len(*ran) != 0 || strings.TrimSpace(out) == "" {
t.Errorf("%s %q: exit %d, handlers run %v, stdout %q, stderr %q; want exit 0, nothing run, help on stdout",
name, args, code, *ran, out, errOut)
*ran = nil
}
}
}
}
}

// A help request after the "--" terminator belongs to the command being run, not to nself ci.
func TestHelpAfterTerminatorIsNotHelp(t *testing.T) {
if wantsHelp([]string{"--", "--help"}) || wantsHelp([]string{"-h", "--", "x"}) == false {
t.Fatal("wantsHelp must stop at the terminator and see a flag before it")
}
}

// A group word with no command of its own lists its children and runs nothing.
func TestGroupHelpListsChildren(t *testing.T) {
ran := stubAllHandlers(t)
t.Chdir(t.TempDir())
for group, child := range map[string]string{"config": "config show", "nodes pools": "nodes pools list", "policy": "policy show"} {
var code int
out, _ := captureStd(t, func() { code = runV15(append(strings.Fields(group), "--help")) })
if code != 0 || len(*ran) != 0 || !strings.Contains(out, child) {
t.Errorf("%s --help: exit %d, ran %v, stdout %q; want exit 0, nothing run, %q listed", group, code, *ran, out, child)
}
}
}
21 changes: 11 additions & 10 deletions free/ci/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,21 +43,22 @@ func main() {
os.Exit(run(os.Args[1:]))
}

// run dispatches argv and returns the exit code. A bare -h/--help in front of
// a command that has captured core help prints that text; the first token
// that is not a flag selects a registered subcommand, otherwise the argv is
// the single-repo gate.
// run dispatches argv and returns the exit code. A -h/--help after a registered
// command prints that command's help and exits 0 without running it (help_dispatch.go).
// The first token that is not a flag selects a registered subcommand, otherwise
// the argv is the single-repo gate.
func run(args []string) int {
if h, key, rest, ok := resolve(args); ok {
if wantsHelp(rest) {
if printHelp(key) {
return 0
}
if wantsHelp(rest) { // help never reaches the handler: it must not start anything
fmt.Print(subHelp(key))
return 0
}
return h(rest)
}
if wantsHelp(args) && printHelp("") {
return 0
if wantsHelp(args) {
if helpForGroup(args) || printHelp("") {
return 0
}
}
return gateCmd(args)
}
4 changes: 4 additions & 0 deletions free/ci/cmd/v15.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ func runV15(args []string) int {
continue
}
if entry, ok := v15Subcommands[strings.Join(args[:n], " ")]; ok {
if wantsHelp(args[n:]) { // before the --coordinator client and the handler: help starts nothing
fmt.Print(subHelp(strings.Join(args[:n], " ")))
return 0
}
if code, done := nodesRemoteDispatch(strings.Join(args[:n], " "), args[n:]); done { // `nodes <sub> --coordinator <url>` (P7-NODE-26)
return code
}
Expand Down
53 changes: 52 additions & 1 deletion free/ci/internal/exec/copytree.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,15 @@ func linkStaysInside(link, rel string) bool {
return true
}

func copyTree(source, dest string) error {
// copyTree copies source into dest. Paths in skip (and everything under them) are left out when they lie
// strictly inside source: the state directory and the workspace itself can sit in the repo root, and
// copying them would make the copy recurse into itself and hand the job other attempts' files.
func copyTree(source, dest string, skip ...string) error {
root, err := filepath.Abs(source)
if err != nil {
return err
}
skipped := skipRels(root, skip)
return filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
Expand All @@ -65,6 +69,12 @@ func copyTree(source, dest string) error {
if rel == "." {
return nil
}
if underAny(rel, skipped) {
if info.IsDir() {
return filepath.SkipDir
}
return nil
}
target := filepath.Join(dest, rel)
if info.Mode()&os.ModeSymlink != 0 {
return copySymlink(path, rel, target)
Expand Down Expand Up @@ -96,3 +106,44 @@ func copyTree(source, dest string) error {
return closeErr
})
}

// skipRels turns the skip paths into paths relative to root, keeping only those strictly inside it.
// Root and each path are tried both as given and resolved through symlinks (a temp dir may be one on
// macOS), so the match does not depend on how the caller spelled them.
func skipRels(root string, skip []string) []string {
real := func(p string) string {
if r, err := filepath.EvalSymlinks(p); err == nil {
return r
}
return p
}
var out []string
for _, p := range skip {
if p == "" {
continue
}
abs, err := filepath.Abs(p)
if err != nil {
continue
}
for _, base := range []string{root, real(root)} {
for _, cand := range []string{abs, real(abs)} {
rel, err := filepath.Rel(base, cand)
if err == nil && rel != "." && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
out = append(out, rel)
}
}
}
}
return out
}

// underAny reports whether rel is one of dirs or lies under one.
func underAny(rel string, dirs []string) bool {
for _, d := range dirs {
if rel == d || strings.HasPrefix(rel, d+string(filepath.Separator)) {
return true
}
}
return false
}
Loading
Loading