feat(repo): sync forks on the server, wire forks into clones, validate set-default

repo sync OWNER/REPO, run where no Git remote points to the repository,
now updates it on the server: a fork's branch fast-forwards to its
parent's through merge-upstream, and a pull mirror fetches its source.

repo fork reports an existing fork ("! bob/x already exists") instead of
a raw HTTP 409, refuses to fork a repository into its own owner, and
gains --fork-name, --clone (Git flags after --) and --remote, which adds
the fork as origin and renames the old origin to upstream.

repo clone passes Git flags after --, and takes --upstream-remote-name
and --no-upstream for forks. Clones report what they set up.

repo set-default checks that the repository exists before saving it,
prints the saved repository with --view, removes it with --unset, and
reports the change.

Signed-off-by: bircni <bircni@icloud.com>
This commit is contained in:
bircni committed 2026-10-11 23:51:47 +02:00
1 parent 7cd13ef64e
commit 368de47c40
13 files changed
+599 -98

No files matched your search

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