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

1709 lines
47 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 (
"bytes"
"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"`
PushStrategy string `json:"push_strategy"`
LogFile string `json:"log_file"`
AutoCommit bool `json:"auto_commit"`
AutoSync bool `json:"auto_sync"`
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
}
}
_ = e.ensureSummariesDir()
return e, nil
}
func (e *Engine) summariesDir() string {
return filepath.Join(e.baseDir, "summaries")
}
// ensureSummariesDir 创建本地变更摘要目录(gitignored,不同步到远程)。
func (e *Engine) ensureSummariesDir() error {
dir := e.summariesDir()
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
gi := filepath.Join(dir, ".gitignore")
if _, err := os.Stat(gi); err != nil {
_ = os.WriteFile(gi, []byte("*\n"), 0o644)
}
return nil
}
func (e *Engine) summaryPath(name string) string {
return filepath.Join(e.summariesDir(), name+".md")
}
func (e *Engine) pendingPath(name string) string {
return filepath.Join(e.summariesDir(), name+".pending")
}
func (e *Engine) GetPendingSummary(name string) string {
b, err := os.ReadFile(e.pendingPath(name))
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func (e *Engine) SavePendingSummary(name, text string) error {
if err := e.ensureSummariesDir(); err != nil {
return err
}
t := strings.TrimSpace(text)
if t == "" {
return os.Remove(e.pendingPath(name))
}
return os.WriteFile(e.pendingPath(name), []byte(t+"\n"), 0o644)
}
// statusFiles 返回工作区相对改动文件列表(用于自动摘要;须在 git add 之前调用)。
func (e *Engine) statusFiles(repoPath string) []string {
out, err := gitRun(repoPath, 15*time.Second, "status", "--porcelain")
if err != nil {
return nil
}
var files []string
for _, ln := range strings.Split(strings.TrimSpace(out), "\n") {
ln = strings.TrimSpace(ln)
if ln == "" {
continue
}
p := strings.TrimSpace(ln[3:])
if i := strings.Index(p, "->"); i >= 0 {
p = strings.TrimSpace(p[i+2:])
}
p = strings.Trim(p, `"`)
if p != "" {
files = append(files, p)
}
}
return files
}
// autoSummary 在没有任何人工摘要时,依据本次改动自动生成一句话摘要(仅本地记录用)。
// files 须为提交前的改动文件列表(见 statusFiles)。
func (e *Engine) autoSummary(files []string) string {
if len(files) == 0 {
return "自动提交(无改动文件)"
}
const max = 6
shown := files
if len(shown) > max {
shown = files[:max]
}
more := ""
if len(files) > max {
more = fmt.Sprintf(" 等%d个", len(files))
}
return fmt.Sprintf("改动 %d 个文件:%s%s", len(files), strings.Join(shown, ", "), more)
}
// RecordCommit 把一次提交(commit id + 摘要)追加写入本地按项目分开的摘要文件(不上传)。
func (e *Engine) RecordCommit(name, sha, summary string) {
if err := e.ensureSummariesDir(); err != nil {
return
}
ts := time.Now().Format("2006-01-02 15:04:05")
entry := fmt.Sprintf("## %s · %s\n%s\n\n", strings.TrimSpace(sha), ts, strings.TrimSpace(summary))
f, err := os.OpenFile(e.summaryPath(name), os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return
}
defer f.Close()
_, _ = f.WriteString(entry)
}
func (e *Engine) GetSummaryLog(name string) string {
b, err := os.ReadFile(e.summaryPath(name))
if err != nil {
return ""
}
return string(b)
}
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+"] ["+level+"] "+msg)
_ = f.Close()
}
}
e.publish(map[string]any{"type": "log", "repo": s.cfg.Name, "level": level, "msg": msg, "time": ts})
}
const statMaxLines = 2000
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)
}
if rotateStatIfNeeded(path, statMaxLines) {
e.logf(s, "info", "统计文件已滚动截断至最近 %d 行", statMaxLines)
}
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 rotateStatIfNeeded(path string, maxLines int) bool {
data, err := os.ReadFile(path)
if err != nil {
return false
}
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
if len(lines) <= maxLines {
return false
}
keep := append([]string{lines[0]}, lines[len(lines)-(maxLines-1):]...)
if err := os.WriteFile(path, []byte(strings.Join(keep, "\n")+"\n"), 0o644); err != nil {
return false
}
return true
}
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,
PushStrategy: cfg.PushStrategy,
LogFile: cfg.LogFile,
AutoCommit: cfg.AutoCommit,
AutoSync: cfg.AutoSync,
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
autoSync := s.cfg.AutoSync
s.mu.Unlock()
if !autoSync {
e.logf(s, "info", "已关闭自动同步,仅响应手动同步")
e.pushStatus(s)
<-stopCh
return
}
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)
}
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)
}
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()
}()
stashed := false
defer func() {
if !stashed {
return
}
if _, perr := gitRun(cfg.RepoPath, 60*time.Second, "stash", "pop"); perr != nil {
e.logf(s, "warn", "同步前自动 stash 的恢复失败(可能与其他改动冲突),请手动执行 git stash pop: %v", perr)
} else {
e.logf(s, "info", "已恢复同步前自动 stash 的未提交改动")
}
}()
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 !cfg.AutoCommit && dirtyCount(cfg.RepoPath) > 0 {
if _, serr := gitRun(cfg.RepoPath, 60*time.Second, "stash", "push", "-u", "-m", "git-sync-web auto-stash"); serr == nil {
stashed = true
e.logf(s, "info", "已自动 stash 未提交改动(同步结束后恢复)")
} else {
e.logf(s, "warn", "自动 stash 失败,将尝试直接同步")
}
}
// 分支对齐(安全切换)
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")
pend := e.GetPendingSummary(s.cfg.Name)
if pend != "" {
cmsg = pend
}
var autoFiles []string
if pend == "" {
autoFiles = e.statusFiles(cfg.RepoPath)
}
if _, cerr := gitRun(cfg.RepoPath, 120*time.Second, "commit", "-m", cmsg); cerr != nil {
e.setFail(s, "自动提交失败: "+cerr.Error())
return
}
if sha, serr := gitRun(cfg.RepoPath, 10*time.Second, "rev-parse", "--short", "HEAD"); serr == nil {
sum := cmsg
if pend == "" {
sum = e.autoSummary(autoFiles)
}
e.RecordCommit(s.cfg.Name, sha, sum)
}
_ = os.Remove(e.pendingPath(s.cfg.Name))
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 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") {
pushOK, pushErr := e.pushAll(s, cfg, ahead)
if !pushOK {
if pushErr != nil && strings.Contains(strings.ToLower(pushErr.Error()), "non-fast-forward") {
switch cfg.PushStrategy {
case "force":
e.logf(s, "warn", "按推送策略 force 执行强制推送(--force-with-lease)")
if _, ferr := gitRun(cfg.RepoPath, 300*time.Second, "push", "--force-with-lease", remote, cfg.Branch); ferr != nil {
e.setFail(s, "强制推送失败: "+ferr.Error())
return
}
e.logf(s, "info", "强制推送成功")
e.stat(s, "push", "success", ahead)
s.mu.Lock()
s.pushConflict = false
s.lastError = ""
s.mu.Unlock()
case "rebase":
e.logf(s, "warn", "按推送策略 rebase 后重试推送")
if _, rerr := gitRun(cfg.RepoPath, 300*time.Second, "rebase", remote+"/"+cfg.Branch); rerr != nil {
e.setFail(s, "变基到远程失败(请手动解决冲突): "+rerr.Error())
return
}
if _, perr := gitRun(cfg.RepoPath, 300*time.Second, "push", remote, cfg.Branch); perr != nil {
e.setFail(s, "变基后推送失败: "+perr.Error())
return
}
e.logf(s, "info", "变基后推送成功")
e.stat(s, "push", "success", ahead)
s.mu.Lock()
s.pushConflict = false
s.lastError = ""
s.mu.Unlock()
default:
e.logf(s, "warn", "推送被拒绝(non-fast-forward),等待用户决策")
return
}
} else {
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)
}
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
}
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
}
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
}
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
}
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
}
func (e *Engine) Create(c *RepoConfig) error {
branch := c.Branch
if branch == "" {
branch = "main"
}
if err := ensureRepo(c.RepoPath, branch); err != nil {
return fmt.Errorf("初始化仓库失败: %w", err)
}
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)
}
branch := c.Branch
if branch == "" {
branch = "main"
}
if err := ensureRepo(c.RepoPath, branch); err != nil {
return fmt.Errorf("初始化仓库失败: %w", err)
}
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
}
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
}
const fileEditMaxBytes = 1 << 20 // 1MB,网页编辑单文件上限
// safeRepoPath 将仓库内相对路径解析为绝对路径,并严格校验不越界、不触及 .git
func (e *Engine) safeRepoPath(s *repoState, rel string) (string, error) {
rel = filepath.ToSlash(strings.TrimSpace(rel))
rel = strings.TrimPrefix(rel, "/")
if rel == "" || rel == "." {
return "", fmt.Errorf("无效路径")
}
for _, seg := range strings.Split(rel, "/") {
if seg == ".." {
return "", fmt.Errorf("路径不允许包含 ..")
}
}
root := filepath.Clean(s.cfg.RepoPath)
full := filepath.Clean(filepath.Join(root, filepath.FromSlash(rel)))
if full != root && !strings.HasPrefix(full, root+string(os.PathSeparator)) {
return "", fmt.Errorf("路径越界: %s", rel)
}
relClean := strings.TrimPrefix(full, root+string(os.PathSeparator))
if relClean == ".git" || strings.HasPrefix(relClean, ".git"+string(os.PathSeparator)) {
return "", fmt.Errorf("禁止访问 .git 内部")
}
return full, nil
}
type FileNode struct {
Path string `json:"path"`
Status string `json:"status"` // M 已修改 / A 新增 / D 删除 / ? 未跟踪 / 空 未改动
}
func (e *Engine) FileTree(name string) []FileNode {
s := e.get(name)
if s == nil {
return []FileNode{}
}
set := map[string]struct{}{}
collect := func(args ...string) {
if out, err := gitRun(s.cfg.RepoPath, 15*time.Second, args...); err == nil {
for _, ln := range strings.Split(strings.TrimSpace(out), "\n") {
ln = strings.TrimSpace(ln)
if ln != "" {
set[ln] = struct{}{}
}
}
}
}
collect("ls-files")
collect("ls-files", "--others", "--exclude-standard")
paths := make([]string, 0, len(set))
for p := range set {
paths = append(paths, p)
}
sort.Strings(paths)
status := changedFiles(s.cfg.RepoPath)
nodes := make([]FileNode, 0, len(paths))
for _, p := range paths {
nodes = append(nodes, FileNode{Path: p, Status: status[p]})
}
return nodes
}
type FileContent struct {
Path string `json:"path"`
Content string `json:"content"`
Binary bool `json:"binary"`
Size int `json:"size"`
Changed []int `json:"changed,omitempty"`
}
func (e *Engine) ReadFileContent(name, rel string) (*FileContent, error) {
s := e.get(name)
if s == nil {
return nil, fmt.Errorf("配置不存在: %s", name)
}
full, err := e.safeRepoPath(s, rel)
if err != nil {
return nil, err
}
info, err := os.Stat(full)
if err != nil {
return nil, err
}
if info.IsDir() {
return nil, fmt.Errorf("这是一个目录: %s", rel)
}
if info.Size() > fileEditMaxBytes {
return nil, fmt.Errorf("文件过大(>%d 字节),禁止在网页编辑", fileEditMaxBytes)
}
data, err := os.ReadFile(full)
if err != nil {
return nil, err
}
if bytes.Contains(data, []byte{0}) {
return &FileContent{Path: rel, Binary: true, Size: int(info.Size())}, nil
}
content := string(data)
changed, _ := fileChangeLines(s.cfg.RepoPath, rel, content)
return &FileContent{Path: rel, Content: content, Size: int(info.Size()), Changed: changed}, nil
}
func isUntracked(repoPath, rel string) bool {
out, err := gitRun(repoPath, 10*time.Second, "status", "--porcelain", "--", rel)
if err != nil {
return false
}
for _, line := range strings.Split(out, "\n") {
if strings.HasPrefix(strings.TrimSpace(line), "??") {
return true
}
}
return false
}
func fileChangeLines(repoPath, rel, content string) ([]int, error) {
if _, err := gitRun(repoPath, 10*time.Second, "rev-parse", "--verify", "HEAD"); err != nil {
return nil, nil
}
if isUntracked(repoPath, rel) {
return allLineNumbers(content), nil
}
out, err := gitRun(repoPath, 30*time.Second, "diff", "--no-color", "HEAD", "--", rel)
if err != nil {
return nil, nil
}
return parseDiffAddedLines(out), nil
}
func allLineNumbers(content string) []int {
n := strings.Count(content, "\n")
if content == "" {
return nil
}
n++
lines := make([]int, 0, n)
for i := 1; i <= n; i++ {
lines = append(lines, i)
}
return lines
}
func parseDiffAddedLines(diff string) []int {
set := map[int]struct{}{}
newLine := 0
for _, raw := range strings.Split(diff, "\n") {
line := raw
if strings.HasPrefix(line, "@@") {
if i := strings.Index(line, " +"); i >= 0 {
rest := line[i+2:]
if c, ok := parseHunkNewStart(rest); ok {
newLine = c
}
}
continue
}
if newLine == 0 {
continue
}
switch {
case strings.HasPrefix(line, "+"):
if strings.HasPrefix(line, "+++") {
continue
}
set[newLine] = struct{}{}
newLine++
case strings.HasPrefix(line, " "):
newLine++
case strings.HasPrefix(line, "-"):
default:
}
}
out := make([]int, 0, len(set))
for k := range set {
out = append(out, k)
}
sort.Ints(out)
return out
}
func parseHunkNewStart(rest string) (int, bool) {
end := strings.IndexAny(rest, " @")
if end >= 0 {
rest = rest[:end]
}
if i := strings.Index(rest, ","); i >= 0 {
rest = rest[:i]
}
n := strings.TrimSpace(rest)
if n == "" {
return 0, false
}
v, err := strconv.Atoi(n)
if err != nil {
return 0, false
}
return v, true
}
func (e *Engine) WriteFileContent(name, rel, content string) error {
s := e.get(name)
if s == nil {
return fmt.Errorf("配置不存在: %s", name)
}
full, err := e.safeRepoPath(s, rel)
if err != nil {
return err
}
if len(content) > fileEditMaxBytes {
return fmt.Errorf("内容过大(>%d 字节)", fileEditMaxBytes)
}
if strings.Contains(content, "\x00") {
return fmt.Errorf("内容含空字符,疑似二进制,禁止写入")
}
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
return err
}
return os.WriteFile(full, []byte(content), 0o644)
}
func (e *Engine) CommitFile(name, rel, message string) error {
s := e.get(name)
if s == nil {
return fmt.Errorf("配置不存在: %s", name)
}
if _, err := e.safeRepoPath(s, rel); err != nil {
return err
}
if strings.TrimSpace(message) == "" {
message = "网页端编辑: " + rel
}
if _, err := gitRun(s.cfg.RepoPath, 30*time.Second, "add", "--", rel); err != nil {
return fmt.Errorf("git add 失败: %w", err)
}
if _, err := gitRun(s.cfg.RepoPath, 60*time.Second, "commit", "-m", message, "--", rel); err != nil {
return fmt.Errorf("git commit 失败: %w", err)
}
if sha, serr := gitRun(s.cfg.RepoPath, 10*time.Second, "rev-parse", "--short", "HEAD"); serr == nil {
e.RecordCommit(name, sha, message)
}
e.logf(s, "info", "网页端提交 %s 成功", rel)
return nil
}
func (e *Engine) ResolveConflictFile(name, rel, content string, finish bool) error {
s := e.get(name)
if s == nil {
return fmt.Errorf("配置不存在: %s", name)
}
if err := e.WriteFileContent(name, rel, content); err != nil {
return err
}
if _, err := gitRun(s.cfg.RepoPath, 30*time.Second, "add", "--", rel); err != nil {
return fmt.Errorf("git add 失败: %w", err)
}
e.logf(s, "info", "网页端冲突文件 %s 已暂存", rel)
if !finish {
return nil
}
rest, _ := conflictFiles(s.cfg.RepoPath)
if len(rest) > 0 {
e.pushStatus(s)
return fmt.Errorf("已暂存 %s;但仍有未解决的冲突文件:%s", rel, strings.Join(rest, ", "))
}
return e.ConflictResolve(name)
}
type DiffFile struct {
Path string `json:"path"`
Add int `json:"add"`
Del int `json:"del"`
}
type DiffResult struct {
Scope string `json:"scope"`
Stat string `json:"stat"`
Text string `json:"text"`
Files []DiffFile `json:"files"`
VsRemote bool `json:"vs_remote"`
Word bool `json:"word"`
}
// rel 非空时只对比单个文件(已做路径安全校验)
// mode = "word" 时改用 --word-diff=plain,便于词级定位改动
func (e *Engine) WorkingDiff(name, scope, rel, mode string) (*DiffResult, error) {
s := e.get(name)
if s == nil {
return nil, fmt.Errorf("配置不存在: %s", name)
}
res := &DiffResult{Scope: scope}
var args []string
remote := "origin"
switch scope {
case "staged":
args = []string{"diff", "--cached", "--no-color"}
case "all":
args = []string{"diff", "HEAD", "--no-color"}
case "remote":
ahead, _, _ := aheadBehind(s.cfg.RepoPath, remote, s.cfg.Branch)
if ahead <= 0 {
res.Text = "本地没有领先远程的提交,无需对比。"
return res, nil
}
args = []string{"diff", "--no-color", remote + "/" + s.cfg.Branch, "HEAD"}
res.VsRemote = true
default: // working(未暂存)
args = []string{"diff", "--no-color"}
}
// 词级差异:--word-diff=plain 用 [-x-] / {+x+} 标记行内改动
if mode == "word" {
args = append(args, "--word-diff=plain")
res.Word = true
}
statArgs := append([]string{}, args...)
if scope == "remote" {
statArgs = []string{"diff", "--no-color", "--stat", remote + "/" + s.cfg.Branch, "HEAD"}
} else {
statArgs = append(statArgs, "--stat")
}
// 单文件过滤:追加 -- <path>,先做路径安全校验(防穿越 / .git)
if rel != "" {
if _, err := e.safeRepoPath(s, rel); err != nil {
return nil, err
}
args = append(args, "--", rel)
statArgs = append(statArgs, "--", rel)
}
if statOut, serr := gitRun(s.cfg.RepoPath, 30*time.Second, statArgs...); serr == nil {
statOut = stripWarnings(statOut)
res.Stat = statOut
res.Files = parseStatFiles(statOut)
}
if textOut, terr := gitRun(s.cfg.RepoPath, 60*time.Second, args...); terr == nil {
res.Text = stripWarnings(textOut)
} else {
res.Text = terr.Error()
}
if len(res.Text) > 262144 {
res.Text = res.Text[:262144] + "\n…(diff 过长已截断)"
}
return res, nil
}
func stripWarnings(s string) string {
lines := strings.Split(s, "\n")
out := make([]string, 0, len(lines))
for _, l := range lines {
if strings.HasPrefix(l, "warning:") {
continue
}
out = append(out, l)
}
return strings.Join(out, "\n")
}
func parseStatFiles(stat string) []DiffFile {
var files []DiffFile
for _, line := range strings.Split(stat, "\n") {
if strings.Contains(line, "files changed") || strings.Contains(line, "file changed") {
continue
}
idx := strings.Index(line, " | ")
if idx < 0 {
continue
}
path := strings.TrimSpace(line[:idx])
rest := line[idx+3:]
add, del := 0, 0
for _, r := range rest {
if r == '+' {
add++
} else if r == '-' {
del++
}
}
if path != "" {
files = append(files, DiffFile{Path: path, Add: add, Del: del})
}
}
return files
}