Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
82fed0b
feat: extend autodiscover.ignore_paths to targeted -d commands
emanuelbesliu May 12, 2026
baedc6e
refactor: extract shouldIgnoreTargetedDir helper and gate behind auto…
emanuelbesliu May 12, 2026
353e2f7
fix: address second round of Copilot review feedback
emanuelbesliu May 12, 2026
5908fd4
docs: clarify ignore_paths applies to all targeted -d commands
emanuelbesliu May 12, 2026
ec1a774
docs: clarify ignore_paths applies only when autodiscovery is enabled
emanuelbesliu May 12, 2026
4050a68
docs: fix MD032 blanks-around-lists in repo-level-atlantis-yaml
emanuelbesliu Jun 23, 2026
9fb766f
fix: honor ignored targeted dirs before restrict list
chenrui333 Jun 26, 2026
76cf9ae
fix: make ignored targeted dirs true no-ops
chenrui333 Jun 26, 2026
29ced3b
fix: fail autoplan status on pre-hook errors
chenrui333 Jun 26, 2026
d0e7566
fix: skip ignored targets before command side effects
chenrui333 Jun 26, 2026
ad3f98a
fix: skip ignored targets before hooks and API statuses
chenrui333 Jun 26, 2026
c0c510d
fix: skip ignored targets before validations
chenrui333 Jun 26, 2026
1536eed
fix: use plan runner for autoplan hook checks
chenrui333 Jun 26, 2026
3f55a3b
fix: preserve ignored target no-op boundaries
chenrui333 Jun 26, 2026
f61f28c
fix: validate ignored targets before hooks
chenrui333 Jun 26, 2026
3110355
fix: avoid head config for merge checkout ignore checks
chenrui333 Jun 26, 2026
a90f686
fix: validate pulls before targeted ignore config reads
chenrui333 Jun 26, 2026
65c129e
fix: fail open on unknown targeted ignore config
chenrui333 Jun 26, 2026
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
8 changes: 8 additions & 0 deletions runatlantis.io/docs/repo-level-atlantis-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,6 +418,14 @@ autodiscover:

Autodiscover can also be configured to skip over directories that match a path glob (as defined by the [doublestar path matching package](https://pkg.go.dev/github.com/bmatcuk/doublestar/v4)).

When `ignore_paths` is set, it applies to:

- Automatic project discovery during autoplan and `atlantis plan` (without `-d`)
- `atlantis apply` (without `-d`) when filtering pending plans
- All targeted `-d` commands (`plan`, `apply`, `import`, `state rm`, etc.) when autodiscovery is enabled, if the path has no explicit project configuration

This makes `ignore_paths` useful for **multi-instance setups** where each Atlantis instance manages a different directory subtree. For example, one instance can ignore `environments/prod/**` while another ignores `environments/nonprod/**`, preventing cross-instance interference on targeted commands.

### Custom Backend Config

See [Custom Workflow Use Cases: Custom Backend Config](custom-workflows.md#custom-backend-config)
Expand Down
4 changes: 3 additions & 1 deletion runatlantis.io/docs/server-side-repo-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,9 @@ repos:
# If any part of this setting is set here, it overrides the entire setting in the repo config.
autodiscover:
mode: auto
# Optionally ignore some paths for autodiscovery by a glob path
# Optionally ignore some paths for autodiscovery by a glob path.
# When autodiscovery is enabled, also applies to all targeted -d commands
# (plan, apply, import, etc.) when the path has no explicit project configuration.
ignore_paths:
- foo/*

Expand Down
45 changes: 39 additions & 6 deletions server/controllers/api_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,15 +79,29 @@ func (a *APIRequest) getCommands(ctx *command.Context, cmdName command.Name, cmd
}

cmds := make([]command.ProjectContext, 0)
keptCommentCommands := make([]*events.CommentCommand, 0)
ignoredCommands := 0
nonIgnoredCommands := 0
for _, commentCommand := range cc {
projectCmds, err := cmdBuilder(ctx, commentCommand)
if err != nil {
return nil, nil, fmt.Errorf("failed to build command: %v", err)
if events.IsIgnoredTargetedDir(err) {
ignoredCommands++
continue
}
return nil, nil, fmt.Errorf("failed to build command: %w", err)
}
nonIgnoredCommands++
for _, projectCmd := range projectCmds {
cmds = append(cmds, projectCmd)
keptCommentCommands = append(keptCommentCommands, commentCommand)
}
cmds = append(cmds, projectCmds...)
}
if ignoredCommands > 0 && nonIgnoredCommands == 0 {
return nil, nil, events.ErrIgnoredTargetedDir
}

return cmds, cc, nil
return cmds, keptCommentCommands, nil
}

func (a *APIController) apiReportError(w http.ResponseWriter, code int, err error) {
Expand Down Expand Up @@ -117,7 +131,9 @@ func (a *APIController) Plan(w http.ResponseWriter, r *http.Request) {
a.apiReportError(w, http.StatusInternalServerError, err)
return
}
defer a.Locker.UnlockByPull(ctx.HeadRepo.FullName, ctx.Pull.Num) // nolint: errcheck
if !ctx.CommandSkipped {
defer a.Locker.UnlockByPull(ctx.HeadRepo.FullName, ctx.Pull.Num) // nolint: errcheck
}
if result.HasErrors() {
code = http.StatusInternalServerError
}
Expand Down Expand Up @@ -147,19 +163,28 @@ func (a *APIController) Apply(w http.ResponseWriter, r *http.Request) {
}

// We must first make the plan for all projects
_, err = a.apiPlan(request, ctx)
result, err := a.apiPlan(request, ctx)
if err != nil {
a.apiReportError(w, http.StatusInternalServerError, err)
return
}
if ctx.CommandSkipped {
response, err := json.Marshal(result)
if err != nil {
a.apiReportError(w, http.StatusInternalServerError, err)
return
}
a.respond(w, logging.Warn, code, "%s", string(response))
return
}
defer a.Locker.UnlockByPull(ctx.HeadRepo.FullName, ctx.Pull.Num) // nolint: errcheck

// The API apply endpoint runs plan first. Refresh PR status afterward so
// apply requirements evaluate the VCS state the plan phase just produced.
a.populatePullRequestStatus(ctx)

// We can now prepare and run the apply step
result, err := a.apiApply(request, ctx)
result, err = a.apiApply(request, ctx)
if err != nil {
a.apiReportError(w, http.StatusInternalServerError, err)
return
Expand Down Expand Up @@ -248,6 +273,10 @@ func (a *APIController) apiSetup(ctx *command.Context, cmdName command.Name) err

func (a *APIController) apiPlan(request *APIRequest, ctx *command.Context) (*command.Result, error) {
cmds, cc, err := request.getCommands(ctx, command.Plan, a.ProjectCommandBuilder.BuildPlanCommands)
if events.IsIgnoredTargetedDir(err) {
ctx.CommandSkipped = true
return &command.Result{ProjectResults: []command.ProjectResult{}}, nil
}
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -296,6 +325,10 @@ func (a *APIController) apiPlan(request *APIRequest, ctx *command.Context) (*com

func (a *APIController) apiApply(request *APIRequest, ctx *command.Context) (*command.Result, error) {
cmds, cc, err := request.getCommands(ctx, command.Apply, a.ProjectCommandBuilder.BuildApplyCommands)
if events.IsIgnoredTargetedDir(err) {
ctx.CommandSkipped = true
return &command.Result{ProjectResults: []command.ProjectResult{}}, nil
}
if err != nil {
return nil, err
}
Expand Down
147 changes: 144 additions & 3 deletions server/controllers/api_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,134 @@ func TestAPIController_Plan_PreWorkflowHooksReceiveCorrectCommand(t *testing.T)
command.Plan, capturedCmd.Name.String(), capturedCmd.Name)
}

func TestAPIController_Plan_SkipsIgnoredPathsWithoutShiftingHookCommands(t *testing.T) {
ac, projectCommandBuilder, projectCommandRunner := setup(t)
preWorkflowHooksRunner := ac.PreWorkflowHooksCommandRunner.(*MockPreWorkflowHooksCommandRunner)

When(projectCommandBuilder.BuildPlanCommands(Any[*command.Context](), Any[*events.CommentCommand]())).Then(func(args []Param) ReturnValues {
commentCommand := args[1].(*events.CommentCommand)
if commentCommand.RepoRelDir == "ignored" {
return ReturnValues{[]command.ProjectContext{}, events.ErrIgnoredTargetedDir}
}
return ReturnValues{[]command.ProjectContext{{
CommandName: command.Plan,
RepoRelDir: commentCommand.RepoRelDir,
Workspace: commentCommand.Workspace,
}}, nil}
})

body, _ := json.Marshal(controllers.APIRequest{
Repository: "Repo",
Ref: "main",
Type: "Gitlab",
Paths: []struct {
Directory string
Workspace string
}{
{
Directory: "ignored",
Workspace: "ignored-workspace",
},
{
Directory: "kept",
Workspace: "kept-workspace",
},
},
})

req, _ := http.NewRequest("POST", "", bytes.NewBuffer(body))
req.Header.Set(atlantisTokenHeader, atlantisToken)
w := httptest.NewRecorder()
ac.Plan(w, req)
ResponseContains(t, w, http.StatusOK, "")

projectCommandRunner.VerifyWasCalledOnce().Plan(Any[command.ProjectContext]())
_, capturedCmd := preWorkflowHooksRunner.VerifyWasCalledOnce().
RunPreHooks(Any[*command.Context](), Any[*events.CommentCommand]()).
GetCapturedArguments()

Equals(t, "kept", capturedCmd.RepoRelDir)
Equals(t, "kept-workspace", capturedCmd.Workspace)
}

func TestAPIController_Plan_AllIgnoredPathsNoOp(t *testing.T) {
ac, projectCommandBuilder, projectCommandRunner := setup(t, func(config *apiControllerTestConfig) {
config.allowUnlockByPull = false
})
commitStatusUpdater := ac.CommitStatusUpdater.(*MockCommitStatusUpdater)

When(projectCommandBuilder.BuildPlanCommands(Any[*command.Context](), Any[*events.CommentCommand]())).
ThenReturn([]command.ProjectContext{}, events.ErrIgnoredTargetedDir)

body, _ := json.Marshal(controllers.APIRequest{
Repository: "Repo",
Ref: "main",
Type: "Gitlab",
Paths: []struct {
Directory string
Workspace string
}{
{
Directory: "ignored",
Workspace: "default",
},
},
})

req, _ := http.NewRequest("POST", "", bytes.NewBuffer(body))
req.Header.Set(atlantisTokenHeader, atlantisToken)
w := httptest.NewRecorder()
ac.Plan(w, req)
ResponseContains(t, w, http.StatusOK, "")

projectCommandRunner.VerifyWasCalled(Never()).Plan(Any[command.ProjectContext]())
commitStatusUpdater.VerifyWasCalled(Never()).UpdateCombined(
Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name]())
commitStatusUpdater.VerifyWasCalled(Never()).UpdateCombinedCount(
Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name](), Any[models.ProjectCounts]())
}

func TestAPIController_Apply_AllIgnoredPathsNoOp(t *testing.T) {
ac, projectCommandBuilder, projectCommandRunner := setup(t, func(config *apiControllerTestConfig) {
config.allowUnlockByPull = false
})
commitStatusUpdater := ac.CommitStatusUpdater.(*MockCommitStatusUpdater)

When(projectCommandBuilder.BuildPlanCommands(Any[*command.Context](), Any[*events.CommentCommand]())).
ThenReturn([]command.ProjectContext{}, events.ErrIgnoredTargetedDir)
When(projectCommandBuilder.BuildApplyCommands(Any[*command.Context](), Any[*events.CommentCommand]())).
ThenReturn([]command.ProjectContext{}, events.ErrIgnoredTargetedDir)

body, _ := json.Marshal(controllers.APIRequest{
Repository: "Repo",
Ref: "main",
Type: "Gitlab",
Paths: []struct {
Directory string
Workspace string
}{
{
Directory: "ignored",
Workspace: "default",
},
},
})

req, _ := http.NewRequest("POST", "", bytes.NewBuffer(body))
req.Header.Set(atlantisTokenHeader, atlantisToken)
w := httptest.NewRecorder()
ac.Apply(w, req)
ResponseContains(t, w, http.StatusOK, "")

projectCommandRunner.VerifyWasCalled(Never()).Plan(Any[command.ProjectContext]())
projectCommandRunner.VerifyWasCalled(Never()).Apply(Any[command.ProjectContext]())
projectCommandBuilder.VerifyWasCalled(Never()).BuildApplyCommands(Any[*command.Context](), Any[*events.CommentCommand]())
commitStatusUpdater.VerifyWasCalled(Never()).UpdateCombined(
Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name]())
commitStatusUpdater.VerifyWasCalled(Never()).UpdateCombinedCount(
Any[logging.SimpleLogging](), Any[models.Repo](), Any[models.PullRequest](), Any[models.CommitStatus](), Any[command.Name](), Any[models.ProjectCounts]())
}

// TestAPIController_Apply_PreWorkflowHooksReceiveCorrectCommand verifies that when
// calling the Apply API endpoint, the pre-workflow hooks receive a CommentCommand
// with Name set to command.Apply for the apply phase (and command.Plan for the
Expand Down Expand Up @@ -476,12 +604,25 @@ func TestAPIController_ListLocksEmpty(t *testing.T) {
Equals(t, expected, result)
}

func setup(t *testing.T) (controllers.APIController, *MockProjectCommandBuilder, *MockProjectCommandRunner) {
type apiControllerTestConfig struct {
allowUnlockByPull bool
}

func setup(t *testing.T, options ...func(*apiControllerTestConfig)) (controllers.APIController, *MockProjectCommandBuilder, *MockProjectCommandRunner) {
RegisterMockTestingT(t)
config := &apiControllerTestConfig{
allowUnlockByPull: true,
}
for _, option := range options {
option(config)
}

gmockCtrl := gomock.NewController(t)
locker := NewMockLocker(gmockCtrl)
// Allow incidental calls to UnlockByPull (called internally during plan/apply operations)
locker.EXPECT().UnlockByPull(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
if config.allowUnlockByPull {
// Allow incidental calls to UnlockByPull (called internally during plan/apply operations)
locker.EXPECT().UnlockByPull(gomock.Any(), gomock.Any()).Return(nil, nil).AnyTimes()
}
logger := logging.NewNoopLogger(t)
parser := NewMockEventParsing()
repoAllowlistChecker, err := events.NewRepoAllowlistChecker("*")
Expand Down
73 changes: 56 additions & 17 deletions server/events/apply_command_runner.go
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,25 @@ func (a *ApplyCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) {
baseRepo := ctx.Pull.BaseRepo
pull := ctx.Pull

var projectCmds []command.ProjectContext
var projectCmdsBuilt bool
var projectCmdsErr error
if cmd.IsForSpecificProject() {
ctx.PullRequestStatus, err = a.pullReqStatusFetcher.FetchPullStatus(ctx.Log, pull)
if err != nil {
// On error we continue the request with mergeable assumed false.
// We want to continue because not all apply's will need this status,
// only if they rely on the mergeability requirement.
// All PullRequestStatus fields are set to false by default when error.
ctx.Log.Warn("unable to get pull request status: %s. Continuing with mergeable and approved assumed false", err)
}
projectCmds, projectCmdsErr = a.prjCmdBuilder.BuildApplyCommands(ctx, cmd)
projectCmdsBuilt = true
if MarkCommandSkippedIfIgnoredTargetedDir(ctx, cmd.CommandName(), projectCmdsErr) {
return
}
}

locked, err := a.IsLocked()
if err != nil {
ctx.Log.Err("checking global apply lock: %s", err)
Expand Down Expand Up @@ -106,27 +125,30 @@ func (a *ApplyCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) {
return
}

// Get the mergeable status before we set any build statuses of our own.
// We do this here because when we set a "Pending" status, if users have
// required the Atlantis status checks to pass, then we've now changed
// the mergeability status of the pull request.
// This sets the approved, mergeable, and sqlocked status in the context.
ctx.PullRequestStatus, err = a.pullReqStatusFetcher.FetchPullStatus(ctx.Log, pull)
if err != nil {
// On error we continue the request with mergeable assumed false.
// We want to continue because not all apply's will need this status,
// only if they rely on the mergeability requirement.
// All PullRequestStatus fields are set to false by default when error.
ctx.Log.Warn("unable to get pull request status: %s. Continuing with mergeable and approved assumed false", err)
if !projectCmdsBuilt {
// Get the mergeable status before we set any build statuses of our own.
// We do this here because when we set a "Pending" status, if users have
// required the Atlantis status checks to pass, then we've now changed
// the mergeability status of the pull request.
// This sets the approved, mergeable, and sqlocked status in the context.
ctx.PullRequestStatus, err = a.pullReqStatusFetcher.FetchPullStatus(ctx.Log, pull)
if err != nil {
// On error we continue the request with mergeable assumed false.
// We want to continue because not all apply's will need this status,
// only if they rely on the mergeability requirement.
// All PullRequestStatus fields are set to false by default when error.
ctx.Log.Warn("unable to get pull request status: %s. Continuing with mergeable and approved assumed false", err)
}
projectCmds, projectCmdsErr = a.prjCmdBuilder.BuildApplyCommands(ctx, cmd)
if MarkCommandSkippedIfIgnoredTargetedDir(ctx, cmd.CommandName(), projectCmdsErr) {
return
}
}

var projectCmds []command.ProjectContext
projectCmds, err = a.prjCmdBuilder.BuildApplyCommands(ctx, cmd)
if err != nil {
if projectCmdsErr != nil {
if statusErr := a.commitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.FailedCommitStatus, cmd.CommandName()); statusErr != nil {
ctx.Log.Warn("unable to update commit status: %s", statusErr)
}
a.pullUpdater.updatePull(ctx, cmd, command.Result{Error: err})
a.pullUpdater.updatePull(ctx, cmd, command.Result{Error: projectCmdsErr})
return
}

Expand Down Expand Up @@ -164,6 +186,9 @@ func (a *ApplyCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) {
}
return
}
if len(projectCmds) > 0 {
a.updatePendingCommitStatus(ctx)
}

result := runProjectCmdsWithCancellationTracker(ctx, projectCmds, a.cancellationTracker, a.parallelPoolSize, a.isParallelEnabled(projectCmds), a.prjCmdRunner.Apply)
ctx.CommandHasErrors = result.HasErrors()
Expand All @@ -186,6 +211,20 @@ func (a *ApplyCommandRunner) Run(ctx *command.Context, cmd *CommentCommand) {
}
}

func (a *ApplyCommandRunner) updatePendingCommitStatus(ctx *command.Context) {
if a.silenceVCSStatusNoProjects {
ctx.Log.Debug("silence enabled - not setting pending VCS status")
return
}
if err := a.commitStatusUpdater.UpdateCombined(ctx.Log, ctx.Pull.BaseRepo, ctx.Pull, models.PendingCommitStatus, command.Apply); err != nil {
ctx.Log.Warn("unable to update commit status: %s", err)
}
}

func (a *ApplyCommandRunner) ShouldSkipPreWorkflowHooks(ctx *command.Context, cmd *CommentCommand) bool {
return MarkCommandSkippedIfIgnoredTarget(ctx, command.Apply, cmd, a.prjCmdBuilder)
}

func (a *ApplyCommandRunner) IsLocked() (bool, error) {
lock, err := a.locker.CheckApplyLock()

Expand Down
Loading
Loading