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
|
# Repository Guidelines
|
||||||
|
|
||||||
|
Always run `make build` before commit.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
- `cmd/banger` and `cmd/bangerd` are the main user entrypoints.
|
- `cmd/banger` and `cmd/bangerd` are the main user entrypoints.
|
||||||
|
|
|
||||||
|
|
@ -215,12 +215,14 @@ type GuestSessionSendResult struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceExportParams struct {
|
type WorkspaceExportParams struct {
|
||||||
IDOrName string `json:"id_or_name"`
|
IDOrName string `json:"id_or_name"`
|
||||||
GuestPath string `json:"guest_path,omitempty"`
|
GuestPath string `json:"guest_path,omitempty"`
|
||||||
|
BaseCommit string `json:"base_commit,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type WorkspaceExportResult struct {
|
type WorkspaceExportResult struct {
|
||||||
GuestPath string `json:"guest_path"`
|
GuestPath string `json:"guest_path"`
|
||||||
|
BaseCommit string `json:"base_commit"`
|
||||||
Patch []byte `json:"patch"`
|
Patch []byte `json:"patch"`
|
||||||
ChangedFiles []string `json:"changed_files"`
|
ChangedFiles []string `json:"changed_files"`
|
||||||
HasChanges bool `json:"has_changes"`
|
HasChanges bool `json:"has_changes"`
|
||||||
|
|
|
||||||
|
|
@ -941,13 +941,15 @@ func newVMWorkspacePrepareCommand() *cobra.Command {
|
||||||
func newVMWorkspaceExportCommand() *cobra.Command {
|
func newVMWorkspaceExportCommand() *cobra.Command {
|
||||||
var guestPath string
|
var guestPath string
|
||||||
var outputPath string
|
var outputPath string
|
||||||
|
var baseCommit string
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "export <id-or-name>",
|
Use: "export <id-or-name>",
|
||||||
Short: "Pull changes from a guest workspace back to the host as a patch",
|
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>"),
|
Args: exactArgsUsage(1, "usage: banger vm workspace export <id-or-name>"),
|
||||||
Example: strings.TrimSpace(`
|
Example: strings.TrimSpace(`
|
||||||
banger vm workspace export devbox | git apply
|
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 --output worker.diff
|
||||||
banger vm workspace export devbox --guest-path /root/project --output changes.diff
|
banger vm workspace export devbox --guest-path /root/project --output changes.diff
|
||||||
`),
|
`),
|
||||||
|
|
@ -957,8 +959,9 @@ func newVMWorkspaceExportCommand() *cobra.Command {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
result, err := vmWorkspaceExportFunc(cmd.Context(), layout.SocketPath, api.WorkspaceExportParams{
|
result, err := vmWorkspaceExportFunc(cmd.Context(), layout.SocketPath, api.WorkspaceExportParams{
|
||||||
IDOrName: args[0],
|
IDOrName: args[0],
|
||||||
GuestPath: guestPath,
|
GuestPath: guestPath,
|
||||||
|
BaseCommit: baseCommit,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|
@ -981,6 +984,7 @@ func newVMWorkspaceExportCommand() *cobra.Command {
|
||||||
}
|
}
|
||||||
cmd.Flags().StringVar(&guestPath, "guest-path", "/root/repo", "guest workspace path")
|
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(&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
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2218,3 +2218,30 @@ func TestVMWorkspaceExportGuestPathFlag(t *testing.T) {
|
||||||
t.Fatalf("IDOrName = %q, want devbox", capturedParams.IDOrName)
|
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()
|
defer client.Close()
|
||||||
|
|
||||||
// Stage all changes then emit a binary-safe unified diff against HEAD.
|
// diffRef is the git ref everything is diffed against.
|
||||||
// --binary ensures binary files are handled correctly by git apply.
|
// 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(
|
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(guestPath),
|
||||||
|
guestShellQuote(diffRef),
|
||||||
)
|
)
|
||||||
patch, err := client.RunScriptOutput(ctx, patchScript)
|
patch, err := client.RunScriptOutput(ctx, patchScript)
|
||||||
if err != nil {
|
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).
|
// Enumerate changed paths (index already staged; this is a cheap read).
|
||||||
namesScript := fmt.Sprintf(
|
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(guestPath),
|
||||||
|
guestShellQuote(diffRef),
|
||||||
)
|
)
|
||||||
namesOut, _ := client.RunScriptOutput(ctx, namesScript)
|
namesOut, _ := client.RunScriptOutput(ctx, namesScript)
|
||||||
var changed []string
|
var changed []string
|
||||||
|
|
@ -79,6 +92,7 @@ func (d *Daemon) ExportVMWorkspace(ctx context.Context, params api.WorkspaceExpo
|
||||||
|
|
||||||
return api.WorkspaceExportResult{
|
return api.WorkspaceExportResult{
|
||||||
GuestPath: guestPath,
|
GuestPath: guestPath,
|
||||||
|
BaseCommit: diffRef,
|
||||||
Patch: patch,
|
Patch: patch,
|
||||||
ChangedFiles: changed,
|
ChangedFiles: changed,
|
||||||
HasChanges: len(patch) > 0,
|
HasChanges: len(patch) > 0,
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import (
|
||||||
// Each call to RunScriptOutput returns the next response from the queue.
|
// Each call to RunScriptOutput returns the next response from the queue.
|
||||||
type exportGuestClient struct {
|
type exportGuestClient struct {
|
||||||
responses []exportGuestResponse
|
responses []exportGuestResponse
|
||||||
|
scripts []string
|
||||||
callIndex int
|
callIndex int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,7 +32,8 @@ func (e *exportGuestClient) RunScript(_ context.Context, _ string, _ io.Writer)
|
||||||
return nil
|
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) {
|
if e.callIndex >= len(e.responses) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
@ -113,6 +115,107 @@ func TestExportVMWorkspace_HappyPath(t *testing.T) {
|
||||||
if fake.callIndex != 2 {
|
if fake.callIndex != 2 {
|
||||||
t.Fatalf("RunScriptOutput call count = %d, want 2", fake.callIndex)
|
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) {
|
func TestExportVMWorkspace_NoChanges(t *testing.T) {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue