From 2c800cc5215025bbac4fbd5b36af35eff8d877aa Mon Sep 17 00:00:00 2001 From: mptyl Date: Sat, 15 Aug 2026 22:29:55 +0200 Subject: [PATCH] fix(cli): honor local installation discovery precedence --- tools/tht/cmd/tht/main.go | 14 ++++-- tools/tht/cmd/tht/main_test.go | 10 +++- tools/tht/internal/config/discovery.go | 27 +++++------ tools/tht/internal/config/discovery_test.go | 53 ++++++++++++++++++++- 4 files changed, 83 insertions(+), 21 deletions(-) diff --git a/tools/tht/cmd/tht/main.go b/tools/tht/cmd/tht/main.go index 356e02b6..c2edc150 100644 --- a/tools/tht/cmd/tht/main.go +++ b/tools/tht/cmd/tht/main.go @@ -81,11 +81,11 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "tht: %s\n\n%s", err, usage) return 2 } - if command == "help" && len(commandArgs) == 0 { - fmt.Fprint(stdout, usage) - return 0 - } - if (command == "version" || command == "setup") && len(commandArgs) == 0 { + if isBootstrapCommand(command) { + if command == "help" { + fmt.Fprint(stdout, usage) + return 0 + } return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command)) } workingDirectory, err := os.Getwd() @@ -194,6 +194,10 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { return writeResult(result, err, secretValues, stdout, stderr) } +func isBootstrapCommand(command string) bool { + return command == "help" || command == "setup" || command == "version" +} + func writeRemovalTargets(outputWriter io.Writer, project string, targets []serverops.Container) { fmt.Fprintf(outputWriter, "Removal targets for installation project %s:\n", project) if len(targets) == 0 { diff --git a/tools/tht/cmd/tht/main_test.go b/tools/tht/cmd/tht/main_test.go index 4e1a6b73..278e69d4 100644 --- a/tools/tht/cmd/tht/main_test.go +++ b/tools/tht/cmd/tht/main_test.go @@ -90,7 +90,15 @@ func TestBootstrapCommandsDoNotRequireAnInstallationDescriptor(t *testing.T) { }) t.Setenv("THOTHII_INSTALLATION", "") - for _, args := range [][]string{nil, {"help"}, {"version"}, {"setup"}} { + for _, args := range [][]string{ + nil, + {"help"}, + {"help", "pi"}, + {"version"}, + {"version", "--json"}, + {"setup"}, + {"setup", "--configure-only"}, + } { var stdout, stderr bytes.Buffer _ = run(context.Background(), args, &stdout, &stderr) if output := stdout.String() + stderr.String(); strings.Contains(output, "installation descriptor") { diff --git a/tools/tht/internal/config/discovery.go b/tools/tht/internal/config/discovery.go index ad1abd81..e625d384 100644 --- a/tools/tht/internal/config/discovery.go +++ b/tools/tht/internal/config/discovery.go @@ -41,8 +41,8 @@ func Resolve(explicit string, environment func(string) string, workingDirectory return "", errors.New("installation discovery could not resolve the current directory") } - candidates := make(map[string]string) for directory := filepath.Clean(start); ; directory = filepath.Dir(directory) { + candidates := make(map[string]string) for _, path := range discoveryCandidates(directory) { if hasArtifactsComponent(path) { continue @@ -57,6 +57,17 @@ func Resolve(explicit string, environment func(string) string, workingDirectory canonical := canonicalPath(path) candidates[canonical] = path } + paths := make([]string, 0, len(candidates)) + for _, path := range candidates { + paths = append(paths, path) + } + sort.Strings(paths) + if len(paths) == 1 { + return paths[0], nil + } + if len(paths) > 1 { + return "", fmt.Errorf("multiple valid installation descriptors found: %s; use --installation /%s", formatCandidates(paths, root.Path), installationFileName) + } if directory == root.Path { break } @@ -66,19 +77,7 @@ func Resolve(explicit string, environment func(string) string, workingDirectory } } - paths := make([]string, 0, len(candidates)) - for _, path := range candidates { - paths = append(paths, path) - } - sort.Strings(paths) - switch len(paths) { - case 1: - return paths[0], nil - case 0: - return "", fmt.Errorf("installation descriptor not found from %s; run tht setup or use --installation /%s", filepath.Clean(friendlyRoot), installationFileName) - default: - return "", fmt.Errorf("multiple valid installation descriptors found: %s; use --installation /%s", formatCandidates(paths, root.Path), installationFileName) - } + return "", fmt.Errorf("installation descriptor not found from %s; run tht setup or use --installation /%s", filepath.Clean(friendlyRoot), installationFileName) } func formatCandidates(paths []string, root string) string { diff --git a/tools/tht/internal/config/discovery_test.go b/tools/tht/internal/config/discovery_test.go index 6273046b..da4c8def 100644 --- a/tools/tht/internal/config/discovery_test.go +++ b/tools/tht/internal/config/discovery_test.go @@ -64,6 +64,44 @@ func TestResolveFindsDeployDescriptorFromNestedDirectory(t *testing.T) { } } +func TestResolveUsesLocalCandidateBeforeAncestorCandidate(t *testing.T) { + root := t.TempDir() + writeDiscoverableProject(t, root) + ancestor := writeDiscoverableInstallation(t, root, filepath.Join(root, "deploy", "ancestor")) + localDirectory := filepath.Join(root, "frontend", "feature") + local := writeDiscoverableInstallation(t, root, filepath.Join(localDirectory, "deploy", "local")) + + got, err := Resolve("", func(string) string { return "" }, localDirectory) + if err != nil { + t.Fatal(err) + } + want, err := filepath.EvalSymlinks(local) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("Resolve() = %q, want local %q before ancestor %q", got, want, ancestor) + } +} + +func TestResolveDiscoversDescriptorFromLinkedWorktree(t *testing.T) { + root := t.TempDir() + writeDiscoverableLinkedWorktree(t, root) + want := writeDiscoverableInstallation(t, root, filepath.Join(root, "deploy", "worktree")) + + got, err := Resolve("", func(string) string { return "" }, filepath.Join(root, "frontend")) + if err != nil { + t.Fatal(err) + } + canonicalWant, err := filepath.EvalSymlinks(want) + if err != nil { + t.Fatal(err) + } + if got != canonicalWant { + t.Fatalf("Resolve() = %q, want %q from linked worktree", got, canonicalWant) + } +} + func TestResolveIgnoresArtifactsAndRejectsAmbiguousValidCandidates(t *testing.T) { root := t.TempDir() writeDiscoverableProject(t, root) @@ -112,13 +150,26 @@ func TestResolveDoesNotSearchAboveTheDiscoveredProjectRoot(t *testing.T) { } func writeDiscoverableProject(t *testing.T, root string) { + writeDiscoverableProjectWithGitMarker(t, root, false) +} + +func writeDiscoverableLinkedWorktree(t *testing.T, root string) { + writeDiscoverableProjectWithGitMarker(t, root, true) +} + +func writeDiscoverableProjectWithGitMarker(t *testing.T, root string, linkedWorktree bool) { t.Helper() for _, path := range []string{"backend", "frontend", "harness", "tools", "deploy"} { if err := os.MkdirAll(filepath.Join(root, path), 0o755); err != nil { t.Fatal(err) } } - if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + gitPath := filepath.Join(root, ".git") + if linkedWorktree { + if err := os.WriteFile(gitPath, []byte("gitdir: /tmp/thothii-worktree.git\n"), 0o600); err != nil { + t.Fatal(err) + } + } else if err := os.MkdirAll(gitPath, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "compose.yaml"), []byte("services: {}\n"), 0o600); err != nil {