Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 97 additions & 17 deletions cmd/apps/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,20 @@ func awaitTemplate(ctx context.Context, ch <-chan templateResult) (string, func(
}
}

// commitInPlace derives the app name from the cwd basename and verifies that
// the cwd is suitable for in-place scaffolding (empty modulo .git).
// Returns the derived app name on success.
func commitInPlace() (string, error) {
appName, err := prompt.DeriveInPlaceAppName(".")
if err != nil {
return "", err
}
if err := prompt.CheckInPlaceDirectory("."); err != nil {
return "", err
}
return appName, nil
}

// findProjectSrcDir locates the actual source directory inside a template.
// Templates may nest their content inside a {{.project_name}} directory.
func findProjectSrcDir(templateDir string) string {
Expand Down Expand Up @@ -834,31 +848,82 @@ func runCreate(ctx context.Context, opts createOptions) error {
}()

// Step 1: Get project name (clone runs in parallel for remote templates)
destDir := opts.name
if opts.outputDir != "" {
destDir = filepath.Join(opts.outputDir, opts.name)
if opts.name == prompt.InPlaceName && opts.outputDir != "" {
return prompt.ErrNameDotWithOutputDir
}

if opts.name == "" {
if !isInteractive {
return errors.New("--name is required in non-interactive mode")
}
name, err := prompt.PromptForProjectName(ctx, opts.outputDir)
var (
destDir string
inPlace bool
)
switch {
case opts.name == prompt.InPlaceName:
appName, err := commitInPlace()
if err != nil {
return err
}
opts.name = name
opts.name = appName
destDir = "."
inPlace = true
case opts.name != "":
if err := prompt.ValidateProjectName(opts.name); err != nil {
return err
}
destDir = opts.name
if opts.outputDir != "" {
destDir = filepath.Join(opts.outputDir, opts.name)
}
} else {
if err := prompt.ValidateProjectName(opts.name); err != nil {
return err
}
if _, err := os.Stat(destDir); err == nil {
return fmt.Errorf("directory %s already exists", destDir)
}
default:
if !isInteractive {
return errors.New("--name is required in non-interactive mode")
}
// Offer in-place scaffolding when the current directory is empty
// (modulo .git) and its basename is a valid app name. Skipped when
// --output-dir was set, since in-place targets cwd and would silently
// drop the flag — same reasoning as the --name . / --output-dir mutex
// above.
if opts.outputDir == "" {
if basename, ok := prompt.ShouldOfferInPlace("."); ok {
useCurrent, err := prompt.PromptScaffoldLocation(ctx, basename)
if err != nil {
return err
}
if useCurrent {
// Re-check immediately before committing — the directory may
// have changed between offer and answer.
if err := prompt.CheckInPlaceDirectory("."); err != nil {
return err
}
opts.name = basename
destDir = "."
inPlace = true
}
}
}
if !inPlace {
name, err := prompt.PromptForProjectName(ctx, opts.outputDir)
if err != nil {
return err
}
if name == prompt.InPlaceName {
appName, err := commitInPlace()
if err != nil {
return err
}
opts.name = appName
destDir = "."
inPlace = true
} else {
opts.name = name
destDir = name
if opts.outputDir != "" {
destDir = filepath.Join(opts.outputDir, name)
}
}
}
}

// Step 2: Wait for template (may already be done if the user took time typing the name)
Expand Down Expand Up @@ -1026,9 +1091,17 @@ func runCreate(ctx context.Context, opts createOptions) error {
var projectCreated bool
var runErr error
defer func() {
if runErr != nil && (projectCreated || npmInstallCh != nil) {
os.RemoveAll(destDir)
if runErr == nil || (!projectCreated && npmInstallCh == nil) {
return
}
if inPlace {
// destDir is "." here; a wholesale RemoveAll would wipe the
// user's current directory (including any pre-existing .git).
// Leave the partial scaffold and tell the user to clean up.
log.Warnf(ctx, "scaffold failed in current directory; review and clean up generated files manually (e.g. with git status / git clean -fd)")
return
}
os.RemoveAll(destDir)
}()

// Set description default
Expand Down Expand Up @@ -1151,9 +1224,9 @@ func runCreate(ctx context.Context, opts createOptions) error {
// Show next steps only if user didn't choose to deploy or run
showNextSteps := !shouldDeploy && runMode == prompt.RunModeNone
if showNextSteps {
prompt.PrintSuccess(ctx, opts.name, absOutputDir, fileCount, nextStepsCmd)
prompt.PrintSuccess(ctx, opts.name, absOutputDir, fileCount, nextStepsCmd, inPlace)
} else {
prompt.PrintSuccess(ctx, opts.name, absOutputDir, fileCount, "")
prompt.PrintSuccess(ctx, opts.name, absOutputDir, fileCount, "", inPlace)
}

// Print any onSetupMessage declared by selected plugins in the template manifest.
Expand Down Expand Up @@ -1474,12 +1547,19 @@ func copyTemplate(ctx context.Context, src, dest string, vars templateVars) (int
// removeEmptyDirs removes empty directories under root, deepest-first.
// It is used to clean up directories that were created eagerly but ended up
// with no files after conditional template rendering skipped their contents.
//
// .git is skipped so in-place scaffolding (root == ".") never walks into a
// pre-existing repo and deletes its empty subdirectories (refs/heads,
// refs/tags, objects/info, objects/pack are all empty after `git init`).
func removeEmptyDirs(root string) error {
var dirs []string
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() && d.Name() == ".git" && path != root {
return filepath.SkipDir
}
if d.IsDir() && path != root {
dirs = append(dirs, path)
}
Expand Down
76 changes: 76 additions & 0 deletions cmd/apps/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (

"github.com/databricks/cli/libs/apps/manifest"
"github.com/databricks/cli/libs/apps/prompt"
"github.com/databricks/cli/libs/cmdio"
"github.com/databricks/cli/libs/env"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -1072,3 +1073,78 @@ func TestStartBackgroundNpmInstall_TemplateSubstitution(t *testing.T) {
assert.Contains(t, string(got), `"cool-project"`)
assert.NotContains(t, string(got), "{{.projectName}}")
}

// makeChildDir creates and returns an empty subdirectory of t.TempDir() with
// the requested name. Used to control filepath.Base(cwd) for in-place tests.
func makeChildDir(t *testing.T, name string) string {
t.Helper()
dir := filepath.Join(t.TempDir(), name)
require.NoError(t, os.MkdirAll(dir, 0o755))
return dir
}

func TestCommitInPlace_Success(t *testing.T) {
dir := makeChildDir(t, "my-app")
t.Chdir(dir)

name, err := commitInPlace()
require.NoError(t, err)
assert.Equal(t, "my-app", name)
}

func TestCommitInPlace_AllowsDotGit(t *testing.T) {
dir := makeChildDir(t, "my-app")
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o755))
t.Chdir(dir)

name, err := commitInPlace()
require.NoError(t, err)
assert.Equal(t, "my-app", name)
}

func TestCommitInPlace_RejectsPreExistingGitignore(t *testing.T) {
// The template ships _gitignore that renames to .gitignore on copy.
// Allowing a pre-existing .gitignore would silently destroy the user's
// file via os.WriteFile, so we refuse the directory up front.
dir := makeChildDir(t, "my-app")
require.NoError(t, os.MkdirAll(filepath.Join(dir, ".git"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("node_modules\n"), 0o644))
t.Chdir(dir)

_, err := commitInPlace()
require.Error(t, err)
assert.Contains(t, err.Error(), "not empty")
}

func TestCommitInPlace_RejectsStrayFiles(t *testing.T) {
dir := makeChildDir(t, "my-app")
require.NoError(t, os.WriteFile(filepath.Join(dir, "README.md"), []byte("hi"), 0o644))
t.Chdir(dir)

_, err := commitInPlace()
require.Error(t, err)
assert.Contains(t, err.Error(), "not empty")
}

func TestCommitInPlace_RejectsInvalidBasename(t *testing.T) {
dir := makeChildDir(t, "My_App")
t.Chdir(dir)

_, err := commitInPlace()
require.Error(t, err)
assert.Contains(t, err.Error(), "My_App")
}

func TestRunCreate_NameDotAndOutputDirAreMutuallyExclusive(t *testing.T) {
dir := makeChildDir(t, "my-app")
t.Chdir(dir)

ctx := cmdio.MockDiscard(t.Context())
err := runCreate(ctx, createOptions{
name: prompt.InPlaceName,
nameProvided: true,
outputDir: "elsewhere",
})
require.Error(t, err)
assert.ErrorIs(t, err, prompt.ErrNameDotWithOutputDir)
}
Loading
Loading