feat(repo): sync forks on the server, wire forks into clones, validate set-default

repo sync OWNER/REPO, run where no Git remote points to the repository,
now updates it on the server: a fork's branch fast-forwards to its
parent's through merge-upstream, and a pull mirror fetches its source.

repo fork reports an existing fork ("! bob/x already exists") instead of
a raw HTTP 409, refuses to fork a repository into its own owner, and
gains --fork-name, --clone (Git flags after --) and --remote, which adds
the fork as origin and renames the old origin to upstream.

repo clone passes Git flags after --, and takes --upstream-remote-name
and --no-upstream for forks. Clones report what they set up.

repo set-default checks that the repository exists before saving it,
prints the saved repository with --view, removes it with --unset, and
reports the change.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:51:47 +02:00
1 parent 7cd13ef64e
commit 368de47c40
13 files changed
+599 -98

No files matched your search

+65 -11
View File
@@ -4,7 +4,9 @@
package app
import (
"cmp"
"errors"
"fmt"
"strings"
"gitea.dev/tea/internal/clierr"
@@ -18,18 +20,25 @@ func (a *application) repositorySyncCommand() *cobra.Command {
var branch string
command := &cobra.Command{
Use: "sync [<repository>]",
Short: "Sync a local branch with its remote",
Short: "Sync a branch with its remote or its fork's parent",
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.`,
working tree must be clean. Branches that have diverged from the remote are left unchanged.
Given a repository that has no remote in the current directory, sync updates it on the server
instead: a fork's branch is fast-forwarded to its parent's, and a pull mirror fetches its source.`,
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)),
$ tea repo sync --branch release/1.0
# Update a fork on the server from its parent
$ tea repo sync bob/project`,
Annotations: map[string]string{"tea.arguments": repositoryArguments},
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"))
@@ -41,18 +50,23 @@ $ tea repo sync --branch release/1.0`,
if err != nil {
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()
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"))
inside, _ := a.gitProbe().Output(cmd.Context(), a.options.cwd, false, "rev-parse", "--is-inside-work-tree")
remote, err := "", errNoMatchingRemote
if inside == "true" {
remote, err = a.checkoutRemote(cmd.Context(), target, snapshot)
}
if err != nil {
switch {
case errors.Is(err, errNoMatchingRemote) && len(args) > 0:
return a.syncOnServer(cmd, target, branch)
case inside != "true":
return clierr.Wrap(clierr.Usage, fmt.Errorf("repo sync needs a Git clone of %s; run it inside one, or pass a fork to update it on the server: tea repo sync OWNER/REPO", target.Repo))
case 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"))
case err != nil:
return clierr.Wrap(clierr.Usage, err)
}
if branch == "" {
@@ -90,6 +104,46 @@ $ tea repo sync --branch release/1.0`,
return command
}
// syncOnServer updates the repository of t on the server: a fork's branch, the default branch
// unless named, fast-forwards to its parent's, and a pull mirror fetches its source.
func (a *application) syncOnServer(cmd *cobra.Command, t target, branch string) error {
repositories := services.Repositories{Client: t.client}
item, err := repositories.View(cmd.Context(), t.Repo)
if err != nil {
return err
}
switch {
case item.Mirror:
if err := repositories.MirrorSync(cmd.Context(), item.FullName); err != nil {
return err
}
a.done(cmd, "Started updating the mirror %s from its source", item.FullName)
return a.writeMutationJSON(cmd, git.SyncResult{Remote: mirrorSourceURL(item)})
case !item.Fork || item.Parent == nil:
return clierr.Wrap(clierr.Usage, fmt.Errorf("%s is neither a fork nor a mirror, and has no Git remote here; run tea repo sync inside a clone of it", item.FullName))
}
branch = cmp.Or(branch, item.DefaultBranch)
branches := services.Branches{Client: t.client}
before, err := branches.View(cmd.Context(), item.FullName, branch)
if err != nil {
return err
}
if _, err := repositories.MergeUpstream(cmd.Context(), item.FullName, branch); err != nil {
return err
}
after, err := branches.View(cmd.Context(), item.FullName, branch)
if err != nil {
return err
}
result := git.SyncResult{Branch: branch, Commit: after.SHA, Previous: before.SHA, Remote: item.Parent.FullName}
if result.Previous == result.Commit {
a.done(cmd, "%s %s is already up to date with %s", item.FullName, branch, item.Parent.FullName)
} else {
a.done(cmd, "Synced %s %s from %s (fast-forward %s..%s)", item.FullName, branch, item.Parent.FullName, shortSHA(result.Previous), shortSHA(result.Commit))
}
return a.writeMutationJSON(cmd, result)
}
// 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 {