Compare commits
39
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6c9a76d51 | ||
|
|
0039ed75ca | ||
|
|
ec4e32ae76 | ||
|
|
9ad94a3ab6 | ||
|
|
6deea58585 | ||
|
|
2813fbcfb3 | ||
|
|
d0428b1fea | ||
|
|
8a128af3bc | ||
|
|
c30e58dec1 | ||
|
|
5cee3dfdbd | ||
|
|
98cf6a64a8 | ||
|
|
cbf39e4233 | ||
|
|
d99d1ce7ab | ||
|
|
9d3bdbe583 | ||
|
|
2da2301c37 | ||
|
|
f5838fd8b6 | ||
|
|
f882668404 | ||
|
|
2ea7f8563a | ||
|
|
a32dd0d444 | ||
|
|
feeeffec1f | ||
|
|
0ac3d3e86c | ||
|
|
03494be12f | ||
|
|
aef0b08eda | ||
|
|
000968f4e6 | ||
|
|
c0d599dfb4 | ||
|
|
5227d8f70c | ||
|
|
18dbd34f2c | ||
|
|
be99e73a0e | ||
|
|
b16e589dd9 | ||
|
|
1a92c18800 | ||
|
|
a44334d751 | ||
|
|
06683fa1f6 | ||
|
|
a6ccd748bf | ||
|
|
1c8e1f71d5 | ||
|
|
c57b9fa3b3 | ||
|
|
153913f182 | ||
|
|
90f4d90232 | ||
|
|
076ab18599 | ||
|
|
8e8adbd8a0 |
+83
-9
@@ -1,7 +1,13 @@
|
||||
# ---> Go
|
||||
# If you prefer the allow list template instead of the deny list, see community template:
|
||||
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
||||
#
|
||||
# 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~
|
||||
@@ -18,10 +24,78 @@
|
||||
# Dependency directories (remove the comment below to include it)
|
||||
# vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
### Go Patch ###
|
||||
/vendor/
|
||||
/Godeps/
|
||||
|
||||
# env file
|
||||
.env
|
||||
### 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
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 tamakyi
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
@@ -0,0 +1,218 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Remote struct {
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
type RepoConfig struct {
|
||||
Name string `json:"name"`
|
||||
RepoPath string `json:"repo_path"`
|
||||
Branch string `json:"branch"`
|
||||
Remotes []Remote `json:"remotes"`
|
||||
Mode string `json:"mode"`
|
||||
Interval int `json:"interval"`
|
||||
LogFile string `json:"log_file"`
|
||||
AuthMethod string `json:"auth_method"`
|
||||
RetryCount int `json:"retry_count"`
|
||||
RetryInterval int `json:"retry_interval"`
|
||||
ConflictStrategy string `json:"conflict_strategy"`
|
||||
AutoCommit bool `json:"auto_commit"`
|
||||
Cron string `json:"cron"`
|
||||
}
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[A-Za-z0-9_\-]+$`)
|
||||
|
||||
// normalizeWinPath 把 Git Bash 风格路径 /c/foo/bar 转成 Windows 的 C:/foo/bar,
|
||||
// 兼容旧脚本配置里存放的 bash 风格路径
|
||||
func normalizeWinPath(p string) string {
|
||||
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
|
||||
if len(p) >= 3 && p[0] == '/' && p[2] == '/' {
|
||||
d := p[1]
|
||||
if (d >= 'a' && d <= 'z') || (d >= 'A' && d <= 'Z') {
|
||||
return strings.ToUpper(string(d)) + ":/" + p[3:]
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func unquote(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) >= 2 {
|
||||
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func orDefault(v, def string) string {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return def
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func atoiOr(v string, def int) int {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(v))
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func parseConfFile(path string) (map[string]string, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m := map[string]string{}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
m[strings.TrimSpace(k)] = unquote(v)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func loadRepoConfig(dir, name string) (*RepoConfig, error) {
|
||||
m, err := parseConfFile(filepath.Join(dir, name+".conf"))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c := &RepoConfig{Name: name}
|
||||
c.RepoPath = normalizeWinPath(m["REPO_PATH"])
|
||||
c.Branch = orDefault(m["BRANCH"], "main")
|
||||
c.Mode = orDefault(m["MODE"], "both")
|
||||
c.Interval = atoiOr(m["INTERVAL"], 60)
|
||||
c.LogFile = normalizeWinPath(m["LOG_FILE"])
|
||||
c.AuthMethod = orDefault(m["AUTH_METHOD"], "ssh")
|
||||
c.RetryCount = atoiOr(m["RETRY_COUNT"], 0)
|
||||
c.RetryInterval = atoiOr(m["RETRY_INTERVAL"], 0)
|
||||
c.ConflictStrategy = orDefault(m["CONFLICT_STRATEGY"], "merge")
|
||||
c.AutoCommit = strings.EqualFold(m["AUTO_COMMIT"], "1") || strings.EqualFold(m["AUTO_COMMIT"], "true")
|
||||
c.Cron = strings.TrimSpace(m["CRON"])
|
||||
if remotes := strings.TrimSpace(m["REMOTES"]); remotes != "" {
|
||||
for _, pair := range strings.Split(remotes, ",") {
|
||||
pair = strings.TrimSpace(pair)
|
||||
if pair == "" {
|
||||
continue
|
||||
}
|
||||
n, u, ok := strings.Cut(pair, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
c.Remotes = append(c.Remotes, Remote{Name: strings.TrimSpace(n), URL: strings.TrimSpace(u)})
|
||||
}
|
||||
}
|
||||
// 兼容旧版单远程格式:REMOTE_NAME + REMOTE_URL
|
||||
if len(c.Remotes) == 0 && m["REMOTE_NAME"] != "" && m["REMOTE_URL"] != "" {
|
||||
c.Remotes = append(c.Remotes, Remote{Name: m["REMOTE_NAME"], URL: m["REMOTE_URL"]})
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func q(s string) string { return "\"" + s + "\"" }
|
||||
|
||||
func saveRepoConfig(dir string, c *RepoConfig) error {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "# Git 自动同步配置 - %s\n", c.Name)
|
||||
fmt.Fprintf(&b, "REPO_PATH=%s\n", q(c.RepoPath))
|
||||
fmt.Fprintf(&b, "BRANCH=%s\n", q(c.Branch))
|
||||
pairs := make([]string, len(c.Remotes))
|
||||
for i, r := range c.Remotes {
|
||||
pairs[i] = r.Name + "=" + r.URL
|
||||
}
|
||||
fmt.Fprintf(&b, "REMOTES=%s\n", q(strings.Join(pairs, ",")))
|
||||
fmt.Fprintf(&b, "MODE=%s\n", q(c.Mode))
|
||||
fmt.Fprintf(&b, "INTERVAL=%s\n", q(strconv.Itoa(c.Interval)))
|
||||
fmt.Fprintf(&b, "LOG_FILE=%s\n", q(c.LogFile))
|
||||
fmt.Fprintf(&b, "AUTH_METHOD=%s\n", q(c.AuthMethod))
|
||||
fmt.Fprintf(&b, "RETRY_COUNT=%s\n", q(strconv.Itoa(c.RetryCount)))
|
||||
fmt.Fprintf(&b, "RETRY_INTERVAL=%s\n", q(strconv.Itoa(c.RetryInterval)))
|
||||
fmt.Fprintf(&b, "CONFLICT_STRATEGY=%s\n", q(c.ConflictStrategy))
|
||||
ac := "0"
|
||||
if c.AutoCommit {
|
||||
ac = "1"
|
||||
}
|
||||
fmt.Fprintf(&b, "AUTO_COMMIT=%s\n", q(ac))
|
||||
fmt.Fprintf(&b, "CRON=%s\n", q(c.Cron))
|
||||
return os.WriteFile(filepath.Join(dir, c.Name+".conf"), []byte(b.String()), 0o644)
|
||||
}
|
||||
|
||||
func validateRepoConfig(cfgDir string, c *RepoConfig, isCreate bool) error {
|
||||
if !nameRe.MatchString(c.Name) {
|
||||
return fmt.Errorf("配置名只能包含字母、数字、下划线和连字符")
|
||||
}
|
||||
if isCreate {
|
||||
if _, err := os.Stat(filepath.Join(cfgDir, c.Name+".conf")); err == nil {
|
||||
return fmt.Errorf("配置 %q 已存在", c.Name)
|
||||
}
|
||||
}
|
||||
p := normalizeWinPath(c.RepoPath)
|
||||
if !isRepo(p) {
|
||||
return fmt.Errorf("仓库路径无效或不是 Git 仓库: %s", c.RepoPath)
|
||||
}
|
||||
c.RepoPath = p
|
||||
c.Branch = strings.TrimSpace(c.Branch)
|
||||
if c.Branch == "" {
|
||||
c.Branch = "main"
|
||||
}
|
||||
if len(c.Remotes) == 0 {
|
||||
return fmt.Errorf("至少需要一个远程仓库(格式:名称=URL)")
|
||||
}
|
||||
for _, r := range c.Remotes {
|
||||
if strings.TrimSpace(r.Name) == "" || strings.TrimSpace(r.URL) == "" {
|
||||
return fmt.Errorf("远程配置格式应为 名称=URL")
|
||||
}
|
||||
}
|
||||
switch c.Mode {
|
||||
case "push", "pull", "both":
|
||||
default:
|
||||
return fmt.Errorf("操作模式必须是 push/pull/both")
|
||||
}
|
||||
switch c.AuthMethod {
|
||||
case "ssh", "https":
|
||||
default:
|
||||
return fmt.Errorf("认证方式必须是 ssh/https")
|
||||
}
|
||||
switch c.ConflictStrategy {
|
||||
case "merge", "rebase", "abort":
|
||||
default:
|
||||
return fmt.Errorf("冲突策略必须是 merge/rebase/abort")
|
||||
}
|
||||
if c.Interval < 5 {
|
||||
c.Interval = 60
|
||||
}
|
||||
if c.RetryCount < 0 {
|
||||
c.RetryCount = 0
|
||||
}
|
||||
if c.RetryInterval < 0 {
|
||||
c.RetryInterval = 0
|
||||
}
|
||||
if c.AuthMethod != "https" {
|
||||
c.RetryCount, c.RetryInterval = 0, 0
|
||||
}
|
||||
if strings.TrimSpace(c.Cron) != "" {
|
||||
if _, err := parseCron(c.Cron); err != nil {
|
||||
return fmt.Errorf("cron 表达式无效: %v", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
# Git 自动同步配置 - git-auto-sync
|
||||
REPO_PATH="C:/OtherFiles/Github/git-auto-sync/git-sync-web"
|
||||
BRANCH="main"
|
||||
REMOTES="origin=https://git.shiroko.one/tamakyi/github-auto-sync-golang"
|
||||
MODE="push"
|
||||
INTERVAL="60"
|
||||
LOG_FILE=""
|
||||
AUTH_METHOD="https"
|
||||
RETRY_COUNT="0"
|
||||
RETRY_INTERVAL="0"
|
||||
CONFLICT_STRATEGY="merge"
|
||||
AUTO_COMMIT="1"
|
||||
CRON=""
|
||||
@@ -0,0 +1,124 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 5 段 cron:分 时 日 月 周。支持 * 、数字、范围 a-b、步长 */n 与 a-b/n、逗号列表。
|
||||
// 日(day-of-month) 与 周(day-of-week) 均受限时采用标准 cron 的「或」语义。
|
||||
|
||||
type cronSpec struct {
|
||||
min, hour, dom, mon, dow map[int]bool
|
||||
domAll, dowAll bool
|
||||
}
|
||||
|
||||
func parseCron(spec string) (*cronSpec, error) {
|
||||
fields := strings.Fields(spec)
|
||||
if len(fields) != 5 {
|
||||
return nil, fmt.Errorf("cron 需要 5 个字段(分 时 日 月 周),收到 %d 个", len(fields))
|
||||
}
|
||||
c := &cronSpec{}
|
||||
var err error
|
||||
if c.min, err = parseCronField(fields[0], 0, 59); err != nil {
|
||||
return nil, fmt.Errorf("分钟字段: %w", err)
|
||||
}
|
||||
if c.hour, err = parseCronField(fields[1], 0, 23); err != nil {
|
||||
return nil, fmt.Errorf("小时字段: %w", err)
|
||||
}
|
||||
if c.dom, err = parseCronField(fields[2], 1, 31); err != nil {
|
||||
return nil, fmt.Errorf("日字段: %w", err)
|
||||
}
|
||||
if c.mon, err = parseCronField(fields[3], 1, 12); err != nil {
|
||||
return nil, fmt.Errorf("月字段: %w", err)
|
||||
}
|
||||
if c.dow, err = parseCronField(fields[4], 0, 7); err != nil {
|
||||
return nil, fmt.Errorf("星期字段: %w", err)
|
||||
}
|
||||
if c.dow[7] {
|
||||
c.dow[0] = true
|
||||
}
|
||||
c.domAll = len(c.dom) == 31
|
||||
c.dowAll = len(c.dow) == 8
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func parseCronField(f string, min, max int) (map[int]bool, error) {
|
||||
set := map[int]bool{}
|
||||
for _, part := range strings.Split(f, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
return nil, fmt.Errorf("存在空字段")
|
||||
}
|
||||
step := 1
|
||||
if i := strings.Index(part, "/"); i >= 0 {
|
||||
n, err := strconv.Atoi(part[i+1:])
|
||||
if err != nil || n <= 0 {
|
||||
return nil, fmt.Errorf("无效步长 %q", part)
|
||||
}
|
||||
step = n
|
||||
part = part[:i]
|
||||
if part == "" {
|
||||
part = "*"
|
||||
}
|
||||
}
|
||||
lo, hi := 0, 0
|
||||
if part == "*" {
|
||||
lo, hi = min, max
|
||||
} else if rng := strings.SplitN(part, "-", 2); len(rng) == 2 {
|
||||
var err error
|
||||
if lo, err = strconv.Atoi(rng[0]); err != nil {
|
||||
return nil, fmt.Errorf("无效值 %q", part)
|
||||
}
|
||||
if hi, err = strconv.Atoi(rng[1]); err != nil {
|
||||
return nil, fmt.Errorf("无效值 %q", part)
|
||||
}
|
||||
} else {
|
||||
v, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("无效值 %q", part)
|
||||
}
|
||||
lo, hi = v, v
|
||||
}
|
||||
if lo < min || hi > max || lo > hi {
|
||||
return nil, fmt.Errorf("值超出范围 [%d,%d]: %q", min, max, part)
|
||||
}
|
||||
for v := lo; v <= hi; v += step {
|
||||
set[v] = true
|
||||
}
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
// cronNext 返回 spec 在 after 之后的下一次触发时间(分钟精度,最多向前搜索一年)
|
||||
func cronNext(spec *cronSpec, after time.Time) time.Time {
|
||||
t := after.Truncate(time.Minute).Add(time.Minute)
|
||||
limit := t.AddDate(1, 0, 0)
|
||||
for t.Before(limit) {
|
||||
if !spec.mon[int(t.Month())] {
|
||||
t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()).AddDate(0, 1, 0)
|
||||
continue
|
||||
}
|
||||
domOK := spec.dom[t.Day()]
|
||||
dowOK := spec.dow[int(t.Weekday())]
|
||||
dayOK := domOK && dowOK
|
||||
if !spec.domAll && !spec.dowAll {
|
||||
dayOK = domOK || dowOK
|
||||
}
|
||||
if !dayOK {
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, 1)
|
||||
continue
|
||||
}
|
||||
if !spec.hour[t.Hour()] {
|
||||
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location()).Add(time.Hour)
|
||||
continue
|
||||
}
|
||||
if spec.min[t.Minute()] {
|
||||
return t
|
||||
}
|
||||
t = t.Add(time.Minute)
|
||||
}
|
||||
return limit
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseCron(t *testing.T) {
|
||||
for _, spec := range []string{"*/5 * * * *", "0 9 * * 1-5", "30 8 1,15 * *", "* * * * *", "0 */2 * * *", "0 8-18/2 * * 1-5"} {
|
||||
if _, err := parseCron(spec); err != nil {
|
||||
t.Errorf("parseCron(%q) 应当成功,却得到: %v", spec, err)
|
||||
}
|
||||
}
|
||||
for _, spec := range []string{"* * * *", "61 * * * *", "* * * * 8", "a * * * *", "* * * * * *"} {
|
||||
if _, err := parseCron(spec); err == nil {
|
||||
t.Errorf("parseCron(%q) 应当失败", spec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCronNext(t *testing.T) {
|
||||
// 每日 09:30:从当天 10:00 出发应得到次日 09:30
|
||||
spec, _ := parseCron("30 9 * * *")
|
||||
next := cronNext(spec, time.Date(2026, 9, 8, 10, 0, 0, 0, time.Local))
|
||||
want := time.Date(2026, 9, 9, 9, 30, 0, 0, time.Local)
|
||||
if !next.Equal(want) {
|
||||
t.Fatalf("每日 09:30:期望 %v,得到 %v", want, next)
|
||||
}
|
||||
|
||||
// 每 15 分钟
|
||||
spec2, _ := parseCron("*/15 * * * *")
|
||||
next2 := cronNext(spec2, time.Date(2026, 9, 8, 10, 7, 0, 0, time.Local))
|
||||
want2 := time.Date(2026, 9, 8, 10, 15, 0, 0, time.Local)
|
||||
if !next2.Equal(want2) {
|
||||
t.Fatalf("*/15:期望 %v,得到 %v", want2, next2)
|
||||
}
|
||||
|
||||
// 仅周五 12:00:2026-09-08 是周二,应得到 2026-09-11(周五)
|
||||
spec3, _ := parseCron("0 12 * * 5")
|
||||
next3 := cronNext(spec3, time.Date(2026, 9, 8, 12, 0, 0, 0, time.Local))
|
||||
want3 := time.Date(2026, 9, 11, 12, 0, 0, 0, time.Local)
|
||||
if !next3.Equal(want3) {
|
||||
t.Fatalf("仅周五:期望 %v,得到 %v", want3, next3)
|
||||
}
|
||||
|
||||
// 7 表示周日:2026-09-13 是周日
|
||||
spec4, _ := parseCron("0 0 * * 7")
|
||||
next4 := cronNext(spec4, time.Date(2026, 9, 12, 0, 0, 0, 0, time.Local)) // 周六
|
||||
want4 := time.Date(2026, 9, 13, 0, 0, 0, 0, time.Local)
|
||||
if !next4.Equal(want4) {
|
||||
t.Fatalf("7=周日:期望 %v,得到 %v", want4, next4)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// gitRun 在指定仓库目录执行 git 命令,返回合并后的输出。
|
||||
// 非交互环境:GIT_TERMINAL_PROMPT=0 只能挡住终端提示,挡不住 Windows 凭据管理器(GCM)弹窗,
|
||||
// 因此额外禁用 GCM 交互、askpass 和 ssh 交互,避免后台同步被凭据弹窗永久卡死。
|
||||
// WaitDelay 保证超时 Kill 后即使 git 的子进程仍占用管道,Wait() 也能强制返回(Windows 上
|
||||
// CommandContext 只杀主进程,不杀 git-remote-https / 凭据 helper 等子进程)。
|
||||
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",
|
||||
"GIT_ASKPASS=echo",
|
||||
"GCM_INTERACTIVE=never",
|
||||
"GIT_SSH_COMMAND=ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new",
|
||||
)
|
||||
cmd.WaitDelay = 10 * time.Second
|
||||
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
|
||||
}
|
||||
|
||||
// branchExists 检查本地分支是否存在
|
||||
func branchExists(path, branch string) bool {
|
||||
_, err := gitRun(path, 15*time.Second, "show-ref", "--verify", "-q", "refs/heads/"+branch)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// remoteTip 返回远程分支当前指向的 SHA。
|
||||
// 优先读本地远程跟踪引用(refs/remotes/<remote>/<branch>,快);
|
||||
// 若本地跟踪引用不可用(个别环境下 ref 存储读写异常),回退用 ls-remote 直查远程。
|
||||
func remoteTip(path, remote, branch string) (sha string, exists bool) {
|
||||
if remoteRefExists(path, remote, branch) {
|
||||
out, err := gitRun(path, 15*time.Second, "rev-parse", remote+"/"+branch)
|
||||
if err == nil {
|
||||
return strings.TrimSpace(out), true
|
||||
}
|
||||
}
|
||||
out, err := gitRun(path, 30*time.Second, "ls-remote", remote, "refs/heads/"+branch)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
fields := strings.Fields(strings.TrimSpace(out))
|
||||
if len(fields) < 2 {
|
||||
return "", false
|
||||
}
|
||||
return fields[0], true
|
||||
}
|
||||
|
||||
// aheadBehindSha 基于远程分支的具体 SHA 计算 (领先, 落后),不依赖本地跟踪引用
|
||||
func aheadBehindSha(path, tip string) (int, int, error) {
|
||||
out, err := gitRun(path, 30*time.Second, "rev-list", "--left-right", "--count", "HEAD..."+tip)
|
||||
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
|
||||
}
|
||||
|
||||
func dirtyCount(path string) int {
|
||||
out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
var conflictCodes = map[string]bool{
|
||||
"DD": true, "AU": true, "UD": true, "UA": true, "DU": true, "AA": true, "UU": true,
|
||||
}
|
||||
|
||||
func conflictFiles(path string) ([]string, error) {
|
||||
out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var files []string
|
||||
seen := map[string]bool{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if len(line) < 4 {
|
||||
continue
|
||||
}
|
||||
if !conflictCodes[line[:2]] {
|
||||
continue
|
||||
}
|
||||
p := strings.TrimSpace(line[3:])
|
||||
if i := strings.Index(p, "->"); i >= 0 {
|
||||
p = strings.TrimSpace(p[i+2:])
|
||||
}
|
||||
if p != "" && !seen[p] {
|
||||
seen[p] = true
|
||||
files = append(files, p)
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func abortMerge(path string) (string, error) {
|
||||
if out, err := gitRun(path, 30*time.Second, "rebase", "--abort"); err == nil {
|
||||
return out, nil
|
||||
}
|
||||
return gitRun(path, 30*time.Second, "merge", "--abort")
|
||||
}
|
||||
|
||||
// resolveDone 在用户网页上标记"已解决"后调用:暂存全部 → 继续 merge/rebase 流程
|
||||
func resolveDone(path string, rebase bool) error {
|
||||
if _, err := gitRun(path, 30*time.Second, "add", "-A"); err != nil {
|
||||
return err
|
||||
}
|
||||
if rebase {
|
||||
_, err := gitRun(path, 120*time.Second, "-c", "core.editor=true", "rebase", "--continue")
|
||||
return err
|
||||
}
|
||||
if _, err := gitRun(path, 15*time.Second, "rev-parse", "-q", "--verify", "MERGE_HEAD"); err == nil {
|
||||
_, err := gitRun(path, 120*time.Second, "commit", "--no-edit")
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
[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 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:12:27] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:12:28] 推送至 origin 失败: error: src refspec main does not match any
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
[2026-09-08 17:12:29] === 同步完成(本地领先 1,落后 1)===
|
||||
[2026-09-08 17:19:06] 监控已启动
|
||||
[2026-09-08 17:19:07] === 开始同步(启动检查,模式 push)===
|
||||
[2026-09-08 17:19:18] 检测到 3 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:19:22] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:19:23] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 2 个提交以建立远程分支
|
||||
[2026-09-08 17:19:24] 推送至 origin 失败: error: src refspec main does not match any
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
[2026-09-08 17:19:25] === 同步完成(本地领先 0,落后 0)===
|
||||
[2026-09-08 17:19:43] === 开始同步(手动触发,模式 push)===
|
||||
[2026-09-08 17:19:52] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:19:56] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:19:58] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 3 个提交以建立远程分支
|
||||
[2026-09-08 17:20:00] 推送至 origin 失败: error: src refspec main does not match any
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
[2026-09-08 17:20:00] === 同步完成(本地领先 0,落后 0)===
|
||||
[2026-09-08 17:20:25] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 17:20:34] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:20:38] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:20:39] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 4 个提交以建立远程分支
|
||||
[2026-09-08 17:20:42] 推送至 origin 失败: error: src refspec main does not match any
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
[2026-09-08 17:20:42] === 同步完成(本地领先 0,落后 0)===
|
||||
[2026-09-08 17:21:43] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 17:21:51] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:21:55] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:21:56] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 5 个提交以建立远程分支
|
||||
[2026-09-08 17:21:58] 推送至 origin 失败: error: src refspec main does not match any
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
[2026-09-08 17:21:58] === 同步完成(本地领先 0,落后 0)===
|
||||
[2026-09-08 17:22:59] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 17:23:08] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:23:12] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:23:14] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 6 个提交以建立远程分支
|
||||
[2026-09-08 17:23:16] 推送至 origin 失败: error: src refspec main does not match any
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
[2026-09-08 17:23:16] === 同步完成(本地领先 0,落后 0)===
|
||||
[2026-09-08 17:24:17] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 17:24:25] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:24:29] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:24:31] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 7 个提交以建立远程分支
|
||||
[2026-09-08 17:24:33] 推送至 origin 失败: error: src refspec main does not match any
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
[2026-09-08 17:24:34] === 同步完成(本地领先 0,落后 0)===
|
||||
[2026-09-08 17:25:34] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 17:25:42] 检测到 3 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:25:46] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:25:48] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 8 个提交以建立远程分支
|
||||
[2026-09-08 17:25:49] 推送至 origin 失败: error: src refspec main does not match any
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
[2026-09-08 17:25:50] === 同步完成(本地领先 0,落后 0)===
|
||||
[2026-09-08 17:26:50] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 17:26:58] 检测到 3 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:27:02] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:27:03] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 9 个提交以建立远程分支
|
||||
[2026-09-08 17:27:05] 推送至 origin 失败: error: src refspec main does not match any
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
[2026-09-08 17:27:05] === 同步完成(本地领先 0,落后 0)===
|
||||
[2026-09-08 17:28:06] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 17:28:14] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:28:18] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:28:19] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 10 个提交以建立远程分支
|
||||
[2026-09-08 17:28:21] 推送至 origin 失败: error: src refspec main does not match any
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
[2026-09-08 17:28:21] === 同步完成(本地领先 0,落后 0)===
|
||||
[2026-09-08 17:29:22] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 17:29:30] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:30:38] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:30:40] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 11 个提交以建立远程分支
|
||||
[2026-09-08 17:30:42] 推送至 origin 失败: error: src refspec main does not match any
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
[2026-09-08 17:30:43] === 同步完成(本地领先 0,落后 0)===
|
||||
[2026-09-08 17:31:43] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 17:31:51] 检测到 4 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:31:54] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:31:56] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 12 个提交以建立远程分支
|
||||
[2026-09-08 17:31:58] 推送至 origin 失败: error: src refspec main does not match any
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
[2026-09-08 17:31:59] === 同步完成(本地领先 0,落后 0)===
|
||||
[2026-09-08 17:32:59] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 17:33:07] 检测到 3 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:33:11] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:33:12] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 13 个提交以建立远程分支
|
||||
[2026-09-08 17:33:14] 推送至 origin 失败: error: src refspec main does not match any
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
[2026-09-08 17:33:15] === 同步完成(本地领先 0,落后 0)===
|
||||
[2026-09-08 17:34:16] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 17:34:25] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:34:31] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:34:32] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 14 个提交以建立远程分支
|
||||
[2026-09-08 17:34:34] 推送至 origin 失败: error: src refspec main does not match any
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
[2026-09-08 17:34:34] === 同步完成(本地领先 0,落后 0)===
|
||||
[2026-09-08 17:35:35] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 17:35:43] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:35:46] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:35:47] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 15 个提交以建立远程分支
|
||||
[2026-09-08 17:35:48] 推送至 origin 失败: error: src refspec main does not match any
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
[2026-09-08 17:35:49] === 同步完成(本地领先 0,落后 0)===
|
||||
[2026-09-08 17:41:09] 监控已启动
|
||||
[2026-09-08 17:41:09] === 开始同步(启动检查,模式 push)===
|
||||
[2026-09-08 17:41:20] 本地与远程均无分支 main,已基于当前 HEAD(master)创建
|
||||
[2026-09-08 17:41:21] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:41:24] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:41:26] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 16 个提交以建立远程分支
|
||||
[2026-09-08 17:51:06] 监控已启动
|
||||
[2026-09-08 17:51:06] === 开始同步(启动检查,模式 push)===
|
||||
[2026-09-08 17:51:15] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:51:18] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:51:19] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 17 个提交以建立远程分支
|
||||
[2026-09-08 17:52:48] 推送至 origin 失败: To https://git.shiroko.one/tamakyi/github-auto-sync-golang
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
hint: Updates were rejected because the tip of your current branch is behind
|
||||
hint: its remote counterpart. If you want to integrate the remote changes,
|
||||
hint: use 'git pull' before pushing again.
|
||||
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
|
||||
[2026-09-08 17:52:48] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 17:53:51] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 17:54:07] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:54:12] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:54:13] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 18 个提交以建立远程分支
|
||||
[2026-09-08 17:54:49] 推送至 origin 失败: To https://git.shiroko.one/tamakyi/github-auto-sync-golang
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
hint: Updates were rejected because the tip of your current branch is behind
|
||||
hint: its remote counterpart. If you want to integrate the remote changes,
|
||||
hint: use 'git pull' before pushing again.
|
||||
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
|
||||
[2026-09-08 17:54:49] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 17:55:49] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 17:55:58] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:56:02] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:56:03] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 19 个提交以建立远程分支
|
||||
[2026-09-08 17:57:07] 推送至 origin 失败: To https://git.shiroko.one/tamakyi/github-auto-sync-golang
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
hint: Updates were rejected because the tip of your current branch is behind
|
||||
hint: its remote counterpart. If you want to integrate the remote changes,
|
||||
hint: use 'git pull' before pushing again.
|
||||
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
|
||||
[2026-09-08 17:57:07] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 17:58:07] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 17:58:19] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 17:58:25] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 17:58:29] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 20 个提交以建立远程分支
|
||||
[2026-09-08 18:01:06] 推送至 origin 失败: To https://git.shiroko.one/tamakyi/github-auto-sync-golang
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
hint: Updates were rejected because the tip of your current branch is behind
|
||||
hint: its remote counterpart. If you want to integrate the remote changes,
|
||||
hint: use 'git pull' before pushing again.
|
||||
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
|
||||
[2026-09-08 18:01:06] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 18:02:07] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 18:02:16] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:02:20] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:02:22] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 21 个提交以建立远程分支
|
||||
[2026-09-08 18:07:32] 推送至 origin 失败: 命令超时: git push origin main
|
||||
[2026-09-08 18:07:32] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 18:08:33] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 18:08:42] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:08:47] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:08:49] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 22 个提交以建立远程分支
|
||||
[2026-09-08 18:13:59] 推送至 origin 失败: 命令超时: git push origin main
|
||||
[2026-09-08 18:13:59] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 18:14:59] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 18:15:09] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:15:13] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:15:15] 远程分支 origin/main 不存在(远程仓库可能为空或分支名不一致),将推送本地 23 个提交以建立远程分支
|
||||
[2026-09-08 18:20:25] 推送至 origin 失败: 命令超时: git push origin main
|
||||
[2026-09-08 18:20:25] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 18:21:31] 监控已启动
|
||||
[2026-09-08 18:21:31] === 开始同步(启动检查,模式 push)===
|
||||
[2026-09-08 18:21:40] 检测到 4 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:21:43] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:26:59] 推送至 origin 失败: 命令超时: git push origin main
|
||||
[2026-09-08 18:26:59] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 18:27:59] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 18:28:06] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:28:09] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:33:24] 推送至 origin 失败: 命令超时: git push origin main
|
||||
[2026-09-08 18:33:24] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 18:34:24] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 18:34:31] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:34:33] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:39:49] 推送至 origin 失败: 命令超时: git push origin main
|
||||
[2026-09-08 18:39:49] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 18:40:49] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 18:40:56] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:40:58] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:45:45] 推送至 origin 失败: To https://git.shiroko.one/tamakyi/github-auto-sync-golang
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
hint: Updates were rejected because the tip of your current branch is behind
|
||||
hint: its remote counterpart. If you want to integrate the remote changes,
|
||||
hint: use 'git pull' before pushing again.
|
||||
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
|
||||
[2026-09-08 18:45:45] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 18:46:45] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 18:46:52] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:46:54] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:47:17] 监控已启动
|
||||
[2026-09-08 18:47:17] === 开始同步(启动检查,模式 push)===
|
||||
[2026-09-08 18:47:20] 推送至 origin 失败: To https://git.shiroko.one/tamakyi/github-auto-sync-golang
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
hint: Updates were rejected because the tip of your current branch is behind
|
||||
hint: its remote counterpart. If you want to integrate the remote changes,
|
||||
hint: use 'git pull' before pushing again.
|
||||
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
|
||||
[2026-09-08 18:47:20] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 18:47:21] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:47:22] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:47:37] 推送至 origin 失败: To https://git.shiroko.one/tamakyi/github-auto-sync-golang
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
hint: Updates were rejected because the tip of your current branch is behind
|
||||
hint: its remote counterpart. If you want to integrate the remote changes,
|
||||
hint: use 'git pull' before pushing again.
|
||||
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
|
||||
[2026-09-08 18:47:38] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 18:47:45] === 开始同步(手动触发,模式 push)===
|
||||
[2026-09-08 18:47:49] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:47:49] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:47:55] 推送至 origin 失败: To https://git.shiroko.one/tamakyi/github-auto-sync-golang
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
hint: Updates were rejected because the tip of your current branch is behind
|
||||
hint: its remote counterpart. If you want to integrate the remote changes,
|
||||
hint: use 'git pull' before pushing again.
|
||||
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
|
||||
[2026-09-08 18:47:55] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 18:48:20] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 18:48:26] 配置已更新
|
||||
[2026-09-08 18:48:26] 监控已停止
|
||||
[2026-09-08 18:48:26] 监控已启动
|
||||
[2026-09-08 18:48:26] === 开始同步(启动检查,模式 push)===
|
||||
[2026-09-08 18:48:30] 检测到 3 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:48:31] 检测到 3 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:48:32] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:48:32] 自动提交失败: On branch main
|
||||
nothing to commit, working tree clean
|
||||
[2026-09-08 18:48:39] 推送至 origin 失败: To https://git.shiroko.one/tamakyi/github-auto-sync-golang
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
hint: Updates were rejected because the tip of your current branch is behind
|
||||
hint: its remote counterpart. If you want to integrate the remote changes,
|
||||
hint: use 'git pull' before pushing again.
|
||||
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
|
||||
[2026-09-08 18:48:39] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 18:49:33] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 18:49:42] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:49:47] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:50:15] 推送至 origin 失败: To https://git.shiroko.one/tamakyi/github-auto-sync-golang
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
hint: Updates were rejected because the tip of your current branch is behind
|
||||
hint: its remote counterpart. If you want to integrate the remote changes,
|
||||
hint: use 'git pull' before pushing again.
|
||||
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
|
||||
[2026-09-08 18:50:15] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 18:50:15] 监控已启动
|
||||
[2026-09-08 18:50:16] === 开始同步(启动检查,模式 push)===
|
||||
[2026-09-08 18:50:21] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:50:21] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:50:30] 推送至 origin 失败: To https://git.shiroko.one/tamakyi/github-auto-sync-golang
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
hint: Updates were rejected because the tip of your current branch is behind
|
||||
hint: its remote counterpart. If you want to integrate the remote changes,
|
||||
hint: use 'git pull' before pushing again.
|
||||
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
|
||||
[2026-09-08 18:50:30] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 18:51:15] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 18:51:24] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:51:27] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:51:31] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 18:51:35] 检测到 1 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:51:36] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:51:42] 推送至 origin 失败: To https://git.shiroko.one/tamakyi/github-auto-sync-golang
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
hint: Updates were rejected because the tip of your current branch is behind
|
||||
hint: its remote counterpart. If you want to integrate the remote changes,
|
||||
hint: use 'git pull' before pushing again.
|
||||
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
|
||||
[2026-09-08 18:51:42] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 18:52:43] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 18:52:48] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:52:49] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:52:56] 推送至 origin 失败: To https://git.shiroko.one/tamakyi/github-auto-sync-golang
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
hint: Updates were rejected because the tip of your current branch is behind
|
||||
hint: its remote counterpart. If you want to integrate the remote changes,
|
||||
hint: use 'git pull' before pushing again.
|
||||
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
|
||||
[2026-09-08 18:52:56] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 18:53:56] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 18:54:01] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:54:02] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:54:09] 推送至 origin 失败: To https://git.shiroko.one/tamakyi/github-auto-sync-golang
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
hint: Updates were rejected because the tip of your current branch is behind
|
||||
hint: its remote counterpart. If you want to integrate the remote changes,
|
||||
hint: use 'git pull' before pushing again.
|
||||
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
|
||||
[2026-09-08 18:54:09] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
[2026-09-08 18:55:09] === 开始同步(定时检查,模式 push)===
|
||||
[2026-09-08 18:55:14] 检测到 2 个未提交改动,执行自动提交
|
||||
[2026-09-08 18:55:15] 自动提交完成,将随本轮同步推送
|
||||
[2026-09-08 18:55:22] 推送至 origin 失败: To https://git.shiroko.one/tamakyi/github-auto-sync-golang
|
||||
! [rejected] main -> main (non-fast-forward)
|
||||
error: failed to push some refs to 'https://git.shiroko.one/tamakyi/github-auto-sync-golang'
|
||||
hint: Updates were rejected because the tip of your current branch is behind
|
||||
hint: its remote counterpart. If you want to integrate the remote changes,
|
||||
hint: use 'git pull' before pushing again.
|
||||
hint: See the 'Note about fast-forwards' in 'git push --help' for details.
|
||||
[2026-09-08 18:55:22] 推送失败,本轮同步已终止(跳过拉取以避免分叉加剧)
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"flag"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
//go:embed static
|
||||
var staticFS embed.FS
|
||||
|
||||
func main() {
|
||||
baseDir := flag.String("dir", ".", "项目根目录(包含 configs/ logs/ stats/)")
|
||||
addr := flag.String("addr", "127.0.0.1:5757", "监听地址")
|
||||
password := flag.String("password", "", "访问密码(为空则不启用鉴权)")
|
||||
flag.Parse()
|
||||
|
||||
engine, err := NewEngine(*baseDir)
|
||||
if err != nil {
|
||||
log.Fatalf("初始化失败: %v", err)
|
||||
}
|
||||
engine.LoadAll()
|
||||
engine.StartAll()
|
||||
|
||||
sub, err := fs.Sub(staticFS, "static")
|
||||
if err != nil {
|
||||
log.Fatalf("读取内嵌静态资源失败: %v", err)
|
||||
}
|
||||
|
||||
srv := NewServer(engine, sub, *password)
|
||||
log.Printf("Git Sync Web 已启动: http://%s(鉴权:%t)", *addr, *password != "")
|
||||
if err := http.ListenAndServe(*addr, srv); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
engine *Engine
|
||||
mux *http.ServeMux
|
||||
password string
|
||||
|
||||
tokenMu sync.Mutex
|
||||
tokens map[string]time.Time
|
||||
}
|
||||
|
||||
func NewServer(e *Engine, static fs.FS, password string) *Server {
|
||||
s := &Server{
|
||||
engine: e,
|
||||
mux: http.NewServeMux(),
|
||||
password: password,
|
||||
tokens: map[string]time.Time{},
|
||||
}
|
||||
mux := s.mux
|
||||
mux.HandleFunc("GET /api/status", s.handleStatus)
|
||||
mux.HandleFunc("GET /api/events", s.handleEvents)
|
||||
mux.HandleFunc("POST /api/login", s.handleLogin)
|
||||
mux.HandleFunc("POST /api/logout", s.handleLogout)
|
||||
mux.HandleFunc("POST /api/repos", s.handleCreate)
|
||||
mux.HandleFunc("GET /api/repos/{name}", s.wrapRepo(s.handleGet))
|
||||
mux.HandleFunc("PUT /api/repos/{name}", s.wrapRepo(s.handleUpdate))
|
||||
mux.HandleFunc("DELETE /api/repos/{name}", s.wrapRepo(s.handleDelete))
|
||||
mux.HandleFunc("POST /api/repos/{name}/start", s.wrapRepo(s.startRepo))
|
||||
mux.HandleFunc("POST /api/repos/{name}/stop", s.wrapRepo(s.stopRepo))
|
||||
mux.HandleFunc("POST /api/repos/{name}/sync", s.wrapRepo(s.syncRepo))
|
||||
mux.HandleFunc("POST /api/repos/{name}/action", s.wrapRepo(s.actionRepo))
|
||||
mux.HandleFunc("GET /api/repos/{name}/conflicts", s.wrapRepo(s.conflictsRepo))
|
||||
mux.HandleFunc("GET /api/repos/{name}/log", s.wrapRepo(s.logRepo))
|
||||
mux.HandleFunc("GET /api/repos/{name}/stats", s.wrapRepo(s.statsRepo))
|
||||
mux.HandleFunc("GET /api/repos/{name}/commits", s.wrapRepo(s.commitsRepo))
|
||||
mux.HandleFunc("GET /api/repos/{name}/commits/{hash}", s.wrapRepo(s.commitDiffRepo))
|
||||
mux.Handle("/", http.FileServer(http.FS(static)))
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// 访问密码保护:静态资源开放,/api/* 需要会话(登录接口除外)
|
||||
if s.password != "" && strings.HasPrefix(r.URL.Path, "/api/") &&
|
||||
r.URL.Path != "/api/login" && !s.validToken(r) {
|
||||
writeErr(w, 401, "需要登录")
|
||||
return
|
||||
}
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) validToken(r *http.Request) bool {
|
||||
c, err := r.Cookie("git_sync_token")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
s.tokenMu.Lock()
|
||||
defer s.tokenMu.Unlock()
|
||||
exp, ok := s.tokens[c.Value]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(exp) {
|
||||
delete(s.tokens, c.Value)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, "请求体格式错误")
|
||||
return
|
||||
}
|
||||
if subtle.ConstantTimeCompare([]byte(req.Password), []byte(s.password)) != 1 {
|
||||
writeErr(w, 401, "密码错误")
|
||||
return
|
||||
}
|
||||
buf := make([]byte, 32)
|
||||
if _, err := rand.Read(buf); err != nil {
|
||||
writeErr(w, 500, "生成会话失败")
|
||||
return
|
||||
}
|
||||
token := hex.EncodeToString(buf)
|
||||
s.tokenMu.Lock()
|
||||
s.tokens[token] = time.Now().Add(30 * 24 * time.Hour)
|
||||
s.tokenMu.Unlock()
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "git_sync_token", Value: token, Path: "/",
|
||||
HttpOnly: true, MaxAge: 30 * 24 * 3600,
|
||||
})
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if c, err := r.Cookie("git_sync_token"); err == nil {
|
||||
s.tokenMu.Lock()
|
||||
delete(s.tokens, c.Value)
|
||||
s.tokenMu.Unlock()
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "git_sync_token", Value: "", Path: "/", MaxAge: -1})
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, code int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeErr(w http.ResponseWriter, code int, msg string) {
|
||||
writeJSON(w, code, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
func (s *Server) wrapRepo(h func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
name := r.PathValue("name")
|
||||
if s.engine.get(name) == nil {
|
||||
writeErr(w, 404, "配置不存在: "+name)
|
||||
return
|
||||
}
|
||||
h(w, r, name)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, 200, map[string]any{"repos": s.engine.Snapshots(true)})
|
||||
}
|
||||
|
||||
func (s *Server) handleGet(w http.ResponseWriter, r *http.Request, name string) {
|
||||
if st := s.engine.get(name); st != nil {
|
||||
writeJSON(w, 200, st.snapshot(true))
|
||||
return
|
||||
}
|
||||
writeErr(w, 404, "配置不存在")
|
||||
}
|
||||
|
||||
func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||
var cfg RepoConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||
writeErr(w, 400, "请求体格式错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.engine.Create(&cfg); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "name": cfg.Name})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request, name string) {
|
||||
var cfg RepoConfig
|
||||
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||
writeErr(w, 400, "请求体格式错误: "+err.Error())
|
||||
return
|
||||
}
|
||||
cfg.Name = name
|
||||
if err := s.engine.Update(&cfg); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, name string) {
|
||||
if err := s.engine.Delete(name); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) startRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
if err := s.engine.Start(name); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) stopRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
if err := s.engine.Stop(name); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) syncRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
if err := s.engine.SyncNow(name); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) actionRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
var req struct {
|
||||
Action string `json:"action"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, "请求体格式错误")
|
||||
return
|
||||
}
|
||||
switch req.Action {
|
||||
case "abort":
|
||||
if err := s.engine.ConflictAbort(name); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
case "resolve":
|
||||
if err := s.engine.ConflictResolve(name); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
case "fetch", "pull", "push":
|
||||
if err := s.engine.ManualGit(name, req.Action); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
default:
|
||||
writeErr(w, 400, "未知操作: "+req.Action)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) conflictsRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
writeJSON(w, 200, s.engine.ConflictDiffs(name))
|
||||
}
|
||||
|
||||
func (s *Server) logRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
n := 300
|
||||
if v, err := strconv.Atoi(r.URL.Query().Get("lines")); err == nil && v > 0 && v <= 5000 {
|
||||
n = v
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"lines": s.engine.ReadLog(name, n)})
|
||||
}
|
||||
|
||||
func (s *Server) statsRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
writeJSON(w, 200, s.engine.ReadStats(name))
|
||||
}
|
||||
|
||||
func (s *Server) commitsRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
limit := 50
|
||||
if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 && v <= 200 {
|
||||
limit = v
|
||||
}
|
||||
writeJSON(w, 200, s.engine.ReadCommits(name, limit))
|
||||
}
|
||||
|
||||
func (s *Server) commitDiffRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
diff, err := s.engine.ReadCommitDiff(name, r.PathValue("hash"))
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]string{"diff": diff})
|
||||
}
|
||||
|
||||
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
|
||||
fl, ok := w.(http.Flusher)
|
||||
if !ok {
|
||||
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.Header().Set("X-Accel-Buffering", "no")
|
||||
|
||||
id, ch := s.engine.Bus().Subscribe()
|
||||
defer s.engine.Bus().Unsubscribe(id)
|
||||
|
||||
if snap, err := json.Marshal(map[string]any{"type": "snapshot", "data": s.engine.Snapshots(true)}); err == nil {
|
||||
fmt.Fprintf(w, "data: %s\n\n", snap)
|
||||
fl.Flush()
|
||||
}
|
||||
|
||||
tick := time.NewTicker(25 * time.Second)
|
||||
defer tick.Stop()
|
||||
ctx := r.Context()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-tick.C:
|
||||
fmt.Fprint(w, ": ping\n\n")
|
||||
fl.Flush()
|
||||
case data, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "data: %s\n\n", data)
|
||||
fl.Flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
+419
@@ -0,0 +1,419 @@
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
|
||||
const STATUS = {
|
||||
ok: { text: '正常', cls: 'st-ok' },
|
||||
error: { text: '异常', cls: 'st-error' },
|
||||
conflict: { text: '冲突', cls: 'st-conflict' },
|
||||
running: { text: '同步中', cls: 'st-running' },
|
||||
idle: { text: '未启动', cls: 'st-idle' },
|
||||
};
|
||||
const RESULT_TEXT = { ok: '成功', error: '失败', conflict: '冲突' };
|
||||
|
||||
let repos = [];
|
||||
let current = null;
|
||||
let creating = false;
|
||||
let chart = null;
|
||||
|
||||
// ---------- 基础 ----------
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
async function api(url, opts) {
|
||||
const res = await fetch(url, opts);
|
||||
if (res.status === 401) {
|
||||
showLogin();
|
||||
throw new Error('需要登录');
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.error || res.statusText);
|
||||
return data;
|
||||
}
|
||||
const post = (url) => api(url, { method: 'POST' });
|
||||
|
||||
// ---------- 渲染 ----------
|
||||
function render() {
|
||||
const grid = $('#grid');
|
||||
grid.innerHTML = '';
|
||||
repos.forEach(r => grid.appendChild(card(r)));
|
||||
$('#empty').hidden = repos.length > 0;
|
||||
}
|
||||
|
||||
function card(r) {
|
||||
const st = STATUS[r.status] || STATUS.idle;
|
||||
const el = document.createElement('div');
|
||||
el.className = 'card ' + st.cls;
|
||||
el.dataset.name = r.name;
|
||||
const last = r.last_sync
|
||||
? `${r.last_sync.time.slice(5, 16)} · ${RESULT_TEXT[r.last_sync.result] || r.last_sync.result}`
|
||||
: '从未同步';
|
||||
el.innerHTML = `
|
||||
<div class="card-head">
|
||||
<span class="dot"></span>
|
||||
<h3>${esc(r.name)}</h3>
|
||||
<span class="mode">${esc(r.mode)}</span>
|
||||
</div>
|
||||
<div class="path" title="${esc(r.repo_path)}">${esc(r.repo_path)}</div>
|
||||
<div class="badges">
|
||||
<span class="badge">${esc(r.branch)}</span>
|
||||
<span class="badge ${r.ahead ? 'b-up' : ''}">领先 ↑${r.ahead}</span>
|
||||
<span class="badge ${r.behind ? 'b-down' : ''}">落后 ↓${r.behind}</span>
|
||||
${r.dirty ? `<span class="badge b-dirty">改动 ${r.dirty}</span>` : ''}
|
||||
${r.auto_commit ? '<span class="badge">自动提交</span>' : ''}
|
||||
${r.cron ? `<span class="badge" title="${esc(r.cron)}">cron</span>` : ''}
|
||||
${r.conflicts && r.conflicts.length ? `<span class="badge b-conflict">冲突 ${r.conflicts.length}</span>` : ''}
|
||||
</div>
|
||||
<div class="last">上次同步:${esc(last)}</div>
|
||||
${r.status === 'error' && r.last_error ? `<div class="err" title="${esc(r.last_error)}">${esc(r.last_error)}</div>` : ''}
|
||||
<div class="card-btns">
|
||||
<button class="btn" data-act="sync" ${r.running ? '' : 'disabled'}>立即同步</button>
|
||||
<button class="btn" data-act="toggle">${r.running ? '暂停' : '启动'}</button>
|
||||
<button class="btn" data-act="detail">详情</button>
|
||||
</div>`;
|
||||
return el;
|
||||
}
|
||||
|
||||
$('#grid').addEventListener('click', async (e) => {
|
||||
const btn = e.target.closest('button[data-act]');
|
||||
if (!btn) return;
|
||||
const name = btn.closest('.card').dataset.name;
|
||||
const act = btn.dataset.act;
|
||||
try {
|
||||
if (act === 'sync') {
|
||||
await post(`/api/repos/${name}/sync`);
|
||||
} else if (act === 'toggle') {
|
||||
const r = repos.find(x => x.name === name);
|
||||
await post(`/api/repos/${name}/${r.running ? 'stop' : 'start'}`);
|
||||
} else if (act === 'detail') {
|
||||
openDetail(name);
|
||||
}
|
||||
} catch (err) { alert(err.message); }
|
||||
});
|
||||
|
||||
// ---------- SSE 实时事件 ----------
|
||||
function connect() {
|
||||
const es = new EventSource('/api/events');
|
||||
es.onopen = () => setConn(true);
|
||||
es.onerror = () => setConn(false); // EventSource 会自动重连
|
||||
es.onmessage = (ev) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(ev.data); } catch { return; }
|
||||
if (msg.type === 'snapshot') {
|
||||
repos = msg.data || [];
|
||||
render();
|
||||
} else if (msg.type === 'status') {
|
||||
const i = repos.findIndex(x => x.name === msg.repo);
|
||||
if (i >= 0) repos[i] = msg.data; else repos.push(msg.data);
|
||||
render();
|
||||
} else if (msg.type === 'log') {
|
||||
if (current === msg.repo && !$('#tab-log').hidden) appendLogLine(`[${msg.time}] ${msg.msg}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function setConn(ok) {
|
||||
const el = $('#conn');
|
||||
el.textContent = ok ? '● 已连接' : '● 未连接';
|
||||
el.className = 'conn ' + (ok ? 'on' : 'off');
|
||||
}
|
||||
|
||||
// ---------- 登录 ----------
|
||||
function showLogin() {
|
||||
const el = $('#login');
|
||||
if (el.hidden) {
|
||||
el.hidden = false;
|
||||
setTimeout(() => $('#login-password').focus(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
async function doLogin() {
|
||||
const errEl = $('#login-err');
|
||||
errEl.hidden = true;
|
||||
try {
|
||||
await fetch('/api/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: $('#login-password').value })
|
||||
}).then(async res => {
|
||||
if (!res.ok) {
|
||||
const d = await res.json().catch(() => ({}));
|
||||
throw new Error(d.error || '登录失败');
|
||||
}
|
||||
});
|
||||
location.reload();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message;
|
||||
errEl.hidden = false;
|
||||
}
|
||||
}
|
||||
$('#btn-login').addEventListener('click', doLogin);
|
||||
$('#login-password').addEventListener('keydown', (e) => { if (e.key === 'Enter') doLogin(); });
|
||||
|
||||
// ---------- 详情弹窗 ----------
|
||||
function showTab(t) {
|
||||
document.querySelectorAll('#m-tabs .tab').forEach(b => b.classList.toggle('active', b.dataset.tab === t));
|
||||
document.querySelectorAll('.tab-pane').forEach(p => { p.hidden = p.id !== 'tab-' + t; });
|
||||
if (t === 'log' && current) loadLog(current);
|
||||
if (t === 'history' && current) loadHistory(current);
|
||||
if (t === 'conflict' && current) loadConflicts(current);
|
||||
if (t === 'stats' && current) loadStats(current);
|
||||
}
|
||||
|
||||
$('#m-tabs').addEventListener('click', (e) => {
|
||||
const b = e.target.closest('.tab');
|
||||
if (b) showTab(b.dataset.tab);
|
||||
});
|
||||
|
||||
function openDetail(name) {
|
||||
creating = false;
|
||||
current = name;
|
||||
const r = repos.find(x => x.name === name);
|
||||
$('#m-title').textContent = name;
|
||||
$('#f-name').disabled = true;
|
||||
$('#btn-delete').hidden = false;
|
||||
fillForm(r);
|
||||
showTab('log');
|
||||
$('#modal').hidden = false;
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
creating = true;
|
||||
current = null;
|
||||
$('#m-title').textContent = '新建配置';
|
||||
$('#f-name').disabled = false;
|
||||
$('#btn-delete').hidden = true;
|
||||
fillForm(null);
|
||||
showTab('config');
|
||||
$('#modal').hidden = false;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
$('#modal').hidden = true;
|
||||
current = null;
|
||||
}
|
||||
|
||||
$('#m-close').addEventListener('click', closeModal);
|
||||
$('#modal').addEventListener('click', (e) => { if (e.target === $('#modal')) closeModal(); });
|
||||
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal(); });
|
||||
|
||||
// ---------- 日志 ----------
|
||||
function loadLog(name) {
|
||||
$('#logbox').textContent = '加载中…';
|
||||
api(`/api/repos/${name}/log?lines=500`).then(d => {
|
||||
$('#logbox').textContent = d.lines && d.lines.length ? d.lines.join('\n') : '(暂无日志)';
|
||||
$('#logbox').scrollTop = $('#logbox').scrollHeight;
|
||||
}).catch(err => { $('#logbox').textContent = '日志加载失败:' + err.message; });
|
||||
}
|
||||
|
||||
function appendLogLine(line) {
|
||||
const el = $('#logbox');
|
||||
if (el.textContent === '(暂无日志)') el.textContent = '';
|
||||
el.textContent += (el.textContent ? '\n' : '') + line;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
|
||||
// ---------- 提交历史 ----------
|
||||
function loadHistory(name) {
|
||||
const list = $('#commit-list');
|
||||
list.textContent = '加载中…';
|
||||
api(`/api/repos/${name}/commits?limit=50`).then(commits => {
|
||||
if (!commits.length) {
|
||||
list.textContent = '暂无提交记录。';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = commits.map(c => `
|
||||
<div class="commit" data-hash="${esc(c.hash)}">
|
||||
<div class="commit-head">
|
||||
<span class="commit-hash">${esc(c.hash.slice(0, 8))}</span>
|
||||
<span class="commit-subject" title="${esc(c.subject)}">${esc(c.subject)}</span>
|
||||
<span class="commit-meta">${esc(c.author)} · ${esc(c.date)}</span>
|
||||
</div>
|
||||
<pre class="commit-diff" hidden></pre>
|
||||
</div>`).join('');
|
||||
}).catch(err => { list.textContent = '提交历史加载失败:' + err.message; });
|
||||
}
|
||||
|
||||
$('#commit-list').addEventListener('click', async (e) => {
|
||||
const head = e.target.closest('.commit-head');
|
||||
if (!head || !current) return;
|
||||
const item = head.closest('.commit');
|
||||
const pre = item.querySelector('.commit-diff');
|
||||
if (!pre.hidden) { pre.hidden = true; return; }
|
||||
pre.hidden = false;
|
||||
pre.textContent = '加载中…';
|
||||
try {
|
||||
const d = await api(`/api/repos/${current}/commits/${item.dataset.hash}`);
|
||||
pre.textContent = d.diff || '(无差异内容)';
|
||||
} catch (err) {
|
||||
pre.textContent = '加载失败:' + err.message;
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 冲突 ----------
|
||||
function loadConflicts(name) {
|
||||
api(`/api/repos/${name}/conflicts`).then(files => {
|
||||
const list = $('#conflict-list');
|
||||
if (!files.length) {
|
||||
list.innerHTML = '<p style="color:var(--muted)">当前没有冲突。</p>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = files.map(f =>
|
||||
`<div class="conflict-file"><h4>${esc(f.file)}</h4><pre>${esc(f.diff) || '(无差异内容)'}</pre></div>`
|
||||
).join('');
|
||||
}).catch(err => { $('#conflict-list').innerHTML = `<p style="color:var(--red)">${esc(err.message)}</p>`; });
|
||||
}
|
||||
|
||||
async function doAction(action, okMsg) {
|
||||
try {
|
||||
await api(`/api/repos/${current}/action`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ action })
|
||||
});
|
||||
alert(okMsg);
|
||||
loadConflicts(current);
|
||||
} catch (err) { alert(err.message); }
|
||||
}
|
||||
$('#btn-abort').addEventListener('click', () => {
|
||||
if (confirm('确定放弃本次合并/变基?仓库将回到冲突前状态。')) doAction('abort', '已放弃本次合并');
|
||||
});
|
||||
$('#btn-resolved').addEventListener('click', () => doAction('resolve', '冲突处理流程完成'));
|
||||
|
||||
// ---------- 统计 ----------
|
||||
function dateStr(d) {
|
||||
return d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
||||
}
|
||||
|
||||
function loadStats(name) {
|
||||
api(`/api/repos/${name}/stats`).then(rows => {
|
||||
const days = [];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() - i);
|
||||
days.push(dateStr(d));
|
||||
}
|
||||
const mk = (key) => days.map(d => rows.filter(r => r.result === key && r.time.slice(0, 10) === d).length);
|
||||
const total = { ok: 0, error: 0, conflict: 0 };
|
||||
rows.forEach(r => { if (total[r.result] !== undefined) total[r.result]++; });
|
||||
$('#stat-summary').innerHTML =
|
||||
`<span>近 30 天共 <b>${rows.length}</b> 次同步记录</span>` +
|
||||
`<span style="color:var(--green)">成功 <b>${total.ok}</b></span>` +
|
||||
`<span style="color:var(--red)">失败 <b>${total.error}</b></span>` +
|
||||
`<span style="color:var(--amber)">冲突 <b>${total.conflict}</b></span>`;
|
||||
if (chart) { chart.destroy(); chart = null; }
|
||||
if (!window.Chart) return;
|
||||
chart = new Chart($('#stat-chart'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: days.map(d => d.slice(5)),
|
||||
datasets: [
|
||||
{ label: '成功', data: mk('ok'), backgroundColor: '#97c459' },
|
||||
{ label: '失败', data: mk('error'), backgroundColor: '#f09595' },
|
||||
{ label: '冲突', data: mk('conflict'), backgroundColor: '#ef9f27' },
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
scales: {
|
||||
x: { stacked: true },
|
||||
y: { stacked: true, ticks: { precision: 0 } }
|
||||
},
|
||||
plugins: { legend: { position: 'bottom' } }
|
||||
}
|
||||
});
|
||||
}).catch(err => { $('#stat-summary').textContent = '统计加载失败:' + err.message; });
|
||||
}
|
||||
|
||||
// ---------- 配置表单 ----------
|
||||
function fillForm(r) {
|
||||
$('#f-name').value = r ? r.name : '';
|
||||
$('#f-path').value = r ? r.repo_path : '';
|
||||
$('#f-branch').value = r ? r.branch : 'main';
|
||||
$('#f-mode').value = r ? r.mode : 'both';
|
||||
$('#f-remotes').value = r && r.remotes ? r.remotes.map(x => x.name + '=' + x.url).join('\n') : '';
|
||||
$('#f-interval').value = r ? r.interval : 60;
|
||||
$('#f-auth').value = r ? r.auth_method : 'ssh';
|
||||
$('#f-conflict').value = r ? r.conflict_strategy : 'merge';
|
||||
$('#f-retry').value = r ? r.retry_count : 3;
|
||||
$('#f-retry-interval').value = r ? r.retry_interval : 5;
|
||||
$('#f-autocommit').checked = r ? !!r.auto_commit : false;
|
||||
$('#f-cron').value = r ? (r.cron || '') : '';
|
||||
$('#f-log').value = r ? (r.log_file || '') : '';
|
||||
}
|
||||
|
||||
function readForm() {
|
||||
const remotes = [];
|
||||
$('#f-remotes').value.split('\n').forEach(line => {
|
||||
line = line.trim();
|
||||
if (!line) return;
|
||||
const i = line.indexOf('=');
|
||||
if (i > 0) remotes.push({ name: line.slice(0, i).trim(), url: line.slice(i + 1).trim() });
|
||||
});
|
||||
return {
|
||||
name: $('#f-name').value.trim(),
|
||||
repo_path: $('#f-path').value.trim(),
|
||||
branch: $('#f-branch').value.trim() || 'main',
|
||||
remotes,
|
||||
mode: $('#f-mode').value,
|
||||
interval: parseInt($('#f-interval').value, 10) || 60,
|
||||
auth_method: $('#f-auth').value,
|
||||
retry_count: parseInt($('#f-retry').value, 10) || 0,
|
||||
retry_interval: parseInt($('#f-retry-interval').value, 10) || 0,
|
||||
conflict_strategy: $('#f-conflict').value,
|
||||
auto_commit: $('#f-autocommit').checked,
|
||||
cron: $('#f-cron').value.trim(),
|
||||
log_file: $('#f-log').value.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
$('#cfg-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const cfg = readForm();
|
||||
try {
|
||||
if (creating) {
|
||||
await api('/api/repos', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg)
|
||||
});
|
||||
} else {
|
||||
await api(`/api/repos/${cfg.name}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cfg)
|
||||
});
|
||||
}
|
||||
closeModal();
|
||||
refresh();
|
||||
} catch (err) { alert('保存失败:' + err.message); }
|
||||
});
|
||||
|
||||
$('#btn-delete').addEventListener('click', async () => {
|
||||
if (!current) return;
|
||||
if (!confirm(`确定删除配置 "${current}" ?(不影响日志和统计文件)`)) return;
|
||||
try {
|
||||
await api(`/api/repos/${current}`, { method: 'DELETE' });
|
||||
closeModal();
|
||||
refresh();
|
||||
} catch (err) { alert(err.message); }
|
||||
});
|
||||
|
||||
// ---------- 顶部按钮 ----------
|
||||
$('#btn-new').addEventListener('click', openCreate);
|
||||
$('#btn-empty-new').addEventListener('click', openCreate);
|
||||
$('#btn-sync-all').addEventListener('click', () => {
|
||||
repos.filter(r => r.running).forEach(r => post(`/api/repos/${r.name}/sync`).catch(() => {}));
|
||||
});
|
||||
|
||||
// ---------- 初始化 ----------
|
||||
async function refresh() {
|
||||
try {
|
||||
const d = await api('/api/status');
|
||||
repos = d.repos || [];
|
||||
render();
|
||||
} catch (e) { /* 忽略,SSE 会重试 */ }
|
||||
}
|
||||
|
||||
refresh();
|
||||
connect();
|
||||
setInterval(refresh, 20000);
|
||||
@@ -0,0 +1,143 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Git Sync 控制台</title>
|
||||
<link rel="stylesheet" href="/style.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="brand"><span class="brand-dot"></span>Git Sync 控制台</div>
|
||||
<div class="header-actions">
|
||||
<span id="conn" class="conn">连接中…</span>
|
||||
<button class="btn" id="btn-sync-all">全部同步</button>
|
||||
<button class="btn btn-primary" id="btn-new">+ 新建配置</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<div id="grid" class="grid"></div>
|
||||
<div id="empty" class="empty" hidden>
|
||||
<p>还没有任何仓库配置。</p>
|
||||
<button class="btn btn-primary" id="btn-empty-new">创建第一个配置</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="modal" class="modal" hidden>
|
||||
<div class="modal-box">
|
||||
<div class="modal-head">
|
||||
<h2 id="m-title">详情</h2>
|
||||
<button class="btn-icon" id="m-close" title="关闭">✕</button>
|
||||
</div>
|
||||
<div class="tabs" id="m-tabs">
|
||||
<button data-tab="log" class="tab active">日志</button>
|
||||
<button data-tab="history" class="tab">历史</button>
|
||||
<button data-tab="conflict" class="tab">冲突</button>
|
||||
<button data-tab="stats" class="tab">统计</button>
|
||||
<button data-tab="config" class="tab">配置</button>
|
||||
</div>
|
||||
<div class="tab-body">
|
||||
<div id="tab-log" class="tab-pane">
|
||||
<pre id="logbox" class="logbox">加载中…</pre>
|
||||
</div>
|
||||
<div id="tab-history" class="tab-pane" hidden>
|
||||
<div id="commit-list" class="commit-list">加载中…</div>
|
||||
</div>
|
||||
<div id="tab-conflict" class="tab-pane" hidden>
|
||||
<div class="conflict-actions">
|
||||
<button class="btn btn-danger" id="btn-abort">放弃合并</button>
|
||||
<button class="btn btn-primary" id="btn-resolved">我已在本地解决,继续</button>
|
||||
</div>
|
||||
<div id="conflict-list"></div>
|
||||
</div>
|
||||
<div id="tab-stats" class="tab-pane" hidden>
|
||||
<div id="stat-summary" class="stat-summary"></div>
|
||||
<canvas id="stat-chart" height="120"></canvas>
|
||||
</div>
|
||||
<div id="tab-config" class="tab-pane" hidden>
|
||||
<form id="cfg-form" autocomplete="off">
|
||||
<div class="form-grid">
|
||||
<label>配置名称
|
||||
<input name="name" id="f-name" required pattern="[A-Za-z0-9_\-]+" title="仅字母、数字、下划线、连字符">
|
||||
</label>
|
||||
<label>分支
|
||||
<input name="branch" id="f-branch" value="main">
|
||||
</label>
|
||||
<label class="full">仓库路径
|
||||
<input name="repo_path" id="f-path" placeholder="C:\path\to\repo 或 /c/path/to/repo" required>
|
||||
</label>
|
||||
<label class="full">远程仓库(每行一个,格式 名称=URL)
|
||||
<textarea name="remotes" id="f-remotes" rows="3" placeholder="origin=https://github.com/user/repo.git"></textarea>
|
||||
</label>
|
||||
<label>操作模式
|
||||
<select name="mode" id="f-mode">
|
||||
<option value="both">both(拉取并推送)</option>
|
||||
<option value="push">push(仅推送)</option>
|
||||
<option value="pull">pull(仅拉取)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>检查间隔(秒)
|
||||
<input name="interval" id="f-interval" type="number" min="5" value="60">
|
||||
</label>
|
||||
<label>认证方式
|
||||
<select name="auth_method" id="f-auth">
|
||||
<option value="ssh">SSH</option>
|
||||
<option value="https">HTTPS(支持重试)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>冲突策略
|
||||
<select name="conflict_strategy" id="f-conflict">
|
||||
<option value="merge">merge(冲突后等待人工解决)</option>
|
||||
<option value="rebase">rebase(变基)</option>
|
||||
<option value="abort">abort(冲突后自动放弃)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>重试次数(HTTPS)
|
||||
<input name="retry_count" id="f-retry" type="number" min="0" value="3">
|
||||
</label>
|
||||
<label>重试间隔(秒,HTTPS)
|
||||
<input name="retry_interval" id="f-retry-interval" type="number" min="0" value="5">
|
||||
</label>
|
||||
<label class="full check-row">
|
||||
<input type="checkbox" id="f-autocommit">
|
||||
<span>自动提交模式:检测到未提交改动时自动 commit,并随同步推送到远程</span>
|
||||
</label>
|
||||
<label class="full">Cron 表达式(可选,5 段:分 时 日 月 周;填写后优先于固定间隔)
|
||||
<input name="cron" id="f-cron" placeholder="*/30 * * * * 表示每 30 分钟,留空使用固定间隔">
|
||||
</label>
|
||||
<label class="full">日志文件路径(留空使用默认 logs/配置名.log)
|
||||
<input name="log_file" id="f-log">
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">保存</button>
|
||||
<button type="button" class="btn btn-danger" id="btn-delete" hidden>删除配置</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="login" class="modal" hidden>
|
||||
<div class="modal-box login-box">
|
||||
<div class="modal-head">
|
||||
<h2>Git Sync 控制台</h2>
|
||||
</div>
|
||||
<div class="login-body">
|
||||
<label>访问密码
|
||||
<input type="password" id="login-password">
|
||||
</label>
|
||||
<p id="login-err" class="login-err" hidden></p>
|
||||
<div class="form-actions">
|
||||
<button class="btn btn-primary" id="btn-login">登录</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,97 @@
|
||||
:root {
|
||||
--bg: #f6f7f9; --card: #fff; --border: #e3e5e8; --text: #1f2328; --muted: #6a737d;
|
||||
--blue: #185fa5; --green: #3b6d11; --green-bg: #eaf3de; --red: #a32d2d; --red-bg: #fcebeb;
|
||||
--amber: #854f0b; --amber-bg: #faeeda; --gray-bg: #f1efe8; --radius: 12px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; font: 14px/1.6 -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif; background: var(--bg); color: var(--text); }
|
||||
|
||||
header { display: flex; justify-content: space-between; align-items: center; padding: 12px 24px; background: #fff; border-bottom: 1px solid var(--border); position: sticky; top: 0; z-index: 10; }
|
||||
.brand { font-weight: 500; font-size: 16px; display: flex; align-items: center; gap: 8px; }
|
||||
.brand-dot { width: 10px; height: 10px; border-radius: 50%; background: var(--blue); display: inline-block; }
|
||||
.header-actions { display: flex; gap: 8px; align-items: center; }
|
||||
.conn { font-size: 12px; color: var(--muted); margin-right: 8px; }
|
||||
.conn.on { color: var(--green); }
|
||||
.conn.off { color: var(--red); }
|
||||
|
||||
.btn { border: 1px solid var(--border); background: #fff; border-radius: 8px; padding: 6px 14px; font-size: 13px; cursor: pointer; color: var(--text); }
|
||||
.btn:hover { border-color: #b6bcc4; background: #f3f4f6; }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-primary { background: var(--blue); border-color: var(--blue); color: #fff; }
|
||||
.btn-primary:hover { background: #124d86; border-color: #124d86; }
|
||||
.btn-danger { background: #fff; border-color: #e2b6b6; color: var(--red); }
|
||||
.btn-danger:hover { background: var(--red-bg); }
|
||||
.btn-icon { border: none; background: none; font-size: 18px; cursor: pointer; color: var(--muted); }
|
||||
|
||||
main { max-width: 1200px; margin: 24px auto; padding: 0 24px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; }
|
||||
.empty { text-align: center; color: var(--muted); padding: 80px 0; }
|
||||
|
||||
.card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.card.st-conflict { border-color: #e8c98a; background: #fffdf6; }
|
||||
.card.st-error { border-color: #eec9c9; }
|
||||
.card-head { display: flex; align-items: center; gap: 8px; }
|
||||
.card-head h3 { margin: 0; font-size: 15px; font-weight: 500; flex: 1; }
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
|
||||
.st-ok .dot { background: #4f9e33; }
|
||||
.st-error .dot { background: #d34b4b; }
|
||||
.st-conflict .dot { background: #e39a1d; }
|
||||
.st-idle .dot { background: #b4b2a9; }
|
||||
.st-running .dot { background: #378add; animation: pulse 1.2s infinite; }
|
||||
@keyframes pulse { 50% { opacity: .35; } }
|
||||
.mode { font-size: 11px; color: var(--muted); border: 1px solid var(--border); padding: 1px 6px; border-radius: 6px; }
|
||||
.path { font-size: 12px; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-family: Consolas, monospace; }
|
||||
.badges { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.badge { font-size: 12px; background: var(--gray-bg); border-radius: 6px; padding: 1px 8px; color: #444; }
|
||||
.badge.b-up { background: var(--green-bg); color: var(--green); }
|
||||
.badge.b-down { background: #e6f1fb; color: var(--blue); }
|
||||
.badge.b-dirty { background: var(--amber-bg); color: var(--amber); }
|
||||
.badge.b-conflict { background: var(--red-bg); color: var(--red); }
|
||||
.last { font-size: 12px; color: var(--muted); }
|
||||
.err { font-size: 12px; color: var(--red); background: var(--red-bg); border-radius: 8px; padding: 6px 10px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.card-btns { display: flex; gap: 8px; margin-top: auto; }
|
||||
|
||||
.modal { position: fixed; inset: 0; background: rgba(0,0,0,.35); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal[hidden] { display: none; }
|
||||
.modal-box { background: #fff; border-radius: 16px; width: min(860px, 94vw); max-height: 88vh; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.modal-head { display: flex; justify-content: space-between; align-items: center; padding: 14px 20px; border-bottom: 1px solid var(--border); }
|
||||
.modal-head h2 { margin: 0; font-size: 16px; font-weight: 500; }
|
||||
.tabs { display: flex; gap: 4px; padding: 8px 20px 0; border-bottom: 1px solid var(--border); }
|
||||
.tab { border: none; background: none; padding: 8px 14px; font-size: 13px; cursor: pointer; color: var(--muted); border-bottom: 2px solid transparent; }
|
||||
.tab.active { color: var(--blue); border-bottom-color: var(--blue); }
|
||||
.tab-body { padding: 16px 20px; overflow: auto; }
|
||||
.tab-pane[hidden] { display: none; }
|
||||
|
||||
.logbox { margin: 0; background: #f6f7f9; border: 1px solid var(--border); padding: 12px; border-radius: 8px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 55vh; overflow: auto; }
|
||||
.conflict-actions { display: flex; gap: 8px; margin-bottom: 12px; }
|
||||
.conflict-file { margin-bottom: 14px; }
|
||||
.conflict-file h4 { margin: 0 0 6px; font-size: 13px; font-family: Consolas, monospace; }
|
||||
.conflict-file pre { margin: 0; background: #f6f7f9; border: 1px solid var(--border); border-radius: 8px; padding: 10px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 300px; overflow: auto; }
|
||||
.stat-summary { display: flex; gap: 16px; margin-bottom: 12px; font-size: 13px; color: var(--muted); flex-wrap: wrap; }
|
||||
.stat-summary b { color: var(--text); }
|
||||
|
||||
.commit-list { max-height: 55vh; overflow: auto; }
|
||||
.commit { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; overflow: hidden; }
|
||||
.commit-head { display: flex; gap: 10px; align-items: baseline; padding: 8px 12px; cursor: pointer; }
|
||||
.commit-head:hover { background: #f6f7f9; }
|
||||
.commit-hash { font: 12px Consolas, monospace; color: var(--blue); flex: none; }
|
||||
.commit-subject { flex: 1; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.commit-meta { font-size: 12px; color: var(--muted); flex: none; }
|
||||
.commit-diff { margin: 0; border-top: 1px solid var(--border); background: #f6f7f9; padding: 10px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 320px; overflow: auto; }
|
||||
.commit-diff[hidden] { display: none; }
|
||||
|
||||
.login-box { width: min(360px, 92vw); }
|
||||
.login-body { padding: 20px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.login-err { color: var(--red); font-size: 12px; margin: 0; }
|
||||
.form-grid label.check-row { flex-direction: row; align-items: center; gap: 8px; font-size: 13px; color: var(--text); }
|
||||
.form-grid label.check-row input { width: auto; margin: 0; }
|
||||
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 16px; }
|
||||
.form-grid label { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||
.form-grid .full { grid-column: 1 / -1; }
|
||||
input, select, textarea { border: 1px solid var(--border); border-radius: 8px; padding: 7px 10px; font-size: 13px; font-family: inherit; color: var(--text); background: #fff; }
|
||||
input:focus, select:focus, textarea:focus { outline: none; border-color: var(--blue); }
|
||||
textarea { font-family: Consolas, monospace; resize: vertical; }
|
||||
.form-actions { display: flex; gap: 8px; margin-top: 16px; }
|
||||
|
||||
@media (max-width: 640px) { .form-grid { grid-template-columns: 1fr; } }
|
||||
@@ -0,0 +1,37 @@
|
||||
timestamp,action,result,commits
|
||||
2026-09-08 17:12:28,push,failed,1
|
||||
2026-09-08 17:19:24,push,failed,2
|
||||
2026-09-08 17:20:00,push,failed,3
|
||||
2026-09-08 17:20:42,push,failed,4
|
||||
2026-09-08 17:21:58,push,failed,5
|
||||
2026-09-08 17:23:16,push,failed,6
|
||||
2026-09-08 17:24:33,push,failed,7
|
||||
2026-09-08 17:25:49,push,failed,8
|
||||
2026-09-08 17:27:05,push,failed,9
|
||||
2026-09-08 17:28:21,push,failed,10
|
||||
2026-09-08 17:30:42,push,failed,11
|
||||
2026-09-08 17:31:58,push,failed,12
|
||||
2026-09-08 17:33:14,push,failed,13
|
||||
2026-09-08 17:34:34,push,failed,14
|
||||
2026-09-08 17:35:48,push,failed,15
|
||||
2026-09-08 17:52:48,push,failed,17
|
||||
2026-09-08 17:54:49,push,failed,18
|
||||
2026-09-08 17:57:07,push,failed,19
|
||||
2026-09-08 18:01:06,push,failed,20
|
||||
2026-09-08 18:07:32,push,failed,21
|
||||
2026-09-08 18:13:59,push,failed,22
|
||||
2026-09-08 18:20:25,push,failed,23
|
||||
2026-09-08 18:26:59,push,failed,24
|
||||
2026-09-08 18:33:24,push,failed,25
|
||||
2026-09-08 18:39:49,push,failed,26
|
||||
2026-09-08 18:45:45,push,failed,27
|
||||
2026-09-08 18:47:20,push,failed,28
|
||||
2026-09-08 18:47:38,push,failed,29
|
||||
2026-09-08 18:47:55,push,failed,30
|
||||
2026-09-08 18:48:39,push,failed,31
|
||||
2026-09-08 18:50:15,push,failed,32
|
||||
2026-09-08 18:50:30,push,failed,33
|
||||
2026-09-08 18:51:42,push,failed,35
|
||||
2026-09-08 18:52:56,push,failed,36
|
||||
2026-09-08 18:54:09,push,failed,37
|
||||
2026-09-08 18:55:22,push,failed,38
|
||||
|
Reference in New Issue
Block a user