code.gitea.io/gitea@v1.19.3/modules/repository/generate.go (about)

     1  // Copyright 2019 The Gitea Authors. All rights reserved.
     2  // SPDX-License-Identifier: MIT
     3  
     4  package repository
     5  
     6  import (
     7  	"bufio"
     8  	"bytes"
     9  	"context"
    10  	"fmt"
    11  	"os"
    12  	"path"
    13  	"path/filepath"
    14  	"strings"
    15  	"time"
    16  
    17  	git_model "code.gitea.io/gitea/models/git"
    18  	repo_model "code.gitea.io/gitea/models/repo"
    19  	user_model "code.gitea.io/gitea/models/user"
    20  	"code.gitea.io/gitea/modules/git"
    21  	"code.gitea.io/gitea/modules/log"
    22  	"code.gitea.io/gitea/modules/util"
    23  
    24  	"github.com/gobwas/glob"
    25  	"github.com/huandu/xstrings"
    26  )
    27  
    28  type transformer struct {
    29  	Name      string
    30  	Transform func(string) string
    31  }
    32  
    33  type expansion struct {
    34  	Name         string
    35  	Value        string
    36  	Transformers []transformer
    37  }
    38  
    39  var defaultTransformers = []transformer{
    40  	{Name: "SNAKE", Transform: xstrings.ToSnakeCase},
    41  	{Name: "KEBAB", Transform: xstrings.ToKebabCase},
    42  	{Name: "CAMEL", Transform: func(str string) string {
    43  		return xstrings.FirstRuneToLower(xstrings.ToCamelCase(str))
    44  	}},
    45  	{Name: "PASCAL", Transform: xstrings.ToCamelCase},
    46  	{Name: "LOWER", Transform: strings.ToLower},
    47  	{Name: "UPPER", Transform: strings.ToUpper},
    48  	{Name: "TITLE", Transform: util.ToTitleCase},
    49  }
    50  
    51  func generateExpansion(src string, templateRepo, generateRepo *repo_model.Repository) string {
    52  	expansions := []expansion{
    53  		{Name: "REPO_NAME", Value: generateRepo.Name, Transformers: defaultTransformers},
    54  		{Name: "TEMPLATE_NAME", Value: templateRepo.Name, Transformers: defaultTransformers},
    55  		{Name: "REPO_DESCRIPTION", Value: generateRepo.Description, Transformers: nil},
    56  		{Name: "TEMPLATE_DESCRIPTION", Value: templateRepo.Description, Transformers: nil},
    57  		{Name: "REPO_OWNER", Value: generateRepo.OwnerName, Transformers: defaultTransformers},
    58  		{Name: "TEMPLATE_OWNER", Value: templateRepo.OwnerName, Transformers: defaultTransformers},
    59  		{Name: "REPO_LINK", Value: generateRepo.Link(), Transformers: nil},
    60  		{Name: "TEMPLATE_LINK", Value: templateRepo.Link(), Transformers: nil},
    61  		{Name: "REPO_HTTPS_URL", Value: generateRepo.CloneLink().HTTPS, Transformers: nil},
    62  		{Name: "TEMPLATE_HTTPS_URL", Value: templateRepo.CloneLink().HTTPS, Transformers: nil},
    63  		{Name: "REPO_SSH_URL", Value: generateRepo.CloneLink().SSH, Transformers: nil},
    64  		{Name: "TEMPLATE_SSH_URL", Value: templateRepo.CloneLink().SSH, Transformers: nil},
    65  	}
    66  
    67  	expansionMap := make(map[string]string)
    68  	for _, e := range expansions {
    69  		expansionMap[e.Name] = e.Value
    70  		for _, tr := range e.Transformers {
    71  			expansionMap[fmt.Sprintf("%s_%s", e.Name, tr.Name)] = tr.Transform(e.Value)
    72  		}
    73  	}
    74  
    75  	return os.Expand(src, func(key string) string {
    76  		if expansion, ok := expansionMap[key]; ok {
    77  			return expansion
    78  		}
    79  		return key
    80  	})
    81  }
    82  
    83  // GiteaTemplate holds information about a .gitea/template file
    84  type GiteaTemplate struct {
    85  	Path    string
    86  	Content []byte
    87  
    88  	globs []glob.Glob
    89  }
    90  
    91  // Globs parses the .gitea/template globs or returns them if they were already parsed
    92  func (gt GiteaTemplate) Globs() []glob.Glob {
    93  	if gt.globs != nil {
    94  		return gt.globs
    95  	}
    96  
    97  	gt.globs = make([]glob.Glob, 0)
    98  	scanner := bufio.NewScanner(bytes.NewReader(gt.Content))
    99  	for scanner.Scan() {
   100  		line := strings.TrimSpace(scanner.Text())
   101  		if line == "" || strings.HasPrefix(line, "#") {
   102  			continue
   103  		}
   104  		g, err := glob.Compile(line, '/')
   105  		if err != nil {
   106  			log.Info("Invalid glob expression '%s' (skipped): %v", line, err)
   107  			continue
   108  		}
   109  		gt.globs = append(gt.globs, g)
   110  	}
   111  	return gt.globs
   112  }
   113  
   114  func checkGiteaTemplate(tmpDir string) (*GiteaTemplate, error) {
   115  	gtPath := filepath.Join(tmpDir, ".gitea", "template")
   116  	if _, err := os.Stat(gtPath); os.IsNotExist(err) {
   117  		return nil, nil
   118  	} else if err != nil {
   119  		return nil, err
   120  	}
   121  
   122  	content, err := os.ReadFile(gtPath)
   123  	if err != nil {
   124  		return nil, err
   125  	}
   126  
   127  	gt := &GiteaTemplate{
   128  		Path:    gtPath,
   129  		Content: content,
   130  	}
   131  
   132  	return gt, nil
   133  }
   134  
   135  func generateRepoCommit(ctx context.Context, repo, templateRepo, generateRepo *repo_model.Repository, tmpDir string) error {
   136  	commitTimeStr := time.Now().Format(time.RFC3339)
   137  	authorSig := repo.Owner.NewGitSig()
   138  
   139  	// Because this may call hooks we should pass in the environment
   140  	env := append(os.Environ(),
   141  		"GIT_AUTHOR_NAME="+authorSig.Name,
   142  		"GIT_AUTHOR_EMAIL="+authorSig.Email,
   143  		"GIT_AUTHOR_DATE="+commitTimeStr,
   144  		"GIT_COMMITTER_NAME="+authorSig.Name,
   145  		"GIT_COMMITTER_EMAIL="+authorSig.Email,
   146  		"GIT_COMMITTER_DATE="+commitTimeStr,
   147  	)
   148  
   149  	// Clone to temporary path and do the init commit.
   150  	templateRepoPath := templateRepo.RepoPath()
   151  	if err := git.Clone(ctx, templateRepoPath, tmpDir, git.CloneRepoOptions{
   152  		Depth:  1,
   153  		Branch: templateRepo.DefaultBranch,
   154  	}); err != nil {
   155  		return fmt.Errorf("git clone: %w", err)
   156  	}
   157  
   158  	if err := util.RemoveAll(path.Join(tmpDir, ".git")); err != nil {
   159  		return fmt.Errorf("remove git dir: %w", err)
   160  	}
   161  
   162  	// Variable expansion
   163  	gt, err := checkGiteaTemplate(tmpDir)
   164  	if err != nil {
   165  		return fmt.Errorf("checkGiteaTemplate: %w", err)
   166  	}
   167  
   168  	if gt != nil {
   169  		if err := util.Remove(gt.Path); err != nil {
   170  			return fmt.Errorf("remove .giteatemplate: %w", err)
   171  		}
   172  
   173  		// Avoid walking tree if there are no globs
   174  		if len(gt.Globs()) > 0 {
   175  			tmpDirSlash := strings.TrimSuffix(filepath.ToSlash(tmpDir), "/") + "/"
   176  			if err := filepath.WalkDir(tmpDirSlash, func(path string, d os.DirEntry, walkErr error) error {
   177  				if walkErr != nil {
   178  					return walkErr
   179  				}
   180  
   181  				if d.IsDir() {
   182  					return nil
   183  				}
   184  
   185  				base := strings.TrimPrefix(filepath.ToSlash(path), tmpDirSlash)
   186  				for _, g := range gt.Globs() {
   187  					if g.Match(base) {
   188  						content, err := os.ReadFile(path)
   189  						if err != nil {
   190  							return err
   191  						}
   192  
   193  						if err := os.WriteFile(path,
   194  							[]byte(generateExpansion(string(content), templateRepo, generateRepo)),
   195  							0o644); err != nil {
   196  							return err
   197  						}
   198  						break
   199  					}
   200  				}
   201  				return nil
   202  			}); err != nil {
   203  				return err
   204  			}
   205  		}
   206  	}
   207  
   208  	if err := git.InitRepository(ctx, tmpDir, false); err != nil {
   209  		return err
   210  	}
   211  
   212  	repoPath := repo.RepoPath()
   213  	if stdout, _, err := git.NewCommand(ctx, "remote", "add", "origin").AddDynamicArguments(repoPath).
   214  		SetDescription(fmt.Sprintf("generateRepoCommit (git remote add): %s to %s", templateRepoPath, tmpDir)).
   215  		RunStdString(&git.RunOpts{Dir: tmpDir, Env: env}); err != nil {
   216  		log.Error("Unable to add %v as remote origin to temporary repo to %s: stdout %s\nError: %v", repo, tmpDir, stdout, err)
   217  		return fmt.Errorf("git remote add: %w", err)
   218  	}
   219  
   220  	// set default branch based on whether it's specified in the newly generated repo or not
   221  	defaultBranch := repo.DefaultBranch
   222  	if strings.TrimSpace(defaultBranch) == "" {
   223  		defaultBranch = templateRepo.DefaultBranch
   224  	}
   225  
   226  	return initRepoCommit(ctx, tmpDir, repo, repo.Owner, defaultBranch)
   227  }
   228  
   229  func generateGitContent(ctx context.Context, repo, templateRepo, generateRepo *repo_model.Repository) (err error) {
   230  	tmpDir, err := os.MkdirTemp(os.TempDir(), "gitea-"+repo.Name)
   231  	if err != nil {
   232  		return fmt.Errorf("Failed to create temp dir for repository %s: %w", repo.RepoPath(), err)
   233  	}
   234  
   235  	defer func() {
   236  		if err := util.RemoveAll(tmpDir); err != nil {
   237  			log.Error("RemoveAll: %v", err)
   238  		}
   239  	}()
   240  
   241  	if err = generateRepoCommit(ctx, repo, templateRepo, generateRepo, tmpDir); err != nil {
   242  		return fmt.Errorf("generateRepoCommit: %w", err)
   243  	}
   244  
   245  	// re-fetch repo
   246  	if repo, err = repo_model.GetRepositoryByID(ctx, repo.ID); err != nil {
   247  		return fmt.Errorf("getRepositoryByID: %w", err)
   248  	}
   249  
   250  	// if there was no default branch supplied when generating the repo, use the default one from the template
   251  	if strings.TrimSpace(repo.DefaultBranch) == "" {
   252  		repo.DefaultBranch = templateRepo.DefaultBranch
   253  	}
   254  
   255  	gitRepo, err := git.OpenRepository(ctx, repo.RepoPath())
   256  	if err != nil {
   257  		return fmt.Errorf("openRepository: %w", err)
   258  	}
   259  	defer gitRepo.Close()
   260  	if err = gitRepo.SetDefaultBranch(repo.DefaultBranch); err != nil {
   261  		return fmt.Errorf("setDefaultBranch: %w", err)
   262  	}
   263  	if err = UpdateRepository(ctx, repo, false); err != nil {
   264  		return fmt.Errorf("updateRepository: %w", err)
   265  	}
   266  
   267  	return nil
   268  }
   269  
   270  // GenerateGitContent generates git content from a template repository
   271  func GenerateGitContent(ctx context.Context, templateRepo, generateRepo *repo_model.Repository) error {
   272  	if err := generateGitContent(ctx, generateRepo, templateRepo, generateRepo); err != nil {
   273  		return err
   274  	}
   275  
   276  	if err := UpdateRepoSize(ctx, generateRepo); err != nil {
   277  		return fmt.Errorf("failed to update size for repository: %w", err)
   278  	}
   279  
   280  	if err := git_model.CopyLFS(ctx, generateRepo, templateRepo); err != nil {
   281  		return fmt.Errorf("failed to copy LFS: %w", err)
   282  	}
   283  	return nil
   284  }
   285  
   286  // GenerateRepoOptions contains the template units to generate
   287  type GenerateRepoOptions struct {
   288  	Name          string
   289  	DefaultBranch string
   290  	Description   string
   291  	Private       bool
   292  	GitContent    bool
   293  	Topics        bool
   294  	GitHooks      bool
   295  	Webhooks      bool
   296  	Avatar        bool
   297  	IssueLabels   bool
   298  }
   299  
   300  // IsValid checks whether at least one option is chosen for generation
   301  func (gro GenerateRepoOptions) IsValid() bool {
   302  	return gro.GitContent || gro.Topics || gro.GitHooks || gro.Webhooks || gro.Avatar || gro.IssueLabels // or other items as they are added
   303  }
   304  
   305  // GenerateRepository generates a repository from a template
   306  func GenerateRepository(ctx context.Context, doer, owner *user_model.User, templateRepo *repo_model.Repository, opts GenerateRepoOptions) (_ *repo_model.Repository, err error) {
   307  	generateRepo := &repo_model.Repository{
   308  		OwnerID:       owner.ID,
   309  		Owner:         owner,
   310  		OwnerName:     owner.Name,
   311  		Name:          opts.Name,
   312  		LowerName:     strings.ToLower(opts.Name),
   313  		Description:   opts.Description,
   314  		DefaultBranch: opts.DefaultBranch,
   315  		IsPrivate:     opts.Private,
   316  		IsEmpty:       !opts.GitContent || templateRepo.IsEmpty,
   317  		IsFsckEnabled: templateRepo.IsFsckEnabled,
   318  		TemplateID:    templateRepo.ID,
   319  		TrustModel:    templateRepo.TrustModel,
   320  	}
   321  
   322  	if err = CreateRepositoryByExample(ctx, doer, owner, generateRepo, false, false); err != nil {
   323  		return nil, err
   324  	}
   325  
   326  	repoPath := generateRepo.RepoPath()
   327  	isExist, err := util.IsExist(repoPath)
   328  	if err != nil {
   329  		log.Error("Unable to check if %s exists. Error: %v", repoPath, err)
   330  		return nil, err
   331  	}
   332  	if isExist {
   333  		return nil, repo_model.ErrRepoFilesAlreadyExist{
   334  			Uname: generateRepo.OwnerName,
   335  			Name:  generateRepo.Name,
   336  		}
   337  	}
   338  
   339  	if err = checkInitRepository(ctx, owner.Name, generateRepo.Name); err != nil {
   340  		return generateRepo, err
   341  	}
   342  
   343  	if err = CheckDaemonExportOK(ctx, generateRepo); err != nil {
   344  		return generateRepo, fmt.Errorf("checkDaemonExportOK: %w", err)
   345  	}
   346  
   347  	if stdout, _, err := git.NewCommand(ctx, "update-server-info").
   348  		SetDescription(fmt.Sprintf("GenerateRepository(git update-server-info): %s", repoPath)).
   349  		RunStdString(&git.RunOpts{Dir: repoPath}); err != nil {
   350  		log.Error("GenerateRepository(git update-server-info) in %v: Stdout: %s\nError: %v", generateRepo, stdout, err)
   351  		return generateRepo, fmt.Errorf("error in GenerateRepository(git update-server-info): %w", err)
   352  	}
   353  
   354  	return generateRepo, nil
   355  }