Files
github-auto-sync-golang/engine.go
T

1133 lines
30 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package main
import (
"encoding/csv"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"regexp"
"runtime/debug"
"sort"
"strconv"
"strings"
"sync"
"time"
)
// ---------- 事件总线 ----------
type EventBus struct {
mu sync.Mutex
next int
subs map[int]chan []byte
}
func NewEventBus() *EventBus { return &EventBus{subs: map[int]chan []byte{}} }
func (b *EventBus) Subscribe() (int, chan []byte) {
b.mu.Lock()
defer b.mu.Unlock()
b.next++
ch := make(chan []byte, 128)
b.subs[b.next] = ch
return b.next, ch
}
func (b *EventBus) Unsubscribe(id int) {
b.mu.Lock()
defer b.mu.Unlock()
if ch, ok := b.subs[id]; ok {
delete(b.subs, id)
close(ch)
}
}
func (b *EventBus) Publish(data []byte) {
b.mu.Lock()
defer b.mu.Unlock()
for _, ch := range b.subs {
select {
case ch <- data:
default:
}
}
}
// ---------- 状态 ----------
type SyncResult struct {
Time string `json:"time"`
Result string `json:"result"` // ok | error | conflict | push_conflict
Detail string `json:"detail"`
}
type RepoSnapshot struct {
Name string `json:"name"`
RepoPath string `json:"repo_path"`
Branch string `json:"branch"`
Mode string `json:"mode"`
Interval int `json:"interval"`
AuthMethod string `json:"auth_method"`
RetryCount int `json:"retry_count"`
RetryInterval int `json:"retry_interval"`
ConflictStrategy string `json:"conflict_strategy"`
LogFile string `json:"log_file"`
AutoCommit bool `json:"auto_commit"`
Cron string `json:"cron"`
Remotes []Remote `json:"remotes"`
Running bool `json:"running"`
Status string `json:"status"` // idle | ok | error | conflict | push_conflict
Ahead int `json:"ahead"`
Behind int `json:"behind"`
Dirty int `json:"dirty"`
Conflicts []string `json:"conflicts"`
LastError string `json:"last_error"`
LastSync *SyncResult `json:"last_sync"`
// 新增:推送冲突信息
PushConflict bool `json:"push_conflict"`
PushConflictRemote string `json:"push_conflict_remote"`
PushConflictBranch string `json:"push_conflict_branch"`
}
type repoState struct {
mu sync.Mutex
cfg *RepoConfig
running bool
syncing bool
status string // idle | ok | error | conflict | push_conflict
ahead int
behind int
dirty int
conflicts []string
lastError string
lastSync *SyncResult
stopCh chan struct{}
// 推送冲突信息
pushConflict bool
pushConflictRemote string
pushConflictBranch string
}
type Engine struct {
baseDir string
cfgDir string
logDir string
statsDir string
mu sync.Mutex
states map[string]*repoState
bus *EventBus
}
func NewEngine(base string) (*Engine, error) {
e := &Engine{
baseDir: base,
cfgDir: filepath.Join(base, "configs"),
logDir: filepath.Join(base, "logs"),
statsDir: filepath.Join(base, "stats"),
states: map[string]*repoState{},
bus: NewEventBus(),
}
for _, d := range []string{e.cfgDir, e.logDir, e.statsDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
return nil, err
}
}
return e, nil
}
func (e *Engine) Bus() *EventBus { return e.bus }
func (e *Engine) configNames() []string {
entries, err := os.ReadDir(e.cfgDir)
if err != nil {
return nil
}
var names []string
for _, en := range entries {
if !en.IsDir() && strings.HasSuffix(en.Name(), ".conf") {
names = append(names, strings.TrimSuffix(en.Name(), ".conf"))
}
}
sort.Strings(names)
return names
}
func (e *Engine) get(name string) *repoState {
e.mu.Lock()
defer e.mu.Unlock()
return e.states[name]
}
func (e *Engine) sortedNames() []string {
e.mu.Lock()
defer e.mu.Unlock()
names := make([]string, 0, len(e.states))
for n := range e.states {
names = append(names, n)
}
sort.Strings(names)
return names
}
func (e *Engine) LoadAll() {
e.mu.Lock()
defer e.mu.Unlock()
for _, name := range e.configNames() {
if _, ok := e.states[name]; ok {
continue
}
cfg, err := loadRepoConfig(e.cfgDir, name)
if err != nil {
log.Printf("加载配置 %s 失败: %v", name, err)
continue
}
e.states[name] = &repoState{cfg: cfg, status: "idle"}
}
}
func (e *Engine) publish(v any) {
data, err := json.Marshal(v)
if err != nil {
return
}
e.bus.Publish(data)
}
func (e *Engine) logf(s *repoState, level, format string, args ...any) {
msg := fmt.Sprintf(format, args...)
ts := time.Now().Format("2006-01-02 15:04:05")
logPath := s.cfg.LogFile
if logPath == "" {
logPath = filepath.Join(e.logDir, s.cfg.Name+".log")
}
if logPath != "" {
_ = os.MkdirAll(filepath.Dir(logPath), 0o755)
if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
fmt.Fprintln(f, "["+ts+"] "+msg)
_ = f.Close()
}
}
e.publish(map[string]any{"type": "log", "repo": s.cfg.Name, "level": level, "msg": msg, "time": ts})
}
func (e *Engine) stat(s *repoState, action, result string, commits int) {
path := filepath.Join(e.statsDir, s.cfg.Name+".csv")
if _, err := os.Stat(path); os.IsNotExist(err) {
_ = os.WriteFile(path, []byte("timestamp,action,result,commits\n"), 0o644)
}
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return
}
defer f.Close()
fmt.Fprintf(f, "%s,%s,%s,%d\n", time.Now().Format("2006-01-02 15:04:05"), action, result, commits)
}
func firstRemoteName(cfg *RepoConfig) string {
if len(cfg.Remotes) > 0 {
return cfg.Remotes[0].Name
}
return ""
}
func (s *repoState) snapshot(live bool) RepoSnapshot {
s.mu.Lock()
cfg := s.cfg
snap := RepoSnapshot{
Name: cfg.Name,
RepoPath: cfg.RepoPath,
Branch: cfg.Branch,
Mode: cfg.Mode,
Interval: cfg.Interval,
AuthMethod: cfg.AuthMethod,
RetryCount: cfg.RetryCount,
RetryInterval: cfg.RetryInterval,
ConflictStrategy: cfg.ConflictStrategy,
LogFile: cfg.LogFile,
AutoCommit: cfg.AutoCommit,
Cron: cfg.Cron,
Remotes: cfg.Remotes,
Running: s.running,
Status: s.status,
Ahead: s.ahead,
Behind: s.behind,
Dirty: s.dirty,
Conflicts: append([]string(nil), s.conflicts...),
LastError: s.lastError,
LastSync: s.lastSync,
PushConflict: s.pushConflict,
PushConflictRemote: s.pushConflictRemote,
PushConflictBranch: s.pushConflictBranch,
}
s.mu.Unlock()
if live {
if b, err := currentBranch(cfg.RepoPath); err == nil {
snap.Branch = b
}
snap.Dirty = dirtyCount(cfg.RepoPath)
if remote := firstRemoteName(cfg); remote != "" {
if a, be, err := aheadBehind(cfg.RepoPath, remote, snap.Branch); err == nil {
snap.Ahead, snap.Behind = a, be
}
}
if files, err := conflictFiles(cfg.RepoPath); err == nil && len(files) > 0 {
snap.Conflicts = files
snap.Status = "conflict"
}
}
return snap
}
func (e *Engine) Snapshots(live bool) []RepoSnapshot {
var out []RepoSnapshot
for _, name := range e.sortedNames() {
if s := e.get(name); s != nil {
out = append(out, s.snapshot(live))
}
}
return out
}
func (e *Engine) pushStatus(s *repoState) {
e.publish(map[string]any{"type": "status", "repo": s.cfg.Name, "data": s.snapshot(false)})
}
// ---------- 启动 / 停止 ----------
func (e *Engine) Start(name string) error {
s := e.get(name)
if s == nil {
return fmt.Errorf("配置不存在: %s", name)
}
s.mu.Lock()
if s.running {
s.mu.Unlock()
return nil
}
if !isRepo(s.cfg.RepoPath) {
s.mu.Unlock()
return fmt.Errorf("仓库路径无效: %s", s.cfg.RepoPath)
}
s.stopCh = make(chan struct{})
s.running = true
s.status = "ok"
ch := s.stopCh
s.mu.Unlock()
go e.loop(s, ch)
e.logf(s, "info", "监控已启动")
e.pushStatus(s)
return nil
}
func (e *Engine) loop(s *repoState, stopCh chan struct{}) {
s.mu.Lock()
interval := s.cfg.Interval
cronStr := s.cfg.Cron
s.mu.Unlock()
if interval < 5 {
interval = 5
}
var spec *cronSpec
if cronStr != "" {
sp, err := parseCron(cronStr)
if err != nil {
e.logf(s, "error", "cron 表达式无效: %v(回退为固定间隔 %d 秒)", err, interval)
} else {
spec = sp
e.logf(s, "info", "按 cron 计划运行: %s", cronStr)
}
}
if spec == nil {
e.syncOnce(s, "启动检查")
}
for {
var wait time.Duration
trigger := "定时检查"
if spec != nil {
wait = time.Until(cronNext(spec, time.Now()))
trigger = "cron 触发"
} else {
wait = time.Duration(interval) * time.Second
}
if wait < time.Second {
wait = time.Second
}
timer := time.NewTimer(wait)
select {
case <-stopCh:
timer.Stop()
e.logf(s, "info", "监控已停止")
e.pushStatus(s)
return
case <-timer.C:
e.syncOnce(s, trigger)
}
}
}
func (e *Engine) Stop(name string) error {
s := e.get(name)
if s == nil {
return fmt.Errorf("配置不存在: %s", name)
}
s.mu.Lock()
defer s.mu.Unlock()
if !s.running {
return nil
}
s.running = false
s.status = "idle"
close(s.stopCh)
return nil
}
func (e *Engine) StartAll() {
for _, name := range e.sortedNames() {
if err := e.Start(name); err != nil {
log.Printf("自动启动 %s 失败: %v", name, err)
}
}
}
func (e *Engine) SyncNow(name string) error {
s := e.get(name)
if s == nil {
return fmt.Errorf("配置不存在: %s", name)
}
s.mu.Lock()
defer s.mu.Unlock()
if !s.running {
return fmt.Errorf("监控未启动,请先启动")
}
if s.syncing {
return fmt.Errorf("正在同步中,请稍候")
}
go e.safeSync(s, "手动触发")
return nil
}
// ---------- 同步核心 ----------
func (e *Engine) withRetry(s *repoState, label string, fn func() (string, error)) (string, error) {
maxAttempts, sleep, retryCount := 1, 0, 0
s.mu.Lock()
cfg := s.cfg
s.mu.Unlock()
if cfg.AuthMethod == "https" && cfg.RetryCount > 0 {
maxAttempts = cfg.RetryCount + 1
sleep = cfg.RetryInterval
retryCount = cfg.RetryCount
}
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
if attempt > 1 {
e.logf(s, "warn", "%s 失败,%d 秒后重试(第 %d/%d 次)", label, sleep, attempt-1, retryCount)
time.Sleep(time.Duration(sleep) * time.Second)
}
out, err := fn()
if err == nil {
return out, nil
}
lastErr = err
}
return "", lastErr
}
func (e *Engine) setFail(s *repoState, msg string) {
now := time.Now().Format("2006-01-02 15:04:05")
s.mu.Lock()
s.status = "error"
s.lastError = msg
s.lastSync = &SyncResult{Time: now, Result: "error", Detail: msg}
s.mu.Unlock()
e.logf(s, "error", "%s", msg)
e.pushStatus(s)
}
func (e *Engine) setOK(s *repoState, detail string) {
now := time.Now().Format("2006-01-02 15:04:05")
s.mu.Lock()
s.status = "ok"
s.lastError = ""
s.lastSync = &SyncResult{Time: now, Result: "ok", Detail: detail}
s.mu.Unlock()
e.pushStatus(s)
}
// ensureRemotes 把配置中的远程同步到仓库
func (e *Engine) ensureRemotes(s *repoState, cfg *RepoConfig) {
for _, r := range cfg.Remotes {
out, err := gitRun(cfg.RepoPath, 15*time.Second, "remote", "get-url", r.Name)
if err != nil {
if _, aerr := gitRun(cfg.RepoPath, 15*time.Second, "remote", "add", r.Name, r.URL); aerr != nil {
e.logf(s, "warn", "添加远程 %s 失败: %v", r.Name, aerr)
} else {
e.logf(s, "info", "已添加远程 %s → %s", r.Name, r.URL)
}
} else if strings.TrimSpace(out) != r.URL {
if _, uerr := gitRun(cfg.RepoPath, 15*time.Second, "remote", "set-url", r.Name, r.URL); uerr != nil {
e.logf(s, "warn", "更新远程 %s URL 失败: %v", r.Name, uerr)
} else {
e.logf(s, "info", "已更新远程 %s URL → %s", r.Name, r.URL)
}
}
}
}
func (e *Engine) refreshCounts(s *repoState, cfg *RepoConfig, remote string) {
ahead, behind, err := aheadBehind(cfg.RepoPath, remote, cfg.Branch)
dirty := dirtyCount(cfg.RepoPath)
s.mu.Lock()
if err == nil {
s.ahead, s.behind = ahead, behind
}
s.dirty = dirty
s.mu.Unlock()
e.pushStatus(s)
}
// safeSync 包装 syncOnce:捕获 panic
func (e *Engine) safeSync(s *repoState, trigger string) {
defer func() {
if r := recover(); r != nil {
stack := string(debug.Stack())
if len(stack) > 4096 {
stack = stack[:4096] + "\n…(堆栈已截断)"
}
log.Printf("[panic] syncOnce(%s): %v\n%s", s.cfg.Name, r, stack)
e.logf(s, "error", "同步发生内部异常(已恢复,进程继续运行): %v", r)
}
}()
e.syncOnce(s, trigger)
}
// syncOnce 核心同步逻辑(已调整顺序,并增加 push_conflict 处理)
func (e *Engine) syncOnce(s *repoState, trigger string) {
s.mu.Lock()
if s.syncing {
s.mu.Unlock()
return
}
s.syncing = true
cfg := s.cfg
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.syncing = false
s.mu.Unlock()
}()
if !isRepo(cfg.RepoPath) {
e.setFail(s, "仓库路径无效: "+cfg.RepoPath)
return
}
e.ensureRemotes(s, cfg)
remote := firstRemoteName(cfg)
if remote == "" {
e.setFail(s, "未配置远程仓库")
return
}
e.logf(s, "info", "=== 开始同步(%s,模式 %s)===", trigger, cfg.Mode)
if _, err := e.withRetry(s, "fetch", func() (string, error) {
return gitRun(cfg.RepoPath, 90*time.Second, "fetch", remote)
}); err != nil {
e.setFail(s, "fetch 失败: "+err.Error())
return
}
// 分支对齐(安全切换)
if cur, cerr := currentBranch(cfg.RepoPath); cerr == nil && cur != cfg.Branch {
tip, remoteHasBranch := remoteTip(cfg.RepoPath, remote, cfg.Branch)
localExists := branchExists(cfg.RepoPath, cfg.Branch)
if localExists {
if _, err := gitRun(cfg.RepoPath, 30*time.Second, "checkout", cfg.Branch); err != nil {
e.setFail(s, fmt.Sprintf("切换到配置分支 %s 失败,请先提交或暂存改动: %v", cfg.Branch, err))
return
}
e.logf(s, "info", "本地分支与配置不一致(%s → %s),已自动切换", cur, cfg.Branch)
} else {
if remoteHasBranch {
if _, err := gitRun(cfg.RepoPath, 30*time.Second, "checkout", "-b", cfg.Branch, tip); err != nil {
e.setFail(s, fmt.Sprintf("基于远程 %s/%s 创建分支失败: %v", remote, cfg.Branch, err))
return
}
_, _ = gitRun(cfg.RepoPath, 15*time.Second, "branch", "--set-upstream-to", remote+"/"+cfg.Branch, cfg.Branch)
e.logf(s, "info", "本地无分支 %s,已基于远程 %s(%s)创建并跟踪", cfg.Branch, cfg.Branch, tip[:8])
} else {
if _, err := gitRun(cfg.RepoPath, 30*time.Second, "checkout", "-b", cfg.Branch); err != nil {
e.setFail(s, fmt.Sprintf("创建分支 %s 失败: %v", cfg.Branch, err))
return
}
e.logf(s, "info", "本地与远程均无分支 %s,已基于当前 HEAD(%s)创建", cfg.Branch, cur)
}
}
}
// 自动提交
dirty := dirtyCount(cfg.RepoPath)
if dirty > 0 {
if cfg.AutoCommit {
e.logf(s, "info", "检测到 %d 个未提交改动,执行自动提交", dirty)
if _, cerr := gitRun(cfg.RepoPath, 60*time.Second, "add", "-A"); cerr != nil {
e.setFail(s, "自动提交(git add)失败: "+cerr.Error())
return
}
cmsg := "git-sync-web 自动提交 " + time.Now().Format("2006-01-02 15:04:05")
if _, cerr := gitRun(cfg.RepoPath, 120*time.Second, "commit", "-m", cmsg); cerr != nil {
e.setFail(s, "自动提交失败: "+cerr.Error())
return
}
e.logf(s, "info", "自动提交完成,将随本轮同步推送")
dirty = 0
} else {
e.logf(s, "warn", "工作区有 %d 个未提交改动(未开启自动提交)", dirty)
}
}
// 计算 ahead/behind
var ahead, behind int
var err error
tip, remoteHasBranch := remoteTip(cfg.RepoPath, remote, cfg.Branch)
if remoteHasBranch {
ahead, behind, err = aheadBehindSha(cfg.RepoPath, tip)
if err != nil {
e.setFail(s, "获取领先/落后信息失败: "+err.Error())
return
}
} else {
if out, cerr := gitRun(cfg.RepoPath, 15*time.Second, "rev-list", "--count", "HEAD"); cerr == nil {
ahead, _ = strconv.Atoi(strings.TrimSpace(out))
}
e.logf(s, "warn", "远程分支 %s/%s 不存在,将推送本地 %d 个提交以建立远程分支", remote, cfg.Branch, ahead)
}
s.mu.Lock()
s.ahead, s.behind, s.dirty = ahead, behind, dirty
s.mu.Unlock()
e.pushStatus(s)
// ---- 先拉取 ----
if behind > 0 && (cfg.Mode == "pull" || cfg.Mode == "both") {
if !e.pullRemote(s, cfg, remote, behind) {
return
}
ahead, behind, _ = aheadBehind(cfg.RepoPath, remote, cfg.Branch)
s.mu.Lock()
s.ahead, s.behind = ahead, behind
s.mu.Unlock()
e.pushStatus(s)
}
// ---- 再推送 ----
if ahead > 0 && (cfg.Mode == "push" || cfg.Mode == "both") {
// pushAll 可能返回错误,如果是 non-fast-forward,设置 push_conflict 状态
pushOK, pushErr := e.pushAll(s, cfg, ahead)
if !pushOK {
// 检查是否是 non-fast-forward 错误
if pushErr != nil && strings.Contains(strings.ToLower(pushErr.Error()), "non-fast-forward") {
// 记录冲突远程(取第一个冲突的远程)
// 在 pushAll 内部已经设置了 pushConflict 状态,这里只需退出
e.logf(s, "warn", "推送被拒绝(non-fast-forward),等待用户决策")
return
}
// 其他错误
e.setFail(s, "推送失败: "+pushErr.Error())
return
}
}
// 最终刷新
ahead, behind, _ = aheadBehind(cfg.RepoPath, remote, cfg.Branch)
now := time.Now().Format("2006-01-02 15:04:05")
s.mu.Lock()
s.ahead, s.behind = ahead, behind
s.status = "ok"
s.lastError = ""
s.lastSync = &SyncResult{Time: now, Result: "ok", Detail: "同步完成"}
// 清除可能遗留的推送冲突状态
s.pushConflict = false
s.pushConflictRemote = ""
s.pushConflictBranch = ""
s.mu.Unlock()
e.logf(s, "info", "=== 同步完成(本地领先 %d,落后 %d)===", ahead, behind)
e.pushStatus(s)
}
// pushAll 返回 (是否全部成功, 错误信息)
// 如果遇到 non-fast-forward,会设置 pushConflict 状态并返回 false,错误包含 "non-fast-forward"
func (e *Engine) pushAll(s *repoState, cfg *RepoConfig, n int) (bool, error) {
allOK := true
var firstErr error
for _, r := range cfg.Remotes {
_, err := e.withRetry(s, "push → "+r.Name, func() (string, error) {
return gitRun(cfg.RepoPath, 300*time.Second, "push", r.Name, cfg.Branch)
})
if err != nil {
e.logf(s, "error", "推送至 %s 失败: %v", r.Name, err)
allOK = false
if firstErr == nil {
firstErr = err
}
// 检测到 non-fast-forward,记录状态并继续尝试其他远程
if strings.Contains(strings.ToLower(err.Error()), "non-fast-forward") {
s.mu.Lock()
s.status = "push_conflict"
s.pushConflict = true
s.pushConflictRemote = r.Name
s.pushConflictBranch = cfg.Branch
s.lastError = "推送被拒绝(non-fast-forward),请选择强制覆盖或放弃"
s.lastSync = &SyncResult{
Time: time.Now().Format("2006-01-02 15:04:05"),
Result: "push_conflict",
Detail: fmt.Sprintf("远程 %s 有新提交,推送被拒绝", r.Name),
}
s.mu.Unlock()
e.pushStatus(s)
}
} else {
e.logf(s, "info", "推送至 %s 成功(%d 个提交)", r.Name, n)
}
}
if allOK {
e.stat(s, "push", "success", n)
return true, nil
}
e.stat(s, "push", "failed", n)
return false, firstErr
}
// pullRemote 已使用重试
func (e *Engine) pullRemote(s *repoState, cfg *RepoConfig, remote string, n int) bool {
args := []string{"pull"}
if cfg.ConflictStrategy == "rebase" {
args = append(args, "--rebase")
}
args = append(args, remote, cfg.Branch, "--no-edit")
_, err := e.withRetry(s, "pull", func() (string, error) {
return gitRun(cfg.RepoPath, 300*time.Second, args...)
})
if err == nil {
e.stat(s, "pull", "success", n)
e.logf(s, "info", "拉取成功(%d 个提交)", n)
return true
}
files, cerr := conflictFiles(cfg.RepoPath)
if cerr == nil && len(files) > 0 {
if cfg.ConflictStrategy == "abort" {
_, _ = abortMerge(cfg.RepoPath)
e.stat(s, "pull", "failed", n)
e.setFail(s, "拉取产生冲突,已按策略(abort)自动放弃合并")
return false
}
now := time.Now().Format("2006-01-02 15:04:05")
s.mu.Lock()
s.status = "conflict"
s.conflicts = files
s.lastError = "拉取产生冲突,等待在冲突处理台解决"
s.lastSync = &SyncResult{Time: now, Result: "conflict", Detail: s.lastError}
s.mu.Unlock()
e.stat(s, "pull", "conflict", n)
e.logf(s, "error", "拉取产生冲突:%s(请在详情 → 冲突中处理)", strings.Join(files, ", "))
e.pushStatus(s)
return false
}
e.stat(s, "pull", "failed", n)
e.setFail(s, "拉取失败: "+err.Error())
return false
}
// ---------- 推送冲突处理 ----------
// ForcePush 强制推送(--force-with-lease)到冲突的远程
func (e *Engine) ForcePush(name string) error {
s := e.get(name)
if s == nil {
return fmt.Errorf("配置不存在: %s", name)
}
s.mu.Lock()
if !s.pushConflict {
s.mu.Unlock()
return fmt.Errorf("当前没有待处理的推送冲突")
}
remote := s.pushConflictRemote
branch := s.pushConflictBranch
path := s.cfg.RepoPath
s.mu.Unlock()
e.logf(s, "info", "执行强制推送至 %s %s", remote, branch)
_, err := gitRun(path, 300*time.Second, "push", "--force-with-lease", remote, branch)
if err != nil {
e.logf(s, "error", "强制推送失败: %v", err)
return err
}
e.logf(s, "info", "强制推送成功")
s.mu.Lock()
s.pushConflict = false
s.pushConflictRemote = ""
s.pushConflictBranch = ""
s.status = "ok"
s.lastError = ""
s.lastSync = &SyncResult{Time: time.Now().Format("2006-01-02 15:04:05"), Result: "ok", Detail: "强制推送完成"}
s.mu.Unlock()
e.pushStatus(s)
return nil
}
// AbortPush 放弃强制推送,清除冲突状态
func (e *Engine) AbortPush(name string) error {
s := e.get(name)
if s == nil {
return fmt.Errorf("配置不存在: %s", name)
}
s.mu.Lock()
defer s.mu.Unlock()
if !s.pushConflict {
return fmt.Errorf("当前没有待处理的推送冲突")
}
s.pushConflict = false
s.pushConflictRemote = ""
s.pushConflictBranch = ""
s.status = "ok"
s.lastError = "用户放弃强制推送"
s.lastSync = &SyncResult{Time: time.Now().Format("2006-01-02 15:04:05"), Result: "ok", Detail: "放弃强制推送,状态已清除"}
e.logf(s, "warn", "放弃强制推送")
e.pushStatus(s)
return nil
}
// ---------- 手动操作 ----------
func (e *Engine) ManualGit(name, action string) error {
s := e.get(name)
if s == nil {
return fmt.Errorf("配置不存在: %s", name)
}
if !isRepo(s.cfg.RepoPath) {
return fmt.Errorf("仓库路径无效: %s", s.cfg.RepoPath)
}
s.mu.Lock()
if s.syncing {
s.mu.Unlock()
return fmt.Errorf("正在同步中,请稍候")
}
s.syncing = true
cfg := s.cfg
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.syncing = false
s.mu.Unlock()
}()
remote := firstRemoteName(cfg)
if remote == "" {
return fmt.Errorf("未配置远程仓库")
}
e.ensureRemotes(s, cfg)
switch action {
case "fetch":
e.logf(s, "info", "手动 fetch…")
if _, err := gitRun(cfg.RepoPath, 90*time.Second, "fetch", remote); err != nil {
e.logf(s, "error", "fetch 失败: %v", err)
return err
}
e.logf(s, "info", "fetch 完成")
e.refreshCounts(s, cfg, remote)
return nil
case "push":
e.logf(s, "info", "手动 push…")
ahead, behind, _ := aheadBehind(cfg.RepoPath, remote, cfg.Branch)
s.mu.Lock()
s.behind = behind
s.mu.Unlock()
if ahead == 0 {
e.logf(s, "info", "本地无领先提交,无需推送")
e.refreshCounts(s, cfg, remote)
return nil
}
ok, err := e.pushAll(s, cfg, ahead)
if !ok {
e.logf(s, "error", "推送失败: %v", err)
return err
}
e.refreshCounts(s, cfg, remote)
e.setOK(s, "手动推送完成")
return nil
case "pull":
e.logf(s, "info", "手动 pull…")
if _, err := gitRun(cfg.RepoPath, 90*time.Second, "fetch", remote); err != nil {
e.logf(s, "error", "fetch 失败: %v", err)
return err
}
_, behind, _ := aheadBehind(cfg.RepoPath, remote, cfg.Branch)
if behind == 0 {
e.logf(s, "info", "远程无新提交,已是最新")
e.refreshCounts(s, cfg, remote)
return nil
}
if !e.pullRemote(s, cfg, remote, behind) {
return fmt.Errorf("拉取失败或存在冲突")
}
e.refreshCounts(s, cfg, remote)
e.setOK(s, "手动拉取完成")
return nil
}
return fmt.Errorf("未知操作: %s", action)
}
// ---------- 冲突处理 ----------
func (e *Engine) ConflictAbort(name string) error {
s := e.get(name)
if s == nil {
return fmt.Errorf("配置不存在: %s", name)
}
if _, err := abortMerge(s.cfg.RepoPath); err != nil {
e.logf(s, "error", "放弃合并失败: %v", err)
return err
}
s.mu.Lock()
s.conflicts = nil
s.status = "ok"
s.lastError = ""
s.mu.Unlock()
e.logf(s, "warn", "已放弃本次合并/变基,仓库回到冲突前状态")
e.pushStatus(s)
return nil
}
func (e *Engine) ConflictResolve(name string) error {
s := e.get(name)
if s == nil {
return fmt.Errorf("配置不存在: %s", name)
}
s.mu.Lock()
rebase := s.cfg.ConflictStrategy == "rebase"
path := s.cfg.RepoPath
s.mu.Unlock()
if err := resolveDone(path, rebase); err != nil {
e.logf(s, "error", "继续合并/变基失败: %v", err)
return err
}
files, _ := conflictFiles(path)
if len(files) > 0 {
s.mu.Lock()
s.conflicts = files
s.mu.Unlock()
e.logf(s, "error", "仍有未解决的冲突:%s", strings.Join(files, ", "))
e.pushStatus(s)
return fmt.Errorf("仍有未解决的冲突,请在本地标记已解决后再试")
}
s.mu.Lock()
s.conflicts = nil
s.status = "ok"
s.lastError = ""
s.mu.Unlock()
e.logf(s, "info", "冲突已全部解决,合并流程完成")
e.pushStatus(s)
return nil
}
// ---------- 配置 CRUD ----------
func (e *Engine) Create(c *RepoConfig) error {
if err := validateRepoConfig(e.cfgDir, c, true); err != nil {
return err
}
if err := saveRepoConfig(e.cfgDir, c); err != nil {
return err
}
s := &repoState{cfg: c, status: "idle"}
e.mu.Lock()
e.states[c.Name] = s
e.mu.Unlock()
e.logf(s, "info", "配置已创建(路径 %s)", c.RepoPath)
e.pushStatus(s)
return nil
}
func (e *Engine) Update(c *RepoConfig) error {
s := e.get(c.Name)
if s == nil {
return fmt.Errorf("配置不存在: %s", c.Name)
}
if err := validateRepoConfig(e.cfgDir, c, false); err != nil {
return err
}
s.mu.Lock()
wasRunning := s.running
s.mu.Unlock()
if wasRunning {
_ = e.Stop(c.Name)
}
if err := saveRepoConfig(e.cfgDir, c); err != nil {
return err
}
s.mu.Lock()
s.cfg = c
s.status = "idle"
s.conflicts = nil
s.lastError = ""
s.ahead, s.behind, s.dirty = 0, 0, 0
s.lastSync = nil
// 清除推送冲突
s.pushConflict = false
s.pushConflictRemote = ""
s.pushConflictBranch = ""
s.mu.Unlock()
e.logf(s, "info", "配置已更新")
if wasRunning {
return e.Start(c.Name)
}
e.pushStatus(s)
return nil
}
func (e *Engine) Delete(name string) error {
s := e.get(name)
if s == nil {
return fmt.Errorf("配置不存在: %s", name)
}
_ = e.Stop(name)
s.mu.Lock()
s.running = false
s.mu.Unlock()
_ = os.Remove(filepath.Join(e.cfgDir, name+".conf"))
e.mu.Lock()
delete(e.states, name)
e.mu.Unlock()
log.Printf("配置 %s 已删除", name)
return nil
}
// ---------- 日志 / 统计 / 冲突 diff ----------
type StatRow struct {
Time string `json:"time"`
Action string `json:"action"`
Result string `json:"result"`
Commits int `json:"commits"`
}
type ConflictFile struct {
File string `json:"file"`
Diff string `json:"diff"`
}
func (e *Engine) ReadLog(name string, n int) []string {
s := e.get(name)
if s == nil {
return []string{}
}
logPath := s.cfg.LogFile
if logPath == "" {
logPath = filepath.Join(e.logDir, name+".log")
}
data, err := os.ReadFile(logPath)
if err != nil {
return []string{}
}
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
if len(lines) > n {
lines = lines[len(lines)-n:]
}
return lines
}
func (e *Engine) ReadStats(name string) []StatRow {
f, err := os.Open(filepath.Join(e.statsDir, name+".csv"))
if err != nil {
return []StatRow{}
}
defer f.Close()
records, err := csv.NewReader(f).ReadAll()
if err != nil {
return []StatRow{}
}
rows := []StatRow{}
for i, rec := range records {
if i == 0 || len(rec) < 4 {
continue
}
c, _ := strconv.Atoi(rec[3])
rows = append(rows, StatRow{Time: rec[0], Action: rec[1], Result: rec[2], Commits: c})
}
return rows
}
func (e *Engine) ConflictDiffs(name string) []ConflictFile {
s := e.get(name)
if s == nil {
return []ConflictFile{}
}
s.mu.Lock()
files := append([]string(nil), s.conflicts...)
path := s.cfg.RepoPath
s.mu.Unlock()
if len(files) == 0 {
files, _ = conflictFiles(path)
}
out := []ConflictFile{}
for _, f := range files {
d, err := gitRun(path, 30*time.Second, "diff", "--", f)
if err != nil {
d = err.Error()
}
if len(d) > 65536 {
d = d[:65536] + "\n…(diff 过长已截断)"
}
out = append(out, ConflictFile{File: f, Diff: d})
}
return out
}
// ---------- 提交历史 ----------
var hashRe = regexp.MustCompile(`^[0-9a-fA-F]{6,40}$`)
type Commit struct {
Hash string `json:"hash"`
Author string `json:"author"`
Date string `json:"date"`
Subject string `json:"subject"`
}
func (e *Engine) ReadCommits(name string, limit int) []Commit {
s := e.get(name)
if s == nil {
return []Commit{}
}
if limit <= 0 || limit > 200 {
limit = 50
}
out, err := gitRun(s.cfg.RepoPath, 30*time.Second,
"log", "-n", strconv.Itoa(limit),
"--date=format:%Y-%m-%d %H:%M",
"--pretty=format:%H%x1f%an%x1f%ad%x1f%s")
if err != nil {
return []Commit{}
}
commits := []Commit{}
for _, line := range strings.Split(out, "\n") {
parts := strings.Split(line, "\x1f")
if len(parts) != 4 {
continue
}
commits = append(commits, Commit{Hash: parts[0], Author: parts[1], Date: parts[2], Subject: parts[3]})
}
return commits
}
func (e *Engine) ReadCommitDiff(name, hash string) (string, error) {
s := e.get(name)
if s == nil {
return "", fmt.Errorf("配置不存在: %s", name)
}
if !hashRe.MatchString(hash) {
return "", fmt.Errorf("无效的提交哈希")
}
out, err := gitRun(s.cfg.RepoPath, 60*time.Second, "show", "--no-color", hash)
if err != nil {
return "", err
}
if len(out) > 131072 {
out = out[:131072] + "\n…(内容过长已截断)"
}
return out, nil
}