256 lines
6.3 KiB
Go
256 lines
6.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
func gitRun(repoPath string, timeout time.Duration, args ...string) (string, error) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
|
defer cancel()
|
|
full := append([]string{"-C", repoPath}, args...)
|
|
cmd := exec.CommandContext(ctx, "git", full...)
|
|
cmd.Env = append(os.Environ(),
|
|
"GIT_TERMINAL_PROMPT=0",
|
|
"GIT_ASKPASS=echo",
|
|
"GCM_INTERACTIVE=never",
|
|
"GIT_SSH_COMMAND=ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new",
|
|
)
|
|
cmd.WaitDelay = 10 * time.Second
|
|
var outBuf, errBuf bytes.Buffer
|
|
cmd.Stdout = &outBuf
|
|
cmd.Stderr = &errBuf
|
|
err := cmd.Run()
|
|
out := strings.TrimRight(outBuf.String(), "\r\n\t ")
|
|
if err != nil {
|
|
if ctx.Err() == context.DeadlineExceeded {
|
|
return out, fmt.Errorf("命令超时: git %s", strings.Join(args, " "))
|
|
}
|
|
msg := strings.TrimSpace(errBuf.String())
|
|
if msg == "" {
|
|
msg = out
|
|
}
|
|
if msg == "" {
|
|
msg = err.Error()
|
|
}
|
|
return out, errors.New(msg)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func isRepo(path string) bool {
|
|
if path == "" {
|
|
return false
|
|
}
|
|
_, err := os.Stat(filepath.Join(path, ".git"))
|
|
return err == nil
|
|
}
|
|
|
|
func ensureRepo(path, branch string) error {
|
|
if path == "" {
|
|
return fmt.Errorf("仓库路径为空")
|
|
}
|
|
path = normalizeWinPath(path)
|
|
if err := os.MkdirAll(path, 0o755); err != nil {
|
|
return err
|
|
}
|
|
if isRepo(path) {
|
|
return nil
|
|
}
|
|
if branch == "" {
|
|
branch = "main"
|
|
}
|
|
if _, err := gitRun(path, 30*time.Second, "init", "-b", branch); err != nil {
|
|
if _, e2 := gitRun(path, 30*time.Second, "init"); e2 != nil {
|
|
return e2
|
|
}
|
|
_, _ = gitRun(path, 15*time.Second, "checkout", "-b", branch)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func currentBranch(path string) (string, error) {
|
|
return gitRun(path, 15*time.Second, "rev-parse", "--abbrev-ref", "HEAD")
|
|
}
|
|
|
|
func aheadBehind(path, remote, branch string) (int, int, error) {
|
|
out, err := gitRun(path, 30*time.Second, "rev-list", "--left-right", "--count", "HEAD..."+remote+"/"+branch)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
parts := strings.Fields(out)
|
|
if len(parts) != 2 {
|
|
return 0, 0, fmt.Errorf("无法解析 rev-list 输出: %q", out)
|
|
}
|
|
a, _ := strconv.Atoi(parts[0])
|
|
b, _ := strconv.Atoi(parts[1])
|
|
return a, b, nil
|
|
}
|
|
|
|
func remoteRefExists(path, remote, branch string) bool {
|
|
_, err := gitRun(path, 15*time.Second, "rev-parse", "--verify", "-q", remote+"/"+branch)
|
|
return err == nil
|
|
}
|
|
|
|
func branchExists(path, branch string) bool {
|
|
_, err := gitRun(path, 15*time.Second, "show-ref", "--verify", "-q", "refs/heads/"+branch)
|
|
return err == nil
|
|
}
|
|
|
|
func remoteTip(path, remote, branch string) (sha string, exists bool) {
|
|
if remoteRefExists(path, remote, branch) {
|
|
out, err := gitRun(path, 15*time.Second, "rev-parse", remote+"/"+branch)
|
|
if err == nil {
|
|
return strings.TrimSpace(out), true
|
|
}
|
|
}
|
|
out, err := gitRun(path, 30*time.Second, "ls-remote", remote, "refs/heads/"+branch)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
fields := strings.Fields(strings.TrimSpace(out))
|
|
if len(fields) < 2 {
|
|
return "", false
|
|
}
|
|
return fields[0], true
|
|
}
|
|
|
|
func aheadBehindSha(path, tip string) (int, int, error) {
|
|
out, err := gitRun(path, 30*time.Second, "rev-list", "--left-right", "--count", "HEAD..."+tip)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
parts := strings.Fields(out)
|
|
if len(parts) != 2 {
|
|
return 0, 0, fmt.Errorf("无法解析 rev-list 输出: %q", out)
|
|
}
|
|
a, _ := strconv.Atoi(parts[0])
|
|
b, _ := strconv.Atoi(parts[1])
|
|
return a, b, nil
|
|
}
|
|
|
|
func dirtyCount(path string) int {
|
|
out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
n := 0
|
|
for _, line := range strings.Split(out, "\n") {
|
|
if strings.TrimSpace(line) != "" {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
var conflictCodes = map[string]bool{
|
|
"DD": true, "AU": true, "UD": true, "UA": true, "DU": true, "AA": true, "UU": true,
|
|
}
|
|
|
|
func conflictFiles(path string) ([]string, error) {
|
|
out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var files []string
|
|
seen := map[string]bool{}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
if len(line) < 4 {
|
|
continue
|
|
}
|
|
if !conflictCodes[line[:2]] {
|
|
continue
|
|
}
|
|
p := strings.TrimSpace(line[3:])
|
|
if i := strings.Index(p, "->"); i >= 0 {
|
|
p = strings.TrimSpace(p[i+2:])
|
|
}
|
|
if p != "" && !seen[p] {
|
|
seen[p] = true
|
|
files = append(files, p)
|
|
}
|
|
}
|
|
return files, nil
|
|
}
|
|
|
|
func abortMerge(path string) (string, error) {
|
|
if out, err := gitRun(path, 30*time.Second, "rebase", "--abort"); err == nil {
|
|
return out, nil
|
|
}
|
|
return gitRun(path, 30*time.Second, "merge", "--abort")
|
|
}
|
|
|
|
func resolveDone(path string, rebase bool) error {
|
|
if _, err := gitRun(path, 30*time.Second, "add", "-A"); err != nil {
|
|
return err
|
|
}
|
|
if rebase {
|
|
_, err := gitRun(path, 120*time.Second, "-c", "core.editor=true", "rebase", "--continue")
|
|
return err
|
|
}
|
|
if _, err := gitRun(path, 15*time.Second, "rev-parse", "-q", "--verify", "MERGE_HEAD"); err == nil {
|
|
_, err := gitRun(path, 120*time.Second, "commit", "--no-edit")
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func changedFiles(path string) map[string]string {
|
|
out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
|
|
m := map[string]string{}
|
|
if err != nil {
|
|
return m
|
|
}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
if len(line) < 4 {
|
|
continue
|
|
}
|
|
code := line[:2]
|
|
p := strings.TrimSpace(line[3:])
|
|
if i := strings.Index(p, "->"); i >= 0 {
|
|
p = strings.TrimSpace(p[i+2:])
|
|
}
|
|
p = strings.Trim(p, `"`)
|
|
if p == "" {
|
|
continue
|
|
}
|
|
switch {
|
|
case code == "??":
|
|
m[p] = "?"
|
|
case code[0] == 'A' || code[0] == 'C':
|
|
m[p] = "A"
|
|
case code[0] == 'D' || code[1] == 'D':
|
|
m[p] = "D"
|
|
default:
|
|
m[p] = "M" // 含 UU/AU 冲突状态,统一显示为已修改
|
|
}
|
|
}
|
|
for p, st := range m {
|
|
if st == "?" && strings.HasSuffix(p, "/") {
|
|
expandUntracked(path, p, m)
|
|
}
|
|
}
|
|
return m
|
|
}
|
|
|
|
func expandUntracked(repoPath, dir string, m map[string]string) {
|
|
out, err := gitRun(repoPath, 15*time.Second, "ls-files", "--others", "--exclude-standard", "--", dir)
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, ln := range strings.Split(out, "\n") {
|
|
ln = strings.TrimSpace(ln)
|
|
if ln != "" {
|
|
m[ln] = "?"
|
|
}
|
|
}
|
|
}
|