workspace export: stop mutating the guest repo index

Previously `banger vm workspace export` ran `git add -A` against the
guest's real `.git/index`, so the observation step left staged
changes behind that users never asked for. Reconnecting later (ssh,
another export) surfaced them and looked like phantom work.

Route `git add -A` through a throwaway index file instead:

  tmp_idx=$(mktemp ...)
  trap 'rm -f "$tmp_idx"' EXIT
  git read-tree <ref> --index-output="$tmp_idx"
  GIT_INDEX_FILE="$tmp_idx" git add -A
  GIT_INDEX_FILE="$tmp_idx" git diff --cached <ref> --binary|--name-only

The real .git/index, working tree, and refs stay exactly as the user
left them. Same diff content — commits past <ref>, uncommitted edits,
and untracked files (minus .gitignore) all captured.

Regression test locks the invariant: every export script must route
add -A through GIT_INDEX_FILE and clean the temp index on exit. CLI
help text updated to say "non-mutating".
This commit is contained in:
Thales Maciel 2026-04-19 13:20:56 -03:00
parent 21b74639d8
commit 99de42385f
No known key found for this signature in database
GPG key ID: 33112E6833C34679
3 changed files with 92 additions and 16 deletions

View file

@ -355,3 +355,63 @@ func TestExportVMWorkspace_MultipleChangedFiles(t *testing.T) {
}
}
}
// TestExportVMWorkspace_DoesNotMutateRealIndex is a regression guard
// for an earlier design where `git add -A` ran against the guest's
// real `.git/index`, leaving staged changes behind after what the user
// thought was a read-only observation. Every export script must now
// route `git add -A` through a throwaway index selected by
// GIT_INDEX_FILE, and every script must clean that file up.
func TestExportVMWorkspace_DoesNotMutateRealIndex(t *testing.T) {
t.Parallel()
ctx := context.Background()
apiSock := filepath.Join(t.TempDir(), "fc.sock")
firecracker := startFakeFirecracker(t, apiSock)
vm := testVM("exportbox-readonly", "image-export", "172.16.0.107")
vm.State = model.VMStateRunning
vm.Runtime.State = model.VMStateRunning
vm.Runtime.PID = firecracker.Process.Pid
vm.Runtime.APISockPath = apiSock
fake := &exportGuestClient{
responses: []exportGuestResponse{
{output: []byte("diff --git a/x b/x\n")},
{output: []byte("x\n")},
},
}
d := newExportTestDaemonStore(t, fake)
upsertDaemonVM(t, ctx, d.store, vm)
if _, err := d.ExportVMWorkspace(ctx, api.WorkspaceExportParams{IDOrName: vm.Name}); err != nil {
t.Fatalf("ExportVMWorkspace: %v", err)
}
if len(fake.scripts) == 0 {
t.Fatal("expected at least one export script to be sent")
}
for i, script := range fake.scripts {
if !strings.Contains(script, "GIT_INDEX_FILE") {
t.Errorf("script[%d] missing GIT_INDEX_FILE routing:\n%s", i, script)
}
// git add -A must ONLY appear on a line that also sets
// GIT_INDEX_FILE. A bare occurrence would mutate the real
// index.
for _, line := range strings.Split(script, "\n") {
if strings.Contains(line, "git add -A") && !strings.Contains(line, "GIT_INDEX_FILE") {
t.Errorf("script[%d] has unscoped `git add -A`:\n%s", i, script)
break
}
}
if !strings.Contains(script, "git read-tree") {
t.Errorf("script[%d] missing git read-tree (temp index seed):\n%s", i, script)
}
if !strings.Contains(script, "mktemp") {
t.Errorf("script[%d] missing mktemp for temp index:\n%s", i, script)
}
if !strings.Contains(script, "trap") || !strings.Contains(script, "rm") {
t.Errorf("script[%d] missing temp-index cleanup trap:\n%s", i, script)
}
}
}