git-sync-web 自动提交 2026-09-09 11:53:34

This commit is contained in:
tamakyi
2026-09-09 11:53:35 +08:00
parent 2dd4a46ad6
commit 05d6f1bc84
3 changed files with 98 additions and 10 deletions
+8
View File
@@ -26,6 +26,7 @@ type RepoConfig struct {
RetryCount int `json:"retry_count"` RetryCount int `json:"retry_count"`
RetryInterval int `json:"retry_interval"` RetryInterval int `json:"retry_interval"`
ConflictStrategy string `json:"conflict_strategy"` ConflictStrategy string `json:"conflict_strategy"`
PushStrategy string `json:"push_strategy"`
AutoCommit bool `json:"auto_commit"` AutoCommit bool `json:"auto_commit"`
Cron string `json:"cron"` Cron string `json:"cron"`
} }
@@ -106,6 +107,7 @@ func loadRepoConfig(dir, name string) (*RepoConfig, error) {
c.RetryCount = atoiOr(m["RETRY_COUNT"], 0) c.RetryCount = atoiOr(m["RETRY_COUNT"], 0)
c.RetryInterval = atoiOr(m["RETRY_INTERVAL"], 0) c.RetryInterval = atoiOr(m["RETRY_INTERVAL"], 0)
c.ConflictStrategy = orDefault(m["CONFLICT_STRATEGY"], "merge") 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.AutoCommit = strings.EqualFold(m["AUTO_COMMIT"], "1") || strings.EqualFold(m["AUTO_COMMIT"], "true")
c.Cron = strings.TrimSpace(m["CRON"]) c.Cron = strings.TrimSpace(m["CRON"])
if remotes := strings.TrimSpace(m["REMOTES"]); remotes != "" { 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_COUNT=%s\n", q(strconv.Itoa(c.RetryCount)))
fmt.Fprintf(&b, "RETRY_INTERVAL=%s\n", q(strconv.Itoa(c.RetryInterval))) 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, "CONFLICT_STRATEGY=%s\n", q(c.ConflictStrategy))
fmt.Fprintf(&b, "PUSH_STRATEGY=%s\n", q(c.PushStrategy))
ac := "0" ac := "0"
if c.AutoCommit { if c.AutoCommit {
ac = "1" ac = "1"
@@ -197,6 +200,11 @@ func validateRepoConfig(cfgDir string, c *RepoConfig, isCreate bool) error {
default: default:
return fmt.Errorf("冲突策略必须是 merge/rebase/abort") return fmt.Errorf("冲突策略必须是 merge/rebase/abort")
} }
switch c.PushStrategy {
case "manual", "rebase", "force":
default:
return fmt.Errorf("推送策略必须是 manual/rebase/force")
}
if c.Interval < 5 { if c.Interval < 5 {
c.Interval = 60 c.Interval = 60
} }
+86 -8
View File
@@ -71,6 +71,7 @@ type RepoSnapshot struct {
RetryCount int `json:"retry_count"` RetryCount int `json:"retry_count"`
RetryInterval int `json:"retry_interval"` RetryInterval int `json:"retry_interval"`
ConflictStrategy string `json:"conflict_strategy"` ConflictStrategy string `json:"conflict_strategy"`
PushStrategy string `json:"push_strategy"`
LogFile string `json:"log_file"` LogFile string `json:"log_file"`
AutoCommit bool `json:"auto_commit"` AutoCommit bool `json:"auto_commit"`
Cron string `json:"cron"` 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}) 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) { func (e *Engine) stat(s *repoState, action, result string, commits int) {
path := filepath.Join(e.statsDir, s.cfg.Name+".csv") path := filepath.Join(e.statsDir, s.cfg.Name+".csv")
if _, err := os.Stat(path); os.IsNotExist(err) { if _, err := os.Stat(path); os.IsNotExist(err) {
_ = os.WriteFile(path, []byte("timestamp,action,result,commits\n"), 0o644) _ = 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) f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil { if err != nil {
return 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) 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 { func firstRemoteName(cfg *RepoConfig) string {
if len(cfg.Remotes) > 0 { if len(cfg.Remotes) > 0 {
return cfg.Remotes[0].Name return cfg.Remotes[0].Name
@@ -243,6 +268,7 @@ func (s *repoState) snapshot(live bool) RepoSnapshot {
RetryCount: cfg.RetryCount, RetryCount: cfg.RetryCount,
RetryInterval: cfg.RetryInterval, RetryInterval: cfg.RetryInterval,
ConflictStrategy: cfg.ConflictStrategy, ConflictStrategy: cfg.ConflictStrategy,
PushStrategy: cfg.PushStrategy,
LogFile: cfg.LogFile, LogFile: cfg.LogFile,
AutoCommit: cfg.AutoCommit, AutoCommit: cfg.AutoCommit,
Cron: cfg.Cron, Cron: cfg.Cron,
@@ -516,6 +542,19 @@ func (e *Engine) syncOnce(s *repoState, trigger string) {
s.mu.Unlock() 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) { if !isRepo(cfg.RepoPath) {
e.setFail(s, "仓库路径无效: "+cfg.RepoPath) e.setFail(s, "仓库路径无效: "+cfg.RepoPath)
return return
@@ -536,6 +575,16 @@ func (e *Engine) syncOnce(s *repoState, trigger string) {
return 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 { if cur, cerr := currentBranch(cfg.RepoPath); cerr == nil && cur != cfg.Branch {
tip, remoteHasBranch := remoteTip(cfg.RepoPath, remote, 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") { if ahead > 0 && (cfg.Mode == "push" || cfg.Mode == "both") {
// pushAll 可能返回错误,如果是 non-fast-forward,设置 push_conflict 状态
pushOK, pushErr := e.pushAll(s, cfg, ahead) pushOK, pushErr := e.pushAll(s, cfg, ahead)
if !pushOK { if !pushOK {
// 检查是否是 non-fast-forward 错误 // 检查是否是 non-fast-forward 错误,按推送策略处理(B3)
if pushErr != nil && strings.Contains(strings.ToLower(pushErr.Error()), "non-fast-forward") { if pushErr != nil && strings.Contains(strings.ToLower(pushErr.Error()), "non-fast-forward") {
// 记录冲突远程(取第一个冲突的远程) switch cfg.PushStrategy {
// 在 pushAll 内部已经设置了 pushConflict 状态,这里只需退出 case "force":
e.logf(s, "warn", "推送被拒绝(non-fast-forward),等待用户决策") 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 return
} }
// 其他错误
e.setFail(s, "推送失败: "+pushErr.Error())
return
} }
} }
+4 -2
View File
@@ -341,9 +341,11 @@ function loadStats(name) {
d.setDate(d.getDate() - i); d.setDate(d.getDate() - i);
days.push(dateStr(d)); 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 }; 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 = $('#stat-summary').innerHTML =
`<span>近 30 天共 <b>${rows.length}</b> 次同步记录</span>` + `<span>近 30 天共 <b>${rows.length}</b> 次同步记录</span>` +
`<span style="color:var(--green)">成功 <b>${total.ok}</b></span>` + `<span style="color:var(--green)">成功 <b>${total.ok}</b></span>` +