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>
299 lines
8.8 KiB
Go
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
|
|
}
|