Files
Archivum/backend/internal/git/sync.go
Bjorn Blomberg 19cf285d80 feat(gitsync): auto-bootstrap tom wiki från remote live-gren
Ett repo utan commits adopterar origin/<live_branch> automatiskt vid
första bakgrundssynken — färsk instans behöver ingen manuell reset.

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

299 lines
8.8 KiB
Go

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
}
// HasCommits reports whether the repository has any commit (false on
// a freshly initialised, unborn branch).
func (r *Repo) HasCommits() bool {
r.mu.Lock()
defer r.mu.Unlock()
_, err := r.run("rev-parse", "--verify", "HEAD")
return err == nil
}
// RemoteBranchExists reports whether origin/branch is known locally
// (run Fetch first for a fresh answer).
func (r *Repo) RemoteBranchExists(branch string) bool {
r.mu.Lock()
defer r.mu.Unlock()
_, err := r.run("rev-parse", "--verify", "refs/remotes/origin/"+branch)
return err == nil
}
// 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
}