commit 8e8adbd8a0e547057576eb01f282a557e6d13979 Author: tamakyi Date: Tue Sep 8 17:12:27 2026 +0800 git-sync-web 自动提交 2026-09-08 17:12:27 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0aa2c92 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/config.go b/config.go new file mode 100644 index 0000000..af8e03b --- /dev/null +++ b/config.go @@ -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 +} diff --git a/configs/git-auto-sync.conf b/configs/git-auto-sync.conf new file mode 100644 index 0000000..20f5eb4 --- /dev/null +++ b/configs/git-auto-sync.conf @@ -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="" diff --git a/cron.go b/cron.go new file mode 100644 index 0000000..972dd23 --- /dev/null +++ b/cron.go @@ -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 +} diff --git a/cron_test.go b/cron_test.go new file mode 100644 index 0000000..be7dab8 --- /dev/null +++ b/cron_test.go @@ -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) + } +} diff --git a/engine.go b/engine.go new file mode 100644 index 0000000..8c96cfe --- /dev/null +++ b/engine.go @@ -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 +} diff --git a/git.go b/git.go new file mode 100644 index 0000000..fec07ce --- /dev/null +++ b/git.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4837be5 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module git-sync-web + +go 1.25 diff --git a/logs/git-auto-sync.log b/logs/git-auto-sync.log new file mode 100644 index 0000000..e52607c --- /dev/null +++ b/logs/git-auto-sync.log @@ -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 个未提交改动,执行自动提交 diff --git a/main.go b/main.go new file mode 100644 index 0000000..bd7c667 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/server.go b/server.go new file mode 100644 index 0000000..93fb34f --- /dev/null +++ b/server.go @@ -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() + } + } +} diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..00d607c --- /dev/null +++ b/static/app.js @@ -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 = ` +
+ +

${esc(r.name)}

+ ${esc(r.mode)} +
+
${esc(r.repo_path)}
+
+ ${esc(r.branch)} + 领先 ↑${r.ahead} + 落后 ↓${r.behind} + ${r.dirty ? `改动 ${r.dirty}` : ''} + ${r.auto_commit ? '自动提交' : ''} + ${r.cron ? `cron` : ''} + ${r.conflicts && r.conflicts.length ? `冲突 ${r.conflicts.length}` : ''} +
+
上次同步:${esc(last)}
+ ${r.status === 'error' && r.last_error ? `
${esc(r.last_error)}
` : ''} +
+ + + +
`; + 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 => ` +
+
+ ${esc(c.hash.slice(0, 8))} + ${esc(c.subject)} + ${esc(c.author)} · ${esc(c.date)} +
+ +
`).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 = '

当前没有冲突。

'; + return; + } + list.innerHTML = files.map(f => + `

${esc(f.file)}

${esc(f.diff) || '(无差异内容)'}
` + ).join(''); + }).catch(err => { $('#conflict-list').innerHTML = `

${esc(err.message)}

`; }); +} + +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 = + `近 30 天共 ${rows.length} 次同步记录` + + `成功 ${total.ok}` + + `失败 ${total.error}` + + `冲突 ${total.conflict}`; + 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); diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..6ca8c7a --- /dev/null +++ b/static/index.html @@ -0,0 +1,143 @@ + + + + + +Git Sync 控制台 + + + + +
+
Git Sync 控制台
+
+ 连接中… + + +
+
+ +
+
+ +
+ + + + + + + + diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..e06d229 --- /dev/null +++ b/static/style.css @@ -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; } }