Compare commits

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