package main import ( "bytes" "fmt" "os" "os/exec" "strings" ) // gitRun executes a git command in repoPath, returning a descriptive error // that includes the combined stdout+stderr on failure. func gitRun(cfg *Config, args ...string) error { env, err := gitCommandEnv(cfg) if err != nil { return err } cmd := exec.Command("git", append([]string{"-C", cfg.RepoPath}, args...)...) cmd.Env = env out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, bytes.TrimSpace(out)) } return nil } // gitOutput executes a git command and returns its stdout as a string. func gitOutput(cfg *Config, args ...string) (string, error) { env, err := gitCommandEnv(cfg) if err != nil { return "", err } cmd := exec.Command("git", append([]string{"-C", cfg.RepoPath}, args...)...) cmd.Env = env out, err := cmd.Output() if err != nil { var stderr []byte if ee, ok := err.(*exec.ExitError); ok { stderr = ee.Stderr } return "", fmt.Errorf("git %s: %w\n%s", strings.Join(args, " "), err, bytes.TrimSpace(stderr)) } return string(out), nil } // gitCommandEnv builds the environment for git subprocesses. // If GIT_AUTH_TOKEN is provided, non-interactive HTTPS auth is enabled via askpass. func gitCommandEnv(cfg *Config) ([]string, error) { env := os.Environ() if cfg.GitAuthToken == "" { return env, nil } exePath, err := os.Executable() if err != nil { return nil, fmt.Errorf("resolve executable path for GIT_ASKPASS: %w", err) } env = append(env, "GIT_TERMINAL_PROMPT=0", "GIT_ASKPASS_REQUIRE=force", "GIT_ASKPASS="+exePath, "TDW_GIT_ASKPASS=1", "GIT_AUTH_USERNAME="+cfg.GitAuthUsername, "GIT_AUTH_TOKEN="+cfg.GitAuthToken, ) return env, nil } // GitPull fetches from the remote and rebases the local branch. // Using --autostash ensures any uncommitted changes are preserved across the rebase // (should not normally happen but guards against manual edits). func GitPull(cfg *Config) error { if err := gitRun(cfg, "fetch", "--prune", cfg.RepoRemote); err != nil { return fmt.Errorf("git fetch: %w", err) } if err := gitRun(cfg, "pull", "--rebase", "--autostash", cfg.RepoRemote, cfg.RepoBranch, ); err != nil { return fmt.Errorf("git pull --rebase: %w", err) } return nil } // GitStatusChanged reports true when the working tree has uncommitted changes. func GitStatusChanged(cfg *Config) (bool, error) { out, err := gitOutput(cfg, "status", "--porcelain") if err != nil { return false, err } return strings.TrimSpace(out) != "", nil } // GitCommitAndPush stages the dynamic directory, commits with the given message, // and pushes to the configured remote branch. // Author identity is passed via git -c flags to avoid requiring global git config. func GitCommitAndPush(cfg *Config, message string) error { if err := gitRun(cfg, "add", cfg.DynamicDir); err != nil { return fmt.Errorf("git add: %w", err) } if err := gitRun(cfg, "-c", "user.name="+cfg.AuthorName, "-c", "user.email="+cfg.AuthorEmail, "commit", "-m", message, ); err != nil { return fmt.Errorf("git commit: %w", err) } if err := gitRun(cfg, "push", cfg.RepoRemote, cfg.RepoBranch); err != nil { return fmt.Errorf("git push: %w", err) } return nil }