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:
@@ -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
|
||||
|
||||
280
backend/internal/git/sync.go
Normal file
280
backend/internal/git/sync.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user