Sync Git identity into guest VMs

Populate guest /root/.gitconfig from host git config --global during work-disk preparation so plain VM shells can commit.

Resolve user.name and user.email from the source repo for vm run and write them only into the imported checkout, preserving repo-specific identity overrides.

Update mounted guest .gitconfig through a host temp file plus sudo install instead of direct git config --file writes, since the mounted root-owned work disk blocks Git lockfile creation.

Validated with GOCACHE=/tmp/banger-gocache go test ./..., make build, and a live alpine vm create smoke check for guest git config.
This commit is contained in:
Thales Maciel 2026-03-22 19:30:36 -03:00
parent f798e1db33
commit 42b4a18c63
No known key found for this signature in database
GPG key ID: 33112E6833C34679
5 changed files with 308 additions and 0 deletions

View file

@ -111,6 +111,8 @@ type vmRunRepoSpec struct {
CurrentBranch string
BranchName string
BaseCommit string
GitUserName string
GitUserEmail string
OverlayPaths []string
}
@ -1444,6 +1446,15 @@ func inspectVMRunRepo(ctx context.Context, rawPath, branchName, fromRef string)
}
}
gitUserName, err := gitResolvedConfigValue(ctx, repoRoot, "user.name")
if err != nil {
return vmRunRepoSpec{}, fmt.Errorf("resolve git user.name for %s: %w", repoRoot, err)
}
gitUserEmail, err := gitResolvedConfigValue(ctx, repoRoot, "user.email")
if err != nil {
return vmRunRepoSpec{}, fmt.Errorf("resolve git user.email for %s: %w", repoRoot, err)
}
overlayPaths, err := listVMRunOverlayPaths(ctx, repoRoot)
if err != nil {
return vmRunRepoSpec{}, err
@ -1457,6 +1468,8 @@ func inspectVMRunRepo(ctx context.Context, rawPath, branchName, fromRef string)
CurrentBranch: currentBranch,
BranchName: branchName,
BaseCommit: baseCommit,
GitUserName: gitUserName,
GitUserEmail: gitUserEmail,
OverlayPaths: overlayPaths,
}, nil
}
@ -1552,6 +1565,10 @@ func gitTrimmedOutput(ctx context.Context, dir string, args ...string) (string,
return strings.TrimSpace(string(output)), nil
}
func gitResolvedConfigValue(ctx context.Context, dir, key string) (string, error) {
return gitTrimmedOutput(ctx, dir, "config", "--default", "", "--get", key)
}
func parseNullSeparatedOutput(output []byte) []string {
chunks := bytes.Split(output, []byte{0})
values := make([]string, 0, len(chunks))
@ -1658,6 +1675,10 @@ func vmRunCloneScript(spec vmRunRepoSpec) string {
}
script.WriteString("find \"$DIR\" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +\n")
script.WriteString("git config --global --add safe.directory \"$DIR\"\n")
if strings.TrimSpace(spec.GitUserName) != "" && strings.TrimSpace(spec.GitUserEmail) != "" {
fmt.Fprintf(&script, "git -C \"$DIR\" config user.name %s\n", shellQuote(spec.GitUserName))
fmt.Fprintf(&script, "git -C \"$DIR\" config user.email %s\n", shellQuote(spec.GitUserEmail))
}
return script.String()
}

View file

@ -918,6 +918,10 @@ func TestInspectVMRunRepoUsesRepoRootAndOverlayPaths(t *testing.T) {
}
repoRoot := t.TempDir()
globalConfigPath := filepath.Join(t.TempDir(), "global.gitconfig")
t.Setenv("GIT_CONFIG_GLOBAL", globalConfigPath)
testRunGit(t, repoRoot, "config", "--global", "user.email", "global@example.com")
testRunGit(t, repoRoot, "config", "--global", "user.name", "Global User")
testRunGit(t, repoRoot, "init")
testRunGit(t, repoRoot, "config", "user.email", "test@example.com")
testRunGit(t, repoRoot, "config", "user.name", "Banger Test")
@ -968,6 +972,12 @@ func TestInspectVMRunRepoUsesRepoRootAndOverlayPaths(t *testing.T) {
if spec.BaseCommit != spec.HeadCommit {
t.Fatalf("BaseCommit = %q, want head %q", spec.BaseCommit, spec.HeadCommit)
}
if spec.GitUserName != "Banger Test" {
t.Fatalf("GitUserName = %q, want Banger Test", spec.GitUserName)
}
if spec.GitUserEmail != "test@example.com" {
t.Fatalf("GitUserEmail = %q, want test@example.com", spec.GitUserEmail)
}
wantOverlay := []string{".gitignore", "dir/keep.txt", "tracked.txt", "untracked.txt"}
if !reflect.DeepEqual(spec.OverlayPaths, wantOverlay) {
t.Fatalf("OverlayPaths = %v, want %v", spec.OverlayPaths, wantOverlay)
@ -1100,6 +1110,8 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
CurrentBranch: "main",
BranchName: "feature",
BaseCommit: "cafebabe",
GitUserName: "Repo User",
GitUserEmail: "repo@example.com",
OverlayPaths: []string{"tracked.txt", "nested/keep.txt"},
}
err := runVMRun(
@ -1149,6 +1161,12 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
if !strings.Contains(fakeClient.script, `git config --global --add safe.directory "$DIR"`) {
t.Fatalf("script = %q, want guest safe.directory config", fakeClient.script)
}
if !strings.Contains(fakeClient.script, `git -C "$DIR" config user.name 'Repo User'`) {
t.Fatalf("script = %q, want guest repo user.name config", fakeClient.script)
}
if !strings.Contains(fakeClient.script, `git -C "$DIR" config user.email 'repo@example.com'`) {
t.Fatalf("script = %q, want guest repo user.email config", fakeClient.script)
}
if fakeClient.streamSourceDir != repoRoot {
t.Fatalf("streamSourceDir = %q, want %q", fakeClient.streamSourceDir, repoRoot)
}
@ -1167,6 +1185,19 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
}
}
func TestVMRunCloneScriptSkipsRepoGitIdentityWhenIncomplete(t *testing.T) {
script := vmRunCloneScript(vmRunRepoSpec{
RepoName: "repo",
HeadCommit: "deadbeef",
CurrentBranch: "main",
GitUserName: "Repo User",
})
if strings.Contains(script, `git -C "$DIR" config user.name`) || strings.Contains(script, `git -C "$DIR" config user.email`) {
t.Fatalf("script = %q, want no repo-local git identity commands", script)
}
}
func TestNewBangerdCommandRejectsArgs(t *testing.T) {
cmd := NewBangerdCommand()
cmd.SetArgs([]string{"extra"})

View file

@ -207,6 +207,9 @@ func (workDiskCapability) PrepareHost(ctx context.Context, d *Daemon, vm *model.
if err := d.ensureAuthorizedKeyOnWorkDisk(ctx, vm, image, prep); err != nil {
return err
}
if err := d.ensureGitIdentityOnWorkDisk(ctx, vm); err != nil {
return err
}
return d.ensureOpencodeAuthOnWorkDisk(ctx, vm)
}

View file

@ -32,11 +32,18 @@ var (
)
const (
workDiskGitConfigRelativePath = ".gitconfig"
workDiskOpencodeAuthDirRelativePath = ".local/share/opencode"
workDiskOpencodeAuthRelativePath = workDiskOpencodeAuthDirRelativePath + "/auth.json"
hostGlobalGitIdentitySource = "git config --global"
hostOpencodeAuthDefaultDisplayPath = "~/" + workDiskOpencodeAuthRelativePath
)
type gitIdentity struct {
Name string
Email string
}
func (d *Daemon) CreateVM(ctx context.Context, params api.VMCreateParams) (vm model.VMRecord, err error) {
d.mu.Lock()
defer d.mu.Unlock()
@ -933,6 +940,32 @@ func (d *Daemon) ensureAuthorizedKeyOnWorkDisk(ctx context.Context, vm *model.VM
return nil
}
func (d *Daemon) ensureGitIdentityOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
runner := d.runner
if runner == nil {
runner = system.NewRunner()
}
identity, err := resolveHostGlobalGitIdentity(ctx, runner)
if err != nil {
d.warnGitIdentitySyncSkipped(*vm, hostGlobalGitIdentitySource, err)
return nil
}
vmCreateStage(ctx, "prepare_work_disk", "syncing git identity")
workMount, cleanupWork, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false)
if err != nil {
return err
}
defer cleanupWork()
if err := d.flattenNestedWorkHome(ctx, workMount); err != nil {
return err
}
return writeGitIdentity(ctx, runner, filepath.Join(workMount, workDiskGitConfigRelativePath), identity)
}
func (d *Daemon) ensureOpencodeAuthOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
hostAuthPath, err := resolveHostOpencodeAuthPath()
if err != nil {
@ -990,6 +1023,69 @@ func resolveHostOpencodeAuthPath() (string, error) {
return filepath.Join(home, workDiskOpencodeAuthRelativePath), nil
}
func resolveHostGlobalGitIdentity(ctx context.Context, runner system.CommandRunner) (gitIdentity, error) {
name, err := gitConfigValue(ctx, runner, nil, "user.name")
if err != nil {
return gitIdentity{}, err
}
if name == "" {
return gitIdentity{}, errors.New("host git user.name is empty")
}
email, err := gitConfigValue(ctx, runner, nil, "user.email")
if err != nil {
return gitIdentity{}, err
}
if email == "" {
return gitIdentity{}, errors.New("host git user.email is empty")
}
return gitIdentity{Name: name, Email: email}, nil
}
func gitConfigValue(ctx context.Context, runner system.CommandRunner, extraArgs []string, key string) (string, error) {
args := []string{"config"}
args = append(args, extraArgs...)
args = append(args, "--default", "", "--get", key)
out, err := runner.Run(ctx, "git", args...)
if err != nil {
return "", err
}
return strings.TrimSpace(string(out)), nil
}
func writeGitIdentity(ctx context.Context, runner system.CommandRunner, gitConfigPath string, identity gitIdentity) error {
existing, err := runner.RunSudo(ctx, "cat", gitConfigPath)
if err != nil {
existing = nil
}
tmpFile, err := os.CreateTemp("", "banger-gitconfig-*")
if err != nil {
return err
}
tmpPath := tmpFile.Name()
if _, err := tmpFile.Write(existing); err != nil {
_ = tmpFile.Close()
_ = os.Remove(tmpPath)
return err
}
if err := tmpFile.Close(); err != nil {
_ = os.Remove(tmpPath)
return err
}
defer os.Remove(tmpPath)
if _, err := runner.Run(ctx, "git", "config", "--file", tmpPath, "user.name", identity.Name); err != nil {
return err
}
if _, err := runner.Run(ctx, "git", "config", "--file", tmpPath, "user.email", identity.Email); err != nil {
return err
}
_, err = runner.RunSudo(ctx, "install", "-m", "644", tmpPath, gitConfigPath)
return err
}
func (d *Daemon) warnOpencodeAuthSyncSkipped(vm model.VMRecord, hostPath string, err error) {
if d.logger == nil || err == nil {
return
@ -997,6 +1093,13 @@ func (d *Daemon) warnOpencodeAuthSyncSkipped(vm model.VMRecord, hostPath string,
d.logger.Warn("guest opencode auth sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...)
}
func (d *Daemon) warnGitIdentitySyncSkipped(vm model.VMRecord, source string, err error) {
if d.logger == nil || err == nil {
return
}
d.logger.Warn("guest git identity sync skipped", append(vmLogAttrs(vm), "source", source, "error", err.Error())...)
}
func mergeAuthorizedKey(existing, managed []byte) []byte {
managedLine := strings.TrimSpace(string(managed))
if managedLine == "" {

View file

@ -808,6 +808,121 @@ func TestEnsureAuthorizedKeyOnWorkDiskRepairsNestedRootLayout(t *testing.T) {
}
}
func TestEnsureGitIdentityOnWorkDiskCopiesHostGlobalIdentity(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
hostConfigPath := filepath.Join(t.TempDir(), "host.gitconfig")
t.Setenv("GIT_CONFIG_GLOBAL", hostConfigPath)
testSetGitConfig(t, "user.name", "Banger Host")
testSetGitConfig(t, "user.email", "host@example.com")
workDiskDir := t.TempDir()
d := &Daemon{runner: &filesystemRunner{t: t}}
vm := testVM("git-identity", "image-git-identity", "172.16.0.67")
vm.Runtime.WorkDiskPath = workDiskDir
if err := d.ensureGitIdentityOnWorkDisk(context.Background(), &vm); err != nil {
t.Fatalf("ensureGitIdentityOnWorkDisk: %v", err)
}
guestConfigPath := filepath.Join(workDiskDir, workDiskGitConfigRelativePath)
if got := testGitConfigValue(t, guestConfigPath, "user.name"); got != "Banger Host" {
t.Fatalf("guest user.name = %q, want Banger Host", got)
}
if got := testGitConfigValue(t, guestConfigPath, "user.email"); got != "host@example.com" {
t.Fatalf("guest user.email = %q, want host@example.com", got)
}
}
func TestEnsureGitIdentityOnWorkDiskPreservesExistingGuestConfig(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
hostConfigPath := filepath.Join(t.TempDir(), "host.gitconfig")
t.Setenv("GIT_CONFIG_GLOBAL", hostConfigPath)
testSetGitConfig(t, "user.name", "Fresh Name")
testSetGitConfig(t, "user.email", "fresh@example.com")
workDiskDir := t.TempDir()
guestConfigPath := filepath.Join(workDiskDir, workDiskGitConfigRelativePath)
if err := os.WriteFile(guestConfigPath, []byte("[safe]\n\tdirectory = /root/repo\n[user]\n\tname = stale\n"), 0o644); err != nil {
t.Fatalf("WriteFile(guest .gitconfig): %v", err)
}
d := &Daemon{runner: &filesystemRunner{t: t}}
vm := testVM("git-identity-preserve", "image-git-identity", "172.16.0.68")
vm.Runtime.WorkDiskPath = workDiskDir
if err := d.ensureGitIdentityOnWorkDisk(context.Background(), &vm); err != nil {
t.Fatalf("ensureGitIdentityOnWorkDisk: %v", err)
}
if got := testGitConfigValue(t, guestConfigPath, "user.name"); got != "Fresh Name" {
t.Fatalf("guest user.name = %q, want Fresh Name", got)
}
if got := testGitConfigValue(t, guestConfigPath, "user.email"); got != "fresh@example.com" {
t.Fatalf("guest user.email = %q, want fresh@example.com", got)
}
if got := testGitConfigValue(t, guestConfigPath, "safe.directory"); got != "/root/repo" {
t.Fatalf("guest safe.directory = %q, want /root/repo", got)
}
}
func TestEnsureGitIdentityOnWorkDiskWarnsAndSkipsWhenHostIdentityIncomplete(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
hostConfigPath := filepath.Join(t.TempDir(), "host.gitconfig")
t.Setenv("GIT_CONFIG_GLOBAL", hostConfigPath)
testSetGitConfig(t, "user.name", "Only Name")
workDiskDir := t.TempDir()
guestConfigPath := filepath.Join(workDiskDir, workDiskGitConfigRelativePath)
original := []byte("[user]\n\temail = keep@example.com\n")
if err := os.WriteFile(guestConfigPath, original, 0o644); err != nil {
t.Fatalf("WriteFile(guest .gitconfig): %v", err)
}
var buf bytes.Buffer
logger, _, err := newDaemonLogger(&buf, "info")
if err != nil {
t.Fatalf("newDaemonLogger: %v", err)
}
d := &Daemon{
runner: &filesystemRunner{t: t},
logger: logger,
}
vm := testVM("git-identity-missing", "image-git-identity", "172.16.0.69")
vm.Runtime.WorkDiskPath = workDiskDir
if err := d.ensureGitIdentityOnWorkDisk(context.Background(), &vm); err != nil {
t.Fatalf("ensureGitIdentityOnWorkDisk: %v", err)
}
got, err := os.ReadFile(guestConfigPath)
if err != nil {
t.Fatalf("ReadFile(guest .gitconfig): %v", err)
}
if string(got) != string(original) {
t.Fatalf("guest .gitconfig = %q, want preserved %q", string(got), string(original))
}
entries := parseLogEntries(t, buf.Bytes())
if !hasLogEntry(entries, map[string]string{
"msg": "guest git identity sync skipped",
"vm_name": vm.Name,
"source": hostGlobalGitIdentitySource,
"error": "host git user.email is empty",
}) {
t.Fatalf("expected warn log, got %v", entries)
}
}
func TestEnsureOpencodeAuthOnWorkDiskCopiesHostAuth(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
@ -1599,6 +1714,27 @@ func startHTTPSServerOnTCP4(t *testing.T, handler http.Handler) *net.TCPAddr {
return listener.Addr().(*net.TCPAddr)
}
func testSetGitConfig(t *testing.T, key, value string) {
t.Helper()
cmd := exec.Command("git", "config", "--global", key, value)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git config --global %s: %v: %s", key, err, strings.TrimSpace(string(output)))
}
}
func testGitConfigValue(t *testing.T, configPath, key string) string {
t.Helper()
cmd := exec.Command("git", "config", "--file", configPath, "--get", key)
output, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git config --file %s --get %s: %v: %s", configPath, key, err, strings.TrimSpace(string(output)))
}
return strings.TrimSpace(string(output))
}
type processKillingRunner struct {
*scriptedRunner
proc *exec.Cmd
@ -1610,6 +1746,20 @@ type filesystemRunner struct {
func (r *filesystemRunner) Run(ctx context.Context, name string, args ...string) ([]byte, error) {
r.t.Helper()
if name == "git" {
cmd := exec.CommandContext(ctx, name, args...)
var stdout bytes.Buffer
var stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if stderr.Len() > 0 {
return stdout.Bytes(), fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String()))
}
return stdout.Bytes(), err
}
return stdout.Bytes(), nil
}
return nil, fmt.Errorf("unexpected Run call: %s %v", name, args)
}