git-sync-web 自动提交 2026-09-08 17:12:27

This commit is contained in:
tamakyi
2026-09-08 17:12:27 +08:00
commit 8e8adbd8a0
14 changed files with 2640 additions and 0 deletions
+101
View File
@@ -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
+218
View File
@@ -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
}
+13
View File
@@ -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=""
+124
View File
@@ -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
}
+53
View File
@@ -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)
}
}
+974
View File
@@ -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
}
+140
View File
@@ -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
}
+3
View File
@@ -0,0 +1,3 @@
module git-sync-web
go 1.25
+5
View File
@@ -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 个未提交改动,执行自动提交
+37
View File
@@ -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)
}
}
+313
View File
@@ -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
View File
@@ -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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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);
+143
View File
@@ -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>
+97
View File
@@ -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; } }