Skip to content

Commit 7f51e41

Browse files
authored
Allow add-wizard to initialize empty repositories (#51895)
1 parent 896e0d3 commit 7f51e41

7 files changed

Lines changed: 180 additions & 3 deletions

File tree

pkg/cli/add_command.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ type AddOptions struct {
8282
// the workflow frontmatter, enabling GitHub Actions token auth for Copilot.
8383
// Set by the add-wizard when the user selects org-billing auth instead of a PAT.
8484
AddCopilotRequestsPermission bool
85+
// initializedFiles contains files created by add-wizard after its clean-tree check.
86+
initializedFiles []string
8587
}
8688

8789
// AddWorkflowsResult contains the result of adding workflows
@@ -262,7 +264,7 @@ func AddResolvedWorkflows(ctx context.Context, workflowStrings []string, resolve
262264
}
263265

264266
// Check no other changes are present
265-
if err := checkCleanWorkingDirectory(opts.Verbose); err != nil {
267+
if err := checkCleanWorkingDirectoryIgnoring(opts.Verbose, opts.initializedFiles); err != nil {
266268
return nil, fmt.Errorf("working directory is not clean: %w", err)
267269
}
268270
}

pkg/cli/add_command_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,51 @@ func TestEnsureAddRepositoryInitialized(t *testing.T) {
462462
})
463463
}
464464

465+
// TestEnsureAddRepositoryInitializedWithDetails_AbsolutePaths verifies that
466+
// ensureAddRepositoryInitializedWithDetails returns absolute paths for files
467+
// that were actually written by init, and skips files that init deliberately
468+
// does not create (e.g. .gitattributes when --no-gitattributes is used).
469+
func TestEnsureAddRepositoryInitializedWithDetails_AbsolutePaths(t *testing.T) {
470+
repoDir := t.TempDir()
471+
472+
originalFindGitRoot := addFindGitRoot
473+
originalInitRepository := addInitRepository
474+
originalMissingInitMarkers := addMissingInitMarkers
475+
t.Cleanup(func() {
476+
addFindGitRoot = originalFindGitRoot
477+
addInitRepository = originalInitRepository
478+
addMissingInitMarkers = originalMissingInitMarkers
479+
})
480+
481+
// Use a marker whose isBootstrapInitMarkerSatisfied check uses the default
482+
// branch (file exists and size > 0), so the test does not need to reproduce
483+
// marker-specific content such as a SKILL.md or MCP config.
484+
writtenMarker := ".vscode/settings.json"
485+
skippedMarker := ".gitattributes"
486+
487+
addFindGitRoot = func() (string, error) { return repoDir, nil }
488+
addMissingInitMarkers = func(string, string) ([]string, error) {
489+
return []string{writtenMarker, skippedMarker}, nil
490+
}
491+
addInitRepository = func(InitOptions) error {
492+
// Simulate init: create the settings.json marker but skip .gitattributes.
493+
p := filepath.Join(repoDir, filepath.FromSlash(writtenMarker))
494+
if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil {
495+
return err
496+
}
497+
return os.WriteFile(p, []byte(`{}`), 0644)
498+
}
499+
500+
files, err := ensureAddRepositoryInitializedWithDetails("", false, true)
501+
require.NoError(t, err)
502+
503+
// Only the actually-written file should be returned.
504+
require.Len(t, files, 1)
505+
// The returned path must be absolute.
506+
require.True(t, filepath.IsAbs(files[0]), "expected absolute path, got %q", files[0])
507+
require.Equal(t, filepath.Join(repoDir, filepath.FromSlash(writtenMarker)), files[0])
508+
}
509+
465510
func TestAddResolvedWorkflows_IgnoresBootstrapRequireOwnerTypeDuringInstall(t *testing.T) {
466511
originalCheckOwnerType := bootstrapCheckOwnerType
467512
t.Cleanup(func() {

pkg/cli/add_init.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cli
33
import (
44
"errors"
55
"fmt"
6+
"path/filepath"
67

78
"github.com/github/gh-aw/pkg/gitutil"
89
)
@@ -35,7 +36,6 @@ func ensureAddRepositoryInitializedWithDetails(engineOverride string, verbose bo
3536
if len(missingMarkers) == 0 {
3637
return nil
3738
}
38-
initializedFiles = append(initializedFiles, missingMarkers...)
3939

4040
addLog.Printf("Repository missing init markers; running init: %v", missingMarkers)
4141
if err := addInitRepository(InitOptions{
@@ -53,6 +53,21 @@ func ensureAddRepositoryInitializedWithDetails(engineOverride string, verbose bo
5353
return fmt.Errorf("failed to initialize repository for agentic workflows: %w", err)
5454
}
5555

56+
// Record only the files that were actually written by init (some markers,
57+
// e.g. .gitattributes with --no-gitattributes, may intentionally be skipped).
58+
// Use absolute paths so callers don't need to resolve against gitRoot.
59+
for _, marker := range missingMarkers {
60+
ok, statErr := isBootstrapInitMarkerSatisfied(".", marker)
61+
if statErr != nil || !ok {
62+
continue
63+
}
64+
absPath, pathErr := filepath.Abs(marker)
65+
if pathErr != nil {
66+
return fmt.Errorf("failed to resolve path for initialized file %s: %w", marker, pathErr)
67+
}
68+
initializedFiles = append(initializedFiles, absPath)
69+
}
70+
5671
return nil
5772
})
5873
if err != nil {

pkg/cli/add_interactive_git.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ func (c *AddInteractiveConfig) createWorkflowPRAndConfigureSecret(ctx context.Co
5656
StopAfter: c.StopAfter,
5757
DisableSecurityScanner: c.DisableSecurityScanner,
5858
AddCopilotRequestsPermission: c.UseCopilotRequests,
59+
initializedFiles: initFiles,
5960
}
6061
result, err := AddResolvedWorkflows(ctx, c.WorkflowSpecs, c.resolvedWorkflows, opts)
6162
if err != nil {

pkg/cli/add_workflow_pr.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ func addWorkflowsWithPR(ctx context.Context, workflows []*ResolvedWorkflow, opts
7676

7777
// Create file tracker for rollback capability
7878
tracker := NewFileTracker()
79+
for _, initializedFile := range opts.initializedFiles {
80+
tracker.TrackCreated(initializedFile)
81+
}
7982

8083
// Ensure we switch back to original branch on exit
8184
defer func() {

pkg/cli/git.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -558,9 +558,38 @@ func hasPendingChanges() (bool, error) {
558558

559559
// checkCleanWorkingDirectory checks if there are uncommitted changes
560560
func checkCleanWorkingDirectory(verbose bool) error {
561+
return checkCleanWorkingDirectoryIgnoring(verbose, nil)
562+
}
563+
564+
// checkCleanWorkingDirectoryIgnoring checks for uncommitted changes except for
565+
// the provided paths (which may be absolute or repository-relative).
566+
func checkCleanWorkingDirectoryIgnoring(verbose bool, ignoredPaths []string) error {
561567
console.LogVerbose(verbose, "Checking for uncommitted changes...")
562568

563-
cmd := exec.Command("git", "status", "--porcelain")
569+
args := []string{"status", "--porcelain", "--untracked-files=all"}
570+
if len(ignoredPaths) > 0 {
571+
gitRoot, err := gitutil.FindGitRoot()
572+
if err != nil {
573+
return fmt.Errorf("failed to find git root for path resolution: %w", err)
574+
}
575+
args = append(args, "--", ":(top)**")
576+
for _, ignoredPath := range ignoredPaths {
577+
cleaned := filepath.Clean(ignoredPath)
578+
// Convert absolute paths to paths relative to the git root so they
579+
// work correctly as :(top,...) pathspecs.
580+
if filepath.IsAbs(cleaned) {
581+
rel, relErr := filepath.Rel(gitRoot, cleaned)
582+
if relErr != nil {
583+
return fmt.Errorf("failed to resolve %s relative to git root: %w", ignoredPath, relErr)
584+
}
585+
cleaned = rel
586+
}
587+
path := filepath.ToSlash(strings.TrimPrefix(cleaned, "."+string(filepath.Separator)))
588+
args = append(args, ":(top,literal,exclude)"+path)
589+
}
590+
}
591+
592+
cmd := exec.Command("git", args...)
564593
output, err := cmd.Output()
565594
if err != nil {
566595
return fmt.Errorf("failed to check git status: %w", err)

pkg/cli/git_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,88 @@ func TestGetCurrentBranchNotInRepo(t *testing.T) {
7979
assert.Error(t, err, "getCurrentBranch should return an error when not in a git repository")
8080
}
8181

82+
func TestCheckCleanWorkingDirectoryIgnoring(t *testing.T) {
83+
tmpDir := testutil.TempDir(t, "test-*")
84+
85+
originalDir, err := os.Getwd()
86+
require.NoError(t, err)
87+
defer func() {
88+
require.NoError(t, os.Chdir(originalDir))
89+
}()
90+
91+
require.NoError(t, os.Chdir(tmpDir))
92+
require.NoError(t, exec.Command("git", "init").Run())
93+
require.NoError(t, exec.Command("git", "config", "user.name", "Test User").Run())
94+
require.NoError(t, exec.Command("git", "config", "user.email", "test@example.com").Run())
95+
96+
generatedFile := filepath.Join(".github", "skills", "agentic-workflows", "SKILL.md")
97+
require.NoError(t, os.MkdirAll(filepath.Dir(generatedFile), 0755))
98+
require.NoError(t, os.WriteFile(generatedFile, []byte("generated"), 0644))
99+
100+
require.NoError(t, checkCleanWorkingDirectoryIgnoring(false, []string{generatedFile}))
101+
require.ErrorContains(t, checkCleanWorkingDirectory(false), "working directory has uncommitted changes")
102+
103+
// Staged (but not committed) init file should also be excluded.
104+
require.NoError(t, exec.Command("git", "add", generatedFile).Run())
105+
require.NoError(t, checkCleanWorkingDirectoryIgnoring(false, []string{generatedFile}))
106+
require.ErrorContains(t, checkCleanWorkingDirectory(false), "working directory has uncommitted changes")
107+
108+
require.NoError(t, exec.Command("git", "commit", "-m", "initial commit").Run())
109+
require.NoError(t, os.WriteFile(generatedFile, []byte("updated"), 0644))
110+
111+
require.NoError(t, checkCleanWorkingDirectoryIgnoring(false, []string{generatedFile}))
112+
require.ErrorContains(t, checkCleanWorkingDirectory(false), "working directory has uncommitted changes")
113+
114+
require.NoError(t, os.WriteFile("README.md", []byte("user file"), 0644))
115+
require.ErrorContains(
116+
t,
117+
checkCleanWorkingDirectoryIgnoring(false, []string{generatedFile}),
118+
"working directory has uncommitted changes",
119+
)
120+
}
121+
122+
// TestCheckCleanWorkingDirectoryIgnoringAbsolutePaths verifies that absolute
123+
// paths are accepted when the current directory is a subdirectory of the repo.
124+
// This is the case when the wizard is invoked from a nested directory and
125+
// ensureAddRepositoryInitializedWithDetails returns absolute paths.
126+
func TestCheckCleanWorkingDirectoryIgnoringAbsolutePaths(t *testing.T) {
127+
repoDir := testutil.TempDir(t, "test-*")
128+
129+
originalDir, err := os.Getwd()
130+
require.NoError(t, err)
131+
defer func() {
132+
require.NoError(t, os.Chdir(originalDir))
133+
}()
134+
135+
require.NoError(t, os.Chdir(repoDir))
136+
require.NoError(t, exec.Command("git", "init").Run())
137+
require.NoError(t, exec.Command("git", "config", "user.name", "Test User").Run())
138+
require.NoError(t, exec.Command("git", "config", "user.email", "test@example.com").Run())
139+
140+
// Create the init file at the repo root.
141+
generatedFile := filepath.Join(".github", "skills", "agentic-workflows", "SKILL.md")
142+
require.NoError(t, os.MkdirAll(filepath.Dir(generatedFile), 0755))
143+
require.NoError(t, os.WriteFile(generatedFile, []byte("generated"), 0644))
144+
absGenerated := filepath.Join(repoDir, generatedFile)
145+
146+
// Create a subdirectory and cd into it to simulate a nested invocation.
147+
subDir := filepath.Join(repoDir, "subdir")
148+
require.NoError(t, os.MkdirAll(subDir, 0755))
149+
require.NoError(t, os.Chdir(subDir))
150+
151+
// Passing the absolute path from a nested CWD should still exclude the file.
152+
require.NoError(t, checkCleanWorkingDirectoryIgnoring(false, []string{absGenerated}))
153+
require.ErrorContains(t, checkCleanWorkingDirectory(false), "working directory has uncommitted changes")
154+
155+
// An unrelated untracked file must still be detected even when the init file is excluded.
156+
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "README.md"), []byte("user file"), 0644))
157+
require.ErrorContains(
158+
t,
159+
checkCleanWorkingDirectoryIgnoring(false, []string{absGenerated}),
160+
"working directory has uncommitted changes",
161+
)
162+
}
163+
82164
func TestCreateAndSwitchBranch(t *testing.T) {
83165
tmpDir := testutil.TempDir(t, "test-*")
84166

0 commit comments

Comments
 (0)