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 publish
|
||||
release view
|
||||
repo clone
|
||||
repo create
|
||||
repo edit
|
||||
repo fork
|
||||
repo migrate
|
||||
repo set-default
|
||||
search issue
|
||||
search pr
|
||||
status
|
||||
|
||||
@@ -91,7 +91,7 @@ func TestArgumentErrorsShowTheUsageLine(t *testing.T) {
|
||||
{[]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{"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...)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
+200
-45
@@ -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,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(&actionsEnabled, "actions", false, "Enable Actions (use --actions=false to disable)")
|
||||
group.AddCommand(edit, a.repoArchiveCommand(true), a.repoArchiveCommand(false), a.repoRenameCommand(), a.repoDeleteCommand())
|
||||
var depth int
|
||||
clone := &cobra.Command{Use: "clone <repository> [directory]", Short: "Clone a repository using the configured Git protocol", Args: usageArgs(cobra.RangeArgs(1, 2)), RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if depth < 0 {
|
||||
}
|
||||
|
||||
func (a *application) repoCloneCommand() *cobra.Command {
|
||||
var opts cloneOptions
|
||||
var noUpstream bool
|
||||
command := &cobra.Command{
|
||||
Use: "clone <repository> [<directory>] [-- <gitflags>...]",
|
||||
Short: "Clone a repository locally",
|
||||
Long: `Clone a repository with the configured Git protocol (git_protocol: https or ssh) and tea's
|
||||
credentials. Git flags can follow --.
|
||||
|
||||
A fork's parent is added as the "upstream" remote, which its default branch pulls from while pushes
|
||||
stay on the fork; tea commands in the clone then target the parent. A pull mirror's source is added
|
||||
as the "source" remote.`,
|
||||
Example: `# Clone a repository into ./project
|
||||
$ tea repo clone alice/project
|
||||
|
||||
# Clone into another directory with the last commit only
|
||||
$ tea repo clone alice/project src --depth 1
|
||||
|
||||
# Pass flags to git clone
|
||||
$ tea repo clone alice/project -- --branch develop --recurse-submodules`,
|
||||
Annotations: map[string]string{"tea.arguments": repositoryArguments},
|
||||
Args: argsBeforeGitFlags(1, 2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
at := argsBeforeDash(cmd, args)
|
||||
args, opts.gitFlags = args[:at], args[at:]
|
||||
if opts.depth < 0 {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--depth must be nonnegative; zero fetches full history"))
|
||||
}
|
||||
if a.options.repo != "" {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("clone repository argument cannot be combined with --repo"))
|
||||
}
|
||||
if noUpstream {
|
||||
if cmd.Flags().Changed("upstream-remote-name") {
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--no-upstream and --upstream-remote-name cannot be combined"))
|
||||
}
|
||||
opts.upstream = ""
|
||||
}
|
||||
a.options.repo = args[0]
|
||||
t, err := a.resolve(cmd.Context(), true)
|
||||
if err != nil {
|
||||
@@ -172,25 +187,161 @@ func (a *application) addRepositoryMutations(group *cobra.Command) {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
directory := ""
|
||||
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
|
||||
}
|
||||
if a.options.json != "" {
|
||||
return a.writeJSON(cmd, item)
|
||||
return a.writeMutationJSON(cmd, item)
|
||||
},
|
||||
}
|
||||
command.Flags().IntVarP(&opts.depth, "depth", "d", 0, "Number of commits to fetch (0: full history)")
|
||||
command.Flags().StringVarP(&opts.upstream, "upstream-remote-name", "u", "upstream", "Remote name for the parent of a fork")
|
||||
command.Flags().BoolVar(&noUpstream, "no-upstream", false, "Do not add a remote for the parent of a fork")
|
||||
command.ValidArgsFunction = a.completeRepos()
|
||||
return command
|
||||
}
|
||||
|
||||
func (a *application) repoForkCommand() *cobra.Command {
|
||||
var opts services.ForkOptions
|
||||
var clone, addRemote bool
|
||||
var remoteName string
|
||||
command := &cobra.Command{
|
||||
Use: "fork [<repository>] [-- <gitflags>...]",
|
||||
Short: "Fork a repository",
|
||||
Long: `Fork a repository for your account, or into an organization with --org. When the account or
|
||||
organization already has a fork, tea reports it instead of failing.
|
||||
|
||||
With --clone, the fork is cloned, and its parent is added as the "upstream" remote; Git flags can
|
||||
follow --. Inside a clone of the repository, --remote adds the fork as the "origin" remote (or
|
||||
--remote-name), renaming the current "origin" to "upstream".`,
|
||||
Example: `# Fork the repository of the current clone and add the fork as origin
|
||||
$ tea repo fork --remote
|
||||
|
||||
# Fork a repository into an organization under another name
|
||||
$ tea repo fork alice/project --org acme --fork-name project-fork
|
||||
|
||||
# Fork and clone a repository
|
||||
$ tea repo fork alice/project --clone -- --depth 1`,
|
||||
Annotations: map[string]string{"tea.arguments": repositoryArguments},
|
||||
Args: argsBeforeGitFlags(0, 1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
at := argsBeforeDash(cmd, args)
|
||||
args, gitFlags := args[:at], args[at:]
|
||||
switch {
|
||||
case clone && addRemote:
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--clone and --remote cannot be combined"))
|
||||
case len(gitFlags) > 0 && !clone:
|
||||
return clierr.Wrap(clierr.Usage, errors.New("Git flags after -- require --clone"))
|
||||
case cmd.Flags().Changed("remote-name") && !addRemote:
|
||||
return clierr.Wrap(clierr.Usage, errors.New("--remote-name requires --remote"))
|
||||
}
|
||||
if err := a.repositoryArgument(args); err != nil {
|
||||
return err
|
||||
}
|
||||
t, err := a.resolve(cmd.Context(), true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
owner, _, _ := strings.Cut(t.Repo, "/")
|
||||
if strings.EqualFold(owner, cmp.Or(opts.Organization, t.User)) {
|
||||
return clierr.Wrap(clierr.Usage, fmt.Errorf("cannot fork %s into %s: it already owns it; pass --org to fork it into an organization", t.Repo, owner))
|
||||
}
|
||||
fork, existed, err := (services.Repositories{Client: t.client}).Fork(cmd.Context(), t.Repo, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existed {
|
||||
a.warn(cmd, "%s already exists", fork.FullName)
|
||||
} else {
|
||||
a.done(cmd, "Forked %s to %s", t.Repo, fork.FullName)
|
||||
}
|
||||
if clone {
|
||||
if _, err := a.cloneRepository(cmd, t, fork, cloneOptions{gitFlags: gitFlags, upstream: "upstream"}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if addRemote {
|
||||
if err := a.addForkRemote(cmd, t, fork, remoteName); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if a.options.json != "" {
|
||||
return a.writeJSON(cmd, fork)
|
||||
}
|
||||
return a.printURL(cmd, fork.URL)
|
||||
},
|
||||
}
|
||||
command.Flags().StringVar(&opts.Organization, "org", "", "Fork into this organization")
|
||||
command.Flags().StringVar(&opts.Name, "fork-name", "", "Name of the fork (default: the repository's name)")
|
||||
command.Flags().BoolVar(&clone, "clone", false, "Clone the fork")
|
||||
command.Flags().BoolVar(&addRemote, "remote", false, "Add the fork as a remote of the current clone")
|
||||
command.Flags().StringVar(&remoteName, "remote-name", "origin", "Name of the fork's remote")
|
||||
_ = command.RegisterFlagCompletionFunc("org", a.completeOrgs())
|
||||
command.ValidArgsFunction = a.completeRepos()
|
||||
return command
|
||||
}
|
||||
|
||||
// addForkRemote adds fork as the remote name of the clone in the working directory. A remote of
|
||||
// that name that points to the forked repository is renamed to upstream first.
|
||||
func (a *application) addForkRemote(cmd *cobra.Command, t target, fork services.Repository, name string) error {
|
||||
ctx := cmd.Context()
|
||||
if inside, _ := a.gitProbe().Output(ctx, a.options.cwd, false, "rev-parse", "--is-inside-work-tree"); inside != "true" {
|
||||
return clierr.Wrap(clierr.Usage, fmt.Errorf("forked to %s, but --remote must run inside a Git clone of %s", fork.FullName, t.Repo))
|
||||
}
|
||||
if existing, err := a.gitProbe().Output(ctx, a.options.cwd, false, "remote", "get-url", name); err == nil {
|
||||
if !t.ownsRemote(existing) {
|
||||
return clierr.Wrap(clierr.Usage, fmt.Errorf("forked to %s, but Git remote %s points elsewhere; pass --remote-name", fork.FullName, name))
|
||||
}
|
||||
if _, 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
|
||||
}}
|
||||
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.
|
||||
// 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+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
|
||||
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 {
|
||||
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 [<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 {
|
||||
return err
|
||||
}
|
||||
target, err := a.resolve(cmd.Context(), true)
|
||||
t, err := a.resolve(ctx, true)
|
||||
if err != nil {
|
||||
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
|
||||
}})
|
||||
a.addRepositoryMutations(group)
|
||||
return group
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.dev/tea/internal/clierr"
|
||||
@@ -18,17 +20,24 @@ func (a *application) repositorySyncCommand() *cobra.Command {
|
||||
var branch string
|
||||
command := &cobra.Command{
|
||||
Use: "sync [<repository>]",
|
||||
Short: "Sync a local branch with its remote",
|
||||
Short: "Sync a branch with its remote or its fork's parent",
|
||||
Long: `Fast-forward a local branch to the branch of the same name on the repository's Git remote.
|
||||
|
||||
The branch defaults to the repository's default branch. It is created when it is missing, and it is
|
||||
never checked out: the branch you are on stays checked out. When the branch is checked out, the
|
||||
working tree must be clean. Branches that have diverged from the remote are left unchanged.`,
|
||||
working tree must be clean. Branches that have diverged from the remote are left unchanged.
|
||||
|
||||
Given a repository that has no remote in the current directory, sync updates it on the server
|
||||
instead: a fork's branch is fast-forwarded to its parent's, and a pull mirror fetches its source.`,
|
||||
Example: `# Fast-forward the default branch from the repository's remote
|
||||
$ tea repo sync
|
||||
|
||||
# Fast-forward another branch
|
||||
$ tea repo sync --branch release/1.0`,
|
||||
$ 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) == "" {
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user