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 }