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/"). 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 }