git-sync-web 自动提交 2026-09-09 16:33:32

This commit is contained in:
tamakyi
2026-09-09 16:33:32 +08:00
parent 5cdffecb44
commit 80bd1136f2
13 changed files with 3876 additions and 3887 deletions
+9
View File
@@ -0,0 +1,9 @@
BINARY := git-auto.exe
# 关闭 VCS 标记:绕过本地 .git 对象库不持久导致的 build 报错
.PHONY: build run
build:
go build -buildvcs=false -o $(BINARY) .
run: build
./$(BINARY) -dir .. -addr 127.0.0.1:5757
+123
View File
@@ -0,0 +1,123 @@
# Git 自动同步 · 网页控制台(git-sync-web)
将 `git-auto-sync`(原 Bash 脚本版定时同步工具)的能力搬上网页:用浏览器即可监控、手动触发、查看差异、在线编辑文件、就地解决冲突,无需登录服务器敲命令行。
单二进制、纯标准库实现(Go 1.25+),前端资源通过 `go:embed` 内嵌,**零外部依赖、零运行时下载**,拷贝一个 exe 即可运行。
---
## 核心功能
### 1. 仓库总览(Dashboard)
- 每个被监控仓库一张卡片:同步状态灯、领先/落后提交数、本地改动文件数。
- 运行中的仓库实时显示;关闭「自动同步」的仓库标注「手动模式」徽标,仅响应手动触发。
- 顶部按钮「全部启动 / 全部暂停」一键管控。
### 2. 实时活动(SSE 日志)
- 右侧实时日志面板通过 Server-Sent Events 推送每个仓库的同步过程、提交、冲突等事件,并带错误/警告高亮。
- 支持按关键字过滤、一键清空。
### 3. 手动同步
- 每个仓库提供「获取 / 拉取 / 推送」按钮,随时手动触发,不必等待定时调度。
### 4. 差异查看(Diff)
- 支持「工作区 / 已暂存 / 某次提交」三种范围;统一视图与**并排(split)视图**切换。
- 可选的**词级差异**(`git --word-diff=plain`),改动以行内增删高亮呈现。
- 一键**复制**差异内容、下载 `.patch` 文件;编辑器内「对比此文件」可只看当前文件的改动。
### 5. 网页端文件编辑
- 文件树列出仓库内可跟踪 / 未跟踪文件,并标注改动状态(M/A/D/?)。
- 点击文件即可**在线读取与编辑**,保存可「仅写盘」或「保存并提交」。
- 内置 **CodeMirror** 语法高亮;编辑过程中自动缓存草稿到浏览器 `localStorage`,刷新不丢稿,可「放弃草稿」回滚。
- 二进制文件自动识别并只读提示。
### 6. 冲突解决
- **拉取冲突**:选择「放弃合并」或「我已在本地解决,继续」。
- **推送冲突**(non-fast-forward):提供「强制推送(--force-with-lease)」或「放弃」。
- **冲突文件内联编辑**:直接在网页展开冲突文件、编辑解决、保存并标记已解决;所有冲突处理完毕时自动结束合并/变基。
### 7. 自动同步 / 自动提交 / Cron
- 每个仓库可设固定间隔(秒)或 **Cron 表达式**自动拉取推送(Cron 非空时优先于固定间隔)。
- 「自动提交」开启后,检测到工作区脏文件会自动 `add -A` + `commit` 再推送。
- 「自动同步」开关可单独关闭——关闭后该仓库不再自动运行,仅响应手动同步。
### 8. 提交历史
- 浏览仓库提交记录,点击展开单条提交的详细 diff。提交哈希经正则白名单校验,防止命令注入。
### 9. 统计
- 每次同步写入 CSV 统计(领先/落后/改动等),网页端以图表呈现近 30 天趋势。
### 10. 访问密码(可选)
- 启动时加 `-password` 参数启用鉴权:生成 30 天会话 cookie,除静态资源外的 API 均受保护。
---
## 编译与运行
```bash
# 编译(会重新内嵌 static 目录下的前端资源,改前端后必须重新 build)
go build -o git-auto.exe .
# Windows下可能需要执行如下命令编译:
go build -buildvcs=false -o git-auto.exe .
# 运行
./git-auto.exe -dir "C:/OtherFiles/Github/git-auto-sync" -addr 127.0.0.1:5757
```
| 参数 | 说明 | 默认值 |
| --- | --- | --- |
| `-dir` | 项目根目录,需包含 `configs/`、`logs/`、`stats/` | `.` |
| `-addr` | 监听地址 | `127.0.0.1:5757` |
| `-password` | 访问密码(为空则不启用鉴权) | 空 |
启动后浏览器打开 `http://127.0.0.1:5757` 即可。
---
## 配置文件格式
监控目标以 `.conf` 文件存放在 `-dir` 指定的 `configs/` 目录下,兼容原 Bash 脚本的配置字段:
```ini
# Git 自动同步配置 - 示例
REPO_PATH="/c/OtherFiles/Github/your-repo" # 支持 Git Bash 风格 /c/... 路径,会自动转为 Windows 路径
REMOTE_NAME="origin"
REMOTE_URL="https://example.com/you/repo.git"
# 多远程写法:REMOTES="origin=https://a.git,fork=https://b.git"
BRANCH="main"
MODE="both" # both / push / pull
INTERVAL="60" # 固定间隔(秒)
CRON="" # 可选,cron 表达式优先于 INTERVAL
LOG_FILE="/c/.../sync.log"
AUTH_METHOD="ssh" # ssh / https
AUTO_COMMIT="0" # 1 开启自动提交
AUTO_SYNC="1" # 0 关闭自动同步(仅手动)
CONFLICT_STRATEGY="merge" # merge / rebase
PUSH_STRATEGY="manual" # manual / auto
```
> 说明:路径既支持 `C:/...` 也支持 Git Bash 的 `/c/...`,后者会在加载时自动规范化;`.git` 内部目录被禁止通过网页访问。
---
## 目录结构
```
git-sync-web/
├── main.go # 入口:-dir / -addr / -password,内嵌 static
├── config.go # .conf 解析与写回、Windows 路径规范化
├── git.go # git 执行器、领先/落后、冲突检测
├── engine.go # 每仓库调度循环、SSE 事件总线、文件树/读写
├── cron.go # 纯标准库 5 段 cron 解析(含单测)
├── server.go # REST API 与静态资源服务
└── static/ # 前端:index.html / app.js / style.css / cm-boot.js / vendor/cm/
```
---
## 设计要点
- **单二进制 + 内嵌前端**:部署只需一个可执行文件,不依赖任何外部 CDN 或运行时。
- **安全边界**:仓库内路径经 `safeRepoPath` 严格校验,禁止 `..` 越界与访问 `.git`;提交哈希白名单防注入;网页编辑单文件 ≤1MB 且拒绝二进制写入。
- **优雅降级**:CodeMirror 资源缺失或初始化失败时,编辑器自动回退原生 `<textarea>`,核心功能不受影响。
+231 -234
View File
@@ -1,234 +1,231 @@
package main package main
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
) )
type Remote struct { type Remote struct {
Name string `json:"name"` Name string `json:"name"`
URL string `json:"url"` URL string `json:"url"`
} }
type RepoConfig struct { type RepoConfig struct {
Name string `json:"name"` Name string `json:"name"`
RepoPath string `json:"repo_path"` RepoPath string `json:"repo_path"`
Branch string `json:"branch"` Branch string `json:"branch"`
Remotes []Remote `json:"remotes"` Remotes []Remote `json:"remotes"`
Mode string `json:"mode"` Mode string `json:"mode"`
Interval int `json:"interval"` Interval int `json:"interval"`
LogFile string `json:"log_file"` LogFile string `json:"log_file"`
AuthMethod string `json:"auth_method"` AuthMethod string `json:"auth_method"`
RetryCount int `json:"retry_count"` RetryCount int `json:"retry_count"`
RetryInterval int `json:"retry_interval"` RetryInterval int `json:"retry_interval"`
ConflictStrategy string `json:"conflict_strategy"` ConflictStrategy string `json:"conflict_strategy"`
PushStrategy string `json:"push_strategy"` PushStrategy string `json:"push_strategy"`
AutoCommit bool `json:"auto_commit"` AutoCommit bool `json:"auto_commit"`
AutoSync bool `json:"auto_sync"` AutoSync bool `json:"auto_sync"`
Cron string `json:"cron"` Cron string `json:"cron"`
} }
var nameRe = regexp.MustCompile(`^[A-Za-z0-9_\-]+$`) var nameRe = regexp.MustCompile(`^[A-Za-z0-9_\-]+$`)
// normalizeWinPath 把 Git Bash 风格路径 /c/foo/bar 转成 Windows 的 C:/foo/bar, // normalizeWinPath 把 Git Bash 风格路径 /c/foo/bar 转成 Windows 的 C:/foo/bar,
// 兼容旧脚本配置里存放的 bash 风格路径 func normalizeWinPath(p string) string {
func normalizeWinPath(p string) string { p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/")
p = strings.ReplaceAll(strings.TrimSpace(p), "\\", "/") if len(p) >= 3 && p[0] == '/' && p[2] == '/' {
if len(p) >= 3 && p[0] == '/' && p[2] == '/' { d := p[1]
d := p[1] if (d >= 'a' && d <= 'z') || (d >= 'A' && d <= 'Z') {
if (d >= 'a' && d <= 'z') || (d >= 'A' && d <= 'Z') { return strings.ToUpper(string(d)) + ":/" + p[3:]
return strings.ToUpper(string(d)) + ":/" + p[3:] }
} }
} return p
return p }
}
func unquote(s string) string {
func unquote(s string) string { s = strings.TrimSpace(s)
s = strings.TrimSpace(s) if len(s) >= 2 {
if len(s) >= 2 { if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') { return s[1 : len(s)-1]
return s[1 : len(s)-1] }
} }
} return s
return s }
}
func orDefault(v, def string) string {
func orDefault(v, def string) string { v = strings.TrimSpace(v)
v = strings.TrimSpace(v) if v == "" {
if v == "" { return def
return def }
} return v
return v }
}
func atoiOr(v string, def int) int {
func atoiOr(v string, def int) int { n, err := strconv.Atoi(strings.TrimSpace(v))
n, err := strconv.Atoi(strings.TrimSpace(v)) if err != nil {
if err != nil { return def
return def }
} return n
return n }
}
func parseConfFile(path string) (map[string]string, error) {
func parseConfFile(path string) (map[string]string, error) { data, err := os.ReadFile(path)
data, err := os.ReadFile(path) if err != nil {
if err != nil { return nil, err
return nil, err }
} m := map[string]string{}
m := map[string]string{} for _, line := range strings.Split(string(data), "\n") {
for _, line := range strings.Split(string(data), "\n") { line = strings.TrimSpace(line)
line = strings.TrimSpace(line) if line == "" || strings.HasPrefix(line, "#") {
if line == "" || strings.HasPrefix(line, "#") { continue
continue }
} k, v, ok := strings.Cut(line, "=")
k, v, ok := strings.Cut(line, "=") if !ok {
if !ok { continue
continue }
} m[strings.TrimSpace(k)] = unquote(v)
m[strings.TrimSpace(k)] = unquote(v) }
} return m, nil
return m, nil }
}
func loadRepoConfig(dir, name string) (*RepoConfig, error) {
func loadRepoConfig(dir, name string) (*RepoConfig, error) { m, err := parseConfFile(filepath.Join(dir, name+".conf"))
m, err := parseConfFile(filepath.Join(dir, name+".conf")) if err != nil {
if err != nil { return nil, err
return nil, err }
} c := &RepoConfig{Name: name}
c := &RepoConfig{Name: name} c.RepoPath = normalizeWinPath(m["REPO_PATH"])
c.RepoPath = normalizeWinPath(m["REPO_PATH"]) c.Branch = orDefault(m["BRANCH"], "main")
c.Branch = orDefault(m["BRANCH"], "main") c.Mode = orDefault(m["MODE"], "both")
c.Mode = orDefault(m["MODE"], "both") c.Interval = atoiOr(m["INTERVAL"], 60)
c.Interval = atoiOr(m["INTERVAL"], 60) c.LogFile = normalizeWinPath(m["LOG_FILE"])
c.LogFile = normalizeWinPath(m["LOG_FILE"]) c.AuthMethod = orDefault(m["AUTH_METHOD"], "ssh")
c.AuthMethod = orDefault(m["AUTH_METHOD"], "ssh") c.RetryCount = atoiOr(m["RETRY_COUNT"], 0)
c.RetryCount = atoiOr(m["RETRY_COUNT"], 0) c.RetryInterval = atoiOr(m["RETRY_INTERVAL"], 0)
c.RetryInterval = atoiOr(m["RETRY_INTERVAL"], 0) c.ConflictStrategy = orDefault(m["CONFLICT_STRATEGY"], "merge")
c.ConflictStrategy = orDefault(m["CONFLICT_STRATEGY"], "merge") c.PushStrategy = orDefault(m["PUSH_STRATEGY"], "manual")
c.PushStrategy = orDefault(m["PUSH_STRATEGY"], "manual") c.AutoCommit = strings.EqualFold(m["AUTO_COMMIT"], "1") || strings.EqualFold(m["AUTO_COMMIT"], "true")
c.AutoCommit = strings.EqualFold(m["AUTO_COMMIT"], "1") || strings.EqualFold(m["AUTO_COMMIT"], "true") c.AutoSync = !(strings.EqualFold(m["AUTO_SYNC"], "0") || strings.EqualFold(m["AUTO_SYNC"], "false"))
// 自动同步默认开启;仅在显式配置 0 / false 时关闭(关闭后不再自动触发,但手动同步仍可用) c.Cron = strings.TrimSpace(m["CRON"])
c.AutoSync = !(strings.EqualFold(m["AUTO_SYNC"], "0") || strings.EqualFold(m["AUTO_SYNC"], "false")) if remotes := strings.TrimSpace(m["REMOTES"]); remotes != "" {
c.Cron = strings.TrimSpace(m["CRON"]) for _, pair := range strings.Split(remotes, ",") {
if remotes := strings.TrimSpace(m["REMOTES"]); remotes != "" { pair = strings.TrimSpace(pair)
for _, pair := range strings.Split(remotes, ",") { if pair == "" {
pair = strings.TrimSpace(pair) continue
if pair == "" { }
continue n, u, ok := strings.Cut(pair, "=")
} if !ok {
n, u, ok := strings.Cut(pair, "=") continue
if !ok { }
continue c.Remotes = append(c.Remotes, Remote{Name: strings.TrimSpace(n), URL: strings.TrimSpace(u)})
} }
c.Remotes = append(c.Remotes, Remote{Name: strings.TrimSpace(n), URL: strings.TrimSpace(u)}) }
} 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"]})
// 兼容旧版单远程格式:REMOTE_NAME + REMOTE_URL }
if len(c.Remotes) == 0 && m["REMOTE_NAME"] != "" && m["REMOTE_URL"] != "" { return c, nil
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 {
func q(s string) string { return "\"" + s + "\"" } var b strings.Builder
fmt.Fprintf(&b, "# Git 自动同步配置 - %s\n", c.Name)
func saveRepoConfig(dir string, c *RepoConfig) error { fmt.Fprintf(&b, "REPO_PATH=%s\n", q(c.RepoPath))
var b strings.Builder fmt.Fprintf(&b, "BRANCH=%s\n", q(c.Branch))
fmt.Fprintf(&b, "# Git 自动同步配置 - %s\n", c.Name) pairs := make([]string, len(c.Remotes))
fmt.Fprintf(&b, "REPO_PATH=%s\n", q(c.RepoPath)) for i, r := range c.Remotes {
fmt.Fprintf(&b, "BRANCH=%s\n", q(c.Branch)) pairs[i] = r.Name + "=" + r.URL
pairs := make([]string, len(c.Remotes)) }
for i, r := range c.Remotes { fmt.Fprintf(&b, "REMOTES=%s\n", q(strings.Join(pairs, ",")))
pairs[i] = r.Name + "=" + r.URL fmt.Fprintf(&b, "MODE=%s\n", q(c.Mode))
} fmt.Fprintf(&b, "INTERVAL=%s\n", q(strconv.Itoa(c.Interval)))
fmt.Fprintf(&b, "REMOTES=%s\n", q(strings.Join(pairs, ","))) fmt.Fprintf(&b, "LOG_FILE=%s\n", q(c.LogFile))
fmt.Fprintf(&b, "MODE=%s\n", q(c.Mode)) fmt.Fprintf(&b, "AUTH_METHOD=%s\n", q(c.AuthMethod))
fmt.Fprintf(&b, "INTERVAL=%s\n", q(strconv.Itoa(c.Interval))) fmt.Fprintf(&b, "RETRY_COUNT=%s\n", q(strconv.Itoa(c.RetryCount)))
fmt.Fprintf(&b, "LOG_FILE=%s\n", q(c.LogFile)) fmt.Fprintf(&b, "RETRY_INTERVAL=%s\n", q(strconv.Itoa(c.RetryInterval)))
fmt.Fprintf(&b, "AUTH_METHOD=%s\n", q(c.AuthMethod)) fmt.Fprintf(&b, "CONFLICT_STRATEGY=%s\n", q(c.ConflictStrategy))
fmt.Fprintf(&b, "RETRY_COUNT=%s\n", q(strconv.Itoa(c.RetryCount))) fmt.Fprintf(&b, "PUSH_STRATEGY=%s\n", q(c.PushStrategy))
fmt.Fprintf(&b, "RETRY_INTERVAL=%s\n", q(strconv.Itoa(c.RetryInterval))) ac := "0"
fmt.Fprintf(&b, "CONFLICT_STRATEGY=%s\n", q(c.ConflictStrategy)) if c.AutoCommit {
fmt.Fprintf(&b, "PUSH_STRATEGY=%s\n", q(c.PushStrategy)) ac = "1"
ac := "0" }
if c.AutoCommit { fmt.Fprintf(&b, "AUTO_COMMIT=%s\n", q(ac))
ac = "1" as := "0"
} if c.AutoSync {
fmt.Fprintf(&b, "AUTO_COMMIT=%s\n", q(ac)) as = "1"
as := "0" }
if c.AutoSync { fmt.Fprintf(&b, "AUTO_SYNC=%s\n", q(as))
as = "1" fmt.Fprintf(&b, "CRON=%s\n", q(c.Cron))
} return os.WriteFile(filepath.Join(dir, c.Name+".conf"), []byte(b.String()), 0o644)
fmt.Fprintf(&b, "AUTO_SYNC=%s\n", q(as)) }
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("配置名只能包含字母、数字、下划线和连字符")
func validateRepoConfig(cfgDir string, c *RepoConfig, isCreate bool) error { }
if !nameRe.MatchString(c.Name) { if isCreate {
return fmt.Errorf("配置名只能包含字母、数字、下划线和连字符") if _, err := os.Stat(filepath.Join(cfgDir, c.Name+".conf")); err == nil {
} return fmt.Errorf("配置 %q 已存在", c.Name)
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)
p := normalizeWinPath(c.RepoPath) }
if !isRepo(p) { c.RepoPath = p
return fmt.Errorf("仓库路径无效或不是 Git 仓库: %s", c.RepoPath) c.Branch = strings.TrimSpace(c.Branch)
} if c.Branch == "" {
c.RepoPath = p c.Branch = "main"
c.Branch = strings.TrimSpace(c.Branch) }
if c.Branch == "" { if len(c.Remotes) == 0 {
c.Branch = "main" return fmt.Errorf("至少需要一个远程仓库(格式:名称=URL)")
} }
if len(c.Remotes) == 0 { for _, r := range c.Remotes {
return fmt.Errorf("至少需要一个远程仓库(格式:名称=URL)") if strings.TrimSpace(r.Name) == "" || strings.TrimSpace(r.URL) == "" {
} 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:
switch c.Mode { return fmt.Errorf("操作模式必须是 push/pull/both")
case "push", "pull", "both": }
default: switch c.AuthMethod {
return fmt.Errorf("操作模式必须是 push/pull/both") case "ssh", "https":
} default:
switch c.AuthMethod { return fmt.Errorf("认证方式必须是 ssh/https")
case "ssh", "https": }
default: switch c.ConflictStrategy {
return fmt.Errorf("认证方式必须是 ssh/https") case "merge", "rebase", "abort":
} default:
switch c.ConflictStrategy { return fmt.Errorf("冲突策略必须是 merge/rebase/abort")
case "merge", "rebase", "abort": }
default: switch c.PushStrategy {
return fmt.Errorf("冲突策略必须是 merge/rebase/abort") case "manual", "rebase", "force":
} default:
switch c.PushStrategy { return fmt.Errorf("推送策略必须是 manual/rebase/force")
case "manual", "rebase", "force": }
default: if c.Interval < 5 {
return fmt.Errorf("推送策略必须是 manual/rebase/force") c.Interval = 60
} }
if c.Interval < 5 { if c.RetryCount < 0 {
c.Interval = 60 c.RetryCount = 0
} }
if c.RetryCount < 0 { if c.RetryInterval < 0 {
c.RetryCount = 0 c.RetryInterval = 0
} }
if c.RetryInterval < 0 { if c.AuthMethod != "https" {
c.RetryInterval = 0 c.RetryCount, c.RetryInterval = 0, 0
} }
if c.AuthMethod != "https" { if strings.TrimSpace(c.Cron) != "" {
c.RetryCount, c.RetryInterval = 0, 0 if _, err := parseCron(c.Cron); err != nil {
} return fmt.Errorf("cron 表达式无效: %v", err)
if strings.TrimSpace(c.Cron) != "" { }
if _, err := parseCron(c.Cron); err != nil { }
return fmt.Errorf("cron 表达式无效: %v", err) return nil
} }
}
return nil
}
+3 -1
View File
@@ -9,5 +9,7 @@ AUTH_METHOD="https"
RETRY_COUNT="0" RETRY_COUNT="0"
RETRY_INTERVAL="0" RETRY_INTERVAL="0"
CONFLICT_STRATEGY="merge" CONFLICT_STRATEGY="merge"
AUTO_COMMIT="1" PUSH_STRATEGY="manual"
AUTO_COMMIT="0"
AUTO_SYNC="0"
CRON="" CRON=""
+129 -133
View File
@@ -1,133 +1,129 @@
package main package main
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time" "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
type cronSpec struct { domAll, dowAll bool
min, hour, dom, mon, dow map[int]bool }
domAll, dowAll bool
} func parseCron(spec string) (*cronSpec, error) {
fields := strings.Fields(spec)
func parseCron(spec string) (*cronSpec, error) { if len(fields) != 5 {
fields := strings.Fields(spec) return nil, fmt.Errorf("cron 需要 5 个字段(分 时 日 月 周),收到 %d 个", len(fields))
if len(fields) != 5 { }
return nil, fmt.Errorf("cron 需要 5 个字段(分 时 日 月 周),收到 %d 个", len(fields)) c := &cronSpec{}
} var err error
c := &cronSpec{} if c.min, err = parseCronField(fields[0], 0, 59); err != nil {
var err error return nil, fmt.Errorf("分钟字段: %w", err)
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.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.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.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, err = parseCronField(fields[4], 0, 7); err != nil { }
return nil, fmt.Errorf("星期字段: %w", err) if c.dow[7] {
} c.dow[0] = true
if c.dow[7] { }
c.dow[0] = true c.domAll = len(c.dom) == 31
} c.dowAll = len(c.dow) == 8
c.domAll = len(c.dom) == 31 return c, nil
c.dowAll = len(c.dow) == 8 }
return c, nil
} func parseCronField(f string, min, max int) (map[int]bool, error) {
set := map[int]bool{}
func parseCronField(f string, min, max int) (map[int]bool, error) { for _, part := range strings.Split(f, ",") {
set := map[int]bool{} part = strings.TrimSpace(part)
for _, part := range strings.Split(f, ",") { if part == "" {
part = strings.TrimSpace(part) return nil, fmt.Errorf("存在空字段")
if part == "" { }
return nil, fmt.Errorf("存在空字段") step := 1
} if i := strings.Index(part, "/"); i >= 0 {
step := 1 n, err := strconv.Atoi(part[i+1:])
if i := strings.Index(part, "/"); i >= 0 { if err != nil || n <= 0 {
n, err := strconv.Atoi(part[i+1:]) return nil, fmt.Errorf("无效步长 %q", part)
if err != nil || n <= 0 { }
return nil, fmt.Errorf("无效步长 %q", part) step = n
} part = part[:i]
step = n if part == "" {
part = part[:i] part = "*"
if part == "" { }
part = "*" }
} lo, hi := 0, 0
} if part == "*" {
lo, hi := 0, 0 lo, hi = min, max
if part == "*" { } else if rng := strings.SplitN(part, "-", 2); len(rng) == 2 {
lo, hi = min, max var err error
} else if rng := strings.SplitN(part, "-", 2); len(rng) == 2 { if lo, err = strconv.Atoi(rng[0]); err != nil {
var err error return nil, fmt.Errorf("无效值 %q", part)
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)
if hi, err = strconv.Atoi(rng[1]); err != nil { }
return nil, fmt.Errorf("无效值 %q", part) } else {
} v, err := strconv.Atoi(part)
} else { if err != nil {
v, err := strconv.Atoi(part) return nil, fmt.Errorf("无效值 %q", part)
if err != nil { }
return nil, fmt.Errorf("无效值 %q", part) lo, hi = v, v
} }
lo, hi = v, v if lo < min || hi > max || lo > hi {
} return nil, fmt.Errorf("值超出范围 [%d,%d]: %q", min, max, part)
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
for v := lo; v <= hi; v += step { }
set[v] = true }
} return set, nil
} }
return set, nil
} func cronNextString(expr string) (string, error) {
spec, err := parseCron(expr)
// cronNextString 返回 cron 表达式相对当前时间的下次触发时间(供配置表单实时预览) if err != nil {
func cronNextString(expr string) (string, error) { return "", err
spec, err := parseCron(expr) }
if err != nil { return cronNext(spec, time.Now()).Format("2006-01-02 15:04"), nil
return "", err }
}
return cronNext(spec, time.Now()).Format("2006-01-02 15:04"), nil func cronNext(spec *cronSpec, after time.Time) time.Time {
} t := after.Truncate(time.Minute).Add(time.Minute)
limit := t.AddDate(1, 0, 0)
// cronNext 返回 spec 在 after 之后的下一次触发时间(分钟精度,最多向前搜索一年) for t.Before(limit) {
func cronNext(spec *cronSpec, after time.Time) time.Time { if !spec.mon[int(t.Month())] {
t := after.Truncate(time.Minute).Add(time.Minute) t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()).AddDate(0, 1, 0)
limit := t.AddDate(1, 0, 0) continue
for t.Before(limit) { }
if !spec.mon[int(t.Month())] { domOK := spec.dom[t.Day()]
t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()).AddDate(0, 1, 0) dowOK := spec.dow[int(t.Weekday())]
continue dayOK := domOK && dowOK
} if !spec.domAll && !spec.dowAll {
domOK := spec.dom[t.Day()] dayOK = domOK || dowOK
dowOK := spec.dow[int(t.Weekday())] }
dayOK := domOK && dowOK if !dayOK {
if !spec.domAll && !spec.dowAll { t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, 1)
dayOK = domOK || dowOK continue
} }
if !dayOK { if !spec.hour[t.Hour()] {
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, 1) t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location()).Add(time.Hour)
continue continue
} }
if !spec.hour[t.Hour()] { if spec.min[t.Minute()] {
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location()).Add(time.Hour) return t
continue }
} t = t.Add(time.Minute)
if spec.min[t.Minute()] { }
return t return limit
} }
t = t.Add(time.Minute)
}
return limit
}
+49 -53
View File
@@ -1,53 +1,49 @@
package main package main
import ( import (
"testing" "testing"
"time" "time"
) )
func TestParseCron(t *testing.T) { 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"} { 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 { if _, err := parseCron(spec); err != nil {
t.Errorf("parseCron(%q) 应当成功,却得到: %v", spec, err) t.Errorf("parseCron(%q) 应当成功,却得到: %v", spec, err)
} }
} }
for _, spec := range []string{"* * * *", "61 * * * *", "* * * * 8", "a * * * *", "* * * * * *"} { for _, spec := range []string{"* * * *", "61 * * * *", "* * * * 8", "a * * * *", "* * * * * *"} {
if _, err := parseCron(spec); err == nil { if _, err := parseCron(spec); err == nil {
t.Errorf("parseCron(%q) 应当失败", spec) t.Errorf("parseCron(%q) 应当失败", spec)
} }
} }
} }
func TestCronNext(t *testing.T) { func TestCronNext(t *testing.T) {
// 每日 09:30:从当天 10:00 出发应得到次日 09:30 spec, _ := parseCron("30 9 * * *")
spec, _ := parseCron("30 9 * * *") next := cronNext(spec, time.Date(2026, 9, 8, 10, 0, 0, 0, time.Local))
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)
want := time.Date(2026, 9, 9, 9, 30, 0, 0, time.Local) if !next.Equal(want) {
if !next.Equal(want) { t.Fatalf("每日 09:30:期望 %v,得到 %v", want, next)
t.Fatalf("每日 09:30:期望 %v,得到 %v", want, next) }
}
spec2, _ := parseCron("*/15 * * * *")
// 每 15 分钟 next2 := cronNext(spec2, time.Date(2026, 9, 8, 10, 7, 0, 0, time.Local))
spec2, _ := parseCron("*/15 * * * *") want2 := time.Date(2026, 9, 8, 10, 15, 0, 0, time.Local)
next2 := cronNext(spec2, time.Date(2026, 9, 8, 10, 7, 0, 0, time.Local)) if !next2.Equal(want2) {
want2 := time.Date(2026, 9, 8, 10, 15, 0, 0, time.Local) t.Fatalf("*/15:期望 %v,得到 %v", want2, next2)
if !next2.Equal(want2) { }
t.Fatalf("*/15:期望 %v,得到 %v", want2, next2)
} spec3, _ := parseCron("0 12 * * 5")
next3 := cronNext(spec3, time.Date(2026, 9, 8, 12, 0, 0, 0, time.Local))
// 仅周五 12:00:2026-09-08 是周二,应得到 2026-09-11(周五) want3 := time.Date(2026, 9, 11, 12, 0, 0, 0, time.Local)
spec3, _ := parseCron("0 12 * * 5") if !next3.Equal(want3) {
next3 := cronNext(spec3, time.Date(2026, 9, 8, 12, 0, 0, 0, time.Local)) t.Fatalf("仅周五:期望 %v,得到 %v", want3, next3)
want3 := time.Date(2026, 9, 11, 12, 0, 0, 0, time.Local) }
if !next3.Equal(want3) {
t.Fatalf("仅周五:期望 %v,得到 %v", want3, next3) 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)
// 7 表示周日:2026-09-13 是周日 if !next4.Equal(want4) {
spec4, _ := parseCron("0 0 * * 7") t.Fatalf("7=周日:期望 %v,得到 %v", want4, next4)
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)
}
}
+1465 -1517
View File
File diff suppressed because it is too large Load Diff
+214 -237
View File
@@ -1,237 +1,214 @@
package main package main
import ( import (
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
) )
// gitRun 在指定仓库目录执行 git 命令,返回合并后的输出。 func gitRun(repoPath string, timeout time.Duration, args ...string) (string, error) {
// 非交互环境:GIT_TERMINAL_PROMPT=0 只能挡住终端提示,挡不住 Windows 凭据管理器(GCM)弹窗, ctx, cancel := context.WithTimeout(context.Background(), timeout)
// 因此额外禁用 GCM 交互、askpass 和 ssh 交互,避免后台同步被凭据弹窗永久卡死。 defer cancel()
// WaitDelay 保证超时 Kill 后即使 git 的子进程仍占用管道,Wait() 也能强制返回(Windows 上 full := append([]string{"-C", repoPath}, args...)
// CommandContext 只杀主进程,不杀 git-remote-https / 凭据 helper 等子进程)。 cmd := exec.CommandContext(ctx, "git", full...)
func gitRun(repoPath string, timeout time.Duration, args ...string) (string, error) { cmd.Env = append(os.Environ(),
ctx, cancel := context.WithTimeout(context.Background(), timeout) "GIT_TERMINAL_PROMPT=0",
defer cancel() "GIT_ASKPASS=echo",
full := append([]string{"-C", repoPath}, args...) "GCM_INTERACTIVE=never",
cmd := exec.CommandContext(ctx, "git", full...) "GIT_SSH_COMMAND=ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new",
cmd.Env = append(os.Environ(), )
"GIT_TERMINAL_PROMPT=0", cmd.WaitDelay = 10 * time.Second
"GIT_ASKPASS=echo", var outBuf, errBuf bytes.Buffer
"GCM_INTERACTIVE=never", cmd.Stdout = &outBuf
"GIT_SSH_COMMAND=ssh -oBatchMode=yes -oStrictHostKeyChecking=accept-new", cmd.Stderr = &errBuf
) err := cmd.Run()
cmd.WaitDelay = 10 * time.Second out := strings.TrimRight(outBuf.String(), "\r\n\t ")
// stdout 与 stderr 分开收集:成功时只返回干净的 stdout 供解析, if err != nil {
// 避免 git 的环境告警(如 autocrlf、进度信息)污染解析结果; if ctx.Err() == context.DeadlineExceeded {
// 失败时优先用 stderr 作为错误信息,保证诊断信息不丢失。 return out, fmt.Errorf("命令超时: git %s", strings.Join(args, " "))
var outBuf, errBuf bytes.Buffer }
cmd.Stdout = &outBuf msg := strings.TrimSpace(errBuf.String())
cmd.Stderr = &errBuf if msg == "" {
err := cmd.Run() msg = out
// 只裁剪尾部空白,不要用 TrimSpace 裁整体: }
// git 输出首行的前导空格具有语义(status --porcelain 的暂存位、diff 的上下文行), if msg == "" {
// 整体 TrimSpace 会把它吃掉,导致按固定偏移解析路径时错位。 msg = err.Error()
out := strings.TrimRight(outBuf.String(), "\r\n\t ") }
if err != nil { return out, errors.New(msg)
if ctx.Err() == context.DeadlineExceeded { }
return out, fmt.Errorf("命令超时: git %s", strings.Join(args, " ")) return out, nil
} }
msg := strings.TrimSpace(errBuf.String())
if msg == "" { func isRepo(path string) bool {
msg = out if path == "" {
} return false
if msg == "" { }
msg = err.Error() _, err := os.Stat(filepath.Join(path, ".git"))
} return err == nil
return out, errors.New(msg) }
}
return out, nil func currentBranch(path string) (string, error) {
} return gitRun(path, 15*time.Second, "rev-parse", "--abbrev-ref", "HEAD")
}
func isRepo(path string) bool {
if path == "" { func aheadBehind(path, remote, branch string) (int, int, error) {
return false out, err := gitRun(path, 30*time.Second, "rev-list", "--left-right", "--count", "HEAD..."+remote+"/"+branch)
} if err != nil {
_, err := os.Stat(filepath.Join(path, ".git")) return 0, 0, err
return err == nil }
} parts := strings.Fields(out)
if len(parts) != 2 {
func currentBranch(path string) (string, error) { return 0, 0, fmt.Errorf("无法解析 rev-list 输出: %q", out)
return gitRun(path, 15*time.Second, "rev-parse", "--abbrev-ref", "HEAD") }
} a, _ := strconv.Atoi(parts[0])
b, _ := strconv.Atoi(parts[1])
// aheadBehind 返回 (领先, 落后) 远程跟踪分支的提交数(需先 fetch 才准确) return a, b, nil
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 { func remoteRefExists(path, remote, branch string) bool {
return 0, 0, err _, err := gitRun(path, 15*time.Second, "rev-parse", "--verify", "-q", remote+"/"+branch)
} return err == nil
parts := strings.Fields(out) }
if len(parts) != 2 {
return 0, 0, fmt.Errorf("无法解析 rev-list 输出: %q", out) func branchExists(path, branch string) bool {
} _, err := gitRun(path, 15*time.Second, "show-ref", "--verify", "-q", "refs/heads/"+branch)
a, _ := strconv.Atoi(parts[0]) return err == nil
b, _ := strconv.Atoi(parts[1]) }
return a, b, nil
} func remoteTip(path, remote, branch string) (sha string, exists bool) {
if remoteRefExists(path, remote, branch) {
// remoteRefExists 检查远程跟踪引用是否存在(远程仓库为空或分支名不一致时为 false) out, err := gitRun(path, 15*time.Second, "rev-parse", remote+"/"+branch)
func remoteRefExists(path, remote, branch string) bool { if err == nil {
_, err := gitRun(path, 15*time.Second, "rev-parse", "--verify", "-q", remote+"/"+branch) return strings.TrimSpace(out), true
return err == nil }
} }
out, err := gitRun(path, 30*time.Second, "ls-remote", remote, "refs/heads/"+branch)
// branchExists 检查本地分支是否存在 if err != nil {
func branchExists(path, branch string) bool { return "", false
_, err := gitRun(path, 15*time.Second, "show-ref", "--verify", "-q", "refs/heads/"+branch) }
return err == nil fields := strings.Fields(strings.TrimSpace(out))
} if len(fields) < 2 {
return "", false
// remoteTip 返回远程分支当前指向的 SHA。 }
// 优先读本地远程跟踪引用(refs/remotes/<remote>/<branch>,快); return fields[0], true
// 若本地跟踪引用不可用(个别环境下 ref 存储读写异常),回退用 ls-remote 直查远程。 }
func remoteTip(path, remote, branch string) (sha string, exists bool) {
if remoteRefExists(path, remote, branch) { func aheadBehindSha(path, tip string) (int, int, error) {
out, err := gitRun(path, 15*time.Second, "rev-parse", remote+"/"+branch) out, err := gitRun(path, 30*time.Second, "rev-list", "--left-right", "--count", "HEAD..."+tip)
if err == nil { if err != nil {
return strings.TrimSpace(out), true return 0, 0, err
} }
} parts := strings.Fields(out)
out, err := gitRun(path, 30*time.Second, "ls-remote", remote, "refs/heads/"+branch) if len(parts) != 2 {
if err != nil { return 0, 0, fmt.Errorf("无法解析 rev-list 输出: %q", out)
return "", false }
} a, _ := strconv.Atoi(parts[0])
fields := strings.Fields(strings.TrimSpace(out)) b, _ := strconv.Atoi(parts[1])
if len(fields) < 2 { return a, b, nil
return "", false }
}
return fields[0], true func dirtyCount(path string) int {
} out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
if err != nil {
// aheadBehindSha 基于远程分支的具体 SHA 计算 (领先, 落后),不依赖本地跟踪引用 return 0
func aheadBehindSha(path, tip string) (int, int, error) { }
out, err := gitRun(path, 30*time.Second, "rev-list", "--left-right", "--count", "HEAD..."+tip) n := 0
if err != nil { for _, line := range strings.Split(out, "\n") {
return 0, 0, err if strings.TrimSpace(line) != "" {
} n++
parts := strings.Fields(out) }
if len(parts) != 2 { }
return 0, 0, fmt.Errorf("无法解析 rev-list 输出: %q", out) return n
} }
a, _ := strconv.Atoi(parts[0])
b, _ := strconv.Atoi(parts[1]) var conflictCodes = map[string]bool{
return a, b, nil "DD": true, "AU": true, "UD": true, "UA": true, "DU": true, "AA": true, "UU": true,
} }
func dirtyCount(path string) int { func conflictFiles(path string) ([]string, error) {
out, err := gitRun(path, 30*time.Second, "status", "--porcelain") out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
if err != nil { if err != nil {
return 0 return nil, err
} }
n := 0 var files []string
for _, line := range strings.Split(out, "\n") { seen := map[string]bool{}
if strings.TrimSpace(line) != "" { for _, line := range strings.Split(out, "\n") {
n++ if len(line) < 4 {
} continue
} }
return n if !conflictCodes[line[:2]] {
} continue
}
var conflictCodes = map[string]bool{ p := strings.TrimSpace(line[3:])
"DD": true, "AU": true, "UD": true, "UA": true, "DU": true, "AA": true, "UU": true, if i := strings.Index(p, "->"); i >= 0 {
} p = strings.TrimSpace(p[i+2:])
}
func conflictFiles(path string) ([]string, error) { if p != "" && !seen[p] {
out, err := gitRun(path, 30*time.Second, "status", "--porcelain") seen[p] = true
if err != nil { files = append(files, p)
return nil, err }
} }
var files []string return files, nil
seen := map[string]bool{} }
for _, line := range strings.Split(out, "\n") {
if len(line) < 4 { func abortMerge(path string) (string, error) {
continue if out, err := gitRun(path, 30*time.Second, "rebase", "--abort"); err == nil {
} return out, nil
if !conflictCodes[line[:2]] { }
continue return gitRun(path, 30*time.Second, "merge", "--abort")
} }
p := strings.TrimSpace(line[3:])
if i := strings.Index(p, "->"); i >= 0 { func resolveDone(path string, rebase bool) error {
p = strings.TrimSpace(p[i+2:]) if _, err := gitRun(path, 30*time.Second, "add", "-A"); err != nil {
} return err
if p != "" && !seen[p] { }
seen[p] = true if rebase {
files = append(files, p) _, err := gitRun(path, 120*time.Second, "-c", "core.editor=true", "rebase", "--continue")
} return err
} }
return files, nil 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
func abortMerge(path string) (string, error) { }
if out, err := gitRun(path, 30*time.Second, "rebase", "--abort"); err == nil { return nil
return out, nil }
}
return gitRun(path, 30*time.Second, "merge", "--abort") func changedFiles(path string) map[string]string {
} out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
m := map[string]string{}
// resolveDone 在用户网页上标记"已解决"后调用:暂存全部 → 继续 merge/rebase 流程 if err != nil {
func resolveDone(path string, rebase bool) error { return m
if _, err := gitRun(path, 30*time.Second, "add", "-A"); err != nil { }
return err for _, line := range strings.Split(out, "\n") {
} if len(line) < 4 {
if rebase { continue
_, err := gitRun(path, 120*time.Second, "-c", "core.editor=true", "rebase", "--continue") }
return err code := line[:2]
} p := strings.TrimSpace(line[3:])
if _, err := gitRun(path, 15*time.Second, "rev-parse", "-q", "--verify", "MERGE_HEAD"); err == nil { if i := strings.Index(p, "->"); i >= 0 {
_, err := gitRun(path, 120*time.Second, "commit", "--no-edit") p = strings.TrimSpace(p[i+2:])
return err }
} p = strings.Trim(p, `"`)
return nil if p == "" {
} continue
}
// changedFiles 解析 git status --porcelain,返回 路径 -> 状态标记(M/A/D/?), switch {
// 供网页端文件树展示每个文件的改动状态。 case code == "??":
func changedFiles(path string) map[string]string { m[p] = "?"
out, err := gitRun(path, 30*time.Second, "status", "--porcelain") case code[0] == 'A' || code[0] == 'C':
m := map[string]string{} m[p] = "A"
if err != nil { case code[0] == 'D' || code[1] == 'D':
return m m[p] = "D"
} default:
for _, line := range strings.Split(out, "\n") { m[p] = "M" // 含 UU/AU 冲突状态,统一显示为已修改
if len(line) < 4 { }
continue }
} return m
code := line[:2] }
p := strings.TrimSpace(line[3:])
// 重命名/复制:取目标路径
if i := strings.Index(p, "->"); i >= 0 {
p = strings.TrimSpace(p[i+2:])
}
// git 对含特殊字符的路径会加引号
p = strings.Trim(p, `"`)
if p == "" {
continue
}
switch {
case code == "??":
m[p] = "?"
case code[0] == 'A' || code[0] == 'C':
m[p] = "A"
case code[0] == 'D' || code[1] == 'D':
m[p] = "D"
default:
m[p] = "M" // 含 UU/AU 冲突状态,统一显示为已修改
}
}
return m
}
+1
View File
@@ -0,0 +1 @@
2026/09/09 15:59:43 Git Sync Web 已启动: http://127.0.0.1:5799(鉴权:false)
+37 -37
View File
@@ -1,37 +1,37 @@
package main package main
import ( import (
"embed" "embed"
"flag" "flag"
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
) )
//go:embed static //go:embed static
var staticFS embed.FS var staticFS embed.FS
func main() { func main() {
baseDir := flag.String("dir", ".", "项目根目录(包含 configs/ logs/ stats/)") baseDir := flag.String("dir", ".", "项目根目录(包含 configs/ logs/ stats/)")
addr := flag.String("addr", "127.0.0.1:5757", "监听地址") addr := flag.String("addr", "127.0.0.1:5757", "监听地址")
password := flag.String("password", "", "访问密码(为空则不启用鉴权)") password := flag.String("password", "", "访问密码(为空则不启用鉴权)")
flag.Parse() flag.Parse()
engine, err := NewEngine(*baseDir) engine, err := NewEngine(*baseDir)
if err != nil { if err != nil {
log.Fatalf("初始化失败: %v", err) log.Fatalf("初始化失败: %v", err)
} }
engine.LoadAll() engine.LoadAll()
engine.StartAll() engine.StartAll()
sub, err := fs.Sub(staticFS, "static") sub, err := fs.Sub(staticFS, "static")
if err != nil { if err != nil {
log.Fatalf("读取内嵌静态资源失败: %v", err) log.Fatalf("读取内嵌静态资源失败: %v", err)
} }
srv := NewServer(engine, sub, *password) srv := NewServer(engine, sub, *password)
log.Printf("Git Sync Web 已启动: http://%s(鉴权:%t)", *addr, *password != "") log.Printf("Git Sync Web 已启动: http://%s(鉴权:%t)", *addr, *password != "")
if err := http.ListenAndServe(*addr, srv); err != nil { if err := http.ListenAndServe(*addr, srv); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
+445 -450
View File
@@ -1,451 +1,446 @@
package main package main
import ( import (
"crypto/rand" "crypto/rand"
"crypto/subtle" "crypto/subtle"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io/fs" "io/fs"
"net/http" "net/http"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"time" "time"
) )
type Server struct { type Server struct {
engine *Engine engine *Engine
mux *http.ServeMux mux *http.ServeMux
password string password string
tokenMu sync.Mutex tokenMu sync.Mutex
tokens map[string]time.Time tokens map[string]time.Time
} }
func NewServer(e *Engine, static fs.FS, password string) *Server { func NewServer(e *Engine, static fs.FS, password string) *Server {
s := &Server{ s := &Server{
engine: e, engine: e,
mux: http.NewServeMux(), mux: http.NewServeMux(),
password: password, password: password,
tokens: map[string]time.Time{}, tokens: map[string]time.Time{},
} }
mux := s.mux mux := s.mux
mux.HandleFunc("GET /api/status", s.handleStatus) mux.HandleFunc("GET /api/status", s.handleStatus)
mux.HandleFunc("GET /api/events", s.handleEvents) mux.HandleFunc("GET /api/events", s.handleEvents)
mux.HandleFunc("POST /api/login", s.handleLogin) mux.HandleFunc("POST /api/login", s.handleLogin)
mux.HandleFunc("POST /api/logout", s.handleLogout) mux.HandleFunc("POST /api/logout", s.handleLogout)
mux.HandleFunc("POST /api/repos", s.handleCreate) mux.HandleFunc("POST /api/repos", s.handleCreate)
mux.HandleFunc("GET /api/repos/{name}", s.wrapRepo(s.handleGet)) mux.HandleFunc("GET /api/repos/{name}", s.wrapRepo(s.handleGet))
mux.HandleFunc("PUT /api/repos/{name}", s.wrapRepo(s.handleUpdate)) mux.HandleFunc("PUT /api/repos/{name}", s.wrapRepo(s.handleUpdate))
mux.HandleFunc("DELETE /api/repos/{name}", s.wrapRepo(s.handleDelete)) 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}/start", s.wrapRepo(s.startRepo))
mux.HandleFunc("POST /api/repos/{name}/stop", s.wrapRepo(s.stopRepo)) 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}/sync", s.wrapRepo(s.syncRepo))
mux.HandleFunc("POST /api/repos/{name}/action", s.wrapRepo(s.actionRepo)) mux.HandleFunc("POST /api/repos/{name}/action", s.wrapRepo(s.actionRepo))
mux.HandleFunc("POST /api/repos/{name}/force-push", s.wrapRepo(s.forcePushRepo)) mux.HandleFunc("POST /api/repos/{name}/force-push", s.wrapRepo(s.forcePushRepo))
mux.HandleFunc("POST /api/repos/{name}/abort-push", s.wrapRepo(s.abortPushRepo)) mux.HandleFunc("POST /api/repos/{name}/abort-push", s.wrapRepo(s.abortPushRepo))
mux.HandleFunc("GET /api/repos/{name}/conflicts", s.wrapRepo(s.conflictsRepo)) 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}/log", s.wrapRepo(s.logRepo))
mux.HandleFunc("GET /api/repos/{name}/stats", s.wrapRepo(s.statsRepo)) 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", s.wrapRepo(s.commitsRepo))
mux.HandleFunc("GET /api/repos/{name}/commits/{hash}", s.wrapRepo(s.commitDiffRepo)) mux.HandleFunc("GET /api/repos/{name}/commits/{hash}", s.wrapRepo(s.commitDiffRepo))
mux.HandleFunc("GET /api/repos/{name}/tree", s.wrapRepo(s.treeRepo)) mux.HandleFunc("GET /api/repos/{name}/tree", s.wrapRepo(s.treeRepo))
mux.HandleFunc("GET /api/repos/{name}/file", s.wrapRepo(s.fileRepo)) mux.HandleFunc("GET /api/repos/{name}/file", s.wrapRepo(s.fileRepo))
mux.HandleFunc("PUT /api/repos/{name}/file", s.wrapRepo(s.fileWriteRepo)) mux.HandleFunc("PUT /api/repos/{name}/file", s.wrapRepo(s.fileWriteRepo))
mux.HandleFunc("POST /api/repos/{name}/resolve-file", s.wrapRepo(s.resolveFileRepo)) mux.HandleFunc("POST /api/repos/{name}/resolve-file", s.wrapRepo(s.resolveFileRepo))
mux.HandleFunc("POST /api/repos/{name}/commit", s.wrapRepo(s.commitFileRepo)) mux.HandleFunc("POST /api/repos/{name}/commit", s.wrapRepo(s.commitFileRepo))
mux.HandleFunc("GET /api/repos/{name}/diff", s.wrapRepo(s.diffRepo)) mux.HandleFunc("GET /api/repos/{name}/diff", s.wrapRepo(s.diffRepo))
mux.HandleFunc("GET /api/cron-next", s.handleCronNext) mux.HandleFunc("GET /api/cron-next", s.handleCronNext)
mux.Handle("/", http.FileServer(http.FS(static))) mux.Handle("/", http.FileServer(http.FS(static)))
return s return s
} }
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// 访问密码保护:静态资源开放,/api/* 需要会话(登录接口除外) if s.password != "" && strings.HasPrefix(r.URL.Path, "/api/") &&
if s.password != "" && strings.HasPrefix(r.URL.Path, "/api/") && r.URL.Path != "/api/login" && !s.validToken(r) {
r.URL.Path != "/api/login" && !s.validToken(r) { writeErr(w, 401, "需要登录")
writeErr(w, 401, "需要登录") return
return }
} s.mux.ServeHTTP(w, r)
s.mux.ServeHTTP(w, r) }
}
func (s *Server) validToken(r *http.Request) bool {
func (s *Server) validToken(r *http.Request) bool { c, err := r.Cookie("git_sync_token")
c, err := r.Cookie("git_sync_token") if err != nil {
if err != nil { return false
return false }
} s.tokenMu.Lock()
s.tokenMu.Lock() defer s.tokenMu.Unlock()
defer s.tokenMu.Unlock() exp, ok := s.tokens[c.Value]
exp, ok := s.tokens[c.Value] if !ok {
if !ok { return false
return false }
} if time.Now().After(exp) {
if time.Now().After(exp) { delete(s.tokens, c.Value)
delete(s.tokens, c.Value) return false
return false }
} return true
return true }
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { var req struct {
var req struct { Password string `json:"password"`
Password string `json:"password"` }
} if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, 400, "请求体格式错误")
writeErr(w, 400, "请求体格式错误") return
return }
} if subtle.ConstantTimeCompare([]byte(req.Password), []byte(s.password)) != 1 {
if subtle.ConstantTimeCompare([]byte(req.Password), []byte(s.password)) != 1 { writeErr(w, 401, "密码错误")
writeErr(w, 401, "密码错误") return
return }
} buf := make([]byte, 32)
buf := make([]byte, 32) if _, err := rand.Read(buf); err != nil {
if _, err := rand.Read(buf); err != nil { writeErr(w, 500, "生成会话失败")
writeErr(w, 500, "生成会话失败") return
return }
} token := hex.EncodeToString(buf)
token := hex.EncodeToString(buf) s.tokenMu.Lock()
s.tokenMu.Lock() s.tokens[token] = time.Now().Add(30 * 24 * time.Hour)
s.tokens[token] = time.Now().Add(30 * 24 * time.Hour) s.tokenMu.Unlock()
s.tokenMu.Unlock() http.SetCookie(w, &http.Cookie{
http.SetCookie(w, &http.Cookie{ Name: "git_sync_token", Value: token, Path: "/",
Name: "git_sync_token", Value: token, Path: "/", HttpOnly: true, MaxAge: 30 * 24 * 3600,
HttpOnly: true, MaxAge: 30 * 24 * 3600, })
}) writeJSON(w, 200, map[string]any{"ok": true})
writeJSON(w, 200, map[string]any{"ok": true}) }
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) { if c, err := r.Cookie("git_sync_token"); err == nil {
if c, err := r.Cookie("git_sync_token"); err == nil { s.tokenMu.Lock()
s.tokenMu.Lock() delete(s.tokens, c.Value)
delete(s.tokens, c.Value) s.tokenMu.Unlock()
s.tokenMu.Unlock() }
} http.SetCookie(w, &http.Cookie{Name: "git_sync_token", Value: "", Path: "/", MaxAge: -1})
http.SetCookie(w, &http.Cookie{Name: "git_sync_token", Value: "", Path: "/", MaxAge: -1}) writeJSON(w, 200, map[string]any{"ok": true})
writeJSON(w, 200, map[string]any{"ok": true}) }
}
func writeJSON(w http.ResponseWriter, code int, v any) {
func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Type", "application/json; charset=utf-8") w.WriteHeader(code)
w.WriteHeader(code) _ = json.NewEncoder(w).Encode(v)
_ = json.NewEncoder(w).Encode(v) }
}
func writeErr(w http.ResponseWriter, code int, msg string) {
func writeErr(w http.ResponseWriter, code int, msg string) { writeJSON(w, code, map[string]string{"error": msg})
writeJSON(w, code, map[string]string{"error": msg}) }
}
func (s *Server) wrapRepo(h func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
func (s *Server) wrapRepo(h func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { name := r.PathValue("name")
name := r.PathValue("name") if s.engine.get(name) == nil {
if s.engine.get(name) == nil { writeErr(w, 404, "配置不存在: "+name)
writeErr(w, 404, "配置不存在: "+name) return
return }
} h(w, r, name)
h(w, r, name) }
} }
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{"repos": s.engine.Snapshots(true)})
writeJSON(w, 200, map[string]any{"repos": s.engine.Snapshots(true)}) }
}
func (s *Server) handleGet(w http.ResponseWriter, r *http.Request, name string) {
func (s *Server) handleGet(w http.ResponseWriter, r *http.Request, name string) { if st := s.engine.get(name); st != nil {
if st := s.engine.get(name); st != nil { writeJSON(w, 200, st.snapshot(true))
writeJSON(w, 200, st.snapshot(true)) return
return }
} writeErr(w, 404, "配置不存在")
writeErr(w, 404, "配置不存在") }
}
func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) { var cfg RepoConfig
var cfg RepoConfig if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { writeErr(w, 400, "请求体格式错误: "+err.Error())
writeErr(w, 400, "请求体格式错误: "+err.Error()) return
return }
} if err := s.engine.Create(&cfg); err != nil {
if err := s.engine.Create(&cfg); err != nil { writeErr(w, 400, err.Error())
writeErr(w, 400, err.Error()) return
return }
} writeJSON(w, 200, map[string]any{"ok": true, "name": cfg.Name})
writeJSON(w, 200, map[string]any{"ok": true, "name": cfg.Name}) }
}
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request, name string) {
func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request, name string) { var cfg RepoConfig
var cfg RepoConfig if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { writeErr(w, 400, "请求体格式错误: "+err.Error())
writeErr(w, 400, "请求体格式错误: "+err.Error()) return
return }
} cfg.Name = name
cfg.Name = name if err := s.engine.Update(&cfg); err != nil {
if err := s.engine.Update(&cfg); err != nil { writeErr(w, 400, err.Error())
writeErr(w, 400, err.Error()) return
return }
} writeJSON(w, 200, map[string]any{"ok": true})
writeJSON(w, 200, map[string]any{"ok": true}) }
}
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, name string) {
func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, name string) { if err := s.engine.Delete(name); err != nil {
if err := s.engine.Delete(name); err != nil { writeErr(w, 400, err.Error())
writeErr(w, 400, err.Error()) return
return }
} writeJSON(w, 200, map[string]any{"ok": true})
writeJSON(w, 200, map[string]any{"ok": true}) }
}
func (s *Server) startRepo(w http.ResponseWriter, r *http.Request, name string) {
func (s *Server) startRepo(w http.ResponseWriter, r *http.Request, name string) { if err := s.engine.Start(name); err != nil {
if err := s.engine.Start(name); err != nil { writeErr(w, 400, err.Error())
writeErr(w, 400, err.Error()) return
return }
} writeJSON(w, 200, map[string]any{"ok": true})
writeJSON(w, 200, map[string]any{"ok": true}) }
}
func (s *Server) stopRepo(w http.ResponseWriter, r *http.Request, name string) {
func (s *Server) stopRepo(w http.ResponseWriter, r *http.Request, name string) { if err := s.engine.Stop(name); err != nil {
if err := s.engine.Stop(name); err != nil { writeErr(w, 400, err.Error())
writeErr(w, 400, err.Error()) return
return }
} writeJSON(w, 200, map[string]any{"ok": true})
writeJSON(w, 200, map[string]any{"ok": true}) }
}
func (s *Server) syncRepo(w http.ResponseWriter, r *http.Request, name string) {
func (s *Server) syncRepo(w http.ResponseWriter, r *http.Request, name string) { if err := s.engine.SyncNow(name); err != nil {
if err := s.engine.SyncNow(name); err != nil { writeErr(w, 400, err.Error())
writeErr(w, 400, err.Error()) return
return }
} writeJSON(w, 200, map[string]any{"ok": true})
writeJSON(w, 200, map[string]any{"ok": true}) }
}
func (s *Server) actionRepo(w http.ResponseWriter, r *http.Request, name string) {
func (s *Server) actionRepo(w http.ResponseWriter, r *http.Request, name string) { var req struct {
var req struct { Action string `json:"action"`
Action string `json:"action"` }
} if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, 400, "请求体格式错误")
writeErr(w, 400, "请求体格式错误") return
return }
} switch req.Action {
switch req.Action { case "abort":
case "abort": if err := s.engine.ConflictAbort(name); err != nil {
if err := s.engine.ConflictAbort(name); err != nil { writeErr(w, 500, err.Error())
writeErr(w, 500, err.Error()) return
return }
} case "resolve":
case "resolve": if err := s.engine.ConflictResolve(name); err != nil {
if err := s.engine.ConflictResolve(name); err != nil { writeErr(w, 500, err.Error())
writeErr(w, 500, err.Error()) return
return }
} case "fetch", "pull", "push":
case "fetch", "pull", "push": if err := s.engine.ManualGit(name, req.Action); err != nil {
if err := s.engine.ManualGit(name, req.Action); err != nil { writeErr(w, 500, err.Error())
writeErr(w, 500, err.Error()) return
return }
} default:
default: writeErr(w, 400, "未知操作: "+req.Action)
writeErr(w, 400, "未知操作: "+req.Action) return
return }
} writeJSON(w, 200, map[string]any{"ok": true})
writeJSON(w, 200, map[string]any{"ok": true}) }
}
func (s *Server) conflictsRepo(w http.ResponseWriter, r *http.Request, name string) {
func (s *Server) conflictsRepo(w http.ResponseWriter, r *http.Request, name string) { writeJSON(w, 200, s.engine.ConflictDiffs(name))
writeJSON(w, 200, s.engine.ConflictDiffs(name)) }
}
func (s *Server) logRepo(w http.ResponseWriter, r *http.Request, name string) {
func (s *Server) logRepo(w http.ResponseWriter, r *http.Request, name string) { n := 300
n := 300 if v, err := strconv.Atoi(r.URL.Query().Get("lines")); err == nil && v > 0 && v <= 5000 {
if v, err := strconv.Atoi(r.URL.Query().Get("lines")); err == nil && v > 0 && v <= 5000 { n = v
n = v }
} writeJSON(w, 200, map[string]any{"lines": s.engine.ReadLog(name, n)})
writeJSON(w, 200, map[string]any{"lines": s.engine.ReadLog(name, n)}) }
}
func (s *Server) statsRepo(w http.ResponseWriter, r *http.Request, name string) {
func (s *Server) statsRepo(w http.ResponseWriter, r *http.Request, name string) { writeJSON(w, 200, s.engine.ReadStats(name))
writeJSON(w, 200, s.engine.ReadStats(name)) }
}
func (s *Server) commitsRepo(w http.ResponseWriter, r *http.Request, name string) {
func (s *Server) commitsRepo(w http.ResponseWriter, r *http.Request, name string) { limit := 50
limit := 50 if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 && v <= 200 {
if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 && v <= 200 { limit = v
limit = v }
} writeJSON(w, 200, s.engine.ReadCommits(name, limit))
writeJSON(w, 200, s.engine.ReadCommits(name, limit)) }
}
func (s *Server) commitDiffRepo(w http.ResponseWriter, r *http.Request, name string) {
func (s *Server) commitDiffRepo(w http.ResponseWriter, r *http.Request, name string) { diff, err := s.engine.ReadCommitDiff(name, r.PathValue("hash"))
diff, err := s.engine.ReadCommitDiff(name, r.PathValue("hash")) if err != nil {
if err != nil { writeErr(w, 400, err.Error())
writeErr(w, 400, err.Error()) return
return }
} writeJSON(w, 200, map[string]string{"diff": diff})
writeJSON(w, 200, map[string]string{"diff": diff}) }
}
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) { fl, ok := w.(http.Flusher)
fl, ok := w.(http.Flusher) if !ok {
if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError)
http.Error(w, "streaming unsupported", http.StatusInternalServerError) return
return }
} w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Cache-Control", "no-cache") w.Header().Set("X-Accel-Buffering", "no")
w.Header().Set("X-Accel-Buffering", "no")
id, ch := s.engine.Bus().Subscribe()
id, ch := s.engine.Bus().Subscribe() defer s.engine.Bus().Unsubscribe(id)
defer s.engine.Bus().Unsubscribe(id)
if snap, err := json.Marshal(map[string]any{"type": "snapshot", "data": s.engine.Snapshots(true)}); err == nil {
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)
fmt.Fprintf(w, "data: %s\n\n", snap) fl.Flush()
fl.Flush() }
}
tick := time.NewTicker(25 * time.Second)
tick := time.NewTicker(25 * time.Second) defer tick.Stop()
defer tick.Stop() ctx := r.Context()
ctx := r.Context() for {
for { select {
select { case <-ctx.Done():
case <-ctx.Done(): return
return case <-tick.C:
case <-tick.C: fmt.Fprint(w, ": ping\n\n")
fmt.Fprint(w, ": ping\n\n") fl.Flush()
fl.Flush() case data, ok := <-ch:
case data, ok := <-ch: if !ok {
if !ok { return
return }
} fmt.Fprintf(w, "data: %s\n\n", data)
fmt.Fprintf(w, "data: %s\n\n", data) fl.Flush()
fl.Flush() }
} }
} }
}
func (s *Server) forcePushRepo(w http.ResponseWriter, r *http.Request, name string) {
func (s *Server) forcePushRepo(w http.ResponseWriter, r *http.Request, name string) { if err := s.engine.ForcePush(name); err != nil {
if err := s.engine.ForcePush(name); err != nil { writeErr(w, 400, err.Error())
writeErr(w, 400, err.Error()) return
return }
} writeJSON(w, 200, map[string]any{"ok": true})
writeJSON(w, 200, map[string]any{"ok": true}) }
}
func (s *Server) abortPushRepo(w http.ResponseWriter, r *http.Request, name string) {
func (s *Server) abortPushRepo(w http.ResponseWriter, r *http.Request, name string) { if err := s.engine.AbortPush(name); err != nil {
if err := s.engine.AbortPush(name); err != nil { writeErr(w, 400, err.Error())
writeErr(w, 400, err.Error()) return
return }
} writeJSON(w, 200, map[string]any{"ok": true})
writeJSON(w, 200, map[string]any{"ok": true}) }
}
// ---------- 差异与文件编辑(网页端) ---------- func (s *Server) treeRepo(w http.ResponseWriter, r *http.Request, name string) {
writeJSON(w, 200, map[string]any{"files": s.engine.FileTree(name)})
func (s *Server) treeRepo(w http.ResponseWriter, r *http.Request, name string) { }
writeJSON(w, 200, map[string]any{"files": s.engine.FileTree(name)})
} func (s *Server) fileRepo(w http.ResponseWriter, r *http.Request, name string) {
rel := r.URL.Query().Get("path")
func (s *Server) fileRepo(w http.ResponseWriter, r *http.Request, name string) { if rel == "" {
rel := r.URL.Query().Get("path") writeErr(w, 400, "缺少 path 参数")
if rel == "" { return
writeErr(w, 400, "缺少 path 参数") }
return fc, err := s.engine.ReadFileContent(name, rel)
} if err != nil {
fc, err := s.engine.ReadFileContent(name, rel) writeErr(w, 400, err.Error())
if err != nil { return
writeErr(w, 400, err.Error()) }
return writeJSON(w, 200, fc)
} }
writeJSON(w, 200, fc)
} func (s *Server) fileWriteRepo(w http.ResponseWriter, r *http.Request, name string) {
var req struct {
func (s *Server) fileWriteRepo(w http.ResponseWriter, r *http.Request, name string) { Path string `json:"path"`
var req struct { Content string `json:"content"`
Path string `json:"path"` }
Content string `json:"content"` if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
} writeErr(w, 400, "请求体格式错误")
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { return
writeErr(w, 400, "请求体格式错误") }
return if req.Path == "" {
} writeErr(w, 400, "缺少 path")
if req.Path == "" { return
writeErr(w, 400, "缺少 path") }
return if err := s.engine.WriteFileContent(name, req.Path, req.Content); err != nil {
} writeErr(w, 400, err.Error())
if err := s.engine.WriteFileContent(name, req.Path, req.Content); err != nil { return
writeErr(w, 400, err.Error()) }
return writeJSON(w, 200, map[string]any{"ok": true})
} }
writeJSON(w, 200, map[string]any{"ok": true})
} func (s *Server) resolveFileRepo(w http.ResponseWriter, r *http.Request, name string) {
var req struct {
// resolveFileRepo 网页端解决单个冲突文件:写入 + 暂存,finish=true 时尝试结束合并/变基 Path string `json:"path"`
func (s *Server) resolveFileRepo(w http.ResponseWriter, r *http.Request, name string) { Content string `json:"content"`
var req struct { Finish bool `json:"finish"`
Path string `json:"path"` }
Content string `json:"content"` if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Finish bool `json:"finish"` writeErr(w, 400, "请求体格式错误")
} return
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { }
writeErr(w, 400, "请求体格式错误") if req.Path == "" {
return writeErr(w, 400, "缺少 path")
} return
if req.Path == "" { }
writeErr(w, 400, "缺少 path") if err := s.engine.ResolveConflictFile(name, req.Path, req.Content, req.Finish); err != nil {
return writeErr(w, 400, err.Error())
} return
if err := s.engine.ResolveConflictFile(name, req.Path, req.Content, req.Finish); err != nil { }
writeErr(w, 400, err.Error()) writeJSON(w, 200, map[string]any{"ok": true})
return }
}
writeJSON(w, 200, map[string]any{"ok": true}) func (s *Server) commitFileRepo(w http.ResponseWriter, r *http.Request, name string) {
} var req struct {
Path string `json:"path"`
func (s *Server) commitFileRepo(w http.ResponseWriter, r *http.Request, name string) { Message string `json:"message"`
var req struct { }
Path string `json:"path"` if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
Message string `json:"message"` writeErr(w, 400, "请求体格式错误")
} return
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { }
writeErr(w, 400, "请求体格式错误") if req.Path == "" {
return writeErr(w, 400, "缺少 path")
} return
if req.Path == "" { }
writeErr(w, 400, "缺少 path") if err := s.engine.CommitFile(name, req.Path, req.Message); err != nil {
return writeErr(w, 400, err.Error())
} return
if err := s.engine.CommitFile(name, req.Path, req.Message); err != nil { }
writeErr(w, 400, err.Error()) writeJSON(w, 200, map[string]any{"ok": true})
return }
}
writeJSON(w, 200, map[string]any{"ok": true}) func (s *Server) diffRepo(w http.ResponseWriter, r *http.Request, name string) {
} scope := r.URL.Query().Get("scope")
if scope == "" {
func (s *Server) diffRepo(w http.ResponseWriter, r *http.Request, name string) { scope = "working"
scope := r.URL.Query().Get("scope") }
if scope == "" { // mode = word 时返回 --word-diff=plain 结果(词级差异)
scope = "working" res, err := s.engine.WorkingDiff(name, scope, r.URL.Query().Get("path"), r.URL.Query().Get("mode"))
} if err != nil {
// path 可选:传入时只对比该文件(用于编辑器「对比此文件」) writeErr(w, 400, err.Error())
// mode = word 时返回 --word-diff=plain 结果(词级差异) return
res, err := s.engine.WorkingDiff(name, scope, r.URL.Query().Get("path"), r.URL.Query().Get("mode")) }
if err != nil { writeJSON(w, 200, res)
writeErr(w, 400, err.Error()) }
return
} func (s *Server) handleCronNext(w http.ResponseWriter, r *http.Request) {
writeJSON(w, 200, res) expr := r.URL.Query().Get("expr")
} if expr == "" {
writeErr(w, 400, "缺少 expr 参数")
// handleCronNext 返回 cron 表达式的下次执行时间,供配置表单实时预览 return
func (s *Server) handleCronNext(w http.ResponseWriter, r *http.Request) { }
expr := r.URL.Query().Get("expr") next, err := cronNextString(expr)
if expr == "" { if err != nil {
writeErr(w, 400, "缺少 expr 参数") writeErr(w, 400, err.Error())
return return
} }
next, err := cronNextString(expr) writeJSON(w, 200, map[string]any{"next": next})
if err != nil {
writeErr(w, 400, err.Error())
return
}
writeJSON(w, 200, map[string]any{"next": next})
} }
+1145 -1196
View File
File diff suppressed because it is too large Load Diff
+25 -29
View File
@@ -1,29 +1,25 @@
// CodeMirror 引导:在 codemirror.min.js 及其 mode 加载完成后接管 #file-editor。 (function () {
// 若库未加载或初始化失败,window.Editor.cm 保持 null,自动回退原生 textarea,功能不受影响。 if (typeof window.CodeMirror === 'undefined' || typeof window.Editor === 'undefined') return;
(function () { var ta = document.getElementById('file-editor');
if (typeof window.CodeMirror === 'undefined' || typeof window.Editor === 'undefined') return; if (!ta) return;
var ta = document.getElementById('file-editor'); try {
if (!ta) return; var dark = document.documentElement.classList.contains('dark');
try { var cm = window.CodeMirror.fromTextArea(ta, {
var dark = document.documentElement.classList.contains('dark'); lineNumbers: true,
var cm = window.CodeMirror.fromTextArea(ta, { lineWrapping: true,
lineNumbers: true, indentUnit: 2,
lineWrapping: true, tabSize: 2,
indentUnit: 2, theme: dark ? 'material-darker' : 'eclipse',
tabSize: 2, readOnly: ta.disabled ? 'nocursor' : false,
theme: dark ? 'material-darker' : 'eclipse', extraKeys: {
readOnly: ta.disabled ? 'nocursor' : false, 'Ctrl-S': function () { if (window.Editor.onSave) window.Editor.onSave(); },
extraKeys: { 'Cmd-S': function () { if (window.Editor.onSave) window.Editor.onSave(); }
'Ctrl-S': function () { if (window.Editor.onSave) window.Editor.onSave(); }, }
'Cmd-S': function () { if (window.Editor.onSave) window.Editor.onSave(); } });
} window.Editor.cm = cm;
}); cm.on('change', function () { if (window.Editor._suspend) return; if (window.Editor.onChange) window.Editor.onChange(); });
window.Editor.cm = cm; var cur = window.Editor.value;
// CodeMirror 的 change 事件桥接到草稿缓存 } catch (e) {
cm.on('change', function () { if (window.Editor.onChange) window.Editor.onChange(); }); if (window.console) console.warn('CodeMirror 初始化失败,已回退原生编辑器:', e);
// 模式跟随打开文件时的 setMode }
var cur = window.Editor.value; })();
} catch (e) {
if (window.console) console.warn('CodeMirror 初始化失败,已回退原生编辑器:', e);
}
})();