fj/cmd/repo.go

512 lines
14 KiB
Go
Raw Normal View History

2025-12-08 09:49:07 +01:00
package cmd
import (
"fmt"
"os"
2025-12-16 12:38:19 +01:00
"os/exec"
"path/filepath"
2026-03-10 15:40:22 +01:00
"strings"
2025-12-08 09:49:07 +01:00
"text/tabwriter"
"code.gitea.io/sdk/gitea"
"forgejo.zerova.net/sid/fgj-sid/internal/api"
"forgejo.zerova.net/sid/fgj-sid/internal/config"
2026-01-28 14:29:59 +01:00
"github.com/spf13/cobra"
2025-12-08 09:49:07 +01:00
)
var repoCmd = &cobra.Command{
Use: "repo",
Short: "Manage repositories",
Long: "View and manage repositories.",
}
var repoViewCmd = &cobra.Command{
Use: "view [owner/name]",
Short: "View repository details",
Long: "Display detailed information about a repository.",
Args: cobra.MaximumNArgs(1),
RunE: runRepoView,
}
var repoListCmd = &cobra.Command{
Use: "list",
Short: "List your repositories",
Long: "List repositories owned by the authenticated user.",
RunE: runRepoList,
}
var repoCloneCmd = &cobra.Command{
2025-12-16 12:38:19 +01:00
Use: "clone <owner/name> [destination]",
2025-12-08 09:49:07 +01:00
Short: "Clone a repository",
2025-12-16 12:38:19 +01:00
Long: "Clone a repository locally. If destination is not specified, the repository name is used.",
Args: cobra.RangeArgs(1, 2),
2025-12-08 09:49:07 +01:00
RunE: runRepoClone,
}
var repoForkCmd = &cobra.Command{
Use: "fork <owner/name>",
Short: "Fork a repository",
Long: "Create a fork of a repository.",
Args: cobra.ExactArgs(1),
RunE: runRepoFork,
}
2026-03-10 15:40:22 +01:00
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,
}
var repoEditCmd = &cobra.Command{
Use: "edit [owner/name]",
Short: "Edit repository settings",
Long: "Edit settings of an existing repository such as visibility, description, homepage, and default branch.",
Example: ` # Make a repository private
fgj repo edit owner/repo --private
# Make a repository public
fgj repo edit owner/repo --public
# Update description and homepage
fgj repo edit owner/repo -d "New description" --homepage https://example.com
# Change default branch
fgj repo edit --default-branch develop
# Edit current repo (auto-detected from git context)
fgj repo edit --public`,
Args: cobra.MaximumNArgs(1),
RunE: runRepoEdit,
}
2025-12-08 09:49:07 +01:00
func init() {
rootCmd.AddCommand(repoCmd)
repoCmd.AddCommand(repoCloneCmd)
2026-03-10 15:40:22 +01:00
repoCmd.AddCommand(repoCreateCmd)
repoCmd.AddCommand(repoEditCmd)
2025-12-08 09:49:07 +01:00
repoCmd.AddCommand(repoForkCmd)
2026-03-10 15:40:22 +01:00
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")
2025-12-08 09:49:07 +01:00
repoCloneCmd.Flags().StringP("protocol", "p", "https", "Clone protocol: https or ssh")
repoEditCmd.Flags().StringP("repo", "R", "", "Repository in owner/name format")
repoEditCmd.Flags().StringP("description", "d", "", "Repository description")
repoEditCmd.Flags().String("homepage", "", "Repository home page URL")
repoEditCmd.Flags().String("default-branch", "", "Default branch name")
repoEditCmd.Flags().Bool("private", false, "Make the repository private")
repoEditCmd.Flags().Bool("public", false, "Make the repository public")
repoEditCmd.Flags().Bool("json", false, "Output updated repository as JSON")
repoEditCmd.MarkFlagsMutuallyExclusive("public", "private")
2025-12-08 09:49:07 +01:00
}
func runRepoView(cmd *cobra.Command, args []string) error {
var repo string
if len(args) > 0 {
repo = args[0]
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
2026-01-05 12:47:28 +01:00
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
2025-12-08 09:49:07 +01:00
if err != nil {
return err
}
repository, _, err := client.GetRepo(owner, name)
if err != nil {
return fmt.Errorf("failed to get repository: %w", err)
}
fmt.Printf("Repository: %s/%s\n", repository.Owner.UserName, repository.Name)
fmt.Printf("Description: %s\n", repository.Description)
fmt.Printf("URL: %s\n", repository.HTMLURL)
fmt.Printf("Clone URL (HTTPS): %s\n", repository.CloneURL)
fmt.Printf("Clone URL (SSH): %s\n", repository.SSHURL)
fmt.Printf("Default Branch: %s\n", repository.DefaultBranch)
fmt.Printf("Stars: %d\n", repository.Stars)
fmt.Printf("Forks: %d\n", repository.Forks)
fmt.Printf("Open Issues: %d\n", repository.OpenIssues)
fmt.Printf("Private: %v\n", repository.Private)
fmt.Printf("Created: %s\n", repository.Created.Format("2006-01-02 15:04:05"))
fmt.Printf("Updated: %s\n", repository.Updated.Format("2006-01-02 15:04:05"))
return nil
}
func runRepoList(cmd *cobra.Command, args []string) error {
cfg, err := config.Load()
if err != nil {
return err
}
2026-01-05 12:47:28 +01:00
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
2025-12-08 09:49:07 +01:00
if err != nil {
return err
}
user, _, err := client.GetMyUserInfo()
if err != nil {
return fmt.Errorf("failed to get user info: %w", err)
}
repos, _, err := client.ListUserRepos(user.UserName, gitea.ListReposOptions{})
if err != nil {
return fmt.Errorf("failed to list repositories: %w", err)
}
if len(repos) == 0 {
fmt.Println("No repositories found")
return nil
}
w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
2025-12-08 10:00:50 +01:00
_, _ = fmt.Fprintf(w, "NAME\tVISIBILITY\tDESCRIPTION\n")
2025-12-08 09:49:07 +01:00
for _, repo := range repos {
visibility := "public"
if repo.Private {
visibility = "private"
}
desc := repo.Description
if len(desc) > 50 {
desc = desc[:47] + "..."
}
2025-12-08 10:00:50 +01:00
_, _ = fmt.Fprintf(w, "%s/%s\t%s\t%s\n", repo.Owner.UserName, repo.Name, visibility, desc)
2025-12-08 09:49:07 +01:00
}
2025-12-08 10:00:50 +01:00
_ = w.Flush()
2025-12-08 09:49:07 +01:00
return nil
}
func runRepoClone(cmd *cobra.Command, args []string) error {
repo := args[0]
protocol, _ := cmd.Flags().GetString("protocol")
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
2026-01-05 12:47:28 +01:00
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
2025-12-08 09:49:07 +01:00
if err != nil {
return err
}
repository, _, err := client.GetRepo(owner, name)
if err != nil {
return fmt.Errorf("failed to get repository: %w", err)
}
var cloneURL string
if protocol == "ssh" {
cloneURL = repository.SSHURL
} else {
cloneURL = repository.CloneURL
}
2025-12-16 12:38:19 +01:00
// Determine destination path
var destination string
if len(args) > 1 {
destination = args[1]
} else {
destination = name
}
fmt.Printf("Cloning %s/%s to %s...\n", owner, name, destination)
// Create parent directory if it doesn't exist
if dir := filepath.Dir(destination); dir != "." {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create destination directory: %w", err)
}
}
// Execute git clone
gitCmd := exec.Command("git", "clone", cloneURL, destination)
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)
}
2025-12-08 09:49:07 +01:00
2025-12-16 12:38:19 +01:00
fmt.Printf("Repository cloned successfully to %s\n", destination)
2025-12-08 09:49:07 +01:00
return nil
}
func runRepoFork(cmd *cobra.Command, args []string) error {
repo := args[0]
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
2026-01-05 12:47:28 +01:00
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
2025-12-08 09:49:07 +01:00
if err != nil {
return err
}
fork, _, err := client.CreateFork(owner, name, gitea.CreateForkOption{})
if err != nil {
return fmt.Errorf("failed to fork repository: %w", err)
}
fmt.Printf("Repository forked successfully\n")
fmt.Printf("View at: %s\n", fork.HTMLURL)
fmt.Printf("Clone URL: %s\n", fork.CloneURL)
return nil
}
2026-03-10 15:40:22 +01:00
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")
public, _ := cmd.Flags().GetBool("public")
2026-03-10 15:40:22 +01:00
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")
// --public explicitly sets private=false (default behavior, but makes intent clear)
if public {
private = false
}
2026-03-10 15:40:22 +01:00
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
}
func runRepoEdit(cmd *cobra.Command, args []string) error {
var repo string
if len(args) > 0 {
repo = args[0]
}
if r, _ := cmd.Flags().GetString("repo"); r != "" {
repo = r
}
owner, name, err := parseRepo(repo)
if err != nil {
return err
}
cfg, err := config.Load()
if err != nil {
return err
}
client, err := api.NewClientFromConfig(cfg, "", getDetectedHost())
if err != nil {
return err
}
opt := gitea.EditRepoOption{}
changed := false
if cmd.Flags().Changed("description") {
d, _ := cmd.Flags().GetString("description")
opt.Description = &d
changed = true
}
if cmd.Flags().Changed("homepage") {
h, _ := cmd.Flags().GetString("homepage")
opt.Website = &h
changed = true
}
if cmd.Flags().Changed("default-branch") {
b, _ := cmd.Flags().GetString("default-branch")
opt.DefaultBranch = &b
changed = true
}
if cmd.Flags().Changed("private") {
p := true
opt.Private = &p
changed = true
}
if cmd.Flags().Changed("public") {
p := false
opt.Private = &p
changed = true
}
if !changed {
return fmt.Errorf("no changes specified; use flags like --public, --private, --description, --homepage, or --default-branch")
}
repository, _, err := client.EditRepo(owner, name, opt)
if err != nil {
return fmt.Errorf("failed to edit repository: %w", err)
}
jsonFlag, _ := cmd.Flags().GetBool("json")
if jsonFlag {
return writeJSON(repository)
}
fmt.Printf("Repository updated: %s\n", repository.HTMLURL)
if opt.Private != nil {
if *opt.Private {
fmt.Println("Visibility: private")
} else {
fmt.Println("Visibility: public")
}
}
if opt.Description != nil {
fmt.Printf("Description: %s\n", *opt.Description)
}
if opt.Website != nil {
fmt.Printf("Homepage: %s\n", *opt.Website)
}
if opt.DefaultBranch != nil {
fmt.Printf("Default branch: %s\n", *opt.DefaultBranch)
}
return nil
}