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 <noreply@anthropic.com>
156 lines
5.6 KiB
Go
156 lines
5.6 KiB
Go
package daemon
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"strings"
|
|
"time"
|
|
|
|
"banger/internal/api"
|
|
sess "banger/internal/daemon/session"
|
|
ws "banger/internal/daemon/workspace"
|
|
"banger/internal/model"
|
|
"banger/internal/system"
|
|
)
|
|
|
|
func (d *Daemon) ExportVMWorkspace(ctx context.Context, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error) {
|
|
guestPath := strings.TrimSpace(params.GuestPath)
|
|
if guestPath == "" {
|
|
guestPath = "/root/repo"
|
|
}
|
|
vm, err := d.FindVM(ctx, params.IDOrName)
|
|
if err != nil {
|
|
return api.WorkspaceExportResult{}, err
|
|
}
|
|
if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
|
|
return api.WorkspaceExportResult{}, fmt.Errorf("vm %q is not running", vm.Name)
|
|
}
|
|
client, err := d.dialGuest(ctx, net.JoinHostPort(vm.Runtime.GuestIP, "22"))
|
|
if err != nil {
|
|
return api.WorkspaceExportResult{}, fmt.Errorf("dial guest: %w", err)
|
|
}
|
|
defer client.Close()
|
|
|
|
// diffRef is the git ref everything is diffed against.
|
|
// When the caller supplies BaseCommit (the HEAD at workspace.prepare time),
|
|
// we diff against that fixed point so committed guest changes are included.
|
|
// Without it we fall back to HEAD, which silently drops them.
|
|
diffRef := strings.TrimSpace(params.BaseCommit)
|
|
if diffRef == "" {
|
|
diffRef = "HEAD"
|
|
}
|
|
|
|
// Stage all changes then emit a binary-safe unified diff against diffRef.
|
|
// After git add -A the index contains the full working state, so
|
|
// git diff --cached <diffRef> captures both committed deltas (HEAD moved
|
|
// past diffRef) and any additional uncommitted changes on top.
|
|
patchScript := fmt.Sprintf(
|
|
"set -euo pipefail\ncd %s\ngit add -A\ngit diff --cached %s --binary\n",
|
|
sess.ShellQuote(guestPath),
|
|
sess.ShellQuote(diffRef),
|
|
)
|
|
patch, err := client.RunScriptOutput(ctx, patchScript)
|
|
if err != nil {
|
|
return api.WorkspaceExportResult{}, fmt.Errorf("export workspace diff: %w", err)
|
|
}
|
|
|
|
// Enumerate changed paths (index already staged; this is a cheap read).
|
|
namesScript := fmt.Sprintf(
|
|
"set -euo pipefail\ncd %s\ngit diff --cached %s --name-only\n",
|
|
sess.ShellQuote(guestPath),
|
|
sess.ShellQuote(diffRef),
|
|
)
|
|
namesOut, _ := client.RunScriptOutput(ctx, namesScript)
|
|
var changed []string
|
|
for _, line := range strings.Split(strings.TrimSpace(string(namesOut)), "\n") {
|
|
if line = strings.TrimSpace(line); line != "" {
|
|
changed = append(changed, line)
|
|
}
|
|
}
|
|
|
|
return api.WorkspaceExportResult{
|
|
GuestPath: guestPath,
|
|
BaseCommit: diffRef,
|
|
Patch: patch,
|
|
ChangedFiles: changed,
|
|
HasChanges: len(patch) > 0,
|
|
}, nil
|
|
}
|
|
|
|
func (d *Daemon) PrepareVMWorkspace(ctx context.Context, params api.VMWorkspacePrepareParams) (model.WorkspacePrepareResult, error) {
|
|
mode, err := ws.ParsePrepareMode(params.Mode)
|
|
if err != nil {
|
|
return model.WorkspacePrepareResult{}, err
|
|
}
|
|
guestPath := strings.TrimSpace(params.GuestPath)
|
|
if guestPath == "" {
|
|
guestPath = "/root/repo"
|
|
}
|
|
branchName := strings.TrimSpace(params.Branch)
|
|
fromRef := strings.TrimSpace(params.From)
|
|
if branchName != "" && fromRef == "" {
|
|
fromRef = "HEAD"
|
|
}
|
|
if branchName == "" && strings.TrimSpace(params.From) != "" {
|
|
return model.WorkspacePrepareResult{}, errors.New("workspace from requires branch")
|
|
}
|
|
var prepared model.WorkspacePrepareResult
|
|
_, err = d.withVMLockByRef(ctx, params.IDOrName, func(vm model.VMRecord) (model.VMRecord, error) {
|
|
if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
|
|
return model.VMRecord{}, fmt.Errorf("vm %q is not running", vm.Name)
|
|
}
|
|
result, err := d.prepareVMWorkspaceLocked(ctx, vm, strings.TrimSpace(params.SourcePath), guestPath, branchName, fromRef, mode, params.ReadOnly)
|
|
if err != nil {
|
|
return model.VMRecord{}, err
|
|
}
|
|
prepared = result
|
|
return vm, nil
|
|
})
|
|
return prepared, err
|
|
}
|
|
|
|
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 := ws.InspectRepo(ctx, sourcePath, branchName, fromRef)
|
|
if err != nil {
|
|
return model.WorkspacePrepareResult{}, err
|
|
}
|
|
if len(spec.Submodules) > 0 && mode != model.WorkspacePrepareModeFullCopy {
|
|
return model.WorkspacePrepareResult{}, fmt.Errorf("workspace mode %q does not support git submodules in %s (%s); use --mode full_copy", mode, spec.RepoRoot, strings.Join(spec.Submodules, ", "))
|
|
}
|
|
address := net.JoinHostPort(vm.Runtime.GuestIP, "22")
|
|
if err := d.waitForGuestSSH(ctx, address, 250*time.Millisecond); err != nil {
|
|
return model.WorkspacePrepareResult{}, fmt.Errorf("guest ssh unavailable: %w", err)
|
|
}
|
|
client, err := d.dialGuest(ctx, address)
|
|
if err != nil {
|
|
return model.WorkspacePrepareResult{}, fmt.Errorf("dial guest ssh: %w", err)
|
|
}
|
|
defer client.Close()
|
|
if err := ws.ImportRepoToGuest(ctx, client, spec, guestPath, mode); err != nil {
|
|
return model.WorkspacePrepareResult{}, err
|
|
}
|
|
if readOnly {
|
|
var chmodLog bytes.Buffer
|
|
chmodScript := fmt.Sprintf("set -euo pipefail\nchmod -R a-w %s\n", sess.ShellQuote(guestPath))
|
|
if err := client.RunScript(ctx, chmodScript, &chmodLog); err != nil {
|
|
return model.WorkspacePrepareResult{}, sess.FormatStepError("set workspace readonly", err, chmodLog.String())
|
|
}
|
|
}
|
|
return model.WorkspacePrepareResult{
|
|
VMID: vm.ID,
|
|
SourcePath: spec.SourcePath,
|
|
RepoRoot: spec.RepoRoot,
|
|
RepoName: spec.RepoName,
|
|
GuestPath: guestPath,
|
|
Mode: mode,
|
|
ReadOnly: readOnly,
|
|
HeadCommit: spec.HeadCommit,
|
|
CurrentBranch: spec.CurrentBranch,
|
|
BranchName: spec.BranchName,
|
|
BaseCommit: spec.BaseCommit,
|
|
PreparedAt: model.Now(),
|
|
}, nil
|
|
}
|