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

@ -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)
}