From 1d51370d2629950a9f2d7740cf04b4304134c4e3 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Wed, 15 Apr 2026 16:37:19 -0300 Subject: [PATCH] Extract workspace subpackage with pure repo helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moves the stateless parts of the workspace subsystem into internal/daemon/workspace: - RepoSpec struct + InspectRepo for host-side git inspection - ImportRepoToGuest (taking a minimal GuestClient interface) with the full-copy and metadata-only / shallow-overlay paths - FinalizeScript, PrepareRepoCopy, ResolveSourcePath - ListSubmodules, ListOverlayPaths, ParsePrepareMode - Git helpers (GitOutput, GitTrimmedOutput, GitResolvedConfigValue, ParseNullSeparatedOutput, RunHostCommand, GitFileURL) and the HostCommandOutputFunc test seam - ShallowFetchDepth const The subpackage imports internal/daemon/session for ShellQuote and FormatStepError so both workspace and session pure helpers live in their own subpackages with a clean session→workspace direction of use. daemon/workspace.go shrinks from 481 → 156 LOC, keeping just the three orchestrator methods (Export, Prepare, prepareLocked) that still touch d.store, d.FindVM, d.dialGuest, d.waitForGuestSSH, and the VM lock set. guestSessionHostCommandOutputFunc is removed from guest_sessions.go (its only caller was workspace.go; the new package has its own copy). All tests green. Co-Authored-By: Claude Sonnet 4.6 --- internal/daemon/guest_sessions.go | 14 - internal/daemon/workspace.go | 333 +------------------- internal/daemon/workspace/workspace.go | 400 +++++++++++++++++++++++++ 3 files changed, 404 insertions(+), 343 deletions(-) create mode 100644 internal/daemon/workspace/workspace.go diff --git a/internal/daemon/guest_sessions.go b/internal/daemon/guest_sessions.go index 0477e40..0c15739 100644 --- a/internal/daemon/guest_sessions.go +++ b/internal/daemon/guest_sessions.go @@ -18,20 +18,6 @@ import ( "banger/internal/system" ) -var guestSessionHostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) { - runner := system.NewRunner() - output, err := runner.Run(ctx, name, args...) - if err == nil { - return output, nil - } - command := strings.TrimSpace(strings.Join(append([]string{name}, args...), " ")) - detail := strings.TrimSpace(string(output)) - if detail == "" { - return output, fmt.Errorf("%s: %w", command, err) - } - return output, fmt.Errorf("%s: %w: %s", command, err, detail) -} - type guestSSHClient interface { Close() error RunScript(context.Context, string, io.Writer) error diff --git a/internal/daemon/workspace.go b/internal/daemon/workspace.go index f19f963..d24c585 100644 --- a/internal/daemon/workspace.go +++ b/internal/daemon/workspace.go @@ -6,36 +6,16 @@ import ( "errors" "fmt" "net" - "net/url" - "os" - "path/filepath" - "sort" "strings" "time" "banger/internal/api" sess "banger/internal/daemon/session" + ws "banger/internal/daemon/workspace" "banger/internal/model" "banger/internal/system" ) -const workspaceShallowFetchDepth = 10 - -type workspaceRepoSpec struct { - SourcePath string - RepoRoot string - RepoName string - HeadCommit string - CurrentBranch string - BranchName string - BaseCommit string - OriginURL string - GitUserName string - GitUserEmail string - OverlayPaths []string - Submodules []string -} - func (d *Daemon) ExportVMWorkspace(ctx context.Context, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error) { guestPath := strings.TrimSpace(params.GuestPath) if guestPath == "" { @@ -101,7 +81,7 @@ func (d *Daemon) ExportVMWorkspace(ctx context.Context, params api.WorkspaceExpo } func (d *Daemon) PrepareVMWorkspace(ctx context.Context, params api.VMWorkspacePrepareParams) (model.WorkspacePrepareResult, error) { - mode, err := parseWorkspacePrepareMode(params.Mode) + mode, err := ws.ParsePrepareMode(params.Mode) if err != nil { return model.WorkspacePrepareResult{}, err } @@ -133,7 +113,7 @@ func (d *Daemon) PrepareVMWorkspace(ctx context.Context, params api.VMWorkspaceP } func (d *Daemon) prepareVMWorkspaceLocked(ctx context.Context, vm model.VMRecord, sourcePath, guestPath, branchName, fromRef string, mode model.WorkspacePrepareMode, readOnly bool) (model.WorkspacePrepareResult, error) { - spec, err := inspectWorkspaceRepo(ctx, sourcePath, branchName, fromRef) + spec, err := ws.InspectRepo(ctx, sourcePath, branchName, fromRef) if err != nil { return model.WorkspacePrepareResult{}, err } @@ -149,7 +129,7 @@ func (d *Daemon) prepareVMWorkspaceLocked(ctx context.Context, vm model.VMRecord return model.WorkspacePrepareResult{}, fmt.Errorf("dial guest ssh: %w", err) } defer client.Close() - if err := importWorkspaceRepoToGuest(ctx, client, spec, guestPath, mode); err != nil { + if err := ws.ImportRepoToGuest(ctx, client, spec, guestPath, mode); err != nil { return model.WorkspacePrepareResult{}, err } if readOnly { @@ -174,308 +154,3 @@ func (d *Daemon) prepareVMWorkspaceLocked(ctx context.Context, vm model.VMRecord PreparedAt: model.Now(), }, nil } - -func inspectWorkspaceRepo(ctx context.Context, rawPath, branchName, fromRef string) (workspaceRepoSpec, error) { - sourcePath, err := resolveWorkspaceSourcePath(rawPath) - if err != nil { - return workspaceRepoSpec{}, err - } - repoRoot, err := workspaceGitTrimmedOutput(ctx, sourcePath, "rev-parse", "--show-toplevel") - if err != nil { - return workspaceRepoSpec{}, fmt.Errorf("%s is not inside a git repository", sourcePath) - } - isBare, err := workspaceGitTrimmedOutput(ctx, repoRoot, "rev-parse", "--is-bare-repository") - if err != nil { - return workspaceRepoSpec{}, fmt.Errorf("inspect git repository %s: %w", repoRoot, err) - } - if isBare == "true" { - return workspaceRepoSpec{}, fmt.Errorf("workspace prepare requires a non-bare git repository: %s", repoRoot) - } - submodules, err := listWorkspaceSubmodules(ctx, repoRoot) - if err != nil { - return workspaceRepoSpec{}, err - } - headCommit, err := workspaceGitTrimmedOutput(ctx, repoRoot, "rev-parse", "HEAD^{commit}") - if err != nil { - return workspaceRepoSpec{}, fmt.Errorf("git repository %s must have at least one commit", repoRoot) - } - currentBranch, err := workspaceGitTrimmedOutput(ctx, repoRoot, "branch", "--show-current") - if err != nil { - return workspaceRepoSpec{}, fmt.Errorf("resolve current branch for %s: %w", repoRoot, err) - } - baseCommit := headCommit - branchName = strings.TrimSpace(branchName) - if branchName != "" { - baseCommit, err = workspaceGitTrimmedOutput(ctx, repoRoot, "rev-parse", fromRef+"^{commit}") - if err != nil { - return workspaceRepoSpec{}, fmt.Errorf("resolve workspace from %q: %w", fromRef, err) - } - } - gitUserName, err := workspaceGitResolvedConfigValue(ctx, repoRoot, "user.name") - if err != nil { - return workspaceRepoSpec{}, fmt.Errorf("resolve git user.name for %s: %w", repoRoot, err) - } - gitUserEmail, err := workspaceGitResolvedConfigValue(ctx, repoRoot, "user.email") - if err != nil { - return workspaceRepoSpec{}, fmt.Errorf("resolve git user.email for %s: %w", repoRoot, err) - } - originURL, err := workspaceGitResolvedConfigValue(ctx, repoRoot, "remote.origin.url") - if err != nil { - return workspaceRepoSpec{}, fmt.Errorf("resolve origin url for %s: %w", repoRoot, err) - } - overlayPaths, err := listWorkspaceOverlayPaths(ctx, repoRoot) - if err != nil { - return workspaceRepoSpec{}, err - } - return workspaceRepoSpec{ - SourcePath: sourcePath, - RepoRoot: repoRoot, - RepoName: filepath.Base(repoRoot), - HeadCommit: headCommit, - CurrentBranch: currentBranch, - BranchName: branchName, - BaseCommit: baseCommit, - OriginURL: originURL, - GitUserName: gitUserName, - GitUserEmail: gitUserEmail, - OverlayPaths: overlayPaths, - Submodules: submodules, - }, nil -} - -func importWorkspaceRepoToGuest(ctx context.Context, client guestSSHClient, spec workspaceRepoSpec, guestPath string, mode model.WorkspacePrepareMode) error { - switch mode { - case model.WorkspacePrepareModeFullCopy: - var copyLog bytes.Buffer - command := fmt.Sprintf("rm -rf %s && mkdir -p %s && tar -o -C %s --strip-components=1 -xf -", sess.ShellQuote(guestPath), sess.ShellQuote(guestPath), sess.ShellQuote(guestPath)) - if err := client.StreamTar(ctx, spec.RepoRoot, command, ©Log); err != nil { - return sess.FormatStepError("copy full workspace", err, copyLog.String()) - } - var finalizeLog bytes.Buffer - if err := client.RunScript(ctx, workspaceFinalizeScript(spec, guestPath, mode), &finalizeLog); err != nil { - return sess.FormatStepError("finalize full workspace", err, finalizeLog.String()) - } - return nil - case model.WorkspacePrepareModeMetadataOnly, model.WorkspacePrepareModeShallowOverlay: - repoCopyDir, cleanup, err := prepareWorkspaceRepoCopy(ctx, spec) - if err != nil { - return err - } - defer cleanup() - var copyLog bytes.Buffer - command := fmt.Sprintf("rm -rf %s && mkdir -p %s && tar -o -C %s --strip-components=1 -xf -", sess.ShellQuote(guestPath), sess.ShellQuote(guestPath), sess.ShellQuote(guestPath)) - if err := client.StreamTar(ctx, repoCopyDir, command, ©Log); err != nil { - return sess.FormatStepError("copy guest git metadata", err, copyLog.String()) - } - var scriptLog bytes.Buffer - if err := client.RunScript(ctx, workspaceFinalizeScript(spec, guestPath, mode), &scriptLog); err != nil { - return sess.FormatStepError("prepare guest checkout", err, scriptLog.String()) - } - if mode == model.WorkspacePrepareModeMetadataOnly { - return nil - } - var overlayLog bytes.Buffer - command = fmt.Sprintf("tar -o -C %s --strip-components=1 -xf -", sess.ShellQuote(guestPath)) - if err := client.StreamTarEntries(ctx, spec.RepoRoot, spec.OverlayPaths, command, &overlayLog); err != nil { - return sess.FormatStepError("overlay workspace working tree", err, overlayLog.String()) - } - return nil - default: - return fmt.Errorf("unsupported workspace mode %q", mode) - } -} - -func workspaceFinalizeScript(spec workspaceRepoSpec, guestPath string, mode model.WorkspacePrepareMode) string { - var script strings.Builder - script.WriteString("set -euo pipefail\n") - fmt.Fprintf(&script, "DIR=%s\n", sess.ShellQuote(guestPath)) - script.WriteString("git config --global --add safe.directory \"$DIR\"\n") - if mode != model.WorkspacePrepareModeFullCopy { - script.WriteString("find \"$DIR\" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +\n") - } - switch { - case strings.TrimSpace(spec.BranchName) != "": - fmt.Fprintf(&script, "git -C \"$DIR\" checkout -B %s %s\n", sess.ShellQuote(spec.BranchName), sess.ShellQuote(spec.BaseCommit)) - case strings.TrimSpace(spec.CurrentBranch) != "": - fmt.Fprintf(&script, "git -C \"$DIR\" checkout -B %s %s\n", sess.ShellQuote(spec.CurrentBranch), sess.ShellQuote(spec.HeadCommit)) - default: - fmt.Fprintf(&script, "git -C \"$DIR\" checkout --detach %s\n", sess.ShellQuote(spec.HeadCommit)) - } - if strings.TrimSpace(spec.GitUserName) != "" && strings.TrimSpace(spec.GitUserEmail) != "" { - fmt.Fprintf(&script, "git -C \"$DIR\" config user.name %s\n", sess.ShellQuote(spec.GitUserName)) - fmt.Fprintf(&script, "git -C \"$DIR\" config user.email %s\n", sess.ShellQuote(spec.GitUserEmail)) - } - return script.String() -} - -func prepareWorkspaceRepoCopy(ctx context.Context, spec workspaceRepoSpec) (string, func(), error) { - tempRoot, err := os.MkdirTemp("", "banger-workspace-*") - if err != nil { - return "", nil, err - } - cleanup := func() { _ = os.RemoveAll(tempRoot) } - repoCopyDir := filepath.Join(tempRoot, spec.RepoName) - cloneArgs := []string{"clone", "--no-checkout", "--depth", fmt.Sprintf("%d", workspaceShallowFetchDepth)} - if strings.TrimSpace(spec.CurrentBranch) != "" { - cloneArgs = append(cloneArgs, "--single-branch", "--branch", spec.CurrentBranch) - } - cloneArgs = append(cloneArgs, workspaceGitFileURL(spec.RepoRoot), repoCopyDir) - if err := workspaceRunHostCommand(ctx, "git", cloneArgs...); err != nil { - cleanup() - return "", nil, fmt.Errorf("clone shallow workspace repo copy: %w", err) - } - checkoutCommit := spec.HeadCommit - if strings.TrimSpace(spec.BranchName) != "" { - checkoutCommit = spec.BaseCommit - } - if err := workspaceRunHostCommand(ctx, "git", "-C", repoCopyDir, "cat-file", "-e", checkoutCommit+"^{commit}"); err != nil { - if err := workspaceRunHostCommand(ctx, "git", "-C", repoCopyDir, "fetch", "--depth", fmt.Sprintf("%d", workspaceShallowFetchDepth), workspaceGitFileURL(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 := workspaceRunHostCommand(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 := workspaceRunHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "remove", "origin"); err != nil { - cleanup() - return "", nil, fmt.Errorf("remove workspace placeholder origin remote: %w", err) - } - } - return repoCopyDir, cleanup, nil -} - -func resolveWorkspaceSourcePath(rawPath string) (string, error) { - if strings.TrimSpace(rawPath) == "" { - return "", errors.New("workspace source path is required") - } - absPath, err := filepath.Abs(rawPath) - if err != nil { - return "", err - } - info, err := os.Stat(absPath) - if err != nil { - return "", err - } - if !info.IsDir() { - return "", fmt.Errorf("%s is not a directory", absPath) - } - return absPath, nil -} - -func listWorkspaceSubmodules(ctx context.Context, repoRoot string) ([]string, error) { - output, err := workspaceGitOutput(ctx, repoRoot, "ls-files", "--stage", "-z") - if err != nil { - return nil, fmt.Errorf("inspect workspace git index for %s: %w", repoRoot, err) - } - var submodules []string - for _, record := range workspaceParseNullSeparatedOutput(output) { - if !strings.HasPrefix(record, "160000 ") { - continue - } - _, path, ok := strings.Cut(record, " ") - if !ok { - continue - } - submodules = append(submodules, strings.TrimSpace(path)) - } - sort.Strings(submodules) - return submodules, nil -} - -func listWorkspaceOverlayPaths(ctx context.Context, repoRoot string) ([]string, error) { - trackedOutput, err := workspaceGitOutput(ctx, repoRoot, "ls-files", "-z") - if err != nil { - return nil, fmt.Errorf("list tracked files for %s: %w", repoRoot, err) - } - untrackedOutput, err := workspaceGitOutput(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) - seen := make(map[string]struct{}) - for _, relPath := range workspaceParseNullSeparatedOutput(trackedOutput) { - if relPath == "" { - continue - } - if _, err := os.Lstat(filepath.Join(repoRoot, relPath)); err != nil { - if os.IsNotExist(err) { - continue - } - return nil, err - } - seen[relPath] = struct{}{} - paths = append(paths, relPath) - } - for _, relPath := range workspaceParseNullSeparatedOutput(untrackedOutput) { - if relPath == "" { - continue - } - if _, ok := seen[relPath]; ok { - continue - } - seen[relPath] = struct{}{} - paths = append(paths, relPath) - } - sort.Strings(paths) - return paths, nil -} - -func parseWorkspacePrepareMode(raw string) (model.WorkspacePrepareMode, error) { - switch strings.TrimSpace(raw) { - case "", string(model.WorkspacePrepareModeShallowOverlay): - return model.WorkspacePrepareModeShallowOverlay, nil - case string(model.WorkspacePrepareModeFullCopy): - return model.WorkspacePrepareModeFullCopy, nil - case string(model.WorkspacePrepareModeMetadataOnly): - return model.WorkspacePrepareModeMetadataOnly, nil - default: - return "", fmt.Errorf("unsupported workspace mode %q", raw) - } -} - -func workspaceGitOutput(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 guestSessionHostCommandOutputFunc(ctx, "git", fullArgs...) -} - -func workspaceGitTrimmedOutput(ctx context.Context, dir string, args ...string) (string, error) { - output, err := workspaceGitOutput(ctx, dir, args...) - if err != nil { - return "", err - } - return strings.TrimSpace(string(output)), nil -} - -func workspaceGitResolvedConfigValue(ctx context.Context, dir, key string) (string, error) { - return workspaceGitTrimmedOutput(ctx, dir, "config", "--default", "", "--get", key) -} - -func workspaceParseNullSeparatedOutput(output []byte) []string { - chunks := bytes.Split(output, []byte{0}) - values := make([]string, 0, len(chunks)) - for _, chunk := range chunks { - value := strings.TrimSpace(string(chunk)) - if value == "" { - continue - } - values = append(values, value) - } - return values -} - -func workspaceRunHostCommand(ctx context.Context, name string, args ...string) error { - _, err := guestSessionHostCommandOutputFunc(ctx, name, args...) - return err -} - -func workspaceGitFileURL(path string) string { - return (&url.URL{Scheme: "file", Path: filepath.ToSlash(path)}).String() -} diff --git a/internal/daemon/workspace/workspace.go b/internal/daemon/workspace/workspace.go new file mode 100644 index 0000000..30c1973 --- /dev/null +++ b/internal/daemon/workspace/workspace.go @@ -0,0 +1,400 @@ +// Package workspace contains the pure helpers of the workspace subsystem: +// git repo inspection, shallow copy preparation, guest-side tar import, +// finalization script generation, and small utilities. +// +// The orchestrator methods (ExportVMWorkspace, PrepareVMWorkspace) stay on +// *daemon.Daemon. +package workspace + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + + sess "banger/internal/daemon/session" + "banger/internal/model" + "banger/internal/system" +) + +// ShallowFetchDepth is the default --depth for the transient shallow clone +// used by metadata / overlay prepare modes. +const ShallowFetchDepth = 10 + +// RepoSpec describes the host-side git repository we're about to import into +// a guest. It captures the pieces both InspectRepo and the prepare flow need. +type RepoSpec struct { + SourcePath string + RepoRoot string + RepoName string + HeadCommit string + CurrentBranch string + BranchName string + BaseCommit string + OriginURL string + GitUserName string + GitUserEmail string + OverlayPaths []string + Submodules []string +} + +// GuestClient is the narrow subset of guest SSH operations needed by +// ImportRepoToGuest. Satisfied by the daemon-package guestSSHClient. +type GuestClient interface { + RunScript(ctx context.Context, script string, log io.Writer) error + StreamTar(ctx context.Context, dir, command string, log io.Writer) error + 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) { + runner := system.NewRunner() + output, err := runner.Run(ctx, name, args...) + if err == nil { + return output, nil + } + command := strings.TrimSpace(strings.Join(append([]string{name}, args...), " ")) + detail := strings.TrimSpace(string(output)) + if detail == "" { + return output, fmt.Errorf("%s: %w", command, err) + } + return output, fmt.Errorf("%s: %w: %s", command, err, detail) +} + +// InspectRepo resolves rawPath into an absolute repo root and captures the +// HEAD, branch, optional base-from ref, git identity, origin URL, submodules, +// and overlay paths (tracked + untracked non-ignored files) needed for a +// prepare. +func InspectRepo(ctx context.Context, rawPath, branchName, fromRef string) (RepoSpec, error) { + sourcePath, err := ResolveSourcePath(rawPath) + if err != nil { + return RepoSpec{}, err + } + repoRoot, err := 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") + 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) + if err != nil { + return RepoSpec{}, err + } + headCommit, err := 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") + 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}") + if err != nil { + return RepoSpec{}, fmt.Errorf("resolve workspace from %q: %w", fromRef, err) + } + } + gitUserName, err := 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") + if err != nil { + return RepoSpec{}, fmt.Errorf("resolve git user.email for %s: %w", repoRoot, err) + } + originURL, err := 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) + if err != nil { + return RepoSpec{}, err + } + return RepoSpec{ + SourcePath: sourcePath, + RepoRoot: repoRoot, + RepoName: filepath.Base(repoRoot), + HeadCommit: headCommit, + CurrentBranch: currentBranch, + BranchName: branchName, + BaseCommit: baseCommit, + OriginURL: originURL, + GitUserName: gitUserName, + GitUserEmail: gitUserEmail, + OverlayPaths: overlayPaths, + Submodules: submodules, + }, nil +} + +// 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 { + switch mode { + case model.WorkspacePrepareModeFullCopy: + var copyLog bytes.Buffer + command := fmt.Sprintf("rm -rf %s && mkdir -p %s && tar -o -C %s --strip-components=1 -xf -", sess.ShellQuote(guestPath), sess.ShellQuote(guestPath), sess.ShellQuote(guestPath)) + if err := client.StreamTar(ctx, spec.RepoRoot, command, ©Log); err != nil { + return sess.FormatStepError("copy full workspace", err, copyLog.String()) + } + var finalizeLog bytes.Buffer + if err := client.RunScript(ctx, FinalizeScript(spec, guestPath, mode), &finalizeLog); err != nil { + return sess.FormatStepError("finalize full workspace", err, finalizeLog.String()) + } + return nil + case model.WorkspacePrepareModeMetadataOnly, model.WorkspacePrepareModeShallowOverlay: + repoCopyDir, cleanup, err := PrepareRepoCopy(ctx, spec) + if err != nil { + return err + } + defer cleanup() + var copyLog bytes.Buffer + command := fmt.Sprintf("rm -rf %s && mkdir -p %s && tar -o -C %s --strip-components=1 -xf -", sess.ShellQuote(guestPath), sess.ShellQuote(guestPath), sess.ShellQuote(guestPath)) + if err := client.StreamTar(ctx, repoCopyDir, command, ©Log); err != nil { + return sess.FormatStepError("copy guest git metadata", err, copyLog.String()) + } + var scriptLog bytes.Buffer + if err := client.RunScript(ctx, FinalizeScript(spec, guestPath, mode), &scriptLog); err != nil { + return sess.FormatStepError("prepare guest checkout", err, scriptLog.String()) + } + if mode == model.WorkspacePrepareModeMetadataOnly { + return nil + } + var overlayLog bytes.Buffer + command = fmt.Sprintf("tar -o -C %s --strip-components=1 -xf -", sess.ShellQuote(guestPath)) + if err := client.StreamTarEntries(ctx, spec.RepoRoot, spec.OverlayPaths, command, &overlayLog); err != nil { + return sess.FormatStepError("overlay workspace working tree", err, overlayLog.String()) + } + return nil + default: + return fmt.Errorf("unsupported workspace mode %q", mode) + } +} + +// FinalizeScript returns the bash script run inside the guest after the repo +// copy lands: safe.directory, optional cleanup, branch/detached checkout, +// and git identity config. +func FinalizeScript(spec RepoSpec, guestPath string, mode model.WorkspacePrepareMode) string { + var script strings.Builder + script.WriteString("set -euo pipefail\n") + fmt.Fprintf(&script, "DIR=%s\n", sess.ShellQuote(guestPath)) + script.WriteString("git config --global --add safe.directory \"$DIR\"\n") + if mode != model.WorkspacePrepareModeFullCopy { + script.WriteString("find \"$DIR\" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +\n") + } + switch { + case strings.TrimSpace(spec.BranchName) != "": + fmt.Fprintf(&script, "git -C \"$DIR\" checkout -B %s %s\n", sess.ShellQuote(spec.BranchName), sess.ShellQuote(spec.BaseCommit)) + case strings.TrimSpace(spec.CurrentBranch) != "": + fmt.Fprintf(&script, "git -C \"$DIR\" checkout -B %s %s\n", sess.ShellQuote(spec.CurrentBranch), sess.ShellQuote(spec.HeadCommit)) + default: + fmt.Fprintf(&script, "git -C \"$DIR\" checkout --detach %s\n", sess.ShellQuote(spec.HeadCommit)) + } + if strings.TrimSpace(spec.GitUserName) != "" && strings.TrimSpace(spec.GitUserEmail) != "" { + fmt.Fprintf(&script, "git -C \"$DIR\" config user.name %s\n", sess.ShellQuote(spec.GitUserName)) + fmt.Fprintf(&script, "git -C \"$DIR\" config user.email %s\n", sess.ShellQuote(spec.GitUserEmail)) + } + return script.String() +} + +// 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) { + tempRoot, err := os.MkdirTemp("", "banger-workspace-*") + if err != nil { + return "", nil, err + } + cleanup := func() { _ = os.RemoveAll(tempRoot) } + repoCopyDir := filepath.Join(tempRoot, spec.RepoName) + cloneArgs := []string{"clone", "--no-checkout", "--depth", fmt.Sprintf("%d", ShallowFetchDepth)} + if strings.TrimSpace(spec.CurrentBranch) != "" { + cloneArgs = append(cloneArgs, "--single-branch", "--branch", spec.CurrentBranch) + } + cloneArgs = append(cloneArgs, GitFileURL(spec.RepoRoot), repoCopyDir) + if err := RunHostCommand(ctx, "git", cloneArgs...); err != nil { + cleanup() + return "", nil, fmt.Errorf("clone shallow workspace repo copy: %w", err) + } + checkoutCommit := spec.HeadCommit + 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 { + 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 { + cleanup() + return "", nil, fmt.Errorf("set workspace origin remote: %w", err) + } + } else { + if err := RunHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "remove", "origin"); err != nil { + cleanup() + return "", nil, fmt.Errorf("remove workspace placeholder origin remote: %w", err) + } + } + return repoCopyDir, cleanup, nil +} + +// ResolveSourcePath expands rawPath to an absolute path and verifies it is +// an existing directory. +func ResolveSourcePath(rawPath string) (string, error) { + if strings.TrimSpace(rawPath) == "" { + return "", errors.New("workspace source path is required") + } + absPath, err := filepath.Abs(rawPath) + if err != nil { + return "", err + } + info, err := os.Stat(absPath) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("%s is not a directory", absPath) + } + return absPath, nil +} + +// 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") + if err != nil { + return nil, fmt.Errorf("inspect workspace git index for %s: %w", repoRoot, err) + } + var submodules []string + for _, record := range ParseNullSeparatedOutput(output) { + if !strings.HasPrefix(record, "160000 ") { + continue + } + _, path, ok := strings.Cut(record, "\t") + if !ok { + continue + } + submodules = append(submodules, strings.TrimSpace(path)) + } + sort.Strings(submodules) + return submodules, nil +} + +// ListOverlayPaths returns tracked + untracked non-ignored files in +// repoRoot. Missing tracked entries (deleted working-tree files) are skipped. +func ListOverlayPaths(ctx context.Context, repoRoot string) ([]string, error) { + trackedOutput, err := GitOutput(ctx, repoRoot, "ls-files", "-z") + if err != nil { + 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) + seen := make(map[string]struct{}) + for _, relPath := range ParseNullSeparatedOutput(trackedOutput) { + if relPath == "" { + continue + } + if _, err := os.Lstat(filepath.Join(repoRoot, relPath)); err != nil { + if os.IsNotExist(err) { + continue + } + return nil, err + } + seen[relPath] = struct{}{} + paths = append(paths, relPath) + } + for _, relPath := range ParseNullSeparatedOutput(untrackedOutput) { + if relPath == "" { + continue + } + if _, ok := seen[relPath]; ok { + continue + } + seen[relPath] = struct{}{} + paths = append(paths, relPath) + } + sort.Strings(paths) + return paths, nil +} + +// ParsePrepareMode validates and canonicalises a user-supplied mode value. +func ParsePrepareMode(raw string) (model.WorkspacePrepareMode, error) { + switch strings.TrimSpace(raw) { + case "", string(model.WorkspacePrepareModeShallowOverlay): + return model.WorkspacePrepareModeShallowOverlay, nil + case string(model.WorkspacePrepareModeFullCopy): + return model.WorkspacePrepareModeFullCopy, nil + case string(model.WorkspacePrepareModeMetadataOnly): + return model.WorkspacePrepareModeMetadataOnly, nil + default: + return "", fmt.Errorf("unsupported workspace mode %q", raw) + } +} + +// GitOutput runs `git [-C dir] args...` and returns its raw stdout. +func 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...) +} + +// GitTrimmedOutput returns GitOutput with surrounding whitespace trimmed. +func GitTrimmedOutput(ctx context.Context, dir string, args ...string) (string, error) { + output, err := GitOutput(ctx, dir, args...) + if err != nil { + return "", err + } + return strings.TrimSpace(string(output)), nil +} + +// 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) +} + +// ParseNullSeparatedOutput splits on NULs and trims, returning non-empty +// values in order. +func ParseNullSeparatedOutput(output []byte) []string { + chunks := bytes.Split(output, []byte{0}) + values := make([]string, 0, len(chunks)) + for _, chunk := range chunks { + value := strings.TrimSpace(string(chunk)) + if value == "" { + continue + } + values = append(values, value) + } + 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...) + return err +} + +// GitFileURL returns a file:// URL for path, the form git requires when +// cloning from a local directory. +func GitFileURL(path string) string { + return (&url.URL{Scheme: "file", Path: filepath.ToSlash(path)}).String() +}