git-sync-web 自动提交 2026-09-09 12:38:18
This commit is contained in:
@@ -1209,4 +1209,240 @@ func (e *Engine) ReadCommitDiff(name, hash string) (string, error) {
|
||||
out = out[:131072] + "\n…(内容过长已截断)"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ---------- 差异与文件编辑(网页端) ----------
|
||||
|
||||
const fileEditMaxBytes = 1 << 20 // 1MB,网页编辑单文件上限
|
||||
|
||||
// safeRepoPath 将仓库内相对路径解析为绝对路径,并严格校验不越界、不触及 .git
|
||||
func (e *Engine) safeRepoPath(s *repoState, rel string) (string, error) {
|
||||
rel = filepath.ToSlash(strings.TrimSpace(rel))
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
if rel == "" || rel == "." {
|
||||
return "", fmt.Errorf("无效路径")
|
||||
}
|
||||
for _, seg := range strings.Split(rel, "/") {
|
||||
if seg == ".." {
|
||||
return "", fmt.Errorf("路径不允许包含 ..")
|
||||
}
|
||||
}
|
||||
root := filepath.Clean(s.cfg.RepoPath)
|
||||
full := filepath.Clean(filepath.Join(root, filepath.FromSlash(rel)))
|
||||
if full != root && !strings.HasPrefix(full, root+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("路径越界: %s", rel)
|
||||
}
|
||||
relClean := strings.TrimPrefix(full, root+string(os.PathSeparator))
|
||||
if relClean == ".git" || strings.HasPrefix(relClean, ".git"+string(os.PathSeparator)) {
|
||||
return "", fmt.Errorf("禁止访问 .git 内部")
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
|
||||
// FileNode 文件树节点(网页端)
|
||||
type FileNode struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// FileTree 列出仓库内可编辑文件(已跟踪 + 未跟踪且未被忽略),天然避开 .git 内部
|
||||
func (e *Engine) FileTree(name string) []FileNode {
|
||||
s := e.get(name)
|
||||
if s == nil {
|
||||
return []FileNode{}
|
||||
}
|
||||
set := map[string]struct{}{}
|
||||
collect := func(args ...string) {
|
||||
if out, err := gitRun(s.cfg.RepoPath, 15*time.Second, args...); err == nil {
|
||||
for _, ln := range strings.Split(strings.TrimSpace(out), "\n") {
|
||||
ln = strings.TrimSpace(ln)
|
||||
if ln != "" {
|
||||
set[ln] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
collect("ls-files")
|
||||
collect("ls-files", "--others", "--exclude-standard")
|
||||
paths := make([]string, 0, len(set))
|
||||
for p := range set {
|
||||
paths = append(paths, p)
|
||||
}
|
||||
sort.Strings(paths)
|
||||
nodes := make([]FileNode, 0, len(paths))
|
||||
for _, p := range paths {
|
||||
nodes = append(nodes, FileNode{Path: p})
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
|
||||
// FileContent 文件内容读取结果
|
||||
type FileContent struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Binary bool `json:"binary"`
|
||||
Size int `json:"size"`
|
||||
}
|
||||
|
||||
// ReadFileContent 读取仓库内文件内容(带大小上限与二进制检测)
|
||||
func (e *Engine) ReadFileContent(name, rel string) (*FileContent, error) {
|
||||
s := e.get(name)
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("配置不存在: %s", name)
|
||||
}
|
||||
full, err := e.safeRepoPath(s, rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Stat(full)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, fmt.Errorf("这是一个目录: %s", rel)
|
||||
}
|
||||
if info.Size() > fileEditMaxBytes {
|
||||
return nil, fmt.Errorf("文件过大(>%d 字节),禁止在网页编辑", fileEditMaxBytes)
|
||||
}
|
||||
data, err := os.ReadFile(full)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if bytes.Contains(data, []byte{0}) {
|
||||
return &FileContent{Path: rel, Binary: true, Size: int(info.Size())}, nil
|
||||
}
|
||||
return &FileContent{Path: rel, Content: string(data), Size: int(info.Size())}, nil
|
||||
}
|
||||
|
||||
// WriteFileContent 将内容写回仓库内文件(路径校验 + 大小上限 + 二进制拒绝)
|
||||
func (e *Engine) WriteFileContent(name, rel, content string) error {
|
||||
s := e.get(name)
|
||||
if s == nil {
|
||||
return fmt.Errorf("配置不存在: %s", name)
|
||||
}
|
||||
full, err := e.safeRepoPath(s, rel)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(content) > fileEditMaxBytes {
|
||||
return fmt.Errorf("内容过大(>%d 字节)", fileEditMaxBytes)
|
||||
}
|
||||
if strings.Contains(content, "\x00") {
|
||||
return fmt.Errorf("内容含空字符,疑似二进制,禁止写入")
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(full, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
// CommitFile 仅提交单个被网页端编辑的文件
|
||||
func (e *Engine) CommitFile(name, rel, message string) error {
|
||||
s := e.get(name)
|
||||
if s == nil {
|
||||
return fmt.Errorf("配置不存在: %s", name)
|
||||
}
|
||||
if _, err := e.safeRepoPath(s, rel); err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.TrimSpace(message) == "" {
|
||||
message = "网页端编辑: " + rel
|
||||
}
|
||||
if _, err := gitRun(s.cfg.RepoPath, 30*time.Second, "add", "--", rel); err != nil {
|
||||
return fmt.Errorf("git add 失败: %w", err)
|
||||
}
|
||||
if _, err := gitRun(s.cfg.RepoPath, 60*time.Second, "commit", "-m", message, "--", rel); err != nil {
|
||||
return fmt.Errorf("git commit 失败: %w", err)
|
||||
}
|
||||
e.logf(s, "info", "网页端提交 %s 成功", rel)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DiffFile 单文件增删统计
|
||||
type DiffFile struct {
|
||||
Path string `json:"path"`
|
||||
Add int `json:"add"`
|
||||
Del int `json:"del"`
|
||||
}
|
||||
|
||||
// DiffResult 差异结果(网页端「差异」tab)
|
||||
type DiffResult struct {
|
||||
Scope string `json:"scope"`
|
||||
Stat string `json:"stat"`
|
||||
Text string `json:"text"`
|
||||
Files []DiffFile `json:"files"`
|
||||
VsRemote bool `json:"vs_remote"`
|
||||
}
|
||||
|
||||
// WorkingDiff 返回工作区/暂存/全部/与远程的差异
|
||||
func (e *Engine) WorkingDiff(name, scope string) (*DiffResult, error) {
|
||||
s := e.get(name)
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("配置不存在: %s", name)
|
||||
}
|
||||
res := &DiffResult{Scope: scope}
|
||||
var args []string
|
||||
remote := "origin"
|
||||
switch scope {
|
||||
case "staged":
|
||||
args = []string{"diff", "--cached", "--no-color"}
|
||||
case "all":
|
||||
args = []string{"diff", "HEAD", "--no-color"}
|
||||
case "remote":
|
||||
ahead, _, _ := aheadBehind(s.cfg.RepoPath, remote, s.cfg.Branch)
|
||||
if ahead <= 0 {
|
||||
res.Text = "本地没有领先远程的提交,无需对比。"
|
||||
return res, nil
|
||||
}
|
||||
args = []string{"diff", "--no-color", remote + "/" + s.cfg.Branch, "HEAD"}
|
||||
res.VsRemote = true
|
||||
default: // working(未暂存)
|
||||
args = []string{"diff", "--no-color"}
|
||||
}
|
||||
statArgs := append([]string{}, args...)
|
||||
if scope == "remote" {
|
||||
statArgs = []string{"diff", "--no-color", "--stat", remote + "/" + s.cfg.Branch, "HEAD"}
|
||||
} else {
|
||||
statArgs = append(statArgs, "--stat")
|
||||
}
|
||||
if statOut, serr := gitRun(s.cfg.RepoPath, 30*time.Second, statArgs...); serr == nil {
|
||||
res.Stat = statOut
|
||||
res.Files = parseStatFiles(statOut)
|
||||
}
|
||||
if textOut, terr := gitRun(s.cfg.RepoPath, 60*time.Second, args...); terr == nil {
|
||||
res.Text = textOut
|
||||
} else {
|
||||
res.Text = terr.Error()
|
||||
}
|
||||
if len(res.Text) > 262144 {
|
||||
res.Text = res.Text[:262144] + "\n…(diff 过长已截断)"
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// parseStatFiles 解析 `git diff --stat` 输出为单文件增删统计
|
||||
func parseStatFiles(stat string) []DiffFile {
|
||||
var files []DiffFile
|
||||
for _, line := range strings.Split(stat, "\n") {
|
||||
if strings.Contains(line, "files changed") || strings.Contains(line, "file changed") {
|
||||
continue
|
||||
}
|
||||
idx := strings.Index(line, " | ")
|
||||
if idx < 0 {
|
||||
continue
|
||||
}
|
||||
path := strings.TrimSpace(line[:idx])
|
||||
rest := line[idx+3:]
|
||||
add, del := 0, 0
|
||||
for _, r := range rest {
|
||||
if r == '+' {
|
||||
add++
|
||||
} else if r == '-' {
|
||||
del++
|
||||
}
|
||||
}
|
||||
if path != "" {
|
||||
files = append(files, DiffFile{Path: path, Add: add, Del: del})
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
Reference in New Issue
Block a user