From 05d6f1bc847a18cb308193646fd1ac9fc63501ad Mon Sep 17 00:00:00 2001 From: tamakyi Date: Wed, 9 Sep 2026 11:53:35 +0800 Subject: [PATCH] =?UTF-8?q?git-sync-web=20=E8=87=AA=E5=8A=A8=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=202026-09-09=2011:53:34?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.go | 8 +++++ engine.go | 94 ++++++++++++++++++++++++++++++++++++++++++++++----- static/app.js | 6 ++-- 3 files changed, 98 insertions(+), 10 deletions(-) diff --git a/config.go b/config.go index af8e03b..46f009d 100644 --- a/config.go +++ b/config.go @@ -26,6 +26,7 @@ type RepoConfig struct { RetryCount int `json:"retry_count"` RetryInterval int `json:"retry_interval"` ConflictStrategy string `json:"conflict_strategy"` + PushStrategy string `json:"push_strategy"` AutoCommit bool `json:"auto_commit"` Cron string `json:"cron"` } @@ -106,6 +107,7 @@ func loadRepoConfig(dir, name string) (*RepoConfig, error) { c.RetryCount = atoiOr(m["RETRY_COUNT"], 0) c.RetryInterval = atoiOr(m["RETRY_INTERVAL"], 0) c.ConflictStrategy = orDefault(m["CONFLICT_STRATEGY"], "merge") + c.PushStrategy = orDefault(m["PUSH_STRATEGY"], "manual") c.AutoCommit = strings.EqualFold(m["AUTO_COMMIT"], "1") || strings.EqualFold(m["AUTO_COMMIT"], "true") c.Cron = strings.TrimSpace(m["CRON"]) if remotes := strings.TrimSpace(m["REMOTES"]); remotes != "" { @@ -147,6 +149,7 @@ func saveRepoConfig(dir string, c *RepoConfig) error { fmt.Fprintf(&b, "RETRY_COUNT=%s\n", q(strconv.Itoa(c.RetryCount))) fmt.Fprintf(&b, "RETRY_INTERVAL=%s\n", q(strconv.Itoa(c.RetryInterval))) fmt.Fprintf(&b, "CONFLICT_STRATEGY=%s\n", q(c.ConflictStrategy)) + fmt.Fprintf(&b, "PUSH_STRATEGY=%s\n", q(c.PushStrategy)) ac := "0" if c.AutoCommit { ac = "1" @@ -197,6 +200,11 @@ func validateRepoConfig(cfgDir string, c *RepoConfig, isCreate bool) error { default: return fmt.Errorf("冲突策略必须是 merge/rebase/abort") } + switch c.PushStrategy { + case "manual", "rebase", "force": + default: + return fmt.Errorf("推送策略必须是 manual/rebase/force") + } if c.Interval < 5 { c.Interval = 60 } diff --git a/engine.go b/engine.go index 0169aa8..2b6f78d 100644 --- a/engine.go +++ b/engine.go @@ -71,6 +71,7 @@ type RepoSnapshot struct { 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"` Cron string `json:"cron"` @@ -210,11 +211,18 @@ func (e *Engine) logf(s *repoState, level, format string, args ...any) { e.publish(map[string]any{"type": "log", "repo": s.cfg.Name, "level": level, "msg": msg, "time": ts}) } +// statMaxLines 控制单个仓库统计 CSV 的最大行数(含表头),超过则滚动保留最近 N 行 +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 @@ -223,6 +231,23 @@ func (e *Engine) stat(s *repoState, action, result string, commits int) { fmt.Fprintf(f, "%s,%s,%s,%d\n", time.Now().Format("2006-01-02 15:04:05"), action, result, commits) } +// rotateStatIfNeeded 当 CSV 行数超过 maxLines 时,保留表头 + 最近 (maxLines-1) 条数据行并重写 +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 @@ -243,6 +268,7 @@ func (s *repoState) snapshot(live bool) RepoSnapshot { RetryCount: cfg.RetryCount, RetryInterval: cfg.RetryInterval, ConflictStrategy: cfg.ConflictStrategy, + PushStrategy: cfg.PushStrategy, LogFile: cfg.LogFile, AutoCommit: cfg.AutoCommit, Cron: cfg.Cron, @@ -516,6 +542,19 @@ func (e *Engine) syncOnce(s *repoState, trigger string) { s.mu.Unlock() }() + // 自动 stash 恢复(B4):若本次同步前暂存了改动,结束时尝试恢复 + 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 @@ -536,6 +575,16 @@ func (e *Engine) syncOnce(s *repoState, trigger string) { return } + // 自动 stash(B4):未开启自动提交且有未提交改动时先暂存,避免分支切换/推送被脏工作区阻断 + 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) @@ -620,19 +669,48 @@ func (e *Engine) syncOnce(s *repoState, trigger string) { // ---- 再推送 ---- 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 错误 + // 检查是否是 non-fast-forward 错误,按推送策略处理(B3) if pushErr != nil && strings.Contains(strings.ToLower(pushErr.Error()), "non-fast-forward") { - // 记录冲突远程(取第一个冲突的远程) - // 在 pushAll 内部已经设置了 pushConflict 状态,这里只需退出 - e.logf(s, "warn", "推送被拒绝(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: + // manual:保持 push_conflict 状态,等待用户在控制台决策(强制推送 / 放弃) + e.logf(s, "warn", "推送被拒绝(non-fast-forward),等待用户决策") + return + } + } else { + e.setFail(s, "推送失败: "+pushErr.Error()) return } - // 其他错误 - e.setFail(s, "推送失败: "+pushErr.Error()) - return } } diff --git a/static/app.js b/static/app.js index bdec4f8..48c4226 100644 --- a/static/app.js +++ b/static/app.js @@ -341,9 +341,11 @@ function loadStats(name) { d.setDate(d.getDate() - i); days.push(dateStr(d)); } - const mk = (key) => days.map(d => rows.filter(r => r.result === key && r.time.slice(0, 10) === d).length); + // 后端写入的 result 词汇是 success/failed/conflict,这里归一化到 ok/error/conflict 再统计 + const normResult = r => r.result === 'success' ? 'ok' : r.result === 'failed' ? 'error' : r.result; + const mk = (key) => days.map(d => rows.filter(r => normResult(r) === key && r.time.slice(0, 10) === d).length); const total = { ok: 0, error: 0, conflict: 0 }; - rows.forEach(r => { if (total[r.result] !== undefined) total[r.result]++; }); + rows.forEach(r => { const k = normResult(r); if (total[k] !== undefined) total[k]++; }); $('#stat-summary').innerHTML = `近 30 天共 ${rows.length} 次同步记录` + `成功 ${total.ok}` +