Compare commits

...
12 Commits
16 changed files with 4352 additions and 3889 deletions
+4
View File
@@ -101,8 +101,12 @@ $RECYCLE.BIN/
#logs #logs
logs/* logs/*
*.log
# 运行时生成的统计/日志(避免自动提交把运行产物入库) # 运行时生成的统计/日志(避免自动提交把运行产物入库)
stats/ stats/
#configs
configs/*
# End of https://www.toptal.com/developers/gitignore/api/code,go,linux,macos,windows # End of https://www.toptal.com/developers/gitignore/api/code,go,linux,macos,windows
+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 -226
View File
@@ -1,226 +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"`
Cron string `json:"cron"` AutoSync bool `json:"auto_sync"`
} 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,
// 兼容旧脚本配置里存放的 bash 风格路径 // normalizeWinPath 把 Git Bash 风格路径 /c/foo/bar 转成 Windows 的 C:/foo/bar,
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.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)})
} }
// 兼容旧版单远程格式:REMOTE_NAME + REMOTE_URL }
if len(c.Remotes) == 0 && m["REMOTE_NAME"] != "" && m["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"]}) c.Remotes = append(c.Remotes, Remote{Name: m["REMOTE_NAME"], URL: m["REMOTE_URL"]})
} }
return c, nil return c, nil
} }
func q(s string) string { return "\"" + s + "\"" } func q(s string) string { return "\"" + s + "\"" }
func saveRepoConfig(dir string, c *RepoConfig) error { func saveRepoConfig(dir string, c *RepoConfig) error {
var b strings.Builder var b strings.Builder
fmt.Fprintf(&b, "# Git 自动同步配置 - %s\n", c.Name) fmt.Fprintf(&b, "# Git 自动同步配置 - %s\n", c.Name)
fmt.Fprintf(&b, "REPO_PATH=%s\n", q(c.RepoPath)) fmt.Fprintf(&b, "REPO_PATH=%s\n", q(c.RepoPath))
fmt.Fprintf(&b, "BRANCH=%s\n", q(c.Branch)) fmt.Fprintf(&b, "BRANCH=%s\n", q(c.Branch))
pairs := make([]string, len(c.Remotes)) pairs := make([]string, len(c.Remotes))
for i, r := range c.Remotes { for i, r := range c.Remotes {
pairs[i] = r.Name + "=" + r.URL pairs[i] = r.Name + "=" + r.URL
} }
fmt.Fprintf(&b, "REMOTES=%s\n", q(strings.Join(pairs, ","))) fmt.Fprintf(&b, "REMOTES=%s\n", q(strings.Join(pairs, ",")))
fmt.Fprintf(&b, "MODE=%s\n", q(c.Mode)) fmt.Fprintf(&b, "MODE=%s\n", q(c.Mode))
fmt.Fprintf(&b, "INTERVAL=%s\n", q(strconv.Itoa(c.Interval))) fmt.Fprintf(&b, "INTERVAL=%s\n", q(strconv.Itoa(c.Interval)))
fmt.Fprintf(&b, "LOG_FILE=%s\n", q(c.LogFile)) fmt.Fprintf(&b, "LOG_FILE=%s\n", q(c.LogFile))
fmt.Fprintf(&b, "AUTH_METHOD=%s\n", q(c.AuthMethod)) 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_COUNT=%s\n", q(strconv.Itoa(c.RetryCount)))
fmt.Fprintf(&b, "RETRY_INTERVAL=%s\n", q(strconv.Itoa(c.RetryInterval))) 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, "CONFLICT_STRATEGY=%s\n", q(c.ConflictStrategy))
fmt.Fprintf(&b, "PUSH_STRATEGY=%s\n", q(c.PushStrategy)) fmt.Fprintf(&b, "PUSH_STRATEGY=%s\n", q(c.PushStrategy))
ac := "0" ac := "0"
if c.AutoCommit { if c.AutoCommit {
ac = "1" ac = "1"
} }
fmt.Fprintf(&b, "AUTO_COMMIT=%s\n", q(ac)) fmt.Fprintf(&b, "AUTO_COMMIT=%s\n", q(ac))
fmt.Fprintf(&b, "CRON=%s\n", q(c.Cron)) as := "0"
return os.WriteFile(filepath.Join(dir, c.Name+".conf"), []byte(b.String()), 0o644) if c.AutoSync {
} as = "1"
}
func validateRepoConfig(cfgDir string, c *RepoConfig, isCreate bool) error { fmt.Fprintf(&b, "AUTO_SYNC=%s\n", q(as))
if !nameRe.MatchString(c.Name) { fmt.Fprintf(&b, "CRON=%s\n", q(c.Cron))
return fmt.Errorf("配置名只能包含字母、数字、下划线和连字符") return os.WriteFile(filepath.Join(dir, c.Name+".conf"), []byte(b.String()), 0o644)
} }
if isCreate {
if _, err := os.Stat(filepath.Join(cfgDir, c.Name+".conf")); err == nil { func validateRepoConfig(cfgDir string, c *RepoConfig, isCreate bool) error {
return fmt.Errorf("配置 %q 已存在", c.Name) if !nameRe.MatchString(c.Name) {
} return fmt.Errorf("配置名只能包含字母、数字、下划线和连字符")
} }
p := normalizeWinPath(c.RepoPath) if isCreate {
if !isRepo(p) { if _, err := os.Stat(filepath.Join(cfgDir, c.Name+".conf")); err == nil {
return fmt.Errorf("仓库路径无效或不是 Git 仓库: %s", c.RepoPath) return fmt.Errorf("配置 %q 已存在", c.Name)
} }
c.RepoPath = p }
c.Branch = strings.TrimSpace(c.Branch) p := normalizeWinPath(c.RepoPath)
if c.Branch == "" { if !isRepo(p) {
c.Branch = "main" return fmt.Errorf("仓库路径无效或不是 Git 仓库: %s", c.RepoPath)
} }
if len(c.Remotes) == 0 { c.RepoPath = p
return fmt.Errorf("至少需要一个远程仓库(格式:名称=URL)") c.Branch = strings.TrimSpace(c.Branch)
} if c.Branch == "" {
for _, r := range c.Remotes { c.Branch = "main"
if strings.TrimSpace(r.Name) == "" || strings.TrimSpace(r.URL) == "" { }
return fmt.Errorf("远程配置格式应为 名称=URL") if len(c.Remotes) == 0 {
} return fmt.Errorf("至少需要一个远程仓库(格式:名称=URL)")
} }
switch c.Mode { for _, r := range c.Remotes {
case "push", "pull", "both": if strings.TrimSpace(r.Name) == "" || strings.TrimSpace(r.URL) == "" {
default: return fmt.Errorf("远程配置格式应为 名称=URL")
return fmt.Errorf("操作模式必须是 push/pull/both") }
} }
switch c.AuthMethod { switch c.Mode {
case "ssh", "https": case "push", "pull", "both":
default: default:
return fmt.Errorf("认证方式必须是 ssh/https") return fmt.Errorf("操作模式必须是 push/pull/both")
} }
switch c.ConflictStrategy { switch c.AuthMethod {
case "merge", "rebase", "abort": case "ssh", "https":
default: default:
return fmt.Errorf("冲突策略必须是 merge/rebase/abort") return fmt.Errorf("认证方式必须是 ssh/https")
} }
switch c.PushStrategy { switch c.ConflictStrategy {
case "manual", "rebase", "force": case "merge", "rebase", "abort":
default: default:
return fmt.Errorf("推送策略必须是 manual/rebase/force") return fmt.Errorf("冲突策略必须是 merge/rebase/abort")
} }
if c.Interval < 5 { switch c.PushStrategy {
c.Interval = 60 case "manual", "rebase", "force":
} default:
if c.RetryCount < 0 { return fmt.Errorf("推送策略必须是 manual/rebase/force")
c.RetryCount = 0 }
} if c.Interval < 5 {
if c.RetryInterval < 0 { c.Interval = 60
c.RetryInterval = 0 }
} if c.RetryCount < 0 {
if c.AuthMethod != "https" { c.RetryCount = 0
c.RetryCount, c.RetryInterval = 0, 0 }
} if c.RetryInterval < 0 {
if strings.TrimSpace(c.Cron) != "" { c.RetryInterval = 0
if _, err := parseCron(c.Cron); err != nil { }
return fmt.Errorf("cron 表达式无效: %v", err) if c.AuthMethod != "https" {
} c.RetryCount, c.RetryInterval = 0, 0
} }
return nil 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_COUNT="0"
RETRY_INTERVAL="0" RETRY_INTERVAL="0"
CONFLICT_STRATEGY="merge" CONFLICT_STRATEGY="merge"
AUTO_COMMIT="1" PUSH_STRATEGY="manual"
AUTO_COMMIT="0"
AUTO_SYNC="0"
CRON="" CRON=""
+129 -133
View File
@@ -1,133 +1,129 @@
package main package main
import ( import (
"fmt" "fmt"
"strconv" "strconv"
"strings" "strings"
"time" "time"
) )
// 5 段 cron:分 时 日 月 周。支持 * 、数字、范围 a-b、步长 */n 与 a-b/n、逗号列表。
// 日(day-of-month) 与 周(day-of-week) 均受限时采用标准 cron 的「或」语义。 type cronSpec struct {
min, hour, dom, mon, dow map[int]bool
type cronSpec struct { domAll, dowAll bool
min, hour, dom, mon, dow map[int]bool }
domAll, dowAll bool
} func parseCron(spec string) (*cronSpec, error) {
fields := strings.Fields(spec)
func parseCron(spec string) (*cronSpec, error) { if len(fields) != 5 {
fields := strings.Fields(spec) return nil, fmt.Errorf("cron 需要 5 个字段(分 时 日 月 周),收到 %d 个", len(fields))
if len(fields) != 5 { }
return nil, fmt.Errorf("cron 需要 5 个字段(分 时 日 月 周),收到 %d 个", len(fields)) c := &cronSpec{}
} var err error
c := &cronSpec{} if c.min, err = parseCronField(fields[0], 0, 59); err != nil {
var err error return nil, fmt.Errorf("分钟字段: %w", err)
if c.min, err = parseCronField(fields[0], 0, 59); err != nil { }
return nil, fmt.Errorf("分钟字段: %w", err) if c.hour, err = parseCronField(fields[1], 0, 23); err != nil {
} return nil, fmt.Errorf("小时字段: %w", err)
if c.hour, err = parseCronField(fields[1], 0, 23); err != nil { }
return nil, fmt.Errorf("小时字段: %w", err) if c.dom, err = parseCronField(fields[2], 1, 31); err != nil {
} return nil, fmt.Errorf("日字段: %w", err)
if c.dom, err = parseCronField(fields[2], 1, 31); err != nil { }
return nil, fmt.Errorf("日字段: %w", err) if c.mon, err = parseCronField(fields[3], 1, 12); err != nil {
} return nil, fmt.Errorf("月字段: %w", err)
if c.mon, err = parseCronField(fields[3], 1, 12); err != nil { }
return nil, fmt.Errorf("月字段: %w", err) if c.dow, err = parseCronField(fields[4], 0, 7); err != nil {
} return nil, fmt.Errorf("星期字段: %w", err)
if c.dow, err = parseCronField(fields[4], 0, 7); err != nil { }
return nil, fmt.Errorf("星期字段: %w", err) if c.dow[7] {
} c.dow[0] = true
if c.dow[7] { }
c.dow[0] = true c.domAll = len(c.dom) == 31
} c.dowAll = len(c.dow) == 8
c.domAll = len(c.dom) == 31 return c, nil
c.dowAll = len(c.dow) == 8 }
return c, nil
} func parseCronField(f string, min, max int) (map[int]bool, error) {
set := map[int]bool{}
func parseCronField(f string, min, max int) (map[int]bool, error) { for _, part := range strings.Split(f, ",") {
set := map[int]bool{} part = strings.TrimSpace(part)
for _, part := range strings.Split(f, ",") { if part == "" {
part = strings.TrimSpace(part) return nil, fmt.Errorf("存在空字段")
if part == "" { }
return nil, fmt.Errorf("存在空字段") step := 1
} if i := strings.Index(part, "/"); i >= 0 {
step := 1 n, err := strconv.Atoi(part[i+1:])
if i := strings.Index(part, "/"); i >= 0 { if err != nil || n <= 0 {
n, err := strconv.Atoi(part[i+1:]) return nil, fmt.Errorf("无效步长 %q", part)
if err != nil || n <= 0 { }
return nil, fmt.Errorf("无效步长 %q", part) step = n
} part = part[:i]
step = n if part == "" {
part = part[:i] part = "*"
if part == "" { }
part = "*" }
} lo, hi := 0, 0
} if part == "*" {
lo, hi := 0, 0 lo, hi = min, max
if part == "*" { } else if rng := strings.SplitN(part, "-", 2); len(rng) == 2 {
lo, hi = min, max var err error
} else if rng := strings.SplitN(part, "-", 2); len(rng) == 2 { if lo, err = strconv.Atoi(rng[0]); err != nil {
var err error return nil, fmt.Errorf("无效值 %q", part)
if lo, err = strconv.Atoi(rng[0]); err != nil { }
return nil, fmt.Errorf("无效值 %q", part) if hi, err = strconv.Atoi(rng[1]); err != nil {
} return nil, fmt.Errorf("无效值 %q", part)
if hi, err = strconv.Atoi(rng[1]); err != nil { }
return nil, fmt.Errorf("无效值 %q", part) } else {
} v, err := strconv.Atoi(part)
} else { if err != nil {
v, err := strconv.Atoi(part) return nil, fmt.Errorf("无效值 %q", part)
if err != nil { }
return nil, fmt.Errorf("无效值 %q", part) lo, hi = v, v
} }
lo, hi = v, v if lo < min || hi > max || lo > hi {
} return nil, fmt.Errorf("值超出范围 [%d,%d]: %q", min, max, part)
if lo < min || hi > max || lo > hi { }
return nil, fmt.Errorf("值超出范围 [%d,%d]: %q", min, max, part) for v := lo; v <= hi; v += step {
} set[v] = true
for v := lo; v <= hi; v += step { }
set[v] = true }
} return set, nil
} }
return set, nil
} func cronNextString(expr string) (string, error) {
spec, err := parseCron(expr)
// cronNextString 返回 cron 表达式相对当前时间的下次触发时间(供配置表单实时预览) if err != nil {
func cronNextString(expr string) (string, error) { return "", err
spec, err := parseCron(expr) }
if err != nil { return cronNext(spec, time.Now()).Format("2006-01-02 15:04"), nil
return "", err }
}
return cronNext(spec, time.Now()).Format("2006-01-02 15:04"), nil func cronNext(spec *cronSpec, after time.Time) time.Time {
} t := after.Truncate(time.Minute).Add(time.Minute)
limit := t.AddDate(1, 0, 0)
// cronNext 返回 spec 在 after 之后的下一次触发时间(分钟精度,最多向前搜索一年) for t.Before(limit) {
func cronNext(spec *cronSpec, after time.Time) time.Time { if !spec.mon[int(t.Month())] {
t := after.Truncate(time.Minute).Add(time.Minute) t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()).AddDate(0, 1, 0)
limit := t.AddDate(1, 0, 0) continue
for t.Before(limit) { }
if !spec.mon[int(t.Month())] { domOK := spec.dom[t.Day()]
t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location()).AddDate(0, 1, 0) dowOK := spec.dow[int(t.Weekday())]
continue dayOK := domOK && dowOK
} if !spec.domAll && !spec.dowAll {
domOK := spec.dom[t.Day()] dayOK = domOK || dowOK
dowOK := spec.dow[int(t.Weekday())] }
dayOK := domOK && dowOK if !dayOK {
if !spec.domAll && !spec.dowAll { t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, 1)
dayOK = domOK || dowOK continue
} }
if !dayOK { if !spec.hour[t.Hour()] {
t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location()).AddDate(0, 0, 1) t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location()).Add(time.Hour)
continue continue
} }
if !spec.hour[t.Hour()] { if spec.min[t.Minute()] {
t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location()).Add(time.Hour) return t
continue }
} t = t.Add(time.Minute)
if spec.min[t.Minute()] { }
return t return limit
} }
t = t.Add(time.Minute)
}
return limit
}
+49 -53
View File
@@ -1,53 +1,49 @@
package main package main
import ( import (
"testing" "testing"
"time" "time"
) )
func TestParseCron(t *testing.T) { func TestParseCron(t *testing.T) {
for _, spec := range []string{"*/5 * * * *", "0 9 * * 1-5", "30 8 1,15 * *", "* * * * *", "0 */2 * * *", "0 8-18/2 * * 1-5"} { for _, spec := range []string{"*/5 * * * *", "0 9 * * 1-5", "30 8 1,15 * *", "* * * * *", "0 */2 * * *", "0 8-18/2 * * 1-5"} {
if _, err := parseCron(spec); err != nil { if _, err := parseCron(spec); err != nil {
t.Errorf("parseCron(%q) 应当成功,却得到: %v", spec, err) t.Errorf("parseCron(%q) 应当成功,却得到: %v", spec, err)
} }
} }
for _, spec := range []string{"* * * *", "61 * * * *", "* * * * 8", "a * * * *", "* * * * * *"} { for _, spec := range []string{"* * * *", "61 * * * *", "* * * * 8", "a * * * *", "* * * * * *"} {
if _, err := parseCron(spec); err == nil { if _, err := parseCron(spec); err == nil {
t.Errorf("parseCron(%q) 应当失败", spec) t.Errorf("parseCron(%q) 应当失败", spec)
} }
} }
} }
func TestCronNext(t *testing.T) { func TestCronNext(t *testing.T) {
// 每日 09:30:从当天 10:00 出发应得到次日 09:30 spec, _ := parseCron("30 9 * * *")
spec, _ := parseCron("30 9 * * *") next := cronNext(spec, time.Date(2026, 9, 8, 10, 0, 0, 0, time.Local))
next := cronNext(spec, time.Date(2026, 9, 8, 10, 0, 0, 0, time.Local)) want := time.Date(2026, 9, 9, 9, 30, 0, 0, time.Local)
want := time.Date(2026, 9, 9, 9, 30, 0, 0, time.Local) if !next.Equal(want) {
if !next.Equal(want) { t.Fatalf("每日 09:30:期望 %v,得到 %v", want, next)
t.Fatalf("每日 09:30:期望 %v,得到 %v", want, next) }
}
spec2, _ := parseCron("*/15 * * * *")
// 每 15 分钟 next2 := cronNext(spec2, time.Date(2026, 9, 8, 10, 7, 0, 0, time.Local))
spec2, _ := parseCron("*/15 * * * *") want2 := time.Date(2026, 9, 8, 10, 15, 0, 0, time.Local)
next2 := cronNext(spec2, time.Date(2026, 9, 8, 10, 7, 0, 0, time.Local)) if !next2.Equal(want2) {
want2 := time.Date(2026, 9, 8, 10, 15, 0, 0, time.Local) t.Fatalf("*/15:期望 %v,得到 %v", want2, next2)
if !next2.Equal(want2) { }
t.Fatalf("*/15:期望 %v,得到 %v", want2, next2)
} spec3, _ := parseCron("0 12 * * 5")
next3 := cronNext(spec3, time.Date(2026, 9, 8, 12, 0, 0, 0, time.Local))
// 仅周五 12:00:2026-09-08 是周二,应得到 2026-09-11(周五) want3 := time.Date(2026, 9, 11, 12, 0, 0, 0, time.Local)
spec3, _ := parseCron("0 12 * * 5") if !next3.Equal(want3) {
next3 := cronNext(spec3, time.Date(2026, 9, 8, 12, 0, 0, 0, time.Local)) t.Fatalf("仅周五:期望 %v,得到 %v", want3, next3)
want3 := time.Date(2026, 9, 11, 12, 0, 0, 0, time.Local) }
if !next3.Equal(want3) {
t.Fatalf("仅周五:期望 %v,得到 %v", want3, next3) spec4, _ := parseCron("0 0 * * 7")
} next4 := cronNext(spec4, time.Date(2026, 9, 12, 0, 0, 0, 0, time.Local)) // 周六
want4 := time.Date(2026, 9, 13, 0, 0, 0, 0, time.Local)
// 7 表示周日:2026-09-13 是周日 if !next4.Equal(want4) {
spec4, _ := parseCron("0 0 * * 7") t.Fatalf("7=周日:期望 %v,得到 %v", want4, next4)
next4 := cronNext(spec4, time.Date(2026, 9, 12, 0, 0, 0, 0, time.Local)) // 周六 }
want4 := time.Date(2026, 9, 13, 0, 0, 0, 0, time.Local) }
if !next4.Equal(want4) {
t.Fatalf("7=周日:期望 %v,得到 %v", want4, next4)
}
}
+1752 -1507
View File
File diff suppressed because it is too large Load Diff
+255 -237
View File
@@ -1,237 +1,255 @@
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 ensureRepo(path, branch string) error {
} if path == "" {
return fmt.Errorf("仓库路径为空")
func isRepo(path string) bool { }
if path == "" { path = normalizeWinPath(path)
return false if err := os.MkdirAll(path, 0o755); err != nil {
} return err
_, err := os.Stat(filepath.Join(path, ".git")) }
return err == nil if isRepo(path) {
} return nil
}
func currentBranch(path string) (string, error) { if branch == "" {
return gitRun(path, 15*time.Second, "rev-parse", "--abbrev-ref", "HEAD") branch = "main"
} }
if _, err := gitRun(path, 30*time.Second, "init", "-b", branch); err != nil {
// aheadBehind 返回 (领先, 落后) 远程跟踪分支的提交数(需先 fetch 才准确) if _, e2 := gitRun(path, 30*time.Second, "init"); e2 != nil {
func aheadBehind(path, remote, branch string) (int, int, error) { return e2
out, err := gitRun(path, 30*time.Second, "rev-list", "--left-right", "--count", "HEAD..."+remote+"/"+branch) }
if err != nil { _, _ = gitRun(path, 15*time.Second, "checkout", "-b", branch)
return 0, 0, err }
} return nil
parts := strings.Fields(out) }
if len(parts) != 2 {
return 0, 0, fmt.Errorf("无法解析 rev-list 输出: %q", out) func currentBranch(path string) (string, error) {
} return gitRun(path, 15*time.Second, "rev-parse", "--abbrev-ref", "HEAD")
a, _ := strconv.Atoi(parts[0]) }
b, _ := strconv.Atoi(parts[1])
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 {
// remoteRefExists 检查远程跟踪引用是否存在(远程仓库为空或分支名不一致时为 false) return 0, 0, err
func remoteRefExists(path, remote, branch string) bool { }
_, err := gitRun(path, 15*time.Second, "rev-parse", "--verify", "-q", remote+"/"+branch) parts := strings.Fields(out)
return err == nil if len(parts) != 2 {
} return 0, 0, fmt.Errorf("无法解析 rev-list 输出: %q", out)
}
// branchExists 检查本地分支是否存在 a, _ := strconv.Atoi(parts[0])
func branchExists(path, branch string) bool { b, _ := strconv.Atoi(parts[1])
_, err := gitRun(path, 15*time.Second, "show-ref", "--verify", "-q", "refs/heads/"+branch) return a, b, nil
return err == nil }
}
func remoteRefExists(path, remote, branch string) bool {
// remoteTip 返回远程分支当前指向的 SHA。 _, err := gitRun(path, 15*time.Second, "rev-parse", "--verify", "-q", remote+"/"+branch)
// 优先读本地远程跟踪引用(refs/remotes/<remote>/<branch>,快); return err == nil
// 若本地跟踪引用不可用(个别环境下 ref 存储读写异常),回退用 ls-remote 直查远程。 }
func remoteTip(path, remote, branch string) (sha string, exists bool) {
if remoteRefExists(path, remote, branch) { func branchExists(path, branch string) bool {
out, err := gitRun(path, 15*time.Second, "rev-parse", remote+"/"+branch) _, err := gitRun(path, 15*time.Second, "show-ref", "--verify", "-q", "refs/heads/"+branch)
if err == nil { return err == nil
return strings.TrimSpace(out), true }
}
} func remoteTip(path, remote, branch string) (sha string, exists bool) {
out, err := gitRun(path, 30*time.Second, "ls-remote", remote, "refs/heads/"+branch) if remoteRefExists(path, remote, branch) {
if err != nil { out, err := gitRun(path, 15*time.Second, "rev-parse", remote+"/"+branch)
return "", false if err == nil {
} return strings.TrimSpace(out), true
fields := strings.Fields(strings.TrimSpace(out)) }
if len(fields) < 2 { }
return "", false out, err := gitRun(path, 30*time.Second, "ls-remote", remote, "refs/heads/"+branch)
} if err != nil {
return fields[0], true return "", false
} }
fields := strings.Fields(strings.TrimSpace(out))
// aheadBehindSha 基于远程分支的具体 SHA 计算 (领先, 落后),不依赖本地跟踪引用 if len(fields) < 2 {
func aheadBehindSha(path, tip string) (int, int, error) { return "", false
out, err := gitRun(path, 30*time.Second, "rev-list", "--left-right", "--count", "HEAD..."+tip) }
if err != nil { return fields[0], true
return 0, 0, err }
}
parts := strings.Fields(out) func aheadBehindSha(path, tip string) (int, int, error) {
if len(parts) != 2 { out, err := gitRun(path, 30*time.Second, "rev-list", "--left-right", "--count", "HEAD..."+tip)
return 0, 0, fmt.Errorf("无法解析 rev-list 输出: %q", out) if err != nil {
} return 0, 0, err
a, _ := strconv.Atoi(parts[0]) }
b, _ := strconv.Atoi(parts[1]) parts := strings.Fields(out)
return a, b, nil if len(parts) != 2 {
} return 0, 0, fmt.Errorf("无法解析 rev-list 输出: %q", out)
}
func dirtyCount(path string) int { a, _ := strconv.Atoi(parts[0])
out, err := gitRun(path, 30*time.Second, "status", "--porcelain") b, _ := strconv.Atoi(parts[1])
if err != nil { return a, b, nil
return 0 }
}
n := 0 func dirtyCount(path string) int {
for _, line := range strings.Split(out, "\n") { out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
if strings.TrimSpace(line) != "" { if err != nil {
n++ return 0
} }
} n := 0
return n for _, line := range strings.Split(out, "\n") {
} if strings.TrimSpace(line) != "" {
n++
var conflictCodes = map[string]bool{ }
"DD": true, "AU": true, "UD": true, "UA": true, "DU": true, "AA": true, "UU": true, }
} return n
}
func conflictFiles(path string) ([]string, error) {
out, err := gitRun(path, 30*time.Second, "status", "--porcelain") var conflictCodes = map[string]bool{
if err != nil { "DD": true, "AU": true, "UD": true, "UA": true, "DU": true, "AA": true, "UU": true,
return nil, err }
}
var files []string func conflictFiles(path string) ([]string, error) {
seen := map[string]bool{} out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
for _, line := range strings.Split(out, "\n") { if err != nil {
if len(line) < 4 { return nil, err
continue }
} var files []string
if !conflictCodes[line[:2]] { seen := map[string]bool{}
continue for _, line := range strings.Split(out, "\n") {
} if len(line) < 4 {
p := strings.TrimSpace(line[3:]) continue
if i := strings.Index(p, "->"); i >= 0 { }
p = strings.TrimSpace(p[i+2:]) if !conflictCodes[line[:2]] {
} continue
if p != "" && !seen[p] { }
seen[p] = true p := strings.TrimSpace(line[3:])
files = append(files, p) if i := strings.Index(p, "->"); i >= 0 {
} p = strings.TrimSpace(p[i+2:])
} }
return files, nil if p != "" && !seen[p] {
} seen[p] = true
files = append(files, p)
func abortMerge(path string) (string, error) { }
if out, err := gitRun(path, 30*time.Second, "rebase", "--abort"); err == nil { }
return out, nil return files, nil
} }
return gitRun(path, 30*time.Second, "merge", "--abort")
} func abortMerge(path string) (string, error) {
if out, err := gitRun(path, 30*time.Second, "rebase", "--abort"); err == nil {
// resolveDone 在用户网页上标记"已解决"后调用:暂存全部 → 继续 merge/rebase 流程 return out, nil
func resolveDone(path string, rebase bool) error { }
if _, err := gitRun(path, 30*time.Second, "add", "-A"); err != nil { return gitRun(path, 30*time.Second, "merge", "--abort")
return err }
}
if rebase { func resolveDone(path string, rebase bool) error {
_, err := gitRun(path, 120*time.Second, "-c", "core.editor=true", "rebase", "--continue") if _, err := gitRun(path, 30*time.Second, "add", "-A"); err != nil {
return err return err
} }
if _, err := gitRun(path, 15*time.Second, "rev-parse", "-q", "--verify", "MERGE_HEAD"); err == nil { if rebase {
_, err := gitRun(path, 120*time.Second, "commit", "--no-edit") _, err := gitRun(path, 120*time.Second, "-c", "core.editor=true", "rebase", "--continue")
return err return err
} }
return 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
// changedFiles 解析 git status --porcelain,返回 路径 -> 状态标记(M/A/D/?), }
// 供网页端文件树展示每个文件的改动状态。 return nil
func changedFiles(path string) map[string]string { }
out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
m := map[string]string{} func changedFiles(path string) map[string]string {
if err != nil { out, err := gitRun(path, 30*time.Second, "status", "--porcelain")
return m m := map[string]string{}
} if err != nil {
for _, line := range strings.Split(out, "\n") { return m
if len(line) < 4 { }
continue for _, line := range strings.Split(out, "\n") {
} if len(line) < 4 {
code := line[:2] continue
p := strings.TrimSpace(line[3:]) }
// 重命名/复制:取目标路径 code := line[:2]
if i := strings.Index(p, "->"); i >= 0 { p := strings.TrimSpace(line[3:])
p = strings.TrimSpace(p[i+2:]) if i := strings.Index(p, "->"); i >= 0 {
} p = strings.TrimSpace(p[i+2:])
// git 对含特殊字符的路径会加引号 }
p = strings.Trim(p, `"`) p = strings.Trim(p, `"`)
if p == "" { if p == "" {
continue continue
} }
switch { switch {
case code == "??": case code == "??":
m[p] = "?" m[p] = "?"
case code[0] == 'A' || code[0] == 'C': case code[0] == 'A' || code[0] == 'C':
m[p] = "A" m[p] = "A"
case code[0] == 'D' || code[1] == 'D': case code[0] == 'D' || code[1] == 'D':
m[p] = "D" m[p] = "D"
default: default:
m[p] = "M" // 含 UU/AU 冲突状态,统一显示为已修改 m[p] = "M" // 含 UU/AU 冲突状态,统一显示为已修改
} }
} }
return m for p, st := range m {
} if st == "?" && strings.HasSuffix(p, "/") {
expandUntracked(path, p, m)
}
}
return m
}
func expandUntracked(repoPath, dir string, m map[string]string) {
out, err := gitRun(repoPath, 15*time.Second, "ls-files", "--others", "--exclude-standard", "--", dir)
if err != nil {
return
}
for _, ln := range strings.Split(out, "\n") {
ln = strings.TrimSpace(ln)
if ln != "" {
m[ln] = "?"
}
}
}
+1
View File
@@ -0,0 +1 @@
2026/09/09 15:59:43 Git Sync Web 已启动: http://127.0.0.1:5799(鉴权:false)
+37 -37
View File
@@ -1,37 +1,37 @@
package main package main
import ( import (
"embed" "embed"
"flag" "flag"
"io/fs" "io/fs"
"log" "log"
"net/http" "net/http"
) )
//go:embed static //go:embed static
var staticFS embed.FS var staticFS embed.FS
func main() { func main() {
baseDir := flag.String("dir", ".", "项目根目录(包含 configs/ logs/ stats/)") baseDir := flag.String("dir", ".", "项目根目录(包含 configs/ logs/ stats/)")
addr := flag.String("addr", "127.0.0.1:5757", "监听地址") addr := flag.String("addr", "127.0.0.1:5757", "监听地址")
password := flag.String("password", "", "访问密码(为空则不启用鉴权)") password := flag.String("password", "", "访问密码(为空则不启用鉴权)")
flag.Parse() flag.Parse()
engine, err := NewEngine(*baseDir) engine, err := NewEngine(*baseDir)
if err != nil { if err != nil {
log.Fatalf("初始化失败: %v", err) log.Fatalf("初始化失败: %v", err)
} }
engine.LoadAll() engine.LoadAll()
engine.StartAll() engine.StartAll()
sub, err := fs.Sub(staticFS, "static") sub, err := fs.Sub(staticFS, "static")
if err != nil { if err != nil {
log.Fatalf("读取内嵌静态资源失败: %v", err) log.Fatalf("读取内嵌静态资源失败: %v", err)
} }
srv := NewServer(engine, sub, *password) srv := NewServer(engine, sub, *password)
log.Printf("Git Sync Web 已启动: http://%s(鉴权:%t)", *addr, *password != "") log.Printf("Git Sync Web 已启动: http://%s(鉴权:%t)", *addr, *password != "")
if err := http.ListenAndServe(*addr, srv); err != nil { if err := http.ListenAndServe(*addr, srv); err != nil {
log.Fatal(err) log.Fatal(err)
} }
} }
+469 -450
View File
@@ -1,451 +1,470 @@
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("GET /api/repos/{name}/summary", s.wrapRepo(s.summaryGetRepo))
mux.HandleFunc("POST /api/repos/{name}/commit", s.wrapRepo(s.commitFileRepo)) mux.HandleFunc("POST /api/repos/{name}/summary", s.wrapRepo(s.summarySaveRepo))
mux.HandleFunc("GET /api/repos/{name}/diff", s.wrapRepo(s.diffRepo)) mux.HandleFunc("POST /api/repos/{name}/resolve-file", s.wrapRepo(s.resolveFileRepo))
mux.HandleFunc("GET /api/cron-next", s.handleCronNext) mux.HandleFunc("POST /api/repos/{name}/commit", s.wrapRepo(s.commitFileRepo))
mux.Handle("/", http.FileServer(http.FS(static))) mux.HandleFunc("GET /api/repos/{name}/diff", s.wrapRepo(s.diffRepo))
return s 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/") && func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
r.URL.Path != "/api/login" && !s.validToken(r) { if s.password != "" && strings.HasPrefix(r.URL.Path, "/api/") &&
writeErr(w, 401, "需要登录") r.URL.Path != "/api/login" && !s.validToken(r) {
return writeErr(w, 401, "需要登录")
} return
s.mux.ServeHTTP(w, r) }
} s.mux.ServeHTTP(w, r)
}
func (s *Server) validToken(r *http.Request) bool {
c, err := r.Cookie("git_sync_token") func (s *Server) validToken(r *http.Request) bool {
if err != nil { c, err := r.Cookie("git_sync_token")
return false if err != nil {
} return false
s.tokenMu.Lock() }
defer s.tokenMu.Unlock() s.tokenMu.Lock()
exp, ok := s.tokens[c.Value] defer s.tokenMu.Unlock()
if !ok { exp, ok := s.tokens[c.Value]
return false if !ok {
} return false
if time.Now().After(exp) { }
delete(s.tokens, c.Value) if time.Now().After(exp) {
return false delete(s.tokens, c.Value)
} return false
return true }
} return true
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
var req struct { func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
Password string `json:"password"` var req struct {
} Password string `json:"password"`
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 subtle.ConstantTimeCompare([]byte(req.Password), []byte(s.password)) != 1 { }
writeErr(w, 401, "密码错误") if subtle.ConstantTimeCompare([]byte(req.Password), []byte(s.password)) != 1 {
return writeErr(w, 401, "密码错误")
} return
buf := make([]byte, 32) }
if _, err := rand.Read(buf); err != nil { buf := make([]byte, 32)
writeErr(w, 500, "生成会话失败") if _, err := rand.Read(buf); err != nil {
return writeErr(w, 500, "生成会话失败")
} return
token := hex.EncodeToString(buf) }
s.tokenMu.Lock() token := hex.EncodeToString(buf)
s.tokens[token] = time.Now().Add(30 * 24 * time.Hour) s.tokenMu.Lock()
s.tokenMu.Unlock() s.tokens[token] = time.Now().Add(30 * 24 * time.Hour)
http.SetCookie(w, &http.Cookie{ s.tokenMu.Unlock()
Name: "git_sync_token", Value: token, Path: "/", http.SetCookie(w, &http.Cookie{
HttpOnly: true, MaxAge: 30 * 24 * 3600, Name: "git_sync_token", Value: token, Path: "/",
}) 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) {
if c, err := r.Cookie("git_sync_token"); err == nil { func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.tokenMu.Lock() if c, err := r.Cookie("git_sync_token"); err == nil {
delete(s.tokens, c.Value) s.tokenMu.Lock()
s.tokenMu.Unlock() 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}) 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") func writeJSON(w http.ResponseWriter, code int, v any) {
w.WriteHeader(code) w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(v) 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 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) { func (s *Server) wrapRepo(h func(http.ResponseWriter, *http.Request, string)) http.HandlerFunc {
name := r.PathValue("name") return func(w http.ResponseWriter, r *http.Request) {
if s.engine.get(name) == nil { name := r.PathValue("name")
writeErr(w, 404, "配置不存在: "+name) if s.engine.get(name) == nil {
return writeErr(w, 404, "配置不存在: "+name)
} return
h(w, r, name) }
} 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) 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 { func (s *Server) handleGet(w http.ResponseWriter, r *http.Request, name string) {
writeJSON(w, 200, st.snapshot(true)) if st := s.engine.get(name); st != nil {
return writeJSON(w, 200, st.snapshot(true))
} return
writeErr(w, 404, "配置不存在") }
} writeErr(w, 404, "配置不存在")
}
func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
var cfg RepoConfig func (s *Server) handleCreate(w http.ResponseWriter, r *http.Request) {
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { var cfg RepoConfig
writeErr(w, 400, "请求体格式错误: "+err.Error()) if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
return writeErr(w, 400, "请求体格式错误: "+err.Error())
} return
if err := s.engine.Create(&cfg); err != nil { }
writeErr(w, 400, err.Error()) if err := s.engine.Create(&cfg); err != nil {
return writeErr(w, 400, err.Error())
} 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) {
var cfg RepoConfig func (s *Server) handleUpdate(w http.ResponseWriter, r *http.Request, name string) {
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil { var cfg RepoConfig
writeErr(w, 400, "请求体格式错误: "+err.Error()) if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
return writeErr(w, 400, "请求体格式错误: "+err.Error())
} return
cfg.Name = name }
if err := s.engine.Update(&cfg); err != nil { cfg.Name = name
writeErr(w, 400, err.Error()) if err := s.engine.Update(&cfg); 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) handleDelete(w http.ResponseWriter, r *http.Request, name string) {
if err := s.engine.Delete(name); err != nil { func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request, name string) {
writeErr(w, 400, err.Error()) if err := s.engine.Delete(name); 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) startRepo(w http.ResponseWriter, r *http.Request, name string) {
if err := s.engine.Start(name); err != nil { func (s *Server) startRepo(w http.ResponseWriter, r *http.Request, name string) {
writeErr(w, 400, err.Error()) if err := s.engine.Start(name); 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) stopRepo(w http.ResponseWriter, r *http.Request, name string) {
if err := s.engine.Stop(name); err != nil { func (s *Server) stopRepo(w http.ResponseWriter, r *http.Request, name string) {
writeErr(w, 400, err.Error()) if err := s.engine.Stop(name); 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) syncRepo(w http.ResponseWriter, r *http.Request, name string) {
if err := s.engine.SyncNow(name); err != nil { func (s *Server) syncRepo(w http.ResponseWriter, r *http.Request, name string) {
writeErr(w, 400, err.Error()) if err := s.engine.SyncNow(name); 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) actionRepo(w http.ResponseWriter, r *http.Request, name string) {
var req struct { func (s *Server) actionRepo(w http.ResponseWriter, r *http.Request, name string) {
Action string `json:"action"` var req struct {
} Action string `json:"action"`
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
switch req.Action { }
case "abort": switch req.Action {
if err := s.engine.ConflictAbort(name); err != nil { case "abort":
writeErr(w, 500, err.Error()) if err := s.engine.ConflictAbort(name); err != nil {
return writeErr(w, 500, err.Error())
} return
case "resolve": }
if err := s.engine.ConflictResolve(name); err != nil { case "resolve":
writeErr(w, 500, err.Error()) if err := s.engine.ConflictResolve(name); err != nil {
return writeErr(w, 500, err.Error())
} return
case "fetch", "pull", "push": }
if err := s.engine.ManualGit(name, req.Action); err != nil { case "fetch", "pull", "push":
writeErr(w, 500, err.Error()) if err := s.engine.ManualGit(name, req.Action); err != nil {
return writeErr(w, 500, err.Error())
} return
default: }
writeErr(w, 400, "未知操作: "+req.Action) default:
return writeErr(w, 400, "未知操作: "+req.Action)
} 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) {
writeJSON(w, 200, s.engine.ConflictDiffs(name)) 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 func (s *Server) logRepo(w http.ResponseWriter, r *http.Request, name string) {
if v, err := strconv.Atoi(r.URL.Query().Get("lines")); err == nil && v > 0 && v <= 5000 { n := 300
n = v 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)}) }
} 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) 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 func (s *Server) commitsRepo(w http.ResponseWriter, r *http.Request, name string) {
if v, err := strconv.Atoi(r.URL.Query().Get("limit")); err == nil && v > 0 && v <= 200 { limit := 50
limit = v 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)) }
} 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")) func (s *Server) commitDiffRepo(w http.ResponseWriter, r *http.Request, name string) {
if err != nil { diff, err := s.engine.ReadCommitDiff(name, r.PathValue("hash"))
writeErr(w, 400, err.Error()) if err != nil {
return writeErr(w, 400, err.Error())
} 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) {
fl, ok := w.(http.Flusher) func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
if !ok { fl, ok := w.(http.Flusher)
http.Error(w, "streaming unsupported", http.StatusInternalServerError) if !ok {
return 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("Content-Type", "text/event-stream")
w.Header().Set("X-Accel-Buffering", "no") 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) 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) if snap, err := json.Marshal(map[string]any{"type": "snapshot", "data": s.engine.Snapshots(true)}); err == nil {
fl.Flush() fmt.Fprintf(w, "data: %s\n\n", snap)
} fl.Flush()
}
tick := time.NewTicker(25 * time.Second)
defer tick.Stop() tick := time.NewTicker(25 * time.Second)
ctx := r.Context() defer tick.Stop()
for { ctx := r.Context()
select { for {
case <-ctx.Done(): select {
return case <-ctx.Done():
case <-tick.C: return
fmt.Fprint(w, ": ping\n\n") case <-tick.C:
fl.Flush() fmt.Fprint(w, ": ping\n\n")
case data, ok := <-ch: fl.Flush()
if !ok { case data, ok := <-ch:
return if !ok {
} return
fmt.Fprintf(w, "data: %s\n\n", data) }
fl.Flush() 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 { func (s *Server) forcePushRepo(w http.ResponseWriter, r *http.Request, name string) {
writeErr(w, 400, err.Error()) if err := s.engine.ForcePush(name); 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) abortPushRepo(w http.ResponseWriter, r *http.Request, name string) {
if err := s.engine.AbortPush(name); err != nil { func (s *Server) abortPushRepo(w http.ResponseWriter, r *http.Request, name string) {
writeErr(w, 400, err.Error()) if err := s.engine.AbortPush(name); 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) treeRepo(w http.ResponseWriter, r *http.Request, name string) { func (s *Server) treeRepo(w http.ResponseWriter, r *http.Request, name string) {
writeJSON(w, 200, map[string]any{"files": s.engine.FileTree(name)}) writeJSON(w, 200, map[string]any{"files": s.engine.FileTree(name)})
} }
func (s *Server) fileRepo(w http.ResponseWriter, r *http.Request, name string) { func (s *Server) fileRepo(w http.ResponseWriter, r *http.Request, name string) {
rel := r.URL.Query().Get("path") rel := r.URL.Query().Get("path")
if rel == "" { if rel == "" {
writeErr(w, 400, "缺少 path 参数") writeErr(w, 400, "缺少 path 参数")
return return
} }
fc, err := s.engine.ReadFileContent(name, rel) fc, err := s.engine.ReadFileContent(name, rel)
if err != nil { if err != nil {
writeErr(w, 400, err.Error()) writeErr(w, 400, err.Error())
return return
} }
writeJSON(w, 200, fc) writeJSON(w, 200, fc)
} }
func (s *Server) fileWriteRepo(w http.ResponseWriter, r *http.Request, name string) { func (s *Server) fileWriteRepo(w http.ResponseWriter, r *http.Request, name string) {
var req struct { var req struct {
Path string `json:"path"` Path string `json:"path"`
Content string `json:"content"` Content string `json:"content"`
} }
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 req.Path == "" { if req.Path == "" {
writeErr(w, 400, "缺少 path") writeErr(w, 400, "缺少 path")
return return
} }
if err := s.engine.WriteFileContent(name, req.Path, req.Content); err != nil { if err := s.engine.WriteFileContent(name, req.Path, req.Content); 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})
} }
// resolveFileRepo 网页端解决单个冲突文件:写入 + 暂存,finish=true 时尝试结束合并/变基 func (s *Server) summaryGetRepo(w http.ResponseWriter, r *http.Request, name string) {
func (s *Server) resolveFileRepo(w http.ResponseWriter, r *http.Request, name string) { writeJSON(w, 200, map[string]any{
var req struct { "pending": s.engine.GetPendingSummary(name),
Path string `json:"path"` "log": s.engine.GetSummaryLog(name),
Content string `json:"content"` })
Finish bool `json:"finish"` }
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { func (s *Server) summarySaveRepo(w http.ResponseWriter, r *http.Request, name string) {
writeErr(w, 400, "请求体格式错误") var req struct {
return Summary string `json:"summary"`
} }
if req.Path == "" { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, 400, "缺少 path") writeErr(w, 400, "请求体格式错误")
return return
} }
if err := s.engine.ResolveConflictFile(name, req.Path, req.Content, req.Finish); err != nil { if err := s.engine.SavePendingSummary(name, req.Summary); err != nil {
writeErr(w, 400, err.Error()) writeErr(w, 500, err.Error())
return return
} }
writeJSON(w, 200, map[string]any{"ok": true}) writeJSON(w, 200, map[string]any{"ok": true, "pending": s.engine.GetPendingSummary(name)})
} }
func (s *Server) commitFileRepo(w http.ResponseWriter, r *http.Request, name string) { func (s *Server) resolveFileRepo(w http.ResponseWriter, r *http.Request, name string) {
var req struct { var req struct {
Path string `json:"path"` Path string `json:"path"`
Message string `json:"message"` Content string `json:"content"`
} Finish bool `json:"finish"`
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.CommitFile(name, req.Path, req.Message); err != nil { }
writeErr(w, 400, err.Error()) if err := s.engine.ResolveConflictFile(name, req.Path, req.Content, req.Finish); 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) diffRepo(w http.ResponseWriter, r *http.Request, name string) {
scope := r.URL.Query().Get("scope") func (s *Server) commitFileRepo(w http.ResponseWriter, r *http.Request, name string) {
if scope == "" { var req struct {
scope = "working" Path string `json:"path"`
} Message string `json:"message"`
// path 可选:传入时只对比该文件(用于编辑器「对比此文件」) }
// mode = word 时返回 --word-diff=plain 结果(词级差异) if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
res, err := s.engine.WorkingDiff(name, scope, r.URL.Query().Get("path"), r.URL.Query().Get("mode")) writeErr(w, 400, "请求体格式错误")
if err != nil { return
writeErr(w, 400, err.Error()) }
return if req.Path == "" {
} writeErr(w, 400, "缺少 path")
writeJSON(w, 200, res) return
} }
if err := s.engine.CommitFile(name, req.Path, req.Message); err != nil {
// handleCronNext 返回 cron 表达式的下次执行时间,供配置表单实时预览 writeErr(w, 400, err.Error())
func (s *Server) handleCronNext(w http.ResponseWriter, r *http.Request) { return
expr := r.URL.Query().Get("expr") }
if expr == "" { writeJSON(w, 200, map[string]any{"ok": true})
writeErr(w, 400, "缺少 expr 参数") }
return
} func (s *Server) diffRepo(w http.ResponseWriter, r *http.Request, name string) {
next, err := cronNextString(expr) scope := r.URL.Query().Get("scope")
if err != nil { if scope == "" {
writeErr(w, 400, err.Error()) scope = "working"
return }
} // mode = word 时返回 --word-diff=plain 结果(词级差异)
writeJSON(w, 200, map[string]any{"next": next}) 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})
} }
+1207 -1192
View File
File diff suppressed because it is too large Load Diff
+25 -29
View File
@@ -1,29 +1,25 @@
// CodeMirror 引导:在 codemirror.min.js 及其 mode 加载完成后接管 #file-editor。 (function () {
// 若库未加载或初始化失败,window.Editor.cm 保持 null,自动回退原生 textarea,功能不受影响。 if (typeof window.CodeMirror === 'undefined' || typeof window.Editor === 'undefined') return;
(function () { var ta = document.getElementById('file-editor');
if (typeof window.CodeMirror === 'undefined' || typeof window.Editor === 'undefined') return; if (!ta) return;
var ta = document.getElementById('file-editor'); try {
if (!ta) return; var dark = document.documentElement.classList.contains('dark');
try { var cm = window.CodeMirror.fromTextArea(ta, {
var dark = document.documentElement.classList.contains('dark'); lineNumbers: true,
var cm = window.CodeMirror.fromTextArea(ta, { lineWrapping: true,
lineNumbers: true, indentUnit: 2,
lineWrapping: true, tabSize: 2,
indentUnit: 2, theme: dark ? 'material-darker' : 'eclipse',
tabSize: 2, readOnly: ta.disabled ? 'nocursor' : false,
theme: dark ? 'material-darker' : 'eclipse', extraKeys: {
readOnly: ta.disabled ? 'nocursor' : false, 'Ctrl-S': function () { if (window.Editor.onSave) window.Editor.onSave(); },
extraKeys: { 'Cmd-S': function () { if (window.Editor.onSave) window.Editor.onSave(); }
'Ctrl-S': function () { if (window.Editor.onSave) window.Editor.onSave(); }, }
'Cmd-S': function () { if (window.Editor.onSave) window.Editor.onSave(); } });
} window.Editor.cm = cm;
}); cm.on('change', function () { if (window.Editor._suspend) return; if (window.Editor.onChange) window.Editor.onChange(); });
window.Editor.cm = cm; var cur = window.Editor.value;
// CodeMirror 的 change 事件桥接到草稿缓存 } catch (e) {
cm.on('change', function () { if (window.Editor.onChange) window.Editor.onChange(); }); if (window.console) console.warn('CodeMirror 初始化失败,已回退原生编辑器:', e);
// 模式跟随打开文件时的 setMode }
var cur = window.Editor.value; })();
} catch (e) {
if (window.console) console.warn('CodeMirror 初始化失败,已回退原生编辑器:', e);
}
})();
+30 -16
View File
@@ -111,12 +111,14 @@
</div> </div>
<div class="file-body" data-page-node-id="RXmkN5Ep4iegYWG3iWBiON"> <div class="file-body" data-page-node-id="RXmkN5Ep4iegYWG3iWBiON">
<div id="file-list" class="file-list" data-page-node-id="F75SFyDQShim9rZFPnjuJC">加载中…</div> <div id="file-list" class="file-list" data-page-node-id="F75SFyDQShim9rZFPnjuJC">加载中…</div>
<div class="file-edit-col" data-page-node-id="xh1qou2SVboDfvKTLcXFEA"> <div class="file-edit-col" data-page-node-id="xh1qou2SVboDfvKTLcXFEA">
<textarea id="file-editor" class="file-editor" placeholder="选择左侧文件进行编辑(Ctrl/Cmd + S 保存)" spellcheck="false"></textarea> <div class="file-chg-hint" id="file-chg-hint" hidden>● 黄色高亮 = 相对最近一次提交仍未提交的修改行</div>
<textarea id="file-editor" class="file-editor" placeholder="选择左侧文件进行编辑(Ctrl/Cmd + S 保存)" spellcheck="false"></textarea>
<div class="file-actions" data-page-node-id="GAMAnkYKeMi93EVEx8eYOn"> <div class="file-actions" data-page-node-id="GAMAnkYKeMi93EVEx8eYOn">
<input id="file-msg" class="file-msg" placeholder="提交说明(可选)" data-page-node-id="aS2jRAr1kAkqElk1T4Cldz"> <input id="file-msg" class="file-msg" placeholder="提交说明(可选)" data-page-node-id="aS2jRAr1kAkqElk1T4Cldz">
<button class="btn btn-sm" id="btn-file-save" data-page-node-id="gaVGyD6YkFFElkXJFBtLk5">保存</button> <button class="btn btn-sm" id="btn-file-save" data-page-node-id="gaVGyD6YkFFElkXJFBtLk5">保存</button>
<button class="btn btn-sm btn-primary" id="btn-file-commit" data-page-node-id="1VDju8pdiboyM0zBgwff21">保存并提交</button> <button class="btn btn-sm" id="btn-file-commit-only" title="提交当前文件在磁盘上的改动(不含编辑器里尚未保存的修改)">提交</button>
<button class="btn btn-sm btn-primary" id="btn-file-commit" data-page-node-id="1VDju8pdiboyM0zBgwff21">保存并提交</button>
</div> </div>
</div> </div>
</div> </div>
@@ -127,15 +129,15 @@
<button class="btn btn-sm" id="btn-pull" data-page-node-id="D3F7c7ZkTj2GaQqrBtshfW">拉取</button> <button class="btn btn-sm" id="btn-pull" data-page-node-id="D3F7c7ZkTj2GaQqrBtshfW">拉取</button>
<button class="btn btn-sm" id="btn-push" data-page-node-id="qTA9vsiTSuWz4dwilu7Prf">推送</button> <button class="btn btn-sm" id="btn-push" data-page-node-id="qTA9vsiTSuWz4dwilu7Prf">推送</button>
</div> </div>
<div class="conflict-actions" id="pull-conflict-actions" data-page-node-id="u1mYEBVApFghZDqR2RFg7V"> <div class="conflict-actions" id="pull-conflict-actions" data-page-node-id="u1mYEBVApFghZDqR2RFg7V">
<button class="btn btn-danger" id="btn-abort" data-page-node-id="iFRU44FOA3NXieww4PmbAR">放弃合并</button> <button class="btn btn-sm btn-danger" id="btn-abort" data-page-node-id="iFRU44FOA3NXieww4PmbAR">放弃合并</button>
<button class="btn btn-primary" id="btn-resolved" data-page-node-id="C4SMNurpXaOJ8jByGjS2zg">我已在本地解决,继续</button> <button class="btn btn-sm btn-primary" id="btn-resolved" data-page-node-id="C4SMNurpXaOJ8jByGjS2zg">我已在本地解决,继续</button>
</div> </div>
<div class="conflict-actions" id="push-conflict-actions" hidden data-page-node-id="C2kukXRbyT5HAgGznQBEOF"> <div class="conflict-actions" id="push-conflict-actions" hidden data-page-node-id="C2kukXRbyT5HAgGznQBEOF">
<span class="hint" id="push-conflict-hint" data-page-node-id="meqqutN8Y8BlHoIUOsDuc3"></span> <span class="hint" id="push-conflict-hint" data-page-node-id="meqqutN8Y8BlHoIUOsDuc3"></span>
<button class="btn btn-danger" id="btn-force-push" data-page-node-id="20FtTVRLlB3HrObTPUgS7I">强制推送(--force-with-lease)</button> <button class="btn btn-sm btn-danger" id="btn-force-push" data-page-node-id="20FtTVRLlB3HrObTPUgS7I">强制推送(--force-with-lease)</button>
<button class="btn" id="btn-abort-push" data-page-node-id="kr5vGtshzwTYewuRpjenau">放弃强制推送</button> <button class="btn btn-sm" id="btn-abort-push" data-page-node-id="kr5vGtshzwTYewuRpjenau">放弃强制推送</button>
</div> </div>
<div id="conflict-list" data-page-node-id="5cossjGefHTRs2BQmv7ZiE"></div> <div id="conflict-list" data-page-node-id="5cossjGefHTRs2BQmv7ZiE"></div>
</div> </div>
<div id="tab-stats" class="tab-pane" hidden data-page-node-id="AmEsRLuCU7oz0ILjoFOSan"> <div id="tab-stats" class="tab-pane" hidden data-page-node-id="AmEsRLuCU7oz0ILjoFOSan">
@@ -197,14 +199,26 @@
<input type="checkbox" id="f-autocommit" data-page-node-id="5ADMjJXNR5EGPDmhwiCBlm"> <input type="checkbox" id="f-autocommit" data-page-node-id="5ADMjJXNR5EGPDmhwiCBlm">
<span data-page-node-id="z0tpoI6jpAbJCEwUJ4cSxO">自动提交模式:检测到未提交改动时自动 commit,并随同步推送到远程</span> <span data-page-node-id="z0tpoI6jpAbJCEwUJ4cSxO">自动提交模式:检测到未提交改动时自动 commit,并随同步推送到远程</span>
</label> </label>
<label class="full check-row">
<input type="checkbox" id="f-autosync">
<span>自动同步:按间隔 / Cron 自动拉取推送。关闭后仅响应「立即同步」手动触发,不再自动运行</span>
</label>
<label class="full" data-page-node-id="tei066TFL8N9sqInjysFvD">Cron 表达式(可选,5 段:分 时 日 月 周;填写后优先于固定间隔) <label class="full" data-page-node-id="tei066TFL8N9sqInjysFvD">Cron 表达式(可选,5 段:分 时 日 月 周;填写后优先于固定间隔)
<input name="cron" id="f-cron" placeholder="*/30 * * * * 表示每 30 分钟,留空使用固定间隔" data-page-node-id="6W3Ua8pPoGLuaNh7rGcDOw"> <input name="cron" id="f-cron" placeholder="*/30 * * * * 表示每 30 分钟,留空使用固定间隔" data-page-node-id="6W3Ua8pPoGLuaNh7rGcDOw">
<span id="cron-next" class="cron-next" data-page-node-id="WgXuBnZPqGWvOBGRxzIVCn"></span> <span id="cron-next" class="cron-next" data-page-node-id="WgXuBnZPqGWvOBGRxzIVCn"></span>
</label> </label>
<label class="full" data-page-node-id="fsjZ6wLid5NUBV0yAHKdII">日志文件路径(留空使用默认 logs/配置名.log) <label class="full" data-page-node-id="fsjZ6wLid5NUBV0yAHKdII">日志文件路径(留空使用默认 logs/配置名.log)
<input name="log_file" id="f-log" data-page-node-id="KJpv7jEWYnOQZ1PtQMetfP"> <input name="log_file" id="f-log" data-page-node-id="KJpv7jEWYnOQZ1PtQMetfP">
</label> </label>
<div class="summary-box" id="summary-box" hidden>
<div class="summary-head">
<span>变更摘要(本地,不同步)</span>
<button type="button" class="btn btn-sm" id="btn-summary-save">保存摘要</button>
</div>
<textarea id="f-summary" class="file-editor" style="min-height:84px" placeholder="填写后将作为【自动提交】的下一次 commit 信息;留空则由工具自动生成一句话并在本地记录。此摘要与映射仅存于本地 summaries/,不会上传。"></textarea>
<div class="summary-log" id="summary-log"></div>
</div> </div>
</div>
<div class="form-actions" data-page-node-id="1GfIJLKdxSyPwBqpqCCMb9"> <div class="form-actions" data-page-node-id="1GfIJLKdxSyPwBqpqCCMb9">
<button type="submit" class="btn btn-primary" data-page-node-id="E2123htNE6lmoNzZTg7AwV">保存</button> <button type="submit" class="btn btn-primary" data-page-node-id="E2123htNE6lmoNzZTg7AwV">保存</button>
<button type="button" class="btn" id="btn-clone" hidden data-page-node-id="H6BdDiraff2cjD9EWjF8Eh">克隆此配置</button> <button type="button" class="btn" id="btn-clone" hidden data-page-node-id="H6BdDiraff2cjD9EWjF8Eh">克隆此配置</button>
+28 -8
View File
@@ -23,21 +23,21 @@ header { display: flex; justify-content: space-between; align-items: center; pad
.btn-danger:hover { background: var(--red-bg); } .btn-danger:hover { background: var(--red-bg); }
.btn-icon { border: none; background: none; font-size: 18px; cursor: pointer; color: var(--muted); } .btn-icon { border: none; background: none; font-size: 18px; cursor: pointer; color: var(--muted); }
main { max-width: 1320px; margin: 24px auto; padding: 0 24px; } main { max-width: 1320px; margin: 18px auto; padding: 0 18px; }
.layout { display: flex; gap: 20px; align-items: flex-start; } .layout { display: flex; gap: 16px; align-items: flex-start; }
.main-col { flex: 1; min-width: 0; } .main-col { flex: 1; min-width: 0; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; } .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.empty { text-align: center; color: var(--muted); padding: 80px 0; } .empty { text-align: center; color: var(--muted); padding: 80px 0; }
/* 全局实时活动面板 */ /* 全局实时活动面板 */
.live { width: 360px; flex: none; position: sticky; top: 80px; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); display: flex; flex-direction: column; max-height: calc(100vh - 104px); } .live { width: 460px; flex: none; position: sticky; top: 78px; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); display: flex; flex-direction: column; max-height: calc(100vh - 100px); }
.live-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border); } .live-head { display: flex; align-items: center; gap: 8px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
.live-head h2 { margin: 0; font-size: 15px; font-weight: 500; flex: 1; } .live-head h2 { margin: 0; font-size: 15px; font-weight: 500; flex: 1; }
.live-head .conn { margin: 0; } .live-head .conn { margin: 0; }
.btn-sm { border: 1px solid var(--border); background: #fff; border-radius: 7px; padding: 3px 10px; font-size: 12px; cursor: pointer; color: var(--muted); } .btn-sm { border: 1px solid var(--border); background: #fff; border-radius: 7px; padding: 3px 10px; font-size: 12px; cursor: pointer; color: var(--muted); }
.btn-sm:hover { border-color: #b6bcc4; background: #f3f4f6; } .btn-sm:hover { border-color: #b6bcc4; background: #f3f4f6; }
.live-hint { font-size: 11px; color: var(--muted); padding: 8px 14px; border-bottom: 1px solid var(--border); } .live-hint { font-size: 11px; color: var(--muted); padding: 8px 14px; border-bottom: 1px solid var(--border); }
.live-log { flex: 1; overflow-y: auto; padding: 8px 10px; font-family: Consolas, "Cascadia Code", monospace; font-size: 11.5px; line-height: 1.5; } .live-log { flex: 1; overflow-y: auto; padding: 8px 10px; font-family: Consolas, "Cascadia Code", monospace; font-size: 12px; line-height: 1.45; }
.live-line { display: flex; gap: 6px; padding: 2px 0; border-bottom: 1px solid #f2f2f2; } .live-line { display: flex; gap: 6px; padding: 2px 0; border-bottom: 1px solid #f2f2f2; }
.live-line .ll-time { color: #9aa0a6; flex: none; } .live-line .ll-time { color: #9aa0a6; flex: none; }
.live-line .ll-repo { color: var(--blue); flex: none; font-weight: 600; max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .live-line .ll-repo { color: var(--blue); flex: none; font-weight: 600; max-width: 96px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@@ -53,7 +53,7 @@ main { max-width: 1320px; margin: 24px auto; padding: 0 24px; }
.live { width: 100%; position: static; max-height: 360px; } .live { width: 100%; position: static; max-height: 360px; }
} }
.card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; display: flex; flex-direction: column; gap: 8px; } .card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px; display: flex; flex-direction: column; gap: 6px; }
.card.st-conflict { border-color: #e8c98a; background: #fffdf6; } .card.st-conflict { border-color: #e8c98a; background: #fffdf6; }
.card.st-error { border-color: #eec9c9; } .card.st-error { border-color: #eec9c9; }
.card-head { display: flex; align-items: center; gap: 8px; } .card-head { display: flex; align-items: center; gap: 8px; }
@@ -89,7 +89,8 @@ main { max-width: 1320px; margin: 24px auto; padding: 0 24px; }
.tab-pane[hidden] { display: none; } .tab-pane[hidden] { display: none; }
.logbox { margin: 0; background: #f6f7f9; border: 1px solid var(--border); padding: 12px; border-radius: 8px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 55vh; overflow: auto; } .logbox { margin: 0; background: #f6f7f9; border: 1px solid var(--border); padding: 12px; border-radius: 8px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 55vh; overflow: auto; }
.conflict-actions { display: flex; gap: 8px; margin-bottom: 12px; } .conflict-actions { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.manual-actions { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
.conflict-file { margin-bottom: 14px; } .conflict-file { margin-bottom: 14px; }
.conflict-file h4 { margin: 0 0 6px; font-size: 13px; font-family: Consolas, monospace; } .conflict-file h4 { margin: 0 0 6px; font-size: 13px; font-family: Consolas, monospace; }
.conflict-file pre { margin: 0; background: #f6f7f9; border: 1px solid var(--border); border-radius: 8px; padding: 10px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 300px; overflow: auto; } .conflict-file pre { margin: 0; background: #f6f7f9; border: 1px solid var(--border); border-radius: 8px; padding: 10px; font: 12px/1.5 Consolas, monospace; white-space: pre-wrap; word-break: break-all; max-height: 300px; overflow: auto; }
@@ -144,11 +145,19 @@ textarea { font-family: Consolas, monospace; resize: vertical; }
.file-item { padding: 4px 8px; border-radius: 6px; font-size: 12.5px; font-family: Consolas, monospace; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .file-item { padding: 4px 8px; border-radius: 6px; font-size: 12.5px; font-family: Consolas, monospace; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.file-item:hover { background: #f0f3f6; } .file-item:hover { background: #f0f3f6; }
.file-edit-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; } .file-edit-col { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.file-chg-hint { font-size: 12px; color: #9a6b00; background: rgba(255, 196, 0, 0.12); border: 1px solid rgba(224, 160, 32, 0.4); border-radius: 6px; padding: 4px 8px; }
html.dark .file-chg-hint { color: #d9a93a; background: rgba(255, 196, 0, 0.1); border-color: rgba(201, 143, 28, 0.4); }
.file-editor { flex: 1; min-height: 360px; width: 100%; border: 1px solid var(--border); border-radius: 8px; padding: 10px; font: 12.5px/1.55 Consolas, monospace; color: var(--text); background: #fff; white-space: pre; overflow: auto; } .file-editor { flex: 1; min-height: 360px; width: 100%; border: 1px solid var(--border); border-radius: 8px; padding: 10px; font: 12.5px/1.55 Consolas, monospace; color: var(--text); background: #fff; white-space: pre; overflow: auto; }
.file-editor:disabled { background: #f6f7f9; color: var(--muted); } .file-editor:disabled { background: #f6f7f9; color: var(--muted); }
.file-actions { display: flex; gap: 8px; align-items: center; } .file-actions { display: flex; gap: 8px; align-items: center; }
.file-msg { flex: 1; } .file-msg { flex: 1; }
.summary-box { margin-top: 16px; padding: 12px 14px; border: 1px solid var(--border); border-radius: 10px; background: var(--card); }
.summary-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 13px; color: var(--muted); }
.summary-head .btn-sm { padding: 4px 10px; font-size: 12px; }
.summary-log { margin-top: 10px; padding: 10px 12px; border: 1px dashed var(--border); border-radius: 8px; background: #fafbfc; font: 12px/1.6 Consolas, "Microsoft YaHei", monospace; color: var(--text); white-space: pre-wrap; max-height: 220px; overflow: auto; }
html.dark .summary-log { background: #12151a; }
@media (max-width: 760px) { @media (max-width: 760px) {
.file-body { flex-direction: column; } .file-body { flex-direction: column; }
.file-list { width: 100%; max-height: 200px; } .file-list { width: 100%; max-height: 200px; }
@@ -201,6 +210,7 @@ html.dark .file-editor { background: #12151a; }
/* ---------- 卡片:久未同步 ---------- */ /* ---------- 卡片:久未同步 ---------- */
.badge.b-stale { background: var(--amber-bg); color: var(--amber); } .badge.b-stale { background: var(--amber-bg); color: var(--amber); }
.badge.b-paused { background: var(--gray-bg); color: var(--muted); }
/* ---------- 配置:cron 下次执行预览 ---------- */ /* ---------- 配置:cron 下次执行预览 ---------- */
.cron-next { font-size: 12px; color: var(--muted); min-height: 16px; } .cron-next { font-size: 12px; color: var(--muted); min-height: 16px; }
@@ -239,12 +249,22 @@ html.dark .file-editor { background: #12151a; }
.CodeMirror { height: 420px; border: 1px solid var(--border); border-radius: 8px; font: 12.5px/1.55 Consolas, monospace; } .CodeMirror { height: 420px; border: 1px solid var(--border); border-radius: 8px; font: 12.5px/1.55 Consolas, monospace; }
.CodeMirror-scroll { min-height: 380px; } .CodeMirror-scroll { min-height: 380px; }
/* 未提交修改行高亮 */
.CodeMirror-linebackground.cm-chg {
background-color: rgba(255, 196, 0, 0.16);
box-shadow: inset 3px 0 0 0 #e0a020;
}
html.dark .CodeMirror-linebackground.cm-chg {
background-color: rgba(255, 196, 0, 0.13);
box-shadow: inset 3px 0 0 0 #c98f1c;
}
/* ---------- 冲突文件内联编辑 ---------- */ /* ---------- 冲突文件内联编辑 ---------- */
.cf-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; } .cf-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
.cf-head h4 { margin: 0; flex: 1; } .cf-head h4 { margin: 0; flex: 1; }
.cf-ta { width: 100%; min-height: 260px; border: 1px solid var(--border); border-radius: 8px; padding: 10px; font: 12.5px/1.55 Consolas, monospace; color: var(--text); background: #fff; white-space: pre; overflow: auto; } .cf-ta { width: 100%; min-height: 260px; border: 1px solid var(--border); border-radius: 8px; padding: 10px; font: 12.5px/1.55 Consolas, monospace; color: var(--text); background: #fff; white-space: pre; overflow: auto; }
html.dark .cf-ta { background: #12151a; } html.dark .cf-ta { background: #12151a; }
.cf-actions { display: flex; gap: 8px; margin-top: 6px; } .cf-actions { display: flex; gap: 8px; margin-top: 6px; flex-wrap: wrap; }
.cf-hint { font-size: 12px; color: var(--muted); margin-top: 6px; line-height: 1.5; } .cf-hint { font-size: 12px; color: var(--muted); margin-top: 6px; line-height: 1.5; }
.cf-edit { margin-bottom: 10px; } .cf-edit { margin-bottom: 10px; }
.conflict-file { border: 1px solid var(--border); border-radius: 8px; padding: 10px; margin-bottom: 10px; } .conflict-file { border: 1px solid var(--border); border-radius: 8px; padding: 10px; margin-bottom: 10px; }