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

This commit is contained in:
tamakyi
2026-09-09 16:33:32 +08:00
parent 5cdffecb44
commit 80bd1136f2
13 changed files with 3876 additions and 3887 deletions
+9
View File
@@ -0,0 +1,9 @@
BINARY := git-auto.exe
# 关闭 VCS 标记:绕过本地 .git 对象库不持久导致的 build 报错
.PHONY: build run
build:
go build -buildvcs=false -o $(BINARY) .
run: build
./$(BINARY) -dir .. -addr 127.0.0.1:5757
+123
View File
@@ -0,0 +1,123 @@
# Git 自动同步 · 网页控制台(git-sync-web)
将 `git-auto-sync`(原 Bash 脚本版定时同步工具)的能力搬上网页:用浏览器即可监控、手动触发、查看差异、在线编辑文件、就地解决冲突,无需登录服务器敲命令行。
单二进制、纯标准库实现(Go 1.25+),前端资源通过 `go:embed` 内嵌,**零外部依赖、零运行时下载**,拷贝一个 exe 即可运行。
---
## 核心功能
### 1. 仓库总览(Dashboard)
- 每个被监控仓库一张卡片:同步状态灯、领先/落后提交数、本地改动文件数。
- 运行中的仓库实时显示;关闭「自动同步」的仓库标注「手动模式」徽标,仅响应手动触发。
- 顶部按钮「全部启动 / 全部暂停」一键管控。
### 2. 实时活动(SSE 日志)
- 右侧实时日志面板通过 Server-Sent Events 推送每个仓库的同步过程、提交、冲突等事件,并带错误/警告高亮。
- 支持按关键字过滤、一键清空。
### 3. 手动同步
- 每个仓库提供「获取 / 拉取 / 推送」按钮,随时手动触发,不必等待定时调度。
### 4. 差异查看(Diff)
- 支持「工作区 / 已暂存 / 某次提交」三种范围;统一视图与**并排(split)视图**切换。
- 可选的**词级差异**(`git --word-diff=plain`),改动以行内增删高亮呈现。
- 一键**复制**差异内容、下载 `.patch` 文件;编辑器内「对比此文件」可只看当前文件的改动。
### 5. 网页端文件编辑
- 文件树列出仓库内可跟踪 / 未跟踪文件,并标注改动状态(M/A/D/?)。
- 点击文件即可**在线读取与编辑**,保存可「仅写盘」或「保存并提交」。
- 内置 **CodeMirror** 语法高亮;编辑过程中自动缓存草稿到浏览器 `localStorage`,刷新不丢稿,可「放弃草稿」回滚。
- 二进制文件自动识别并只读提示。
### 6. 冲突解决
- **拉取冲突**:选择「放弃合并」或「我已在本地解决,继续」。
- **推送冲突**(non-fast-forward):提供「强制推送(--force-with-lease)」或「放弃」。
- **冲突文件内联编辑**:直接在网页展开冲突文件、编辑解决、保存并标记已解决;所有冲突处理完毕时自动结束合并/变基。
### 7. 自动同步 / 自动提交 / Cron
- 每个仓库可设固定间隔(秒)或 **Cron 表达式**自动拉取推送(Cron 非空时优先于固定间隔)。
- 「自动提交」开启后,检测到工作区脏文件会自动 `add -A` + `commit` 再推送。
- 「自动同步」开关可单独关闭——关闭后该仓库不再自动运行,仅响应手动同步。
### 8. 提交历史
- 浏览仓库提交记录,点击展开单条提交的详细 diff。提交哈希经正则白名单校验,防止命令注入。
### 9. 统计
- 每次同步写入 CSV 统计(领先/落后/改动等),网页端以图表呈现近 30 天趋势。
### 10. 访问密码(可选)
- 启动时加 `-password` 参数启用鉴权:生成 30 天会话 cookie,除静态资源外的 API 均受保护。
---
## 编译与运行
```bash
# 编译(会重新内嵌 static 目录下的前端资源,改前端后必须重新 build)
go build -o git-auto.exe .
# Windows下可能需要执行如下命令编译:
go build -buildvcs=false -o git-auto.exe .
# 运行
./git-auto.exe -dir "C:/OtherFiles/Github/git-auto-sync" -addr 127.0.0.1:5757
```
| 参数 | 说明 | 默认值 |
| --- | --- | --- |
| `-dir` | 项目根目录,需包含 `configs/`、`logs/`、`stats/` | `.` |
| `-addr` | 监听地址 | `127.0.0.1:5757` |
| `-password` | 访问密码(为空则不启用鉴权) | 空 |
启动后浏览器打开 `http://127.0.0.1:5757` 即可。
---
## 配置文件格式
监控目标以 `.conf` 文件存放在 `-dir` 指定的 `configs/` 目录下,兼容原 Bash 脚本的配置字段:
```ini
# Git 自动同步配置 - 示例
REPO_PATH="/c/OtherFiles/Github/your-repo" # 支持 Git Bash 风格 /c/... 路径,会自动转为 Windows 路径
REMOTE_NAME="origin"
REMOTE_URL="https://example.com/you/repo.git"
# 多远程写法:REMOTES="origin=https://a.git,fork=https://b.git"
BRANCH="main"
MODE="both" # both / push / pull
INTERVAL="60" # 固定间隔(秒)
CRON="" # 可选,cron 表达式优先于 INTERVAL
LOG_FILE="/c/.../sync.log"
AUTH_METHOD="ssh" # ssh / https
AUTO_COMMIT="0" # 1 开启自动提交
AUTO_SYNC="1" # 0 关闭自动同步(仅手动)
CONFLICT_STRATEGY="merge" # merge / rebase
PUSH_STRATEGY="manual" # manual / auto
```
> 说明:路径既支持 `C:/...` 也支持 Git Bash 的 `/c/...`,后者会在加载时自动规范化;`.git` 内部目录被禁止通过网页访问。
---
## 目录结构
```
git-sync-web/
├── main.go # 入口:-dir / -addr / -password,内嵌 static
├── config.go # .conf 解析与写回、Windows 路径规范化
├── git.go # git 执行器、领先/落后、冲突检测
├── engine.go # 每仓库调度循环、SSE 事件总线、文件树/读写
├── cron.go # 纯标准库 5 段 cron 解析(含单测)
├── server.go # REST API 与静态资源服务
└── static/ # 前端:index.html / app.js / style.css / cm-boot.js / vendor/cm/
```
---
## 设计要点
- **单二进制 + 内嵌前端**:部署只需一个可执行文件,不依赖任何外部 CDN 或运行时。
- **安全边界**:仓库内路径经 `safeRepoPath` 严格校验,禁止 `..` 越界与访问 `.git`;提交哈希白名单防注入;网页编辑单文件 ≤1MB 且拒绝二进制写入。
- **优雅降级**:CodeMirror 资源缺失或初始化失败时,编辑器自动回退原生 `<textarea>`,核心功能不受影响。
+231 -234
View File
@@ -1,234 +1,231 @@
package main
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
}
+3 -1
View File
@@ -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=""
+129 -133
View File
@@ -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
View File
@@ -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)
}
}
+1465 -1517
View File
File diff suppressed because it is too large Load Diff
+214 -237
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
2026/09/09 15:59:43 Git Sync Web 已启动: http://127.0.0.1:5799(鉴权:false)
+37 -37
View File
@@ -1,37 +1,37 @@
package main
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)
}
}
+445 -450
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+25 -29
View File
@@ -1,29 +1,25 @@
// CodeMirror 引导:在 codemirror.min.js 及其 mode 加载完成后接管 #file-editor。
// 若库未加载或初始化失败,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);
}
})();