diff --git a/cli/azd/cmd/init.go b/cli/azd/cmd/init.go index e08b62137da..328e679f5ae 100644 --- a/cli/azd/cmd/init.go +++ b/cli/azd/cmd/init.go @@ -401,6 +401,28 @@ func (i *initAction) Run(ctx context.Context) (*actions.ActionResult, error) { } func (i *initAction) initAppWithAgent(ctx context.Context, azdCtx *azdcontext.AzdContext) error { + // Warn the user if the working directory has uncommitted git changes + dirty, err := i.gitCli.IsDirty(ctx, azdCtx.ProjectDirectory()) + if err != nil && !errors.Is(err, git.ErrNotRepository) { + return fmt.Errorf("checking git status: %w", err) + } + + if dirty { + defaultNo := false + confirm := uxlib.NewConfirm(&uxlib.ConfirmOptions{ + Message: "Your working directory has uncommitted changes. Continue initializing?", + DefaultValue: &defaultNo, + }) + result, promptErr := confirm.Ask(ctx) + i.console.Message(ctx, "") + if promptErr != nil { + return promptErr + } + if result == nil || !*result { + return errors.New("user declined to continue with uncommitted changes") + } + } + // Show alpha warning i.console.MessageUxItem(ctx, &ux.MessageTitle{ Title: fmt.Sprintf("Agentic mode init is in preview. The agent will scan your repository and "+ @@ -411,6 +433,13 @@ func (i *initAction) initAppWithAgent(ctx context.Context, azdCtx *azdcontext.Az output.WithLinkFormat("https://aka.ms/azd-feature-stages")), }) + // Prompt for upfront tool access before starting the agent + if err := i.consentManager.PromptWorkflowConsent(ctx, + []string{"copilot", "azure", "azd"}, + ); err != nil { + return err + } + // Create agent copilotAgent, err := i.agentFactory.Create(ctx, agent.WithMode(agent.AgentModeInteractive), @@ -466,6 +495,7 @@ func (i *initAction) initAppWithAgent(ctx context.Context, azdCtx *azdcontext.Az i.console.Message(ctx, output.WithSuccessFormat("Session resumed")) i.console.Message(ctx, "") } + i.console.Message(ctx, "") } } diff --git a/cli/azd/internal/agent/consent/checker.go b/cli/azd/internal/agent/consent/checker.go index f32d6386db2..c42c6b0ad1e 100644 --- a/cli/azd/internal/agent/consent/checker.go +++ b/cli/azd/internal/agent/consent/checker.go @@ -272,13 +272,17 @@ func (cc *ConsentChecker) promptForToolConsent( choices := []*ux.SelectChoice{ { - Value: "always", - Label: "Yes, always allow this tool", + Value: "once", + Label: "Yes, just this once", }, { Value: "session", Label: "Yes, until I restart azd", }, + { + Value: "always", + Label: "Yes, always allow this tool", + }, } // Add trust-all option for this provider if not already trusted @@ -290,10 +294,6 @@ func (cc *ConsentChecker) promptForToolConsent( } choices = append(choices, - &ux.SelectChoice{ - Value: "once", - Label: "Yes, just this once", - }, &ux.SelectChoice{ Value: "skip", Label: "No, skip this tool", diff --git a/cli/azd/internal/agent/consent/types.go b/cli/azd/internal/agent/consent/types.go index dfd5dde841c..fb1d6cfc53b 100644 --- a/cli/azd/internal/agent/consent/types.go +++ b/cli/azd/internal/agent/consent/types.go @@ -275,6 +275,11 @@ type ConsentManager interface { ListConsentRules(ctx context.Context, options ...FilterOption) ([]ConsentRule, error) ClearConsentRules(ctx context.Context, options ...FilterOption) error + // PromptWorkflowConsent shows an upfront consent prompt asking the user whether to grant + // blanket access to the given MCP tool servers. If all servers are already trusted, the + // prompt is skipped. + PromptWorkflowConsent(ctx context.Context, servers []string) error + // Environment context methods IsProjectScopeAvailable(ctx context.Context) bool } diff --git a/cli/azd/internal/agent/consent/workflow_consent.go b/cli/azd/internal/agent/consent/workflow_consent.go new file mode 100644 index 00000000000..74649d3073d --- /dev/null +++ b/cli/azd/internal/agent/consent/workflow_consent.go @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package consent + +import ( + "context" + "fmt" + "log" + "strings" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/output" + "github.com/azure/azure-dev/cli/azd/pkg/ux" + "github.com/mark3labs/mcp-go/mcp" +) + +// PromptWorkflowConsent on consentManager implements the ConsentManager interface method. +// It shows an upfront consent prompt asking the user whether to grant blanket access to the +// given MCP tool servers. If all servers are already trusted, the prompt is skipped. +func (cm *consentManager) PromptWorkflowConsent(ctx context.Context, servers []string) error { + // Skip if every server is already trusted for tool execution + if allServersTrusted(ctx, cm, servers) { + return nil + } + + scope, err := promptForWorkflowConsent(ctx, servers) + if err != nil { + return err + } + + // Empty scope means the user chose "No, prompt me for each operation" — no rules to add + if scope == "" { + return nil + } + + return grantWorkflowRules(ctx, cm, servers, scope) +} + +// allServersTrusted returns true when every server already has a consent rule that +// would allow tool execution (any action, not just read-only). +func allServersTrusted(ctx context.Context, mgr ConsentManager, servers []string) bool { + for _, server := range servers { + request := ConsentRequest{ + ToolID: fmt.Sprintf("%s/test-tool", server), + ServerName: server, + Operation: OperationTypeTool, + Annotations: mcp.ToolAnnotation{}, // no read-only hint — checks for full access + } + + decision, err := mgr.CheckConsent(ctx, request) + if err != nil || !decision.Allowed { + return false + } + } + + return true +} + +// promptForWorkflowConsent displays the consent choices and returns the selected scope +// (ScopeSession or ScopeGlobal), or empty string for "no, prompt me". +func promptForWorkflowConsent(ctx context.Context, servers []string) (Scope, error) { + serverList := strings.Join(servers, ", ") + + message := fmt.Sprintf( + "Grant access to tools for %s to read and write files in your current workspace?", + output.WithHighLightFormat(serverList), + ) + + helpMessage := "This allows the agent workflow to use built-in tools (file read/write, " + + "Azure MCP, azd CLI) without prompting for each tool individually.\n\n" + + "This command does not create or provision any resources in Azure. " + + "It only generates configuration files locally in your workspace.\n\n" + + "You can review or revoke permissions at any time with:\n" + + " azd copilot consent list\n" + + " azd copilot consent revoke" + + choices := []*ux.SelectChoice{ + { + Value: string(ScopeSession), + Label: "Yes, approve for this session", + }, + { + Value: string(ScopeGlobal), + Label: "Yes, always approve", + }, + { + Value: "prompt", + Label: "No, prompt me for each operation", + }, + } + + selector := ux.NewSelect(&ux.SelectOptions{ + Message: message, + HelpMessage: helpMessage, + Choices: choices, + EnableFiltering: new(false), + DisplayCount: len(choices), + }) + + choiceIndex, err := selector.Ask(ctx) + if err != nil { + return "", err + } + + if choiceIndex == nil || *choiceIndex < 0 || *choiceIndex >= len(choices) { + return "", fmt.Errorf("invalid choice selected") + } + + selected := choices[*choiceIndex].Value + if selected == "prompt" { + return "", nil + } + + return Scope(selected), nil +} + +// grantWorkflowRules creates server-level allow rules for every server for tool operations. +func grantWorkflowRules(ctx context.Context, mgr ConsentManager, servers []string, scope Scope) error { + now := time.Now() + + for _, server := range servers { + rule := ConsentRule{ + Scope: scope, + Target: NewServerTarget(server), + Action: ActionAny, + Operation: OperationTypeTool, + Permission: PermissionAllow, + GrantedAt: now, + } + + if err := mgr.GrantConsent(ctx, rule); err != nil { + log.Printf("[consent] failed to persist workflow consent for %s (scope=%s): %v", + server, scope, err) + } + } + + return nil +} diff --git a/cli/azd/internal/agent/consent/workflow_consent_test.go b/cli/azd/internal/agent/consent/workflow_consent_test.go new file mode 100644 index 00000000000..650d6b089c2 --- /dev/null +++ b/cli/azd/internal/agent/consent/workflow_consent_test.go @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package consent + +import ( + "context" + "fmt" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/config" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" + "github.com/mark3labs/mcp-go/mcp" + "github.com/stretchr/testify/require" +) + +func TestPromptWorkflowConsent_SkipsWhenAllServersTrusted(t *testing.T) { + mgr := newTestConsentManager(t) + ctx := context.Background() + servers := []string{"copilot", "azure", "azd"} + + // Pre-grant server-level rules for all three servers + for _, server := range servers { + err := mgr.GrantConsent(ctx, ConsentRule{ + Scope: ScopeGlobal, + Target: NewServerTarget(server), + Action: ActionAny, + Operation: OperationTypeTool, + Permission: PermissionAllow, + }) + require.NoError(t, err) + } + + // allServersTrusted should return true — prompt would be skipped + require.True(t, allServersTrusted(ctx, mgr, servers)) +} + +func TestAllServersTrusted_FalseWhenNoRules(t *testing.T) { + mgr := newTestConsentManager(t) + ctx := context.Background() + + require.False(t, allServersTrusted(ctx, mgr, []string{"copilot", "azure", "azd"})) +} + +func TestAllServersTrusted_FalseWhenPartiallyTrusted(t *testing.T) { + mgr := newTestConsentManager(t) + ctx := context.Background() + + // Only grant trust to one of three servers + err := mgr.GrantConsent(ctx, ConsentRule{ + Scope: ScopeGlobal, + Target: NewServerTarget("copilot"), + Action: ActionAny, + Operation: OperationTypeTool, + Permission: PermissionAllow, + }) + require.NoError(t, err) + + require.False(t, allServersTrusted(ctx, mgr, []string{"copilot", "azure", "azd"})) +} + +func TestAllServersTrusted_TrueWithGlobalWildcard(t *testing.T) { + mgr := newTestConsentManager(t) + ctx := context.Background() + + // A global wildcard (*/*) should cover all servers + err := mgr.GrantConsent(ctx, ConsentRule{ + Scope: ScopeGlobal, + Target: NewGlobalTarget(), + Action: ActionAny, + Operation: OperationTypeTool, + Permission: PermissionAllow, + }) + require.NoError(t, err) + + require.True(t, allServersTrusted(ctx, mgr, []string{"copilot", "azure", "azd"})) +} + +func TestAllServersTrusted_ReadOnlyRuleNotSufficient(t *testing.T) { + mgr := newTestConsentManager(t) + ctx := context.Background() + + // Grant only read-only access — should NOT count as full trust + for _, server := range []string{"copilot", "azure", "azd"} { + err := mgr.GrantConsent(ctx, ConsentRule{ + Scope: ScopeGlobal, + Target: NewServerTarget(server), + Action: ActionReadOnly, + Operation: OperationTypeTool, + Permission: PermissionAllow, + }) + require.NoError(t, err) + } + + require.False(t, allServersTrusted(ctx, mgr, []string{"copilot", "azure", "azd"})) +} + +func TestGrantWorkflowRules_SessionScope(t *testing.T) { + mgr := newTestConsentManager(t) + ctx := context.Background() + servers := []string{"copilot", "azure", "azd"} + + err := grantWorkflowRules(ctx, mgr, servers, ScopeSession) + require.NoError(t, err) + + // Verify every server is now trusted for tool operations + for _, server := range servers { + req := ConsentRequest{ + ToolID: server + "/any-tool", + ServerName: server, + Operation: OperationTypeTool, + Annotations: mcp.ToolAnnotation{}, + } + decision, err := mgr.CheckConsent(ctx, req) + require.NoError(t, err, "server=%s", server) + require.True(t, decision.Allowed, "server=%s should be allowed", server) + } +} + +func TestGrantWorkflowRules_GlobalScope(t *testing.T) { + // Use a shared config dir so the second manager sees persisted rules + configDir := t.TempDir() + t.Setenv("AZD_CONFIG_DIR", configDir) + + fileConfigMgr := config.NewFileConfigManager(config.NewManager()) + userConfigMgr := config.NewUserConfigManager(fileConfigMgr) + + lazyEnvMgr := lazy.NewLazy[environment.Manager](func() (environment.Manager, error) { + return nil, fmt.Errorf("no environment in test") + }) + + mgr := &consentManager{ + lazyEnvManager: lazyEnvMgr, + userConfigManager: userConfigMgr, + sessionRules: make([]ConsentRule, 0), + } + + ctx := context.Background() + servers := []string{"copilot", "azure", "azd"} + + err := grantWorkflowRules(ctx, mgr, servers, ScopeGlobal) + require.NoError(t, err) + + // Create a FRESH consent manager (simulating a new session) — same config dir + mgr2 := &consentManager{ + lazyEnvManager: lazyEnvMgr, + userConfigManager: userConfigMgr, + sessionRules: make([]ConsentRule, 0), + } + + for _, server := range servers { + req := ConsentRequest{ + ToolID: server + "/any-tool", + ServerName: server, + Operation: OperationTypeTool, + Annotations: mcp.ToolAnnotation{}, + } + decision, err := mgr2.CheckConsent(ctx, req) + require.NoError(t, err, "server=%s", server) + require.True(t, decision.Allowed, "server=%s should survive reload", server) + } +} + +func TestGrantWorkflowRules_SessionScope_DoesNotSurviveReload(t *testing.T) { + mgr := newTestConsentManager(t) + ctx := context.Background() + + err := grantWorkflowRules(ctx, mgr, []string{"copilot"}, ScopeSession) + require.NoError(t, err) + + // Same manager — should work + decision, err := mgr.CheckConsent(ctx, ConsentRequest{ + ToolID: "copilot/any-tool", + ServerName: "copilot", + Operation: OperationTypeTool, + Annotations: mcp.ToolAnnotation{}, + }) + require.NoError(t, err) + require.True(t, decision.Allowed) + + // New manager — session rules gone + mgr2 := newTestConsentManager(t) + decision, err = mgr2.CheckConsent(ctx, ConsentRequest{ + ToolID: "copilot/any-tool", + ServerName: "copilot", + Operation: OperationTypeTool, + Annotations: mcp.ToolAnnotation{}, + }) + require.NoError(t, err) + require.False(t, decision.Allowed) + require.True(t, decision.RequiresPrompt) +} diff --git a/cli/azd/internal/agent/copilot_agent.go b/cli/azd/internal/agent/copilot_agent.go index 859f52feb3c..d2095e68f15 100644 --- a/cli/azd/internal/agent/copilot_agent.go +++ b/cli/azd/internal/agent/copilot_agent.go @@ -46,10 +46,11 @@ type CopilotAgent struct { onSessionStarted func(sessionID string) // Runtime state - session *copilot.Session - sessionID string - sessionCtx context.Context - display *AgentDisplay // last display for usage metrics + clientStarted bool + session *copilot.Session + sessionID string + sessionCtx context.Context + display *AgentDisplay // last display for usage metrics // Cleanup — ordered slice for deterministic teardown cleanupTasks []cleanupTask @@ -69,6 +70,15 @@ func (a *CopilotAgent) Initialize(ctx context.Context, opts ...InitOption) (*Ini opt(options) } + // Always ensure copilot is downloaded and user is authenticated first + if err := a.ensureClientStarted(ctx); err != nil { + return nil, err + } + + if err := a.ensureAuthenticated(ctx); err != nil { + return nil, err + } + // Load current config azdConfig, err := a.configManager.Load() if err != nil { @@ -97,7 +107,7 @@ func (a *CopilotAgent) Initialize(ctx context.Context, opts ...InitOption) (*Ini }, nil } - // First run — prompt for reasoning effort + // Prompt for reasoning effort effortChoices := []*uxlib.SelectChoice{ {Value: "low", Label: "Low — fastest, lowest cost"}, {Value: "medium", Label: "Medium — balanced (recommended)"}, @@ -115,7 +125,6 @@ func (a *CopilotAgent) Initialize(ctx context.Context, opts ...InitOption) (*Ini }) effortIdx, err := effortSelector.Ask(ctx) - fmt.Println() if err != nil { return nil, err } @@ -129,23 +138,21 @@ func (a *CopilotAgent) Initialize(ctx context.Context, opts ...InitOption) (*Ini {Value: "", Label: "Default model (recommended)"}, } - // Start client to list models - if startErr := a.clientManager.Start(ctx); startErr == nil { - models, modelsErr := a.clientManager.ListModels(ctx) - if modelsErr == nil { - for _, m := range models { - label := m.Name - if m.DefaultReasoningEffort != "" { - label += fmt.Sprintf(" (%s)", m.DefaultReasoningEffort) - } - if m.Billing != nil { - label += fmt.Sprintf(" (%.0fx)", m.Billing.Multiplier) - } - modelChoices = append(modelChoices, &uxlib.SelectChoice{ - Value: m.ID, - Label: label, - }) + // Client already started — list models + models, modelsErr := a.clientManager.ListModels(ctx) + if modelsErr == nil { + for _, m := range models { + label := m.Name + if m.DefaultReasoningEffort != "" { + label += fmt.Sprintf(" (%s)", m.DefaultReasoningEffort) } + if m.Billing != nil { + label += fmt.Sprintf(" (%.0fx)", m.Billing.Multiplier) + } + modelChoices = append(modelChoices, &uxlib.SelectChoice{ + Value: m.ID, + Label: label, + }) } } @@ -160,7 +167,6 @@ func (a *CopilotAgent) Initialize(ctx context.Context, opts ...InitOption) (*Ini }) modelIdx, err := modelSelector.Ask(ctx) - fmt.Println() if err != nil { return nil, err } @@ -235,7 +241,7 @@ func (a *CopilotAgent) SelectSession(ctx context.Context) (*SessionMetadata, err }) idx, err := selector.Ask(ctx) - fmt.Println() + a.console.Message(ctx, "") if err != nil { return nil, nil } @@ -249,7 +255,7 @@ func (a *CopilotAgent) SelectSession(ctx context.Context) (*SessionMetadata, err // ListSessions returns previous sessions for the given working directory. func (a *CopilotAgent) ListSessions(ctx context.Context, cwd string) ([]SessionMetadata, error) { - if err := a.clientManager.Start(ctx); err != nil { + if err := a.ensureClientStarted(ctx); err != nil { return nil, err } @@ -370,6 +376,21 @@ func (a *CopilotAgent) addCleanup(name string, fn func() error) { a.cleanupTasks = append(a.cleanupTasks, cleanupTask{name: name, fn: fn}) } +// ensureClientStarted starts the Copilot client if not already running. +// Idempotent — safe to call multiple times. +func (a *CopilotAgent) ensureClientStarted(ctx context.Context) error { + if a.clientStarted { + return nil + } + + if err := a.clientManager.Start(ctx); err != nil { + return err + } + a.addCleanup("copilot-client", a.clientManager.Stop) + a.clientStarted = true + return nil +} + // ensureSession creates or resumes a Copilot session if one doesn't exist. func (a *CopilotAgent) ensureSession(ctx context.Context, resumeSessionID string) error { if a.session != nil { @@ -379,10 +400,9 @@ func (a *CopilotAgent) ensureSession(ctx context.Context, resumeSessionID string a.sessionCtx = ctx // Start client (extracts bundled CLI to cache if needed) - if err := a.clientManager.Start(ctx); err != nil { + if err := a.ensureClientStarted(ctx); err != nil { return err } - a.addCleanup("copilot-client", a.clientManager.Stop) // Check authentication — prompt to sign in if needed if err := a.ensureAuthenticated(ctx); err != nil { @@ -847,7 +867,7 @@ func (a *CopilotAgent) ensureAuthenticated(ctx context.Context) error { return fmt.Errorf("GitHub Copilot authentication is required to continue") } - fmt.Println() + a.console.Message(ctx, "") if err := a.cli.Login(ctx); err != nil { return fmt.Errorf("GitHub Copilot sign-in failed: %w", err) } @@ -858,6 +878,8 @@ func (a *CopilotAgent) ensureAuthenticated(ctx context.Context) error { return fmt.Errorf("GitHub Copilot authentication was not completed") } + a.console.Message(ctx, "") + return nil } diff --git a/cli/azd/internal/agent/display.go b/cli/azd/internal/agent/display.go index fe836cb1bee..fc632ab54cd 100644 --- a/cli/azd/internal/agent/display.go +++ b/cli/azd/internal/agent/display.go @@ -141,9 +141,14 @@ func (d *AgentDisplay) Start(ctx context.Context) (func(), error) { printer.Fprintln() return nil }), - // Blank line before spinner + // Blank line before spinner (skip if last output was already blank) uxlib.NewVisualElement(func(printer uxlib.Printer) error { - printer.Fprintln() + d.mu.Lock() + wasBlank := d.lastPrintedBlank + d.mu.Unlock() + if !wasBlank { + printer.Fprintln() + } return nil }), d.spinner, diff --git a/cli/azd/pkg/tools/git/git.go b/cli/azd/pkg/tools/git/git.go index 46780ab9769..66b372f59d9 100644 --- a/cli/azd/pkg/tools/git/git.go +++ b/cli/azd/pkg/tools/git/git.go @@ -249,6 +249,31 @@ func (cli *Cli) IsUntrackedFile(ctx context.Context, repositoryPath string, file return false, nil } +// GetStatus returns the porcelain status output of the repository. +// An empty string indicates a clean working directory with no uncommitted changes. +func (cli *Cli) GetStatus(ctx context.Context, repositoryPath string) (string, error) { + runArgs := newRunArgs("-C", repositoryPath, "status", "--porcelain") + res, err := cli.commandRunner.Run(ctx, runArgs) + if notGitRepositoryRegex.MatchString(res.Stderr) { + return "", ErrNotRepository + } else if err != nil { + return "", fmt.Errorf("failed to get repository status: %w", err) + } + + return strings.TrimRight(res.Stdout, "\r\n"), nil +} + +// IsDirty returns true if the repository has uncommitted changes including +// staged, unstaged, or untracked files. +func (cli *Cli) IsDirty(ctx context.Context, repositoryPath string) (bool, error) { + status, err := cli.GetStatus(ctx, repositoryPath) + if err != nil { + return false, err + } + + return status != "", nil +} + // SetGitHubAuthForRepo creates git config for the repositoryPath like // // [credential "https://github.com"] (when credential is equal to "https://github.com") diff --git a/cli/azd/pkg/tools/git/git_test.go b/cli/azd/pkg/tools/git/git_test.go new file mode 100644 index 00000000000..c2f5fc7466b --- /dev/null +++ b/cli/azd/pkg/tools/git/git_test.go @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package git + +import ( + "context" + "errors" + "slices" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/exec" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockexec" + "github.com/stretchr/testify/require" +) + +func TestGetStatus(t *testing.T) { + tests := []struct { + name string + stdout string + stderr string + exitCode int + err error + wantStatus string + wantErr error + }{ + { + name: "CleanRepo", + stdout: "", + exitCode: 0, + wantStatus: "", + }, + { + name: "DirtyRepo_ModifiedFile", + stdout: " M file.go\n", + exitCode: 0, + wantStatus: " M file.go", + }, + { + name: "DirtyRepo_MultipleChanges", + stdout: " M file.go\n?? newfile.txt\nA staged.go\n", + exitCode: 0, + wantStatus: " M file.go\n?? newfile.txt\nA staged.go", + }, + { + name: "NotAGitRepo", + stderr: "fatal: not a git repository (or any of the parent directories): .git", + err: errors.New("exit code: 128"), + wantErr: ErrNotRepository, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cmdRunner := mockexec.NewMockCommandRunner() + cmdRunner.When(func(args exec.RunArgs, command string) bool { + return slices.Contains(args.Args, "status") && + slices.Contains(args.Args, "--porcelain") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + ExitCode: tt.exitCode, + Stdout: tt.stdout, + Stderr: tt.stderr, + }, tt.err + }) + + cli := NewCli(cmdRunner) + status, err := cli.GetStatus(t.Context(), ".") + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + require.Equal(t, tt.wantStatus, status) + } + }) + } +} + +func TestIsDirty(t *testing.T) { + tests := []struct { + name string + stdout string + stderr string + exitCode int + err error + wantDirty bool + wantErr error + }{ + { + name: "CleanRepo", + stdout: "", + exitCode: 0, + wantDirty: false, + }, + { + name: "DirtyRepo", + stdout: " M file.go\n", + exitCode: 0, + wantDirty: true, + }, + { + name: "NotAGitRepo", + stderr: "fatal: not a git repository (or any of the parent directories): .git", + err: errors.New("exit code: 128"), + wantErr: ErrNotRepository, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cmdRunner := mockexec.NewMockCommandRunner() + cmdRunner.When(func(args exec.RunArgs, command string) bool { + return slices.Contains(args.Args, "status") && + slices.Contains(args.Args, "--porcelain") + }).RespondFn(func(args exec.RunArgs) (exec.RunResult, error) { + return exec.RunResult{ + ExitCode: tt.exitCode, + Stdout: tt.stdout, + Stderr: tt.stderr, + }, tt.err + }) + + cli := NewCli(cmdRunner) + dirty, err := cli.IsDirty(context.Background(), ".") + + if tt.wantErr != nil { + require.ErrorIs(t, err, tt.wantErr) + } else { + require.NoError(t, err) + require.Equal(t, tt.wantDirty, dirty) + } + }) + } +} diff --git a/cli/azd/pkg/ux/printer.go b/cli/azd/pkg/ux/printer.go index b9331cb1c3b..3f82ccd9e4a 100644 --- a/cli/azd/pkg/ux/printer.go +++ b/cli/azd/pkg/ux/printer.go @@ -135,6 +135,9 @@ func (p *printer) Fprintf(format string, a ...any) { // Check if the content includes any line breaks hasNewLines := strings.Count(content, "\n") > 0 + // Wrapping already counted for the current accumulated line + alreadyCounted := CountLineBreaks(p.currentLine, p.consoleWidth) + var lastLine string newLines := 0 @@ -143,13 +146,17 @@ func (p *printer) Fprintf(format string, a ...any) { // This is used to keep track of the current line being printed lastNewLineIndex := strings.LastIndex(content, "\n") lastLine = content[lastNewLineIndex+1:] - newLines = CountLineBreaks(content, p.consoleWidth) + + // Include accumulated currentLine for accurate wrapping of the first line + fullContent := p.currentLine + content + newLines = CountLineBreaks(fullContent, p.consoleWidth) - alreadyCounted p.currentLine = lastLine } else { // Need to see if appending content to the current line will cause wrapping // (i.e. if the line is longer than the console width) p.currentLine += content - newLines = CountLineBreaks(p.currentLine, p.consoleWidth) + // Only count NEW wrapping lines (delta from what was already tracked) + newLines = CountLineBreaks(p.currentLine, p.consoleWidth) - alreadyCounted } fmt.Fprint(p.writer, content)