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>
This commit is contained in:
2026-08-02 23:10:34 +02:00
parent 952ff5f951
commit e181e8e68e
11 changed files with 1506 additions and 1 deletions

View File

@@ -8,11 +8,19 @@ import (
"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.
@@ -35,6 +43,8 @@ func (r *Repo) initIfNeeded() error {
// 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
@@ -52,6 +62,8 @@ func (r *Repo) Commit(path, content, message, author, email string) error {
// 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
@@ -92,24 +104,32 @@ func (r *Repo) Log(path string) ([]LogEntry, error) {
// 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
}
@@ -123,6 +143,8 @@ func (r *Repo) CommitAll(message, author, email string) error {
// 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
}
@@ -153,6 +175,10 @@ func writeFile(path, content string) error {
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

View File

@@ -0,0 +1,280 @@
package git
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"time"
)
// ErrMergeConflict is returned by Pull when the merge stopped on
// conflicts. The merge is left in progress so the caller can resolve
// per file (ours/theirs), commit, or abort.
var ErrMergeConflict = errors.New("MERGE_CONFLICT")
// ErrUnrelatedHistories is returned by Pull when local and remote do
// not share any history. The caller should offer a reset from remote.
var ErrUnrelatedHistories = errors.New("UNRELATED_HISTORIES")
// SetSSHKey configures the key and known_hosts file used for all
// remote operations. Host keys are accepted on first contact
// (accept-new) and pinned in knownHosts after that.
func (r *Repo) SetSSHKey(keyPath, knownHostsPath string) {
r.mu.Lock()
defer r.mu.Unlock()
r.sshCommand = fmt.Sprintf(
"ssh -i %q -o UserKnownHostsFile=%q -o StrictHostKeyChecking=accept-new -o IdentitiesOnly=yes",
keyPath, knownHostsPath,
)
}
// SetRemote points origin at url, creating the remote if needed.
func (r *Repo) SetRemote(url string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, err := r.run("remote", "get-url", "origin"); err != nil {
_, err = r.run("remote", "add", "origin", url)
return err
}
_, err := r.run("remote", "set-url", "origin", url)
return err
}
// Fetch updates all remote-tracking refs.
func (r *Repo) Fetch() error {
r.mu.Lock()
defer r.mu.Unlock()
_, err := r.run("fetch", "origin", "--prune")
return err
}
// CurrentBranch returns the checked-out branch name. It works on an
// unborn branch (fresh init) too, where rev-parse would fail.
func (r *Repo) CurrentBranch() (string, error) {
r.mu.Lock()
defer r.mu.Unlock()
out, err := r.run("symbolic-ref", "--short", "HEAD")
if err != nil {
// Detached HEAD.
out, err = r.run("rev-parse", "--short", "HEAD")
if err != nil {
return "", err
}
}
return strings.TrimSpace(out), nil
}
// Branches lists local branch names plus remote branches that have no
// local counterpart yet (reported as "origin/<name>").
func (r *Repo) Branches() (local []string, remote []string, err error) {
r.mu.Lock()
defer r.mu.Unlock()
local, remote = []string{}, []string{} // non-nil so they serialise as []
out, err := r.run("for-each-ref", "--format=%(refname:short)", "refs/heads")
if err != nil {
return local, remote, err
}
for _, l := range strings.Split(strings.TrimSpace(out), "\n") {
if l != "" {
local = append(local, l)
}
}
out, err = r.run("for-each-ref", "--format=%(refname:short)", "refs/remotes/origin")
if err != nil {
return local, remote, nil // no remote yet is fine
}
for _, l := range strings.Split(strings.TrimSpace(out), "\n") {
name := strings.TrimPrefix(l, "origin/")
if name == "" || name == "HEAD" || l == "origin" {
continue
}
remote = append(remote, name)
}
return local, remote, nil
}
// CreateBranch creates a branch at the current HEAD and switches to it.
func (r *Repo) CreateBranch(name string) error {
r.mu.Lock()
defer r.mu.Unlock()
_, err := r.run("checkout", "-b", name)
return err
}
// SwitchBranch checks out an existing branch. A branch that only
// exists on the remote gets a local tracking branch.
func (r *Repo) SwitchBranch(name string) error {
r.mu.Lock()
defer r.mu.Unlock()
if _, err := r.run("rev-parse", "--verify", "refs/heads/"+name); err == nil {
_, err = r.run("checkout", name)
return err
}
if _, err := r.run("rev-parse", "--verify", "refs/remotes/origin/"+name); err == nil {
_, err = r.run("checkout", "-b", name, "--track", "origin/"+name)
return err
}
return fmt.Errorf("branch %q not found locally or on origin", name)
}
// AheadBehind reports how many commits branch is ahead of and behind
// origin/branch. hasUpstream is false when origin/branch is missing.
func (r *Repo) AheadBehind(branch string) (ahead, behind int, hasUpstream bool) {
r.mu.Lock()
defer r.mu.Unlock()
out, err := r.run("rev-list", "--left-right", "--count",
branch+"...origin/"+branch)
if err != nil {
return 0, 0, false
}
fmt.Sscanf(strings.TrimSpace(out), "%d\t%d", &ahead, &behind)
return ahead, behind, true
}
// Pull merges origin/branch into the current branch. ffOnly restricts
// the merge to fast-forwards (used by background sync so it never
// creates surprise merges). On conflict the merge is left open and
// ErrMergeConflict is returned.
func (r *Repo) Pull(branch, author, email string, ffOnly bool) error {
r.mu.Lock()
defer r.mu.Unlock()
args := []string{
"-c", fmt.Sprintf("user.name=%s", author),
"-c", fmt.Sprintf("user.email=%s", email),
"merge", "--no-edit",
}
if ffOnly {
args = append(args, "--ff-only")
}
args = append(args, "origin/"+branch)
_, err := r.run(args...)
if err == nil {
return nil
}
if strings.Contains(err.Error(), "unrelated histories") {
return ErrUnrelatedHistories
}
if files := r.conflictFiles(); len(files) > 0 {
return ErrMergeConflict
}
return err
}
// Push publishes branch to origin and sets it as upstream.
func (r *Repo) Push(branch string) error {
r.mu.Lock()
defer r.mu.Unlock()
_, err := r.run("push", "-u", "origin", branch)
return err
}
// MergeInProgress reports whether a merge has been started but not
// yet committed or aborted.
func (r *Repo) MergeInProgress() bool {
_, err := os.Stat(filepath.Join(r.root, ".git", "MERGE_HEAD"))
return err == nil
}
// ConflictFiles lists paths that are in conflicted state.
func (r *Repo) ConflictFiles() []string {
r.mu.Lock()
defer r.mu.Unlock()
return r.conflictFiles()
}
func (r *Repo) conflictFiles() []string {
files := []string{} // non-nil so it serialises as [] not null
out, err := r.run("diff", "--name-only", "--diff-filter=U")
if err != nil {
return files
}
for _, l := range strings.Split(strings.TrimSpace(out), "\n") {
if l != "" {
files = append(files, l)
}
}
return files
}
// ResolveConflicts settles conflicted paths by taking "ours" (local)
// or "theirs" (remote). Empty paths means every conflicted file. When
// no conflicts remain the merge is committed.
func (r *Repo) ResolveConflicts(strategy string, paths []string, author, email string) error {
if strategy != "ours" && strategy != "theirs" {
return fmt.Errorf("unknown strategy %q (want ours or theirs)", strategy)
}
r.mu.Lock()
defer r.mu.Unlock()
if len(paths) == 0 {
paths = r.conflictFiles()
}
for _, p := range paths {
if _, err := r.run("checkout", "--"+strategy, "--", p); err != nil {
// Delete/modify conflicts have no blob on one side; taking
// the missing side means removing the file.
if _, rmErr := r.run("rm", "--", p); rmErr != nil {
return err
}
continue
}
if _, err := r.run("add", "--", p); err != nil {
return err
}
}
if len(r.conflictFiles()) > 0 {
return nil // partial resolve; merge stays open
}
_, err := r.run(
"-c", fmt.Sprintf("user.name=%s", author),
"-c", fmt.Sprintf("user.email=%s", email),
"commit", "--no-edit",
)
return err
}
// AbortMerge throws away an in-progress merge.
func (r *Repo) AbortMerge() error {
r.mu.Lock()
defer r.mu.Unlock()
_, err := r.run("merge", "--abort")
return err
}
// ResetToRemote force-matches branch to origin/branch. Any local-only
// state is kept in a backup branch first, so the operation is
// recoverable. Returns the backup branch name ("" when the repo had
// no commits to back up).
func (r *Repo) ResetToRemote(branch, author, email string) (backup string, err error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.MergeInProgress() {
if _, err := r.run("merge", "--abort"); err != nil {
return "", err
}
}
// Snapshot uncommitted edits so the backup branch is complete.
if out, _ := r.run("status", "--porcelain"); strings.TrimSpace(out) != "" {
r.run("add", "-A")
r.run(
"-c", fmt.Sprintf("user.name=%s", author),
"-c", fmt.Sprintf("user.email=%s", email),
"commit", "-m", "Snapshot före reset från remote",
)
}
if _, err := r.run("rev-parse", "--verify", "HEAD"); err == nil {
backup = "backup/" + time.Now().Format("20060102-150405")
if _, err := r.run("branch", backup); err != nil {
return "", err
}
}
if _, err := r.run("fetch", "origin", "--prune"); err != nil {
return backup, err
}
if _, err := r.run("checkout", "-B", branch, "origin/"+branch); err != nil {
return backup, err
}
_, err = r.run("branch", "--set-upstream-to", "origin/"+branch, branch)
return backup, err
}