fix(repo): sync a branch without switching to it

tea repo sync ran git switch on the synced branch, so syncing main while
on experiment left the user on main. A branch that is not checked out now
moves with update-ref, guarded by its old value; the checked-out branch
merges with --ff-only and needs a clean working tree; a missing branch is
created and set to track the remote. Diverged branches stay untouched.

Sync reports "✓ Synced main from origin/main (fast-forward a..b)" on a
terminal, "already up to date" when nothing changed, and the JSON result
gains the previous commit. Outside a clone, or without a remote for the
repository, the errors now name repo sync instead of checkout.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:51:43 +02:00
1 parent 33f6406a1e
commit ea2e9aadbb
6 files changed
+341 -142

No files matched your search

-1
View File
@@ -514,7 +514,6 @@ const (
repo list repo list
repo migrate repo migrate
repo set-default repo set-default
repo sync
repo view repo view
search issue search issue
search pr search pr
+18
View File
@@ -0,0 +1,18 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"bytes"
"testing"
)
// repoTTYDeps returns serverDeps for a 100-column terminal without color or links, as people read tea's output.
func repoTTYDeps(t *testing.T, serverURL string) (Dependencies, *bytes.Buffer, *bytes.Buffer) {
t.Helper()
deps, out, diagnostics := serverDeps(t, serverURL)
deps.Getenv = serverEnv(serverURL, map[string]string{"TERM": "dumb"})
deps.Streams.OutTTY, deps.Streams.Width = true, 100
return deps, out, diagnostics
}
+45 -6
View File
@@ -5,7 +5,6 @@ package app
import ( import (
"errors" "errors"
"fmt"
"strings" "strings"
"gitea.dev/tea/internal/clierr" "gitea.dev/tea/internal/clierr"
@@ -17,7 +16,21 @@ import (
func (a *application) repositorySyncCommand() *cobra.Command { func (a *application) repositorySyncCommand() *cobra.Command {
var branch string var branch string
command := &cobra.Command{Use: "sync [repository]", Short: "Fast-forward a local branch from the selected repository remote", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error { command := &cobra.Command{
Use: "sync [<repository>]",
Short: "Sync a local branch with its remote",
Long: `Fast-forward a local branch to the branch of the same name on the repository's Git remote.
The branch defaults to the repository's default branch. It is created when it is missing, and it is
never checked out: the branch you are on stays checked out. When the branch is checked out, the
working tree must be clean. Branches that have diverged from the remote are left unchanged.`,
Example: `# Fast-forward the default branch from the repository's remote
$ tea repo sync
# Fast-forward another branch
$ tea repo sync --branch release/1.0`,
Args: usageArgs(cobra.MaximumNArgs(1)),
RunE: func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("branch") && strings.TrimSpace(branch) == "" { if cmd.Flags().Changed("branch") && strings.TrimSpace(branch) == "" {
return clierr.Wrap(clierr.Usage, errors.New("--branch cannot be empty")) return clierr.Wrap(clierr.Usage, errors.New("--branch cannot be empty"))
} }
@@ -28,11 +41,17 @@ func (a *application) repositorySyncCommand() *cobra.Command {
if err != nil { if err != nil {
return err return err
} }
if inside, _ := a.gitProbe().Output(cmd.Context(), a.options.cwd, false, "rev-parse", "--is-inside-work-tree"); inside != "true" {
return clierr.Wrap(clierr.Usage, errors.New("repo sync must run inside a Git clone of "+target.Repo))
}
snapshot, err := a.deps.Settings.Load() snapshot, err := a.deps.Settings.Load()
if err != nil { if err != nil {
return err return err
} }
remote, err := a.checkoutRemote(cmd.Context(), target, snapshot) remote, err := a.checkoutRemote(cmd.Context(), target, snapshot)
if errors.Is(err, errNoMatchingRemote) {
return clierr.Wrap(clierr.Usage, errors.New("no Git remote of this clone points to "+target.Repo+"; add one with git remote add, or pass --remote"))
}
if err != nil { if err != nil {
return clierr.Wrap(clierr.Usage, err) return clierr.Wrap(clierr.Usage, err)
} }
@@ -61,9 +80,29 @@ func (a *application) repositorySyncCommand() *cobra.Command {
if a.options.json != "" { if a.options.json != "" {
return a.writeJSON(cmd, result) return a.writeJSON(cmd, result)
} }
_, err = fmt.Fprintf(cmd.OutOrStdout(), "Synced %s (%s) from %s\n", result.Branch, result.Commit, result.Remote) a.reportSync(cmd, result, remote+"/"+result.Branch)
return err return nil
}} },
command.Flags().StringVarP(&branch, "branch", "b", "", "Local and remote branch (default: repository default branch)") }
command.Flags().StringVarP(&branch, "branch", "b", "", "Branch to sync (default: the repository's default branch)")
_ = command.RegisterFlagCompletionFunc("branch", a.completeBranches())
command.ValidArgsFunction = a.completeRepos()
return command return command
} }
// reportSync tells how a sync from source, such as origin/main, changed the branch.
func (a *application) reportSync(cmd *cobra.Command, result git.SyncResult, source string) {
switch result.Previous {
case result.Commit:
a.done(cmd, "%s is already up to date with %s", result.Branch, source)
case "":
a.done(cmd, "Created %s from %s (%s)", result.Branch, source, shortSHA(result.Commit))
default:
a.done(cmd, "Synced %s from %s (fast-forward %s..%s)", result.Branch, source, shortSHA(result.Previous), shortSHA(result.Commit))
}
}
// shortSHA abbreviates a commit ID to seven characters, as Gitea and Git show them.
func shortSHA(sha string) string {
return sha[:min(len(sha), 7)]
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"context"
"errors"
"net/http"
"strings"
"testing"
"gitea.dev/tea/internal/clierr"
"github.com/stretchr/testify/require"
)
// syncGit answers the Git commands of a local repo sync in a clone whose origin is url, with
// experiment checked out and main one commit behind origin.
type syncGit struct {
url, inside string
calls []string
}
func (g *syncGit) Output(_ context.Context, _ string, _ bool, args ...string) (string, error) {
for len(args) > 2 && args[0] == "-c" {
args = args[2:]
}
call := strings.Join(args, " ")
g.calls = append(g.calls, call)
switch call {
case "rev-parse --is-inside-work-tree":
if g.inside == "" {
return "", errors.New("not a git repository")
}
return g.inside, nil
case "remote get-url origin":
return g.url, nil
case "symbolic-ref --quiet HEAD":
return "refs/heads/experiment", nil
case "rev-parse --verify --quiet refs/heads/main^{commit}":
return "1111111aaaa", nil
case "rev-parse --verify FETCH_HEAD^{commit}":
return "2222222bbbb", nil
}
return "", nil
}
func TestRepoSyncFastForwardsWithoutSwitchingBranches(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
deps, out, diagnostics := repoTTYDeps(t, server.URL)
runner := &syncGit{url: server.URL + "/alice/project.git", inside: "true"}
deps.Git = runner
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "sync", "-R", "alice/project", "--remote", "origin", "-b", "main"}))
require.Empty(t, out.String())
require.Equal(t, "✓ Synced main from origin/main (fast-forward 1111111..2222222)\n", diagnostics.String())
require.Contains(t, runner.calls, "update-ref -m tea repo sync refs/heads/main 2222222bbbb 1111111aaaa")
for _, call := range runner.calls {
require.False(t, strings.HasPrefix(call, "switch") || strings.HasPrefix(call, "checkout") || strings.HasPrefix(call, "merge "), call)
}
}
func TestRepoSyncOutsideACloneExplainsWhatItNeeds(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
deps, _, _ := serverDeps(t, server.URL)
deps.Git = &syncGit{}
err := Execute(context.Background(), deps, []string{"repo", "sync", "-R", "alice/project", "-b", "main"})
require.Equal(t, clierr.Usage, clierr.ExitCode(err), "%v", err)
require.ErrorContains(t, err, "repo sync must run inside a Git clone of alice/project")
}
+59 -28
View File
@@ -9,21 +9,27 @@ import (
"fmt" "fmt"
) )
// SyncOptions selects one existing local branch and its source remote branch. // SyncOptions selects the local branch to fast-forward and the remote whose branch of the same name it follows.
type SyncOptions struct { type SyncOptions struct {
Directory, Remote, Branch string Directory, Remote, Branch string
CredentialArgs []string CredentialArgs []string
Prompt bool Prompt bool
} }
// SyncResult reports a completed local fast-forward. // SyncResult reports a synced branch. Previous is the branch's commit before the sync, empty when
// the sync created the branch; it equals Commit when there was nothing to sync. For a sync on the
// server, Remote names the repository the branch was synced from.
type SyncResult struct { type SyncResult struct {
Branch string `json:"branch"` Branch string `json:"branch"`
Commit string `json:"commit"` Commit string `json:"commit"`
Previous string `json:"previous"`
Remote string `json:"remote"` Remote string `json:"remote"`
} }
// SyncBranch fetches and fast-forwards an existing branch without resetting commits. // SyncBranch fast-forwards the local branch to the remote's branch of the same name, creating it
// when it is missing, and never changes which branch is checked out. The checked-out branch merges
// with --ff-only and needs a clean working tree; another branch moves with update-ref, which fails
// if the branch changed meanwhile. A branch that already contains the remote history is kept.
func SyncBranch(ctx context.Context, runner Runner, opts SyncOptions) (SyncResult, error) { func SyncBranch(ctx context.Context, runner Runner, opts SyncOptions) (SyncResult, error) {
run := func(args ...string) (string, error) { return runner.Output(ctx, opts.Directory, opts.Prompt, args...) } run := func(args ...string) (string, error) { return runner.Output(ctx, opts.Directory, opts.Prompt, args...) }
if opts.Remote == "" || opts.Branch == "" { if opts.Remote == "" || opts.Branch == "" {
@@ -35,20 +41,27 @@ func SyncBranch(ctx context.Context, runner Runner, opts SyncOptions) (SyncResul
if err := requireWorkTree(run, "sync"); err != nil { if err := requireWorkTree(run, "sync"); err != nil {
return SyncResult{}, err return SyncResult{}, err
} }
ref := "refs/heads/" + opts.Branch
head, err := optional(run("symbolic-ref", "--quiet", "HEAD"))
if err != nil {
return SyncResult{}, err
}
checkedOut := head == ref
if checkedOut {
status, err := run("status", "--porcelain=v1", "--untracked-files=normal") status, err := run("status", "--porcelain=v1", "--untracked-files=normal")
if err != nil { if err != nil {
return SyncResult{}, err return SyncResult{}, err
} }
if status != "" { if status != "" {
return SyncResult{}, errors.New("working tree has changes; commit or stash them before syncing") return SyncResult{}, fmt.Errorf("working tree has changes; commit or stash them before syncing %s", opts.Branch)
} }
localRef := "refs/heads/" + opts.Branch + "^{commit}" }
previous, err := run("rev-parse", "--verify", localRef) previous, err := optional(run("rev-parse", "--verify", "--quiet", ref+"^{commit}"))
if err != nil { if err != nil {
return SyncResult{}, fmt.Errorf("local branch is unavailable; create it before syncing: %w", err) return SyncResult{}, err
} }
fetch := append([]string{}, opts.CredentialArgs...) fetch := append([]string{}, opts.CredentialArgs...)
fetch = append(fetch, "fetch", "--no-tags", "--", opts.Remote, "refs/heads/"+opts.Branch) fetch = append(fetch, "fetch", "--no-tags", "--", opts.Remote, ref)
if _, err := run(fetch...); err != nil { if _, err := run(fetch...); err != nil {
return SyncResult{}, err return SyncResult{}, err
} }
@@ -56,39 +69,57 @@ func SyncBranch(ctx context.Context, runner Runner, opts SyncOptions) (SyncResul
if err != nil { if err != nil {
return SyncResult{}, err return SyncResult{}, err
} }
current, err := run("rev-parse", "--verify", localRef) result := SyncResult{Branch: opts.Branch, Commit: commit, Previous: previous, Remote: opts.Remote}
if err != nil { if previous == commit {
return SyncResult{}, err return result, nil
} }
if current != previous { if previous != "" {
return SyncResult{}, errors.New("local branch changed while fetching; retry sync")
}
if previous != commit {
if _, err := run("merge-base", "--is-ancestor", previous, commit); err != nil { if _, err := run("merge-base", "--is-ancestor", previous, commit); err != nil {
if failure, ok := errors.AsType[*CommandError](err); !ok || failure.ExitCode != 1 { if failure, ok := errors.AsType[*CommandError](err); !ok || failure.ExitCode != 1 {
return SyncResult{}, err return SyncResult{}, err
} }
if _, err := run("merge-base", "--is-ancestor", commit, previous); err != nil { if _, err := run("merge-base", "--is-ancestor", commit, previous); err != nil {
return SyncResult{}, fmt.Errorf("branch cannot be fast-forwarded; resolve divergence before syncing: %w", err) return SyncResult{}, fmt.Errorf("%s and %s/%s have diverged; merge or rebase before syncing: %w", opts.Branch, opts.Remote, opts.Branch, err)
} }
// The local branch already contains the remote history. result.Commit = previous
commit = previous return result, nil
} }
} }
if _, err := run("switch", opts.Branch); err != nil { if !checkedOut {
// The old value makes update-ref fail if the branch moved, or appeared, while fetching.
if _, err := run("update-ref", "-m", "tea repo sync", ref, commit, previous); err != nil {
return SyncResult{}, fmt.Errorf("%s changed while fetching; retry sync: %w", opts.Branch, err)
}
if previous == "" {
for _, setting := range [][2]string{{"remote", opts.Remote}, {"merge", ref}} {
if _, err := run("config", "branch."+opts.Branch+"."+setting[0], setting[1]); err != nil {
return SyncResult{}, fmt.Errorf("created %s, but could not set it to track %s: %w", opts.Branch, opts.Remote, err)
}
}
}
return result, nil
}
current, err := run("rev-parse", "--verify", "HEAD^{commit}")
if err != nil {
return SyncResult{}, err return SyncResult{}, err
} }
if previous != commit { if current != previous {
return SyncResult{}, fmt.Errorf("%s changed while fetching; retry sync", opts.Branch)
}
if _, err := run("merge", "--ff-only", "--no-edit", commit); err != nil { if _, err := run("merge", "--ff-only", "--no-edit", commit); err != nil {
return SyncResult{}, fmt.Errorf("branch was selected but sync failed: %w", err) return SyncResult{}, fmt.Errorf("could not fast-forward %s: %w", opts.Branch, err)
} }
} if actual, err := run("rev-parse", "--verify", "HEAD^{commit}"); err != nil || actual != commit {
actual, err := run("rev-parse", "--verify", "HEAD^{commit}")
if err != nil {
return SyncResult{}, fmt.Errorf("sync finished, but HEAD could not be verified: %w", err)
}
if actual != commit {
return SyncResult{}, errors.New("HEAD changed during sync; inspect the working tree before retrying") return SyncResult{}, errors.New("HEAD changed during sync; inspect the working tree before retrying")
} }
return SyncResult{Branch: opts.Branch, Commit: commit, Remote: opts.Remote}, nil return result, nil
}
// optional returns "" without an error for a Git command that exits with status 1 to report
// that what it looked for does not exist, such as rev-parse --verify --quiet.
func optional(output string, err error) (string, error) {
if failure, ok := errors.AsType[*CommandError](err); ok && failure.ExitCode == 1 {
return "", nil
}
return output, err
} }
+144 -107
View File
@@ -5,128 +5,165 @@ package git
import ( import (
"context" "context"
"strings" "os"
"os/exec"
"path/filepath"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestSyncValidatesBeforeChangingBranches(t *testing.T) { // syncFixture is a clone of a bare remote whose main branch is one commit ahead of the clone's.
for _, test := range []struct { type syncFixture struct {
dirty, diverged bool t *testing.T
message string runner System
work string
behind, ahead string
}
func newSyncFixture(t *testing.T) syncFixture {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("Git is not installed")
}
root := t.TempDir()
env := append(os.Environ(), "GIT_CONFIG_GLOBAL="+filepath.Join(root, "gitconfig"), "GIT_CONFIG_NOSYSTEM=1",
"GIT_AUTHOR_NAME=tea", "GIT_AUTHOR_EMAIL=tea@example.invalid", "GIT_COMMITTER_NAME=tea", "GIT_COMMITTER_EMAIL=tea@example.invalid")
f := syncFixture{t: t, runner: System{Env: env}, work: filepath.Join(root, "work")}
remote, pusher := filepath.Join(root, "remote.git"), filepath.Join(root, "pusher")
f.git(root, "init", "--bare", "-b", "main", remote)
f.git(root, "clone", remote, pusher)
f.git(pusher, "commit", "--allow-empty", "-m", "first")
f.git(pusher, "push", "origin", "HEAD:main")
f.git(root, "clone", remote, f.work)
f.git(f.work, "switch", "-c", "experiment")
f.git(pusher, "commit", "--allow-empty", "-m", "second")
f.git(pusher, "push", "origin", "HEAD:main")
f.behind, f.ahead = f.git(f.work, "rev-parse", "main"), f.git(pusher, "rev-parse", "HEAD")
return f
}
func (f syncFixture) git(dir string, args ...string) string {
f.t.Helper()
output, err := f.runner.Output(context.Background(), dir, false, args...)
require.NoError(f.t, err, args)
return output
}
func (f syncFixture) sync(branch string) (SyncResult, error) {
return SyncBranch(context.Background(), f.runner, SyncOptions{Directory: f.work, Remote: "origin", Branch: branch})
}
func TestSyncBranchKeepsTheCheckedOutBranch(t *testing.T) {
for _, tc := range []struct {
name, checkout string
prepare func(f syncFixture)
want func(f syncFixture) SyncResult
tracks bool
}{ }{
{true, false, "working tree has changes"}, {
{false, true, "cannot be fast-forwarded"}, name: "another branch is checked out", checkout: "experiment",
want: func(f syncFixture) SyncResult { return SyncResult{"main", f.ahead, f.behind, "origin"} },
},
{
name: "another branch with local changes", checkout: "experiment",
prepare: func(f syncFixture) {
require.NoError(t, os.WriteFile(filepath.Join(f.work, "draft.txt"), []byte("draft"), 0o600))
},
want: func(f syncFixture) SyncResult { return SyncResult{"main", f.ahead, f.behind, "origin"} },
},
{
name: "the branch is checked out", checkout: "main",
want: func(f syncFixture) SyncResult { return SyncResult{"main", f.ahead, f.behind, "origin"} },
},
{
name: "the branch is missing", checkout: "experiment",
prepare: func(f syncFixture) { f.git(f.work, "branch", "-D", "main") },
want: func(f syncFixture) SyncResult { return SyncResult{"main", f.ahead, "", "origin"} },
tracks: true,
},
{
name: "the branch is up to date", checkout: "experiment",
prepare: func(f syncFixture) {
f.git(f.work, "fetch", "origin")
f.git(f.work, "branch", "-f", "main", f.ahead)
},
want: func(f syncFixture) SyncResult { return SyncResult{"main", f.ahead, f.ahead, "origin"} },
},
{
name: "the branch is ahead", checkout: "main",
prepare: func(f syncFixture) {
f.git(f.work, "fetch", "origin")
f.git(f.work, "reset", "--hard", f.ahead)
f.git(f.work, "commit", "--allow-empty", "-m", "local")
},
want: func(f syncFixture) SyncResult {
local := f.git(f.work, "rev-parse", "main")
return SyncResult{"main", local, local, "origin"}
},
},
} { } {
runner := &checkoutRunner{dirty: test.dirty, diverged: test.diverged, localCommit: "previous"} t.Run(tc.name, func(t *testing.T) {
_, err := SyncBranch(context.Background(), runner, SyncOptions{Directory: "/work", Remote: "origin", Branch: "main"}) f := newSyncFixture(t)
require.ErrorContains(t, err, test.message) if tc.checkout == "main" {
for _, call := range runner.calls { f.git(f.work, "switch", "main")
require.False(t, strings.HasPrefix(call, "switch "))
require.False(t, strings.HasPrefix(call, "merge "))
if test.dirty {
require.False(t, strings.HasPrefix(call, "fetch "))
} }
if tc.prepare != nil {
tc.prepare(f)
} }
} want := tc.want(f)
runner := &checkoutRunner{localCommit: "previous"} result, err := f.sync("main")
result, err := SyncBranch(context.Background(), runner, SyncOptions{Directory: "/work", Remote: "origin", Branch: "main", CredentialArgs: []string{"-c", "credential.helper=tea"}})
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, SyncResult{Branch: "main", Commit: "abc123", Remote: "origin"}, result) require.Equal(t, tc.checkout, f.git(f.work, "branch", "--show-current"))
require.Contains(t, runner.calls, "-c credential.helper=tea fetch --no-tags -- origin refs/heads/main") require.Equal(t, want.Commit, f.git(f.work, "rev-parse", "main"))
require.Equal(t, []string{"switch main", "merge --ff-only --no-edit abc123", "rev-parse --verify HEAD^{commit}"}, runner.calls[len(runner.calls)-3:]) require.Equal(t, want, result)
if tc.tracks {
require.Equal(t, "origin", f.git(f.work, "config", "branch.main.remote"))
require.Equal(t, "refs/heads/main", f.git(f.work, "config", "branch.main.merge"))
}
})
}
} }
type syncFailureRunner struct { func TestSyncBranchRefusesWhatItCannotFastForward(t *testing.T) {
checkoutRunner for _, tc := range []struct {
missing, changed bool name, message string
reads int prepare func(f syncFixture)
}
func (r *syncFailureRunner) Output(ctx context.Context, dir string, prompt bool, args ...string) (string, error) {
if len(args) == 3 && args[0] == "rev-parse" && strings.HasPrefix(args[2], "refs/heads/") {
r.reads++
if r.missing {
return "", &CommandError{ExitCode: 1}
}
if r.changed && r.reads == 2 {
return "other-commit", nil
}
}
return r.checkoutRunner.Output(ctx, dir, prompt, args...)
}
func TestSyncRejectsMissingOrChangedBranch(t *testing.T) {
for _, test := range []struct {
missing, changed bool
message string
}{ }{
{true, false, "local branch is unavailable"}, {
{false, true, "local branch changed while fetching"}, name: "local changes on the checked-out branch", message: "working tree has changes",
prepare: func(f syncFixture) {
f.git(f.work, "switch", "main")
require.NoError(t, os.WriteFile(filepath.Join(f.work, "draft.txt"), []byte("draft"), 0o600))
},
},
{
name: "diverged history", message: "main and origin/main have diverged",
prepare: func(f syncFixture) {
f.git(f.work, "switch", "main")
f.git(f.work, "commit", "--allow-empty", "-m", "local")
f.git(f.work, "switch", "experiment")
},
},
} { } {
runner := &syncFailureRunner{missing: test.missing, changed: test.changed} t.Run(tc.name, func(t *testing.T) {
_, err := SyncBranch(context.Background(), runner, SyncOptions{Directory: "/work", Remote: "origin", Branch: "main"}) f := newSyncFixture(t)
require.ErrorContains(t, err, test.message) tc.prepare(f)
for _, call := range runner.calls { branch, before := f.git(f.work, "branch", "--show-current"), f.git(f.work, "rev-parse", "main")
require.False(t, strings.HasPrefix(call, "switch ")) _, err := f.sync("main")
require.False(t, strings.HasPrefix(call, "merge ")) require.ErrorContains(t, err, tc.message)
if test.missing { require.Equal(t, branch, f.git(f.work, "branch", "--show-current"))
require.False(t, strings.HasPrefix(call, "fetch ")) require.Equal(t, before, f.git(f.work, "rev-parse", "main"))
} })
}
} }
} }
type aheadSyncRunner struct{ checkoutRunner } func TestSyncBranchValidatesItsInput(t *testing.T) {
f := newSyncFixture(t)
func (r *aheadSyncRunner) Output(ctx context.Context, dir string, prompt bool, args ...string) (string, error) { _, err := SyncBranch(context.Background(), f.runner, SyncOptions{Directory: f.work, Remote: "origin"})
if len(args) == 3 && args[2] == "HEAD^{commit}" { require.ErrorContains(t, err, "requires a remote and branch")
r.calls = append(r.calls, strings.Join(args, " ")) _, err = f.sync("bad..name")
return r.localCommit, nil require.Error(t, err)
} _, err = SyncBranch(context.Background(), f.runner, SyncOptions{Directory: t.TempDir(), Remote: "origin", Branch: "main"})
if len(args) == 4 && args[0] == "merge-base" && args[2] == "previous" { require.Error(t, err)
r.calls = append(r.calls, strings.Join(args, " "))
return "", &CommandError{ExitCode: 1}
}
return r.checkoutRunner.Output(ctx, dir, prompt, args...)
}
func TestSyncPreservesBranchAheadOfRemote(t *testing.T) {
runner := &aheadSyncRunner{localCommit: "previous"}
result, err := SyncBranch(context.Background(), runner, SyncOptions{Directory: "/work", Remote: "origin", Branch: "main"})
require.NoError(t, err)
require.Equal(t, "previous", result.Commit)
require.Contains(t, runner.calls, "merge-base --is-ancestor abc123 previous")
require.Equal(t, "switch main", runner.calls[len(runner.calls)-2])
for _, call := range runner.calls {
require.False(t, strings.HasPrefix(call, "merge "))
}
}
func TestSyncEqualHistoryDoesNotMerge(t *testing.T) {
runner := &checkoutRunner{}
result, err := SyncBranch(context.Background(), runner, SyncOptions{Directory: "/work", Remote: "origin", Branch: "main"})
require.NoError(t, err)
require.Equal(t, "abc123", result.Commit)
for _, call := range runner.calls {
require.False(t, strings.HasPrefix(call, "merge-base "))
require.False(t, strings.HasPrefix(call, "merge "))
}
}
type changedHeadRunner struct{ checkoutRunner }
func (r *changedHeadRunner) Output(ctx context.Context, dir string, prompt bool, args ...string) (string, error) {
if len(args) == 3 && args[2] == "HEAD^{commit}" {
return "unexpected", nil
}
return r.checkoutRunner.Output(ctx, dir, prompt, args...)
}
func TestSyncRejectsUnexpectedFinalHead(t *testing.T) {
runner := &changedHeadRunner{}
result, err := SyncBranch(context.Background(), runner, SyncOptions{Directory: "/work", Remote: "origin", Branch: "main"})
require.ErrorContains(t, err, "HEAD changed during sync")
require.Equal(t, SyncResult{}, result)
} }