git-sync-web 自动提交 2026-09-08 17:12:27
This commit is contained in:
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Remote struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type RepoConfig struct {
|
||||
Name string `json:"name"`
|
||||
RepoPath string `json:"repo_path"`
|
||||
Branch string `json:"branch"`
|
||||
Remotes []Remote `json:"remotes"`
|
||||
Mode string `json:"mode"`
|
||||
Interval int `json:"interval"`
|
||||
LogFile string `json:"log_file"`
|
||||
AuthMethod string `json:"auth_method"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
RetryInterval int `json:"retry_interval"`
|
||||
ConflictStrategy string `json:"conflict_strategy"`
|
||||
AutoCommit bool `json:"auto_commit"`
|
||||
Cron string `json:"cron"`
|
||||
}
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[A-Za-z0-9_\-]+$`)
|
||||
|
||||
// normalizeWinPath 把 Git Bash 风格路径 /c/foo/bar 转成 Windows 的 C:/foo/bar,
|
||||
// 兼容旧脚本配置里存放的 bash 风格路径
|
||||
func normalizeWinPath(p string) string {
|
||||
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
|
||||
if len(p) >= 3 && p[0] == '/' && p[2] == '/' {
|
||||
d := p[1]
|
||||
if (d >= 'a' && d <= 'z') || (d >= 'A' && d <= 'Z') {
|
||||
return strings.ToUpper(string(d)) + ":/" + p[3:]
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func unquote(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) >= 2 {
|
||||
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func orDefault(v, def string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func atoiOr(v string, def int) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func parseConfFile(path string) (map[string]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := map[string]string{}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
m[strings.TrimSpace(k)] = unquote(v)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func loadRepoConfig(dir, name string) (*RepoConfig, error) {
|
||||
m, err := parseConfFile(filepath.Join(dir, name+".conf"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &RepoConfig{Name: name}
|
||||
c.RepoPath = normalizeWinPath(m["REPO_PATH"])
|
||||
c.Branch = orDefault(m["BRANCH"], "main")
|
||||
c.Mode = orDefault(m["MODE"], "both")
|
||||
c.Interval = atoiOr(m["INTERVAL"], 60)
|
||||
c.LogFile = normalizeWinPath(m["LOG_FILE"])
|
||||
c.AuthMethod = orDefault(m["AUTH_METHOD"], "ssh")
|
||||
c.RetryCount = atoiOr(m["RETRY_COUNT"], 0)
|
||||
c.RetryInterval = atoiOr(m["RETRY_INTERVAL"], 0)
|
||||
c.ConflictStrategy = orDefault(m["CONFLICT_STRATEGY"], "merge")
|
||||
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 != "" {
|
||||
for _, pair := range strings.Split(remotes, ",") {
|
||||
pair = strings.TrimSpace(pair)
|
||||
if pair == "" {
|
||||
continue
|
||||
}
|
||||
n, u, ok := strings.Cut(pair, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
c.Remotes = append(c.Remotes, Remote{Name: strings.TrimSpace(n), URL: strings.TrimSpace(u)})
|
||||
}
|
||||
}
|
||||
// 兼容旧版单远程格式:REMOTE_NAME + REMOTE_URL
|
||||
if len(c.Remotes) == 0 && m["REMOTE_NAME"] != "" && m["REMOTE_URL"] != "" {
|
||||
c.Remotes = append(c.Remotes, Remote{Name: m["REMOTE_NAME"], URL: m["REMOTE_URL"]})
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func q(s string) string { return "\"" + s + "\"" }
|
||||
|
||||
func saveRepoConfig(dir string, c *RepoConfig) error {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Git 自动同步配置 - %s\n", c.Name)
|
||||
fmt.Fprintf(&b, "REPO_PATH=%s\n", q(c.RepoPath))
|
||||
fmt.Fprintf(&b, "BRANCH=%s\n", q(c.Branch))
|
||||
pairs := make([]string, len(c.Remotes))
|
||||
for i, r := range c.Remotes {
|
||||
pairs[i] = r.Name + "=" + r.URL
|
||||
}
|
||||
fmt.Fprintf(&b, "REMOTES=%s\n", q(strings.Join(pairs, ",")))
|
||||
fmt.Fprintf(&b, "MODE=%s\n", q(c.Mode))
|
||||
fmt.Fprintf(&b, "INTERVAL=%s\n", q(strconv.Itoa(c.Interval)))
|
||||
fmt.Fprintf(&b, "LOG_FILE=%s\n", q(c.LogFile))
|
||||
fmt.Fprintf(&b, "AUTH_METHOD=%s\n", q(c.AuthMethod))
|
||||
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))
|
||||
ac := "0"
|
||||
if c.AutoCommit {
|
||||
ac = "1"
|
||||
}
|
||||
fmt.Fprintf(&b, "AUTO_COMMIT=%s\n", q(ac))
|
||||
fmt.Fprintf(&b, "CRON=%s\n", q(c.Cron))
|
||||
return os.WriteFile(filepath.Join(dir, c.Name+".conf"), []byte(b.String()), 0o644)
|
||||
}
|
||||
|
||||
func validateRepoConfig(cfgDir string, c *RepoConfig, isCreate bool) error {
|
||||
if !nameRe.MatchString(c.Name) {
|
||||
return fmt.Errorf("配置名只能包含字母、数字、下划线和连字符")
|
||||
}
|
||||
if isCreate {
|
||||
if _, err := os.Stat(filepath.Join(cfgDir, c.Name+".conf")); err == nil {
|
||||
return fmt.Errorf("配置 %q 已存在", c.Name)
|
||||
}
|
||||
}
|
||||
p := normalizeWinPath(c.RepoPath)
|
||||
if !isRepo(p) {
|
||||
return fmt.Errorf("仓库路径无效或不是 Git 仓库: %s", c.RepoPath)
|
||||
}
|
||||
c.RepoPath = p
|
||||
c.Branch = strings.TrimSpace(c.Branch)
|
||||
if c.Branch == "" {
|
||||
c.Branch = "main"
|
||||
}
|
||||
if len(c.Remotes) == 0 {
|
||||
return fmt.Errorf("至少需要一个远程仓库(格式:名称=URL)")
|
||||
}
|
||||
for _, r := range c.Remotes {
|
||||
if strings.TrimSpace(r.Name) == "" || strings.TrimSpace(r.URL) == "" {
|
||||
return fmt.Errorf("远程配置格式应为 名称=URL")
|
||||
}
|
||||
}
|
||||
switch c.Mode {
|
||||
case "push", "pull", "both":
|
||||
default:
|
||||
return fmt.Errorf("操作模式必须是 push/pull/both")
|
||||
}
|
||||
switch c.AuthMethod {
|
||||
case "ssh", "https":
|
||||
default:
|
||||
return fmt.Errorf("认证方式必须是 ssh/https")
|
||||
}
|
||||
switch c.ConflictStrategy {
|
||||
case "merge", "rebase", "abort":
|
||||
default:
|
||||
return fmt.Errorf("冲突策略必须是 merge/rebase/abort")
|
||||
}
|
||||
if c.Interval < 5 {
|
||||
c.Interval = 60
|
||||
}
|
||||
if c.RetryCount < 0 {
|
||||
c.RetryCount = 0
|
||||
}
|
||||
if c.RetryInterval < 0 {
|
||||
c.RetryInterval = 0
|
||||
}
|
||||
if c.AuthMethod != "https" {
|
||||
c.RetryCount, c.RetryInterval = 0, 0
|
||||
}
|
||||
if strings.TrimSpace(c.Cron) != "" {
|
||||
if _, err := parseCron(c.Cron); err != nil {
|
||||
return fmt.Errorf("cron 表达式无效: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user