diff --git a/src/resources/extensions/gsd/auto/phases.ts b/src/resources/extensions/gsd/auto/phases.ts index fba025d22..35ecad194 100644 --- a/src/resources/extensions/gsd/auto/phases.ts +++ b/src/resources/extensions/gsd/auto/phases.ts @@ -27,7 +27,7 @@ import { debugLog } from "../debug-logger.js"; import { PROJECT_FILES } from "../detection.js"; import { MergeConflictError } from "../git-service.js"; import { join, basename, dirname, parse as parsePath } from "node:path"; -import { existsSync, cpSync } from "node:fs"; +import { existsSync, cpSync, readdirSync } from "node:fs"; import { logWarning, logError } from "../workflow-logger.js"; import { gsdRoot } from "../paths.js"; import { atomicWriteSync } from "../atomic-write.js"; @@ -1009,11 +1009,20 @@ export async function runUnitPhase( } const hasProjectFile = PROJECT_FILES.some((f) => deps.existsSync(join(s.basePath, f))); const hasSrcDir = deps.existsSync(join(s.basePath, "src")); + // Xcode bundles have project-specific names (*.xcodeproj, *.xcworkspace) + // that cannot be matched by exact filename — scan the directory by suffix. + let hasXcodeBundle = false; + try { + const entries = deps.existsSync(s.basePath) ? readdirSync(s.basePath) : []; + hasXcodeBundle = entries.some((e: string) => e.endsWith(".xcodeproj") || e.endsWith(".xcworkspace")); + } catch (err) { + debugLog("runUnitPhase", { phase: "xcode-bundle-scan-failed", basePath: s.basePath, error: String(err) }); + } // Monorepo support (#2347): if no project files in the worktree directory, // walk parent directories up to the filesystem root. In monorepos, // package.json / Cargo.toml etc. live in a parent directory. let hasProjectFileInParent = false; - if (!hasProjectFile && !hasSrcDir) { + if (!hasProjectFile && !hasSrcDir && !hasXcodeBundle) { let checkDir = dirname(s.basePath); const { root } = parsePath(checkDir); while (checkDir !== root) { @@ -1027,11 +1036,11 @@ export async function runUnitPhase( checkDir = dirname(checkDir); } } - if (!hasProjectFile && !hasSrcDir && !hasProjectFileInParent) { + if (!hasProjectFile && !hasSrcDir && !hasXcodeBundle && !hasProjectFileInParent) { // Greenfield projects won't have project files yet — the first task creates them. // Log a warning but allow execution to proceed. The .git check above is sufficient // to ensure we're in a valid working directory. - debugLog("runUnitPhase", { phase: "worktree-health-warn-greenfield", basePath: s.basePath, hasProjectFile, hasSrcDir }); + debugLog("runUnitPhase", { phase: "worktree-health-warn-greenfield", basePath: s.basePath, hasProjectFile, hasSrcDir, hasXcodeBundle }); ctx.ui.notify(`Warning: ${s.basePath} has no recognized project files — proceeding as greenfield project`, "warning"); } } diff --git a/src/resources/extensions/gsd/tests/worktree-health-dispatch.test.ts b/src/resources/extensions/gsd/tests/worktree-health-dispatch.test.ts index 6c2ed26f7..fc8e828e1 100644 --- a/src/resources/extensions/gsd/tests/worktree-health-dispatch.test.ts +++ b/src/resources/extensions/gsd/tests/worktree-health-dispatch.test.ts @@ -9,7 +9,7 @@ import { describe, test, beforeEach, afterEach } from "node:test"; import assert from "node:assert/strict"; -import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { execSync } from "node:child_process"; @@ -57,13 +57,20 @@ function hasRecognizedProjectFiles(basePath: string, existsSyncFn: (p: string) = return false; } +/** Simulate the phases.ts Xcode-bundle detection (readdirSync suffix scan). */ +function hasXcodeBundle(basePath: string): boolean { + try { + return readdirSync(basePath).some((e) => e.endsWith(".xcodeproj") || e.endsWith(".xcworkspace")); + } catch { return false; } +} + import { existsSync } from "node:fs"; // ─── Tests ─────────────────────────────────────────────────────────────────── test("PROJECT_FILES is exported and contains expected multi-ecosystem entries", () => { assert.ok(Array.isArray(PROJECT_FILES), "PROJECT_FILES is an array"); - assert.ok(PROJECT_FILES.length >= 17, `expected >= 17 entries, got ${PROJECT_FILES.length}`); + assert.ok(PROJECT_FILES.length >= 18, `expected >= 18 entries, got ${PROJECT_FILES.length}`); // Spot-check key ecosystems assert.ok(PROJECT_FILES.includes("Cargo.toml"), "includes Rust marker"); assert.ok(PROJECT_FILES.includes("go.mod"), "includes Go marker"); @@ -140,3 +147,29 @@ describe("health check without git repo", () => { assert.ok(!wouldPassHealthCheck(dir, existsSync), "no-git directory should fail health check"); }); }); + +describe("health check with xcodegen and Xcode bundles", () => { + let dir: string; + beforeEach(() => { dir = createGitRepo(); }); + afterEach(() => { rmSync(dir, { recursive: true, force: true }); }); + + test("health check passes for xcodegen project (project.yml, no Package.swift)", () => { + writeFileSync(join(dir, "project.yml"), "name: MyApp\ntargets:\n MyApp:\n type: application\n"); + assert.ok(wouldPassHealthCheck(dir, existsSync), "xcodegen project should pass health check"); + }); + + // Regression for the real-world failure in #1882: an iOS project with a + // project-specific Xcode bundle (Sudokuxyz.xcodeproj/) was blocked because + // PROJECT_FILES only probes exact filenames, not suffix-based directory names. + test("Xcode bundle (*.xcodeproj) is not in PROJECT_FILES but detected by suffix scan", () => { + mkdirSync(join(dir, "Sudokuxyz.xcodeproj"), { recursive: true }); + mkdirSync(join(dir, "Sources", "Sudokuxyz"), { recursive: true }); + writeFileSync(join(dir, "Sources", "Sudokuxyz", "ContentView.swift"), "import SwiftUI\n"); + // PROJECT_FILES uses exact names — cannot match project-specific bundle names + assert.ok(!hasRecognizedProjectFiles(dir, existsSync), "xcodeproj bundle must NOT be in PROJECT_FILES"); + // The readdirSync suffix scan used in phases.ts detects it + assert.ok(hasXcodeBundle(dir), "readdirSync suffix scan detects .xcodeproj bundle"); + // Health check passes regardless (only requires .git) + assert.ok(wouldPassHealthCheck(dir, existsSync), "Xcode bundle project should pass health check"); + }); +});