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

@ -43,26 +43,18 @@ func (d *Daemon) ExportVMWorkspace(ctx context.Context, params api.WorkspaceExpo
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),
)
// Both scripts run `git add -A` to capture the working tree
// (committed deltas + uncommitted modifications + untracked files
// minus .gitignore), but they route it through a throwaway index
// file instead of .git/index. Export is an observation step; the
// user's real staging area must stay exactly as they left it.
patchScript := exportScript(guestPath, diffRef, "--binary")
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),
)
namesScript := exportScript(guestPath, diffRef, "--name-only")
namesOut, _ := client.RunScriptOutput(ctx, namesScript)
var changed []string
for _, line := range strings.Split(strings.TrimSpace(string(namesOut)), "\n") {
@ -80,6 +72,30 @@ func (d *Daemon) ExportVMWorkspace(ctx context.Context, params api.WorkspaceExpo
}, nil
}
// exportScript emits a shell snippet that diffs the working tree at
// guestPath against diffRef (HEAD or a commit SHA) WITHOUT touching
// the repo's real index. diffFlag selects the git-diff output mode
// ("--binary" for the patch body, "--name-only" for the file list).
//
// Mechanics: seed a temp index from diffRef's tree via git read-tree,
// restage the working tree into that temp index with GIT_INDEX_FILE,
// then emit the diff. The temp index is rm'd on exit via trap.
func exportScript(guestPath, diffRef, diffFlag string) string {
return fmt.Sprintf(
"set -euo pipefail\n"+
"cd %s\n"+
"tmp_idx=\"$(mktemp \"${TMPDIR:-/tmp}/banger-export.XXXXXX\")\"\n"+
"trap 'rm -f \"$tmp_idx\"' EXIT\n"+
"git read-tree %s --index-output=\"$tmp_idx\"\n"+
"GIT_INDEX_FILE=\"$tmp_idx\" git add -A\n"+
"GIT_INDEX_FILE=\"$tmp_idx\" git diff --cached %s %s\n",
sess.ShellQuote(guestPath),
sess.ShellQuote(diffRef),
sess.ShellQuote(diffRef),
diffFlag,
)
}
func (d *Daemon) PrepareVMWorkspace(ctx context.Context, params api.VMWorkspacePrepareParams) (model.WorkspacePrepareResult, error) {
mode, err := ws.ParsePrepareMode(params.Mode)
if err != nil {