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
1 change: 1 addition & 0 deletions cli/azd/.vscode/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ words:
- agentcopilot
- agentdetect
- Authenticode
- azdignore
- gofmt
- golangci
- lightspeed
Expand Down
65 changes: 65 additions & 0 deletions cli/azd/docs/azdignore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
# .azdignore - Template File Exclusion

## Overview

`.azdignore` lets template authors exclude files from being copied when consumers run `azd init`.
Place a `.azdignore` file at the root of your template repository to keep contributor-only files
(CI configs, internal docs, etc.) out of consumer projects.

## Syntax

`.azdignore` uses standard `.gitignore` pattern syntax (via [go-gitignore](https://github.com/denormal/go-gitignore)):

- `*` matches any characters within a path segment
- `**` matches across directory boundaries (recursive)
- `?` matches a single character
- `!pattern` negates a previously matched pattern
- `dirname/` matches a directory and all its contents
- Lines starting with `#` are comments
- Blank lines are ignored

## Where to Place It

Place `.azdignore` at the **root** of your template repository, alongside `azure.yaml`.

Only the root `.azdignore` is processed for ignore rules. Nested `.azdignore` files
(e.g., `docs/.azdignore`) are not processed but are still removed from consumer projects
to keep the output clean.

## Examples

A typical `.azdignore` for a template repository:

```gitignore
# CI/CD files - consumers don't need the template's CI config
.github/

# Contributor-only documentation
CONTRIBUTING.md
CODE_OF_CONDUCT.md
docs/internal/

# Build artifacts that shouldn't be in templates
**/node_modules
*.log
```

### Negation Example

Exclude all markdown files except `README.md`:

```gitignore
*.md
!README.md
```

## Behavior

- **Self-removing**: `.azdignore` is always removed from the consumer's project after
processing. Consumers never see the `.azdignore` file.
- **Works with .gitignore**: When initializing from a local template, both `.gitignore`
and `.azdignore` rules apply. `.azdignore` is preserved during the staging copy even if
a `.gitignore` pattern would otherwise exclude it (e.g., `.*`).
- **No-op when absent**: If no `.azdignore` file exists, all template files are copied as usual.
- **Security**: Symlinked `.azdignore` files are rejected. Path traversal patterns (e.g., `../`)
cannot escape the template directory. Files exceeding 1 MB are rejected.
150 changes: 150 additions & 0 deletions cli/azd/internal/repository/initializer.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ package repository

import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"io/fs"
"log"
"maps"
Expand All @@ -35,6 +37,10 @@ import (
"github.com/otiai10/copy"
)

// azdIgnoreFileName is the name of the file template authors can place at the root of a template
// repository to exclude files from being copied when consumers run azd init. It uses .gitignore syntax.
const azdIgnoreFileName = ".azdignore"

// Initializer handles the initialization of a local repository.
type Initializer struct {
console input.Console
Expand Down Expand Up @@ -127,6 +133,15 @@ func (i *Initializer) Initialize(
return err
}

// Apply .azdignore rules: remove files from staging that template authors
// want excluded when consumers init from the template.
if err := removeAzdIgnoredFiles(staging); err != nil {
return fmt.Errorf("applying %s rules: %w", azdIgnoreFileName, err)
}
// Executable file paths may reference files removed by .azdignore;
// keep only those still present in staging.
filesWithExecPerms = filterExistingFiles(staging, filesWithExecPerms)

skipStagingFiles, err := i.promptForDuplicates(ctx, staging, target)
if err != nil {
return err
Expand Down Expand Up @@ -262,6 +277,13 @@ func (i *Initializer) copyLocalTemplate(source, destination string) error {
return true, nil
}

// Never skip the root .azdignore — it must reach staging so
// removeAzdIgnoredFiles can apply its rules and then remove it.
if relToSource, relErr := filepath.Rel(source, src); relErr == nil &&
filepath.ToSlash(relToSource) == azdIgnoreFileName {
return false, nil
}

// Check all applicable .gitignore matchers (a matcher applies when
// the file is inside the matcher's base directory).
for _, m := range matchers {
Expand All @@ -286,6 +308,134 @@ func (i *Initializer) copyLocalTemplate(source, destination string) error {
return nil
}

// azdIgnoreMaxSize is the maximum allowed size for a .azdignore file (1 MB).
const azdIgnoreMaxSize = 1 << 20

// loadAzdIgnore reads an .azdignore file from the root of dir and returns a
// gitignore-syntax matcher. Returns nil if no .azdignore file exists.
// Symlinks are rejected to prevent reading files outside the staging directory.
func loadAzdIgnore(dir string) (gitignore.GitIgnore, error) {
path := filepath.Join(dir, azdIgnoreFileName)
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("reading %s: %w", azdIgnoreFileName, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf(
"%s must be a regular file", azdIgnoreFileName)
}
if info.Size() > azdIgnoreMaxSize {
return nil, fmt.Errorf(
"%s exceeds maximum size (%d bytes)", azdIgnoreFileName, azdIgnoreMaxSize)
}
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", azdIgnoreFileName, err)
}
defer f.Close()
// Use LimitReader to enforce the size limit on actual bytes read,
// guarding against TOCTOU between Lstat and Open.
data, err := io.ReadAll(io.LimitReader(f, azdIgnoreMaxSize+1))
if err != nil {
return nil, fmt.Errorf("reading %s: %w", azdIgnoreFileName, err)
}
if int64(len(data)) > azdIgnoreMaxSize {
return nil, fmt.Errorf(
"%s exceeds maximum size (%d bytes)", azdIgnoreFileName, azdIgnoreMaxSize)
}
// Strip UTF-8 BOM that Windows editors may prepend. The invisible bytes
// would otherwise become part of the first ignore pattern and break matching.
data = bytes.TrimPrefix(data, utf8BOM)
return gitignore.New(bytes.NewReader(data), dir, nil), nil
}

// utf8BOM is the byte order mark that some Windows editors prepend to UTF-8 files.
var utf8BOM = []byte{0xEF, 0xBB, 0xBF}

// removeAzdIgnoredFiles reads an .azdignore file from dir and removes all matching
// entries. The .azdignore file itself is also removed. This is a no-op when no
// .azdignore file is present.
func removeAzdIgnoredFiles(dir string) error {
ig, err := loadAzdIgnore(dir)
if err != nil {
return err
}
if ig == nil {
return nil
}

// Collect paths to remove before modifying the tree.
var toRemove []string
err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}

rel, relErr := filepath.Rel(dir, path)
if relErr != nil {
return relErr
}
if rel == "." {
return nil
}

match := ig.Relative(filepath.ToSlash(rel), d.IsDir())
if match != nil && match.Ignore() {
toRemove = append(toRemove, path)
if d.IsDir() {
return filepath.SkipDir
}
}
return nil
})
if err != nil {
return fmt.Errorf("scanning for %s matches: %w", azdIgnoreFileName, err)
}

for _, p := range toRemove {
if err := os.RemoveAll(p); err != nil {
return fmt.Errorf("removing %s-ignored path: %w", azdIgnoreFileName, err)
}
}

// Remove all .azdignore files — the root one that was just processed and
// any nested ones that templates might contain (e.g., docs/.azdignore).
// Consumers should never see .azdignore files in their project.
err = filepath.WalkDir(dir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if !d.IsDir() && d.Name() == azdIgnoreFileName {
if removeErr := os.Remove(path); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
return fmt.Errorf("removing %s file: %w", azdIgnoreFileName, removeErr)
}
}
return nil
})
if err != nil {
return fmt.Errorf("cleaning nested %s files: %w", azdIgnoreFileName, err)
}

return nil
}

// filterExistingFiles returns only those relative paths that still exist under dir.
func filterExistingFiles(dir string, paths []string) []string {
if len(paths) == 0 {
return paths
}
filtered := make([]string, 0, len(paths))
for _, p := range paths {
if _, err := os.Stat(filepath.Join(dir, p)); err == nil {
filtered = append(filtered, p)
}
}
return filtered
}

// findExecutableFiles walks root and returns paths (relative to root) of files
// that have any executable permission bit set. On Windows, exec bits are not
// meaningful so an empty list is returned immediately.
Expand Down
Loading
Loading