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 }