git-sync-web 自动提交 2026-09-09 16:33:32

This commit is contained in:
tamakyi
2026-09-09 16:33:32 +08:00
parent 5cdffecb44
commit 80bd1136f2
13 changed files with 3876 additions and 3887 deletions
+231 -234
View File
@@ -1,234 +1,231 @@
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
}
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,
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")
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)})
}
}
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
}