git-sync-web 自动提交 2026-09-09 16:33:32

This commit is contained in:
tamakyi
2026-09-09 16:33:32 +08:00
parent 5cdffecb44
commit 80bd1136f2
13 changed files with 3876 additions and 3887 deletions
+214 -237
View File
@@ -1,237 +1,214 @@
package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
)
// gitRun 在指定仓库目录执行 git 命令,返回合并后的输出。
// 非交互环境:GIT_TERMINAL_PROMPT=0 只能挡住终端提示,挡不住 Windows 凭据管理器(GCM)弹窗,
// 因此额外禁用 GCM 交互、askpass 和 ssh 交互,避免后台同步被凭据弹窗永久卡死。
// WaitDelay 保证超时 Kill 后即使 git 的子进程仍占用管道,Wait() 也能强制返回(Windows 上
// CommandContext 只杀主进程,不杀 git-remote-https / 凭据 helper 等子进程)。
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
// stdout 与 stderr 分开收集:成功时只返回干净的 stdout 供解析,
// 避免 git 的环境告警(如 autocrlf、进度信息)污染解析结果;
// 失败时优先用 stderr 作为错误信息,保证诊断信息不丢失。
var outBuf, errBuf bytes.Buffer
cmd.Stdout = &outBuf
cmd.Stderr = &errBuf
err := cmd.Run()
// 只裁剪尾部空白,不要用 TrimSpace 裁整体:
// git 输出首行的前导空格具有语义(status --porcelain 的暂存位、diff 的上下文行),
// 整体 TrimSpace 会把它吃掉,导致按固定偏移解析路径时错位。
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 currentBranch(path string) (string, error) {
return gitRun(path, 15*time.Second, "rev-parse", "--abbrev-ref", "HEAD")
}
// aheadBehind 返回 (领先, 落后) 远程跟踪分支的提交数(需先 fetch 才准确)
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
}
// remoteRefExists 检查远程跟踪引用是否存在(远程仓库为空或分支名不一致时为 false)
func remoteRefExists(path, remote, branch string) bool {
_, err := gitRun(path, 15*time.Second, "rev-parse", "--verify", "-q", remote+"/"+branch)
return err == nil
}
// branchExists 检查本地分支是否存在
func branchExists(path, branch string) bool {
_, err := gitRun(path, 15*time.Second, "show-ref", "--verify", "-q", "refs/heads/"+branch)
return err == nil
}
// remoteTip 返回远程分支当前指向的 SHA。
// 优先读本地远程跟踪引用(refs/remotes/<remote>/<branch>,快);
// 若本地跟踪引用不可用(个别环境下 ref 存储读写异常),回退用 ls-remote 直查远程。
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
}
// aheadBehindSha 基于远程分支的具体 SHA 计算 (领先, 落后),不依赖本地跟踪引用
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")
}
// resolveDone 在用户网页上标记"已解决"后调用:暂存全部 → 继续 merge/rebase 流程
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
}
// changedFiles 解析 git status --porcelain,返回 路径 -> 状态标记(M/A/D/?),
// 供网页端文件树展示每个文件的改动状态。
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:])
}
// git 对含特殊字符的路径会加引号
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 冲突状态,统一显示为已修改
}
}
return m
}
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 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 冲突状态,统一显示为已修改
}
}
return m
}