seams: move the last four package globals onto instance fields

Three test seams were still package-level mutable vars, which tests
had to swap before use. That's the classic path to flaky parallel
tests — two goroutines fighting over the same global fake. Push each
down to the struct that owns the behaviour.

internal/daemon/dns_routing.go
  lookupExecutableFunc + vmDNSAddrFunc → fields on *HostNetwork,
  defaulted at newHostNetwork time. dns_routing_test builds
  HostNetwork{..., lookupExecutable: stub, vmDNSAddr: stub} inline,
  no more t.Cleanup dance around package-level vars.

internal/daemon/preflight.go + doctor.go
  vsockHostDevicePath (mutable string) → vsockHostDevice field on
  *VMService, defaulted via defaultVsockHostDevice constant in
  newVMService. Preflight reads s.vsockHostDevice; doctor reads
  d.vm.vsockHostDevice. Logger test sets d.vm.vsockHostDevice = tmp
  after wireServices.

internal/daemon/workspace/workspace.go
  HostCommandOutputFunc → *Inspector struct with a Runner field.
  Every git-using helper (GitOutput, GitTrimmedOutput,
  GitResolvedConfigValue, RunHostCommand, ListSubmodules,
  ListOverlayPaths, CountUntrackedPaths, InspectRepo,
  ImportRepoToGuest, PrepareRepoCopy) is now a method on *Inspector.
  NewInspector() wraps the real host runner for production;
  WorkspaceService holds one via repoInspector, CLI deps holds one
  too. cli_test.go's submodule-rejection test builds its own
  Inspector with a scripted Runner instead of patching a global.
  Pure helpers (FinalizeScript, ResolveSourcePath, ParsePrepareMode,
  ShellQuote, FormatStepError, GitFileURL, ParseNullSeparatedOutput)
  stay free functions since they don't touch the host.

Sentinel: grep for HostCommandOutputFunc, lookupExecutableFunc,
vmDNSAddrFunc, vsockHostDevicePath is now empty across internal/.
make lint test green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-22 12:07:14 -03:00
parent 2685bc73f8
commit ecb18ce6ca
No known key found for this signature in database
GPG key ID: 33112E6833C34679
17 changed files with 201 additions and 137 deletions

View file

@ -2,6 +2,12 @@
// git repo inspection, shallow copy preparation, guest-side tar import,
// finalization script generation, and small utilities.
//
// Every helper that needs to run a host command (git or otherwise)
// lives as a method on *Inspector rather than a free function that
// routes through a package global. That way two tests running in
// parallel can each build their own Inspector with a stub Runner
// without fighting over shared state.
//
// The orchestrator methods (ExportVMWorkspace, PrepareVMWorkspace) stay on
// *daemon.Daemon.
package workspace
@ -51,9 +57,28 @@ type GuestClient interface {
StreamTarEntries(ctx context.Context, dir string, entries []string, command string, log io.Writer) error
}
// HostCommandOutputFunc runs a host command and returns its combined output.
// Declared as a package var so tests can substitute a stub runner.
var HostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) {
// RunnerFunc is the single-method surface every Inspector needs: run a
// host command with args, return combined output + error. Tests supply
// a stub that records calls and replays canned responses; production
// uses realHostRunner which wraps system.NewRunner.
type RunnerFunc func(ctx context.Context, name string, args ...string) ([]byte, error)
// Inspector bundles the host-command seam for all git-using workspace
// helpers. Construct one at the boundary where you're reading the
// filesystem (CLI deps, WorkspaceService) and call its methods directly;
// don't reach into the struct from helper code.
type Inspector struct {
Runner RunnerFunc
}
// NewInspector returns an Inspector backed by the real host runner.
// Production callers (CLI deps initialisation, daemon WorkspaceService
// wiring) use this; tests construct Inspector{Runner: stub} directly.
func NewInspector() *Inspector {
return &Inspector{Runner: realHostRunner}
}
func realHostRunner(ctx context.Context, name string, args ...string) ([]byte, error) {
runner := system.NewRunner()
output, err := runner.Run(ctx, name, args...)
if err == nil {
@ -72,55 +97,55 @@ var HostCommandOutputFunc = func(ctx context.Context, name string, args ...strin
// submodules, and overlay paths needed for a prepare. Overlay paths
// cover tracked files by default; untracked non-ignored files are
// included only when includeUntracked is true.
func InspectRepo(ctx context.Context, rawPath, branchName, fromRef string, includeUntracked bool) (RepoSpec, error) {
func (i *Inspector) InspectRepo(ctx context.Context, rawPath, branchName, fromRef string, includeUntracked bool) (RepoSpec, error) {
sourcePath, err := ResolveSourcePath(rawPath)
if err != nil {
return RepoSpec{}, err
}
repoRoot, err := GitTrimmedOutput(ctx, sourcePath, "rev-parse", "--show-toplevel")
repoRoot, err := i.GitTrimmedOutput(ctx, sourcePath, "rev-parse", "--show-toplevel")
if err != nil {
return RepoSpec{}, fmt.Errorf("%s is not inside a git repository", sourcePath)
}
isBare, err := GitTrimmedOutput(ctx, repoRoot, "rev-parse", "--is-bare-repository")
isBare, err := i.GitTrimmedOutput(ctx, repoRoot, "rev-parse", "--is-bare-repository")
if err != nil {
return RepoSpec{}, fmt.Errorf("inspect git repository %s: %w", repoRoot, err)
}
if isBare == "true" {
return RepoSpec{}, fmt.Errorf("workspace prepare requires a non-bare git repository: %s", repoRoot)
}
submodules, err := ListSubmodules(ctx, repoRoot)
submodules, err := i.ListSubmodules(ctx, repoRoot)
if err != nil {
return RepoSpec{}, err
}
headCommit, err := GitTrimmedOutput(ctx, repoRoot, "rev-parse", "HEAD^{commit}")
headCommit, err := i.GitTrimmedOutput(ctx, repoRoot, "rev-parse", "HEAD^{commit}")
if err != nil {
return RepoSpec{}, fmt.Errorf("git repository %s must have at least one commit", repoRoot)
}
currentBranch, err := GitTrimmedOutput(ctx, repoRoot, "branch", "--show-current")
currentBranch, err := i.GitTrimmedOutput(ctx, repoRoot, "branch", "--show-current")
if err != nil {
return RepoSpec{}, fmt.Errorf("resolve current branch for %s: %w", repoRoot, err)
}
baseCommit := headCommit
branchName = strings.TrimSpace(branchName)
if branchName != "" {
baseCommit, err = GitTrimmedOutput(ctx, repoRoot, "rev-parse", fromRef+"^{commit}")
baseCommit, err = i.GitTrimmedOutput(ctx, repoRoot, "rev-parse", fromRef+"^{commit}")
if err != nil {
return RepoSpec{}, fmt.Errorf("resolve workspace from %q: %w", fromRef, err)
}
}
gitUserName, err := GitResolvedConfigValue(ctx, repoRoot, "user.name")
gitUserName, err := i.GitResolvedConfigValue(ctx, repoRoot, "user.name")
if err != nil {
return RepoSpec{}, fmt.Errorf("resolve git user.name for %s: %w", repoRoot, err)
}
gitUserEmail, err := GitResolvedConfigValue(ctx, repoRoot, "user.email")
gitUserEmail, err := i.GitResolvedConfigValue(ctx, repoRoot, "user.email")
if err != nil {
return RepoSpec{}, fmt.Errorf("resolve git user.email for %s: %w", repoRoot, err)
}
originURL, err := GitResolvedConfigValue(ctx, repoRoot, "remote.origin.url")
originURL, err := i.GitResolvedConfigValue(ctx, repoRoot, "remote.origin.url")
if err != nil {
return RepoSpec{}, fmt.Errorf("resolve origin url for %s: %w", repoRoot, err)
}
overlayPaths, err := ListOverlayPaths(ctx, repoRoot, includeUntracked)
overlayPaths, err := i.ListOverlayPaths(ctx, repoRoot, includeUntracked)
if err != nil {
return RepoSpec{}, err
}
@ -142,7 +167,7 @@ func InspectRepo(ctx context.Context, rawPath, branchName, fromRef string, inclu
// ImportRepoToGuest materialises spec inside the guest at guestPath. Mode
// selects between full copy, metadata-only, or shallow metadata + overlay.
func ImportRepoToGuest(ctx context.Context, client GuestClient, spec RepoSpec, guestPath string, mode model.WorkspacePrepareMode) error {
func (i *Inspector) ImportRepoToGuest(ctx context.Context, client GuestClient, spec RepoSpec, guestPath string, mode model.WorkspacePrepareMode) error {
switch mode {
case model.WorkspacePrepareModeFullCopy:
var copyLog bytes.Buffer
@ -156,7 +181,7 @@ func ImportRepoToGuest(ctx context.Context, client GuestClient, spec RepoSpec, g
}
return nil
case model.WorkspacePrepareModeMetadataOnly, model.WorkspacePrepareModeShallowOverlay:
repoCopyDir, cleanup, err := PrepareRepoCopy(ctx, spec)
repoCopyDir, cleanup, err := i.PrepareRepoCopy(ctx, spec)
if err != nil {
return err
}
@ -212,7 +237,7 @@ func FinalizeScript(spec RepoSpec, guestPath string, mode model.WorkspacePrepare
// PrepareRepoCopy materialises a shallow clone of spec into a temp dir. The
// returned cleanup removes the temp root.
func PrepareRepoCopy(ctx context.Context, spec RepoSpec) (string, func(), error) {
func (i *Inspector) PrepareRepoCopy(ctx context.Context, spec RepoSpec) (string, func(), error) {
tempRoot, err := os.MkdirTemp("", "banger-workspace-*")
if err != nil {
return "", nil, err
@ -224,7 +249,7 @@ func PrepareRepoCopy(ctx context.Context, spec RepoSpec) (string, func(), error)
cloneArgs = append(cloneArgs, "--single-branch", "--branch", spec.CurrentBranch)
}
cloneArgs = append(cloneArgs, GitFileURL(spec.RepoRoot), repoCopyDir)
if err := RunHostCommand(ctx, "git", cloneArgs...); err != nil {
if err := i.RunHostCommand(ctx, "git", cloneArgs...); err != nil {
cleanup()
return "", nil, fmt.Errorf("clone shallow workspace repo copy: %w", err)
}
@ -232,19 +257,19 @@ func PrepareRepoCopy(ctx context.Context, spec RepoSpec) (string, func(), error)
if strings.TrimSpace(spec.BranchName) != "" {
checkoutCommit = spec.BaseCommit
}
if err := RunHostCommand(ctx, "git", "-C", repoCopyDir, "cat-file", "-e", checkoutCommit+"^{commit}"); err != nil {
if err := RunHostCommand(ctx, "git", "-C", repoCopyDir, "fetch", "--depth", fmt.Sprintf("%d", ShallowFetchDepth), GitFileURL(spec.RepoRoot), checkoutCommit); err != nil {
if err := i.RunHostCommand(ctx, "git", "-C", repoCopyDir, "cat-file", "-e", checkoutCommit+"^{commit}"); err != nil {
if err := i.RunHostCommand(ctx, "git", "-C", repoCopyDir, "fetch", "--depth", fmt.Sprintf("%d", ShallowFetchDepth), GitFileURL(spec.RepoRoot), checkoutCommit); err != nil {
cleanup()
return "", nil, fmt.Errorf("fetch shallow workspace repo commit %s: %w", checkoutCommit, err)
}
}
if strings.TrimSpace(spec.OriginURL) != "" {
if err := RunHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "set-url", "origin", spec.OriginURL); err != nil {
if err := i.RunHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "set-url", "origin", spec.OriginURL); err != nil {
cleanup()
return "", nil, fmt.Errorf("set workspace origin remote: %w", err)
}
} else {
if err := RunHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "remove", "origin"); err != nil {
if err := i.RunHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "remove", "origin"); err != nil {
cleanup()
return "", nil, fmt.Errorf("remove workspace placeholder origin remote: %w", err)
}
@ -273,8 +298,8 @@ func ResolveSourcePath(rawPath string) (string, error) {
}
// ListSubmodules returns the gitlink paths in repoRoot (mode 160000 entries).
func ListSubmodules(ctx context.Context, repoRoot string) ([]string, error) {
output, err := GitOutput(ctx, repoRoot, "ls-files", "--stage", "-z")
func (i *Inspector) ListSubmodules(ctx context.Context, repoRoot string) ([]string, error) {
output, err := i.GitOutput(ctx, repoRoot, "ls-files", "--stage", "-z")
if err != nil {
return nil, fmt.Errorf("inspect workspace git index for %s: %w", repoRoot, err)
}
@ -304,8 +329,8 @@ func ListSubmodules(ctx context.Context, repoRoot string) ([]string, error) {
// leave the developer's machine. Callers that genuinely want the
// fuller set (scratch repos, vendored binaries the user is iterating
// on) opt in explicitly.
func ListOverlayPaths(ctx context.Context, repoRoot string, includeUntracked bool) ([]string, error) {
trackedOutput, err := GitOutput(ctx, repoRoot, "ls-files", "-z")
func (i *Inspector) ListOverlayPaths(ctx context.Context, repoRoot string, includeUntracked bool) ([]string, error) {
trackedOutput, err := i.GitOutput(ctx, repoRoot, "ls-files", "-z")
if err != nil {
return nil, fmt.Errorf("list tracked files for %s: %w", repoRoot, err)
}
@ -325,7 +350,7 @@ func ListOverlayPaths(ctx context.Context, repoRoot string, includeUntracked boo
paths = append(paths, relPath)
}
if includeUntracked {
untrackedOutput, err := GitOutput(ctx, repoRoot, "ls-files", "--others", "--exclude-standard", "-z")
untrackedOutput, err := i.GitOutput(ctx, repoRoot, "ls-files", "--others", "--exclude-standard", "-z")
if err != nil {
return nil, fmt.Errorf("list untracked files for %s: %w", repoRoot, err)
}
@ -348,8 +373,8 @@ func ListOverlayPaths(ctx context.Context, repoRoot string, includeUntracked boo
// files in repoRoot. Used by the CLI to warn the user when they are
// about to ship a workspace that has local-but-unignored scratch
// files which, under the default, will be skipped.
func CountUntrackedPaths(ctx context.Context, repoRoot string) (int, error) {
untrackedOutput, err := GitOutput(ctx, repoRoot, "ls-files", "--others", "--exclude-standard", "-z")
func (i *Inspector) CountUntrackedPaths(ctx context.Context, repoRoot string) (int, error) {
untrackedOutput, err := i.GitOutput(ctx, repoRoot, "ls-files", "--others", "--exclude-standard", "-z")
if err != nil {
return 0, fmt.Errorf("list untracked files for %s: %w", repoRoot, err)
}
@ -377,18 +402,18 @@ func ParsePrepareMode(raw string) (model.WorkspacePrepareMode, error) {
}
// GitOutput runs `git [-C dir] args...` and returns its raw stdout.
func GitOutput(ctx context.Context, dir string, args ...string) ([]byte, error) {
func (i *Inspector) GitOutput(ctx context.Context, dir string, args ...string) ([]byte, error) {
fullArgs := make([]string, 0, len(args)+2)
if strings.TrimSpace(dir) != "" {
fullArgs = append(fullArgs, "-C", dir)
}
fullArgs = append(fullArgs, args...)
return HostCommandOutputFunc(ctx, "git", fullArgs...)
return i.Runner(ctx, "git", fullArgs...)
}
// GitTrimmedOutput returns GitOutput with surrounding whitespace trimmed.
func GitTrimmedOutput(ctx context.Context, dir string, args ...string) (string, error) {
output, err := GitOutput(ctx, dir, args...)
func (i *Inspector) GitTrimmedOutput(ctx context.Context, dir string, args ...string) (string, error) {
output, err := i.GitOutput(ctx, dir, args...)
if err != nil {
return "", err
}
@ -396,8 +421,8 @@ func GitTrimmedOutput(ctx context.Context, dir string, args ...string) (string,
}
// GitResolvedConfigValue reads git config key with --default "" --get.
func GitResolvedConfigValue(ctx context.Context, dir, key string) (string, error) {
return GitTrimmedOutput(ctx, dir, "config", "--default", "", "--get", key)
func (i *Inspector) GitResolvedConfigValue(ctx context.Context, dir, key string) (string, error) {
return i.GitTrimmedOutput(ctx, dir, "config", "--default", "", "--get", key)
}
// ParseNullSeparatedOutput splits on NULs and trims, returning non-empty
@ -415,10 +440,10 @@ func ParseNullSeparatedOutput(output []byte) []string {
return values
}
// RunHostCommand runs a host command via HostCommandOutputFunc, discarding
// its stdout.
func RunHostCommand(ctx context.Context, name string, args ...string) error {
_, err := HostCommandOutputFunc(ctx, name, args...)
// RunHostCommand runs a host command via the Inspector's Runner,
// discarding its stdout.
func (i *Inspector) RunHostCommand(ctx context.Context, name string, args ...string) error {
_, err := i.Runner(ctx, name, args...)
return err
}

View file

@ -59,7 +59,8 @@ func seedRepo(t *testing.T) string {
func TestListOverlayPaths_TrackedOnlyByDefault(t *testing.T) {
repo := seedRepo(t)
got, err := ListOverlayPaths(context.Background(), repo, false)
i := NewInspector()
got, err := i.ListOverlayPaths(context.Background(), repo, false)
if err != nil {
t.Fatalf("ListOverlayPaths: %v", err)
}
@ -71,7 +72,8 @@ func TestListOverlayPaths_TrackedOnlyByDefault(t *testing.T) {
func TestListOverlayPaths_IncludeUntracked(t *testing.T) {
repo := seedRepo(t)
got, err := ListOverlayPaths(context.Background(), repo, true)
i := NewInspector()
got, err := i.ListOverlayPaths(context.Background(), repo, true)
if err != nil {
t.Fatalf("ListOverlayPaths: %v", err)
}
@ -89,7 +91,8 @@ func TestListOverlayPaths_IncludeUntracked(t *testing.T) {
func TestCountUntrackedPaths(t *testing.T) {
repo := seedRepo(t)
count, err := CountUntrackedPaths(context.Background(), repo)
i := NewInspector()
count, err := i.CountUntrackedPaths(context.Background(), repo)
if err != nil {
t.Fatalf("CountUntrackedPaths: %v", err)
}