git-sync-web 自动提交 2026-09-08 17:12:27
This commit is contained in:
+101
@@ -0,0 +1,101 @@
|
|||||||
|
# Created by https://www.toptal.com/developers/gitignore/api/code,go,linux,macos,windows
|
||||||
|
# Edit at https://www.toptal.com/developers/gitignore?templates=code,go,linux,macos,windows
|
||||||
|
|
||||||
|
### Code ###
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
### Go ###
|
||||||
|
# Binaries for programs and plugins
|
||||||
|
*.exe
|
||||||
|
*.exe~
|
||||||
|
*.dll
|
||||||
|
*.so
|
||||||
|
*.dylib
|
||||||
|
|
||||||
|
# Test binary, built with `go test -c`
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# Output of the go coverage tool, specifically when used with LiteIDE
|
||||||
|
*.out
|
||||||
|
|
||||||
|
# Dependency directories (remove the comment below to include it)
|
||||||
|
# vendor/
|
||||||
|
|
||||||
|
### Go Patch ###
|
||||||
|
/vendor/
|
||||||
|
/Godeps/
|
||||||
|
|
||||||
|
### Linux ###
|
||||||
|
*~
|
||||||
|
|
||||||
|
# temporary files which can be created if a process still has a handle open of a deleted file
|
||||||
|
.fuse_hidden*
|
||||||
|
|
||||||
|
# KDE directory preferences
|
||||||
|
.directory
|
||||||
|
|
||||||
|
# Linux trash folder which might appear on any partition or disk
|
||||||
|
.Trash-*
|
||||||
|
|
||||||
|
# .nfs files are created when an open file is removed but is still being accessed
|
||||||
|
.nfs*
|
||||||
|
|
||||||
|
### macOS ###
|
||||||
|
# General
|
||||||
|
.DS_Store
|
||||||
|
.AppleDouble
|
||||||
|
.LSOverride
|
||||||
|
|
||||||
|
# Icon must end with two \r
|
||||||
|
Icon
|
||||||
|
|
||||||
|
|
||||||
|
# Thumbnails
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Files that might appear in the root of a volume
|
||||||
|
.DocumentRevisions-V100
|
||||||
|
.fseventsd
|
||||||
|
.Spotlight-V100
|
||||||
|
.TemporaryItems
|
||||||
|
.Trashes
|
||||||
|
.VolumeIcon.icns
|
||||||
|
.com.apple.timemachine.donotpresent
|
||||||
|
|
||||||
|
# Directories potentially created on remote AFP share
|
||||||
|
.AppleDB
|
||||||
|
.AppleDesktop
|
||||||
|
Network Trash Folder
|
||||||
|
Temporary Items
|
||||||
|
.apdisk
|
||||||
|
|
||||||
|
### Windows ###
|
||||||
|
# Windows thumbnail cache files
|
||||||
|
Thumbs.db
|
||||||
|
Thumbs.db:encryptable
|
||||||
|
ehthumbs.db
|
||||||
|
ehthumbs_vista.db
|
||||||
|
|
||||||
|
# Dump file
|
||||||
|
*.stackdump
|
||||||
|
|
||||||
|
# Folder config file
|
||||||
|
[Dd]esktop.ini
|
||||||
|
|
||||||
|
# Recycle Bin used on file shares
|
||||||
|
$RECYCLE.BIN/
|
||||||
|
|
||||||
|
# Windows Installer files
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# Windows shortcuts
|
||||||
|
*.lnk
|
||||||
|
|
||||||
|
# End of https://www.toptal.com/developers/gitignore/api/code,go,linux,macos,windows
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
# Git 自动同步配置 - git-auto-sync
|
||||||
|
REPO_PATH="C:/OtherFiles/Github/git-auto-sync/git-sync-web"
|
||||||
|
BRANCH="main"
|
||||||
|
REMOTES="origin=https://git.shiroko.one/tamakyi/github-auto-sync-golang"
|
||||||
|
MODE="both"
|
||||||
|
INTERVAL="60"
|
||||||
|
LOG_FILE=""
|
||||||
|
AUTH_METHOD="ssh"
|
||||||
|
RETRY_COUNT="0"
|
||||||
|
RETRY_INTERVAL="0"
|
||||||
|
CONFLICT_STRATEGY="merge"
|
||||||
|
AUTO_COMMIT="1"
|
||||||
|
CRON=""
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 5 段 cron:分 时 日 月 周。支持 * 、数字、范围 a-b、步长 */n 与 a-b/n、逗号列表。
|
||||||
|
// 日(day-of-month) 与 周(day-of-week) 均受限时采用标准 cron 的「或」语义。
|
||||||
|
|
||||||
|
type cronSpec struct {
|
||||||
|
min, hour, dom, mon, dow map[int]bool
|
||||||
|
domAll, dowAll bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCron(spec string) (*cronSpec, error) {
|
||||||
|
fields := strings.Fields(spec)
|
||||||
|
if len(fields) != 5 {
|
||||||
|
return nil, fmt.Errorf("cron 需要 5 个字段(分 时 日 月 周),收到 %d 个", len(fields))
|
||||||
|
}
|
||||||
|
c := &cronSpec{}
|
||||||
|
var err error
|
||||||
|
if c.min, err = parseCronField(fields[0], 0, 59); err != nil {
|
||||||
|
return nil, fmt.Errorf("分钟字段: %w", err)
|
||||||
|
}
|
||||||
|
if c.hour, err = parseCronField(fields[1], 0, 23); err != nil {
|
||||||
|
return nil, fmt.Errorf("小时字段: %w", err)
|
||||||
|
}
|
||||||
|
if c.dom, err = parseCronField(fields[2], 1, 31); err != nil {
|
||||||
|
return nil, fmt.Errorf("日字段: %w", err)
|
||||||
|
}
|
||||||
|
if c.mon, err = parseCronField(fields[3], 1, 12); err != nil {
|
||||||
|
return nil, fmt.Errorf("月字段: %w", err)
|
||||||
|
}
|
||||||
|
if c.dow, err = parseCronField(fields[4], 0, 7); err != nil {
|
||||||
|
return nil, fmt.Errorf("星期字段: %w", err)
|
||||||
|
}
|
||||||
|
if c.dow[7] {
|
||||||
|
c.dow[0] = true
|
||||||
|
}
|
||||||
|
c.domAll = len(c.dom) == 31
|
||||||
|
c.dowAll = len(c.dow) == 8
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCronField(f string, min, max int) (map[int]bool, error) {
|
||||||
|
set := map[int]bool{}
|
||||||
|
for _, part := range strings.Split(f, ",") {
|
||||||
|
part = strings.TrimSpace(part)
|
||||||
|
if part == "" {
|
||||||
|
return nil, fmt.Errorf("存在空字段")
|
||||||
|
}
|
||||||
|
step := 1
|
||||||
|
if i := strings.Index(part, "/"); i >= 0 {
|
||||||
|
n, err := strconv.Atoi(part[i+1:])
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return nil, fmt.Errorf("无效步长 %q", part)
|
||||||
|
}
|
||||||
|
step = n
|
||||||
|
part = part[:i]
|
||||||
|
if part == "" {
|
||||||
|
part = "*"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lo, hi := 0, 0
|
||||||
|
if part == "*" {
|
||||||
|
lo, hi = min, max
|
||||||
|
} else if rng := strings.SplitN(part, "-", 2); len(rng) == 2 {
|
||||||
|
var err error
|
||||||
|
if lo, err = strconv.Atoi(rng[0]); err != nil {
|
||||||
|
return nil, fmt.Errorf("无效值 %q", part)
|
||||||
|
}
|
||||||
|
if hi, err = strconv.Atoi(rng[1]); err != nil {
|
||||||
|
return nil, fmt.Errorf("无效值 %q", part)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
v, err := strconv.Atoi(part)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("无效值 %q", part)
|
||||||
|
}
|
||||||
|
lo, hi = v, v
|
||||||
|
}
|
||||||
|
if lo < min || hi > max || lo > hi {
|
||||||
|
return nil, fmt.Errorf("值超出范围 [%d,%d]: %q", min, max, part)
|
||||||
|
}
|
||||||
|
for v := lo; v <= hi; v += step {
|
||||||
|
set[v] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cronNext 返回 spec 在 after 之后的下一次触发时间(分钟精度,最多向前搜索一年)
|
||||||
|
func cronNext(spec *cronSpec, after time.Time) time.Time {
|
||||||
|
t := after.Truncate(time.Minute).Add(time.Minute)
|
||||||
|
limit := t.AddDate(1, 0, 0)
|
||||||
|
for t.Before(limit) {
|
||||||
|
if !spec.mon[int(t.Month())] {
|
||||||
|
t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()).AddDate(0, 1, 0)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
domOK := spec.dom[t.Day()]
|
||||||
|
dowOK := spec.dow[int(t.Weekday())]
|
||||||
|
dayOK := domOK && dowOK
|
||||||
|
if !spec.domAll && !spec.dowAll {
|
||||||
|
dayOK = domOK || dowOK
|
||||||
|
}
|
||||||
|
if !dayOK {
|
||||||
|
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, 1)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !spec.hour[t.Hour()] {
|
||||||
|
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location()).Add(time.Hour)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if spec.min[t.Minute()] {
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
t = t.Add(time.Minute)
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCron(t *testing.T) {
|
||||||
|
for _, spec := range []string{"*/5 * * * *", "0 9 * * 1-5", "30 8 1,15 * *", "* * * * *", "0 */2 * * *", "0 8-18/2 * * 1-5"} {
|
||||||
|
if _, err := parseCron(spec); err != nil {
|
||||||
|
t.Errorf("parseCron(%q) 应当成功,却得到: %v", spec, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, spec := range []string{"* * * *", "61 * * * *", "* * * * 8", "a * * * *", "* * * * * *"} {
|
||||||
|
if _, err := parseCron(spec); err == nil {
|
||||||
|
t.Errorf("parseCron(%q) 应当失败", spec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCronNext(t *testing.T) {
|
||||||
|
// 每日 09:30:从当天 10:00 出发应得到次日 09:30
|
||||||
|
spec, _ := parseCron("30 9 * * *")
|
||||||
|
next := cronNext(spec, time.Date(2026, 9, 8, 10, 0, 0, 0, time.Local))
|
||||||
|
want := time.Date(2026, 9, 9, 9, 30, 0, 0, time.Local)
|
||||||
|
if !next.Equal(want) {
|
||||||
|
t.Fatalf("每日 09:30:期望 %v,得到 %v", want, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 每 15 分钟
|
||||||
|
spec2, _ := parseCron("*/15 * * * *")
|
||||||
|
next2 := cronNext(spec2, time.Date(2026, 9, 8, 10, 7, 0, 0, time.Local))
|
||||||
|
want2 := time.Date(2026, 9, 8, 10, 15, 0, 0, time.Local)
|
||||||
|
if !next2.Equal(want2) {
|
||||||
|
t.Fatalf("*/15:期望 %v,得到 %v", want2, next2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅周五 12:00:2026-09-08 是周二,应得到 2026-09-11(周五)
|
||||||
|
spec3, _ := parseCron("0 12 * * 5")
|
||||||
|
next3 := cronNext(spec3, time.Date(2026, 9, 8, 12, 0, 0, 0, time.Local))
|
||||||
|
want3 := time.Date(2026, 9, 11, 12, 0, 0, 0, time.Local)
|
||||||
|
if !next3.Equal(want3) {
|
||||||
|
t.Fatalf("仅周五:期望 %v,得到 %v", want3, next3)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7 表示周日:2026-09-13 是周日
|
||||||
|
spec4, _ := parseCron("0 0 * * 7")
|
||||||
|
next4 := cronNext(spec4, time.Date(2026, 9, 12, 0, 0, 0, 0, time.Local)) // 周六
|
||||||
|
want4 := time.Date(2026, 9, 13, 0, 0, 0, 0, time.Local)
|
||||||
|
if !next4.Equal(want4) {
|
||||||
|
t.Fatalf("7=周日:期望 %v,得到 %v", want4, next4)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,974 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------- 事件总线(SSE 广播) ----------
|
||||||
|
|
||||||
|
type EventBus struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
next int
|
||||||
|
subs map[int]chan []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEventBus() *EventBus { return &EventBus{subs: map[int]chan []byte{}} }
|
||||||
|
|
||||||
|
func (b *EventBus) Subscribe() (int, chan []byte) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
b.next++
|
||||||
|
ch := make(chan []byte, 128)
|
||||||
|
b.subs[b.next] = ch
|
||||||
|
return b.next, ch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *EventBus) Unsubscribe(id int) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if ch, ok := b.subs[id]; ok {
|
||||||
|
delete(b.subs, id)
|
||||||
|
close(ch)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *EventBus) Publish(data []byte) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
for _, ch := range b.subs {
|
||||||
|
select {
|
||||||
|
case ch <- data:
|
||||||
|
default: // 订阅者阻塞时丢弃,避免拖慢同步循环
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 状态 ----------
|
||||||
|
|
||||||
|
type SyncResult struct {
|
||||||
|
Time string `json:"time"`
|
||||||
|
Result string `json:"result"` // ok | error | conflict
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RepoSnapshot struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
RepoPath string `json:"repo_path"`
|
||||||
|
Branch string `json:"branch"`
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
AuthMethod string `json:"auth_method"`
|
||||||
|
RetryCount int `json:"retry_count"`
|
||||||
|
RetryInterval int `json:"retry_interval"`
|
||||||
|
ConflictStrategy string `json:"conflict_strategy"`
|
||||||
|
LogFile string `json:"log_file"`
|
||||||
|
AutoCommit bool `json:"auto_commit"`
|
||||||
|
Cron string `json:"cron"`
|
||||||
|
Remotes []Remote `json:"remotes"`
|
||||||
|
Running bool `json:"running"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Ahead int `json:"ahead"`
|
||||||
|
Behind int `json:"behind"`
|
||||||
|
Dirty int `json:"dirty"`
|
||||||
|
Conflicts []string `json:"conflicts"`
|
||||||
|
LastError string `json:"last_error"`
|
||||||
|
LastSync *SyncResult `json:"last_sync"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type repoState struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
cfg *RepoConfig
|
||||||
|
running bool
|
||||||
|
syncing bool
|
||||||
|
status string // idle | ok | error | conflict
|
||||||
|
ahead int
|
||||||
|
behind int
|
||||||
|
dirty int
|
||||||
|
conflicts []string
|
||||||
|
lastError string
|
||||||
|
lastSync *SyncResult
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Engine struct {
|
||||||
|
baseDir string
|
||||||
|
cfgDir string
|
||||||
|
logDir string
|
||||||
|
statsDir string
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
states map[string]*repoState
|
||||||
|
bus *EventBus
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewEngine(base string) (*Engine, error) {
|
||||||
|
e := &Engine{
|
||||||
|
baseDir: base,
|
||||||
|
cfgDir: filepath.Join(base, "configs"),
|
||||||
|
logDir: filepath.Join(base, "logs"),
|
||||||
|
statsDir: filepath.Join(base, "stats"),
|
||||||
|
states: map[string]*repoState{},
|
||||||
|
bus: NewEventBus(),
|
||||||
|
}
|
||||||
|
for _, d := range []string{e.cfgDir, e.logDir, e.statsDir} {
|
||||||
|
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Bus() *EventBus { return e.bus }
|
||||||
|
|
||||||
|
func (e *Engine) configNames() []string {
|
||||||
|
entries, err := os.ReadDir(e.cfgDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, en := range entries {
|
||||||
|
if !en.IsDir() && strings.HasSuffix(en.Name(), ".conf") {
|
||||||
|
names = append(names, strings.TrimSuffix(en.Name(), ".conf"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) get(name string) *repoState {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
return e.states[name]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) sortedNames() []string {
|
||||||
|
e.mu.Lock()
|
||||||
|
defer e.mu.Unlock()
|
||||||
|
names := make([]string, 0, len(e.states))
|
||||||
|
for n := range e.states {
|
||||||
|
names = append(names, n)
|
||||||
|
}
|
||||||
|
sort.Strings(names)
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) LoadAll() {
|
||||||
|
for _, name := range e.configNames() {
|
||||||
|
if _, ok := e.states[name]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cfg, err := loadRepoConfig(e.cfgDir, name)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("加载配置 %s 失败: %v", name, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
e.states[name] = &repoState{cfg: cfg, status: "idle"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) publish(v any) {
|
||||||
|
data, err := json.Marshal(v)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.bus.Publish(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) logf(s *repoState, level, format string, args ...any) {
|
||||||
|
msg := fmt.Sprintf(format, args...)
|
||||||
|
ts := time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
logPath := s.cfg.LogFile
|
||||||
|
if logPath == "" {
|
||||||
|
logPath = filepath.Join(e.logDir, s.cfg.Name+".log")
|
||||||
|
}
|
||||||
|
if logPath != "" {
|
||||||
|
_ = os.MkdirAll(filepath.Dir(logPath), 0o755)
|
||||||
|
if f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644); err == nil {
|
||||||
|
fmt.Fprintln(f, "["+ts+"] "+msg)
|
||||||
|
_ = f.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
e.publish(map[string]any{"type": "log", "repo": s.cfg.Name, "level": level, "msg": msg, "time": ts})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) stat(s *repoState, action, result string, commits int) {
|
||||||
|
path := filepath.Join(e.statsDir, s.cfg.Name+".csv")
|
||||||
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||||
|
_ = os.WriteFile(path, []byte("timestamp,action,result,commits\n"), 0o644)
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
fmt.Fprintf(f, "%s,%s,%s,%d\n", time.Now().Format("2006-01-02 15:04:05"), action, result, commits)
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstRemoteName(cfg *RepoConfig) string {
|
||||||
|
if len(cfg.Remotes) > 0 {
|
||||||
|
return cfg.Remotes[0].Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *repoState) snapshot(live bool) RepoSnapshot {
|
||||||
|
s.mu.Lock()
|
||||||
|
cfg := s.cfg
|
||||||
|
snap := RepoSnapshot{
|
||||||
|
Name: cfg.Name,
|
||||||
|
RepoPath: cfg.RepoPath,
|
||||||
|
Branch: cfg.Branch,
|
||||||
|
Mode: cfg.Mode,
|
||||||
|
Interval: cfg.Interval,
|
||||||
|
AuthMethod: cfg.AuthMethod,
|
||||||
|
RetryCount: cfg.RetryCount,
|
||||||
|
RetryInterval: cfg.RetryInterval,
|
||||||
|
ConflictStrategy: cfg.ConflictStrategy,
|
||||||
|
LogFile: cfg.LogFile,
|
||||||
|
AutoCommit: cfg.AutoCommit,
|
||||||
|
Cron: cfg.Cron,
|
||||||
|
Remotes: cfg.Remotes,
|
||||||
|
Running: s.running,
|
||||||
|
Status: s.status,
|
||||||
|
Ahead: s.ahead,
|
||||||
|
Behind: s.behind,
|
||||||
|
Dirty: s.dirty,
|
||||||
|
Conflicts: append([]string(nil), s.conflicts...),
|
||||||
|
LastError: s.lastError,
|
||||||
|
LastSync: s.lastSync,
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
if live {
|
||||||
|
if b, err := currentBranch(cfg.RepoPath); err == nil {
|
||||||
|
snap.Branch = b
|
||||||
|
}
|
||||||
|
snap.Dirty = dirtyCount(cfg.RepoPath)
|
||||||
|
if remote := firstRemoteName(cfg); remote != "" {
|
||||||
|
if a, be, err := aheadBehind(cfg.RepoPath, remote, snap.Branch); err == nil {
|
||||||
|
snap.Ahead, snap.Behind = a, be
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if files, err := conflictFiles(cfg.RepoPath); err == nil && len(files) > 0 {
|
||||||
|
snap.Conflicts = files
|
||||||
|
snap.Status = "conflict"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snap
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Snapshots(live bool) []RepoSnapshot {
|
||||||
|
var out []RepoSnapshot
|
||||||
|
for _, name := range e.sortedNames() {
|
||||||
|
if s := e.get(name); s != nil {
|
||||||
|
out = append(out, s.snapshot(live))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) pushStatus(s *repoState) {
|
||||||
|
e.publish(map[string]any{"type": "status", "repo": s.cfg.Name, "data": s.snapshot(false)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 启动 / 停止 ----------
|
||||||
|
|
||||||
|
func (e *Engine) Start(name string) error {
|
||||||
|
s := e.get(name)
|
||||||
|
if s == nil {
|
||||||
|
return fmt.Errorf("配置不存在: %s", name)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.running {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !isRepo(s.cfg.RepoPath) {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return fmt.Errorf("仓库路径无效: %s", s.cfg.RepoPath)
|
||||||
|
}
|
||||||
|
s.stopCh = make(chan struct{})
|
||||||
|
s.running = true
|
||||||
|
s.status = "ok"
|
||||||
|
ch := s.stopCh
|
||||||
|
s.mu.Unlock()
|
||||||
|
go e.loop(s, ch)
|
||||||
|
e.logf(s, "info", "监控已启动")
|
||||||
|
e.pushStatus(s)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) loop(s *repoState, stopCh chan struct{}) {
|
||||||
|
s.mu.Lock()
|
||||||
|
interval := s.cfg.Interval
|
||||||
|
cronStr := s.cfg.Cron
|
||||||
|
s.mu.Unlock()
|
||||||
|
if interval < 5 {
|
||||||
|
interval = 5
|
||||||
|
}
|
||||||
|
|
||||||
|
var spec *cronSpec
|
||||||
|
if cronStr != "" {
|
||||||
|
sp, err := parseCron(cronStr)
|
||||||
|
if err != nil {
|
||||||
|
e.logf(s, "error", "cron 表达式无效: %v(回退为固定间隔 %d 秒)", err, interval)
|
||||||
|
} else {
|
||||||
|
spec = sp
|
||||||
|
e.logf(s, "info", "按 cron 计划运行: %s", cronStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if spec == nil {
|
||||||
|
e.syncOnce(s, "启动检查")
|
||||||
|
}
|
||||||
|
for {
|
||||||
|
var wait time.Duration
|
||||||
|
trigger := "定时检查"
|
||||||
|
if spec != nil {
|
||||||
|
wait = time.Until(cronNext(spec, time.Now()))
|
||||||
|
trigger = "cron 触发"
|
||||||
|
} else {
|
||||||
|
wait = time.Duration(interval) * time.Second
|
||||||
|
}
|
||||||
|
if wait < time.Second {
|
||||||
|
wait = time.Second
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(wait)
|
||||||
|
select {
|
||||||
|
case <-stopCh:
|
||||||
|
timer.Stop()
|
||||||
|
e.logf(s, "info", "监控已停止")
|
||||||
|
e.pushStatus(s)
|
||||||
|
return
|
||||||
|
case <-timer.C:
|
||||||
|
e.syncOnce(s, trigger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Stop(name string) error {
|
||||||
|
s := e.get(name)
|
||||||
|
if s == nil {
|
||||||
|
return fmt.Errorf("配置不存在: %s", name)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if !s.running {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
s.running = false
|
||||||
|
s.status = "idle"
|
||||||
|
close(s.stopCh)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) StartAll() {
|
||||||
|
for _, name := range e.sortedNames() {
|
||||||
|
if err := e.Start(name); err != nil {
|
||||||
|
log.Printf("自动启动 %s 失败: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) SyncNow(name string) error {
|
||||||
|
s := e.get(name)
|
||||||
|
if s == nil {
|
||||||
|
return fmt.Errorf("配置不存在: %s", name)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if !s.running {
|
||||||
|
return fmt.Errorf("监控未启动,请先启动")
|
||||||
|
}
|
||||||
|
if s.syncing {
|
||||||
|
return fmt.Errorf("正在同步中,请稍候")
|
||||||
|
}
|
||||||
|
go e.syncOnce(s, "手动触发")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 同步核心 ----------
|
||||||
|
|
||||||
|
func (e *Engine) withRetry(s *repoState, label string, fn func() (string, error)) (string, error) {
|
||||||
|
maxAttempts, sleep, retryCount := 1, 0, 0
|
||||||
|
s.mu.Lock()
|
||||||
|
cfg := s.cfg
|
||||||
|
s.mu.Unlock()
|
||||||
|
if cfg.AuthMethod == "https" && cfg.RetryCount > 0 {
|
||||||
|
maxAttempts = cfg.RetryCount + 1
|
||||||
|
sleep = cfg.RetryInterval
|
||||||
|
retryCount = cfg.RetryCount
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||||
|
if attempt > 1 {
|
||||||
|
e.logf(s, "warn", "%s 失败,%d 秒后重试(第 %d/%d 次)", label, sleep, attempt-1, retryCount)
|
||||||
|
time.Sleep(time.Duration(sleep) * time.Second)
|
||||||
|
}
|
||||||
|
out, err := fn()
|
||||||
|
if err == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
return "", lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) setFail(s *repoState, msg string) {
|
||||||
|
now := time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
s.mu.Lock()
|
||||||
|
s.status = "error"
|
||||||
|
s.lastError = msg
|
||||||
|
s.lastSync = &SyncResult{Time: now, Result: "error", Detail: msg}
|
||||||
|
s.mu.Unlock()
|
||||||
|
e.logf(s, "error", "%s", msg)
|
||||||
|
e.pushStatus(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) setOK(s *repoState, detail string) {
|
||||||
|
now := time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
s.mu.Lock()
|
||||||
|
s.status = "ok"
|
||||||
|
s.lastError = ""
|
||||||
|
s.lastSync = &SyncResult{Time: now, Result: "ok", Detail: detail}
|
||||||
|
s.mu.Unlock()
|
||||||
|
e.pushStatus(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureRemotes 把配置中的远程同步到仓库(缺失则添加,URL 变化则更新)
|
||||||
|
func (e *Engine) ensureRemotes(s *repoState, cfg *RepoConfig) {
|
||||||
|
for _, r := range cfg.Remotes {
|
||||||
|
out, err := gitRun(cfg.RepoPath, 15*time.Second, "remote", "get-url", r.Name)
|
||||||
|
if err != nil {
|
||||||
|
if _, aerr := gitRun(cfg.RepoPath, 15*time.Second, "remote", "add", r.Name, r.URL); aerr != nil {
|
||||||
|
e.logf(s, "warn", "添加远程 %s 失败: %v", r.Name, aerr)
|
||||||
|
} else {
|
||||||
|
e.logf(s, "info", "已添加远程 %s → %s", r.Name, r.URL)
|
||||||
|
}
|
||||||
|
} else if strings.TrimSpace(out) != r.URL {
|
||||||
|
if _, uerr := gitRun(cfg.RepoPath, 15*time.Second, "remote", "set-url", r.Name, r.URL); uerr != nil {
|
||||||
|
e.logf(s, "warn", "更新远程 %s URL 失败: %v", r.Name, uerr)
|
||||||
|
} else {
|
||||||
|
e.logf(s, "info", "已更新远程 %s URL → %s", r.Name, r.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) refreshCounts(s *repoState, cfg *RepoConfig, remote string) {
|
||||||
|
ahead, behind, err := aheadBehind(cfg.RepoPath, remote, cfg.Branch)
|
||||||
|
dirty := dirtyCount(cfg.RepoPath)
|
||||||
|
s.mu.Lock()
|
||||||
|
if err == nil {
|
||||||
|
s.ahead, s.behind = ahead, behind
|
||||||
|
}
|
||||||
|
s.dirty = dirty
|
||||||
|
s.mu.Unlock()
|
||||||
|
e.pushStatus(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) syncOnce(s *repoState, trigger string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.syncing {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.syncing = true
|
||||||
|
cfg := s.cfg
|
||||||
|
s.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.syncing = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if !isRepo(cfg.RepoPath) {
|
||||||
|
e.setFail(s, "仓库路径无效: "+cfg.RepoPath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.ensureRemotes(s, cfg)
|
||||||
|
remote := firstRemoteName(cfg)
|
||||||
|
if remote == "" {
|
||||||
|
e.setFail(s, "未配置远程仓库")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
e.logf(s, "info", "=== 开始同步(%s,模式 %s)===", trigger, cfg.Mode)
|
||||||
|
|
||||||
|
if _, err := e.withRetry(s, "fetch", func() (string, error) {
|
||||||
|
return gitRun(cfg.RepoPath, 90*time.Second, "fetch", remote)
|
||||||
|
}); err != nil {
|
||||||
|
e.setFail(s, "fetch 失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dirty := dirtyCount(cfg.RepoPath)
|
||||||
|
if dirty > 0 {
|
||||||
|
if cfg.AutoCommit {
|
||||||
|
e.logf(s, "info", "检测到 %d 个未提交改动,执行自动提交", dirty)
|
||||||
|
if _, cerr := gitRun(cfg.RepoPath, 60*time.Second, "add", "-A"); cerr != nil {
|
||||||
|
e.setFail(s, "自动提交(git add)失败: "+cerr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cmsg := "git-sync-web 自动提交 " + time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
if _, cerr := gitRun(cfg.RepoPath, 120*time.Second, "commit", "-m", cmsg); cerr != nil {
|
||||||
|
e.setFail(s, "自动提交失败: "+cerr.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e.logf(s, "info", "自动提交完成,将随本轮同步推送")
|
||||||
|
dirty = 0
|
||||||
|
} else {
|
||||||
|
e.logf(s, "warn", "工作区有 %d 个未提交改动(未开启自动提交)", dirty)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var ahead, behind int
|
||||||
|
var err error
|
||||||
|
if remoteRefExists(cfg.RepoPath, remote, cfg.Branch) {
|
||||||
|
ahead, behind, err = aheadBehind(cfg.RepoPath, remote, cfg.Branch)
|
||||||
|
if err != nil {
|
||||||
|
e.setFail(s, "获取领先/落后信息失败: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 远程分支不存在(空仓库或分支名不一致):把本地提交视为全部领先,推送时建立远程分支
|
||||||
|
if out, cerr := gitRun(cfg.RepoPath, 15*time.Second, "rev-list", "--count", "HEAD"); cerr == nil {
|
||||||
|
ahead, _ = strconv.Atoi(strings.TrimSpace(out))
|
||||||
|
}
|
||||||
|
e.logf(s, "warn", "远程分支 %s/%s 不存在(远程仓库可能为空或分支名不一致),将推送本地 %d 个提交以建立远程分支", remote, cfg.Branch, ahead)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.ahead, s.behind, s.dirty = ahead, behind, dirty
|
||||||
|
s.mu.Unlock()
|
||||||
|
e.pushStatus(s)
|
||||||
|
|
||||||
|
aborted := false
|
||||||
|
if ahead > 0 && (cfg.Mode == "push" || cfg.Mode == "both") {
|
||||||
|
if !e.pushAll(s, cfg, ahead) {
|
||||||
|
aborted = true // 推送失败,本轮终止,避免继续拉取加剧分叉
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !aborted && behind > 0 && (cfg.Mode == "pull" || cfg.Mode == "both") {
|
||||||
|
if !e.pullRemote(s, cfg, remote, behind) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ahead, behind, _ = aheadBehind(cfg.RepoPath, remote, cfg.Branch)
|
||||||
|
now := time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
s.mu.Lock()
|
||||||
|
s.ahead, s.behind = ahead, behind
|
||||||
|
s.status = "ok"
|
||||||
|
s.lastError = ""
|
||||||
|
s.lastSync = &SyncResult{Time: now, Result: "ok", Detail: "同步完成"}
|
||||||
|
s.mu.Unlock()
|
||||||
|
e.logf(s, "info", "=== 同步完成(本地领先 %d,落后 %d)===", ahead, behind)
|
||||||
|
e.pushStatus(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) pushAll(s *repoState, cfg *RepoConfig, n int) bool {
|
||||||
|
allOK := true
|
||||||
|
for _, r := range cfg.Remotes {
|
||||||
|
_, err := e.withRetry(s, "push → "+r.Name, func() (string, error) {
|
||||||
|
return gitRun(cfg.RepoPath, 300*time.Second, "push", r.Name, cfg.Branch)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
e.logf(s, "error", "推送至 %s 失败: %v", r.Name, err)
|
||||||
|
allOK = false
|
||||||
|
} else {
|
||||||
|
e.logf(s, "info", "推送至 %s 成功(%d 个提交)", r.Name, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if allOK {
|
||||||
|
e.stat(s, "push", "success", n)
|
||||||
|
} else {
|
||||||
|
e.stat(s, "push", "failed", n)
|
||||||
|
}
|
||||||
|
return allOK
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) pullRemote(s *repoState, cfg *RepoConfig, remote string, n int) bool {
|
||||||
|
args := []string{"pull"}
|
||||||
|
if cfg.ConflictStrategy == "rebase" {
|
||||||
|
args = append(args, "--rebase")
|
||||||
|
}
|
||||||
|
args = append(args, remote, cfg.Branch, "--no-edit")
|
||||||
|
_, err := gitRun(cfg.RepoPath, 300*time.Second, args...)
|
||||||
|
if err == nil {
|
||||||
|
e.stat(s, "pull", "success", n)
|
||||||
|
e.logf(s, "info", "拉取成功(%d 个提交)", n)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
files, cerr := conflictFiles(cfg.RepoPath)
|
||||||
|
if cerr == nil && len(files) > 0 {
|
||||||
|
if cfg.ConflictStrategy == "abort" {
|
||||||
|
_, _ = abortMerge(cfg.RepoPath)
|
||||||
|
e.stat(s, "pull", "failed", n)
|
||||||
|
e.setFail(s, "拉取产生冲突,已按策略(abort)自动放弃合并")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
now := time.Now().Format("2006-01-02 15:04:05")
|
||||||
|
s.mu.Lock()
|
||||||
|
s.status = "conflict"
|
||||||
|
s.conflicts = files
|
||||||
|
s.lastError = "拉取产生冲突,等待在冲突处理台解决"
|
||||||
|
s.lastSync = &SyncResult{Time: now, Result: "conflict", Detail: s.lastError}
|
||||||
|
s.mu.Unlock()
|
||||||
|
e.stat(s, "pull", "conflict", n)
|
||||||
|
e.logf(s, "error", "拉取产生冲突:%s(请在详情 → 冲突中处理)", strings.Join(files, ", "))
|
||||||
|
e.pushStatus(s)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
e.stat(s, "pull", "failed", n)
|
||||||
|
e.setFail(s, "拉取失败: "+err.Error())
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 手动操作 ----------
|
||||||
|
|
||||||
|
func (e *Engine) ManualGit(name, action string) error {
|
||||||
|
s := e.get(name)
|
||||||
|
if s == nil {
|
||||||
|
return fmt.Errorf("配置不存在: %s", name)
|
||||||
|
}
|
||||||
|
if !isRepo(s.cfg.RepoPath) {
|
||||||
|
return fmt.Errorf("仓库路径无效: %s", s.cfg.RepoPath)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
if s.syncing {
|
||||||
|
s.mu.Unlock()
|
||||||
|
return fmt.Errorf("正在同步中,请稍候")
|
||||||
|
}
|
||||||
|
s.syncing = true
|
||||||
|
cfg := s.cfg
|
||||||
|
s.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.syncing = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
remote := firstRemoteName(cfg)
|
||||||
|
if remote == "" {
|
||||||
|
return fmt.Errorf("未配置远程仓库")
|
||||||
|
}
|
||||||
|
e.ensureRemotes(s, cfg)
|
||||||
|
|
||||||
|
switch action {
|
||||||
|
case "fetch":
|
||||||
|
e.logf(s, "info", "手动 fetch…")
|
||||||
|
if _, err := gitRun(cfg.RepoPath, 90*time.Second, "fetch", remote); err != nil {
|
||||||
|
e.logf(s, "error", "fetch 失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
e.logf(s, "info", "fetch 完成")
|
||||||
|
e.refreshCounts(s, cfg, remote)
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "push":
|
||||||
|
e.logf(s, "info", "手动 push…")
|
||||||
|
ahead, behind, _ := aheadBehind(cfg.RepoPath, remote, cfg.Branch)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.behind = behind
|
||||||
|
s.mu.Unlock()
|
||||||
|
if ahead == 0 {
|
||||||
|
e.logf(s, "info", "本地无领先提交,无需推送")
|
||||||
|
e.refreshCounts(s, cfg, remote)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !e.pushAll(s, cfg, ahead) {
|
||||||
|
e.setFail(s, "推送失败")
|
||||||
|
return fmt.Errorf("推送失败")
|
||||||
|
}
|
||||||
|
e.refreshCounts(s, cfg, remote)
|
||||||
|
e.setOK(s, "手动推送完成")
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case "pull":
|
||||||
|
e.logf(s, "info", "手动 pull…")
|
||||||
|
if _, err := gitRun(cfg.RepoPath, 90*time.Second, "fetch", remote); err != nil {
|
||||||
|
e.logf(s, "error", "fetch 失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, behind, _ := aheadBehind(cfg.RepoPath, remote, cfg.Branch)
|
||||||
|
if behind == 0 {
|
||||||
|
e.logf(s, "info", "远程无新提交,已是最新")
|
||||||
|
e.refreshCounts(s, cfg, remote)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !e.pullRemote(s, cfg, remote, behind) {
|
||||||
|
return fmt.Errorf("拉取失败或存在冲突")
|
||||||
|
}
|
||||||
|
e.refreshCounts(s, cfg, remote)
|
||||||
|
e.setOK(s, "手动拉取完成")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("未知操作: %s", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 冲突处理 ----------
|
||||||
|
|
||||||
|
func (e *Engine) ConflictAbort(name string) error {
|
||||||
|
s := e.get(name)
|
||||||
|
if s == nil {
|
||||||
|
return fmt.Errorf("配置不存在: %s", name)
|
||||||
|
}
|
||||||
|
if _, err := abortMerge(s.cfg.RepoPath); err != nil {
|
||||||
|
e.logf(s, "error", "放弃合并失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.conflicts = nil
|
||||||
|
s.status = "ok"
|
||||||
|
s.lastError = ""
|
||||||
|
s.mu.Unlock()
|
||||||
|
e.logf(s, "warn", "已放弃本次合并/变基,仓库回到冲突前状态")
|
||||||
|
e.pushStatus(s)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) ConflictResolve(name string) error {
|
||||||
|
s := e.get(name)
|
||||||
|
if s == nil {
|
||||||
|
return fmt.Errorf("配置不存在: %s", name)
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
rebase := s.cfg.ConflictStrategy == "rebase"
|
||||||
|
path := s.cfg.RepoPath
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
if err := resolveDone(path, rebase); err != nil {
|
||||||
|
e.logf(s, "error", "继续合并/变基失败: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
files, _ := conflictFiles(path)
|
||||||
|
if len(files) > 0 {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.conflicts = files
|
||||||
|
s.mu.Unlock()
|
||||||
|
e.logf(s, "error", "仍有未解决的冲突:%s", strings.Join(files, ", "))
|
||||||
|
e.pushStatus(s)
|
||||||
|
return fmt.Errorf("仍有未解决的冲突,请在本地标记已解决后再试")
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.conflicts = nil
|
||||||
|
s.status = "ok"
|
||||||
|
s.lastError = ""
|
||||||
|
s.mu.Unlock()
|
||||||
|
e.logf(s, "info", "冲突已全部解决,合并流程完成")
|
||||||
|
e.pushStatus(s)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 配置 CRUD ----------
|
||||||
|
|
||||||
|
func (e *Engine) Create(c *RepoConfig) error {
|
||||||
|
if err := validateRepoConfig(e.cfgDir, c, true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := saveRepoConfig(e.cfgDir, c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s := &repoState{cfg: c, status: "idle"}
|
||||||
|
e.mu.Lock()
|
||||||
|
e.states[c.Name] = s
|
||||||
|
e.mu.Unlock()
|
||||||
|
e.logf(s, "info", "配置已创建(路径 %s)", c.RepoPath)
|
||||||
|
e.pushStatus(s)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Update(c *RepoConfig) error {
|
||||||
|
s := e.get(c.Name)
|
||||||
|
if s == nil {
|
||||||
|
return fmt.Errorf("配置不存在: %s", c.Name)
|
||||||
|
}
|
||||||
|
if err := validateRepoConfig(e.cfgDir, c, false); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
wasRunning := s.running
|
||||||
|
s.mu.Unlock()
|
||||||
|
if wasRunning {
|
||||||
|
_ = e.Stop(c.Name)
|
||||||
|
}
|
||||||
|
if err := saveRepoConfig(e.cfgDir, c); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
s.cfg = c
|
||||||
|
s.status = "idle"
|
||||||
|
s.conflicts = nil
|
||||||
|
s.lastError = ""
|
||||||
|
s.ahead, s.behind, s.dirty = 0, 0, 0
|
||||||
|
s.lastSync = nil
|
||||||
|
s.mu.Unlock()
|
||||||
|
e.logf(s, "info", "配置已更新")
|
||||||
|
if wasRunning {
|
||||||
|
return e.Start(c.Name)
|
||||||
|
}
|
||||||
|
e.pushStatus(s)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) Delete(name string) error {
|
||||||
|
s := e.get(name)
|
||||||
|
if s == nil {
|
||||||
|
return fmt.Errorf("配置不存在: %s", name)
|
||||||
|
}
|
||||||
|
_ = e.Stop(name)
|
||||||
|
s.mu.Lock()
|
||||||
|
s.running = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
_ = os.Remove(filepath.Join(e.cfgDir, name+".conf"))
|
||||||
|
e.mu.Lock()
|
||||||
|
delete(e.states, name)
|
||||||
|
e.mu.Unlock()
|
||||||
|
log.Printf("配置 %s 已删除", name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 日志 / 统计 / 冲突 diff 读取 ----------
|
||||||
|
|
||||||
|
type StatRow struct {
|
||||||
|
Time string `json:"time"`
|
||||||
|
Action string `json:"action"`
|
||||||
|
Result string `json:"result"`
|
||||||
|
Commits int `json:"commits"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConflictFile struct {
|
||||||
|
File string `json:"file"`
|
||||||
|
Diff string `json:"diff"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) ReadLog(name string, n int) []string {
|
||||||
|
s := e.get(name)
|
||||||
|
if s == nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
logPath := s.cfg.LogFile
|
||||||
|
if logPath == "" {
|
||||||
|
logPath = filepath.Join(e.logDir, name+".log")
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(logPath)
|
||||||
|
if err != nil {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||||
|
if len(lines) > n {
|
||||||
|
lines = lines[len(lines)-n:]
|
||||||
|
}
|
||||||
|
return lines
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) ReadStats(name string) []StatRow {
|
||||||
|
f, err := os.Open(filepath.Join(e.statsDir, name+".csv"))
|
||||||
|
if err != nil {
|
||||||
|
return []StatRow{}
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
records, err := csv.NewReader(f).ReadAll()
|
||||||
|
if err != nil {
|
||||||
|
return []StatRow{}
|
||||||
|
}
|
||||||
|
rows := []StatRow{}
|
||||||
|
for i, rec := range records {
|
||||||
|
if i == 0 || len(rec) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c, _ := strconv.Atoi(rec[3])
|
||||||
|
rows = append(rows, StatRow{Time: rec[0], Action: rec[1], Result: rec[2], Commits: c})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) ConflictDiffs(name string) []ConflictFile {
|
||||||
|
s := e.get(name)
|
||||||
|
if s == nil {
|
||||||
|
return []ConflictFile{}
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
files := append([]string(nil), s.conflicts...)
|
||||||
|
path := s.cfg.RepoPath
|
||||||
|
s.mu.Unlock()
|
||||||
|
if len(files) == 0 {
|
||||||
|
files, _ = conflictFiles(path)
|
||||||
|
}
|
||||||
|
out := []ConflictFile{}
|
||||||
|
for _, f := range files {
|
||||||
|
d, err := gitRun(path, 30*time.Second, "diff", "--", f)
|
||||||
|
if err != nil {
|
||||||
|
d = err.Error()
|
||||||
|
}
|
||||||
|
if len(d) > 65536 {
|
||||||
|
d = d[:65536] + "\n…(diff 过长已截断)"
|
||||||
|
}
|
||||||
|
out = append(out, ConflictFile{File: f, Diff: d})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 提交历史 ----------
|
||||||
|
|
||||||
|
var hashRe = regexp.MustCompile(`^[0-9a-fA-F]{6,40}$`)
|
||||||
|
|
||||||
|
type Commit struct {
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
Author string `json:"author"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) ReadCommits(name string, limit int) []Commit {
|
||||||
|
s := e.get(name)
|
||||||
|
if s == nil {
|
||||||
|
return []Commit{}
|
||||||
|
}
|
||||||
|
if limit <= 0 || limit > 200 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
out, err := gitRun(s.cfg.RepoPath, 30*time.Second,
|
||||||
|
"log", "-n", strconv.Itoa(limit),
|
||||||
|
"--date=format:%Y-%m-%d %H:%M",
|
||||||
|
"--pretty=format:%H%x1f%an%x1f%ad%x1f%s")
|
||||||
|
if err != nil {
|
||||||
|
return []Commit{}
|
||||||
|
}
|
||||||
|
commits := []Commit{}
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
parts := strings.Split(line, "\x1f")
|
||||||
|
if len(parts) != 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
commits = append(commits, Commit{Hash: parts[0], Author: parts[1], Date: parts[2], Subject: parts[3]})
|
||||||
|
}
|
||||||
|
return commits
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Engine) ReadCommitDiff(name, hash string) (string, error) {
|
||||||
|
s := e.get(name)
|
||||||
|
if s == nil {
|
||||||
|
return "", fmt.Errorf("配置不存在: %s", name)
|
||||||
|
}
|
||||||
|
if !hashRe.MatchString(hash) {
|
||||||
|
return "", fmt.Errorf("无效的提交哈希")
|
||||||
|
}
|
||||||
|
out, err := gitRun(s.cfg.RepoPath, 60*time.Second, "show", "--no-color", hash)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(out) > 131072 {
|
||||||
|
out = out[:131072] + "\n…(内容过长已截断)"
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// gitRun 在指定仓库目录执行 git 命令,返回合并后的输出。
|
||||||
|
// GIT_TERMINAL_PROMPT=0 避免认证弹窗卡死后台进程。
|
||||||
|
func gitRun(repoPath string, timeout time.Duration, args ...string) (string, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
full := append([]string{"-C", repoPath}, args...)
|
||||||
|
cmd := exec.CommandContext(ctx, "git", full...)
|
||||||
|
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
|
||||||
|
var buf bytes.Buffer
|
||||||
|
cmd.Stdout = &buf
|
||||||
|
cmd.Stderr = &buf
|
||||||
|
err := cmd.Run()
|
||||||
|
out := strings.TrimSpace(buf.String())
|
||||||
|
if err != nil {
|
||||||
|
if ctx.Err() == context.DeadlineExceeded {
|
||||||
|
return out, fmt.Errorf("命令超时: git %s", strings.Join(args, " "))
|
||||||
|
}
|
||||||
|
msg := out
|
||||||
|
if msg == "" {
|
||||||
|
msg = err.Error()
|
||||||
|
}
|
||||||
|
return out, errors.New(msg)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func isRepo(path string) bool {
|
||||||
|
if path == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := os.Stat(filepath.Join(path, ".git"))
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentBranch(path string) (string, error) {
|
||||||
|
return gitRun(path, 15*time.Second, "rev-parse", "--abbrev-ref", "HEAD")
|
||||||
|
}
|
||||||
|
|
||||||
|
// aheadBehind 返回 (领先, 落后) 远程跟踪分支的提交数(需先 fetch 才准确)
|
||||||
|
func aheadBehind(path, remote, branch string) (int, int, error) {
|
||||||
|
out, err := gitRun(path, 30*time.Second, "rev-list", "--left-right", "--count", "HEAD..."+remote+"/"+branch)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
parts := strings.Fields(out)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return 0, 0, fmt.Errorf("无法解析 rev-list 输出: %q", out)
|
||||||
|
}
|
||||||
|
a, _ := strconv.Atoi(parts[0])
|
||||||
|
b, _ := strconv.Atoi(parts[1])
|
||||||
|
return a, b, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// remoteRefExists 检查远程跟踪引用是否存在(远程仓库为空或分支名不一致时为 false)
|
||||||
|
func remoteRefExists(path, remote, branch string) bool {
|
||||||
|
_, err := gitRun(path, 15*time.Second, "rev-parse", "--verify", "-q", remote+"/"+branch)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirtyCount(path string) int {
|
||||||
|
out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
if strings.TrimSpace(line) != "" {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflictCodes = map[string]bool{
|
||||||
|
"DD": true, "AU": true, "UD": true, "UA": true, "DU": true, "AA": true, "UU": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func conflictFiles(path string) ([]string, error) {
|
||||||
|
out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var files []string
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
if len(line) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !conflictCodes[line[:2]] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := strings.TrimSpace(line[3:])
|
||||||
|
if i := strings.Index(p, "->"); i >= 0 {
|
||||||
|
p = strings.TrimSpace(p[i+2:])
|
||||||
|
}
|
||||||
|
if p != "" && !seen[p] {
|
||||||
|
seen[p] = true
|
||||||
|
files = append(files, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func abortMerge(path string) (string, error) {
|
||||||
|
if out, err := gitRun(path, 30*time.Second, "rebase", "--abort"); err == nil {
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
return gitRun(path, 30*time.Second, "merge", "--abort")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveDone 在用户网页上标记"已解决"后调用:暂存全部 → 继续 merge/rebase 流程
|
||||||
|
func resolveDone(path string, rebase bool) error {
|
||||||
|
if _, err := gitRun(path, 30*time.Second, "add", "-A"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if rebase {
|
||||||
|
_, err := gitRun(path, 120*time.Second, "-c", "core.editor=true", "rebase", "--continue")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := gitRun(path, 15*time.Second, "rev-parse", "-q", "--verify", "MERGE_HEAD"); err == nil {
|
||||||
|
_, err := gitRun(path, 120*time.Second, "commit", "--no-edit")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[2026-09-08 17:12:15] 配置已创建(路径 C:/OtherFiles/Github/git-auto-sync/git-sync-web)
|
||||||
|
[2026-09-08 17:12:21] 监控已启动
|
||||||
|
[2026-09-08 17:12:21] 已添加远程 origin → https://git.shiroko.one/tamakyi/github-auto-sync-golang
|
||||||
|
[2026-09-08 17:12:21] === 开始同步(启动检查,模式 both)===
|
||||||
|
[2026-09-08 17:12:26] 检测到 12 个未提交改动,执行自动提交
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"flag"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var staticFS embed.FS
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
baseDir := flag.String("dir", ".", "项目根目录(包含 configs/ logs/ stats/)")
|
||||||
|
addr := flag.String("addr", "127.0.0.1:5757", "监听地址")
|
||||||
|
password := flag.String("password", "", "访问密码(为空则不启用鉴权)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
engine, err := NewEngine(*baseDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("初始化失败: %v", err)
|
||||||
|
}
|
||||||
|
engine.LoadAll()
|
||||||
|
engine.StartAll()
|
||||||
|
|
||||||
|
sub, err := fs.Sub(staticFS, "static")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("读取内嵌静态资源失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := NewServer(engine, sub, *password)
|
||||||
|
log.Printf("Git Sync Web 已启动: http://%s(鉴权:%t)", *addr, *password != "")
|
||||||
|
if err := http.ListenAndServe(*addr, srv); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Server struct {
|
||||||
|
engine *Engine
|
||||||
|
mux *http.ServeMux
|
||||||
|
password string
|
||||||
|
|
||||||
|
tokenMu sync.Mutex
|
||||||
|
tokens map[string]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewServer(e *Engine, static fs.FS, password string) *Server {
|
||||||
|
s := &Server{
|
||||||
|
engine: e,
|
||||||
|
mux: http.NewServeMux(),
|
||||||
|
password: password,
|
||||||
|
tokens: map[string]time.Time{},
|
||||||
|
}
|
||||||
|
mux := s.mux
|
||||||
|
mux.HandleFunc("GET /api/status", s.handleStatus)
|
||||||
|
mux.HandleFunc("GET /api/events", s.handleEvents)
|
||||||
|
mux.HandleFunc("POST /api/login", s.handleLogin)
|
||||||
|
mux.HandleFunc("POST /api/logout", s.handleLogout)
|
||||||
|
mux.HandleFunc("POST /api/repos", s.handleCreate)
|
||||||
|
mux.HandleFunc("GET /api/repos/{name}", s.wrapRepo(s.handleGet))
|
||||||
|
mux.HandleFunc("PUT /api/repos/{name}", s.wrapRepo(s.handleUpdate))
|
||||||
|
mux.HandleFunc("DELETE /api/repos/{name}", s.wrapRepo(s.handleDelete))
|
||||||
|
mux.HandleFunc("POST /api/repos/{name}/start", s.wrapRepo(s.startRepo))
|
||||||
|
mux.HandleFunc("POST /api/repos/{name}/stop", s.wrapRepo(s.stopRepo))
|
||||||
|
mux.HandleFunc("POST /api/repos/{name}/sync", s.wrapRepo(s.syncRepo))
|
||||||
|
mux.HandleFunc("POST /api/repos/{name}/action", s.wrapRepo(s.actionRepo))
|
||||||
|
mux.HandleFunc("GET /api/repos/{name}/conflicts", s.wrapRepo(s.conflictsRepo))
|
||||||
|
mux.HandleFunc("GET /api/repos/{name}/log", s.wrapRepo(s.logRepo))
|
||||||
|
mux.HandleFunc("GET /api/repos/{name}/stats", s.wrapRepo(s.statsRepo))
|
||||||
|
mux.HandleFunc("GET /api/repos/{name}/commits", s.wrapRepo(s.commitsRepo))
|
||||||
|
mux.HandleFunc("GET /api/repos/{name}/commits/{hash}", s.wrapRepo(s.commitDiffRepo))
|
||||||
|
mux.Handle("/", http.FileServer(http.FS(static)))
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// 访问密码保护:静态资源开放,/api/* 需要会话(登录接口除外)
|
||||||
|
if s.password != "" && strings.HasPrefix(r.URL.Path, "/api/") &&
|
||||||
|
r.URL.Path != "/api/login" && !s.validToken(r) {
|
||||||
|
writeErr(w, 401, "需要登录")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mux.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) validToken(r *http.Request) bool {
|
||||||
|
c, err := r.Cookie("git_sync_token")
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
s.tokenMu.Lock()
|
||||||
|
defer s.tokenMu.Unlock()
|
||||||
|
exp, ok := s.tokens[c.Value]
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if time.Now().After(exp) {
|
||||||
|
delete(s.tokens, c.Value)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, 400, "请求体格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if subtle.ConstantTimeCompare([]byte(req.Password), []byte(s.password)) != 1 {
|
||||||
|
writeErr(w, 401, "密码错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
writeErr(w, 500, "生成会话失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
token := hex.EncodeToString(buf)
|
||||||
|
s.tokenMu.Lock()
|
||||||
|
s.tokens[token] = time.Now().Add(30 * 24 * time.Hour)
|
||||||
|
s.tokenMu.Unlock()
|
||||||
|
http.SetCookie(w, &http.Cookie{
|
||||||
|
Name: "git_sync_token", Value: token, Path: "/",
|
||||||
|
HttpOnly: true, MaxAge: 30 * 24 * 3600,
|
||||||
|
})
|
||||||
|
writeJSON(w, 200, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if c, err := r.Cookie("git_sync_token"); err == nil {
|
||||||
|
s.tokenMu.Lock()
|
||||||
|
delete(s.tokens, c.Value)
|
||||||
|
s.tokenMu.Unlock()
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: "git_sync_token", Value: "", Path: "/", MaxAge: -1})
|
||||||
|
writeJSON(w, 200, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
|
w.WriteHeader(code)
|
||||||
|
_ = json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeErr(w http.ResponseWriter, code int, msg string) {
|
||||||
|
writeJSON(w, code, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) wrapRepo(h func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
if s.engine.get(name) == nil {
|
||||||
|
writeErr(w, 404, "配置不存在: "+name)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h(w, r, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, 200, map[string]any{"repos": s.engine.Snapshots(true)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleGet(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
if st := s.engine.get(name); st != nil {
|
||||||
|
writeJSON(w, 200, st.snapshot(true))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeErr(w, 404, "配置不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var cfg RepoConfig
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||||
|
writeErr(w, 400, "请求体格式错误: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.engine.Create(&cfg); err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"ok": true, "name": cfg.Name})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
var cfg RepoConfig
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||||
|
writeErr(w, 400, "请求体格式错误: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.Name = name
|
||||||
|
if err := s.engine.Update(&cfg); err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
if err := s.engine.Delete(name); err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) startRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
if err := s.engine.Start(name); err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) stopRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
if err := s.engine.Stop(name); err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) syncRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
if err := s.engine.SyncNow(name); err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) actionRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
var req struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeErr(w, 400, "请求体格式错误")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch req.Action {
|
||||||
|
case "abort":
|
||||||
|
if err := s.engine.ConflictAbort(name); err != nil {
|
||||||
|
writeErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "resolve":
|
||||||
|
if err := s.engine.ConflictResolve(name); err != nil {
|
||||||
|
writeErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case "fetch", "pull", "push":
|
||||||
|
if err := s.engine.ManualGit(name, req.Action); err != nil {
|
||||||
|
writeErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
writeErr(w, 400, "未知操作: "+req.Action)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"ok": true})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) conflictsRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
writeJSON(w, 200, s.engine.ConflictDiffs(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) logRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
n := 300
|
||||||
|
if v, err := strconv.Atoi(r.URL.Query().Get("lines")); err == nil && v > 0 && v <= 5000 {
|
||||||
|
n = v
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"lines": s.engine.ReadLog(name, n)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) statsRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
writeJSON(w, 200, s.engine.ReadStats(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) commitsRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
limit := 50
|
||||||
|
if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 && v <= 200 {
|
||||||
|
limit = v
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, s.engine.ReadCommits(name, limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) commitDiffRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||||
|
diff, err := s.engine.ReadCommitDiff(name, r.PathValue("hash"))
|
||||||
|
if err != nil {
|
||||||
|
writeErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]string{"diff": diff})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
fl, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
id, ch := s.engine.Bus().Subscribe()
|
||||||
|
defer s.engine.Bus().Unsubscribe(id)
|
||||||
|
|
||||||
|
if snap, err := json.Marshal(map[string]any{"type": "snapshot", "data": s.engine.Snapshots(true)}); err == nil {
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", snap)
|
||||||
|
fl.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
tick := time.NewTicker(25 * time.Second)
|
||||||
|
defer tick.Stop()
|
||||||
|
ctx := r.Context()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-tick.C:
|
||||||
|
fmt.Fprint(w, ": ping\n\n")
|
||||||
|
fl.Flush()
|
||||||
|
case data, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||||
|
fl.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+419
@@ -0,0 +1,419 @@
|
|||||||
|
const $ = (sel) => document.querySelector(sel);
|
||||||
|
|
||||||
|
const STATUS = {
|
||||||
|
ok: { text: '正常', cls: 'st-ok' },
|
||||||
|
error: { text: '异常', cls: 'st-error' },
|
||||||
|
conflict: { text: '冲突', cls: 'st-conflict' },
|
||||||
|
running: { text: '同步中', cls: 'st-running' },
|
||||||
|
idle: { text: '未启动', cls: 'st-idle' },
|
||||||
|
};
|
||||||
|
const RESULT_TEXT = { ok: '成功', error: '失败', conflict: '冲突' };
|
||||||
|
|
||||||
|
let repos = [];
|
||||||
|
let current = null;
|
||||||
|
let creating = false;
|
||||||
|
let chart = null;
|
||||||
|
|
||||||
|
// ---------- 基础 ----------
|
||||||
|
function esc(s) {
|
||||||
|
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(url, opts) {
|
||||||
|
const res = await fetch(url, opts);
|
||||||
|
if (res.status === 401) {
|
||||||
|
showLogin();
|
||||||
|
throw new Error('需要登录');
|
||||||
|
}
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
if (!res.ok) throw new Error(data.error || res.statusText);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
const post = (url) => api(url, { method: 'POST' });
|
||||||
|
|
||||||
|
// ---------- 渲染 ----------
|
||||||
|
function render() {
|
||||||
|
const grid = $('#grid');
|
||||||
|
grid.innerHTML = '';
|
||||||
|
repos.forEach(r => grid.appendChild(card(r)));
|
||||||
|
$('#empty').hidden = repos.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function card(r) {
|
||||||
|
const st = STATUS[r.status] || STATUS.idle;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'card ' + st.cls;
|
||||||
|
el.dataset.name = r.name;
|
||||||
|
const last = r.last_sync
|
||||||
|
? `${r.last_sync.time.slice(5, 16)} · ${RESULT_TEXT[r.last_sync.result] || r.last_sync.result}`
|
||||||
|
: '从未同步';
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="card-head">
|
||||||
|
<span class="dot"></span>
|
||||||
|
<h3>${esc(r.name)}</h3>
|
||||||
|
<span class="mode">${esc(r.mode)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="path" title="${esc(r.repo_path)}">${esc(r.repo_path)}</div>
|
||||||
|
<div class="badges">
|
||||||
|
<span class="badge">${esc(r.branch)}</span>
|
||||||
|
<span class="badge ${r.ahead ? 'b-up' : ''}">领先 ↑${r.ahead}</span>
|
||||||
|
<span class="badge ${r.behind ? 'b-down' : ''}">落后 ↓${r.behind}</span>
|
||||||
|
${r.dirty ? `<span class="badge b-dirty">改动 ${r.dirty}</span>` : ''}
|
||||||
|
${r.auto_commit ? '<span class="badge">自动提交</span>' : ''}
|
||||||
|
${r.cron ? `<span class="badge" title="${esc(r.cron)}">cron</span>` : ''}
|
||||||
|
${r.conflicts && r.conflicts.length ? `<span class="badge b-conflict">冲突 ${r.conflicts.length}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="last">上次同步:${esc(last)}</div>
|
||||||
|
${r.status === 'error' && r.last_error ? `<div class="err" title="${esc(r.last_error)}">${esc(r.last_error)}</div>` : ''}
|
||||||
|
<div class="card-btns">
|
||||||
|
<button class="btn" data-act="sync" ${r.running ? '' : 'disabled'}>立即同步</button>
|
||||||
|
<button class="btn" data-act="toggle">${r.running ? '暂停' : '启动'}</button>
|
||||||
|
<button class="btn" data-act="detail">详情</button>
|
||||||
|
</div>`;
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#grid').addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('button[data-act]');
|
||||||
|
if (!btn) return;
|
||||||
|
const name = btn.closest('.card').dataset.name;
|
||||||
|
const act = btn.dataset.act;
|
||||||
|
try {
|
||||||
|
if (act === 'sync') {
|
||||||
|
await post(`/api/repos/${name}/sync`);
|
||||||
|
} else if (act === 'toggle') {
|
||||||
|
const r = repos.find(x => x.name === name);
|
||||||
|
await post(`/api/repos/${name}/${r.running ? 'stop' : 'start'}`);
|
||||||
|
} else if (act === 'detail') {
|
||||||
|
openDetail(name);
|
||||||
|
}
|
||||||
|
} catch (err) { alert(err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- SSE 实时事件 ----------
|
||||||
|
function connect() {
|
||||||
|
const es = new EventSource('/api/events');
|
||||||
|
es.onopen = () => setConn(true);
|
||||||
|
es.onerror = () => setConn(false); // EventSource 会自动重连
|
||||||
|
es.onmessage = (ev) => {
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(ev.data); } catch { return; }
|
||||||
|
if (msg.type === 'snapshot') {
|
||||||
|
repos = msg.data || [];
|
||||||
|
render();
|
||||||
|
} else if (msg.type === 'status') {
|
||||||
|
const i = repos.findIndex(x => x.name === msg.repo);
|
||||||
|
if (i >= 0) repos[i] = msg.data; else repos.push(msg.data);
|
||||||
|
render();
|
||||||
|
} else if (msg.type === 'log') {
|
||||||
|
if (current === msg.repo && !$('#tab-log').hidden) appendLogLine(`[${msg.time}] ${msg.msg}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConn(ok) {
|
||||||
|
const el = $('#conn');
|
||||||
|
el.textContent = ok ? '● 已连接' : '● 未连接';
|
||||||
|
el.className = 'conn ' + (ok ? 'on' : 'off');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 登录 ----------
|
||||||
|
function showLogin() {
|
||||||
|
const el = $('#login');
|
||||||
|
if (el.hidden) {
|
||||||
|
el.hidden = false;
|
||||||
|
setTimeout(() => $('#login-password').focus(), 50);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogin() {
|
||||||
|
const errEl = $('#login-err');
|
||||||
|
errEl.hidden = true;
|
||||||
|
try {
|
||||||
|
await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ password: $('#login-password').value })
|
||||||
|
}).then(async res => {
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(d.error || '登录失败');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
errEl.textContent = err.message;
|
||||||
|
errEl.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$('#btn-login').addEventListener('click', doLogin);
|
||||||
|
$('#login-password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
|
||||||
|
|
||||||
|
// ---------- 详情弹窗 ----------
|
||||||
|
function showTab(t) {
|
||||||
|
document.querySelectorAll('#m-tabs .tab').forEach(b => b.classList.toggle('active', b.dataset.tab === t));
|
||||||
|
document.querySelectorAll('.tab-pane').forEach(p => { p.hidden = p.id !== 'tab-' + t; });
|
||||||
|
if (t === 'log' && current) loadLog(current);
|
||||||
|
if (t === 'history' && current) loadHistory(current);
|
||||||
|
if (t === 'conflict' && current) loadConflicts(current);
|
||||||
|
if (t === 'stats' && current) loadStats(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#m-tabs').addEventListener('click', (e) => {
|
||||||
|
const b = e.target.closest('.tab');
|
||||||
|
if (b) showTab(b.dataset.tab);
|
||||||
|
});
|
||||||
|
|
||||||
|
function openDetail(name) {
|
||||||
|
creating = false;
|
||||||
|
current = name;
|
||||||
|
const r = repos.find(x => x.name === name);
|
||||||
|
$('#m-title').textContent = name;
|
||||||
|
$('#f-name').disabled = true;
|
||||||
|
$('#btn-delete').hidden = false;
|
||||||
|
fillForm(r);
|
||||||
|
showTab('log');
|
||||||
|
$('#modal').hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
creating = true;
|
||||||
|
current = null;
|
||||||
|
$('#m-title').textContent = '新建配置';
|
||||||
|
$('#f-name').disabled = false;
|
||||||
|
$('#btn-delete').hidden = true;
|
||||||
|
fillForm(null);
|
||||||
|
showTab('config');
|
||||||
|
$('#modal').hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
$('#modal').hidden = true;
|
||||||
|
current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#m-close').addEventListener('click', closeModal);
|
||||||
|
$('#modal').addEventListener('click', (e) => { if (e.target === $('#modal')) closeModal(); });
|
||||||
|
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
|
||||||
|
|
||||||
|
// ---------- 日志 ----------
|
||||||
|
function loadLog(name) {
|
||||||
|
$('#logbox').textContent = '加载中…';
|
||||||
|
api(`/api/repos/${name}/log?lines=500`).then(d => {
|
||||||
|
$('#logbox').textContent = d.lines && d.lines.length ? d.lines.join('\n') : '(暂无日志)';
|
||||||
|
$('#logbox').scrollTop = $('#logbox').scrollHeight;
|
||||||
|
}).catch(err => { $('#logbox').textContent = '日志加载失败:' + err.message; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendLogLine(line) {
|
||||||
|
const el = $('#logbox');
|
||||||
|
if (el.textContent === '(暂无日志)') el.textContent = '';
|
||||||
|
el.textContent += (el.textContent ? '\n' : '') + line;
|
||||||
|
el.scrollTop = el.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 提交历史 ----------
|
||||||
|
function loadHistory(name) {
|
||||||
|
const list = $('#commit-list');
|
||||||
|
list.textContent = '加载中…';
|
||||||
|
api(`/api/repos/${name}/commits?limit=50`).then(commits => {
|
||||||
|
if (!commits.length) {
|
||||||
|
list.textContent = '暂无提交记录。';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = commits.map(c => `
|
||||||
|
<div class="commit" data-hash="${esc(c.hash)}">
|
||||||
|
<div class="commit-head">
|
||||||
|
<span class="commit-hash">${esc(c.hash.slice(0, 8))}</span>
|
||||||
|
<span class="commit-subject" title="${esc(c.subject)}">${esc(c.subject)}</span>
|
||||||
|
<span class="commit-meta">${esc(c.author)} · ${esc(c.date)}</span>
|
||||||
|
</div>
|
||||||
|
<pre class="commit-diff" hidden></pre>
|
||||||
|
</div>`).join('');
|
||||||
|
}).catch(err => { list.textContent = '提交历史加载失败:' + err.message; });
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#commit-list').addEventListener('click', async (e) => {
|
||||||
|
const head = e.target.closest('.commit-head');
|
||||||
|
if (!head || !current) return;
|
||||||
|
const item = head.closest('.commit');
|
||||||
|
const pre = item.querySelector('.commit-diff');
|
||||||
|
if (!pre.hidden) { pre.hidden = true; return; }
|
||||||
|
pre.hidden = false;
|
||||||
|
pre.textContent = '加载中…';
|
||||||
|
try {
|
||||||
|
const d = await api(`/api/repos/${current}/commits/${item.dataset.hash}`);
|
||||||
|
pre.textContent = d.diff || '(无差异内容)';
|
||||||
|
} catch (err) {
|
||||||
|
pre.textContent = '加载失败:' + err.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 冲突 ----------
|
||||||
|
function loadConflicts(name) {
|
||||||
|
api(`/api/repos/${name}/conflicts`).then(files => {
|
||||||
|
const list = $('#conflict-list');
|
||||||
|
if (!files.length) {
|
||||||
|
list.innerHTML = '<p style="color:var(--muted)">当前没有冲突。</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = files.map(f =>
|
||||||
|
`<div class="conflict-file"><h4>${esc(f.file)}</h4><pre>${esc(f.diff) || '(无差异内容)'}</pre></div>`
|
||||||
|
).join('');
|
||||||
|
}).catch(err => { $('#conflict-list').innerHTML = `<p style="color:var(--red)">${esc(err.message)}</p>`; });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doAction(action, okMsg) {
|
||||||
|
try {
|
||||||
|
await api(`/api/repos/${current}/action`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ action })
|
||||||
|
});
|
||||||
|
alert(okMsg);
|
||||||
|
loadConflicts(current);
|
||||||
|
} catch (err) { alert(err.message); }
|
||||||
|
}
|
||||||
|
$('#btn-abort').addEventListener('click', () => {
|
||||||
|
if (confirm('确定放弃本次合并/变基?仓库将回到冲突前状态。')) doAction('abort', '已放弃本次合并');
|
||||||
|
});
|
||||||
|
$('#btn-resolved').addEventListener('click', () => doAction('resolve', '冲突处理流程完成'));
|
||||||
|
|
||||||
|
// ---------- 统计 ----------
|
||||||
|
function dateStr(d) {
|
||||||
|
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStats(name) {
|
||||||
|
api(`/api/repos/${name}/stats`).then(rows => {
|
||||||
|
const days = [];
|
||||||
|
for (let i = 29; i >= 0; i--) {
|
||||||
|
const d = new Date();
|
||||||
|
d.setDate(d.getDate() - i);
|
||||||
|
days.push(dateStr(d));
|
||||||
|
}
|
||||||
|
const mk = (key) => days.map(d => rows.filter(r => r.result === key && r.time.slice(0, 10) === d).length);
|
||||||
|
const total = { ok: 0, error: 0, conflict: 0 };
|
||||||
|
rows.forEach(r => { if (total[r.result] !== undefined) total[r.result]++; });
|
||||||
|
$('#stat-summary').innerHTML =
|
||||||
|
`<span>近 30 天共 <b>${rows.length}</b> 次同步记录</span>` +
|
||||||
|
`<span style="color:var(--green)">成功 <b>${total.ok}</b></span>` +
|
||||||
|
`<span style="color:var(--red)">失败 <b>${total.error}</b></span>` +
|
||||||
|
`<span style="color:var(--amber)">冲突 <b>${total.conflict}</b></span>`;
|
||||||
|
if (chart) { chart.destroy(); chart = null; }
|
||||||
|
if (!window.Chart) return;
|
||||||
|
chart = new Chart($('#stat-chart'), {
|
||||||
|
type: 'bar',
|
||||||
|
data: {
|
||||||
|
labels: days.map(d => d.slice(5)),
|
||||||
|
datasets: [
|
||||||
|
{ label: '成功', data: mk('ok'), backgroundColor: '#97c459' },
|
||||||
|
{ label: '失败', data: mk('error'), backgroundColor: '#f09595' },
|
||||||
|
{ label: '冲突', data: mk('conflict'), backgroundColor: '#ef9f27' },
|
||||||
|
]
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
scales: {
|
||||||
|
x: { stacked: true },
|
||||||
|
y: { stacked: true, ticks: { precision: 0 } }
|
||||||
|
},
|
||||||
|
plugins: { legend: { position: 'bottom' } }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).catch(err => { $('#stat-summary').textContent = '统计加载失败:' + err.message; });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 配置表单 ----------
|
||||||
|
function fillForm(r) {
|
||||||
|
$('#f-name').value = r ? r.name : '';
|
||||||
|
$('#f-path').value = r ? r.repo_path : '';
|
||||||
|
$('#f-branch').value = r ? r.branch : 'main';
|
||||||
|
$('#f-mode').value = r ? r.mode : 'both';
|
||||||
|
$('#f-remotes').value = r && r.remotes ? r.remotes.map(x => x.name + '=' + x.url).join('\n') : '';
|
||||||
|
$('#f-interval').value = r ? r.interval : 60;
|
||||||
|
$('#f-auth').value = r ? r.auth_method : 'ssh';
|
||||||
|
$('#f-conflict').value = r ? r.conflict_strategy : 'merge';
|
||||||
|
$('#f-retry').value = r ? r.retry_count : 3;
|
||||||
|
$('#f-retry-interval').value = r ? r.retry_interval : 5;
|
||||||
|
$('#f-autocommit').checked = r ? !!r.auto_commit : false;
|
||||||
|
$('#f-cron').value = r ? (r.cron || '') : '';
|
||||||
|
$('#f-log').value = r ? (r.log_file || '') : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readForm() {
|
||||||
|
const remotes = [];
|
||||||
|
$('#f-remotes').value.split('\n').forEach(line => {
|
||||||
|
line = line.trim();
|
||||||
|
if (!line) return;
|
||||||
|
const i = line.indexOf('=');
|
||||||
|
if (i > 0) remotes.push({ name: line.slice(0, i).trim(), url: line.slice(i + 1).trim() });
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
name: $('#f-name').value.trim(),
|
||||||
|
repo_path: $('#f-path').value.trim(),
|
||||||
|
branch: $('#f-branch').value.trim() || 'main',
|
||||||
|
remotes,
|
||||||
|
mode: $('#f-mode').value,
|
||||||
|
interval: parseInt($('#f-interval').value, 10) || 60,
|
||||||
|
auth_method: $('#f-auth').value,
|
||||||
|
retry_count: parseInt($('#f-retry').value, 10) || 0,
|
||||||
|
retry_interval: parseInt($('#f-retry-interval').value, 10) || 0,
|
||||||
|
conflict_strategy: $('#f-conflict').value,
|
||||||
|
auto_commit: $('#f-autocommit').checked,
|
||||||
|
cron: $('#f-cron').value.trim(),
|
||||||
|
log_file: $('#f-log').value.trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$('#cfg-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const cfg = readForm();
|
||||||
|
try {
|
||||||
|
if (creating) {
|
||||||
|
await api('/api/repos', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(cfg)
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await api(`/api/repos/${cfg.name}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(cfg)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
closeModal();
|
||||||
|
refresh();
|
||||||
|
} catch (err) { alert('保存失败:' + err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#btn-delete').addEventListener('click', async () => {
|
||||||
|
if (!current) return;
|
||||||
|
if (!confirm(`确定删除配置 "${current}" ?(不影响日志和统计文件)`)) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/repos/${current}`, { method: 'DELETE' });
|
||||||
|
closeModal();
|
||||||
|
refresh();
|
||||||
|
} catch (err) { alert(err.message); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 顶部按钮 ----------
|
||||||
|
$('#btn-new').addEventListener('click', openCreate);
|
||||||
|
$('#btn-empty-new').addEventListener('click', openCreate);
|
||||||
|
$('#btn-sync-all').addEventListener('click', () => {
|
||||||
|
repos.filter(r => r.running).forEach(r => post(`/api/repos/${r.name}/sync`).catch(() => {}));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------- 初始化 ----------
|
||||||
|
async function refresh() {
|
||||||
|
try {
|
||||||
|
const d = await api('/api/status');
|
||||||
|
repos = d.repos || [];
|
||||||
|
render();
|
||||||
|
} catch (e) { /* 忽略,SSE 会重试 */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
connect();
|
||||||
|
setInterval(refresh, 20000);
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Git Sync 控制台</title>
|
||||||
|
<link rel="stylesheet" href="/style.css">
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<div class="brand"><span class="brand-dot"></span>Git Sync 控制台</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<span id="conn" class="conn">连接中…</span>
|
||||||
|
<button class="btn" id="btn-sync-all">全部同步</button>
|
||||||
|
<button class="btn btn-primary" id="btn-new">+ 新建配置</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div id="grid" class="grid"></div>
|
||||||
|
<div id="empty" class="empty" hidden>
|
||||||
|
<p>还没有任何仓库配置。</p>
|
||||||
|
<button class="btn btn-primary" id="btn-empty-new">创建第一个配置</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div id="modal" class="modal" hidden>
|
||||||
|
<div class="modal-box">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2 id="m-title">详情</h2>
|
||||||
|
<button class="btn-icon" id="m-close" title="关闭">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="tabs" id="m-tabs">
|
||||||
|
<button data-tab="log" class="tab active">日志</button>
|
||||||
|
<button data-tab="history" class="tab">历史</button>
|
||||||
|
<button data-tab="conflict" class="tab">冲突</button>
|
||||||
|
<button data-tab="stats" class="tab">统计</button>
|
||||||
|
<button data-tab="config" class="tab">配置</button>
|
||||||
|
</div>
|
||||||
|
<div class="tab-body">
|
||||||
|
<div id="tab-log" class="tab-pane">
|
||||||
|
<pre id="logbox" class="logbox">加载中…</pre>
|
||||||
|
</div>
|
||||||
|
<div id="tab-history" class="tab-pane" hidden>
|
||||||
|
<div id="commit-list" class="commit-list">加载中…</div>
|
||||||
|
</div>
|
||||||
|
<div id="tab-conflict" class="tab-pane" hidden>
|
||||||
|
<div class="conflict-actions">
|
||||||
|
<button class="btn btn-danger" id="btn-abort">放弃合并</button>
|
||||||
|
<button class="btn btn-primary" id="btn-resolved">我已在本地解决,继续</button>
|
||||||
|
</div>
|
||||||
|
<div id="conflict-list"></div>
|
||||||
|
</div>
|
||||||
|
<div id="tab-stats" class="tab-pane" hidden>
|
||||||
|
<div id="stat-summary" class="stat-summary"></div>
|
||||||
|
<canvas id="stat-chart" height="120"></canvas>
|
||||||
|
</div>
|
||||||
|
<div id="tab-config" class="tab-pane" hidden>
|
||||||
|
<form id="cfg-form" autocomplete="off">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label>配置名称
|
||||||
|
<input name="name" id="f-name" required pattern="[A-Za-z0-9_\-]+" title="仅字母、数字、下划线、连字符">
|
||||||
|
</label>
|
||||||
|
<label>分支
|
||||||
|
<input name="branch" id="f-branch" value="main">
|
||||||
|
</label>
|
||||||
|
<label class="full">仓库路径
|
||||||
|
<input name="repo_path" id="f-path" placeholder="C:\path\to\repo 或 /c/path/to/repo" required>
|
||||||
|
</label>
|
||||||
|
<label class="full">远程仓库(每行一个,格式 名称=URL)
|
||||||
|
<textarea name="remotes" id="f-remotes" rows="3" placeholder="origin=https://github.com/user/repo.git"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>操作模式
|
||||||
|
<select name="mode" id="f-mode">
|
||||||
|
<option value="both">both(拉取并推送)</option>
|
||||||
|
<option value="push">push(仅推送)</option>
|
||||||
|
<option value="pull">pull(仅拉取)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>检查间隔(秒)
|
||||||
|
<input name="interval" id="f-interval" type="number" min="5" value="60">
|
||||||
|
</label>
|
||||||
|
<label>认证方式
|
||||||
|
<select name="auth_method" id="f-auth">
|
||||||
|
<option value="ssh">SSH</option>
|
||||||
|
<option value="https">HTTPS(支持重试)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>冲突策略
|
||||||
|
<select name="conflict_strategy" id="f-conflict">
|
||||||
|
<option value="merge">merge(冲突后等待人工解决)</option>
|
||||||
|
<option value="rebase">rebase(变基)</option>
|
||||||
|
<option value="abort">abort(冲突后自动放弃)</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>重试次数(HTTPS)
|
||||||
|
<input name="retry_count" id="f-retry" type="number" min="0" value="3">
|
||||||
|
</label>
|
||||||
|
<label>重试间隔(秒,HTTPS)
|
||||||
|
<input name="retry_interval" id="f-retry-interval" type="number" min="0" value="5">
|
||||||
|
</label>
|
||||||
|
<label class="full check-row">
|
||||||
|
<input type="checkbox" id="f-autocommit">
|
||||||
|
<span>自动提交模式:检测到未提交改动时自动 commit,并随同步推送到远程</span>
|
||||||
|
</label>
|
||||||
|
<label class="full">Cron 表达式(可选,5 段:分 时 日 月 周;填写后优先于固定间隔)
|
||||||
|
<input name="cron" id="f-cron" placeholder="*/30 * * * * 表示每 30 分钟,留空使用固定间隔">
|
||||||
|
</label>
|
||||||
|
<label class="full">日志文件路径(留空使用默认 logs/配置名.log)
|
||||||
|
<input name="log_file" id="f-log">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button type="submit" class="btn btn-primary">保存</button>
|
||||||
|
<button type="button" class="btn btn-danger" id="btn-delete" hidden>删除配置</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="login" class="modal" hidden>
|
||||||
|
<div class="modal-box login-box">
|
||||||
|
<div class="modal-head">
|
||||||
|
<h2>Git Sync 控制台</h2>
|
||||||
|
</div>
|
||||||
|
<div class="login-body">
|
||||||
|
<label>访问密码
|
||||||
|
<input type="password" id="login-password">
|
||||||
|
</label>
|
||||||
|
<p id="login-err" class="login-err" hidden></p>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn btn-primary" id="btn-login">登录</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
:root {
|
||||||
|
--bg: #f6f7f9; --card: #fff; --border: #e3e5e8; --text: #1f2328; --muted: #6a737d;
|
||||||
|
--blue: #185fa5; --green: #3b6d11; --green-bg: #eaf3de; --red: #a32d2d; --red-bg: #fcebeb;
|
||||||
|
--amber: #854f0b; --amber-bg: #faeeda; --gray-bg: #f1efe8; --radius: 12px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; font: 14px/1.6 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; background: var(--bg); color: var(--text); }
|
||||||
|
|
||||||
|
header { display: flex; justify-content: space-between; align-items: center; padding: 12px 24px; background: #fff; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
|
||||||
|
.brand { font-weight: 500; font-size: 16px; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.brand-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--blue); display: inline-block; }
|
||||||
|
.header-actions { display: flex; gap: 8px; align-items: center; }
|
||||||
|
.conn { font-size: 12px; color: var(--muted); margin-right: 8px; }
|
||||||
|
.conn.on { color: var(--green); }
|
||||||
|
.conn.off { color: var(--red); }
|
||||||
|
|
||||||
|
.btn { border: 1px solid var(--border); background: #fff; border-radius: 8px; padding: 6px 14px; font-size: 13px; cursor: pointer; color: var(--text); }
|
||||||
|
.btn:hover { border-color: #b6bcc4; background: #f3f4f6; }
|
||||||
|
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||||
|
.btn-primary { background: var(--blue); border-color: var(--blue); color: #fff; }
|
||||||
|
.btn-primary:hover { background: #124d86; border-color: #124d86; }
|
||||||
|
.btn-danger { background: #fff; border-color: #e2b6b6; color: var(--red); }
|
||||||
|
.btn-danger:hover { background: var(--red-bg); }
|
||||||
|
.btn-icon { border: none; background: none; font-size: 18px; cursor: pointer; color: var(--muted); }
|
||||||
|
|
||||||
|
main { max-width: 1200px; margin: 24px auto; padding: 0 24px; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
||||||
|
.empty { text-align: center; color: var(--muted); padding: 80px 0; }
|
||||||
|
|
||||||
|
.card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.card.st-conflict { border-color: #e8c98a; background: #fffdf6; }
|
||||||
|
.card.st-error { border-color: #eec9c9; }
|
||||||
|
.card-head { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.card-head h3 { margin: 0; font-size: 15px; font-weight: 500; flex: 1; }
|
||||||
|
.dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
|
||||||
|
.st-ok .dot { background: #4f9e33; }
|
||||||
|
.st-error .dot { background: #d34b4b; }
|
||||||
|
.st-conflict .dot { background: #e39a1d; }
|
||||||
|
.st-idle .dot { background: #b4b2a9; }
|
||||||
|
.st-running .dot { background: #378add; animation: pulse 1.2s infinite; }
|
||||||
|
@keyframes pulse { 50% { opacity: .35; } }
|
||||||
|
.mode { font-size: 11px; color: var(--muted); border: 1px solid var(--border); padding: 1px 6px; border-radius: 6px; }
|
||||||
|
.path { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: Consolas, monospace; }
|
||||||
|
.badges { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.badge { font-size: 12px; background: var(--gray-bg); border-radius: 6px; padding: 1px 8px; color: #444; }
|
||||||
|
.badge.b-up { background: var(--green-bg); color: var(--green); }
|
||||||
|
.badge.b-down { background: #e6f1fb; color: var(--blue); }
|
||||||
|
.badge.b-dirty { background: var(--amber-bg); color: var(--amber); }
|
||||||
|
.badge.b-conflict { background: var(--red-bg); color: var(--red); }
|
||||||
|
.last { font-size: 12px; color: var(--muted); }
|
||||||
|
.err { font-size: 12px; color: var(--red); background: var(--red-bg); border-radius: 8px; padding: 6px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.card-btns { display: flex; gap: 8px; margin-top: auto; }
|
||||||
|
|
||||||
|
.modal { position: fixed; inset: 0; background: rgba(0,0,0,.35); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||||
|
.modal[hidden] { display: none; }
|
||||||
|
.modal-box { background: #fff; border-radius: 16px; width: min(860px, 94vw); max-height: 88vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||||
|
.modal-head { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; border-bottom: 1px solid var(--border); }
|
||||||
|
.modal-head h2 { margin: 0; font-size: 16px; font-weight: 500; }
|
||||||
|
.tabs { display: flex; gap: 4px; padding: 8px 20px 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.tab { border: none; background: none; padding: 8px 14px; font-size: 13px; cursor: pointer; color: var(--muted); border-bottom: 2px solid transparent; }
|
||||||
|
.tab.active { color: var(--blue); border-bottom-color: var(--blue); }
|
||||||
|
.tab-body { padding: 16px 20px; overflow: auto; }
|
||||||
|
.tab-pane[hidden] { display: none; }
|
||||||
|
|
||||||
|
.logbox { margin: 0; background: #f6f7f9; border: 1px solid var(--border); padding: 12px; border-radius: 8px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 55vh; overflow: auto; }
|
||||||
|
.conflict-actions { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||||
|
.conflict-file { margin-bottom: 14px; }
|
||||||
|
.conflict-file h4 { margin: 0 0 6px; font-size: 13px; font-family: Consolas, monospace; }
|
||||||
|
.conflict-file pre { margin: 0; background: #f6f7f9; border: 1px solid var(--border); border-radius: 8px; padding: 10px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 300px; overflow: auto; }
|
||||||
|
.stat-summary { display: flex; gap: 16px; margin-bottom: 12px; font-size: 13px; color: var(--muted); flex-wrap: wrap; }
|
||||||
|
.stat-summary b { color: var(--text); }
|
||||||
|
|
||||||
|
.commit-list { max-height: 55vh; overflow: auto; }
|
||||||
|
.commit { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; overflow: hidden; }
|
||||||
|
.commit-head { display: flex; gap: 10px; align-items: baseline; padding: 8px 12px; cursor: pointer; }
|
||||||
|
.commit-head:hover { background: #f6f7f9; }
|
||||||
|
.commit-hash { font: 12px Consolas, monospace; color: var(--blue); flex: none; }
|
||||||
|
.commit-subject { flex: 1; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.commit-meta { font-size: 12px; color: var(--muted); flex: none; }
|
||||||
|
.commit-diff { margin: 0; border-top: 1px solid var(--border); background: #f6f7f9; padding: 10px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 320px; overflow: auto; }
|
||||||
|
.commit-diff[hidden] { display: none; }
|
||||||
|
|
||||||
|
.login-box { width: min(360px, 92vw); }
|
||||||
|
.login-body { padding: 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||||
|
.login-err { color: var(--red); font-size: 12px; margin: 0; }
|
||||||
|
.form-grid label.check-row { flex-direction: row; align-items: center; gap: 8px; font-size: 13px; color: var(--text); }
|
||||||
|
.form-grid label.check-row input { width: auto; margin: 0; }
|
||||||
|
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
|
||||||
|
.form-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||||
|
.form-grid .full { grid-column: 1 / -1; }
|
||||||
|
input, select, textarea { border: 1px solid var(--border); border-radius: 8px; padding: 7px 10px; font-size: 13px; font-family: inherit; color: var(--text); background: #fff; }
|
||||||
|
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--blue); }
|
||||||
|
textarea { font-family: Consolas, monospace; resize: vertical; }
|
||||||
|
.form-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||||
|
|
||||||
|
@media (max-width: 640px) { .form-grid { grid-template-columns: 1fr; } }
|
||||||
Reference in New Issue
Block a user