git-sync-web 自动提交 2026-09-09 16:33:32
This commit is contained in:
@@ -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
|
||||
@@ -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>`,核心功能不受影响。
|
||||
@@ -1,234 +1,231 @@
|
||||
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"`
|
||||
PushStrategy string `json:"push_strategy"`
|
||||
AutoCommit bool `json:"auto_commit"`
|
||||
AutoSync bool `json:"auto_sync"`
|
||||
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.PushStrategy = orDefault(m["PUSH_STRATEGY"], "manual")
|
||||
c.AutoCommit = strings.EqualFold(m["AUTO_COMMIT"], "1") || strings.EqualFold(m["AUTO_COMMIT"], "true")
|
||||
// 自动同步默认开启;仅在显式配置 0 / false 时关闭(关闭后不再自动触发,但手动同步仍可用)
|
||||
c.AutoSync = !(strings.EqualFold(m["AUTO_SYNC"], "0") || strings.EqualFold(m["AUTO_SYNC"], "false"))
|
||||
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))
|
||||
fmt.Fprintf(&b, "PUSH_STRATEGY=%s\n", q(c.PushStrategy))
|
||||
ac := "0"
|
||||
if c.AutoCommit {
|
||||
ac = "1"
|
||||
}
|
||||
fmt.Fprintf(&b, "AUTO_COMMIT=%s\n", q(ac))
|
||||
as := "0"
|
||||
if c.AutoSync {
|
||||
as = "1"
|
||||
}
|
||||
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("配置名只能包含字母、数字、下划线和连字符")
|
||||
}
|
||||
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")
|
||||
}
|
||||
switch c.PushStrategy {
|
||||
case "manual", "rebase", "force":
|
||||
default:
|
||||
return fmt.Errorf("推送策略必须是 manual/rebase/force")
|
||||
}
|
||||
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
|
||||
}
|
||||
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"`
|
||||
PushStrategy string `json:"push_strategy"`
|
||||
AutoCommit bool `json:"auto_commit"`
|
||||
AutoSync bool `json:"auto_sync"`
|
||||
Cron string `json:"cron"`
|
||||
}
|
||||
|
||||
var nameRe = regexp.MustCompile(`^[A-Za-z0-9_\-]+$`)
|
||||
|
||||
// normalizeWinPath 把 Git Bash 风格路径 /c/foo/bar 转成 Windows 的 C:/foo/bar,
|
||||
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.PushStrategy = orDefault(m["PUSH_STRATEGY"], "manual")
|
||||
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"))
|
||||
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)})
|
||||
}
|
||||
}
|
||||
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))
|
||||
fmt.Fprintf(&b, "PUSH_STRATEGY=%s\n", q(c.PushStrategy))
|
||||
ac := "0"
|
||||
if c.AutoCommit {
|
||||
ac = "1"
|
||||
}
|
||||
fmt.Fprintf(&b, "AUTO_COMMIT=%s\n", q(ac))
|
||||
as := "0"
|
||||
if c.AutoSync {
|
||||
as = "1"
|
||||
}
|
||||
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("配置名只能包含字母、数字、下划线和连字符")
|
||||
}
|
||||
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")
|
||||
}
|
||||
switch c.PushStrategy {
|
||||
case "manual", "rebase", "force":
|
||||
default:
|
||||
return fmt.Errorf("推送策略必须是 manual/rebase/force")
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -9,5 +9,7 @@ AUTH_METHOD="https"
|
||||
RETRY_COUNT="0"
|
||||
RETRY_INTERVAL="0"
|
||||
CONFLICT_STRATEGY="merge"
|
||||
AUTO_COMMIT="1"
|
||||
PUSH_STRATEGY="manual"
|
||||
AUTO_COMMIT="0"
|
||||
AUTO_SYNC="0"
|
||||
CRON=""
|
||||
|
||||
@@ -1,133 +1,129 @@
|
||||
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
|
||||
}
|
||||
|
||||
// cronNextString 返回 cron 表达式相对当前时间的下次触发时间(供配置表单实时预览)
|
||||
func cronNextString(expr string) (string, error) {
|
||||
spec, err := parseCron(expr)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cronNext(spec, time.Now()).Format("2006-01-02 15:04"), 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
|
||||
}
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func cronNextString(expr string) (string, error) {
|
||||
spec, err := parseCron(expr)
|
||||
if err != 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)
|
||||
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
|
||||
}
|
||||
|
||||
+49
-53
@@ -1,53 +1,49 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,237 +1,214 @@
|
||||
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
|
||||
// stdout 与 stderr 分开收集:成功时只返回干净的 stdout 供解析,
|
||||
// 避免 git 的环境告警(如 autocrlf、进度信息)污染解析结果;
|
||||
// 失败时优先用 stderr 作为错误信息,保证诊断信息不丢失。
|
||||
var outBuf, errBuf bytes.Buffer
|
||||
cmd.Stdout = &outBuf
|
||||
cmd.Stderr = &errBuf
|
||||
err := cmd.Run()
|
||||
// 只裁剪尾部空白,不要用 TrimSpace 裁整体:
|
||||
// git 输出首行的前导空格具有语义(status --porcelain 的暂存位、diff 的上下文行),
|
||||
// 整体 TrimSpace 会把它吃掉,导致按固定偏移解析路径时错位。
|
||||
out := strings.TrimRight(outBuf.String(), "\r\n\t ")
|
||||
if err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return out, fmt.Errorf("命令超时: git %s", strings.Join(args, " "))
|
||||
}
|
||||
msg := strings.TrimSpace(errBuf.String())
|
||||
if msg == "" {
|
||||
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
|
||||
}
|
||||
|
||||
// changedFiles 解析 git status --porcelain,返回 路径 -> 状态标记(M/A/D/?),
|
||||
// 供网页端文件树展示每个文件的改动状态。
|
||||
func changedFiles(path string) map[string]string {
|
||||
out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
|
||||
m := map[string]string{}
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if len(line) < 4 {
|
||||
continue
|
||||
}
|
||||
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
|
||||
}
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
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 outBuf, errBuf bytes.Buffer
|
||||
cmd.Stdout = &outBuf
|
||||
cmd.Stderr = &errBuf
|
||||
err := cmd.Run()
|
||||
out := strings.TrimRight(outBuf.String(), "\r\n\t ")
|
||||
if err != nil {
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
return out, fmt.Errorf("命令超时: git %s", strings.Join(args, " "))
|
||||
}
|
||||
msg := strings.TrimSpace(errBuf.String())
|
||||
if msg == "" {
|
||||
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")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func remoteRefExists(path, remote, branch string) bool {
|
||||
_, err := gitRun(path, 15*time.Second, "rev-parse", "--verify", "-q", remote+"/"+branch)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func branchExists(path, branch string) bool {
|
||||
_, err := gitRun(path, 15*time.Second, "show-ref", "--verify", "-q", "refs/heads/"+branch)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func changedFiles(path string) map[string]string {
|
||||
out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
|
||||
m := map[string]string{}
|
||||
if err != nil {
|
||||
return m
|
||||
}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if len(line) < 4 {
|
||||
continue
|
||||
}
|
||||
code := line[:2]
|
||||
p := strings.TrimSpace(line[3:])
|
||||
if i := strings.Index(p, "->"); i >= 0 {
|
||||
p = strings.TrimSpace(p[i+2:])
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
2026/09/09 15:59:43 Git Sync Web 已启动: http://127.0.0.1:5799(鉴权:false)
|
||||
@@ -1,37 +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)
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,451 +1,446 @@
|
||||
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("POST /api/repos/{name}/force-push", s.wrapRepo(s.forcePushRepo))
|
||||
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}/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.HandleFunc("GET /api/repos/{name}/tree", s.wrapRepo(s.treeRepo))
|
||||
mux.HandleFunc("GET /api/repos/{name}/file", s.wrapRepo(s.fileRepo))
|
||||
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}/commit", s.wrapRepo(s.commitFileRepo))
|
||||
mux.HandleFunc("GET /api/repos/{name}/diff", s.wrapRepo(s.diffRepo))
|
||||
mux.HandleFunc("GET /api/cron-next", s.handleCronNext)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) forcePushRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
if err := s.engine.ForcePush(name); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) abortPushRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
if err := s.engine.AbortPush(name); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
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) fileRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
rel := r.URL.Query().Get("path")
|
||||
if rel == "" {
|
||||
writeErr(w, 400, "缺少 path 参数")
|
||||
return
|
||||
}
|
||||
fc, err := s.engine.ReadFileContent(name, rel)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, fc)
|
||||
}
|
||||
|
||||
func (s *Server) fileWriteRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, "请求体格式错误")
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
writeErr(w, 400, "缺少 path")
|
||||
return
|
||||
}
|
||||
if err := s.engine.WriteFileContent(name, req.Path, req.Content); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// resolveFileRepo 网页端解决单个冲突文件:写入 + 暂存,finish=true 时尝试结束合并/变基
|
||||
func (s *Server) resolveFileRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Finish bool `json:"finish"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, "请求体格式错误")
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
writeErr(w, 400, "缺少 path")
|
||||
return
|
||||
}
|
||||
if err := s.engine.ResolveConflictFile(name, req.Path, req.Content, req.Finish); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
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"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, "请求体格式错误")
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
writeErr(w, 400, "缺少 path")
|
||||
return
|
||||
}
|
||||
if err := s.engine.CommitFile(name, req.Path, req.Message); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
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 == "" {
|
||||
scope = "working"
|
||||
}
|
||||
// path 可选:传入时只对比该文件(用于编辑器「对比此文件」)
|
||||
// mode = word 时返回 --word-diff=plain 结果(词级差异)
|
||||
res, err := s.engine.WorkingDiff(name, scope, r.URL.Query().Get("path"), r.URL.Query().Get("mode"))
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, res)
|
||||
}
|
||||
|
||||
// handleCronNext 返回 cron 表达式的下次执行时间,供配置表单实时预览
|
||||
func (s *Server) handleCronNext(w http.ResponseWriter, r *http.Request) {
|
||||
expr := r.URL.Query().Get("expr")
|
||||
if expr == "" {
|
||||
writeErr(w, 400, "缺少 expr 参数")
|
||||
return
|
||||
}
|
||||
next, err := cronNextString(expr)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"next": next})
|
||||
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("POST /api/repos/{name}/force-push", s.wrapRepo(s.forcePushRepo))
|
||||
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}/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.HandleFunc("GET /api/repos/{name}/tree", s.wrapRepo(s.treeRepo))
|
||||
mux.HandleFunc("GET /api/repos/{name}/file", s.wrapRepo(s.fileRepo))
|
||||
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}/commit", s.wrapRepo(s.commitFileRepo))
|
||||
mux.HandleFunc("GET /api/repos/{name}/diff", s.wrapRepo(s.diffRepo))
|
||||
mux.HandleFunc("GET /api/cron-next", s.handleCronNext)
|
||||
mux.Handle("/", http.FileServer(http.FS(static)))
|
||||
return s
|
||||
}
|
||||
|
||||
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) forcePushRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
if err := s.engine.ForcePush(name); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) abortPushRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
if err := s.engine.AbortPush(name); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
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) fileRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
rel := r.URL.Query().Get("path")
|
||||
if rel == "" {
|
||||
writeErr(w, 400, "缺少 path 参数")
|
||||
return
|
||||
}
|
||||
fc, err := s.engine.ReadFileContent(name, rel)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, fc)
|
||||
}
|
||||
|
||||
func (s *Server) fileWriteRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, "请求体格式错误")
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
writeErr(w, 400, "缺少 path")
|
||||
return
|
||||
}
|
||||
if err := s.engine.WriteFileContent(name, req.Path, req.Content); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
func (s *Server) resolveFileRepo(w http.ResponseWriter, r *http.Request, name string) {
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Finish bool `json:"finish"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, "请求体格式错误")
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
writeErr(w, 400, "缺少 path")
|
||||
return
|
||||
}
|
||||
if err := s.engine.ResolveConflictFile(name, req.Path, req.Content, req.Finish); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
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"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeErr(w, 400, "请求体格式错误")
|
||||
return
|
||||
}
|
||||
if req.Path == "" {
|
||||
writeErr(w, 400, "缺少 path")
|
||||
return
|
||||
}
|
||||
if err := s.engine.CommitFile(name, req.Path, req.Message); err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
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 == "" {
|
||||
scope = "working"
|
||||
}
|
||||
// mode = word 时返回 --word-diff=plain 结果(词级差异)
|
||||
res, err := s.engine.WorkingDiff(name, scope, r.URL.Query().Get("path"), r.URL.Query().Get("mode"))
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, res)
|
||||
}
|
||||
|
||||
func (s *Server) handleCronNext(w http.ResponseWriter, r *http.Request) {
|
||||
expr := r.URL.Query().Get("expr")
|
||||
if expr == "" {
|
||||
writeErr(w, 400, "缺少 expr 参数")
|
||||
return
|
||||
}
|
||||
next, err := cronNextString(expr)
|
||||
if err != nil {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"next": next})
|
||||
}
|
||||
+1145
-1196
File diff suppressed because it is too large
Load Diff
+25
-29
@@ -1,29 +1,25 @@
|
||||
// CodeMirror 引导:在 codemirror.min.js 及其 mode 加载完成后接管 #file-editor。
|
||||
// 若库未加载或初始化失败,window.Editor.cm 保持 null,自动回退原生 textarea,功能不受影响。
|
||||
(function () {
|
||||
if (typeof window.CodeMirror === 'undefined' || typeof window.Editor === 'undefined') return;
|
||||
var ta = document.getElementById('file-editor');
|
||||
if (!ta) return;
|
||||
try {
|
||||
var dark = document.documentElement.classList.contains('dark');
|
||||
var cm = window.CodeMirror.fromTextArea(ta, {
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
indentUnit: 2,
|
||||
tabSize: 2,
|
||||
theme: dark ? 'material-darker' : 'eclipse',
|
||||
readOnly: ta.disabled ? 'nocursor' : false,
|
||||
extraKeys: {
|
||||
'Ctrl-S': function () { if (window.Editor.onSave) window.Editor.onSave(); },
|
||||
'Cmd-S': function () { if (window.Editor.onSave) window.Editor.onSave(); }
|
||||
}
|
||||
});
|
||||
window.Editor.cm = cm;
|
||||
// CodeMirror 的 change 事件桥接到草稿缓存
|
||||
cm.on('change', function () { if (window.Editor.onChange) window.Editor.onChange(); });
|
||||
// 模式跟随打开文件时的 setMode
|
||||
var cur = window.Editor.value;
|
||||
} catch (e) {
|
||||
if (window.console) console.warn('CodeMirror 初始化失败,已回退原生编辑器:', e);
|
||||
}
|
||||
})();
|
||||
(function () {
|
||||
if (typeof window.CodeMirror === 'undefined' || typeof window.Editor === 'undefined') return;
|
||||
var ta = document.getElementById('file-editor');
|
||||
if (!ta) return;
|
||||
try {
|
||||
var dark = document.documentElement.classList.contains('dark');
|
||||
var cm = window.CodeMirror.fromTextArea(ta, {
|
||||
lineNumbers: true,
|
||||
lineWrapping: true,
|
||||
indentUnit: 2,
|
||||
tabSize: 2,
|
||||
theme: dark ? 'material-darker' : 'eclipse',
|
||||
readOnly: ta.disabled ? 'nocursor' : false,
|
||||
extraKeys: {
|
||||
'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(); });
|
||||
var cur = window.Editor.value;
|
||||
} catch (e) {
|
||||
if (window.console) console.warn('CodeMirror 初始化失败,已回退原生编辑器:', e);
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user