Merge pull request 'feat: implement repo create command' (#35) from feat/repo-create into main

Reviewed-on: https://codeberg.org/romaintb/fgj/pulls/35
This commit is contained in:
Romain Bertrand 2026-03-13 18:10:06 +01:00
commit fbdb2320cc
4 changed files with 248 additions and 2 deletions

View file

@ -5,6 +5,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"text/tabwriter" "text/tabwriter"
"code.gitea.io/sdk/gitea" "code.gitea.io/sdk/gitea"
@ -50,12 +51,35 @@ var repoForkCmd = &cobra.Command{
RunE: runRepoFork, RunE: runRepoFork,
} }
var repoCreateCmd = &cobra.Command{
Use: "create <name>",
Short: "Create a new repository",
Long: `Create a new repository on the Forgejo instance.
Name can be "reponame" (creates under your account) or "org/reponame"
(creates under an organization). Repositories are public by default; use --private to create a private one.`,
Args: cobra.ExactArgs(1),
RunE: runRepoCreate,
}
func init() { func init() {
rootCmd.AddCommand(repoCmd) rootCmd.AddCommand(repoCmd)
repoCmd.AddCommand(repoViewCmd)
repoCmd.AddCommand(repoListCmd)
repoCmd.AddCommand(repoCloneCmd) repoCmd.AddCommand(repoCloneCmd)
repoCmd.AddCommand(repoCreateCmd)
repoCmd.AddCommand(repoForkCmd) repoCmd.AddCommand(repoForkCmd)
repoCmd.AddCommand(repoListCmd)
repoCmd.AddCommand(repoViewCmd)
repoCreateCmd.Flags().StringP("description", "d", "", "Description of the repository")
repoCreateCmd.Flags().Bool("private", false, "Make the new repository private")
repoCreateCmd.Flags().Bool("public", false, "Make the new repository public")
repoCreateCmd.Flags().Bool("add-readme", false, "Add a README file to the new repository")
repoCreateCmd.Flags().StringP("gitignore", "g", "", "Gitignore template (e.g. Go, Python)")
repoCreateCmd.Flags().StringP("license", "l", "", "License template (e.g. MIT, Apache-2.0)")
repoCreateCmd.Flags().String("homepage", "", "Repository home page URL")
repoCreateCmd.Flags().BoolP("clone", "c", false, "Clone the new repository to the current directory")
repoCreateCmd.Flags().StringP("team", "t", "", "Team name to be granted access (org repos only)")
repoCreateCmd.MarkFlagsMutuallyExclusive("public", "private")
repoCloneCmd.Flags().StringP("protocol", "p", "https", "Clone protocol: https or ssh") repoCloneCmd.Flags().StringP("protocol", "p", "https", "Clone protocol: https or ssh")
} }
@ -237,3 +261,125 @@ func runRepoFork(cmd *cobra.Command, args []string) error {
return nil return nil
} }
func runRepoCreate(cmd *cobra.Command, args []string) error {
inputName := args[0]
org, repoName, isOrg, err := parseCreateName(inputName)
if err != nil {
return err
}
private, _ := cmd.Flags().GetBool("private")
description, _ := cmd.Flags().GetString("description")
addReadme, _ := cmd.Flags().GetBool("add-readme")
gitignore, _ := cmd.Flags().GetString("gitignore")
license, _ := cmd.Flags().GetString("license")
homepage, _ := cmd.Flags().GetString("homepage")
doClone, _ := cmd.Flags().GetBool("clone")
team, _ := cmd.Flags().GetString("team")
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
opt := gitea.CreateRepoOption{
Name: repoName,
Description: description,
Private: private,
AutoInit: addReadme,
Gitignores: gitignore,
License: license,
}
var repo *gitea.Repository
if isOrg {
repo, _, err = client.CreateOrgRepo(org, opt)
} else {
repo, _, err = client.CreateRepo(opt)
}
if err != nil {
return fmt.Errorf("failed to create repository: %w", err)
}
if homepage != "" {
ownerName := org
if !isOrg {
// For personal repos, get the owner from the created repo or fall back to API
if repo.Owner != nil {
ownerName = repo.Owner.UserName
} else {
user, _, userErr := client.GetMyUserInfo()
if userErr != nil {
fmt.Fprintf(os.Stderr, "warning: repository created but could not determine owner for homepage: %v\n", userErr)
homepage = "" // skip EditRepo
} else {
ownerName = user.UserName
}
}
}
if homepage != "" {
_, _, err = client.EditRepo(ownerName, repo.Name, gitea.EditRepoOption{
Website: &homepage,
})
if err != nil {
fmt.Fprintf(os.Stderr, "warning: repository created but failed to set homepage: %v\n", err)
}
}
}
if team != "" {
if !isOrg {
fmt.Fprintln(os.Stderr, "warning: --team is only meaningful for organization repositories")
} else {
_, err = client.AddRepoTeam(org, repo.Name, team)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: repository created but failed to add team %q: %v\n", team, err)
}
}
}
fmt.Printf("Repository created: %s\n", repo.HTMLURL)
if doClone {
cloneURL := repo.CloneURL
if hostCfg, hostErr := cfg.GetHost("", getDetectedHost()); hostErr == nil {
if hostCfg.GitProtocol == "ssh" {
cloneURL = repo.SSHURL
}
}
fmt.Printf("Cloning into %s...\n", repo.Name)
gitCmd := exec.Command("git", "clone", cloneURL, repo.Name)
gitCmd.Stdout = os.Stdout
gitCmd.Stderr = os.Stderr
gitCmd.Stdin = os.Stdin
if err := gitCmd.Run(); err != nil {
return fmt.Errorf("failed to clone repository: %w", err)
}
}
return nil
}
// parseCreateName parses the name argument for repo creation.
// Returns org, repoName, and whether it targets an org.
// Input: "reponame" → ("", "reponame", false)
// Input: "org/reponame" → ("org", "reponame", true)
func parseCreateName(name string) (org, repoName string, isOrg bool, err error) {
parts := strings.SplitN(name, "/", 2)
if len(parts) == 2 {
if parts[0] == "" || parts[1] == "" {
return "", "", false, fmt.Errorf("invalid repository name %q: org and repo name must not be empty", name)
}
return parts[0], parts[1], true, nil
}
if name == "" {
return "", "", false, fmt.Errorf("repository name must not be empty")
}
return "", name, false, nil
}

53
cmd/repo_create_test.go Normal file
View file

@ -0,0 +1,53 @@
package cmd
import "testing"
func TestParseCreateName(t *testing.T) {
t.Run("simple repo name", func(t *testing.T) {
org, repo, isOrg, err := parseCreateName("myrepo")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if org != "" || repo != "myrepo" || isOrg {
t.Errorf("got (%q, %q, %v), want (%q, %q, %v)", org, repo, isOrg, "", "myrepo", false)
}
})
t.Run("org/repo name", func(t *testing.T) {
org, repo, isOrg, err := parseCreateName("myorg/myrepo")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if org != "myorg" || repo != "myrepo" || !isOrg {
t.Errorf("got (%q, %q, %v), want (%q, %q, %v)", org, repo, isOrg, "myorg", "myrepo", true)
}
})
t.Run("empty string", func(t *testing.T) {
_, _, _, err := parseCreateName("")
if err == nil {
t.Error("expected error for empty string, got nil")
}
})
t.Run("slash only", func(t *testing.T) {
_, _, _, err := parseCreateName("/")
if err == nil {
t.Error("expected error for '/', got nil")
}
})
t.Run("empty repo after slash", func(t *testing.T) {
_, _, _, err := parseCreateName("org/")
if err == nil {
t.Error("expected error for 'org/', got nil")
}
})
t.Run("empty org before slash", func(t *testing.T) {
_, _, _, err := parseCreateName("/repo")
if err == nil {
t.Error("expected error for '/repo', got nil")
}
})
}

View file

@ -219,6 +219,14 @@ func (env *TestEnv) GetLabelIDs(labelNames []string) []int64 {
return ids return ids
} }
// CleanupRepo deletes a repository created during testing.
func (env *TestEnv) CleanupRepo(owner, repoName string) {
_, err := env.Client.DeleteRepo(owner, repoName)
if err != nil {
env.T.Logf("warning: failed to delete repo %s/%s: %v", owner, repoName, err)
}
}
// GetBinaryPath returns the path to the built fgj binary // GetBinaryPath returns the path to the built fgj binary
func (env *TestEnv) GetBinaryPath() string { func (env *TestEnv) GetBinaryPath() string {
binaryPath := os.Getenv("FGJ_BINARY_PATH") binaryPath := os.Getenv("FGJ_BINARY_PATH")

View file

@ -940,3 +940,42 @@ func TestCLIReleaseList(t *testing.T) {
t.Logf("Successfully listed releases via CLI") t.Logf("Successfully listed releases via CLI")
} }
// TestCLIRepoCreate verifies `fgj repo create` creates a repository
func TestCLIRepoCreate(t *testing.T) {
env := NewTestEnv(t)
repoName := fmt.Sprintf("fgj-test-create-%d", time.Now().UnixNano())
defer env.CleanupRepo(env.Owner, repoName)
result := env.RunCLI(
"--hostname", env.Hostname,
"repo", "create", repoName,
"--public",
"-d", "Created by fgj functional test",
)
if result.ExitCode != 0 {
t.Fatalf("repo create failed with exit code %d: %s", result.ExitCode, result.Stderr)
}
if !bytes.Contains([]byte(result.Stdout), []byte(repoName)) {
t.Fatalf("expected output to contain repo name %q, got: %s", repoName, result.Stdout)
}
repo, _, err := env.Client.GetRepo(env.Owner, repoName)
if err != nil {
t.Fatalf("repo was not created or not accessible: %v", err)
}
if repo.Name != repoName {
t.Fatalf("expected repo name %q, got %q", repoName, repo.Name)
}
if repo.Private {
t.Fatalf("expected public repo, got private")
}
if repo.Description != "Created by fgj functional test" {
t.Fatalf("expected description %q, got %q", "Created by fgj functional test", repo.Description)
}
t.Logf("Successfully created repository %s via CLI", repo.FullName)
}