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
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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=""
|
||||||
|
|||||||
@@ -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
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+25
-29
@@ -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);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|||||||
Reference in New Issue
Block a user