git-sync-web 自动提交 2026-09-09 11:53:34
This commit is contained in:
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,21 +669,50 @@ 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", "按推送策略 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),等待用户决策")
|
e.logf(s, "warn", "推送被拒绝(non-fast-forward),等待用户决策")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 其他错误
|
} else {
|
||||||
e.setFail(s, "推送失败: "+pushErr.Error())
|
e.setFail(s, "推送失败: "+pushErr.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 最终刷新
|
// 最终刷新
|
||||||
ahead, behind, _ = aheadBehind(cfg.RepoPath, remote, cfg.Branch)
|
ahead, behind, _ = aheadBehind(cfg.RepoPath, remote, cfg.Branch)
|
||||||
|
|||||||
+4
-2
@@ -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>` +
|
||||||
|
|||||||
Reference in New Issue
Block a user