Co-author cleanup now removes a Claude or Codex Co-authored-by line together with the non-blank lines that follow it, removes any Claude-Session trailer line, and collapses the blank lines left behind. Other Co-authored-by lines are still removed individually.
626 lines
17 KiB
Go
626 lines
17 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type App struct {
|
|
mu sync.RWMutex
|
|
repoPath string
|
|
}
|
|
|
|
func NewApp() *App {
|
|
return &App{}
|
|
}
|
|
|
|
type RepositoryState struct {
|
|
Path string `json:"path"`
|
|
Clean bool `json:"clean"`
|
|
DetachedHead bool `json:"detachedHead"`
|
|
CurrentBranch string `json:"currentBranch"`
|
|
OriginURL string `json:"originUrl"`
|
|
}
|
|
|
|
type CommitRecord struct {
|
|
Hash string `json:"hash"`
|
|
ShortHash string `json:"shortHash"`
|
|
AuthorName string `json:"authorName"`
|
|
AuthorEmail string `json:"authorEmail"`
|
|
AuthorDate string `json:"authorDate"`
|
|
CommitterName string `json:"committerName"`
|
|
CommitterEmail string `json:"committerEmail"`
|
|
CommitterDate string `json:"committerDate"`
|
|
Refs []string `json:"refs"`
|
|
Tree string `json:"tree"`
|
|
Parents []string `json:"parents"`
|
|
Subject string `json:"subject"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type ApplyRequest struct {
|
|
Commits []CommitRecord `json:"commits"`
|
|
ForcePush bool `json:"forcePush"`
|
|
PushTags bool `json:"pushTags"`
|
|
RemoveCoAuthors bool `json:"removeCoAuthors"`
|
|
}
|
|
|
|
type ApplyResult struct {
|
|
RewrittenCommits int `json:"rewrittenCommits"`
|
|
RemovedCoAuthors int `json:"removedCoAuthors"`
|
|
ForcePushed bool `json:"forcePushed"`
|
|
BackupReference string `json:"backupReference"`
|
|
Warnings []string `json:"warnings"`
|
|
}
|
|
|
|
func (a *App) SetRepository(path string) (RepositoryState, error) {
|
|
normalized, err := normalizePath(path)
|
|
if err != nil {
|
|
return RepositoryState{}, err
|
|
}
|
|
if err := validateRepository(normalized); err != nil {
|
|
return RepositoryState{}, err
|
|
}
|
|
|
|
a.mu.Lock()
|
|
a.repoPath = normalized
|
|
a.mu.Unlock()
|
|
|
|
return a.inspectRepository(normalized)
|
|
}
|
|
|
|
func (a *App) GetRepositoryState() (RepositoryState, error) {
|
|
repo, ok := a.currentRepository()
|
|
if !ok {
|
|
return RepositoryState{}, nil
|
|
}
|
|
return a.inspectRepository(repo)
|
|
}
|
|
|
|
func (a *App) LoadHistory() ([]CommitRecord, error) {
|
|
repo, ok := a.currentRepository()
|
|
if !ok {
|
|
return nil, errors.New("no repository selected")
|
|
}
|
|
|
|
output, err := runGit(repo,
|
|
"log",
|
|
"--all",
|
|
"--reverse",
|
|
"--date=iso-strict",
|
|
"--pretty=format:%H%x1f%h%x1f%an%x1f%ae%x1f%aI%x1f%cn%x1f%ce%x1f%cI%x1f%D%x1f%T%x1f%P%x1f%B%x1e",
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
records := strings.Split(output, "\x1e")
|
|
commits := make([]CommitRecord, 0, len(records))
|
|
|
|
for _, raw := range records {
|
|
if strings.TrimSpace(raw) == "" {
|
|
continue
|
|
}
|
|
|
|
parts := strings.SplitN(raw, "\x1f", 12)
|
|
if len(parts) != 12 {
|
|
return nil, fmt.Errorf("could not parse commit record: %q", raw)
|
|
}
|
|
|
|
message := normalizeMessage(parts[11])
|
|
commits = append(commits, CommitRecord{
|
|
Hash: parts[0],
|
|
ShortHash: parts[1],
|
|
AuthorName: parts[2],
|
|
AuthorEmail: parts[3],
|
|
AuthorDate: parts[4],
|
|
CommitterName: parts[5],
|
|
CommitterEmail: parts[6],
|
|
CommitterDate: parts[7],
|
|
Refs: splitAndTrim(parts[8], ","),
|
|
Tree: parts[9],
|
|
Parents: splitAndTrim(parts[10], " "),
|
|
Subject: firstLine(message),
|
|
Message: message,
|
|
})
|
|
}
|
|
|
|
return commits, nil
|
|
}
|
|
|
|
func (a *App) ApplyChanges(req ApplyRequest) (ApplyResult, error) {
|
|
repo, ok := a.currentRepository()
|
|
if !ok {
|
|
return ApplyResult{}, errors.New("no repository selected")
|
|
}
|
|
if len(req.Commits) == 0 {
|
|
return ApplyResult{}, errors.New("nothing to apply: no commit payload supplied")
|
|
}
|
|
|
|
dirty, err := hasUncommittedChanges(repo)
|
|
if err != nil {
|
|
return ApplyResult{}, err
|
|
}
|
|
if dirty {
|
|
return ApplyResult{}, errors.New("repository has uncommitted changes; commit or stash before rewriting history")
|
|
}
|
|
|
|
currentHistory, err := a.LoadHistory()
|
|
if err != nil {
|
|
return ApplyResult{}, err
|
|
}
|
|
|
|
incomingByHash := make(map[string]CommitRecord, len(req.Commits))
|
|
for _, incoming := range req.Commits {
|
|
incomingByHash[incoming.Hash] = incoming
|
|
}
|
|
|
|
updatedByHash := make(map[string]CommitRecord)
|
|
messageOverride := make(map[string]bool)
|
|
removedCoAuthors := 0
|
|
|
|
for _, current := range currentHistory {
|
|
incoming, exists := incomingByHash[current.Hash]
|
|
if !exists {
|
|
incoming = current
|
|
}
|
|
|
|
normalized, err := normalizeIncomingCommit(incoming, current)
|
|
if err != nil {
|
|
return ApplyResult{}, fmt.Errorf("commit %s is invalid: %w", incoming.ShortHash, err)
|
|
}
|
|
|
|
if req.RemoveCoAuthors {
|
|
var removed int
|
|
normalized.Message, removed = removeCoAuthorLines(normalized.Message)
|
|
normalized.Subject = firstLine(normalized.Message)
|
|
removedCoAuthors += removed
|
|
}
|
|
|
|
if editableFieldsChanged(current, normalized) {
|
|
updatedByHash[incoming.Hash] = normalized
|
|
messageOverride[incoming.Hash] = normalizeMessage(current.Message) != normalizeMessage(normalized.Message)
|
|
}
|
|
}
|
|
|
|
result := ApplyResult{
|
|
RewrittenCommits: len(updatedByHash),
|
|
RemovedCoAuthors: removedCoAuthors,
|
|
Warnings: []string{},
|
|
}
|
|
|
|
if len(updatedByHash) > 0 {
|
|
headBefore, _ := runGit(repo, "rev-parse", "--verify", "HEAD")
|
|
backupTag := fmt.Sprintf("nexus-backup-%d", time.Now().UTC().Unix())
|
|
if _, err := runGit(repo, "tag", backupTag); err != nil {
|
|
return ApplyResult{}, fmt.Errorf("failed to create backup tag: %w", err)
|
|
}
|
|
result.BackupReference = "refs/tags/" + backupTag
|
|
|
|
if err := rewriteHistory(repo, updatedByHash, messageOverride); err != nil {
|
|
return ApplyResult{}, err
|
|
}
|
|
|
|
if err := cleanupFilterBranchRefs(repo); err != nil {
|
|
result.Warnings = append(result.Warnings, err.Error())
|
|
}
|
|
|
|
headAfter, _ := runGit(repo, "rev-parse", "--verify", "HEAD")
|
|
if strings.TrimSpace(headBefore) != "" &&
|
|
strings.TrimSpace(headAfter) != "" &&
|
|
strings.TrimSpace(headBefore) == strings.TrimSpace(headAfter) {
|
|
result.Warnings = append(result.Warnings, "HEAD did not change; edited commits may be outside the current branch")
|
|
}
|
|
}
|
|
|
|
if req.ForcePush {
|
|
if err := forcePushToOrigin(repo, req.PushTags); err != nil {
|
|
return result, err
|
|
}
|
|
result.ForcePushed = true
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (a *App) inspectRepository(repo string) (RepositoryState, error) {
|
|
dirty, err := hasUncommittedChanges(repo)
|
|
if err != nil {
|
|
return RepositoryState{}, err
|
|
}
|
|
|
|
branchName, branchErr := runGit(repo, "symbolic-ref", "--quiet", "--short", "HEAD")
|
|
detachedHead := false
|
|
currentBranch := strings.TrimSpace(branchName)
|
|
if branchErr != nil {
|
|
detachedHead = true
|
|
currentBranch = ""
|
|
}
|
|
|
|
origin, err := runGit(repo, "remote", "get-url", "origin")
|
|
if err != nil {
|
|
origin = ""
|
|
}
|
|
|
|
return RepositoryState{
|
|
Path: repo,
|
|
Clean: !dirty,
|
|
DetachedHead: detachedHead,
|
|
CurrentBranch: currentBranch,
|
|
OriginURL: strings.TrimSpace(origin),
|
|
}, nil
|
|
}
|
|
|
|
func (a *App) currentRepository() (string, bool) {
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
if a.repoPath == "" {
|
|
return "", false
|
|
}
|
|
return a.repoPath, true
|
|
}
|
|
|
|
func normalizePath(path string) (string, error) {
|
|
candidate := strings.TrimSpace(path)
|
|
if candidate == "" {
|
|
return "", errors.New("repository path is required")
|
|
}
|
|
|
|
absolute, err := filepath.Abs(filepath.Clean(candidate))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return absolute, nil
|
|
}
|
|
|
|
func validateRepository(path string) error {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.IsDir() {
|
|
return errors.New("selected path is not a directory")
|
|
}
|
|
|
|
output, err := runGit(path, "rev-parse", "--is-inside-work-tree")
|
|
if err != nil {
|
|
return fmt.Errorf("selected path is not a git worktree: %w", err)
|
|
}
|
|
if strings.TrimSpace(output) != "true" {
|
|
return errors.New("selected path is not inside a git worktree")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func hasUncommittedChanges(repo string) (bool, error) {
|
|
status, err := runGit(repo, "status", "--porcelain")
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return strings.TrimSpace(status) != "", nil
|
|
}
|
|
|
|
func rewriteHistory(repo string, updatedByHash map[string]CommitRecord, messageOverride map[string]bool) error {
|
|
tempDir, err := os.MkdirTemp("", "giteditor-rewrite-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer os.RemoveAll(tempDir)
|
|
|
|
messageDir := filepath.Join(tempDir, "messages")
|
|
if err := os.MkdirAll(messageDir, 0o700); err != nil {
|
|
return err
|
|
}
|
|
|
|
keys := make([]string, 0, len(updatedByHash))
|
|
for hash := range updatedByHash {
|
|
keys = append(keys, hash)
|
|
}
|
|
sort.Strings(keys)
|
|
|
|
messageFileByHash := make(map[string]string)
|
|
for _, hash := range keys {
|
|
if !messageOverride[hash] {
|
|
continue
|
|
}
|
|
commit := updatedByHash[hash]
|
|
messagePath := filepath.Join(messageDir, hash+".txt")
|
|
if err := os.WriteFile(messagePath, []byte(commit.Message), 0o600); err != nil {
|
|
return err
|
|
}
|
|
messageFileByHash[hash] = messagePath
|
|
}
|
|
|
|
var envFilter strings.Builder
|
|
envFilter.WriteString("case \"$GIT_COMMIT\" in\n")
|
|
for _, hash := range keys {
|
|
commit := updatedByHash[hash]
|
|
fmt.Fprintf(&envFilter, "%s)\n", hash)
|
|
fmt.Fprintf(&envFilter, "export GIT_AUTHOR_NAME=%s\n", shellQuote(commit.AuthorName))
|
|
fmt.Fprintf(&envFilter, "export GIT_AUTHOR_EMAIL=%s\n", shellQuote(commit.AuthorEmail))
|
|
fmt.Fprintf(&envFilter, "export GIT_AUTHOR_DATE=%s\n", shellQuote(commit.AuthorDate))
|
|
fmt.Fprintf(&envFilter, "export GIT_COMMITTER_NAME=%s\n", shellQuote(commit.CommitterName))
|
|
fmt.Fprintf(&envFilter, "export GIT_COMMITTER_EMAIL=%s\n", shellQuote(commit.CommitterEmail))
|
|
fmt.Fprintf(&envFilter, "export GIT_COMMITTER_DATE=%s\n", shellQuote(commit.CommitterDate))
|
|
envFilter.WriteString(";;\n")
|
|
}
|
|
envFilter.WriteString("esac\n")
|
|
|
|
var msgFilter strings.Builder
|
|
msgFilter.WriteString("case \"$GIT_COMMIT\" in\n")
|
|
for _, hash := range keys {
|
|
messagePath, hasOverride := messageFileByHash[hash]
|
|
if !hasOverride {
|
|
continue
|
|
}
|
|
fmt.Fprintf(&msgFilter, "%s)\n", hash)
|
|
fmt.Fprintf(&msgFilter, "cat %s\n", shellQuote(messagePath))
|
|
msgFilter.WriteString(";;\n")
|
|
}
|
|
msgFilter.WriteString("*)\ncat\n;;\nesac\n")
|
|
|
|
envFilterPath := filepath.Join(tempDir, "env-filter.sh")
|
|
msgFilterPath := filepath.Join(tempDir, "msg-filter.sh")
|
|
|
|
if err := os.WriteFile(envFilterPath, []byte(envFilter.String()), 0o700); err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(msgFilterPath, []byte(msgFilter.String()), 0o700); err != nil {
|
|
return err
|
|
}
|
|
|
|
envFilterCmd := ". " + shellQuote(envFilterPath)
|
|
msgFilterCmd := ". " + shellQuote(msgFilterPath)
|
|
|
|
output, err := runGit(
|
|
repo,
|
|
"filter-branch",
|
|
"-f",
|
|
"--env-filter", envFilterCmd,
|
|
"--msg-filter", msgFilterCmd,
|
|
"--tag-name-filter", "cat",
|
|
"--",
|
|
"--all",
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("history rewrite failed: %w", err)
|
|
}
|
|
if rewrittenRefCount(output) == 0 {
|
|
return errors.New("git did not rewrite any refs; no effective history changes were applied")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func rewrittenRefCount(filterBranchOutput string) int {
|
|
return strings.Count(filterBranchOutput, " was rewritten")
|
|
}
|
|
|
|
func cleanupFilterBranchRefs(repo string) error {
|
|
refsRaw, err := runGit(repo, "for-each-ref", "--format=%(refname)", "refs/original/")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, ref := range splitAndTrim(refsRaw, "\n") {
|
|
if _, err := runGit(repo, "update-ref", "-d", ref); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if _, err := runGit(repo, "reflog", "expire", "--expire=now", "--all"); err != nil {
|
|
return err
|
|
}
|
|
if _, err := runGit(repo, "gc", "--prune=now"); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func forcePushToOrigin(repo string, pushTags bool) error {
|
|
if _, err := runGit(repo, "remote", "get-url", "origin"); err != nil {
|
|
return errors.New("origin remote is not configured; cannot force push")
|
|
}
|
|
|
|
if _, err := runGit(repo, "push", "--force-with-lease", "origin", "--all"); err != nil {
|
|
return err
|
|
}
|
|
if pushTags {
|
|
if _, err := runGit(repo, "push", "--force-with-lease", "origin", "--tags"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func normalizeIncomingCommit(incoming CommitRecord, current CommitRecord) (CommitRecord, error) {
|
|
incoming.AuthorName = strings.TrimSpace(incoming.AuthorName)
|
|
incoming.AuthorEmail = strings.TrimSpace(incoming.AuthorEmail)
|
|
incoming.CommitterName = strings.TrimSpace(incoming.CommitterName)
|
|
incoming.CommitterEmail = strings.TrimSpace(incoming.CommitterEmail)
|
|
incoming.Message = normalizeMessage(incoming.Message)
|
|
|
|
if incoming.AuthorName == "" || incoming.AuthorEmail == "" {
|
|
return CommitRecord{}, errors.New("author name and email are required")
|
|
}
|
|
if incoming.CommitterName == "" || incoming.CommitterEmail == "" {
|
|
return CommitRecord{}, errors.New("committer name and email are required")
|
|
}
|
|
|
|
authorDate, err := normalizeGitDate(incoming.AuthorDate)
|
|
if err != nil {
|
|
return CommitRecord{}, fmt.Errorf("invalid author date %q", incoming.AuthorDate)
|
|
}
|
|
committerDate, err := normalizeGitDate(incoming.CommitterDate)
|
|
if err != nil {
|
|
return CommitRecord{}, fmt.Errorf("invalid committer date %q", incoming.CommitterDate)
|
|
}
|
|
|
|
incoming.AuthorDate = authorDate
|
|
incoming.CommitterDate = committerDate
|
|
incoming.Hash = current.Hash
|
|
incoming.ShortHash = current.ShortHash
|
|
incoming.Refs = current.Refs
|
|
incoming.Tree = current.Tree
|
|
incoming.Parents = current.Parents
|
|
incoming.Subject = firstLine(incoming.Message)
|
|
|
|
return incoming, nil
|
|
}
|
|
|
|
func normalizeGitDate(input string) (string, error) {
|
|
parsed, err := parseGitDate(input)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return formatGitDate(parsed), nil
|
|
}
|
|
|
|
func parseGitDate(input string) (time.Time, error) {
|
|
date := strings.TrimSpace(input)
|
|
if date == "" {
|
|
return time.Time{}, errors.New("date cannot be empty")
|
|
}
|
|
|
|
layouts := []string{
|
|
time.RFC3339Nano,
|
|
time.RFC3339,
|
|
"2006-01-02 15:04:05 -0700",
|
|
time.RFC1123Z,
|
|
time.RFC822Z,
|
|
"2006-01-02T15:04:05",
|
|
"2006-01-02T15:04",
|
|
"2006-01-02 15:04:05",
|
|
"2006-01-02 15:04",
|
|
}
|
|
|
|
for _, layout := range layouts {
|
|
var (
|
|
t time.Time
|
|
err error
|
|
)
|
|
|
|
if strings.Contains(layout, "Z07") || strings.Contains(layout, "-0700") || layout == time.RFC1123Z || layout == time.RFC822Z {
|
|
t, err = time.Parse(layout, date)
|
|
} else {
|
|
t, err = time.ParseInLocation(layout, date, time.Local)
|
|
}
|
|
if err == nil {
|
|
return t, nil
|
|
}
|
|
}
|
|
|
|
return time.Time{}, errors.New("date must be ISO 8601, a standard Git date, or YYYY-MM-DD HH:MM[:SS]")
|
|
}
|
|
|
|
func formatGitDate(value time.Time) string {
|
|
return value.Format("2006-01-02T15:04:05-07:00")
|
|
}
|
|
|
|
var coAuthorLinePattern = regexp.MustCompile(`(?i)^\s*co-authored(?:-|\s+)by\s*:`)
|
|
var aiAttributionPattern = regexp.MustCompile(`(?i)^\s*co-authored(?:-|\s+)by\s*:.*\b(claude|codex)\b`)
|
|
|
|
// removeCoAuthorLines drops Co-authored-by trailer lines and Claude-Session:
|
|
// lines. A Claude or Codex co-author line also takes the contiguous non-blank
|
|
// lines that follow it (its attribution block). Returns the number of lines removed.
|
|
func removeCoAuthorLines(message string) (string, int) {
|
|
lines := strings.Split(strings.ReplaceAll(message, "\r\n", "\n"), "\n")
|
|
kept := make([]string, 0, len(lines))
|
|
removed := 0
|
|
inBlock := false
|
|
for _, line := range lines {
|
|
switch {
|
|
case aiAttributionPattern.MatchString(line):
|
|
inBlock = true
|
|
case inBlock && strings.TrimSpace(line) != "":
|
|
case coAuthorLinePattern.MatchString(line), strings.Contains(strings.ToLower(line), "claude-session:"):
|
|
inBlock = false
|
|
default:
|
|
inBlock = false
|
|
kept = append(kept, line)
|
|
continue
|
|
}
|
|
removed++
|
|
}
|
|
return normalizeMessage(blankRunPattern.ReplaceAllString(strings.Join(kept, "\n"), "\n\n")), removed
|
|
}
|
|
|
|
var blankRunPattern = regexp.MustCompile(`\n(?:[ \t]*\n){2,}`)
|
|
|
|
func editableFieldsChanged(current CommitRecord, incoming CommitRecord) bool {
|
|
return current.AuthorName != incoming.AuthorName ||
|
|
current.AuthorEmail != incoming.AuthorEmail ||
|
|
!gitDatesEqual(current.AuthorDate, incoming.AuthorDate) ||
|
|
current.CommitterName != incoming.CommitterName ||
|
|
current.CommitterEmail != incoming.CommitterEmail ||
|
|
!gitDatesEqual(current.CommitterDate, incoming.CommitterDate) ||
|
|
normalizeMessage(current.Message) != normalizeMessage(incoming.Message)
|
|
}
|
|
|
|
func gitDatesEqual(left string, right string) bool {
|
|
leftDate, leftErr := normalizeGitDate(left)
|
|
rightDate, rightErr := normalizeGitDate(right)
|
|
if leftErr != nil || rightErr != nil {
|
|
return strings.TrimSpace(left) == strings.TrimSpace(right)
|
|
}
|
|
return leftDate == rightDate
|
|
}
|
|
|
|
func firstLine(message string) string {
|
|
parts := strings.SplitN(message, "\n", 2)
|
|
return strings.TrimSpace(parts[0])
|
|
}
|
|
|
|
func normalizeMessage(message string) string {
|
|
message = strings.ReplaceAll(message, "\r\n", "\n")
|
|
return strings.TrimRight(message, "\n")
|
|
}
|
|
|
|
func splitAndTrim(value string, sep string) []string {
|
|
raw := strings.Split(value, sep)
|
|
result := make([]string, 0, len(raw))
|
|
for _, candidate := range raw {
|
|
candidate = strings.TrimSpace(candidate)
|
|
if candidate == "" {
|
|
continue
|
|
}
|
|
result = append(result, candidate)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func shellQuote(value string) string {
|
|
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
|
|
}
|
|
|
|
func runGit(repo string, args ...string) (string, error) {
|
|
cmd := exec.Command("git", append([]string{"-C", repo}, args...)...)
|
|
cmd.Env = append(os.Environ(), "FILTER_BRANCH_SQUELCH_WARNING=1")
|
|
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
detail := strings.TrimSpace(stderr.String())
|
|
if detail == "" {
|
|
detail = err.Error()
|
|
}
|
|
return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), detail)
|
|
}
|
|
|
|
return stdout.String(), nil
|
|
}
|