feat(vm): add vm exec command with workspace dirty detection

Introduces three interconnected features for persistent VM workflows:

1. `banger vm exec <vm> -- <cmd>`: runs a command in the prepared
   workspace, automatically cd-ing into the guest path and wrapping
   via `mise exec --` so mise-managed tools are on PATH. Falls back
   to a plain exec when mise isn't available. Exit code propagates
   verbatim.

2. Workspace persistence: workspace.prepare now stores the guest path,
   host source path, and HEAD commit into a new `workspace_json` column
   on the vms table (migration 3). This state survives daemon restarts
   and informs both dirty-checking and auto-prepare.

3. Dirty detection: `vm exec` compares the stored HEAD commit against
   the current host repo HEAD. When stale it warns and, with
   --auto-prepare, re-syncs the workspace before running.

Also:
- WORKSPACE column added to `banger ps` / `vm list`
- `banger vm` quick reference updated with `vm exec` entry
This commit is contained in:
Thales Maciel 2026-04-26 23:53:45 -03:00
parent c8637b0fe4
commit d59425adb9
No known key found for this signature in database
GPG key ID: 33112E6833C34679
8 changed files with 260 additions and 13 deletions

View file

@ -205,7 +205,7 @@ func (s *WorkspaceService) miseTrustGuestRepo(ctx context.Context, client ws.Gue
// edit doesn't accidentally drop the `command -v` guard.
func miseTrustScript(guestPath string) string {
return fmt.Sprintf(
"if command -v mise >/dev/null 2>&1; then mise trust --quiet --all %s 2>/dev/null || true; fi\n",
"if command -v mise >/dev/null 2>&1; then cd %s && mise trust --quiet --all 2>/dev/null || true; fi\n",
ws.ShellQuote(guestPath),
)
}
@ -247,6 +247,18 @@ func (s *WorkspaceService) prepareVMWorkspaceGuestIO(ctx context.Context, vm mod
// 'banger vm run ./repo -- <cmd>' invocation. Best-effort: a
// missing mise binary or a 'trust' that does nothing is fine.
s.miseTrustGuestRepo(ctx, client, guestPath)
preparedAt := model.Now()
// Persist workspace state so `vm exec` and dirty-checking can
// resolve guest path + HEAD commit without re-stating them. Best
// effort: a store failure here doesn't roll back the prepare.
if err := s.store.SetVMWorkspace(ctx, vm.ID, model.VMWorkspace{
GuestPath: guestPath,
SourcePath: spec.SourcePath,
HeadCommit: spec.HeadCommit,
PreparedAt: preparedAt,
}); err != nil && s.logger != nil {
s.logger.Warn("failed to persist workspace state", "vm_id", vm.ID, "error", err)
}
return model.WorkspacePrepareResult{
VMID: vm.ID,
SourcePath: spec.SourcePath,
@ -258,6 +270,6 @@ func (s *WorkspaceService) prepareVMWorkspaceGuestIO(ctx context.Context, vm mod
CurrentBranch: spec.CurrentBranch,
BranchName: spec.BranchName,
BaseCommit: spec.BaseCommit,
PreparedAt: model.Now(),
PreparedAt: preparedAt,
}, nil
}

View file

@ -619,7 +619,7 @@ func TestMiseTrustScriptShape(t *testing.T) {
got := miseTrustScript("/root/repo")
for _, want := range []string{
"command -v mise",
"mise trust --quiet --all '/root/repo'",
"cd '/root/repo' && mise trust --quiet --all",
"|| true",
} {
if !strings.Contains(got, want) {