vm run: ship tracked files only by default; add --include-untracked + --dry-run

Workspace-mode vm run and vm workspace prepare used to copy both
tracked AND untracked non-ignored files into the guest. That silently
catches local .env files, scratch notes, credentials, and any other
working-tree state a developer hasn't explicitly gitignored — a real
data-exposure footgun given the golden image ships Docker and the
usual dev tooling.

Flip the default to tracked-only. Users who actually want the fuller
set opt in with --include-untracked (documented in both commands'
help). Gitignored files are still always excluded regardless of the
flag.

Add --dry-run to both vm run and vm workspace prepare. Dry-run
inspects the repo CLI-side (no VM created, no daemon RPC needed since
the daemon is always local and the inspection is a pure git read),
prints the exact file list + mode, and exits. A byte-level preview of
what would land in the guest.

When running real (non-dry) and untracked files exist in the repo but
are being skipped under the new default, print a one-line notice
pointing to --include-untracked so users aren't surprised when the
guest is missing something they expected.

Signature changes:
- ListOverlayPaths takes an includeUntracked bool (tracked always;
  untracked gated by flag).
- InspectRepo takes the same flag and passes it through.
- VMWorkspacePrepareParams gains IncludeUntracked.
- WorkspaceService.workspaceInspectRepo seam signature widened to
  match (4 callers in tests updated).

New workspace package tests cover both modes and verify that
gitignored files never leak regardless of the flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-21 19:53:17 -03:00
parent 25a1466947
commit 2a7f55f028
No known key found for this signature in database
GPG key ID: 33112E6833C34679
11 changed files with 293 additions and 67 deletions

View file

@ -83,10 +83,14 @@ banger vm run --rm -- script.sh # ephemeral: VM is deleted on exit
``` ```
- **Bare mode** gives you a clean shell. - **Bare mode** gives you a clean shell.
- **Workspace mode** (path given) copies the repo's tracked + untracked - **Workspace mode** (path given) copies the repo's git-tracked files
non-ignored files into `/root/repo` and kicks off a best-effort into `/root/repo` and kicks off a best-effort `mise` tooling
`mise` tooling bootstrap from the repo's `.mise.toml` / bootstrap from the repo's `.mise.toml` / `.tool-versions`. Log:
`.tool-versions`. Log: `/root/.cache/banger/vm-run-tooling-<repo>.log`. `/root/.cache/banger/vm-run-tooling-<repo>.log`. Untracked files
(including local `.env`, scratch notes, credentials that aren't
gitignored) are skipped by default — pass `--include-untracked` to
also ship them. Pass `--dry-run` to print the exact file list and
exit without creating a VM.
- **Command mode** (`-- <cmd>`) runs the command in the guest; exit - **Command mode** (`-- <cmd>`) runs the command in the guest; exit
code propagates through `banger`. code propagates through `banger`.
@ -94,9 +98,10 @@ Disconnecting from an interactive session leaves the VM running. Use
`vm stop` / `vm delete` to clean up — or pass `--rm` so the VM `vm stop` / `vm delete` to clean up — or pass `--rm` so the VM
auto-deletes once the session / command exits. auto-deletes once the session / command exits.
`--branch` and `--from` apply only to workspace mode. `--rm` skips `--branch`, `--from`, `--include-untracked`, and `--dry-run` apply
the delete when the initial ssh wait times out, so a wedged sshd only to workspace mode. `--rm` skips the delete when the initial ssh
leaves the VM alive for `banger vm logs` inspection. wait times out, so a wedged sshd leaves the VM alive for `banger vm
logs` inspection.
## Hostnames: reaching `<vm>.vm` ## Hostnames: reaching `<vm>.vm`

View file

@ -70,9 +70,12 @@ materialises a local git checkout into the guest:
banger vm workspace prepare <vm> ./other-repo --guest-path /root/repo banger vm workspace prepare <vm> ./other-repo --guest-path /root/repo
``` ```
Default guest path is `/root/repo`; default mode is a shallow metadata Default guest path is `/root/repo`; default mode is a shallow
copy plus tracked and untracked non-ignored overlay. For repositories metadata copy plus a tracked-files overlay. Untracked files are
with submodules, pass `--mode full_copy`. skipped by default — pass `--include-untracked` to ship untracked
non-ignored files too (the old behaviour). Pass `--dry-run` to list
the exact file set without touching the guest. For repositories with
submodules, pass `--mode full_copy`.
## Inspecting boot failures ## Inspecting boot failures

View file

@ -137,13 +137,14 @@ type WorkspaceExportResult struct {
} }
type VMWorkspacePrepareParams struct { type VMWorkspacePrepareParams struct {
IDOrName string `json:"id_or_name"` IDOrName string `json:"id_or_name"`
SourcePath string `json:"source_path"` SourcePath string `json:"source_path"`
GuestPath string `json:"guest_path,omitempty"` GuestPath string `json:"guest_path,omitempty"`
Branch string `json:"branch,omitempty"` Branch string `json:"branch,omitempty"`
From string `json:"from,omitempty"` From string `json:"from,omitempty"`
Mode string `json:"mode,omitempty"` Mode string `json:"mode,omitempty"`
ReadOnly bool `json:"readonly,omitempty"` ReadOnly bool `json:"readonly,omitempty"`
IncludeUntracked bool `json:"include_untracked,omitempty"`
} }
type VMWorkspacePrepareResult struct { type VMWorkspacePrepareResult struct {

View file

@ -62,6 +62,8 @@ func (d *deps) newVMRunCommand() *cobra.Command {
branchName string branchName string
fromRef = "HEAD" fromRef = "HEAD"
removeOnExit bool removeOnExit bool
includeUntracked bool
dryRun bool
) )
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "run [path] [-- command args...]", Use: "run [path] [-- command args...]",
@ -107,7 +109,17 @@ Three modes:
if err != nil { if err != nil {
return err return err
} }
repoPtr = &vmRunRepo{sourcePath: resolved, branchName: branchName, fromRef: fromRef} repoPtr = &vmRunRepo{sourcePath: resolved, branchName: branchName, fromRef: fromRef, includeUntracked: includeUntracked}
}
if dryRun {
if repoPtr == nil {
return errors.New("--dry-run requires a workspace path")
}
dryFromRef := ""
if strings.TrimSpace(repoPtr.branchName) != "" {
dryFromRef = repoPtr.fromRef
}
return runWorkspaceDryRun(cmd.Context(), cmd.OutOrStdout(), repoPtr.sourcePath, repoPtr.branchName, dryFromRef, repoPtr.includeUntracked)
} }
layout, err := paths.Resolve() layout, err := paths.Resolve()
@ -151,6 +163,8 @@ Three modes:
cmd.Flags().StringVar(&branchName, "branch", "", "create and switch to a new guest branch") cmd.Flags().StringVar(&branchName, "branch", "", "create and switch to a new guest branch")
cmd.Flags().StringVar(&fromRef, "from", "HEAD", "base ref for --branch") cmd.Flags().StringVar(&fromRef, "from", "HEAD", "base ref for --branch")
cmd.Flags().BoolVar(&removeOnExit, "rm", false, "delete the VM after the ssh session / command exits") cmd.Flags().BoolVar(&removeOnExit, "rm", false, "delete the VM after the ssh session / command exits")
cmd.Flags().BoolVar(&includeUntracked, "include-untracked", false, "also copy untracked non-ignored files into the guest workspace (default: tracked files only)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "list the files that would be copied into the guest workspace and exit without creating a VM")
_ = cmd.RegisterFlagCompletionFunc("image", d.completeImageNames) _ = cmd.RegisterFlagCompletionFunc("image", d.completeImageNames)
return cmd return cmd
} }
@ -570,6 +584,8 @@ func (d *deps) newVMWorkspacePrepareCommand() *cobra.Command {
var fromRef string var fromRef string
var mode string var mode string
var readOnly bool var readOnly bool
var includeUntracked bool
var dryRun bool
cmd := &cobra.Command{ cmd := &cobra.Command{
Use: "prepare <id-or-name> [path]", Use: "prepare <id-or-name> [path]",
Short: "Copy a local repo into a running VM", Short: "Copy a local repo into a running VM",
@ -582,10 +598,6 @@ func (d *deps) newVMWorkspacePrepareCommand() *cobra.Command {
banger vm workspace prepare devbox ../repo --mode full_copy banger vm workspace prepare devbox ../repo --mode full_copy
`), `),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
sourcePath := "" sourcePath := ""
if len(args) > 1 { if len(args) > 1 {
sourcePath = args[1] sourcePath = args[1]
@ -605,14 +617,27 @@ func (d *deps) newVMWorkspacePrepareCommand() *cobra.Command {
if strings.TrimSpace(branchName) != "" { if strings.TrimSpace(branchName) != "" {
prepareFrom = fromRef prepareFrom = fromRef
} }
if dryRun {
return runWorkspaceDryRun(cmd.Context(), cmd.OutOrStdout(), resolvedPath, branchName, prepareFrom, includeUntracked)
}
layout, _, err := d.ensureDaemon(cmd.Context())
if err != nil {
return err
}
if !includeUntracked {
if err := noteUntrackedSkipped(cmd.Context(), cmd.ErrOrStderr(), resolvedPath); err != nil {
return err
}
}
result, err := d.vmWorkspacePrepare(cmd.Context(), layout.SocketPath, api.VMWorkspacePrepareParams{ result, err := d.vmWorkspacePrepare(cmd.Context(), layout.SocketPath, api.VMWorkspacePrepareParams{
IDOrName: args[0], IDOrName: args[0],
SourcePath: resolvedPath, SourcePath: resolvedPath,
GuestPath: guestPath, GuestPath: guestPath,
Branch: branchName, Branch: branchName,
From: prepareFrom, From: prepareFrom,
Mode: mode, Mode: mode,
ReadOnly: readOnly, ReadOnly: readOnly,
IncludeUntracked: includeUntracked,
}) })
if err != nil { if err != nil {
return err return err
@ -625,6 +650,8 @@ func (d *deps) newVMWorkspacePrepareCommand() *cobra.Command {
cmd.Flags().StringVar(&fromRef, "from", "HEAD", "base ref for --branch") cmd.Flags().StringVar(&fromRef, "from", "HEAD", "base ref for --branch")
cmd.Flags().StringVar(&mode, "mode", string(model.WorkspacePrepareModeShallowOverlay), "workspace mode: shallow_overlay, full_copy, metadata_only") cmd.Flags().StringVar(&mode, "mode", string(model.WorkspacePrepareModeShallowOverlay), "workspace mode: shallow_overlay, full_copy, metadata_only")
cmd.Flags().BoolVar(&readOnly, "readonly", false, "make the prepared workspace read-only") cmd.Flags().BoolVar(&readOnly, "readonly", false, "make the prepared workspace read-only")
cmd.Flags().BoolVar(&includeUntracked, "include-untracked", false, "also copy untracked non-ignored files into the guest workspace (default: tracked files only)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "list the files that would be copied and exit without touching the guest")
return cmd return cmd
} }

View file

@ -39,9 +39,10 @@ type vmRunGuestClient interface {
// RepoName, HEAD commit, etc.) comes back from the workspace.prepare // RepoName, HEAD commit, etc.) comes back from the workspace.prepare
// RPC, which does the full git inspection daemon-side. // RPC, which does the full git inspection daemon-side.
type vmRunRepo struct { type vmRunRepo struct {
sourcePath string sourcePath string
branchName string branchName string
fromRef string fromRef string
includeUntracked bool
} }
const vmRunToolingInstallTimeoutSeconds = 120 const vmRunToolingInstallTimeoutSeconds = 120
@ -193,13 +194,19 @@ func (d *deps) runVMRun(ctx context.Context, socketPath string, cfg model.Daemon
if strings.TrimSpace(repo.branchName) != "" { if strings.TrimSpace(repo.branchName) != "" {
fromRef = repo.fromRef fromRef = repo.fromRef
} }
if !repo.includeUntracked {
if err := noteUntrackedSkipped(ctx, stderr, repo.sourcePath); err != nil {
printVMRunWarning(stderr, fmt.Sprintf("count untracked files failed: %v", err))
}
}
prepared, err := d.vmWorkspacePrepare(ctx, socketPath, api.VMWorkspacePrepareParams{ prepared, err := d.vmWorkspacePrepare(ctx, socketPath, api.VMWorkspacePrepareParams{
IDOrName: vmRef, IDOrName: vmRef,
SourcePath: repo.sourcePath, SourcePath: repo.sourcePath,
GuestPath: vmRunGuestDir(), GuestPath: vmRunGuestDir(),
Branch: repo.branchName, Branch: repo.branchName,
From: fromRef, From: fromRef,
Mode: string(model.WorkspacePrepareModeShallowOverlay), Mode: string(model.WorkspacePrepareModeShallowOverlay),
IncludeUntracked: repo.includeUntracked,
}) })
if err != nil { if err != nil {
return fmt.Errorf("vm %q is running but workspace prepare failed: %w", vmRef, err) return fmt.Errorf("vm %q is running but workspace prepare failed: %w", vmRef, err)

View file

@ -0,0 +1,54 @@
package cli
import (
"context"
"fmt"
"io"
"banger/internal/daemon/workspace"
)
// runWorkspaceDryRun inspects the local repo at resolvedPath and
// prints the file list that `vm run` / `workspace prepare` would ship
// into the guest. Runs on the CLI side (no daemon RPC needed) since
// the daemon is always local and the workspace inspection is a pure
// git read.
func runWorkspaceDryRun(ctx context.Context, out io.Writer, resolvedPath, branchName, fromRef string, includeUntracked bool) error {
spec, err := workspace.InspectRepo(ctx, resolvedPath, branchName, fromRef, includeUntracked)
if err != nil {
return err
}
fmt.Fprintf(out, "dry-run: %d file(s) would be copied to guest\n", len(spec.OverlayPaths))
fmt.Fprintf(out, "repo: %s\n", spec.RepoRoot)
if includeUntracked {
fmt.Fprintln(out, "mode: tracked + untracked non-ignored (--include-untracked)")
} else {
fmt.Fprintln(out, "mode: tracked only (re-run with --include-untracked to also copy untracked non-ignored files)")
}
fmt.Fprintln(out, "---")
for _, path := range spec.OverlayPaths {
fmt.Fprintln(out, path)
}
if !includeUntracked {
if err := noteUntrackedSkipped(ctx, out, spec.RepoRoot); err != nil {
return err
}
}
return nil
}
// noteUntrackedSkipped prints a one-line notice to stderr-analog when
// the repo has untracked non-ignored files that will NOT be copied
// because --include-untracked was not passed. Silent when there are
// no such files, or when the count can't be determined.
func noteUntrackedSkipped(ctx context.Context, out io.Writer, repoRoot string) error {
count, err := workspace.CountUntrackedPaths(ctx, repoRoot)
if err != nil {
return err
}
if count == 0 {
return nil
}
fmt.Fprintf(out, "---\nnote: %d untracked non-ignored file(s) were NOT copied (git-tracked files only by default — pass --include-untracked to include them)\n", count)
return nil
}

View file

@ -20,11 +20,11 @@ import (
// opposed to always requiring callers to populate s.workspaceInspectRepo // opposed to always requiring callers to populate s.workspaceInspectRepo
// in a constructor) lets tests selectively override one hook without // in a constructor) lets tests selectively override one hook without
// having to wire both. // having to wire both.
func (s *WorkspaceService) workspaceInspectRepoHook(ctx context.Context, sourcePath, branchName, fromRef string) (ws.RepoSpec, error) { func (s *WorkspaceService) workspaceInspectRepoHook(ctx context.Context, sourcePath, branchName, fromRef string, includeUntracked bool) (ws.RepoSpec, error) {
if s != nil && s.workspaceInspectRepo != nil { if s != nil && s.workspaceInspectRepo != nil {
return s.workspaceInspectRepo(ctx, sourcePath, branchName, fromRef) return s.workspaceInspectRepo(ctx, sourcePath, branchName, fromRef, includeUntracked)
} }
return ws.InspectRepo(ctx, sourcePath, branchName, fromRef) return ws.InspectRepo(ctx, sourcePath, branchName, fromRef, includeUntracked)
} }
func (s *WorkspaceService) workspaceImportHook(ctx context.Context, client ws.GuestClient, spec ws.RepoSpec, guestPath string, mode model.WorkspacePrepareMode) error { func (s *WorkspaceService) workspaceImportHook(ctx context.Context, client ws.GuestClient, spec ws.RepoSpec, guestPath string, mode model.WorkspacePrepareMode) error {
@ -160,14 +160,14 @@ func (s *WorkspaceService) PrepareVMWorkspace(ctx context.Context, params api.VM
unlock := s.workspaceLocks.lock(vm.ID) unlock := s.workspaceLocks.lock(vm.ID)
defer unlock() defer unlock()
return s.prepareVMWorkspaceGuestIO(ctx, vm, strings.TrimSpace(params.SourcePath), guestPath, branchName, fromRef, mode, params.ReadOnly) return s.prepareVMWorkspaceGuestIO(ctx, vm, strings.TrimSpace(params.SourcePath), guestPath, branchName, fromRef, mode, params.ReadOnly, params.IncludeUntracked)
} }
// prepareVMWorkspaceGuestIO performs the actual guest-side work: // prepareVMWorkspaceGuestIO performs the actual guest-side work:
// inspect the local repo, dial SSH, stream the tar, optionally chmod // inspect the local repo, dial SSH, stream the tar, optionally chmod
// readonly. It is called without holding the VM mutex. // readonly. It is called without holding the VM mutex.
func (s *WorkspaceService) prepareVMWorkspaceGuestIO(ctx context.Context, vm model.VMRecord, sourcePath, guestPath, branchName, fromRef string, mode model.WorkspacePrepareMode, readOnly bool) (model.WorkspacePrepareResult, error) { func (s *WorkspaceService) prepareVMWorkspaceGuestIO(ctx context.Context, vm model.VMRecord, sourcePath, guestPath, branchName, fromRef string, mode model.WorkspacePrepareMode, readOnly, includeUntracked bool) (model.WorkspacePrepareResult, error) {
spec, err := s.workspaceInspectRepoHook(ctx, sourcePath, branchName, fromRef) spec, err := s.workspaceInspectRepoHook(ctx, sourcePath, branchName, fromRef, includeUntracked)
if err != nil { if err != nil {
return model.WorkspacePrepareResult{}, err return model.WorkspacePrepareResult{}, err
} }

View file

@ -67,11 +67,12 @@ var HostCommandOutputFunc = func(ctx context.Context, name string, args ...strin
return output, fmt.Errorf("%s: %w: %s", command, err, detail) return output, fmt.Errorf("%s: %w: %s", command, err, detail)
} }
// InspectRepo resolves rawPath into an absolute repo root and captures the // InspectRepo resolves rawPath into an absolute repo root and captures
// HEAD, branch, optional base-from ref, git identity, origin URL, submodules, // the HEAD, branch, optional base-from ref, git identity, origin URL,
// and overlay paths (tracked + untracked non-ignored files) needed for a // submodules, and overlay paths needed for a prepare. Overlay paths
// prepare. // cover tracked files by default; untracked non-ignored files are
func InspectRepo(ctx context.Context, rawPath, branchName, fromRef string) (RepoSpec, error) { // included only when includeUntracked is true.
func InspectRepo(ctx context.Context, rawPath, branchName, fromRef string, includeUntracked bool) (RepoSpec, error) {
sourcePath, err := ResolveSourcePath(rawPath) sourcePath, err := ResolveSourcePath(rawPath)
if err != nil { if err != nil {
return RepoSpec{}, err return RepoSpec{}, err
@ -119,7 +120,7 @@ func InspectRepo(ctx context.Context, rawPath, branchName, fromRef string) (Repo
if err != nil { if err != nil {
return RepoSpec{}, fmt.Errorf("resolve origin url for %s: %w", repoRoot, err) return RepoSpec{}, fmt.Errorf("resolve origin url for %s: %w", repoRoot, err)
} }
overlayPaths, err := ListOverlayPaths(ctx, repoRoot) overlayPaths, err := ListOverlayPaths(ctx, repoRoot, includeUntracked)
if err != nil { if err != nil {
return RepoSpec{}, err return RepoSpec{}, err
} }
@ -292,17 +293,22 @@ func ListSubmodules(ctx context.Context, repoRoot string) ([]string, error) {
return submodules, nil return submodules, nil
} }
// ListOverlayPaths returns tracked + untracked non-ignored files in // ListOverlayPaths returns tracked files in repoRoot, plus (when
// repoRoot. Missing tracked entries (deleted working-tree files) are skipped. // includeUntracked is true) untracked non-ignored files. Missing
func ListOverlayPaths(ctx context.Context, repoRoot string) ([]string, error) { // tracked entries (deleted working-tree files) are skipped in both
// modes.
//
// The default is tracked-only because "untracked + not gitignored"
// silently catches local credentials, .env files, scratch notes, and
// other secrets that live in the working tree but aren't meant to
// 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") trackedOutput, err := GitOutput(ctx, repoRoot, "ls-files", "-z")
if err != nil { if err != nil {
return nil, fmt.Errorf("list tracked files for %s: %w", repoRoot, err) return nil, fmt.Errorf("list tracked files for %s: %w", repoRoot, err)
} }
untrackedOutput, err := GitOutput(ctx, repoRoot, "ls-files", "--others", "--exclude-standard", "-z")
if err != nil {
return nil, fmt.Errorf("list untracked files for %s: %w", repoRoot, err)
}
paths := make([]string, 0) paths := make([]string, 0)
seen := make(map[string]struct{}) seen := make(map[string]struct{})
for _, relPath := range ParseNullSeparatedOutput(trackedOutput) { for _, relPath := range ParseNullSeparatedOutput(trackedOutput) {
@ -318,20 +324,44 @@ func ListOverlayPaths(ctx context.Context, repoRoot string) ([]string, error) {
seen[relPath] = struct{}{} seen[relPath] = struct{}{}
paths = append(paths, relPath) paths = append(paths, relPath)
} }
for _, relPath := range ParseNullSeparatedOutput(untrackedOutput) { if includeUntracked {
if relPath == "" { untrackedOutput, err := GitOutput(ctx, repoRoot, "ls-files", "--others", "--exclude-standard", "-z")
continue if err != nil {
return nil, fmt.Errorf("list untracked files for %s: %w", repoRoot, err)
} }
if _, ok := seen[relPath]; ok { for _, relPath := range ParseNullSeparatedOutput(untrackedOutput) {
continue if relPath == "" {
continue
}
if _, ok := seen[relPath]; ok {
continue
}
seen[relPath] = struct{}{}
paths = append(paths, relPath)
} }
seen[relPath] = struct{}{}
paths = append(paths, relPath)
} }
sort.Strings(paths) sort.Strings(paths)
return paths, nil return paths, nil
} }
// CountUntrackedPaths returns the number of untracked non-ignored
// 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")
if err != nil {
return 0, fmt.Errorf("list untracked files for %s: %w", repoRoot, err)
}
count := 0
for _, relPath := range ParseNullSeparatedOutput(untrackedOutput) {
if relPath != "" {
count++
}
}
return count, nil
}
// ParsePrepareMode validates and canonicalises a user-supplied mode value. // ParsePrepareMode validates and canonicalises a user-supplied mode value.
func ParsePrepareMode(raw string) (model.WorkspacePrepareMode, error) { func ParsePrepareMode(raw string) (model.WorkspacePrepareMode, error) {
switch strings.TrimSpace(raw) { switch strings.TrimSpace(raw) {

View file

@ -0,0 +1,99 @@
package workspace
import (
"context"
"os"
"os/exec"
"path/filepath"
"slices"
"testing"
)
// seedRepo creates a tiny git repo with one tracked file, one
// gitignored file, and one untracked-non-ignored file. Returns the
// repo root path. Skips the test if git isn't on PATH (unusual for
// a dev machine, but polite).
func seedRepo(t *testing.T) string {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skipf("git not on PATH: %v", err)
}
dir := t.TempDir()
run := func(args ...string) {
t.Helper()
cmd := exec.Command(args[0], args[1:]...)
cmd.Dir = dir
// Isolate from the ambient user config so commits don't need
// a global user.name/user.email. Also disable GPG signing.
cmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t",
"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t",
"GIT_CONFIG_GLOBAL=/dev/null",
)
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("%v: %v\n%s", args, err, out)
}
}
writeFile := func(relPath, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, relPath), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
run("git", "init", "-q", "-b", "main")
run("git", "config", "commit.gpgsign", "false")
writeFile(".gitignore", "ignored.log\n")
writeFile("README.md", "hello\n")
run("git", "add", ".gitignore", "README.md")
run("git", "commit", "-q", "-m", "init")
// A tracked file AFTER the first commit so ls-files picks it up.
// A gitignored file so --exclude-standard filters it.
// An untracked non-ignored file so the flag matters.
writeFile("src.go", "package main\n")
run("git", "add", "src.go")
run("git", "commit", "-q", "-m", "src")
writeFile("ignored.log", "noisy\n")
writeFile("SECRETS.env", "TOKEN=abc\n")
return dir
}
func TestListOverlayPaths_TrackedOnlyByDefault(t *testing.T) {
repo := seedRepo(t)
got, err := ListOverlayPaths(context.Background(), repo, false)
if err != nil {
t.Fatalf("ListOverlayPaths: %v", err)
}
want := []string{".gitignore", "README.md", "src.go"}
if !slices.Equal(got, want) {
t.Fatalf("got %v, want %v (untracked SECRETS.env must be excluded; gitignored ignored.log must always be excluded)", got, want)
}
}
func TestListOverlayPaths_IncludeUntracked(t *testing.T) {
repo := seedRepo(t)
got, err := ListOverlayPaths(context.Background(), repo, true)
if err != nil {
t.Fatalf("ListOverlayPaths: %v", err)
}
want := []string{".gitignore", "README.md", "SECRETS.env", "src.go"}
if !slices.Equal(got, want) {
t.Fatalf("got %v, want %v", got, want)
}
// gitignored files must stay out even when untracked is included.
for _, p := range got {
if p == "ignored.log" {
t.Fatalf("gitignored file leaked into overlay: %v", got)
}
}
}
func TestCountUntrackedPaths(t *testing.T) {
repo := seedRepo(t)
count, err := CountUntrackedPaths(context.Background(), repo)
if err != nil {
t.Fatalf("CountUntrackedPaths: %v", err)
}
if count != 1 {
t.Fatalf("count = %d, want 1 (only SECRETS.env; ignored.log is gitignored)", count)
}
}

View file

@ -46,7 +46,7 @@ type WorkspaceService struct {
beginOperation func(name string, attrs ...any) *operationLog beginOperation func(name string, attrs ...any) *operationLog
// Test seams. // Test seams.
workspaceInspectRepo func(ctx context.Context, sourcePath, branchName, fromRef string) (ws.RepoSpec, error) workspaceInspectRepo func(ctx context.Context, sourcePath, branchName, fromRef string, includeUntracked bool) (ws.RepoSpec, error)
workspaceImport func(ctx context.Context, client ws.GuestClient, spec ws.RepoSpec, guestPath string, mode model.WorkspacePrepareMode) error workspaceImport func(ctx context.Context, client ws.GuestClient, spec ws.RepoSpec, guestPath string, mode model.WorkspacePrepareMode) error
} }

View file

@ -400,7 +400,7 @@ func TestPrepareVMWorkspace_ReleasesVMLockDuringGuestIO(t *testing.T) {
// Import blocks until we say go. // Import blocks until we say go.
importStarted := make(chan struct{}) importStarted := make(chan struct{})
releaseImport := make(chan struct{}) releaseImport := make(chan struct{})
d.ws.workspaceInspectRepo = func(context.Context, string, string, string) (workspace.RepoSpec, error) { d.ws.workspaceInspectRepo = func(context.Context, string, string, string, bool) (workspace.RepoSpec, error) {
return workspace.RepoSpec{RepoName: "fake", RepoRoot: "/tmp/fake"}, nil return workspace.RepoSpec{RepoName: "fake", RepoRoot: "/tmp/fake"}, nil
} }
d.ws.workspaceImport = func(context.Context, workspace.GuestClient, workspace.RepoSpec, string, model.WorkspacePrepareMode) error { d.ws.workspaceImport = func(context.Context, workspace.GuestClient, workspace.RepoSpec, string, model.WorkspacePrepareMode) error {
@ -483,7 +483,7 @@ func TestPrepareVMWorkspace_SerialisesConcurrentPreparesOnSameVM(t *testing.T) {
upsertDaemonVM(t, ctx, d.store, vm) upsertDaemonVM(t, ctx, d.store, vm)
d.vm.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: firecracker.Process.Pid}) d.vm.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: firecracker.Process.Pid})
d.ws.workspaceInspectRepo = func(context.Context, string, string, string) (workspace.RepoSpec, error) { d.ws.workspaceInspectRepo = func(context.Context, string, string, string, bool) (workspace.RepoSpec, error) {
return workspace.RepoSpec{RepoName: "fake", RepoRoot: "/tmp/fake"}, nil return workspace.RepoSpec{RepoName: "fake", RepoRoot: "/tmp/fake"}, nil
} }