diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 84c02158..c0f3a092 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -505,12 +505,9 @@ const ( release list release publish release view - repo clone repo create repo edit - repo fork repo migrate - repo set-default search issue search pr status diff --git a/internal/app/args_test.go b/internal/app/args_test.go index b62cef56..a2b5aa64 100644 --- a/internal/app/args_test.go +++ b/internal/app/args_test.go @@ -91,7 +91,7 @@ func TestArgumentErrorsShowTheUsageLine(t *testing.T) { {[]string{"pr", "checkout"}, "missing pull request number\nUsage: tea pr checkout [flags]"}, {[]string{"run", "view"}, "missing run ID\nUsage: tea run view [] [flags]"}, {[]string{"release", "asset", "download", "v1"}, "missing asset ID\nUsage: tea release asset download [flags]"}, - {[]string{"repo", "clone"}, "missing repository\nUsage: tea repo clone [directory] [flags]"}, + {[]string{"repo", "clone"}, "missing repository\nUsage: tea repo clone [] [-- ...] [flags]"}, } { requireUsageBeforeRequests(t, tc.want, tc.args...) } diff --git a/internal/app/clone_test.go b/internal/app/clone_test.go index 15d4f886..934176dc 100644 --- a/internal/app/clone_test.go +++ b/internal/app/clone_test.go @@ -171,3 +171,25 @@ func TestCloneAddsSourceRemoteForMirrors(t *testing.T) { 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") +} diff --git a/internal/app/repo_mutations.go b/internal/app/repo_mutations.go index 59ce7e75..6294c1b4 100644 --- a/internal/app/repo_mutations.go +++ b/internal/app/repo_mutations.go @@ -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 [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 [] [-- ...]", + 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 [] [-- ...]", + 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 diff --git a/internal/app/repo_mutations_test.go b/internal/app/repo_mutations_test.go index d848bc64..6c23e720 100644 --- a/internal/app/repo_mutations_test.go +++ b/internal/app/repo_mutations_test.go @@ -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) { 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) diff --git a/internal/app/repos.go b/internal/app/repos.go index 563cc461..6924a57a 100644 --- a/internal/app/repos.go +++ b/internal/app/repos.go @@ -51,22 +51,83 @@ func (a *application) repoCommand() *cobra.Command { 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.`, } - group.AddCommand(a.repoListCommand(), a.repoViewCommand(), a.repositorySyncCommand()) - 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 { - if err := a.repositoryArgument(args); err != nil { - return err - } - target, err := a.resolve(cmd.Context(), true) - if err != nil { - return err - } - _, err = a.git(cmd.Context(), "config", "--local", "tea.repository", target.Host+"/"+target.Repo) - return err - }}) + group.AddCommand(a.repoListCommand(), a.repoViewCommand(), a.repositorySyncCommand(), a.repoSetDefaultCommand()) a.addRepositoryMutations(group) return group } +func (a *application) repoSetDefaultCommand() *cobra.Command { + var view, unset bool + command := &cobra.Command{ + Use: "set-default []", + 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 { + return err + } + t, err := a.resolve(ctx, true) + if err != nil { + return err + } + item, err := (services.Repositories{Client: t.client}).View(ctx, t.Repo) + if err != nil { + return err + } + if _, err := a.git(ctx, "config", "--local", "tea.repository", t.Host+"/"+item.FullName); err != nil { + 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 { var opts services.RepositoryListOptions list := &cobra.Command{ diff --git a/internal/app/repos_test.go b/internal/app/repos_test.go index 94e34567..17c6cff8 100644 --- a/internal/app/repos_test.go +++ b/internal/app/repos_test.go @@ -7,6 +7,7 @@ import ( "bytes" "context" "net/http" + "strings" "testing" "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()) } +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) { server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { require.Equal(t, "/api/v1/repos/alice/empty", r.URL.Path) diff --git a/internal/app/repository_create.go b/internal/app/repository_create.go index 24e7baef..4efd78be 100644 --- a/internal/app/repository_create.go +++ b/internal/app/repository_create.go @@ -140,7 +140,8 @@ func (a *application) repoCreateCommand() *cobra.Command { return err } 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.StringVar(&opts.Name, "name", "", "Repository name") diff --git a/internal/app/repository_sync.go b/internal/app/repository_sync.go index 3e6474d4..4ef46306 100644 --- a/internal/app/repository_sync.go +++ b/internal/app/repository_sync.go @@ -4,7 +4,9 @@ package app import ( + "cmp" "errors" + "fmt" "strings" "gitea.dev/tea/internal/clierr" @@ -18,18 +20,25 @@ func (a *application) repositorySyncCommand() *cobra.Command { var branch string command := &cobra.Command{ Use: "sync []", - Short: "Sync a local branch with its remote", + Short: "Sync a branch with its remote or its fork's parent", Long: `Fast-forward a local branch to the branch of the same name on the repository's Git remote. The branch defaults to the repository's default branch. It is created when it is missing, and it is never checked out: the branch you are on stays checked out. When the branch is checked out, the -working tree must be clean. Branches that have diverged from the remote are left unchanged.`, +working tree must be clean. Branches that have diverged from the remote are left unchanged. + +Given a repository that has no remote in the current directory, sync updates it on the server +instead: a fork's branch is fast-forwarded to its parent's, and a pull mirror fetches its source.`, Example: `# Fast-forward the default branch from the repository's remote $ tea repo sync # Fast-forward another branch -$ tea repo sync --branch release/1.0`, - Args: usageArgs(cobra.MaximumNArgs(1)), +$ tea repo sync --branch release/1.0 + +# Update a fork on the server from its parent +$ tea repo sync bob/project`, + Annotations: map[string]string{"tea.arguments": repositoryArguments}, + Args: usageArgs(cobra.MaximumNArgs(1)), RunE: func(cmd *cobra.Command, args []string) error { if cmd.Flags().Changed("branch") && strings.TrimSpace(branch) == "" { return clierr.Wrap(clierr.Usage, errors.New("--branch cannot be empty")) @@ -41,18 +50,23 @@ $ tea repo sync --branch release/1.0`, if err != nil { return err } - if inside, _ := a.gitProbe().Output(cmd.Context(), a.options.cwd, false, "rev-parse", "--is-inside-work-tree"); inside != "true" { - return clierr.Wrap(clierr.Usage, errors.New("repo sync must run inside a Git clone of "+target.Repo)) - } snapshot, err := a.deps.Settings.Load() if err != nil { return err } - remote, err := a.checkoutRemote(cmd.Context(), target, snapshot) - if errors.Is(err, errNoMatchingRemote) { - return clierr.Wrap(clierr.Usage, errors.New("no Git remote of this clone points to "+target.Repo+"; add one with git remote add, or pass --remote")) + inside, _ := a.gitProbe().Output(cmd.Context(), a.options.cwd, false, "rev-parse", "--is-inside-work-tree") + remote, err := "", errNoMatchingRemote + if inside == "true" { + remote, err = a.checkoutRemote(cmd.Context(), target, snapshot) } - if err != nil { + switch { + case errors.Is(err, errNoMatchingRemote) && len(args) > 0: + return a.syncOnServer(cmd, target, branch) + case inside != "true": + return clierr.Wrap(clierr.Usage, fmt.Errorf("repo sync needs a Git clone of %s; run it inside one, or pass a fork to update it on the server: tea repo sync OWNER/REPO", target.Repo)) + case errors.Is(err, errNoMatchingRemote): + return clierr.Wrap(clierr.Usage, errors.New("no Git remote of this clone points to "+target.Repo+"; add one with git remote add, or pass --remote")) + case err != nil: return clierr.Wrap(clierr.Usage, err) } if branch == "" { @@ -90,6 +104,46 @@ $ tea repo sync --branch release/1.0`, return command } +// syncOnServer updates the repository of t on the server: a fork's branch, the default branch +// unless named, fast-forwards to its parent's, and a pull mirror fetches its source. +func (a *application) syncOnServer(cmd *cobra.Command, t target, branch string) error { + repositories := services.Repositories{Client: t.client} + item, err := repositories.View(cmd.Context(), t.Repo) + if err != nil { + return err + } + switch { + case item.Mirror: + if err := repositories.MirrorSync(cmd.Context(), item.FullName); err != nil { + return err + } + a.done(cmd, "Started updating the mirror %s from its source", item.FullName) + return a.writeMutationJSON(cmd, git.SyncResult{Remote: mirrorSourceURL(item)}) + case !item.Fork || item.Parent == nil: + return clierr.Wrap(clierr.Usage, fmt.Errorf("%s is neither a fork nor a mirror, and has no Git remote here; run tea repo sync inside a clone of it", item.FullName)) + } + branch = cmp.Or(branch, item.DefaultBranch) + branches := services.Branches{Client: t.client} + before, err := branches.View(cmd.Context(), item.FullName, branch) + if err != nil { + return err + } + if _, err := repositories.MergeUpstream(cmd.Context(), item.FullName, branch); err != nil { + return err + } + after, err := branches.View(cmd.Context(), item.FullName, branch) + if err != nil { + return err + } + result := git.SyncResult{Branch: branch, Commit: after.SHA, Previous: before.SHA, Remote: item.Parent.FullName} + if result.Previous == result.Commit { + a.done(cmd, "%s %s is already up to date with %s", item.FullName, branch, item.Parent.FullName) + } else { + a.done(cmd, "Synced %s %s from %s (fast-forward %s..%s)", item.FullName, branch, item.Parent.FullName, shortSHA(result.Previous), shortSHA(result.Commit)) + } + return a.writeMutationJSON(cmd, result) +} + // reportSync tells how a sync from source, such as origin/main, changed the branch. func (a *application) reportSync(cmd *cobra.Command, result git.SyncResult, source string) { switch result.Previous { diff --git a/internal/app/repository_sync_test.go b/internal/app/repository_sync_test.go index 7041afcb..0c4f82ac 100644 --- a/internal/app/repository_sync_test.go +++ b/internal/app/repository_sync_test.go @@ -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) { server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) @@ -71,5 +127,5 @@ func TestRepoSyncOutsideACloneExplainsWhatItNeeds(t *testing.T) { deps.Git = &syncGit{} err := Execute(context.Background(), deps, []string{"repo", "sync", "-R", "alice/project", "-b", "main"}) require.Equal(t, clierr.Usage, clierr.ExitCode(err), "%v", err) - require.ErrorContains(t, err, "repo sync must run inside a Git clone of alice/project") + 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") } diff --git a/internal/git/sync.go b/internal/git/sync.go index 30d817b4..62e74801 100644 --- a/internal/git/sync.go +++ b/internal/git/sync.go @@ -61,7 +61,7 @@ func SyncBranch(ctx context.Context, runner Runner, opts SyncOptions) (SyncResul return SyncResult{}, err } 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 { return SyncResult{}, err } diff --git a/internal/services/repositories.go b/internal/services/repositories.go index f35c277c..924b1ca5 100644 --- a/internal/services/repositories.go +++ b/internal/services/repositories.go @@ -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. -func (s Repositories) Fork(ctx context.Context, repo, org string) (Repository, error) { +// ForkOptions places a fork: under Organization instead of the account, and named Name instead +// 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 - if org != "" { - options.Organization = &org + if opts.Organization != "" { + 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) }) + 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) + }) } diff --git a/internal/services/repositories_test.go b/internal/services/repositories_test.go index d73c7990..ad77d37d 100644 --- a/internal/services/repositories_test.go +++ b/internal/services/repositories_test.go @@ -44,8 +44,9 @@ func TestRepositoryForkSendsOrganizationOnlyWhenSelected(t *testing.T) { w.WriteHeader(http.StatusAccepted) _, _ = 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.False(t, existed) require.Equal(t, "alice/project", item.Parent.FullName) } }