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:
@@ -254,6 +254,35 @@ resolveDBPath(configPath):
|
||||
└─ Annars → <config-katalog>/archivum.db (dev-miljö)
|
||||
```
|
||||
|
||||
### Git-synk mot remote (SSH)
|
||||
|
||||
Wiki-repot kan synkas mot ett remote git-repo (t.ex. Gitea) över SSH.
|
||||
Implementation: `internal/git/sync.go` (remote-operationer + mutex) och
|
||||
`internal/graph/gitsync.go` (GraphQL-handlers, nyckelhantering, bakgrunds-
|
||||
ticker). Admin-UI: `frontend/src/components/GitSyncSection.vue` med
|
||||
`MergeConflictDialog.vue` som popup vid konflikter.
|
||||
|
||||
- **SSH-nyckel:** servern genererar själv ett ed25519-nyckelpar vid första
|
||||
aktivering, lagrat i `<config-dir>/ssh/` (0600). Publika nyckeln visas i
|
||||
admin-panelen och registreras som deploy key hos git-värden. `GIT_SSH_COMMAND`
|
||||
pekar på nyckeln + egen `known_hosts` (accept-new) eftersom appen kör utan
|
||||
hemkatalog. Privata nyckeln exponeras aldrig via API:t.
|
||||
- **Live-gren** (`live_branch`): grenen som bakgrundssynken håller uppdaterad.
|
||||
Bakgrundssynk (intervall i `auto_sync_minutes`, 0 = manuell) gör endast
|
||||
fast-forward-pulls; allt som kräver riktig merge flaggas för admin
|
||||
(`attention` i `gitSyncStatus`).
|
||||
- **Read-only** (`read_only`): pull tillåts men aldrig push (spärras i backend).
|
||||
- **Grenar:** skapa och byt gren i admin-panelen; remote-grenar spåras
|
||||
automatiskt vid byte.
|
||||
- **Merge-konflikter:** pull lämnar mergen öppen och UI:t visar en popup där
|
||||
man väljer lokal/remote per fil eller för allt; alternativt avbryt mergen.
|
||||
- **Reset från remote:** tvingar lokala grenen att matcha remoten; lokalt
|
||||
läge sparas alltid först i en backup-gren (`backup/<timestamp>`). Används
|
||||
också för bootstrap när historikerna är orelaterade (`UNRELATED_HISTORIES`).
|
||||
- **Headless-konfiguration:** allt kan sättas via `config.json`-sektionen
|
||||
`git_sync` + omstart, eller via GraphQL-mutationen `updateGitSync` — inget
|
||||
webbgui krävs.
|
||||
|
||||
### Konfiguration
|
||||
|
||||
`config.json` hanteras av `internal/config`:
|
||||
@@ -263,6 +292,8 @@ resolveDBPath(configPath):
|
||||
- `listen_addr` — TCP-adress att lyssna på, t.ex. `:4000`
|
||||
- `public_url` — extern bas-URL (t.ex. `https://archivum.brasse-pc.eu`), används
|
||||
för att härleda OIDC-redirect-URI
|
||||
- `git_sync` — se avsnittet ovan: `enabled`, `remote_url`, `live_branch`,
|
||||
`read_only`, `auto_sync_minutes`
|
||||
- `oidc` — valfri OIDC/SSO-konfiguration (Authentik):
|
||||
- `enabled`, `issuer`, `client_id`, `client_secret`, `redirect_url`
|
||||
- `groups_claim` (default `groups`), `username_claim` (default `preferred_username`)
|
||||
|
||||
@@ -19,6 +19,36 @@ type Config struct {
|
||||
// (e.g. https://archivum.brasse-pc.eu). Used to build the OIDC
|
||||
// redirect URL when one is not set explicitly.
|
||||
PublicURL string `json:"public_url"`
|
||||
GitSync GitSyncConfig `json:"git_sync"`
|
||||
}
|
||||
|
||||
// GitSyncConfig configures synchronisation of the wiki git repository
|
||||
// with a remote (SSH or HTTPS). The SSH keypair is generated by the
|
||||
// server itself and lives next to config.json so the whole feature can
|
||||
// be driven headlessly by editing config.json and restarting.
|
||||
type GitSyncConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
RemoteURL string `json:"remote_url"` // e.g. ssh://git@git.brasse-pc.eu:2222/brasse/Archivum-documets.git
|
||||
// LiveBranch is the branch that counts as the live content of this
|
||||
// instance: auto-sync pulls/pushes it and resets target it.
|
||||
LiveBranch string `json:"live_branch"`
|
||||
// ReadOnly forbids every push to the remote; the instance only
|
||||
// pulls. Local edits still commit locally.
|
||||
ReadOnly bool `json:"read_only"`
|
||||
// AutoSyncMinutes > 0 enables background sync on that interval.
|
||||
// Background pulls are fast-forward only; anything needing a real
|
||||
// merge is left for an admin to resolve in the UI.
|
||||
AutoSyncMinutes int `json:"auto_sync_minutes"`
|
||||
}
|
||||
|
||||
// Normalize fills in defaults for optional git-sync fields.
|
||||
func (g *GitSyncConfig) Normalize() {
|
||||
if g.LiveBranch == "" {
|
||||
g.LiveBranch = "main"
|
||||
}
|
||||
if g.AutoSyncMinutes < 0 {
|
||||
g.AutoSyncMinutes = 0
|
||||
}
|
||||
}
|
||||
|
||||
type LDAPConfig struct {
|
||||
@@ -105,6 +135,7 @@ func Load(path string) (*Config, error) {
|
||||
}
|
||||
|
||||
cfg.OIDC.Normalize()
|
||||
cfg.GitSync.Normalize()
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
604
backend/internal/graph/gitsync.go
Normal file
604
backend/internal/graph/gitsync.go
Normal file
@@ -0,0 +1,604 @@
|
||||
package graph
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/brasse-b/archivum/internal/auth"
|
||||
"github.com/brasse-b/archivum/internal/config"
|
||||
"github.com/brasse-b/archivum/internal/git"
|
||||
)
|
||||
|
||||
// The identity used for commits that the sync engine itself creates
|
||||
// (merge commits, pre-reset snapshots).
|
||||
const (
|
||||
syncAuthor = "Archivum Sync"
|
||||
syncEmail = "sync@archivum.local"
|
||||
)
|
||||
|
||||
// ── SSH key management ────────────────────────────────────────────────────────
|
||||
|
||||
// sshDir returns the directory holding the sync keypair and known_hosts,
|
||||
// next to config.json so it lives on the persistent /config volume.
|
||||
func (s *Server) sshDir() string {
|
||||
return filepath.Join(filepath.Dir(s.configPath), "ssh")
|
||||
}
|
||||
|
||||
// ensureSSHKey generates an ed25519 keypair on first use and returns
|
||||
// (keyPath, knownHostsPath, publicKey). The private key never leaves
|
||||
// the server; the public key is what gets registered as a deploy key.
|
||||
func (s *Server) ensureSSHKey() (string, string, string, error) {
|
||||
dir := s.sshDir()
|
||||
keyPath := filepath.Join(dir, "id_ed25519")
|
||||
pubPath := keyPath + ".pub"
|
||||
khPath := filepath.Join(dir, "known_hosts")
|
||||
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if f, err := os.OpenFile(khPath, os.O_CREATE, 0644); err == nil {
|
||||
f.Close()
|
||||
}
|
||||
|
||||
if pub, err := os.ReadFile(pubPath); err == nil {
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
return keyPath, khPath, strings.TrimSpace(string(pub)), nil
|
||||
}
|
||||
}
|
||||
|
||||
pub, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
block, err := ssh.MarshalPrivateKey(priv, "archivum-git-sync")
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
if err := os.WriteFile(keyPath, pem.EncodeToMemory(block), 0600); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
sshPub, err := ssh.NewPublicKey(pub)
|
||||
if err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
pubLine := bytes.TrimSpace(ssh.MarshalAuthorizedKey(sshPub))
|
||||
pubLine = append(pubLine, []byte(" archivum-git-sync\n")...)
|
||||
if err := os.WriteFile(pubPath, pubLine, 0644); err != nil {
|
||||
return "", "", "", err
|
||||
}
|
||||
log.Printf("[gitsync] generated new ssh keypair at %s", keyPath)
|
||||
return keyPath, khPath, strings.TrimSpace(string(pubLine)), nil
|
||||
}
|
||||
|
||||
// ── Engine wiring ─────────────────────────────────────────────────────────────
|
||||
|
||||
// configureGitSync (re)applies git-sync settings: key, remote and the
|
||||
// background ticker. Called from initRuntime and after updateGitSync.
|
||||
func (s *Server) configureGitSync(cfg *config.Config) {
|
||||
s.syncMu.Lock()
|
||||
if s.syncStop != nil {
|
||||
close(s.syncStop)
|
||||
s.syncStop = nil
|
||||
}
|
||||
s.syncMu.Unlock()
|
||||
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
gs := cfg.GitSync
|
||||
gs.Normalize()
|
||||
|
||||
s.mu.RLock()
|
||||
repo := s.gitRepo
|
||||
s.mu.RUnlock()
|
||||
if repo == nil || !gs.Enabled || gs.RemoteURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
keyPath, khPath, _, err := s.ensureSSHKey()
|
||||
if err != nil {
|
||||
log.Printf("[gitsync] ssh key setup failed: %v", err)
|
||||
return
|
||||
}
|
||||
repo.SetSSHKey(keyPath, khPath)
|
||||
if err := repo.SetRemote(gs.RemoteURL); err != nil {
|
||||
log.Printf("[gitsync] setting remote failed: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("[gitsync] enabled — remote=%s live=%s readOnly=%v auto=%dmin",
|
||||
gs.RemoteURL, gs.LiveBranch, gs.ReadOnly, gs.AutoSyncMinutes)
|
||||
|
||||
stop := make(chan struct{})
|
||||
s.syncMu.Lock()
|
||||
s.syncStop = stop
|
||||
s.syncMu.Unlock()
|
||||
|
||||
go func() {
|
||||
// One eager sync shortly after (re)configuration, then on the
|
||||
// configured interval. Manual-only mode still gets the eager
|
||||
// run so status is populated after a restart.
|
||||
timer := time.NewTimer(10 * time.Second)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-timer.C:
|
||||
s.backgroundSync()
|
||||
}
|
||||
if gs.AutoSyncMinutes <= 0 {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(time.Duration(gs.AutoSyncMinutes) * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-ticker.C:
|
||||
s.backgroundSync()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// backgroundSync fetches and, when the live branch is checked out,
|
||||
// fast-forwards it and pushes local commits. It never merges: anything
|
||||
// that needs a real merge raises the attention flag for an admin.
|
||||
func (s *Server) backgroundSync() {
|
||||
s.mu.RLock()
|
||||
repo := s.gitRepo
|
||||
cfg := s.cfg
|
||||
s.mu.RUnlock()
|
||||
if repo == nil || cfg == nil || !cfg.GitSync.Enabled {
|
||||
return
|
||||
}
|
||||
gs := cfg.GitSync
|
||||
|
||||
record := func(err error, attention bool) {
|
||||
s.syncMu.Lock()
|
||||
s.lastSyncAt = time.Now().Format(time.RFC3339)
|
||||
if err != nil {
|
||||
s.lastSyncErr = err.Error()
|
||||
} else {
|
||||
s.lastSyncErr = ""
|
||||
}
|
||||
s.syncAttention = attention
|
||||
s.syncMu.Unlock()
|
||||
}
|
||||
|
||||
if repo.MergeInProgress() {
|
||||
record(errors.New("merge väntar på att lösas i admin-panelen"), true)
|
||||
return
|
||||
}
|
||||
if err := repo.Fetch(); err != nil {
|
||||
record(fmt.Errorf("fetch: %w", err), false)
|
||||
return
|
||||
}
|
||||
current, err := repo.CurrentBranch()
|
||||
if err != nil || current != gs.LiveBranch {
|
||||
// Someone is working on another branch; leave it alone.
|
||||
record(nil, false)
|
||||
return
|
||||
}
|
||||
|
||||
ahead, behind, hasUpstream := repo.AheadBehind(gs.LiveBranch)
|
||||
if !hasUpstream {
|
||||
if gs.ReadOnly {
|
||||
record(nil, false)
|
||||
return
|
||||
}
|
||||
record(repo.Push(gs.LiveBranch), false)
|
||||
return
|
||||
}
|
||||
if behind > 0 {
|
||||
if err := repo.Pull(gs.LiveBranch, syncAuthor, syncEmail, true); err != nil {
|
||||
// Not fast-forwardable (or unrelated histories): an admin
|
||||
// must pull/merge or reset from the UI.
|
||||
record(fmt.Errorf("kan inte snabbspola — manuell merge eller reset krävs (%w)", err), true)
|
||||
return
|
||||
}
|
||||
}
|
||||
if ahead > 0 && !gs.ReadOnly {
|
||||
if err := repo.Push(gs.LiveBranch); err != nil {
|
||||
record(fmt.Errorf("push: %w", err), false)
|
||||
return
|
||||
}
|
||||
}
|
||||
record(nil, false)
|
||||
}
|
||||
|
||||
// gitSyncState returns the shared handles used by every handler below.
|
||||
func (s *Server) gitSyncState() (*git.Repo, *config.Config) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.gitRepo, s.cfg
|
||||
}
|
||||
|
||||
// ── GraphQL handlers (admin only) ─────────────────────────────────────────────
|
||||
|
||||
func (s *Server) handleGitSyncSettings(w http.ResponseWriter, sess *auth.Session) {
|
||||
if !s.requireAdmin(w, sess) {
|
||||
return
|
||||
}
|
||||
_, cfg := s.gitSyncState()
|
||||
if cfg == nil {
|
||||
writeGQLError(w, "server not initialised")
|
||||
return
|
||||
}
|
||||
gs := cfg.GitSync
|
||||
gs.Normalize()
|
||||
publicKey := ""
|
||||
if pub, err := os.ReadFile(filepath.Join(s.sshDir(), "id_ed25519.pub")); err == nil {
|
||||
publicKey = strings.TrimSpace(string(pub))
|
||||
}
|
||||
writeJSONObj(w, map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"gitSyncSettings": map[string]interface{}{
|
||||
"enabled": gs.Enabled,
|
||||
"remoteUrl": gs.RemoteURL,
|
||||
"liveBranch": gs.LiveBranch,
|
||||
"readOnly": gs.ReadOnly,
|
||||
"autoSyncMinutes": gs.AutoSyncMinutes,
|
||||
"publicKey": publicKey,
|
||||
"keySet": publicKey != "",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleUpdateGitSync(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
||||
if !s.requireAdmin(w, sess) {
|
||||
return
|
||||
}
|
||||
input, ok := req.Variables["input"].(map[string]interface{})
|
||||
if !ok {
|
||||
input, _ = req.Variables["i"].(map[string]interface{})
|
||||
}
|
||||
if input == nil {
|
||||
writeGQLError(w, "missing input")
|
||||
return
|
||||
}
|
||||
s.mu.RLock()
|
||||
cfg := s.cfg
|
||||
s.mu.RUnlock()
|
||||
if cfg == nil {
|
||||
writeGQLError(w, "server not initialised")
|
||||
return
|
||||
}
|
||||
|
||||
newCfg := *cfg
|
||||
gs := config.GitSyncConfig{
|
||||
Enabled: boolVal(input, "enabled"),
|
||||
RemoteURL: strings.TrimSpace(strVal(input, "remoteUrl")),
|
||||
LiveBranch: strings.TrimSpace(strVal(input, "liveBranch")),
|
||||
ReadOnly: boolVal(input, "readOnly"),
|
||||
AutoSyncMinutes: intVal(input, "autoSyncMinutes"),
|
||||
}
|
||||
gs.Normalize()
|
||||
newCfg.GitSync = gs
|
||||
|
||||
if err := config.Save(s.configPath, &newCfg); err != nil {
|
||||
writeGQLError(w, fmt.Sprintf("failed to save config: %v", err))
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.cfg = &newCfg
|
||||
s.mu.Unlock()
|
||||
s.configureGitSync(&newCfg)
|
||||
|
||||
log.Printf("[admin] git sync updated by %s (enabled=%v remote=%s)",
|
||||
sess.Username, gs.Enabled, gs.RemoteURL)
|
||||
writeJSON(w, `{"data":{"updateGitSync":true}}`)
|
||||
}
|
||||
|
||||
func (s *Server) handleGitRegenerateKey(w http.ResponseWriter, sess *auth.Session) {
|
||||
if !s.requireAdmin(w, sess) {
|
||||
return
|
||||
}
|
||||
dir := s.sshDir()
|
||||
os.Remove(filepath.Join(dir, "id_ed25519"))
|
||||
os.Remove(filepath.Join(dir, "id_ed25519.pub"))
|
||||
_, _, publicKey, err := s.ensureSSHKey()
|
||||
if err != nil {
|
||||
writeGQLError(w, fmt.Sprintf("key generation failed: %v", err))
|
||||
return
|
||||
}
|
||||
s.mu.RLock()
|
||||
cfg := s.cfg
|
||||
s.mu.RUnlock()
|
||||
if cfg != nil {
|
||||
s.configureGitSync(cfg)
|
||||
}
|
||||
writeJSONObj(w, map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"gitRegenerateKey": map[string]interface{}{"publicKey": publicKey},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleGitSyncStatus(w http.ResponseWriter, sess *auth.Session) {
|
||||
if !s.requireAdmin(w, sess) {
|
||||
return
|
||||
}
|
||||
repo, cfg := s.gitSyncState()
|
||||
if repo == nil || cfg == nil {
|
||||
writeGQLError(w, "server not initialised")
|
||||
return
|
||||
}
|
||||
gs := cfg.GitSync
|
||||
gs.Normalize()
|
||||
|
||||
current, _ := repo.CurrentBranch()
|
||||
ahead, behind, hasUpstream := 0, 0, false
|
||||
if gs.Enabled {
|
||||
ahead, behind, hasUpstream = repo.AheadBehind(current)
|
||||
}
|
||||
conflicts := repo.ConflictFiles()
|
||||
s.syncMu.Lock()
|
||||
lastAt, lastErr, attention := s.lastSyncAt, s.lastSyncErr, s.syncAttention
|
||||
s.syncMu.Unlock()
|
||||
|
||||
writeJSONObj(w, map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"gitSyncStatus": map[string]interface{}{
|
||||
"enabled": gs.Enabled,
|
||||
"currentBranch": current,
|
||||
"liveBranch": gs.LiveBranch,
|
||||
"readOnly": gs.ReadOnly,
|
||||
"ahead": ahead,
|
||||
"behind": behind,
|
||||
"hasUpstream": hasUpstream,
|
||||
"dirty": repo.HasUncommitted(),
|
||||
"mergeInProgress": repo.MergeInProgress(),
|
||||
"conflicts": conflicts,
|
||||
"lastSyncAt": lastAt,
|
||||
"lastSyncError": lastErr,
|
||||
"attention": attention || repo.MergeInProgress(),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleGitBranches(w http.ResponseWriter, sess *auth.Session) {
|
||||
if !s.requireAdmin(w, sess) {
|
||||
return
|
||||
}
|
||||
repo, _ := s.gitSyncState()
|
||||
if repo == nil {
|
||||
writeGQLError(w, "server not initialised")
|
||||
return
|
||||
}
|
||||
local, remote, err := repo.Branches()
|
||||
if err != nil {
|
||||
writeGQLError(w, err.Error())
|
||||
return
|
||||
}
|
||||
current, _ := repo.CurrentBranch()
|
||||
writeJSONObj(w, map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"gitBranches": map[string]interface{}{
|
||||
"current": current,
|
||||
"local": local,
|
||||
"remote": remote,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleGitCreateBranch(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
||||
if !s.requireAdmin(w, sess) {
|
||||
return
|
||||
}
|
||||
repo, _ := s.gitSyncState()
|
||||
name := strings.TrimSpace(strVal(req.Variables, "name"))
|
||||
if repo == nil || name == "" {
|
||||
writeGQLError(w, "missing branch name")
|
||||
return
|
||||
}
|
||||
if err := repo.CreateBranch(name); err != nil {
|
||||
writeGQLError(w, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("[gitsync] %s created branch %s", sess.Username, name)
|
||||
writeJSON(w, `{"data":{"gitCreateBranch":true}}`)
|
||||
}
|
||||
|
||||
func (s *Server) handleGitSwitchBranch(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
||||
if !s.requireAdmin(w, sess) {
|
||||
return
|
||||
}
|
||||
repo, _ := s.gitSyncState()
|
||||
name := strings.TrimSpace(strVal(req.Variables, "name"))
|
||||
if repo == nil || name == "" {
|
||||
writeGQLError(w, "missing branch name")
|
||||
return
|
||||
}
|
||||
if repo.MergeInProgress() {
|
||||
writeGQLError(w, "merge in progress — resolve or abort it first")
|
||||
return
|
||||
}
|
||||
if err := repo.SwitchBranch(name); err != nil {
|
||||
writeGQLError(w, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("[gitsync] %s switched to branch %s", sess.Username, name)
|
||||
writeJSON(w, `{"data":{"gitSwitchBranch":true}}`)
|
||||
}
|
||||
|
||||
func (s *Server) handleGitPull(w http.ResponseWriter, sess *auth.Session) {
|
||||
if !s.requireAdmin(w, sess) {
|
||||
return
|
||||
}
|
||||
repo, cfg := s.gitSyncState()
|
||||
if repo == nil || cfg == nil || !cfg.GitSync.Enabled {
|
||||
writeGQLError(w, "git sync is not enabled")
|
||||
return
|
||||
}
|
||||
if err := repo.Fetch(); err != nil {
|
||||
writeGQLError(w, err.Error())
|
||||
return
|
||||
}
|
||||
current, err := repo.CurrentBranch()
|
||||
if err != nil {
|
||||
writeGQLError(w, err.Error())
|
||||
return
|
||||
}
|
||||
result := "OK"
|
||||
err = repo.Pull(current, sess.Username, sess.Username+"@archivum.local", false)
|
||||
switch {
|
||||
case errors.Is(err, git.ErrMergeConflict):
|
||||
result = "MERGE_CONFLICT"
|
||||
case errors.Is(err, git.ErrUnrelatedHistories):
|
||||
result = "UNRELATED_HISTORIES"
|
||||
case err != nil:
|
||||
writeGQLError(w, err.Error())
|
||||
return
|
||||
}
|
||||
s.syncMu.Lock()
|
||||
s.lastSyncAt = time.Now().Format(time.RFC3339)
|
||||
s.lastSyncErr = ""
|
||||
s.syncAttention = result != "OK"
|
||||
s.syncMu.Unlock()
|
||||
writeJSONObj(w, map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"gitPull": map[string]interface{}{
|
||||
"result": result,
|
||||
"conflicts": repo.ConflictFiles(),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleGitPush(w http.ResponseWriter, sess *auth.Session) {
|
||||
if !s.requireAdmin(w, sess) {
|
||||
return
|
||||
}
|
||||
repo, cfg := s.gitSyncState()
|
||||
if repo == nil || cfg == nil || !cfg.GitSync.Enabled {
|
||||
writeGQLError(w, "git sync is not enabled")
|
||||
return
|
||||
}
|
||||
if cfg.GitSync.ReadOnly {
|
||||
writeGQLError(w, "repository is configured read-only — pushing is disabled")
|
||||
return
|
||||
}
|
||||
current, err := repo.CurrentBranch()
|
||||
if err != nil {
|
||||
writeGQLError(w, err.Error())
|
||||
return
|
||||
}
|
||||
if err := repo.Push(current); err != nil {
|
||||
writeGQLError(w, err.Error())
|
||||
return
|
||||
}
|
||||
log.Printf("[gitsync] %s pushed %s", sess.Username, current)
|
||||
writeJSON(w, `{"data":{"gitPush":true}}`)
|
||||
}
|
||||
|
||||
func (s *Server) handleGitResolveMerge(w http.ResponseWriter, req gqlRequest, sess *auth.Session) {
|
||||
if !s.requireAdmin(w, sess) {
|
||||
return
|
||||
}
|
||||
repo, _ := s.gitSyncState()
|
||||
if repo == nil {
|
||||
writeGQLError(w, "server not initialised")
|
||||
return
|
||||
}
|
||||
strategy := strVal(req.Variables, "strategy")
|
||||
paths := strSlice(req.Variables, "paths")
|
||||
if err := repo.ResolveConflicts(strategy, paths, sess.Username, sess.Username+"@archivum.local"); err != nil {
|
||||
writeGQLError(w, err.Error())
|
||||
return
|
||||
}
|
||||
remaining := repo.ConflictFiles()
|
||||
if len(remaining) == 0 {
|
||||
s.syncMu.Lock()
|
||||
s.syncAttention = false
|
||||
s.syncMu.Unlock()
|
||||
}
|
||||
writeJSONObj(w, map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"gitResolveMerge": map[string]interface{}{
|
||||
"resolved": len(remaining) == 0,
|
||||
"remaining": remaining,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleGitAbortMerge(w http.ResponseWriter, sess *auth.Session) {
|
||||
if !s.requireAdmin(w, sess) {
|
||||
return
|
||||
}
|
||||
repo, _ := s.gitSyncState()
|
||||
if repo == nil {
|
||||
writeGQLError(w, "server not initialised")
|
||||
return
|
||||
}
|
||||
if err := repo.AbortMerge(); err != nil {
|
||||
writeGQLError(w, err.Error())
|
||||
return
|
||||
}
|
||||
s.syncMu.Lock()
|
||||
s.syncAttention = false
|
||||
s.syncMu.Unlock()
|
||||
writeJSON(w, `{"data":{"gitAbortMerge":true}}`)
|
||||
}
|
||||
|
||||
func (s *Server) handleGitResetFromRemote(w http.ResponseWriter, sess *auth.Session) {
|
||||
if !s.requireAdmin(w, sess) {
|
||||
return
|
||||
}
|
||||
repo, cfg := s.gitSyncState()
|
||||
if repo == nil || cfg == nil || !cfg.GitSync.Enabled {
|
||||
writeGQLError(w, "git sync is not enabled")
|
||||
return
|
||||
}
|
||||
if err := repo.Fetch(); err != nil {
|
||||
writeGQLError(w, err.Error())
|
||||
return
|
||||
}
|
||||
// Reset the current branch when it exists on the remote; otherwise
|
||||
// adopt the live branch (bootstrap case: local repo predates the
|
||||
// remote and is on an unrelated branch like master).
|
||||
current, err := repo.CurrentBranch()
|
||||
if err != nil || current == "" {
|
||||
current = cfg.GitSync.LiveBranch
|
||||
} else if _, _, hasUpstream := repo.AheadBehind(current); !hasUpstream {
|
||||
current = cfg.GitSync.LiveBranch
|
||||
}
|
||||
backup, err := repo.ResetToRemote(current, syncAuthor, syncEmail)
|
||||
if err != nil {
|
||||
writeGQLError(w, err.Error())
|
||||
return
|
||||
}
|
||||
s.syncMu.Lock()
|
||||
s.syncAttention = false
|
||||
s.lastSyncErr = ""
|
||||
s.syncMu.Unlock()
|
||||
log.Printf("[gitsync] %s reset %s from remote (backup: %s)", sess.Username, current, backup)
|
||||
writeJSONObj(w, map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"gitResetFromRemote": map[string]interface{}{"backupBranch": backup},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func intVal(m map[string]interface{}, key string) int {
|
||||
if f, ok := m[key].(float64); ok {
|
||||
return int(f)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
@@ -68,6 +68,15 @@ type Query {
|
||||
# Effective permissions a subject has on each path (admin only, for the
|
||||
# access-control tree). For a user this includes their group memberships.
|
||||
subjectAccess(subjectType: String!, subjectId: Int!, paths: [String!]!): [PathAccess!]!
|
||||
|
||||
# Git sync settings incl. the public deploy key (admin only).
|
||||
gitSyncSettings: GitSyncSettings!
|
||||
|
||||
# Live git sync state: branch, ahead/behind, conflicts (admin only).
|
||||
gitSyncStatus: GitSyncStatus!
|
||||
|
||||
# Local and remote branches (admin only).
|
||||
gitBranches: GitBranches!
|
||||
}
|
||||
|
||||
type PathAccess {
|
||||
@@ -135,6 +144,38 @@ type Mutation {
|
||||
# Commit all uncommitted files in the storage path.
|
||||
initCommit(message: String!): Boolean!
|
||||
|
||||
# ── Git sync (admin only) ────────────────────────────────────────────────────
|
||||
# Update sync settings; the server (re)connects the remote and ticker.
|
||||
updateGitSync(input: GitSyncInput!): Boolean!
|
||||
|
||||
# Merge origin/<current> into the current branch. result is OK,
|
||||
# MERGE_CONFLICT (merge left open for gitResolveMerge/gitAbortMerge)
|
||||
# or UNRELATED_HISTORIES (use gitResetFromRemote).
|
||||
gitPull: GitPullResult!
|
||||
|
||||
# Push the current branch to origin. Fails when read-only.
|
||||
gitPush: Boolean!
|
||||
|
||||
# Create a branch at HEAD and switch to it.
|
||||
gitCreateBranch(name: String!): Boolean!
|
||||
|
||||
# Switch to a local branch, or track a remote-only one.
|
||||
gitSwitchBranch(name: String!): Boolean!
|
||||
|
||||
# Settle conflicted paths with "ours" or "theirs" (empty paths = all),
|
||||
# committing the merge when nothing remains.
|
||||
gitResolveMerge(strategy: String!, paths: [String!]): GitResolveResult!
|
||||
|
||||
# Throw away an in-progress merge.
|
||||
gitAbortMerge: Boolean!
|
||||
|
||||
# Force the current branch to match origin. Local-only state is kept
|
||||
# in the returned backup branch.
|
||||
gitResetFromRemote: GitResetResult!
|
||||
|
||||
# Replace the sync keypair (register the new public key as deploy key).
|
||||
gitRegenerateKey: GitKeyResult!
|
||||
|
||||
# Delete an image file.
|
||||
deleteImage(slug: String!, filename: String!): Boolean!
|
||||
|
||||
@@ -250,6 +291,64 @@ type RepoStatus {
|
||||
hasUncommitted: Boolean!
|
||||
}
|
||||
|
||||
type GitSyncSettings {
|
||||
enabled: Boolean!
|
||||
remoteUrl: String!
|
||||
liveBranch: String!
|
||||
readOnly: Boolean!
|
||||
autoSyncMinutes: Int!
|
||||
publicKey: String!
|
||||
keySet: Boolean!
|
||||
}
|
||||
|
||||
type GitSyncStatus {
|
||||
enabled: Boolean!
|
||||
currentBranch: String!
|
||||
liveBranch: String!
|
||||
readOnly: Boolean!
|
||||
ahead: Int!
|
||||
behind: Int!
|
||||
hasUpstream: Boolean!
|
||||
dirty: Boolean!
|
||||
mergeInProgress: Boolean!
|
||||
conflicts: [String!]!
|
||||
lastSyncAt: String!
|
||||
lastSyncError: String!
|
||||
attention: Boolean!
|
||||
}
|
||||
|
||||
type GitBranches {
|
||||
current: String!
|
||||
local: [String!]!
|
||||
remote: [String!]!
|
||||
}
|
||||
|
||||
type GitPullResult {
|
||||
result: String!
|
||||
conflicts: [String!]!
|
||||
}
|
||||
|
||||
type GitResolveResult {
|
||||
resolved: Boolean!
|
||||
remaining: [String!]!
|
||||
}
|
||||
|
||||
type GitResetResult {
|
||||
backupBranch: String!
|
||||
}
|
||||
|
||||
type GitKeyResult {
|
||||
publicKey: String!
|
||||
}
|
||||
|
||||
input GitSyncInput {
|
||||
enabled: Boolean!
|
||||
remoteUrl: String!
|
||||
liveBranch: String!
|
||||
readOnly: Boolean!
|
||||
autoSyncMinutes: Int!
|
||||
}
|
||||
|
||||
type ImageFile {
|
||||
name: String!
|
||||
url: String!
|
||||
|
||||
@@ -33,6 +33,13 @@ type Server struct {
|
||||
authMgr *auth.Manager
|
||||
gitRepo *git.Repo
|
||||
oidc *auth.OIDCProvider
|
||||
|
||||
// Git-sync engine state (see gitsync.go).
|
||||
syncMu sync.Mutex
|
||||
syncStop chan struct{}
|
||||
lastSyncAt string
|
||||
lastSyncErr string
|
||||
syncAttention bool
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.Config, configPath string) http.Handler {
|
||||
@@ -112,6 +119,7 @@ func (s *Server) initRuntime(cfg *config.Config) {
|
||||
s.mu.Unlock()
|
||||
|
||||
s.configureOIDC(cfg)
|
||||
s.configureGitSync(cfg)
|
||||
}
|
||||
|
||||
// configureOIDC (re)initialises the OIDC provider from config. Discovery
|
||||
@@ -225,6 +233,44 @@ func (s *Server) dispatchAuthenticated(
|
||||
q := req.Query
|
||||
|
||||
switch {
|
||||
// ── Git sync (matched first; none of these names collide with the
|
||||
// generic "config"/"document"/"history" substrings further down) ────────
|
||||
case strings.Contains(q, "updateGitSync"):
|
||||
s.handleUpdateGitSync(w, req, sess)
|
||||
|
||||
case strings.Contains(q, "gitSyncSettings"):
|
||||
s.handleGitSyncSettings(w, sess)
|
||||
|
||||
case strings.Contains(q, "gitSyncStatus"):
|
||||
s.handleGitSyncStatus(w, sess)
|
||||
|
||||
case strings.Contains(q, "gitBranches"):
|
||||
s.handleGitBranches(w, sess)
|
||||
|
||||
case strings.Contains(q, "gitCreateBranch"):
|
||||
s.handleGitCreateBranch(w, req, sess)
|
||||
|
||||
case strings.Contains(q, "gitSwitchBranch"):
|
||||
s.handleGitSwitchBranch(w, req, sess)
|
||||
|
||||
case strings.Contains(q, "gitPull"):
|
||||
s.handleGitPull(w, sess)
|
||||
|
||||
case strings.Contains(q, "gitPush"):
|
||||
s.handleGitPush(w, sess)
|
||||
|
||||
case strings.Contains(q, "gitResolveMerge"):
|
||||
s.handleGitResolveMerge(w, req, sess)
|
||||
|
||||
case strings.Contains(q, "gitAbortMerge"):
|
||||
s.handleGitAbortMerge(w, sess)
|
||||
|
||||
case strings.Contains(q, "gitResetFromRemote"):
|
||||
s.handleGitResetFromRemote(w, sess)
|
||||
|
||||
case strings.Contains(q, "gitRegenerateKey"):
|
||||
s.handleGitRegenerateKey(w, sess)
|
||||
|
||||
// ── Effective-access queries (matched before generic acl/subject cases) ────
|
||||
case strings.Contains(q, "myAccess"):
|
||||
s.handleMyAccess(w, req, sess)
|
||||
|
||||
@@ -38,7 +38,8 @@ FROM alpine:3.20
|
||||
|
||||
# su-exec: lightweight tool to drop privileges (replaces gosu / setpriv).
|
||||
# git: needed for wiki commit operations.
|
||||
RUN apk add --no-cache su-exec git
|
||||
# openssh-client: git-sync over ssh:// remotes (git shells out to ssh)
|
||||
RUN apk add --no-cache su-exec git openssh-client
|
||||
|
||||
# Create the app group and user that the entrypoint will run as.
|
||||
# PUID/PGID can be overridden at runtime via environment variables;
|
||||
|
||||
325
frontend/src/components/GitSyncSection.vue
Normal file
325
frontend/src/components/GitSyncSection.vue
Normal file
@@ -0,0 +1,325 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||
import { gql } from '@/lib/gql'
|
||||
import MergeConflictDialog from '@/components/MergeConflictDialog.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'status', msg: string, isError?: boolean): void
|
||||
}>()
|
||||
|
||||
interface GitSettings {
|
||||
enabled: boolean; remoteUrl: string; liveBranch: string
|
||||
readOnly: boolean; autoSyncMinutes: number; publicKey: string; keySet: boolean
|
||||
}
|
||||
interface GitStatus {
|
||||
enabled: boolean; currentBranch: string; liveBranch: string; readOnly: boolean
|
||||
ahead: number; behind: number; hasUpstream: boolean; dirty: boolean
|
||||
mergeInProgress: boolean; conflicts: string[]
|
||||
lastSyncAt: string; lastSyncError: string; attention: boolean
|
||||
}
|
||||
|
||||
const settings = ref<GitSettings>({
|
||||
enabled: false, remoteUrl: '', liveBranch: 'main',
|
||||
readOnly: false, autoSyncMinutes: 0, publicKey: '', keySet: false,
|
||||
})
|
||||
const status = ref<GitStatus | null>(null)
|
||||
const branches = ref<{ current: string; local: string[]; remote: string[] }>({ current: '', local: [], remote: [] })
|
||||
const busy = ref(false)
|
||||
const switchTarget = ref('')
|
||||
const newBranchName = ref('')
|
||||
const confirmReset = ref(false)
|
||||
const confirmRegen = ref(false)
|
||||
const showMergeDialog = ref(false)
|
||||
const suggestReset = ref(false)
|
||||
let poll: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
// Remote branches that have no local counterpart yet.
|
||||
const allBranches = computed(() => {
|
||||
const extra = branches.value.remote.filter(b => !branches.value.local.includes(b))
|
||||
return [...branches.value.local, ...extra.map(b => `${b} (remote)`)]
|
||||
})
|
||||
|
||||
async function loadSettings() {
|
||||
const d = await gql<{ gitSyncSettings: GitSettings }>(
|
||||
`{ gitSyncSettings { enabled remoteUrl liveBranch readOnly autoSyncMinutes publicKey keySet } }`)
|
||||
settings.value = d.gitSyncSettings
|
||||
}
|
||||
async function loadStatus() {
|
||||
try {
|
||||
const d = await gql<{ gitSyncStatus: GitStatus }>(
|
||||
`{ gitSyncStatus { enabled currentBranch liveBranch readOnly ahead behind hasUpstream dirty mergeInProgress conflicts lastSyncAt lastSyncError attention } }`)
|
||||
status.value = d.gitSyncStatus
|
||||
if (d.gitSyncStatus.mergeInProgress && d.gitSyncStatus.conflicts.length > 0) showMergeDialog.value = true
|
||||
} catch { /* repo not ready yet — non-fatal */ }
|
||||
}
|
||||
async function loadBranches() {
|
||||
try {
|
||||
const d = await gql<{ gitBranches: { current: string; local: string[]; remote: string[] } }>(
|
||||
`{ gitBranches { current local remote } }`)
|
||||
branches.value = d.gitBranches
|
||||
switchTarget.value = d.gitBranches.current
|
||||
} catch { /* non-fatal */ }
|
||||
}
|
||||
async function refresh() {
|
||||
await Promise.all([loadStatus(), loadBranches()])
|
||||
}
|
||||
|
||||
async function saveSettings() {
|
||||
busy.value = true
|
||||
try {
|
||||
await gql(
|
||||
`mutation UpdateGitSync($input: GitSyncInput!) { updateGitSync(input: $input) }`,
|
||||
{ input: {
|
||||
enabled: settings.value.enabled,
|
||||
remoteUrl: settings.value.remoteUrl,
|
||||
liveBranch: settings.value.liveBranch,
|
||||
readOnly: settings.value.readOnly,
|
||||
autoSyncMinutes: Number(settings.value.autoSyncMinutes) || 0,
|
||||
} },
|
||||
)
|
||||
emit('status', 'Git sync settings saved.')
|
||||
await loadSettings()
|
||||
await refresh()
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function copyKey() {
|
||||
await navigator.clipboard.writeText(settings.value.publicKey)
|
||||
emit('status', 'Public key copied to clipboard.')
|
||||
}
|
||||
|
||||
async function regenerateKey() {
|
||||
confirmRegen.value = false
|
||||
busy.value = true
|
||||
try {
|
||||
const d = await gql<{ gitRegenerateKey: { publicKey: string } }>(
|
||||
`mutation { gitRegenerateKey { publicKey } }`)
|
||||
settings.value.publicKey = d.gitRegenerateKey.publicKey
|
||||
settings.value.keySet = true
|
||||
emit('status', 'New keypair generated — update the deploy key on your git host.')
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function pull() {
|
||||
busy.value = true
|
||||
suggestReset.value = false
|
||||
try {
|
||||
const d = await gql<{ gitPull: { result: string; conflicts: string[] } }>(
|
||||
`mutation { gitPull { result conflicts } }`)
|
||||
if (d.gitPull.result === 'MERGE_CONFLICT') {
|
||||
showMergeDialog.value = true
|
||||
} else if (d.gitPull.result === 'UNRELATED_HISTORIES') {
|
||||
suggestReset.value = true
|
||||
} else {
|
||||
emit('status', 'Pulled from remote.')
|
||||
}
|
||||
await refresh()
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function push() {
|
||||
busy.value = true
|
||||
try {
|
||||
await gql(`mutation { gitPush }`)
|
||||
emit('status', 'Pushed to remote.')
|
||||
await refresh()
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function resolveMerge(strategy: 'ours' | 'theirs', paths?: string[]) {
|
||||
busy.value = true
|
||||
try {
|
||||
const d = await gql<{ gitResolveMerge: { resolved: boolean; remaining: string[] } }>(
|
||||
`mutation Resolve($strategy: String!, $paths: [String!]) { gitResolveMerge(strategy: $strategy, paths: $paths) { resolved remaining } }`,
|
||||
{ strategy, paths: paths ?? null },
|
||||
)
|
||||
if (d.gitResolveMerge.resolved) {
|
||||
showMergeDialog.value = false
|
||||
emit('status', 'Merge resolved and committed.')
|
||||
}
|
||||
await refresh()
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function abortMerge() {
|
||||
busy.value = true
|
||||
try {
|
||||
await gql(`mutation { gitAbortMerge }`)
|
||||
showMergeDialog.value = false
|
||||
emit('status', 'Merge aborted.')
|
||||
await refresh()
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function resetFromRemote() {
|
||||
confirmReset.value = false
|
||||
busy.value = true
|
||||
try {
|
||||
const d = await gql<{ gitResetFromRemote: { backupBranch: string } }>(
|
||||
`mutation { gitResetFromRemote { backupBranch } }`)
|
||||
suggestReset.value = false
|
||||
showMergeDialog.value = false
|
||||
const backup = d.gitResetFromRemote.backupBranch
|
||||
emit('status', backup ? `Reset from remote. Previous state kept in branch ${backup}.` : 'Reset from remote.')
|
||||
await refresh()
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function createBranch() {
|
||||
const name = newBranchName.value.trim()
|
||||
if (!name) return
|
||||
busy.value = true
|
||||
try {
|
||||
await gql(`mutation Create($name: String!) { gitCreateBranch(name: $name) }`, { name })
|
||||
emit('status', `Created and switched to branch ${name}.`)
|
||||
newBranchName.value = ''
|
||||
await refresh()
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
async function switchBranch() {
|
||||
const name = switchTarget.value.replace(/ \(remote\)$/, '')
|
||||
if (!name || name === branches.value.current) return
|
||||
busy.value = true
|
||||
try {
|
||||
await gql(`mutation Switch($name: String!) { gitSwitchBranch(name: $name) }`, { name })
|
||||
emit('status', `Switched to branch ${name}.`)
|
||||
await refresh()
|
||||
} catch (err: any) { emit('status', err.message, true) } finally { busy.value = false }
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try { await loadSettings() } catch { /* not initialised */ }
|
||||
if (settings.value.enabled) await refresh()
|
||||
poll = setInterval(() => { if (settings.value.enabled && !busy.value) loadStatus() }, 30000)
|
||||
})
|
||||
onUnmounted(() => { if (poll) clearInterval(poll) })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="card p-6">
|
||||
<div class="flex items-center justify-between border-b border-slate-200 dark:border-slate-700 pb-2 mb-4">
|
||||
<h2 class="text-lg font-semibold">Git Sync (remote repository)</h2>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="settings.enabled" class="rounded border-slate-300 text-accent-600 focus:ring-accent-500" />
|
||||
<span class="text-sm font-medium">Enable</span>
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-sm text-slate-500 dark:text-slate-400 mb-4">
|
||||
Sync the wiki with a remote git repository over SSH. Register the public key below as a
|
||||
deploy key (with write access unless read-only) on your git host.
|
||||
<span v-if="status?.attention" class="font-medium text-amber-600 dark:text-amber-400">Needs attention — see status below.</span>
|
||||
</p>
|
||||
|
||||
<div v-if="settings.enabled" class="space-y-4">
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div class="sm:col-span-2">
|
||||
<label class="label">Remote URL (SSH)</label>
|
||||
<input v-model="settings.remoteUrl" class="input font-mono text-sm" placeholder="ssh://git@git.example.com:2222/user/repo.git" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Live branch</label>
|
||||
<input v-model="settings.liveBranch" class="input" placeholder="main" />
|
||||
<p class="text-xs text-slate-500 mt-1">The branch auto-sync keeps in sync</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label">Auto-sync interval (minutes, 0 = manual)</label>
|
||||
<input v-model.number="settings.autoSyncMinutes" type="number" min="0" class="input" />
|
||||
</div>
|
||||
<div class="sm:col-span-2">
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox" v-model="settings.readOnly" class="rounded border-slate-300 text-accent-600 focus:ring-accent-500" />
|
||||
<span class="text-sm font-medium">Read-only (pull from remote, never push)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Deploy key -->
|
||||
<div>
|
||||
<label class="label">Deploy key (public)</label>
|
||||
<div v-if="settings.keySet" class="flex gap-2 items-start">
|
||||
<code class="flex-1 block text-xs font-mono bg-slate-100 dark:bg-slate-900/60 rounded-lg p-3 break-all select-all">{{ settings.publicKey }}</code>
|
||||
<div class="flex flex-col gap-2">
|
||||
<button class="btn-secondary text-xs" @click="copyKey">Copy</button>
|
||||
<button class="btn-ghost text-xs" @click="confirmRegen = true">Regenerate</button>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="text-xs text-slate-500">The keypair is generated when sync is first enabled and saved.</p>
|
||||
<div v-if="confirmRegen" class="mt-2 rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50/50 dark:bg-amber-900/10 p-3 text-sm flex items-center justify-between gap-2">
|
||||
<span>The old key stops working immediately. Continue?</span>
|
||||
<span class="flex gap-2">
|
||||
<button class="btn-ghost text-xs" @click="confirmRegen = false">Cancel</button>
|
||||
<button class="btn-primary text-xs" :disabled="busy" @click="regenerateKey">Regenerate key</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<button class="btn-primary" :disabled="busy" @click="saveSettings">Save Git Sync Config</button>
|
||||
</div>
|
||||
|
||||
<!-- Status -->
|
||||
<div v-if="status?.enabled" class="rounded-lg border border-slate-200 dark:border-slate-700 p-4 space-y-3">
|
||||
<div class="flex flex-wrap items-center gap-x-4 gap-y-1 text-sm">
|
||||
<span>Branch: <code class="font-mono">{{ status.currentBranch }}</code>
|
||||
<span v-if="status.currentBranch === status.liveBranch" class="ml-1 text-[10px] font-bold px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400">live</span>
|
||||
</span>
|
||||
<span v-if="status.hasUpstream" class="text-slate-500">↑{{ status.ahead }} ↓{{ status.behind }}</span>
|
||||
<span v-else class="text-amber-600 dark:text-amber-400">no upstream yet</span>
|
||||
<span v-if="status.dirty" class="text-amber-600 dark:text-amber-400">uncommitted changes</span>
|
||||
<span v-if="status.mergeInProgress" class="text-red-600 dark:text-red-400 font-medium">merge in progress</span>
|
||||
<span v-if="status.lastSyncAt" class="text-slate-400 text-xs ml-auto">last sync {{ new Date(status.lastSyncAt).toLocaleString() }}</span>
|
||||
</div>
|
||||
<p v-if="status.lastSyncError" class="text-xs text-red-600 dark:text-red-400 font-mono">{{ status.lastSyncError }}</p>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button class="btn-secondary" :disabled="busy" @click="pull">Pull</button>
|
||||
<button class="btn-secondary" :disabled="busy || settings.readOnly" :title="settings.readOnly ? 'Repository is read-only' : ''" @click="push">Push</button>
|
||||
<button v-if="status.mergeInProgress" class="btn-primary" :disabled="busy" @click="showMergeDialog = true">Resolve merge…</button>
|
||||
<button class="btn-ghost text-red-600 dark:text-red-400 ml-auto" :disabled="busy" @click="confirmReset = true">Reset from remote…</button>
|
||||
</div>
|
||||
|
||||
<div v-if="suggestReset" class="rounded-lg border border-amber-300 dark:border-amber-700 bg-amber-50/50 dark:bg-amber-900/10 p-3 text-sm">
|
||||
Local and remote histories are unrelated — the safe way forward is a reset from remote
|
||||
(current state is kept in a backup branch).
|
||||
</div>
|
||||
<div v-if="confirmReset" class="rounded-lg border border-red-300 dark:border-red-700 bg-red-50/50 dark:bg-red-900/10 p-3 text-sm flex items-center justify-between gap-2">
|
||||
<span>Replace the local <code class="font-mono">{{ status.currentBranch }}</code> with the remote version? Current state is saved in a backup branch.</span>
|
||||
<span class="flex gap-2 shrink-0">
|
||||
<button class="btn-ghost text-xs" @click="confirmReset = false">Cancel</button>
|
||||
<button class="btn-primary text-xs" :disabled="busy" @click="resetFromRemote">Reset</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Branches -->
|
||||
<div class="flex flex-wrap items-end gap-2 pt-2 border-t border-slate-100 dark:border-slate-700/50">
|
||||
<div>
|
||||
<label class="label">Switch branch</label>
|
||||
<div class="flex gap-2">
|
||||
<select v-model="switchTarget" class="input">
|
||||
<option v-for="b in allBranches" :key="b" :value="b">{{ b }}</option>
|
||||
</select>
|
||||
<button class="btn-secondary" :disabled="busy || switchTarget.replace(/ \(remote\)$/, '') === branches.current" @click="switchBranch">Switch</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto">
|
||||
<label class="label">New branch</label>
|
||||
<div class="flex gap-2">
|
||||
<input v-model="newBranchName" class="input" placeholder="draft/my-changes" @keydown.enter.prevent="createBranch" />
|
||||
<button class="btn-secondary" :disabled="busy || !newBranchName.trim()" @click="createBranch">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MergeConflictDialog
|
||||
:is-open="showMergeDialog"
|
||||
:conflicts="status?.conflicts ?? []"
|
||||
:busy="busy"
|
||||
@close="showMergeDialog = false"
|
||||
@resolve="resolveMerge"
|
||||
@abort="abortMerge"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
58
frontend/src/components/MergeConflictDialog.vue
Normal file
58
frontend/src/components/MergeConflictDialog.vue
Normal file
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
isOpen: boolean
|
||||
conflicts: string[]
|
||||
busy: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'resolve', strategy: 'ours' | 'theirs', paths?: string[]): void
|
||||
(e: 'abort'): void
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<div v-if="isOpen" class="fixed inset-0 z-50 flex items-center justify-center bg-slate-900/50 backdrop-blur-sm p-4">
|
||||
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl border border-slate-200 dark:border-slate-700 w-full max-w-lg overflow-hidden flex flex-col max-h-[80vh]">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="p-4 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-slate-900 dark:text-white">Merge conflicts</h3>
|
||||
<button type="button" @click="emit('close')" class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-200">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div class="p-4 bg-slate-50 dark:bg-slate-900/50 flex-1 overflow-auto flex flex-col gap-3">
|
||||
<p class="text-sm text-slate-600 dark:text-slate-400">
|
||||
The pull stopped because these files were changed both locally and on the remote.
|
||||
Pick which version to keep — per file, or for everything at once.
|
||||
</p>
|
||||
<ul class="bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg divide-y divide-slate-100 dark:divide-slate-700/50 overflow-y-auto">
|
||||
<li v-for="file in conflicts" :key="file" class="flex items-center gap-2 px-3 py-2">
|
||||
<span class="text-sm font-mono text-slate-700 dark:text-slate-300 truncate flex-1">{{ file }}</span>
|
||||
<button type="button" class="btn-secondary text-xs px-2 py-1" :disabled="busy" @click="emit('resolve', 'ours', [file])">Keep local</button>
|
||||
<button type="button" class="btn-secondary text-xs px-2 py-1" :disabled="busy" @click="emit('resolve', 'theirs', [file])">Take remote</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="p-4 border-t border-slate-200 dark:border-slate-700 flex flex-wrap justify-end gap-2 bg-white dark:bg-slate-800">
|
||||
<button type="button" class="btn-ghost mr-auto" :disabled="busy" @click="emit('abort')">Abort merge</button>
|
||||
<button type="button" class="btn-secondary" :disabled="busy" @click="emit('resolve', 'ours')">Keep all local</button>
|
||||
<button type="button" class="btn-primary" :disabled="busy" @click="emit('resolve', 'theirs')">Take all remote</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active, .fade-leave-active { transition: opacity 0.15s; }
|
||||
.fade-enter-from, .fade-leave-to { opacity: 0; }
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import { ref, onMounted, computed } from 'vue'
|
||||
import { gql } from '@/lib/gql'
|
||||
import { hashPassword } from '@/lib/crypto'
|
||||
import FolderPicker from '@/components/FolderPicker.vue'
|
||||
import GitSyncSection from '@/components/GitSyncSection.vue'
|
||||
|
||||
// ── Shared ──────────────────────────────────────────────────────────────────
|
||||
const loading = ref(true)
|
||||
@@ -443,6 +444,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Git sync -->
|
||||
<GitSyncSection @status="showStatus" />
|
||||
|
||||
<!-- Admin password -->
|
||||
<section class="card p-6">
|
||||
<h2 class="text-lg font-semibold mb-4 border-b border-slate-200 dark:border-slate-700 pb-2">Your Password</h2>
|
||||
|
||||
Reference in New Issue
Block a user