235 lines
6.7 KiB
Go
235 lines
6.7 KiB
Go
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"`
|
||
PushStrategy string `json:"push_strategy"`
|
||
AutoCommit bool `json:"auto_commit"`
|
||
AutoSync bool `json:"auto_sync"`
|
||
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.PushStrategy = orDefault(m["PUSH_STRATEGY"], "manual")
|
||
c.AutoCommit = strings.EqualFold(m["AUTO_COMMIT"], "1") || strings.EqualFold(m["AUTO_COMMIT"], "true")
|
||
// 自动同步默认开启;仅在显式配置 0 / false 时关闭(关闭后不再自动触发,但手动同步仍可用)
|
||
c.AutoSync = !(strings.EqualFold(m["AUTO_SYNC"], "0") || strings.EqualFold(m["AUTO_SYNC"], "false"))
|
||
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))
|
||
fmt.Fprintf(&b, "PUSH_STRATEGY=%s\n", q(c.PushStrategy))
|
||
ac := "0"
|
||
if c.AutoCommit {
|
||
ac = "1"
|
||
}
|
||
fmt.Fprintf(&b, "AUTO_COMMIT=%s\n", q(ac))
|
||
as := "0"
|
||
if c.AutoSync {
|
||
as = "1"
|
||
}
|
||
fmt.Fprintf(&b, "AUTO_SYNC=%s\n", q(as))
|
||
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")
|
||
}
|
||
switch c.PushStrategy {
|
||
case "manual", "rebase", "force":
|
||
default:
|
||
return fmt.Errorf("推送策略必须是 manual/rebase/force")
|
||
}
|
||
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
|
||
}
|