mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 19:11: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
+576
-75
No files matched your search
@@ -505,12 +505,9 @@ const (
|
|||||||
release list
|
release list
|
||||||
release publish
|
release publish
|
||||||
release view
|
release view
|
||||||
repo clone
|
|
||||||
repo create
|
repo create
|
||||||
repo edit
|
repo edit
|
||||||
repo fork
|
|
||||||
repo migrate
|
repo migrate
|
||||||
repo set-default
|
|
||||||
search issue
|
search issue
|
||||||
search pr
|
search pr
|
||||||
status
|
status
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ func TestArgumentErrorsShowTheUsageLine(t *testing.T) {
|
|||||||
{[]string{"pr", "checkout"}, "missing pull request number\nUsage: tea pr checkout <number> [flags]"},
|
{[]string{"pr", "checkout"}, "missing pull request number\nUsage: tea pr checkout <number> [flags]"},
|
||||||
{[]string{"run", "view"}, "missing run ID\nUsage: tea run view [<run-id>] [flags]"},
|
{[]string{"run", "view"}, "missing run ID\nUsage: tea run view [<run-id>] [flags]"},
|
||||||
{[]string{"release", "asset", "download", "v1"}, "missing asset ID\nUsage: tea release asset download <tag> <asset-id> [flags]"},
|
{[]string{"release", "asset", "download", "v1"}, "missing asset ID\nUsage: tea release asset download <tag> <asset-id> [flags]"},
|
||||||
{[]string{"repo", "clone"}, "missing repository\nUsage: tea repo clone <repository> [directory] [flags]"},
|
{[]string{"repo", "clone"}, "missing repository\nUsage: tea repo clone <repository> [<directory>] [-- <gitflags>...] [flags]"},
|
||||||
} {
|
} {
|
||||||
requireUsageBeforeRequests(t, tc.want, tc.args...)
|
requireUsageBeforeRequests(t, tc.want, tc.args...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,3 +171,25 @@ func TestCloneAddsSourceRemoteForMirrors(t *testing.T) {
|
|||||||
require.Equal(t, []string{clone}, runner.calls, unsafe)
|
require.Equal(t, []string{clone}, runner.calls, unsafe)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestClonePassesGitFlagsAndNamesTheUpstreamRemote(t *testing.T) {
|
||||||
|
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = fmt.Fprintf(w, `{"name":"project","full_name":"bob/project","fork":true,"clone_url":"http://%[1]s/bob/project.git","parent":{"full_name":"alice/project","default_branch":"main","clone_url":"http://%[1]s/alice/project.git"}}`, r.Host)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
deps, _, diagnostics := repoTTYDeps(t, server.URL)
|
||||||
|
clone := credentialCall(t, deps, server.URL, "alice", "clone", "--branch", "dev", "--", server.URL+"/bob/project.git")
|
||||||
|
parent := server.URL + "/alice/project.git"
|
||||||
|
runner := &fakeGit{results: map[string]string{
|
||||||
|
clone: "", "remote add -- parent " + parent: "", "config branch.main.remote parent": "",
|
||||||
|
"config branch.main.merge refs/heads/main": "", "config branch.main.pushRemote origin": "",
|
||||||
|
}}
|
||||||
|
deps.Git = runner
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "clone", "bob/project", "-u", "parent", "--", "--branch", "dev"}))
|
||||||
|
require.Equal(t, []string{clone, "remote add -- parent " + parent, "config branch.main.remote parent", "config branch.main.merge refs/heads/main", "config branch.main.pushRemote origin"}, runner.calls)
|
||||||
|
require.Equal(t, "✓ Cloned bob/project into project\n✓ Added remote parent for alice/project; tea commands in the clone target it (change with tea repo set-default)\n", diagnostics.String())
|
||||||
|
runner.calls = nil
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "clone", "bob/project", "--no-upstream", "--", "--branch", "dev"}))
|
||||||
|
require.Equal(t, []string{clone}, runner.calls)
|
||||||
|
require.ErrorContains(t, Execute(context.Background(), deps, []string{"repo", "clone", "bob/project", "a", "b"}), "too many arguments")
|
||||||
|
}
|
||||||
+200
-45
@@ -4,6 +4,7 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
@@ -72,24 +73,7 @@ func mirrorSourceURL(item services.Repository) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *application) addRepositoryMutations(group *cobra.Command) {
|
func (a *application) addRepositoryMutations(group *cobra.Command) {
|
||||||
group.AddCommand(a.repoCreateCommand(), a.repoMigrateCommand())
|
group.AddCommand(a.repoCreateCommand(), a.repoMigrateCommand(), a.repoForkCommand(), a.repoCloneCommand())
|
||||||
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)
|
|
||||||
var description, repoName, website, defaultBranch string
|
var description, repoName, website, defaultBranch string
|
||||||
var private, template, repoArchived, actionsEnabled bool
|
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 {
|
edit := &cobra.Command{Use: "edit [repository]", Short: "Edit repository settings", Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
@@ -155,14 +139,45 @@ 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(&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)")
|
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())
|
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 {
|
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"))
|
return clierr.Wrap(clierr.Usage, errors.New("--depth must be nonnegative; zero fetches full history"))
|
||||||
}
|
}
|
||||||
if a.options.repo != "" {
|
if a.options.repo != "" {
|
||||||
return clierr.Wrap(clierr.Usage, errors.New("clone repository argument cannot be combined with --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]
|
a.options.repo = args[0]
|
||||||
t, err := a.resolve(cmd.Context(), true)
|
t, err := a.resolve(cmd.Context(), true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -172,25 +187,161 @@ func (a *application) addRepositoryMutations(group *cobra.Command) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
directory := ""
|
|
||||||
if len(args) == 2 {
|
if len(args) == 2 {
|
||||||
directory = args[1]
|
opts.directory = args[1]
|
||||||
}
|
}
|
||||||
if err := a.cloneRepository(cmd, t, item, directory, depth); err != nil {
|
if _, err := a.cloneRepository(cmd, t, item, opts); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if a.options.json != "" {
|
return a.writeMutationJSON(cmd, item)
|
||||||
return a.writeJSON(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 _, 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.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
|
return nil
|
||||||
}}
|
}
|
||||||
clone.Flags().IntVarP(&depth, "depth", "d", 0, "Number of commits to fetch (0: full history)")
|
|
||||||
group.AddCommand(clone)
|
// 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})
|
||||||
|
}
|
||||||
|
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.
|
// 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.
|
// It returns the path of the clone.
|
||||||
func (a *application) cloneRepository(cmd *cobra.Command, t target, item services.Repository, directory string, depth int) error {
|
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
|
protocol := t.gitProtocol
|
||||||
host := t.host
|
host := t.host
|
||||||
if protocol == "ssh" {
|
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}
|
hosts := map[string]settings.Host{t.Host: host}
|
||||||
remote, remoteIsSSH, err := cloneURLForProtocol(item.CloneURL, item.SSHURL, protocol)
|
remote, remoteIsSSH, err := cloneURLForProtocol(item.CloneURL, item.SSHURL, protocol)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
cloneHost, cloneRepo, err := settings.RepositoryURL(remote, hosts)
|
cloneHost, cloneRepo, err := settings.RepositoryURL(remote, hosts)
|
||||||
if err != nil || cloneHost != t.Host || !strings.EqualFold(cloneRepo, t.Repo) || (protocol == "ssh") != remoteIsSSH {
|
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")
|
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"}
|
gitArgs := []string{"clone"}
|
||||||
if depth > 0 {
|
if depth > 0 {
|
||||||
gitArgs = append(gitArgs, "--depth", fmt.Sprint(depth))
|
gitArgs = append(gitArgs, "--depth", fmt.Sprint(depth))
|
||||||
}
|
}
|
||||||
|
gitArgs = append(gitArgs, opts.gitFlags...)
|
||||||
gitArgs = append(gitArgs, "--", remote)
|
gitArgs = append(gitArgs, "--", remote)
|
||||||
if directory != "" {
|
if directory != "" {
|
||||||
gitArgs = append(gitArgs, 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)
|
credentialArgs, err := a.gitTransportArguments(t, remoteIsSSH)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
gitArgs = append(credentialArgs, gitArgs...)
|
gitArgs = append(credentialArgs, gitArgs...)
|
||||||
if _, err := a.git(cmd.Context(), gitArgs...); err != nil {
|
if _, err := a.git(cmd.Context(), gitArgs...); err != nil {
|
||||||
return err
|
return "", err
|
||||||
}
|
}
|
||||||
clone := directory
|
clone := directory
|
||||||
if !filepath.IsAbs(clone) {
|
if !filepath.IsAbs(clone) {
|
||||||
clone = filepath.Join(a.options.cwd, 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)
|
upstream, upstreamSSH, err := cloneURLForProtocol(item.Parent.CloneURL, item.Parent.SSHURL, protocol)
|
||||||
if err != nil {
|
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)
|
upstreamHost, upstreamRepo, err := settings.RepositoryURL(upstream, hosts)
|
||||||
if err != nil || upstreamHost != t.Host || !strings.EqualFold(upstreamRepo, item.Parent.FullName) || (protocol == "ssh") != upstreamSSH {
|
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 {
|
if _, err := a.gitIn(cmd.Context(), clone, "remote", "add", "--", opts.upstream, upstream); err != nil {
|
||||||
return fmt.Errorf("cloned the fork, but could not add the upstream remote: %w", err)
|
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.
|
// The default branch pulls from upstream, but pushes stay on the fork.
|
||||||
if branch := item.Parent.DefaultBranch; branch != "" {
|
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 {
|
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.
|
// The remote is named source because repository discovery prefers a remote named upstream.
|
||||||
if source := mirrorSourceURL(item); source != "" {
|
if source := mirrorSourceURL(item); source != "" {
|
||||||
if _, err := a.gitIn(cmd.Context(), clone, "remote", "add", "--", "source", source); err != nil {
|
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
|
// inferredRepository words where a repository that no argument or --repo named came from, as in
|
||||||
|
|||||||
@@ -159,6 +159,67 @@ func TestRenamedURL(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRepoForkReportsAnExistingFork(t *testing.T) {
|
||||||
|
var requests []string
|
||||||
|
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests = append(requests, r.Method+" "+r.URL.Path)
|
||||||
|
switch r.Method + " " + r.URL.Path {
|
||||||
|
case "POST /api/v1/repos/bob/project/forks":
|
||||||
|
w.WriteHeader(http.StatusConflict)
|
||||||
|
_, _ = w.Write([]byte(`{"message":"repository is already forked by user [uname: alice, repo path: bob/project, fork path: alice/project-fork]"}`))
|
||||||
|
case "GET /api/v1/repos/bob/project/forks":
|
||||||
|
if r.URL.Query().Get("page") != "1" {
|
||||||
|
_, _ = w.Write([]byte(`[]`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`[{"full_name":"carol/project","owner":{"login":"carol"}},{"full_name":"alice/project-fork","owner":{"login":"alice"}}]`))
|
||||||
|
case "GET /api/v1/repos/alice/project-fork":
|
||||||
|
_, _ = w.Write([]byte(`{"full_name":"alice/project-fork","fork":true,"html_url":"https://gitea.test/alice/project-fork","parent":{"full_name":"bob/project"}}`))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
deps, out, diagnostics := repoTTYDeps(t, server.URL)
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "fork", "bob/project"}))
|
||||||
|
require.Equal(t, "! alice/project-fork already exists\n", diagnostics.String())
|
||||||
|
require.Equal(t, "https://gitea.test/alice/project-fork\n", out.String())
|
||||||
|
require.Equal(t, "GET /api/v1/repos/alice/project-fork", requests[len(requests)-1])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoForkRefusesTheOwnersRepository(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)
|
||||||
|
err := Execute(context.Background(), deps, []string{"repo", "fork", "alice/project"})
|
||||||
|
require.Equal(t, clierr.Usage, clierr.ExitCode(err), "%v", err)
|
||||||
|
require.ErrorContains(t, err, "cannot fork alice/project into alice: it already owns it; pass --org")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoForkAddsTheForkAsOrigin(t *testing.T) {
|
||||||
|
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
require.Equal(t, "POST /api/v1/repos/bob/project/forks", r.Method+" "+r.URL.Path)
|
||||||
|
requirePayload(t, `{"name":"mine"}`, r.Body)
|
||||||
|
_, _ = fmt.Fprintf(w, `{"full_name":"alice/mine","html_url":"https://gitea.test/alice/mine","clone_url":"http://%s/alice/mine.git"}`, r.Host)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
deps, out, diagnostics := repoTTYDeps(t, server.URL)
|
||||||
|
fork := server.URL + "/alice/mine.git"
|
||||||
|
runner := &fakeGit{results: map[string]string{
|
||||||
|
"rev-parse --is-inside-work-tree": "true",
|
||||||
|
"remote get-url origin": server.URL + "/bob/project.git",
|
||||||
|
"remote rename origin upstream": "",
|
||||||
|
"remote add -- origin " + fork: "",
|
||||||
|
}}
|
||||||
|
deps.Git = runner
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "fork", "bob/project", "--fork-name", "mine", "--remote"}))
|
||||||
|
require.Equal(t, []string{"rev-parse --is-inside-work-tree", "remote get-url origin", "remote rename origin upstream", "remote add -- origin " + fork}, runner.calls)
|
||||||
|
require.Equal(t, "✓ Forked bob/project to alice/mine\n✓ Renamed Git remote origin to upstream\n✓ Added Git remote origin for alice/mine\n", diagnostics.String())
|
||||||
|
require.Equal(t, "https://gitea.test/alice/mine\n", out.String())
|
||||||
|
}
|
||||||
|
|
||||||
func TestRepoDeleteReportsTheDeletion(t *testing.T) {
|
func TestRepoDeleteReportsTheDeletion(t *testing.T) {
|
||||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
require.Equal(t, http.MethodDelete+" /api/v1/repos/alice/x", r.Method+" "+r.URL.Path)
|
require.Equal(t, http.MethodDelete+" /api/v1/repos/alice/x", r.Method+" "+r.URL.Path)
|
||||||
|
|||||||
+68
-7
@@ -51,20 +51,81 @@ func (a *application) repoCommand() *cobra.Command {
|
|||||||
Long: `Work with Gitea repositories: list and view them, create, fork, clone and migrate them, keep
|
Long: `Work with Gitea repositories: list and view them, create, fork, clone and migrate them, keep
|
||||||
local branches and forks in sync, and change their settings.`,
|
local branches and forks in sync, and change their settings.`,
|
||||||
}
|
}
|
||||||
group.AddCommand(a.repoListCommand(), a.repoViewCommand(), a.repositorySyncCommand())
|
group.AddCommand(a.repoListCommand(), a.repoViewCommand(), a.repositorySyncCommand(), a.repoSetDefaultCommand())
|
||||||
group.AddCommand(&cobra.Command{Use: "set-default [repository]", Short: "Save the selected repository in local Git configuration", Annotations: textOutput("set-default configures Git"), Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error {
|
a.addRepositoryMutations(group)
|
||||||
|
return group
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *application) repoSetDefaultCommand() *cobra.Command {
|
||||||
|
var view, unset bool
|
||||||
|
command := &cobra.Command{
|
||||||
|
Use: "set-default [<repository>]",
|
||||||
|
Short: "Set the default repository for this clone",
|
||||||
|
Long: `Set the repository that tea commands use in the current Git clone, instead of the one found through
|
||||||
|
its Git remotes. It is saved as tea.repository in the clone's Git configuration.
|
||||||
|
|
||||||
|
Without an argument, the repository that tea selects now is saved.`,
|
||||||
|
Example: `# Make tea commands in this clone use the fork's parent
|
||||||
|
$ tea repo set-default alice/project
|
||||||
|
|
||||||
|
# Show the saved repository
|
||||||
|
$ tea repo set-default --view
|
||||||
|
|
||||||
|
# Go back to the repository of the Git remotes
|
||||||
|
$ tea repo set-default --unset`,
|
||||||
|
Annotations: map[string]string{"tea.arguments": repositoryArguments, "tea.text": "set-default configures Git"},
|
||||||
|
Args: usageArgs(cobra.MaximumNArgs(1)),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
switch {
|
||||||
|
case view && unset:
|
||||||
|
return clierr.Wrap(clierr.Usage, errors.New("--view and --unset cannot be combined"))
|
||||||
|
case (view || unset) && len(args) > 0:
|
||||||
|
return clierr.Wrap(clierr.Usage, errors.New("--view and --unset take no repository"))
|
||||||
|
}
|
||||||
|
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, errors.New("set-default must run inside a Git clone"))
|
||||||
|
}
|
||||||
|
saved, _ := a.gitProbe().Output(ctx, a.options.cwd, false, "config", "--local", "--get", "tea.repository")
|
||||||
|
switch {
|
||||||
|
case view && saved == "":
|
||||||
|
_, err := fmt.Fprintln(cmd.ErrOrStderr(), "no default repository is set for this clone")
|
||||||
|
return err
|
||||||
|
case view:
|
||||||
|
_, err := fmt.Fprintln(cmd.OutOrStdout(), terminal.SafeLine(saved))
|
||||||
|
return err
|
||||||
|
case unset && saved == "":
|
||||||
|
a.warn(cmd, "No default repository is set for this clone")
|
||||||
|
return nil
|
||||||
|
case unset:
|
||||||
|
if _, err := a.git(ctx, "config", "--local", "--unset", "tea.repository"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.done(cmd, "Removed the default repository %s from this clone", saved)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
if err := a.repositoryArgument(args); err != nil {
|
if err := a.repositoryArgument(args); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
target, err := a.resolve(cmd.Context(), true)
|
t, err := a.resolve(ctx, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = a.git(cmd.Context(), "config", "--local", "tea.repository", target.Host+"/"+target.Repo)
|
item, err := (services.Repositories{Client: t.client}).View(ctx, t.Repo)
|
||||||
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}})
|
}
|
||||||
a.addRepositoryMutations(group)
|
if _, err := a.git(ctx, "config", "--local", "tea.repository", t.Host+"/"+item.FullName); err != nil {
|
||||||
return group
|
return err
|
||||||
|
}
|
||||||
|
a.done(cmd, "Set %s as the default repository for this clone", item.FullName)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
command.Flags().BoolVarP(&view, "view", "v", false, "Print the saved default repository")
|
||||||
|
command.Flags().BoolVarP(&unset, "unset", "u", false, "Remove the saved default repository")
|
||||||
|
command.ValidArgsFunction = a.completeRepos()
|
||||||
|
return command
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *application) repoListCommand() *cobra.Command {
|
func (a *application) repoListCommand() *cobra.Command {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -104,6 +105,44 @@ View this repository on Gitea: https://gitea.test/bob/project
|
|||||||
"clone:\thttps://gitea.test/bob/project.git\nssh:\t\nurl:\thttps://gitea.test/bob/project\n--\nHello **there**\n", out.String())
|
"clone:\thttps://gitea.test/bob/project.git\nssh:\t\nurl:\thttps://gitea.test/bob/project\n--\nHello **there**\n", out.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRepoSetDefaultValidatesTheRepository(t *testing.T) {
|
||||||
|
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/api/v1/repos/alice/project" {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"full_name":"alice/project"}`))
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
deps, _, diagnostics := repoTTYDeps(t, server.URL)
|
||||||
|
saved := "config --local tea.repository " + server.URL + "/alice/project"
|
||||||
|
runner := &fakeGit{results: map[string]string{"rev-parse --is-inside-work-tree": "true", saved: ""}}
|
||||||
|
deps.Git = runner
|
||||||
|
err := Execute(context.Background(), deps, []string{"repo", "set-default", "nobody/none"})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.NotContains(t, strings.Join(runner.calls, "\n"), "config --local tea.repository")
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "set-default", "alice/project"}))
|
||||||
|
require.Contains(t, runner.calls, saved)
|
||||||
|
require.Equal(t, "✓ Set alice/project as the default repository for this clone\n", diagnostics.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepoSetDefaultViewAndUnset(t *testing.T) {
|
||||||
|
deps, out, diagnostics := repoTTYDeps(t, "http://gitea.test")
|
||||||
|
runner := &fakeGit{results: map[string]string{
|
||||||
|
"rev-parse --is-inside-work-tree": "true",
|
||||||
|
"config --local --get tea.repository": "http://gitea.test/alice/project",
|
||||||
|
"config --local --unset tea.repository": "",
|
||||||
|
}}
|
||||||
|
deps.Git = runner
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "set-default", "--view"}))
|
||||||
|
require.Equal(t, "http://gitea.test/alice/project\n", out.String())
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "set-default", "-u"}))
|
||||||
|
require.Equal(t, "✓ Removed the default repository http://gitea.test/alice/project from this clone\n", diagnostics.String())
|
||||||
|
deps.Git = &fakeGit{}
|
||||||
|
err := Execute(context.Background(), deps, []string{"repo", "set-default", "-v"})
|
||||||
|
require.ErrorContains(t, err, "set-default must run inside a Git clone")
|
||||||
|
}
|
||||||
|
|
||||||
func TestRepoViewOfAnEmptyRepositorySkipsTheReadme(t *testing.T) {
|
func TestRepoViewOfAnEmptyRepositorySkipsTheReadme(t *testing.T) {
|
||||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
require.Equal(t, "/api/v1/repos/alice/empty", r.URL.Path)
|
require.Equal(t, "/api/v1/repos/alice/empty", r.URL.Path)
|
||||||
|
|||||||
@@ -140,7 +140,8 @@ func (a *application) repoCreateCommand() *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
target.Repo = item.FullName
|
target.Repo = item.FullName
|
||||||
return a.cloneRepository(cmd, target, item, "", 0)
|
_, err = a.cloneRepository(cmd, target, item, cloneOptions{upstream: "upstream"})
|
||||||
|
return err
|
||||||
}}
|
}}
|
||||||
flags := command.Flags()
|
flags := command.Flags()
|
||||||
flags.StringVar(&opts.Name, "name", "", "Repository name")
|
flags.StringVar(&opts.Name, "name", "", "Repository name")
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.dev/tea/internal/clierr"
|
"gitea.dev/tea/internal/clierr"
|
||||||
@@ -18,17 +20,24 @@ func (a *application) repositorySyncCommand() *cobra.Command {
|
|||||||
var branch string
|
var branch string
|
||||||
command := &cobra.Command{
|
command := &cobra.Command{
|
||||||
Use: "sync [<repository>]",
|
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.
|
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
|
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
|
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
|
Example: `# Fast-forward the default branch from the repository's remote
|
||||||
$ tea repo sync
|
$ tea repo sync
|
||||||
|
|
||||||
# Fast-forward another branch
|
# Fast-forward another branch
|
||||||
$ tea repo sync --branch release/1.0`,
|
$ 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)),
|
Args: usageArgs(cobra.MaximumNArgs(1)),
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
if cmd.Flags().Changed("branch") && strings.TrimSpace(branch) == "" {
|
if cmd.Flags().Changed("branch") && strings.TrimSpace(branch) == "" {
|
||||||
@@ -41,18 +50,23 @@ $ tea repo sync --branch release/1.0`,
|
|||||||
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)
|
inside, _ := a.gitProbe().Output(cmd.Context(), a.options.cwd, false, "rev-parse", "--is-inside-work-tree")
|
||||||
if errors.Is(err, errNoMatchingRemote) {
|
remote, 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 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)
|
return clierr.Wrap(clierr.Usage, err)
|
||||||
}
|
}
|
||||||
if branch == "" {
|
if branch == "" {
|
||||||
@@ -90,6 +104,46 @@ $ tea repo sync --branch release/1.0`,
|
|||||||
return command
|
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.
|
// 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) {
|
func (a *application) reportSync(cmd *cobra.Command, result git.SyncResult, source string) {
|
||||||
switch result.Previous {
|
switch result.Previous {
|
||||||
|
|||||||
@@ -62,6 +62,62 @@ func TestRepoSyncFastForwardsWithoutSwitchingBranches(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRepoSyncUpdatesAForkOrMirrorOnTheServer(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, repo, feedback string
|
||||||
|
requests []string
|
||||||
|
json string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "fork", repo: `{"full_name":"bob/project","fork":true,"default_branch":"main","parent":{"full_name":"alice/project"}}`,
|
||||||
|
requests: []string{"GET /api/v1/repos/bob/project", "GET /api/v1/repos/bob/project/branches/main", "POST /api/v1/repos/bob/project/merge-upstream", "GET /api/v1/repos/bob/project/branches/main"},
|
||||||
|
feedback: "✓ Synced bob/project main from alice/project (fast-forward 1111111..2222222)\n",
|
||||||
|
json: `{"branch":"main","commit":"2222222bbbb","previous":"1111111aaaa","remote":"alice/project"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mirror", repo: `{"full_name":"bob/project","mirror":true,"original_url":"https://example.test/source.git"}`,
|
||||||
|
requests: []string{"GET /api/v1/repos/bob/project", "POST /api/v1/repos/bob/project/mirror-sync"},
|
||||||
|
feedback: "✓ Started updating the mirror bob/project from its source\n",
|
||||||
|
json: `{"branch":"","commit":"","previous":"","remote":"https://example.test/source.git"}`,
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var requests []string
|
||||||
|
synced := false
|
||||||
|
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requests = append(requests, r.Method+" "+r.URL.Path)
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/api/v1/repos/bob/project":
|
||||||
|
_, _ = w.Write([]byte(test.repo))
|
||||||
|
case "/api/v1/repos/bob/project/branches/main":
|
||||||
|
commit := "1111111aaaa"
|
||||||
|
if synced {
|
||||||
|
commit = "2222222bbbb"
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(`{"name":"main","commit":{"id":"` + commit + `"}}`))
|
||||||
|
case "/api/v1/repos/bob/project/merge-upstream":
|
||||||
|
requirePayload(t, `{"branch":"main","ff_only":true}`, r.Body)
|
||||||
|
synced = true
|
||||||
|
_, _ = w.Write([]byte(`{"merge_type":"fast-forward"}`))
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
deps, out, diagnostics := repoTTYDeps(t, server.URL)
|
||||||
|
deps.Git = &syncGit{}
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "sync", "bob/project"}))
|
||||||
|
require.Equal(t, test.requests, requests)
|
||||||
|
require.Equal(t, test.feedback, diagnostics.String())
|
||||||
|
require.Empty(t, out.String())
|
||||||
|
deps, out, _ = serverDeps(t, server.URL)
|
||||||
|
deps.Git, synced, requests = &syncGit{}, false, nil
|
||||||
|
require.NoError(t, Execute(context.Background(), deps, []string{"repo", "sync", "bob/project", "--json"}))
|
||||||
|
require.JSONEq(t, test.json, out.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRepoSyncOutsideACloneExplainsWhatItNeeds(t *testing.T) {
|
func TestRepoSyncOutsideACloneExplainsWhatItNeeds(t *testing.T) {
|
||||||
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
|
||||||
@@ -71,5 +127,5 @@ func TestRepoSyncOutsideACloneExplainsWhatItNeeds(t *testing.T) {
|
|||||||
deps.Git = &syncGit{}
|
deps.Git = &syncGit{}
|
||||||
err := Execute(context.Background(), deps, []string{"repo", "sync", "-R", "alice/project", "-b", "main"})
|
err := Execute(context.Background(), deps, []string{"repo", "sync", "-R", "alice/project", "-b", "main"})
|
||||||
require.Equal(t, clierr.Usage, clierr.ExitCode(err), "%v", err)
|
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")
|
require.ErrorContains(t, err, "repo sync needs a Git clone of alice/project; run it inside one, or pass a fork to update it on the server")
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ func SyncBranch(ctx context.Context, runner Runner, opts SyncOptions) (SyncResul
|
|||||||
return SyncResult{}, err
|
return SyncResult{}, err
|
||||||
}
|
}
|
||||||
fetch := append([]string{}, opts.CredentialArgs...)
|
fetch := append([]string{}, opts.CredentialArgs...)
|
||||||
fetch = append(fetch, "fetch", "--no-tags", "--", opts.Remote, ref)
|
fetch = append(fetch, "fetch", "--quiet", "--no-tags", "--", opts.Remote, ref)
|
||||||
if _, err := run(fetch...); err != nil {
|
if _, err := run(fetch...); err != nil {
|
||||||
return SyncResult{}, err
|
return SyncResult{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,13 +184,67 @@ func (s Repositories) Delete(ctx context.Context, repo string) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fork creates a fork for the current user or selected organization.
|
// ForkOptions places a fork: under Organization instead of the account, and named Name instead
|
||||||
func (s Repositories) Fork(ctx context.Context, repo, org string) (Repository, error) {
|
// of the forked repository's name.
|
||||||
|
type ForkOptions struct{ Organization, Name string }
|
||||||
|
|
||||||
|
// Fork forks repo for the account or the organization. When that owner already has a fork of
|
||||||
|
// repo, Fork returns it and reports that it existed.
|
||||||
|
func (s Repositories) Fork(ctx context.Context, repo string, opts ForkOptions) (Repository, bool, error) {
|
||||||
var options gitea.CreateForkOption
|
var options gitea.CreateForkOption
|
||||||
if org != "" {
|
if opts.Organization != "" {
|
||||||
options.Organization = &org
|
options.Organization = &opts.Organization
|
||||||
}
|
}
|
||||||
return repoConvert(ctx, s.Client, repo, repositoryFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) {
|
if opts.Name != "" {
|
||||||
|
options.Name = &opts.Name
|
||||||
|
}
|
||||||
|
fork, err := repoConvert(ctx, s.Client, repo, repositoryFrom, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Repository, *gitea.Response, error) {
|
||||||
return c.Repositories.CreateFork(ctx, owner, name, options)
|
return c.Repositories.CreateFork(ctx, owner, name, options)
|
||||||
})
|
})
|
||||||
|
apiErr, ok := errors.AsType[*transport.APIError](err)
|
||||||
|
if !ok || apiErr.Status != http.StatusConflict || !strings.Contains(apiErr.Message, "already forked") {
|
||||||
|
return fork, false, err
|
||||||
|
}
|
||||||
|
owner := opts.Organization
|
||||||
|
if owner == "" {
|
||||||
|
if owner, err = CurrentAccount(ctx, s.Client); err != nil {
|
||||||
|
return Repository{}, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
forks, listErr := listAll(func(page int) ([]*gitea.Repository, error) {
|
||||||
|
return repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) ([]*gitea.Repository, *gitea.Response, error) {
|
||||||
|
return c.Repositories.ListForks(ctx, owner, name, gitea.ListForksOptions{ListOptions: listOptions(50, page)})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
if listErr != nil {
|
||||||
|
return Repository{}, false, listErr
|
||||||
|
}
|
||||||
|
for _, existing := range forks {
|
||||||
|
if existing.Owner != nil && strings.EqualFold(existing.Owner.UserName, owner) {
|
||||||
|
// The fork list omits the parent that a fork's own record has.
|
||||||
|
fork, err := s.View(ctx, existing.FullName)
|
||||||
|
return fork, err == nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Repository{}, false, apiErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeUpstream fast-forwards branch of the fork repo to its parent's branch on the server. It
|
||||||
|
// returns how Gitea updated the branch: "fast-forward", or "up-to-date" when there was nothing to do.
|
||||||
|
func (s Repositories) MergeUpstream(ctx context.Context, repo, branch string) (string, error) {
|
||||||
|
result, err := repoCall(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.MergeUpstreamResponse, *gitea.Response, error) {
|
||||||
|
return c.Repositories.MergeUpstream(ctx, owner, name, gitea.MergeUpstreamRequest{Branch: branch, FfOnly: true})
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return result.MergeStyle, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MirrorSync asks the server to update the pull mirror repo from its source; the update runs in
|
||||||
|
// the background.
|
||||||
|
func (s Repositories) MirrorSync(ctx context.Context, repo string) error {
|
||||||
|
return repoDo(ctx, s.Client, repo, func(ctx context.Context, c *gitea.Client, owner, name string) (*gitea.Response, error) {
|
||||||
|
return c.Repositories.MirrorSync(ctx, owner, name)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
@@ -44,8 +44,9 @@ func TestRepositoryForkSendsOrganizationOnlyWhenSelected(t *testing.T) {
|
|||||||
w.WriteHeader(http.StatusAccepted)
|
w.WriteHeader(http.StatusAccepted)
|
||||||
_, _ = w.Write([]byte(`{"id":2,"full_name":"bob/project","fork":true,"parent":{"id":1,"full_name":"alice/project"}}`))
|
_, _ = w.Write([]byte(`{"id":2,"full_name":"bob/project","fork":true,"parent":{"id":1,"full_name":"alice/project"}}`))
|
||||||
}))
|
}))
|
||||||
item, err := (Repositories{Client: client}).Fork(context.Background(), "alice/project", org)
|
item, existed, err := (Repositories{Client: client}).Fork(context.Background(), "alice/project", ForkOptions{Organization: org})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
require.False(t, existed)
|
||||||
require.Equal(t, "alice/project", item.Parent.FullName)
|
require.Equal(t, "alice/project", item.Parent.FullName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user