workspace.export: add base_commit to capture worker git commits
Without base_commit, export diffs against the current guest HEAD. If the worker ran git commit inside the VM, HEAD advanced and the diff came back empty — committed work was silently lost. With base_commit set to the head_commit from workspace.prepare, the diff uses that fixed point instead. After git add -A the index holds the full working state, so git diff --cached <base_commit> captures everything: committed deltas (HEAD moved past base) and any uncommitted changes on top, in one patch, applied with the same git apply flow. - WorkspaceExportParams gains base_commit - WorkspaceExportResult echoes back the ref actually used - CLI gains --base-commit flag - Tests assert scripts use the caller-supplied ref and that omitting it falls back to HEAD
This commit is contained in:
parent
94c353f317
commit
ff51b7ce21
6 changed files with 162 additions and 10 deletions
|
|
@ -1,5 +1,7 @@
|
|||
# Repository Guidelines
|
||||
|
||||
Always run `make build` before commit.
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `cmd/banger` and `cmd/bangerd` are the main user entrypoints.
|
||||
|
|
|
|||
|
|
@ -215,12 +215,14 @@ type GuestSessionSendResult struct {
|
|||
}
|
||||
|
||||
type WorkspaceExportParams struct {
|
||||
IDOrName string `json:"id_or_name"`
|
||||
GuestPath string `json:"guest_path,omitempty"`
|
||||
IDOrName string `json:"id_or_name"`
|
||||
GuestPath string `json:"guest_path,omitempty"`
|
||||
BaseCommit string `json:"base_commit,omitempty"`
|
||||
}
|
||||
|
||||
type WorkspaceExportResult struct {
|
||||
GuestPath string `json:"guest_path"`
|
||||
BaseCommit string `json:"base_commit"`
|
||||
Patch []byte `json:"patch"`
|
||||
ChangedFiles []string `json:"changed_files"`
|
||||
HasChanges bool `json:"has_changes"`
|
||||
|
|
|
|||
|
|
@ -941,13 +941,15 @@ func newVMWorkspacePrepareCommand() *cobra.Command {
|
|||
func newVMWorkspaceExportCommand() *cobra.Command {
|
||||
var guestPath string
|
||||
var outputPath string
|
||||
var baseCommit string
|
||||
cmd := &cobra.Command{
|
||||
Use: "export <id-or-name>",
|
||||
Short: "Pull changes from a guest workspace back to the host as a patch",
|
||||
Long: "Stage all changes inside the guest workspace (git add -A) and emit a binary-safe unified diff against HEAD. With no --output flag the patch is written to stdout so it can be piped directly to git apply.",
|
||||
Long: "Stage all changes inside the guest workspace (git add -A) and emit a binary-safe unified diff. Pass --base-commit with the head_commit from workspace prepare to capture changes even when the worker ran git commit inside the VM. Without --base-commit the diff is against the current guest HEAD, which misses committed changes.",
|
||||
Args: exactArgsUsage(1, "usage: banger vm workspace export <id-or-name>"),
|
||||
Example: strings.TrimSpace(`
|
||||
banger vm workspace export devbox | git apply
|
||||
banger vm workspace export devbox --base-commit abc1234 | git apply
|
||||
banger vm workspace export devbox --output worker.diff
|
||||
banger vm workspace export devbox --guest-path /root/project --output changes.diff
|
||||
`),
|
||||
|
|
@ -957,8 +959,9 @@ func newVMWorkspaceExportCommand() *cobra.Command {
|
|||
return err
|
||||
}
|
||||
result, err := vmWorkspaceExportFunc(cmd.Context(), layout.SocketPath, api.WorkspaceExportParams{
|
||||
IDOrName: args[0],
|
||||
GuestPath: guestPath,
|
||||
IDOrName: args[0],
|
||||
GuestPath: guestPath,
|
||||
BaseCommit: baseCommit,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -981,6 +984,7 @@ func newVMWorkspaceExportCommand() *cobra.Command {
|
|||
}
|
||||
cmd.Flags().StringVar(&guestPath, "guest-path", "/root/repo", "guest workspace path")
|
||||
cmd.Flags().StringVar(&outputPath, "output", "", "write patch to this file instead of stdout")
|
||||
cmd.Flags().StringVar(&baseCommit, "base-commit", "", "diff from this commit (use head_commit from workspace prepare to capture worker git commits)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2218,3 +2218,30 @@ func TestVMWorkspaceExportGuestPathFlag(t *testing.T) {
|
|||
t.Fatalf("IDOrName = %q, want devbox", capturedParams.IDOrName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVMWorkspaceExportBaseCommitFlag(t *testing.T) {
|
||||
stubEnsureDaemonForSend(t)
|
||||
|
||||
origExport := vmWorkspaceExportFunc
|
||||
t.Cleanup(func() { vmWorkspaceExportFunc = origExport })
|
||||
|
||||
var capturedParams api.WorkspaceExportParams
|
||||
vmWorkspaceExportFunc = func(_ context.Context, _ string, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error) {
|
||||
capturedParams = params
|
||||
return api.WorkspaceExportResult{
|
||||
HasChanges: false,
|
||||
BaseCommit: params.BaseCommit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
cmd := NewBangerCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{"vm", "workspace", "export", "devbox", "--base-commit", "abc1234deadbeef"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute: %v", err)
|
||||
}
|
||||
if capturedParams.BaseCommit != "abc1234deadbeef" {
|
||||
t.Fatalf("BaseCommit = %q, want abc1234deadbeef", capturedParams.BaseCommit)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,11 +53,23 @@ func (d *Daemon) ExportVMWorkspace(ctx context.Context, params api.WorkspaceExpo
|
|||
}
|
||||
defer client.Close()
|
||||
|
||||
// Stage all changes then emit a binary-safe unified diff against HEAD.
|
||||
// --binary ensures binary files are handled correctly by git apply.
|
||||
// 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 HEAD --binary\n",
|
||||
"set -euo pipefail\ncd %s\ngit add -A\ngit diff --cached %s --binary\n",
|
||||
guestShellQuote(guestPath),
|
||||
guestShellQuote(diffRef),
|
||||
)
|
||||
patch, err := client.RunScriptOutput(ctx, patchScript)
|
||||
if err != nil {
|
||||
|
|
@ -66,8 +78,9 @@ func (d *Daemon) ExportVMWorkspace(ctx context.Context, params api.WorkspaceExpo
|
|||
|
||||
// Enumerate changed paths (index already staged; this is a cheap read).
|
||||
namesScript := fmt.Sprintf(
|
||||
"set -euo pipefail\ncd %s\ngit diff --cached HEAD --name-only\n",
|
||||
"set -euo pipefail\ncd %s\ngit diff --cached %s --name-only\n",
|
||||
guestShellQuote(guestPath),
|
||||
guestShellQuote(diffRef),
|
||||
)
|
||||
namesOut, _ := client.RunScriptOutput(ctx, namesScript)
|
||||
var changed []string
|
||||
|
|
@ -79,6 +92,7 @@ func (d *Daemon) ExportVMWorkspace(ctx context.Context, params api.WorkspaceExpo
|
|||
|
||||
return api.WorkspaceExportResult{
|
||||
GuestPath: guestPath,
|
||||
BaseCommit: diffRef,
|
||||
Patch: patch,
|
||||
ChangedFiles: changed,
|
||||
HasChanges: len(patch) > 0,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import (
|
|||
// Each call to RunScriptOutput returns the next response from the queue.
|
||||
type exportGuestClient struct {
|
||||
responses []exportGuestResponse
|
||||
scripts []string
|
||||
callIndex int
|
||||
}
|
||||
|
||||
|
|
@ -31,7 +32,8 @@ func (e *exportGuestClient) RunScript(_ context.Context, _ string, _ io.Writer)
|
|||
return nil
|
||||
}
|
||||
|
||||
func (e *exportGuestClient) RunScriptOutput(_ context.Context, _ string) ([]byte, error) {
|
||||
func (e *exportGuestClient) RunScriptOutput(_ context.Context, script string) ([]byte, error) {
|
||||
e.scripts = append(e.scripts, script)
|
||||
if e.callIndex >= len(e.responses) {
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -113,6 +115,107 @@ func TestExportVMWorkspace_HappyPath(t *testing.T) {
|
|||
if fake.callIndex != 2 {
|
||||
t.Fatalf("RunScriptOutput call count = %d, want 2", fake.callIndex)
|
||||
}
|
||||
// No base_commit provided: diff ref must be HEAD.
|
||||
for _, script := range fake.scripts {
|
||||
if !strings.Contains(script, "HEAD") {
|
||||
t.Fatalf("script missing HEAD ref: %q", script)
|
||||
}
|
||||
}
|
||||
if result.BaseCommit != "HEAD" {
|
||||
t.Fatalf("BaseCommit = %q, want HEAD", result.BaseCommit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportVMWorkspace_WithBaseCommit(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
apiSock := filepath.Join(t.TempDir(), "fc.sock")
|
||||
firecracker := startFakeFirecracker(t, apiSock)
|
||||
|
||||
vm := testVM("exportbox-base", "image-export", "172.16.0.105")
|
||||
vm.State = model.VMStateRunning
|
||||
vm.Runtime.State = model.VMStateRunning
|
||||
vm.Runtime.PID = firecracker.Process.Pid
|
||||
vm.Runtime.APISockPath = apiSock
|
||||
|
||||
// Simulate: worker committed inside the VM. Without base_commit the diff
|
||||
// against the new HEAD would be empty. With base_commit we capture
|
||||
// everything since the original checkout.
|
||||
patch := []byte("diff --git a/worker.go b/worker.go\nindex 0000000..abcdef 100644\n")
|
||||
names := []byte("worker.go\n")
|
||||
|
||||
fake := &exportGuestClient{
|
||||
responses: []exportGuestResponse{
|
||||
{output: patch},
|
||||
{output: names},
|
||||
},
|
||||
}
|
||||
d := newExportTestDaemonStore(t, fake)
|
||||
upsertDaemonVM(t, ctx, d.store, vm)
|
||||
|
||||
const prepareCommit = "abc1234deadbeef"
|
||||
result, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{
|
||||
IDOrName: vm.Name,
|
||||
BaseCommit: prepareCommit,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExportVMWorkspace: %v", err)
|
||||
}
|
||||
if !result.HasChanges {
|
||||
t.Fatal("HasChanges = false, want true")
|
||||
}
|
||||
if result.BaseCommit != prepareCommit {
|
||||
t.Fatalf("BaseCommit = %q, want %q", result.BaseCommit, prepareCommit)
|
||||
}
|
||||
// Both scripts must reference the caller-supplied commit, not HEAD.
|
||||
for _, script := range fake.scripts {
|
||||
if strings.Contains(script, " HEAD") {
|
||||
t.Fatalf("script used HEAD instead of base_commit: %q", script)
|
||||
}
|
||||
if !strings.Contains(script, prepareCommit) {
|
||||
t.Fatalf("script missing base_commit %q: %q", prepareCommit, script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportVMWorkspace_BaseCommitFallsBackToHEAD(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx := context.Background()
|
||||
|
||||
apiSock := filepath.Join(t.TempDir(), "fc.sock")
|
||||
firecracker := startFakeFirecracker(t, apiSock)
|
||||
|
||||
vm := testVM("exportbox-nobase", "image-export", "172.16.0.106")
|
||||
vm.State = model.VMStateRunning
|
||||
vm.Runtime.State = model.VMStateRunning
|
||||
vm.Runtime.PID = firecracker.Process.Pid
|
||||
vm.Runtime.APISockPath = apiSock
|
||||
|
||||
fake := &exportGuestClient{
|
||||
responses: []exportGuestResponse{
|
||||
{output: nil},
|
||||
{output: nil},
|
||||
},
|
||||
}
|
||||
d := newExportTestDaemonStore(t, fake)
|
||||
upsertDaemonVM(t, ctx, d.store, vm)
|
||||
|
||||
result, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{
|
||||
IDOrName: vm.Name,
|
||||
BaseCommit: "", // omitted
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ExportVMWorkspace: %v", err)
|
||||
}
|
||||
if result.BaseCommit != "HEAD" {
|
||||
t.Fatalf("BaseCommit = %q, want HEAD when not supplied", result.BaseCommit)
|
||||
}
|
||||
for _, script := range fake.scripts {
|
||||
if !strings.Contains(script, "HEAD") {
|
||||
t.Fatalf("script missing HEAD fallback: %q", script)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportVMWorkspace_NoChanges(t *testing.T) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue