package main import ( "encoding/csv" "encoding/json" "fmt" "log" "os" "path/filepath" "regexp" "runtime/debug" "sort" "strconv" "strings" "sync" "time" ) // ---------- 事件总线(SSE 广播) ---------- 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 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"` 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"` } type repoState struct { mu sync.Mutex cfg *RepoConfig running bool syncing bool status string // idle | ok | error | conflict ahead int behind int dirty int conflicts []string lastError string lastSync *SyncResult stopCh chan struct{} } 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() { 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, } 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 把配置中的远程同步到仓库(缺失则添加,URL 变化则更新) 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:捕获后台 goroutine 的 panic,避免整个进程被杀死。 // 此前「立即同步后程序自动退出」的直接原因就是后台 goroutine panic 无 recover。 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) } 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 } // 分支对齐:本地当前分支与配置不一致时,自动切换/创建(对齐原 bash 脚本行为) // 否则 push 会因 src refspec 不存在而失败 if cur, cerr := currentBranch(cfg.RepoPath); cerr == nil && cur != cfg.Branch { tip, remoteHasBranch := remoteTip(cfg.RepoPath, remote, cfg.Branch) switch { case branchExists(cfg.RepoPath, cfg.Branch): if _, err := gitRun(cfg.RepoPath, 30*time.Second, "checkout", cfg.Branch); err != nil { e.logf(s, "warn", "切换到配置分支 %s 失败: %v(继续在 %s 上同步)", cfg.Branch, err, cur) } else { e.logf(s, "info", "本地分支与配置不一致(%s → %s),已自动切换", cur, cfg.Branch) } case remoteHasBranch: if _, err := gitRun(cfg.RepoPath, 30*time.Second, "checkout", "-B", cfg.Branch, tip); err != nil { e.logf(s, "warn", "基于远程 %s 创建分支失败: %v(继续在 %s 上同步)", cfg.Branch, err, cur) } else { _, _ = 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]) } default: if _, err := gitRun(cfg.RepoPath, 30*time.Second, "checkout", "-B", cfg.Branch); err != nil { e.logf(s, "warn", "创建分支 %s 失败: %v(继续在 %s 上同步)", cfg.Branch, err, cur) } else { 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) } } 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 ahead > 0 && (cfg.Mode == "push" || cfg.Mode == "both") { if !e.pushAll(s, cfg, ahead) { // 推送失败,本轮终止,避免继续拉取加剧分叉 e.setFail(s, "推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)") return } } 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) 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.mu.Unlock() e.logf(s, "info", "=== 同步完成(本地领先 %d,落后 %d)===", ahead, behind) e.pushStatus(s) } func (e *Engine) pushAll(s *repoState, cfg *RepoConfig, n int) bool { allOK := true 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 } else { e.logf(s, "info", "推送至 %s 成功(%d 个提交)", r.Name, n) } } if allOK { e.stat(s, "push", "success", n) } else { e.stat(s, "push", "failed", n) } return allOK } 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 := 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 } // ---------- 手动操作 ---------- 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 } if !e.pushAll(s, cfg, ahead) { e.setFail(s, "推送失败") return fmt.Errorf("推送失败") } 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.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 }