diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 2fda7c4..07d71a0 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -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() } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 0664607..aaf6d56 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -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"}) diff --git a/internal/daemon/capabilities.go b/internal/daemon/capabilities.go index 78031e3..779078d 100644 --- a/internal/daemon/capabilities.go +++ b/internal/daemon/capabilities.go @@ -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) } diff --git a/internal/daemon/vm.go b/internal/daemon/vm.go index b4c90e3..450bd4e 100644 --- a/internal/daemon/vm.go +++ b/internal/daemon/vm.go @@ -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 == "" { diff --git a/internal/daemon/vm_test.go b/internal/daemon/vm_test.go index a3ddc76..d487125 100644 --- a/internal/daemon/vm_test.go +++ b/internal/daemon/vm_test.go @@ -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) }