mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
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:
13 files changed
+599
-98
No files matched your search
+217
-62
@@ -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
|
||||
|
||||
Reference in new issue
Block a user