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

+217 -62
View File
@@ -4,6 +4,7 @@
package app
import (
"cmp"
"errors"
"fmt"
"net/url"
@@ -72,24 +73,7 @@ func mirrorSourceURL(item services.Repository) string {
}
func (a *application) addRepositoryMutations(group *cobra.Command) {
group.AddCommand(a.repoCreateCommand(), a.repoMigrateCommand())
var forkOrg string
fork := &cobra.Command{Use: "fork [repository]", Short: "Fork the selected repository", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
if err := a.repositoryArgument(args); err != nil {
return err
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
item, err := (services.Repositories{Client: t.client}).Fork(cmd.Context(), t.Repo, forkOrg)
if err != nil {
return err
}
return writeRecord(a, cmd, item, repositoryRecord)
}}
fork.Flags().StringVar(&forkOrg, "org", "", "Fork under an organization")
group.AddCommand(fork)
group.AddCommand(a.repoCreateCommand(), a.repoMigrateCommand(), a.repoForkCommand(), a.repoCloneCommand())
var description, repoName, website, defaultBranch string
var private, template, repoArchived, actionsEnabled bool
edit := &cobra.Command{Use: "edit [repository]", Short: "Edit repository settings", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
@@ -155,42 +139,209 @@ func (a *application) addRepositoryMutations(group *cobra.Command) {
edit.Flags().BoolVar(&template, "as-template", false, "Make repository a template (use =false to disable)")
edit.Flags().BoolVar(&actionsEnabled, "actions", false, "Enable Actions (use --actions=false to disable)")
group.AddCommand(edit, a.repoArchiveCommand(true), a.repoArchiveCommand(false), a.repoRenameCommand(), a.repoDeleteCommand())
var depth int
clone := &cobra.Command{Use: "clone <repository> [directory]", Short: "Clone a repository using the configured Git protocol", Args: usageArgs(cobra.RangeArgs(1, 2)), RunE: func(cmd *cobra.Command, args []string) error {
if depth < 0 {
return clierr.Wrap(clierr.Usage, errors.New("--depth must be nonnegative; zero fetches full history"))
}
func (a *application) repoCloneCommand() *cobra.Command {
var opts cloneOptions
var noUpstream bool
command := &cobra.Command{
Use: "clone <repository> [<directory>] [-- <gitflags>...]",
Short: "Clone a repository locally",
Long: `Clone a repository with the configured Git protocol (git_protocol: https or ssh) and tea's
credentials. Git flags can follow --.
A fork's parent is added as the "upstream" remote, which its default branch pulls from while pushes
stay on the fork; tea commands in the clone then target the parent. A pull mirror's source is added
as the "source" remote.`,
Example: `# Clone a repository into ./project
$ tea repo clone alice/project
# Clone into another directory with the last commit only
$ tea repo clone alice/project src --depth 1
# Pass flags to git clone
$ tea repo clone alice/project -- --branch develop --recurse-submodules`,
Annotations: map[string]string{"tea.arguments": repositoryArguments},
Args: argsBeforeGitFlags(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
at := argsBeforeDash(cmd, args)
args, opts.gitFlags = args[:at], args[at:]
if opts.depth < 0 {
return clierr.Wrap(clierr.Usage, errors.New("--depth must be nonnegative; zero fetches full history"))
}
if a.options.repo != "" {
return clierr.Wrap(clierr.Usage, errors.New("clone repository argument cannot be combined with --repo"))
}
if noUpstream {
if cmd.Flags().Changed("upstream-remote-name") {
return clierr.Wrap(clierr.Usage, errors.New("--no-upstream and --upstream-remote-name cannot be combined"))
}
opts.upstream = ""
}
a.options.repo = args[0]
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
item, err := (services.Repositories{Client: t.client}).View(cmd.Context(), t.Repo)
if err != nil {
return err
}
if len(args) == 2 {
opts.directory = args[1]
}
if _, err := a.cloneRepository(cmd, t, item, opts); err != nil {
return err
}
return a.writeMutationJSON(cmd, item)
},
}
command.Flags().IntVarP(&opts.depth, "depth", "d", 0, "Number of commits to fetch (0: full history)")
command.Flags().StringVarP(&opts.upstream, "upstream-remote-name", "u", "upstream", "Remote name for the parent of a fork")
command.Flags().BoolVar(&noUpstream, "no-upstream", false, "Do not add a remote for the parent of a fork")
command.ValidArgsFunction = a.completeRepos()
return command
}
func (a *application) repoForkCommand() *cobra.Command {
var opts services.ForkOptions
var clone, addRemote bool
var remoteName string
command := &cobra.Command{
Use: "fork [<repository>] [-- <gitflags>...]",
Short: "Fork a repository",
Long: `Fork a repository for your account, or into an organization with --org. When the account or
organization already has a fork, tea reports it instead of failing.
With --clone, the fork is cloned, and its parent is added as the "upstream" remote; Git flags can
follow --. Inside a clone of the repository, --remote adds the fork as the "origin" remote (or
--remote-name), renaming the current "origin" to "upstream".`,
Example: `# Fork the repository of the current clone and add the fork as origin
$ tea repo fork --remote
# Fork a repository into an organization under another name
$ tea repo fork alice/project --org acme --fork-name project-fork
# Fork and clone a repository
$ tea repo fork alice/project --clone -- --depth 1`,
Annotations: map[string]string{"tea.arguments": repositoryArguments},
Args: argsBeforeGitFlags(0, 1),
RunE: func(cmd *cobra.Command, args []string) error {
at := argsBeforeDash(cmd, args)
args, gitFlags := args[:at], args[at:]
switch {
case clone && addRemote:
return clierr.Wrap(clierr.Usage, errors.New("--clone and --remote cannot be combined"))
case len(gitFlags) > 0 && !clone:
return clierr.Wrap(clierr.Usage, errors.New("Git flags after -- require --clone"))
case cmd.Flags().Changed("remote-name") && !addRemote:
return clierr.Wrap(clierr.Usage, errors.New("--remote-name requires --remote"))
}
if err := a.repositoryArgument(args); err != nil {
return err
}
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
}
owner, _, _ := strings.Cut(t.Repo, "/")
if strings.EqualFold(owner, cmp.Or(opts.Organization, t.User)) {
return clierr.Wrap(clierr.Usage, fmt.Errorf("cannot fork %s into %s: it already owns it; pass --org to fork it into an organization", t.Repo, owner))
}
fork, existed, err := (services.Repositories{Client: t.client}).Fork(cmd.Context(), t.Repo, opts)
if err != nil {
return err
}
if existed {
a.warn(cmd, "%s already exists", fork.FullName)
} else {
a.done(cmd, "Forked %s to %s", t.Repo, fork.FullName)
}
if clone {
if _, err := a.cloneRepository(cmd, t, fork, cloneOptions{gitFlags: gitFlags, upstream: "upstream"}); err != nil {
return err
}
}
if addRemote {
if err := a.addForkRemote(cmd, t, fork, remoteName); err != nil {
return err
}
}
if a.options.json != "" {
return a.writeJSON(cmd, fork)
}
return a.printURL(cmd, fork.URL)
},
}
command.Flags().StringVar(&opts.Organization, "org", "", "Fork into this organization")
command.Flags().StringVar(&opts.Name, "fork-name", "", "Name of the fork (default: the repository's name)")
command.Flags().BoolVar(&clone, "clone", false, "Clone the fork")
command.Flags().BoolVar(&addRemote, "remote", false, "Add the fork as a remote of the current clone")
command.Flags().StringVar(&remoteName, "remote-name", "origin", "Name of the fork's remote")
_ = command.RegisterFlagCompletionFunc("org", a.completeOrgs())
command.ValidArgsFunction = a.completeRepos()
return command
}
// addForkRemote adds fork as the remote name of the clone in the working directory. A remote of
// that name that points to the forked repository is renamed to upstream first.
func (a *application) addForkRemote(cmd *cobra.Command, t target, fork services.Repository, name string) error {
ctx := cmd.Context()
if inside, _ := a.gitProbe().Output(ctx, a.options.cwd, false, "rev-parse", "--is-inside-work-tree"); inside != "true" {
return clierr.Wrap(clierr.Usage, fmt.Errorf("forked to %s, but --remote must run inside a Git clone of %s", fork.FullName, t.Repo))
}
if existing, err := a.gitProbe().Output(ctx, a.options.cwd, false, "remote", "get-url", name); err == nil {
if !t.ownsRemote(existing) {
return clierr.Wrap(clierr.Usage, fmt.Errorf("forked to %s, but Git remote %s points elsewhere; pass --remote-name", fork.FullName, name))
}
if a.options.repo != "" {
return clierr.Wrap(clierr.Usage, errors.New("clone repository argument cannot be combined with --repo"))
if _, err := a.git(ctx, "remote", "rename", name, "upstream"); err != nil {
return fmt.Errorf("forked to %s, but could not rename Git remote %s to upstream: %w", fork.FullName, name, err)
}
a.options.repo = args[0]
t, err := a.resolve(cmd.Context(), true)
if err != nil {
return err
a.done(cmd, "Renamed Git remote %s to upstream", name)
}
url, _, err := cloneURLForProtocol(fork.CloneURL, fork.SSHURL, t.gitProtocol)
if err != nil {
return err
}
if _, err := a.git(ctx, "remote", "add", "--", name, url); err != nil {
return fmt.Errorf("forked to %s, but could not add Git remote %s: %w", fork.FullName, name, err)
}
a.done(cmd, "Added Git remote %s for %s", name, fork.FullName)
return nil
}
// argsBeforeGitFlags accepts between minimum and maximum arguments before --, and any Git flags after it.
func argsBeforeGitFlags(minimum, maximum int) cobra.PositionalArgs {
return func(cmd *cobra.Command, args []string) error {
if args = args[:argsBeforeDash(cmd, args)]; len(args) > maximum {
return clierr.Wrap(clierr.Usage, &argumentError{problem: "too many arguments", cmd: cmd})
}
item, err := (services.Repositories{Client: t.client}).View(cmd.Context(), t.Repo)
if err != nil {
return err
}
directory := ""
if len(args) == 2 {
directory = args[1]
}
if err := a.cloneRepository(cmd, t, item, directory, depth); err != nil {
return err
}
if a.options.json != "" {
return a.writeJSON(cmd, item)
}
return nil
}}
clone.Flags().IntVarP(&depth, "depth", "d", 0, "Number of commits to fetch (0: full history)")
group.AddCommand(clone)
return usageArgs(cobra.MinimumNArgs(minimum))(cmd, args)
}
}
// argsBeforeDash counts the arguments before --, which belong to tea; the rest go to Git.
func argsBeforeDash(cmd *cobra.Command, args []string) int {
if at := cmd.ArgsLenAtDash(); at >= 0 {
return at
}
return len(args)
}
// cloneOptions are the choices of repo clone, and of repo create and repo fork with --clone.
type cloneOptions struct {
// directory is the clone's path; empty lets Git name it after the repository.
directory string
depth int
gitFlags []string
// upstream names the remote of a fork's parent; empty adds none.
upstream string
}
// cloneRepository clones item with the configured Git protocol and adds fork and mirror remotes.
// An empty directory lets Git choose it from the repository name.
func (a *application) cloneRepository(cmd *cobra.Command, t target, item services.Repository, directory string, depth int) error {
// It returns the path of the clone.
func (a *application) cloneRepository(cmd *cobra.Command, t target, item services.Repository, opts cloneOptions) (string, error) {
directory, depth := opts.directory, opts.depth
protocol := t.gitProtocol
host := t.host
if protocol == "ssh" {
@@ -199,16 +350,17 @@ func (a *application) cloneRepository(cmd *cobra.Command, t target, item service
hosts := map[string]settings.Host{t.Host: host}
remote, remoteIsSSH, err := cloneURLForProtocol(item.CloneURL, item.SSHURL, protocol)
if err != nil {
return err
return "", err
}
cloneHost, cloneRepo, err := settings.RepositoryURL(remote, hosts)
if err != nil || cloneHost != t.Host || !strings.EqualFold(cloneRepo, t.Repo) || (protocol == "ssh") != remoteIsSSH {
return errors.New("Gitea clone URL does not match the selected instance and repository; check the server root URL and configured ssh_host")
if err != nil || cloneHost != t.Host || !strings.EqualFold(cloneRepo, item.FullName) || (protocol == "ssh") != remoteIsSSH {
return "", errors.New("Gitea clone URL does not match the selected instance and repository; check the server root URL and configured ssh_host")
}
gitArgs := []string{"clone"}
if depth > 0 {
gitArgs = append(gitArgs, "--depth", fmt.Sprint(depth))
}
gitArgs = append(gitArgs, opts.gitFlags...)
gitArgs = append(gitArgs, "--", remote)
if directory != "" {
gitArgs = append(gitArgs, directory)
@@ -217,44 +369,47 @@ func (a *application) cloneRepository(cmd *cobra.Command, t target, item service
}
credentialArgs, err := a.gitTransportArguments(t, remoteIsSSH)
if err != nil {
return err
return "", err
}
gitArgs = append(credentialArgs, gitArgs...)
if _, err := a.git(cmd.Context(), gitArgs...); err != nil {
return err
return "", err
}
clone := directory
if !filepath.IsAbs(clone) {
clone = filepath.Join(a.options.cwd, clone)
}
if item.Fork && item.Parent != nil {
a.done(cmd, "Cloned %s into %s", item.FullName, directory)
if item.Fork && item.Parent != nil && opts.upstream != "" {
upstream, upstreamSSH, err := cloneURLForProtocol(item.Parent.CloneURL, item.Parent.SSHURL, protocol)
if err != nil {
return errors.New("cloned the fork, but Gitea did not return a parent clone URL")
return clone, errors.New("cloned the fork, but Gitea did not return a parent clone URL")
}
upstreamHost, upstreamRepo, err := settings.RepositoryURL(upstream, hosts)
if err != nil || upstreamHost != t.Host || !strings.EqualFold(upstreamRepo, item.Parent.FullName) || (protocol == "ssh") != upstreamSSH {
return errors.New("cloned the fork, but its parent clone URL does not match the selected instance")
return clone, errors.New("cloned the fork, but its parent clone URL does not match the selected instance")
}
if _, err := a.gitIn(cmd.Context(), clone, "remote", "add", "--", "upstream", upstream); err != nil {
return fmt.Errorf("cloned the fork, but could not add the upstream remote: %w", err)
if _, err := a.gitIn(cmd.Context(), clone, "remote", "add", "--", opts.upstream, upstream); err != nil {
return clone, fmt.Errorf("cloned the fork, but could not add the %s remote: %w", opts.upstream, err)
}
// The default branch pulls from upstream, but pushes stay on the fork.
if branch := item.Parent.DefaultBranch; branch != "" {
for _, setting := range [][2]string{{"remote", "upstream"}, {"merge", "refs/heads/" + branch}, {"pushRemote", "origin"}} {
for _, setting := range [][2]string{{"remote", opts.upstream}, {"merge", "refs/heads/" + branch}, {"pushRemote", "origin"}} {
if _, err := a.gitIn(cmd.Context(), clone, "config", "branch."+branch+"."+setting[0], setting[1]); err != nil {
return fmt.Errorf("cloned the fork and added upstream, but could not configure branch tracking: %w", err)
return clone, fmt.Errorf("cloned the fork and added %s, but could not configure branch tracking: %w", opts.upstream, err)
}
}
}
a.done(cmd, "Added remote %s for %s; tea commands in the clone target it (change with tea repo set-default)", opts.upstream, item.Parent.FullName)
}
// The remote is named source because repository discovery prefers a remote named upstream.
if source := mirrorSourceURL(item); source != "" {
if _, err := a.gitIn(cmd.Context(), clone, "remote", "add", "--", "source", source); err != nil {
return fmt.Errorf("cloned the mirror, but could not add the source remote: %w", err)
return clone, fmt.Errorf("cloned the mirror, but could not add the source remote: %w", err)
}
a.done(cmd, "Added remote source for the mirror's source")
}
return nil
return clone, nil
}
// inferredRepository words where a repository that no argument or --repo named came from, as in