diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index 7bec25d86a8..138bb94807a 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -30,6 +30,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/extensions" "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" @@ -53,8 +54,14 @@ func newInitFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *in func newInitCmd() *cobra.Command { return &cobra.Command{ - Use: "init", + Use: "init [directory]", Short: "Initialize a new application.", + Long: `Initialize a new application. + +When used with --template, a new directory is created (named after the template) +and the project is initialized inside it — similar to git clone. +Pass "." as the directory to initialize in the current directory instead.`, + Args: cobra.MaximumNArgs(1), } } @@ -134,6 +141,7 @@ type initAction struct { cmdRun exec.CommandRunner gitCli *git.Cli flags *initFlags + args []string repoInitializer *repository.Initializer templateManager *templates.TemplateManager featuresManager *alpha.FeatureManager @@ -151,6 +159,7 @@ func newInitAction( console input.Console, gitCli *git.Cli, flags *initFlags, + args []string, repoInitializer *repository.Initializer, templateManager *templates.TemplateManager, featuresManager *alpha.FeatureManager, @@ -167,6 +176,7 @@ func newInitAction( cmdRun: cmdRun, gitCli: gitCli, flags: flags, + args: args, repoInitializer: repoInitializer, templateManager: templateManager, featuresManager: featuresManager, @@ -184,9 +194,6 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) { return nil, fmt.Errorf("getting cwd: %w", err) } - azdCtx := azdcontext.NewAzdContextWithDirectory(wd) - i.lazyAzdCtx.SetValue(azdCtx) - if i.flags.templateBranch != "" && i.flags.templatePath == "" { return nil, &internal.ErrorWithSuggestion{ Err: internal.ErrBranchRequiresTemplate, @@ -194,6 +201,79 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) { } } + // Validate init-mode combinations before any filesystem side effects. + isTemplateInit := i.flags.templatePath != "" || len(i.flags.templateTags) > 0 + initModeCount := 0 + if isTemplateInit { + initModeCount++ + } + if i.flags.fromCode { + initModeCount++ + } + if i.flags.minimal { + initModeCount++ + } + if initModeCount > 1 { + return nil, &internal.ErrorWithSuggestion{ + Err: internal.ErrMultipleInitModes, + Suggestion: "Choose one: 'azd init --template ', 'azd init --from-code', or 'azd init --minimal'.", + } + } + + // The positional [directory] argument is only valid with --template. + if len(i.args) > 0 && !isTemplateInit { + return nil, &internal.ErrorWithSuggestion{ + Err: fmt.Errorf("positional [directory] argument requires --template"), + Suggestion: "Use 'azd init --template [directory]' to initialize " + + "a template into a new directory.", + } + } + + // Resolve local template paths to absolute before any chdir so that + // relative paths like ../my-template resolve against the original CWD. + if i.flags.templatePath != "" && templates.LooksLikeLocalPath(i.flags.templatePath) { + absPath, err := filepath.Abs(i.flags.templatePath) + if err == nil { + i.flags.templatePath = absPath + } + } + + // When a template is specified, auto-create a project directory (like git clone). + // The user can pass a positional [directory] argument to override the folder name, + // or pass "." to use the current directory (preserving existing behavior). + createdProjectDir := "" + originalWd := wd + + if isTemplateInit { + targetDir, err := i.resolveTargetDirectory(wd) + if err != nil { + return nil, err + } + + if targetDir != wd { + // Check if target already exists and is non-empty + if err := i.validateTargetDirectory(ctx, targetDir); err != nil { + return nil, err + } + + if err := os.MkdirAll(targetDir, osutil.PermissionDirectory); err != nil { + return nil, fmt.Errorf("creating project directory '%s': %w", + filepath.Base(targetDir), err) + } + + if err := os.Chdir(targetDir); err != nil { + return nil, fmt.Errorf("changing to project directory '%s': %w", + filepath.Base(targetDir), err) + } + + wd = targetDir + createdProjectDir = targetDir + } + } + + azdCtx := azdcontext.NewAzdContextWithDirectory(wd) + i.lazyAzdCtx.SetValue(azdCtx) + // ensure that git is available if err := tools.EnsureInstalled(ctx, []tools.ExternalTool{i.gitCli}...); err != nil { return nil, err @@ -238,26 +318,11 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) { } var initTypeSelect initType = initUnknown - initTypeCount := 0 - if i.flags.templatePath != "" || len(i.flags.templateTags) > 0 { - initTypeCount++ + if isTemplateInit { initTypeSelect = initAppTemplate - } - if i.flags.fromCode { - initTypeCount++ + } else if i.flags.fromCode || i.flags.minimal { initTypeSelect = initFromApp } - if i.flags.minimal { - initTypeCount++ - initTypeSelect = initFromApp // Minimal now also uses initFromApp path - } - - if initTypeCount > 1 { - return nil, &internal.ErrorWithSuggestion{ - Err: internal.ErrMultipleInitModes, - Suggestion: "Choose one: 'azd init --template ', 'azd init --from-code', or 'azd init --minimal'.", - } - } if initTypeSelect == initUnknown { if existingProject { @@ -281,6 +346,21 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) { output.WithLinkFormat("%s", wd), output.WithLinkFormat("%s", "https://aka.ms/azd-third-party-code-notice")) + if createdProjectDir != "" { + // Compute a user-friendly cd path relative to where they started + cdPath, relErr := filepath.Rel(originalWd, createdProjectDir) + if relErr != nil { + cdPath = createdProjectDir // Fall back to absolute path + } + // Quote the path when it contains whitespace so the hint is copy/paste-safe + cdPathDisplay := cdPath + if strings.ContainsAny(cdPath, " \t") { + cdPathDisplay = fmt.Sprintf("%q", cdPath) + } + followUp += fmt.Sprintf("\n\nChange to the project directory:\n %s", + output.WithHighLightFormat("cd %s", cdPathDisplay)) + } + if i.featuresManager.IsEnabled(agentcopilot.FeatureCopilot) { followUp += fmt.Sprintf("\n\n%s Run %s to deploy project to the cloud.", output.WithHintFormat("(→) NEXT STEPS:"), @@ -813,13 +893,21 @@ func (i *initAction) initializeExtensions(ctx context.Context, azdCtx *azdcontex } func getCmdInitHelpDescription(*cobra.Command) string { - return generateCmdHelpDescription("Initialize a new application in your current directory.", + return generateCmdHelpDescription( + "Initialize a new application. When using --template, creates a project directory automatically.", []string{ formatHelpNote( fmt.Sprintf("Running %s without flags specified will prompt "+ "you to initialize using your existing code, or from a template.", output.WithHighLightFormat("init"), )), + formatHelpNote( + fmt.Sprintf("When using %s, a new directory is created "+ + "(named after the template) and the project is initialized inside it. "+ + "Pass %s as the directory to use the current directory instead.", + output.WithHighLightFormat("--template"), + output.WithHighLightFormat("."), + )), formatHelpNote( "To view all available sample templates, including those submitted by the azd community, visit: " + output.WithLinkFormat("https://azure.github.io/awesome-azd") + "."), @@ -828,11 +916,16 @@ func getCmdInitHelpDescription(*cobra.Command) string { func getCmdInitHelpFooter(*cobra.Command) string { return generateCmdHelpSamplesBlock(map[string]string{ - "Initialize a template to your current local directory from a GitHub repo.": fmt.Sprintf("%s %s", + "Initialize a template into a new project directory.": fmt.Sprintf("%s %s", + output.WithHighLightFormat("azd init --template"), + output.WithWarningFormat("[GitHub repo URL]"), + ), + "Initialize a template into the current directory.": fmt.Sprintf("%s %s %s", output.WithHighLightFormat("azd init --template"), output.WithWarningFormat("[GitHub repo URL]"), + output.WithHighLightFormat("."), ), - "Initialize a template to your current local directory from a branch other than main.": fmt.Sprintf("%s %s %s %s", + "Initialize a template from a branch other than main.": fmt.Sprintf("%s %s %s %s", output.WithHighLightFormat("azd init --template"), output.WithWarningFormat("[GitHub repo URL]"), output.WithHighLightFormat("--branch"), @@ -910,3 +1003,74 @@ type initModeRequiredErrorOptions struct { Description string `json:"description"` Command string `json:"command"` } + +// resolveTargetDirectory determines the target directory for template initialization. +// It returns the current working directory when "." is passed or no template is specified, +// otherwise it derives or uses the explicit directory name. +func (i *initAction) resolveTargetDirectory(wd string) (string, error) { + if len(i.args) > 0 { + dirArg := i.args[0] + if dirArg == "." { + return wd, nil + } + + if filepath.IsAbs(dirArg) { + return dirArg, nil + } + + return filepath.Join(wd, dirArg), nil + } + + // No positional arg: auto-derive from template path + if i.flags.templatePath != "" { + dirName := templates.DeriveDirectoryName(i.flags.templatePath) + return filepath.Join(wd, dirName), nil + } + + // Template selected via --filter tags (interactive selection) — use CWD + return wd, nil +} + +// validateTargetDirectory checks that the target directory is safe to use. +// If it already exists and is non-empty, it prompts the user for confirmation +// or returns an error in non-interactive mode. +func (i *initAction) validateTargetDirectory(ctx context.Context, targetDir string) error { + f, err := os.Open(targetDir) + if errors.Is(err, os.ErrNotExist) { + return nil // Directory doesn't exist yet — will be created + } + if err != nil { + return fmt.Errorf("reading directory '%s': %w", filepath.Base(targetDir), err) + } + + // Read a single entry to check emptiness without loading the full listing. + names, _ := f.Readdirnames(1) + f.Close() + + if len(names) == 0 { + return nil // Empty directory is fine + } + + dirName := filepath.Base(targetDir) + + if i.console.IsNoPromptMode() { + return fmt.Errorf( + "directory '%s' already exists and is not empty; "+ + "use '.' to initialize in the current directory instead", dirName) + } + + proceed, err := i.console.Confirm(ctx, input.ConsoleOptions{ + Message: fmt.Sprintf( + "Directory '%s' already exists and is not empty. Initialize here anyway?", dirName), + DefaultValue: false, + }) + if err != nil { + return fmt.Errorf("prompting for directory confirmation: %w", err) + } + + if !proceed { + return errors.New("initialization cancelled") + } + + return nil +} diff --git a/cli/azd/cmd/init_test.go b/cli/azd/cmd/init_test.go index 05891a0fb11..59945ce58b7 100644 --- a/cli/azd/cmd/init_test.go +++ b/cli/azd/cmd/init_test.go @@ -25,7 +25,9 @@ import ( // setupInitAction creates an initAction wired with mocks that pass git-install checks. // The working directory is changed to a temp dir so that .env loading and azdcontext work. -func setupInitAction(t *testing.T, mockContext *mocks.MockContext, flags *initFlags) *initAction { +func setupInitAction( + t *testing.T, mockContext *mocks.MockContext, flags *initFlags, args ...string, +) *initAction { t.Helper() // Work in a temp directory so os.Getwd / godotenv.Overload operate in isolation. @@ -50,6 +52,7 @@ func setupInitAction(t *testing.T, mockContext *mocks.MockContext, flags *initFl cmdRun: mockContext.CommandRunner, gitCli: gitCli, flags: flags, + args: args, featuresManager: alpha.NewFeaturesManagerWithConfig(config.NewEmptyConfig()), } } @@ -231,3 +234,195 @@ func TestInitFailFastMissingEnvNonInteractive(t *testing.T) { } }) } + +func TestInitResolveTargetDirectory(t *testing.T) { + t.Run("DotArgUsesCwd", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + flags := &initFlags{ + templatePath: "owner/repo", + global: &internal.GlobalCommandOptions{}, + } + action := setupInitAction(t, mockContext, flags, ".") + + wd, err := os.Getwd() + require.NoError(t, err) + + result, err := action.resolveTargetDirectory(wd) + require.NoError(t, err) + require.Equal(t, wd, result) + }) + + t.Run("ExplicitDirectoryUsesArg", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + flags := &initFlags{ + templatePath: "owner/repo", + global: &internal.GlobalCommandOptions{}, + } + action := setupInitAction(t, mockContext, flags, "my-project") + + wd, err := os.Getwd() + require.NoError(t, err) + + result, err := action.resolveTargetDirectory(wd) + require.NoError(t, err) + require.Equal(t, filepath.Join(wd, "my-project"), result) + }) + + t.Run("NoArgDerivesFromTemplatePath", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + flags := &initFlags{ + templatePath: "Azure-Samples/todo-nodejs-mongo", + global: &internal.GlobalCommandOptions{}, + } + action := setupInitAction(t, mockContext, flags) + + wd, err := os.Getwd() + require.NoError(t, err) + + result, err := action.resolveTargetDirectory(wd) + require.NoError(t, err) + require.Equal(t, filepath.Join(wd, "todo-nodejs-mongo"), result) + }) + + t.Run("NoArgWithFilterTagsUsesCwd", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + flags := &initFlags{ + templateTags: []string{"python"}, + global: &internal.GlobalCommandOptions{}, + } + action := setupInitAction(t, mockContext, flags) + + wd, err := os.Getwd() + require.NoError(t, err) + + result, err := action.resolveTargetDirectory(wd) + require.NoError(t, err) + require.Equal(t, wd, result) + }) + + t.Run("TemplateWithDotGitSuffix", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + flags := &initFlags{ + templatePath: "https://github.com/Azure-Samples/todo-nodejs-mongo.git", + global: &internal.GlobalCommandOptions{}, + } + action := setupInitAction(t, mockContext, flags) + + wd, err := os.Getwd() + require.NoError(t, err) + + result, err := action.resolveTargetDirectory(wd) + require.NoError(t, err) + require.Equal(t, filepath.Join(wd, "todo-nodejs-mongo"), result) + }) +} + +func TestInitValidateTargetDirectory(t *testing.T) { + t.Run("NonExistentDirectoryIsValid", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + flags := &initFlags{ + templatePath: "owner/repo", + global: &internal.GlobalCommandOptions{}, + } + action := setupInitAction(t, mockContext, flags) + + err := action.validateTargetDirectory( + *mockContext.Context, filepath.Join(t.TempDir(), "nonexistent")) + require.NoError(t, err) + }) + + t.Run("EmptyDirectoryIsValid", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + flags := &initFlags{ + templatePath: "owner/repo", + global: &internal.GlobalCommandOptions{}, + } + action := setupInitAction(t, mockContext, flags) + + emptyDir := t.TempDir() + err := action.validateTargetDirectory(*mockContext.Context, emptyDir) + require.NoError(t, err) + }) + + t.Run("NonEmptyDirectoryErrorsInNoPromptMode", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + mockContext.Console.SetNoPromptMode(true) + flags := &initFlags{ + templatePath: "owner/repo", + global: &internal.GlobalCommandOptions{NoPrompt: true}, + } + action := setupInitAction(t, mockContext, flags) + + nonEmptyDir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(nonEmptyDir, "existing.txt"), []byte("content"), 0600)) + + err := action.validateTargetDirectory(*mockContext.Context, nonEmptyDir) + require.Error(t, err) + require.Contains(t, err.Error(), "already exists and is not empty") + }) +} + +func TestInitCreatesProjectDirectory(t *testing.T) { + t.Run("TemplateInitCreatesDirectory", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + mockContext.Console.SetNoPromptMode(true) + flags := &initFlags{ + templatePath: "Azure-Samples/todo-nodejs-mongo", + global: &internal.GlobalCommandOptions{NoPrompt: true}, + } + flags.EnvironmentName = "testenv" + action := setupInitAction(t, mockContext, flags) + + wd, err := os.Getwd() + require.NoError(t, err) + + expectedDir := filepath.Join(wd, "todo-nodejs-mongo") + require.NoDirExists(t, expectedDir) + + // Run will panic or error later due to missing template mocks, + // but the directory should be created before that point. + _ = runActionSafe(*mockContext.Context, action) + require.DirExists(t, expectedDir) + }) + + t.Run("DotArgDoesNotCreateDirectory", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + mockContext.Console.SetNoPromptMode(true) + flags := &initFlags{ + templatePath: "Azure-Samples/todo-nodejs-mongo", + global: &internal.GlobalCommandOptions{NoPrompt: true}, + } + flags.EnvironmentName = "testenv" + action := setupInitAction(t, mockContext, flags, ".") + + wd, err := os.Getwd() + require.NoError(t, err) + + // Should NOT create a todo-nodejs-mongo subdirectory + _ = runActionSafe(*mockContext.Context, action) + + derivedDir := filepath.Join(wd, "todo-nodejs-mongo") + require.NoDirExists(t, derivedDir) + }) + + t.Run("ExplicitDirArgCreatesNamedDirectory", func(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + mockContext.Console.SetNoPromptMode(true) + flags := &initFlags{ + templatePath: "Azure-Samples/todo-nodejs-mongo", + global: &internal.GlobalCommandOptions{NoPrompt: true}, + } + flags.EnvironmentName = "testenv" + action := setupInitAction(t, mockContext, flags, "my-custom-project") + + wd, err := os.Getwd() + require.NoError(t, err) + + expectedDir := filepath.Join(wd, "my-custom-project") + require.NoDirExists(t, expectedDir) + + _ = runActionSafe(*mockContext.Context, action) + require.DirExists(t, expectedDir) + }) +} diff --git a/cli/azd/cmd/testdata/TestFigSpec.ts b/cli/azd/cmd/testdata/TestFigSpec.ts index 1070af51d1b..284e8e2556e 100644 --- a/cli/azd/cmd/testdata/TestFigSpec.ts +++ b/cli/azd/cmd/testdata/TestFigSpec.ts @@ -2430,6 +2430,10 @@ const completionSpec: Fig.Spec = { description: 'Provision and deploy to Azure after initializing the project from a template.', }, ], + args: { + name: 'directory', + isOptional: true, + }, }, { name: ['mcp'], diff --git a/cli/azd/cmd/testdata/TestUsage-azd-init.snap b/cli/azd/cmd/testdata/TestUsage-azd-init.snap index 7f09a7a7188..d991b14513e 100644 --- a/cli/azd/cmd/testdata/TestUsage-azd-init.snap +++ b/cli/azd/cmd/testdata/TestUsage-azd-init.snap @@ -1,11 +1,12 @@ -Initialize a new application in your current directory. +Initialize a new application. When using --template, creates a project directory automatically. • Running init without flags specified will prompt you to initialize using your existing code, or from a template. + • When using --template, a new directory is created (named after the template) and the project is initialized inside it. Pass . as the directory to use the current directory instead. • To view all available sample templates, including those submitted by the azd community, visit: https://azure.github.io/awesome-azd. Usage - azd init [flags] + azd init [directory] [flags] Flags -b, --branch string : The template branch to initialize from. Must be used with a template argument (--template or -t). @@ -26,10 +27,13 @@ Global Flags --no-prompt : Accepts the default value instead of prompting, or it fails if there is no default. Examples - Initialize a template to your current local directory from a GitHub repo. + Initialize a template from a branch other than main. + azd init --template [GitHub repo URL] --branch [Branch name] + + Initialize a template into a new project directory. azd init --template [GitHub repo URL] - Initialize a template to your current local directory from a branch other than main. - azd init --template [GitHub repo URL] --branch [Branch name] + Initialize a template into the current directory. + azd init --template [GitHub repo URL] . diff --git a/cli/azd/pkg/templates/path.go b/cli/azd/pkg/templates/path.go index 4201b00ac03..8136f0b4046 100644 --- a/cli/azd/pkg/templates/path.go +++ b/cli/azd/pkg/templates/path.go @@ -47,7 +47,7 @@ func Absolute(path string) (string, error) { // reference (./..., ../..., or an absolute path). Bare names like "my-template" or // "owner/repo" always resolve to GitHub URLs, even if a same-named local directory exists, // to avoid silently overriding remote template resolution. - if looksLikeLocalPath(path) { + if LooksLikeLocalPath(path) { // Use Lstat to reject symlinks consistently with copyLocalTemplate. if info, err := os.Lstat(path); err == nil { if info.Mode()&os.ModeSymlink != 0 { @@ -97,9 +97,9 @@ func IsLocalPath(resolvedPath string) bool { return !isRemoteURI(resolvedPath) } -// looksLikeLocalPath returns true if the path appears to be an explicit local filesystem reference +// LooksLikeLocalPath returns true if the path appears to be an explicit local filesystem reference // (e.g., ".", "..", starts with ./, ../, or is an absolute path). -func looksLikeLocalPath(path string) bool { +func LooksLikeLocalPath(path string) bool { return path == "." || path == ".." || strings.HasPrefix(path, "./") || @@ -108,3 +108,58 @@ func looksLikeLocalPath(path string) bool { strings.HasPrefix(path, `.\`) || filepath.IsAbs(path) } + +// DeriveDirectoryName extracts a directory name from a template path, +// following git clone conventions. For example: +// +// - "todo-nodejs-mongo" → "todo-nodejs-mongo" +// - "Azure-Samples/todo-nodejs-mongo" → "todo-nodejs-mongo" +// - "https://github.com/Azure-Samples/todo-nodejs-mongo" → "todo-nodejs-mongo" +// - "https://github.com/Azure-Samples/todo-nodejs-mongo.git" → "todo-nodejs-mongo" +// - "../my-template" → "my-template" +func DeriveDirectoryName(templatePath string) string { + path := strings.TrimSpace(templatePath) + path = strings.TrimRight(path, "/") + + // Strip .git suffix (like git clone does) + path = strings.TrimSuffix(path, ".git") + + var name string + + // For remote URIs, extract the last path segment from the URL + if isRemoteURI(path) { + // Handle git@host:owner/repo format + if strings.HasPrefix(path, "git@") { + if idx := strings.LastIndex(path, ":"); idx >= 0 { + path = path[idx+1:] + } + } + + // Take the last path segment + if idx := strings.LastIndex(path, "/"); idx >= 0 { + name = path[idx+1:] + } else { + name = path + } + } else { + // For local paths and bare names, use the last path component + name = filepath.Base(path) + } + + // Reject unsafe directory names that could cause path traversal + if name == "." || name == ".." || name == "" { + // Fall back to a sanitized version of the full path + name = strings.NewReplacer("/", "-", "\\", "-", ":", "-").Replace( + strings.TrimRight(templatePath, "/")) + // Trim leading dots and dashes from the sanitized name + name = strings.TrimLeft(name, ".-") + } + + // Final safety: if the name is still empty or unsafe after sanitization, + // use a generic fallback + if name == "" || name == "." || name == ".." { + name = "new-project" + } + + return name +} diff --git a/cli/azd/pkg/templates/path_test.go b/cli/azd/pkg/templates/path_test.go index a670f0e4754..4bd1b306a59 100644 --- a/cli/azd/pkg/templates/path_test.go +++ b/cli/azd/pkg/templates/path_test.go @@ -198,6 +198,107 @@ func Test_Absolute_OwnerRepoResolvesToGitHub(t *testing.T) { require.Equal(t, "https://github.com/owner/repo", result) } +func Test_DeriveDirectoryName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "BareRepoName", + input: "todo-nodejs-mongo", + expected: "todo-nodejs-mongo", + }, + { + name: "OwnerRepo", + input: "Azure-Samples/todo-nodejs-mongo", + expected: "todo-nodejs-mongo", + }, + { + name: "HttpsURL", + input: "https://github.com/Azure-Samples/todo-nodejs-mongo", + expected: "todo-nodejs-mongo", + }, + { + name: "HttpsURLWithDotGit", + input: "https://github.com/Azure-Samples/todo-nodejs-mongo.git", + expected: "todo-nodejs-mongo", + }, + { + name: "HttpsURLTrailingSlash", + input: "https://github.com/Azure-Samples/todo-nodejs-mongo/", + expected: "todo-nodejs-mongo", + }, + { + name: "GitAtURI", + input: "git@github.com:Azure-Samples/todo-nodejs-mongo.git", + expected: "todo-nodejs-mongo", + }, + { + name: "SshURL", + input: "ssh://git@github.com/Azure-Samples/todo-nodejs-mongo.git", + expected: "todo-nodejs-mongo", + }, + { + name: "GitProtocolURL", + input: "git://github.com/Azure-Samples/todo-nodejs-mongo.git", + expected: "todo-nodejs-mongo", + }, + { + name: "RelativePath", + input: "../my-template", + expected: "my-template", + }, + { + name: "DotSlashRelativePath", + input: "./my-template", + expected: "my-template", + }, + { + name: "AbsolutePath", + input: "/home/user/projects/my-template", + expected: "my-template", + }, + { + name: "BareNameTrailingSlash", + input: "todo-nodejs-mongo/", + expected: "todo-nodejs-mongo", + }, + { + name: "NestedLocalPath", + input: "../projects/my-template", + expected: "my-template", + }, + { + name: "FileURL", + input: "file:///home/user/my-template", + expected: "my-template", + }, + { + name: "DotDotTraversalIsSanitized", + input: "owner/..", + expected: "owner-..", + }, + { + name: "BareDotDotFallsBackToDefault", + input: "..", + expected: "new-project", + }, + { + name: "BareDotFallsBackToDefault", + input: ".", + expected: "new-project", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := DeriveDirectoryName(tt.input) + require.Equal(t, tt.expected, result) + }) + } +} + func Test_IsLocalPath(t *testing.T) { tests := []struct { name string