Skip to content

Commit f0e171f

Browse files
committed
merge: fix/v0.20.3-intent-md-greenfield-seed (#2)
Closes greenfield-friend dogfood finding #2. INTENT.md now an explicit greenfield-seed marker.
2 parents 29b24cb + 80bf56a commit f0e171f

2 files changed

Lines changed: 100 additions & 0 deletions

File tree

src/commands/init.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,63 @@ const BROWNFIELD_MARKER_EXTENSIONS = ['.sln', '.csproj', '.fsproj', '.vbproj', '
7777

7878
const BROWNFIELD_SOURCE_DIRS = ['src', 'app', 'lib', 'pkg', 'cmd', 'tests', 'test', 'spec']
7979

80+
// v0.20.3 #2 — INTENT.md is the explicit greenfield-seed marker. A directory
81+
// whose only contentful files are INTENT.md (plus neutral git metadata) is
82+
// greenfield by design, regardless of tracked vs untracked status. Caught
83+
// from the v0.20.2 quizr greenfield-friend dogfood (2026-05-14): a fresh
84+
// `git init` + write INTENT.md was misclassifying as brownfield because
85+
// the untracked-files scan picked up INTENT.md and treated it like
86+
// ordinary source content.
87+
const GREENFIELD_SEED_FILES = ['INTENT.md']
88+
const NEUTRAL_METADATA_ENTRIES = new Set([
89+
'.git',
90+
'.code-oz',
91+
'.gitignore',
92+
'.gitattributes',
93+
])
94+
95+
async function listGitTrackedPaths(cwd: string): Promise<string[]> {
96+
try {
97+
const proc = Bun.spawn(['git', '-C', cwd, 'ls-files'], {
98+
stdout: 'pipe',
99+
stderr: 'pipe',
100+
})
101+
const text = await new Response(proc.stdout).text()
102+
await proc.exited
103+
return text
104+
.split('\n')
105+
.map((line) => line.trim())
106+
.filter((line) => line.length > 0 && !line.startsWith('.code-oz/'))
107+
} catch {
108+
return []
109+
}
110+
}
111+
112+
async function isOnlyGreenfieldSeed(cwd: string): Promise<boolean> {
113+
// 1. Walk top-level entries. Anything outside {seed, neutral metadata}
114+
// disqualifies — a real brownfield project has source files or
115+
// lockfiles at root that would show up here.
116+
const entries = await readdir(cwd).catch(() => [] as string[])
117+
for (const e of entries) {
118+
if (GREENFIELD_SEED_FILES.includes(e)) continue
119+
if (NEUTRAL_METADATA_ENTRIES.has(e)) continue
120+
return false
121+
}
122+
// 2. If the directory is a git repo, also confirm git tracks nothing
123+
// outside the seed list. A user could have committed INTENT.md
124+
// (`git add INTENT.md && git commit`) and the dirent walk still
125+
// looks seed-only — but tracked content elsewhere in the tree
126+
// would disqualify. Filtering on the basename matches the
127+
// top-level seed pattern (nested INTENT.md is not a seed).
128+
if (!(await pathExists(join(cwd, '.git')))) return true
129+
const tracked = await listGitTrackedPaths(cwd)
130+
for (const f of tracked) {
131+
if (GREENFIELD_SEED_FILES.includes(f)) continue
132+
return false
133+
}
134+
return true
135+
}
136+
80137
async function gitTracksFiles(cwd: string): Promise<boolean> {
81138
try {
82139
const proc = Bun.spawn(['git', '-C', cwd, 'ls-files'], {
@@ -121,6 +178,12 @@ async function gitHasContentfulUntrackedFiles(cwd: string): Promise<boolean> {
121178
}
122179

123180
export async function detectProfile(cwd: string): Promise<Profile> {
181+
// Greenfield-seed early exit (v0.20.3 #2): INTENT.md is the explicit
182+
// seed marker. A directory whose only contentful files are INTENT.md
183+
// (plus neutral git metadata like .gitignore) is greenfield by design,
184+
// regardless of whether INTENT.md is tracked or untracked.
185+
if (await isOnlyGreenfieldSeed(cwd)) return 'greenfield'
186+
124187
if (await gitTracksFiles(cwd)) return 'brownfield'
125188
if (await gitHasContentfulUntrackedFiles(cwd)) return 'brownfield'
126189

tests/cli-init.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,43 @@ describe('code-oz init', () => {
168168
expect(await detectProfile(cwd)).toBe('greenfield')
169169
})
170170

171+
// v0.20.3 #2 — INTENT.md is the explicit greenfield-seed marker. A directory
172+
// whose only contentful files are INTENT.md (plus neutral git metadata like
173+
// .gitignore) is greenfield by design, regardless of whether INTENT.md is
174+
// tracked or untracked. Caught from the v0.20.2 quizr greenfield-friend
175+
// dogfood (2026-05-14): `git init && write INTENT.md && code-oz init` was
176+
// misclassifying as brownfield because the untracked-files scan picked up
177+
// INTENT.md and treated it like ordinary source content.
178+
179+
test('treats a git-initialized directory containing only INTENT.md as greenfield', async () => {
180+
const cwd = tempDir!
181+
await Bun.spawn(['git', '-C', cwd, 'init', '-q']).exited
182+
await writeFile(join(cwd, 'INTENT.md'), '# Build me\n\nA tiny quiz game.\n', 'utf8')
183+
expect(await detectProfile(cwd)).toBe('greenfield')
184+
})
185+
186+
test('treats a directory with committed INTENT.md as greenfield', async () => {
187+
const cwd = tempDir!
188+
await Bun.spawn(['git', '-C', cwd, 'init', '-q']).exited
189+
await Bun.spawn(['git', '-C', cwd, 'config', 'user.email', 'test@example.com']).exited
190+
await Bun.spawn(['git', '-C', cwd, 'config', 'user.name', 'Test']).exited
191+
await writeFile(join(cwd, 'INTENT.md'), '# Build me\n', 'utf8')
192+
await Bun.spawn(['git', '-C', cwd, 'add', 'INTENT.md']).exited
193+
await Bun.spawn(['git', '-C', cwd, 'commit', '-q', '-m', 'init']).exited
194+
expect(await detectProfile(cwd)).toBe('greenfield')
195+
})
196+
197+
test('still detects brownfield when INTENT.md sits alongside other source files', async () => {
198+
// Negative-direction guard: an INTENT.md inside a real brownfield project
199+
// (e.g., user wrote intent for a refactor) must NOT force greenfield.
200+
// The seed-only exit fires ONLY when INTENT.md is the entire contentful
201+
// surface area.
202+
const cwd = tempDir!
203+
await writeFile(join(cwd, 'INTENT.md'), '# Add a new feature\n', 'utf8')
204+
await writeFile(join(cwd, 'package.json'), '{"name":"existing"}', 'utf8')
205+
expect(await detectProfile(cwd)).toBe('brownfield')
206+
})
207+
171208
test('init records the detected profile in config.yaml', async () => {
172209
await writeFile(join(tempDir!, 'package.json'), '{"name":"x"}', 'utf8')
173210
const { profile, paths } = await initProject({ cwd: tempDir! })

0 commit comments

Comments
 (0)