Files
Bjorn Blomberg e181e8e68e feat(gitsync): synka wiki-repot mot remote över SSH
- ed25519-deploy-nyckel genereras av servern (config-volymen, 0600),
  GIT_SSH_COMMAND med egen known_hosts (accept-new)
- inställningar: remote-URL, live-gren, read-only, auto-synk-intervall
- bakgrundssynk: endast fast-forward, flaggar attention vid merge-behov
- pull/push, skapa/byt gren, merge-popup (ours/theirs per fil eller allt),
  abort, reset från remote med automatisk backup-gren
- headless-konfig via config.json eller updateGitSync-mutationen
- openssh-client tillagd i runtime-imagen; mutex kring alla git-operationer

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 23:10:34 +02:00

190 lines
4.9 KiB
Go

package git
import (
"bytes"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync"
)
// Repo wraps a bare directory that is a Git repository.
type Repo struct {
root string
// mu serialises whole operations (a background sync racing a user
// save would otherwise corrupt index/merge state).
mu sync.Mutex
// sshCommand is exported to git as GIT_SSH_COMMAND when set, so
// remotes can be reached with a dedicated key and known_hosts file
// even though the server runs without a home directory.
sshCommand string
}
// Open returns a Repo for an existing directory, initialising Git if needed.
func Open(root string) (*Repo, error) {
r := &Repo{root: root}
if err := r.initIfNeeded(); err != nil {
return nil, err
}
return r, nil
}
func (r *Repo) initIfNeeded() error {
out, _ := r.run("rev-parse", "--is-inside-work-tree")
if strings.TrimSpace(out) == "true" {
return nil
}
_, err := r.run("init")
return err
}
// Commit writes data to path and creates a Git commit authored by author.
func (r *Repo) Commit(path, content, message, author, email string) error {
r.mu.Lock()
defer r.mu.Unlock()
full := filepath.Join(r.root, path)
if err := writeFile(full, content); err != nil {
return err
}
if _, err := r.run("add", path); err != nil {
return err
}
_, err := r.run(
"-c", fmt.Sprintf("user.name=%s", author),
"-c", fmt.Sprintf("user.email=%s", email),
"commit", "-m", message,
)
return err
}
// Log returns the commit history for a file, including added/removed line counts.
func (r *Repo) Log(path string) ([]LogEntry, error) {
r.mu.Lock()
defer r.mu.Unlock()
out, err := r.run("log", "--format=COMMIT|%H|%an|%ae|%ai|%s", "--numstat", "--", path)
if err != nil {
return nil, err
}
var entries []LogEntry
var current *LogEntry
for _, line := range strings.Split(strings.TrimSpace(out), "\n") {
if strings.HasPrefix(line, "COMMIT|") {
if current != nil {
entries = append(entries, *current)
}
parts := strings.SplitN(strings.TrimPrefix(line, "COMMIT|"), "|", 5)
if len(parts) == 5 {
current = &LogEntry{
Hash: parts[0],
Author: parts[1],
Email: parts[2],
Date: parts[3],
Subject: parts[4],
}
}
} else if current != nil && strings.Contains(line, "\t") {
// numstat line: "added\tremoved\tfilename"
fields := strings.SplitN(line, "\t", 3)
if len(fields) == 3 {
added, _ := strconv.Atoi(fields[0])
removed, _ := strconv.Atoi(fields[1])
current.Added += added
current.Removed += removed
}
}
}
if current != nil {
entries = append(entries, *current)
}
return entries, nil
}
// Diff returns the unified diff between two commits for a file.
func (r *Repo) Diff(path, fromHash, toHash string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
out, err := r.run("diff", fromHash, toHash, "--", path)
return out, err
}
// Show returns the file content at a specific commit.
func (r *Repo) Show(hash, path string) (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
return r.run("show", fmt.Sprintf("%s:%s", hash, path))
}
// HasUncommitted returns true if the working tree contains untracked or
// modified files that have not yet been committed.
func (r *Repo) HasUncommitted() bool {
r.mu.Lock()
defer r.mu.Unlock()
out, err := r.run("status", "--porcelain")
return err == nil && strings.TrimSpace(out) != ""
}
// CommitAll stages every file in the repo root and creates a commit.
func (r *Repo) CommitAll(message, author, email string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, err := r.run("add", "-A"); err != nil {
return err
}
_, err := r.run(
"-c", fmt.Sprintf("user.name=%s", author),
"-c", fmt.Sprintf("user.email=%s", email),
"commit", "-m", message,
)
return err
}
// Move renames a file from oldPath to newPath and creates a Git commit.
func (r *Repo) Move(oldPath, newPath, message, author, email string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, err := r.run("mv", oldPath, newPath); err != nil {
return err
}
_, err := r.run(
"-c", fmt.Sprintf("user.name=%s", author),
"-c", fmt.Sprintf("user.email=%s", email),
"commit", "-m", message,
)
return err
}
type LogEntry struct {
Hash string
Author string
Email string
Date string
Subject string
Added int
Removed int
}
func writeFile(path, content string) error {
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return err
}
return os.WriteFile(path, []byte(content), 0644)
}
func (r *Repo) run(args ...string) (string, error) {
cmd := exec.Command("git", append([]string{"-C", r.root}, args...)...)
cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")
if r.sshCommand != "" {
cmd.Env = append(cmd.Env, "GIT_SSH_COMMAND="+r.sshCommand)
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("git %v: %w — %s", args, err, stderr.String())
}
return stdout.String(), nil
}