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
+387 -188

No files matched your search

-1
View File
@@ -514,7 +514,6 @@ const (
repo list
repo migrate
repo set-default
repo sync
repo view
search issue
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
}
+85 -46
View File
@@ -5,7 +5,6 @@ package app
import (
"errors"
"fmt"
"strings"
"gitea.dev/tea/internal/clierr"
@@ -17,53 +16,93 @@ import (
func (a *application) repositorySyncCommand() *cobra.Command {
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 {
if cmd.Flags().Changed("branch") && strings.TrimSpace(branch) == "" {
return clierr.Wrap(clierr.Usage, errors.New("--branch cannot be empty"))
}
if err := a.repositoryArgument(args); err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
snapshot, err := a.deps.Settings.Load()
if err != nil {
return err
}
remote, err := a.checkoutRemote(cmd.Context(), target, snapshot)
if err != nil {
return clierr.Wrap(clierr.Usage, err)
}
if branch == "" {
repository, err := (services.Repositories{Client: target.client}).View(cmd.Context(), target.Repo)
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) == "" {
return clierr.Wrap(clierr.Usage, errors.New("--branch cannot be empty"))
}
if err := a.repositoryArgument(args); err != nil {
return err
}
target, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
branch = repository.DefaultBranch
}
raw, err := a.git(cmd.Context(), "remote", "get-url", remote)
if err != nil {
return err
}
if !target.ownsRemote(raw) {
return clierr.Wrap(clierr.Usage, errors.New("selected Git remote changed; resolve the repository again before sync"))
}
credentials, err := a.gitTransportArguments(target, settings.RemoteIsSSH(raw))
if err != nil {
return err
}
result, err := git.SyncBranch(cmd.Context(), a.deps.Git, git.SyncOptions{Directory: a.options.cwd, Remote: remote, Branch: branch, CredentialArgs: credentials, Prompt: a.policy(snapshot).CanPrompt(a.deps.Streams)})
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, result)
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "Synced %s (%s) from %s\n", result.Branch, result.Commit, result.Remote)
return err
}}
command.Flags().StringVarP(&branch, "branch", "b", "", "Local and remote branch (default: repository default branch)")
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()
if err != nil {
return err
}
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 {
return clierr.Wrap(clierr.Usage, err)
}
if branch == "" {
repository, err := (services.Repositories{Client: target.client}).View(cmd.Context(), target.Repo)
if err != nil {
return err
}
branch = repository.DefaultBranch
}
raw, err := a.git(cmd.Context(), "remote", "get-url", remote)
if err != nil {
return err
}
if !target.ownsRemote(raw) {
return clierr.Wrap(clierr.Usage, errors.New("selected Git remote changed; resolve the repository again before sync"))
}
credentials, err := a.gitTransportArguments(target, settings.RemoteIsSSH(raw))
if err != nil {
return err
}
result, err := git.SyncBranch(cmd.Context(), a.deps.Git, git.SyncOptions{Directory: a.options.cwd, Remote: remote, Branch: branch, CredentialArgs: credentials, Prompt: a.policy(snapshot).CanPrompt(a.deps.Streams)})
if err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, result)
}
a.reportSync(cmd, result, remote+"/"+result.Branch)
return nil
},
}
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
}
// 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")
}