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}` +