cli/azd/internal/repository/initializer.go (478 lines of code) (raw):

// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. // Package repository provides handling of files in the user's code repository. package repository import ( "bufio" "context" "errors" "fmt" "io/fs" "log" "maps" "os" "path/filepath" "strings" "github.com/azure/azure-dev/cli/azd/pkg/alpha" "github.com/azure/azure-dev/cli/azd/pkg/environment" "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/lazy" "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/output" "github.com/azure/azure-dev/cli/azd/pkg/output/ux" "github.com/azure/azure-dev/cli/azd/pkg/project" "github.com/azure/azure-dev/cli/azd/pkg/templates" "github.com/azure/azure-dev/cli/azd/pkg/tools/dotnet" "github.com/azure/azure-dev/cli/azd/pkg/tools/git" "github.com/azure/azure-dev/cli/azd/resources" "github.com/joho/godotenv" "github.com/otiai10/copy" ) // Initializer handles the initialization of a local repository. type Initializer struct { console input.Console gitCli *git.Cli dotnetCli *dotnet.Cli features *alpha.FeatureManager lazyEnvManager *lazy.Lazy[environment.Manager] } func NewInitializer( console input.Console, gitCli *git.Cli, dotnetCli *dotnet.Cli, features *alpha.FeatureManager, lazyEnvManager *lazy.Lazy[environment.Manager], ) *Initializer { return &Initializer{ console: console, gitCli: gitCli, lazyEnvManager: lazyEnvManager, dotnetCli: dotnetCli, features: features, } } // Initializes a local repository in the project directory from a remote repository. // // A confirmation prompt is displayed for any existing files to be overwritten. func (i *Initializer) Initialize( ctx context.Context, azdCtx *azdcontext.AzdContext, template *templates.Template, templateBranch string) error { var err error stepMessage := fmt.Sprintf("Downloading template code to: %s", output.WithLinkFormat("%s", azdCtx.ProjectDirectory())) i.console.ShowSpinner(ctx, stepMessage, input.Step) defer i.console.StopSpinner(ctx, stepMessage+"\n", input.GetStepResultFormat(err)) staging, err := os.MkdirTemp("", "az-dev-template") if err != nil { return fmt.Errorf("creating temp folder: %w", err) } // Attempt to remove the temporary directory we cloned the template into, but don't fail the // overall operation if we can't. defer func() { _ = os.RemoveAll(staging) }() target := azdCtx.ProjectDirectory() templateUrl, err := templates.Absolute(template.RepositoryPath) if err != nil { return err } filesWithExecPerms, err := i.fetchCode(ctx, templateUrl, templateBranch, staging) if err != nil { return err } skipStagingFiles, err := i.promptForDuplicates(ctx, staging, target) if err != nil { return err } isEmpty, err := osutil.IsDirEmpty(target) if err != nil { return err } options := copy.Options{} if skipStagingFiles != nil { options.Skip = func(fileInfo os.FileInfo, src, dest string) (bool, error) { if _, shouldSkip := skipStagingFiles[src]; shouldSkip { return true, nil } return false, nil } } if err := copy.Copy(staging, target, options); err != nil { return fmt.Errorf("copying template contents from temp staging directory: %w", err) } err = i.writeCoreAssets(ctx, azdCtx) if err != nil { return err } if err := i.initializeProject(ctx, azdCtx, &template.Metadata); err != nil { return fmt.Errorf("initializing project: %w", err) } err = i.gitInitialize(ctx, target, filesWithExecPerms, isEmpty) if err != nil { return err } i.console.StopSpinner(ctx, stepMessage+"\n", input.GetStepResultFormat(err)) return nil } func (i *Initializer) fetchCode( ctx context.Context, templateUrl string, templateBranch string, destination string) (executableFilePaths []string, err error) { err = i.gitCli.ShallowClone(ctx, templateUrl, templateBranch, destination) if err != nil { return nil, fmt.Errorf("fetching template: %w", err) } stagedFilesOutput, err := i.gitCli.ListStagedFiles(ctx, destination) if err != nil { return nil, fmt.Errorf("listing files with permissions: %w", err) } executableFilePaths, err = parseExecutableFiles(stagedFilesOutput) if err != nil { return nil, fmt.Errorf("parsing file permissions output: %w", err) } if err := os.RemoveAll(filepath.Join(destination, ".git")); err != nil { return nil, fmt.Errorf("removing .git folder after clone: %w", err) } return executableFilePaths, nil } // promptForDuplicates prompts the user for any duplicate files detected. // The list of absolute source file paths to skip are returned. func (i *Initializer) promptForDuplicates( ctx context.Context, staging string, target string) (skipSourceFiles map[string]struct{}, err error) { log.Printf( "template init, checking for duplicates. source: %s target: %s", staging, target, ) duplicateFiles, err := determineDuplicates(staging, target) if err != nil { return nil, fmt.Errorf("checking for overwrites: %w", err) } if len(duplicateFiles) > 0 { i.console.StopSpinner(ctx, "", input.StepDone) i.console.MessageUxItem(ctx, &ux.WarningMessage{ Description: "The following files are present both locally and in the template:", }) for _, file := range duplicateFiles { i.console.Message(ctx, fmt.Sprintf(" * %s", file)) } selection, err := i.console.Select(ctx, input.ConsoleOptions{ Message: "What would you like to do with these files?", Options: []string{ "Overwrite with versions from template", "Keep my existing files unchanged", }, }) if err != nil { return nil, fmt.Errorf("prompting to overwrite: %w", err) } switch selection { case 0: // overwrite return nil, nil case 1: // keep skipSourceFiles = make(map[string]struct{}, len(duplicateFiles)) for _, file := range duplicateFiles { // this also cleans the result, which is important for matching sourceFile := filepath.Join(staging, file) skipSourceFiles[sourceFile] = struct{}{} } return skipSourceFiles, nil } } return nil, nil } func (i *Initializer) gitInitialize(ctx context.Context, target string, executableFilesToRestore []string, stageAllFiles bool) error { err := i.ensureGitRepository(ctx, target) if err != nil { return err } // Set executable files for _, executableFile := range executableFilesToRestore { err = i.gitCli.AddFileExecPermission(ctx, target, executableFile) if err != nil { return fmt.Errorf("restoring file permissions: %w", err) } } if stageAllFiles { err = i.gitCli.AddFile(ctx, target, "*") if err != nil { return fmt.Errorf("staging newly fetched template files: %w", err) } } return nil } func (i *Initializer) ensureGitRepository(ctx context.Context, repoPath string) error { _, err := i.gitCli.GetCurrentBranch(ctx, repoPath) if err != nil { if !errors.Is(err, git.ErrNotRepository) { return fmt.Errorf("determining current git repository state: %w", err) } err = i.gitCli.InitRepo(ctx, repoPath) if err != nil { return fmt.Errorf("initializing git repository: %w", err) } i.console.MessageUxItem(ctx, &ux.DoneMessage{Message: "Initialized git repository"}) } return nil } // Initialize the project with any metadata values from the template func (i *Initializer) initializeProject( ctx context.Context, azdCtx *azdcontext.AzdContext, templateMetaData *templates.Metadata, ) error { if templateMetaData == nil || len(templateMetaData.Project) == 0 { return nil } projectPath := azdCtx.ProjectPath() projectConfig, err := project.LoadConfig(ctx, projectPath) if err != nil { return fmt.Errorf("loading project config: %w", err) } for key, value := range templateMetaData.Project { if err := projectConfig.Set(key, value); err != nil { return fmt.Errorf("setting project config: %w", err) } } return project.SaveConfig(ctx, projectConfig, projectPath) } func parseExecutableFiles(stagedFilesOutput string) ([]string, error) { scanner := bufio.NewScanner(strings.NewReader(stagedFilesOutput)) executableFiles := []string{} for scanner.Scan() { // Format for git ls --stage: // <mode> <object> <stage>\t<file> // In other words, space delimited for first three properties, tab delimited before filepath is present4ed // Scan first word to obtain <mode> advance, word, err := bufio.ScanWords(scanner.Bytes(), false) if err != nil { return nil, err } // 100755 is the only possible mode for git-tracked executable files if string(word) == "100755" { // Advance to past '\t', taking the remainder which is <file> _, filepath, found := strings.Cut(scanner.Text()[advance:], "\t") if !found { return nil, errors.New("invalid staged files output format, missing file path") } executableFiles = append(executableFiles, filepath) } } return executableFiles, nil } // Initializes a minimal azd project. func (i *Initializer) InitializeMinimal(ctx context.Context, azdCtx *azdcontext.AzdContext) error { projectDir := azdCtx.ProjectDirectory() var err error projectFormatted := output.WithLinkFormat("%s", projectDir) i.console.ShowSpinner(ctx, fmt.Sprintf("Creating minimal project files at: %s", projectFormatted), input.Step) defer i.console.StopSpinner(ctx, fmt.Sprintf("Created minimal project files at: %s", projectFormatted)+"\n", input.GetStepResultFormat(err)) isEmpty, err := osutil.IsDirEmpty(projectDir) if err != nil { return err } err = i.writeCoreAssets(ctx, azdCtx) if err != nil { return err } projectConfig, err := project.Load(ctx, azdCtx.ProjectPath()) if err != nil { return err } // Default infra path if not specified infraPath := projectConfig.Infra.Path if infraPath == "" { infraPath = project.DefaultPath } err = os.MkdirAll(infraPath, osutil.PermissionDirectory) if err != nil { return err } module := projectConfig.Infra.Module if projectConfig.Infra.Module == "" { module = project.DefaultModule } mainPath := filepath.Join(infraPath, module) retryInfix := ".azd" err = i.writeFileSafe( ctx, fmt.Sprintf("%s.bicep", mainPath), retryInfix, resources.MinimalBicep, osutil.PermissionFile) if err != nil { return err } err = i.writeFileSafe( ctx, fmt.Sprintf("%s.parameters.json", mainPath), retryInfix, resources.MinimalBicepParameters, osutil.PermissionFile) if err != nil { return err } err = i.gitInitialize(ctx, projectDir, []string{}, isEmpty) if err != nil { return err } return nil } // writeFileSafe writes a file to path but only if it doesn't already exist. // If it does exist, an extra attempt is performed to write the file with the retryInfix appended to the filename, // before the file extension. // If both files exist, no action is taken. func (i *Initializer) writeFileSafe( ctx context.Context, path string, retryInfix string, content []byte, perm fs.FileMode) error { _, err := os.Stat(path) if errors.Is(err, os.ErrNotExist) { return os.WriteFile(path, content, perm) } if err != nil { return err } if retryInfix == "" { return nil } ext := filepath.Ext(path) pathNoExt := strings.TrimSuffix(path, ext) renamed := pathNoExt + retryInfix + ext _, err = os.Stat(renamed) if errors.Is(err, os.ErrNotExist) { i.console.MessageUxItem( ctx, &ux.WarningMessage{ Description: fmt.Sprintf("A file already exists at %s, writing to %s instead", path, renamed), }) return os.WriteFile(renamed, content, perm) } // If both files exist, do nothing. We don't want to accidentally overwrite a user's file. return err } func (i *Initializer) writeCoreAssets(ctx context.Context, azdCtx *azdcontext.AzdContext) error { // Check to see if `azure.yaml` exists, and if it doesn't, create it. if _, err := os.Stat(azdCtx.ProjectPath()); errors.Is(err, os.ErrNotExist) { _, err = project.New(ctx, azdCtx.ProjectPath(), azdcontext.ProjectName(azdCtx.ProjectDirectory())) if err != nil { return fmt.Errorf("failed to create a project file: %w", err) } i.console.MessageUxItem(ctx, &ux.DoneMessage{Message: fmt.Sprintf("Created a new %s file", azdcontext.ProjectFileName)}) } //create .azure when running azd init err := os.MkdirAll( filepath.Join(azdCtx.ProjectDirectory(), azdcontext.EnvironmentDirectoryName), osutil.PermissionDirectory, ) if err != nil { return fmt.Errorf("failed to create a directory: %w", err) } //create .gitignore or open existing .gitignore file, and contains .azure gitignoreFile, err := os.OpenFile( filepath.Join(azdCtx.ProjectDirectory(), ".gitignore"), os.O_APPEND|os.O_RDWR|os.O_CREATE, osutil.PermissionFile, ) if err != nil { return fmt.Errorf("fail to create or open .gitignore: %w", err) } defer gitignoreFile.Close() writeGitignoreFile := true // Determines newline based on the last line containing a newline useCrlf := false // default to true, since if the file is empty, no preceding newline is needed. hasTrailingNewLine := true //bufio scanner splits on new lines by default reader := bufio.NewReader(gitignoreFile) for { text, err := reader.ReadString('\n') if err == nil { // reset unless we're on the last line useCrlf = false } if err != nil && len(text) > 0 { // err != nil means no delimiter (newline) was found // if text is present, that must mean the last line doesn't contain newline hasTrailingNewLine = false } if len(text) > 0 && text[len(text)-1] == '\n' { text = text[0 : len(text)-1] } if len(text) > 0 && text[len(text)-1] == '\r' { text = text[0 : len(text)-1] useCrlf = true } // match on entire line // gitignore files can't have comments inline if azdcontext.EnvironmentDirectoryName == text { writeGitignoreFile = false break } // EOF if err != nil { break } } if writeGitignoreFile { newLine := "\n" if useCrlf { newLine = "\r\n" } appendContents := azdcontext.EnvironmentDirectoryName + newLine if !hasTrailingNewLine { appendContents = newLine + appendContents } _, err := gitignoreFile.WriteString(appendContents) if err != nil { return fmt.Errorf("fail to write '%s' in .gitignore: %w", azdcontext.EnvironmentDirectoryName, err) } } return nil } const allowNonEmptyEnvVar = "AZD_ALLOW_NON_EMPTY_FOLDER" func InitEnvFileValues() (map[string]string, error) { values, err := godotenv.Read() if err != nil { // ignore the error if the file does not exist if !os.IsNotExist(err) { return nil, fmt.Errorf("reading .env file: %w", err) } } // remove azd specific control variables maps.DeleteFunc(values, func(key, value string) bool { return key == allowNonEmptyEnvVar }) return values, nil } // PromptIfNonEmpty prompts the user for confirmation if the project directory to initialize in is non-empty. // Returns error if an error occurred while prompting, or if the user declines confirmation. func (i *Initializer) PromptIfNonEmpty(ctx context.Context, azdCtx *azdcontext.AzdContext) error { dir := azdCtx.ProjectDirectory() isEmpty, err := osutil.IsDirEmpty(dir) if err != nil { return err } if _, allowNonEmpty := os.LookupEnv(allowNonEmptyEnvVar); allowNonEmpty { return nil } if !isEmpty { _, err := i.gitCli.GetCurrentBranch(ctx, dir) if err != nil && !errors.Is(err, git.ErrNotRepository) { return fmt.Errorf("determining current git repository state: %w", err) } warningMessage := output.WithWarningFormat("WARNING: The current directory is not empty.") i.console.Message(ctx, warningMessage) i.console.Message(ctx, "Initializing an app in this directory may overwrite existing files.\n") message := fmt.Sprintf( "Continue initializing an app in '%s'?", dir) if err != nil { message = fmt.Sprintf( "Continue initializing an app here? This will also initialize a new git repository in '%s'.", dir) } confirm, err := i.console.Confirm(ctx, input.ConsoleOptions{ Message: message, }) if err != nil { return err } if !confirm { return fmt.Errorf("confirmation declined") } } return nil } // Returns files that are both present in source and target. // The files returned are expressed in their relative paths to source/target. func determineDuplicates(source string, target string) ([]string, error) { var duplicateFiles []string if err := filepath.WalkDir(source, func(path string, d fs.DirEntry, walkErr error) error { if walkErr != nil { return walkErr } if d.IsDir() { return nil } partial, err := filepath.Rel(source, path) if err != nil { return fmt.Errorf("computing relative path: %w", err) } if _, err := os.Stat(filepath.Join(target, partial)); err == nil { duplicateFiles = append(duplicateFiles, partial) } return nil }); err != nil { return nil, fmt.Errorf("enumerating template files: %w", err) } return duplicateFiles, nil }