diff --git a/.specify/memory/roadmap/phase-5-backlog.md b/.specify/memory/roadmap/phase-5-backlog.md index d01898a..350316d 100644 --- a/.specify/memory/roadmap/phase-5-backlog.md +++ b/.specify/memory/roadmap/phase-5-backlog.md @@ -50,13 +50,10 @@ Post-1.0 enhancements for advanced workflows, improved onboarding, and enterpris - **Dependencies**: Extract Command - **Notes**: Supports advanced monorepo scenarios with nested subtree structures -### 6. Extract Flatten Mode (`--flatten` flag) +### 6. ~~Extract Flatten Mode (`--flatten` flag)~~ IMPLEMENTED as `base: match` -- **Purpose & user value**: Strip pattern prefix from extracted paths (e.g., `src/**/*.c` extracts `src/foo.c` to `dest/foo.c` instead of `dest/src/foo.c`) -- **Success metrics**: - - Users can flatten path structure when needed without manual post-processing -- **Dependencies**: Extract Command -- **Notes**: Default behavior (009-multi-pattern-extraction) preserves full relative paths per industry best practices. `--flatten` restores pre-009 behavior for users who prefer prefix stripping. Handles filename conflicts with clear errors. +- **Status**: Implemented +- **Notes**: The `base` field (`root`/`match`) on extraction mappings replaces the proposed `--flatten` flag. `base: match` strips the literal prefix before the first glob/brace character from destination paths. `--base match` CLI flag for ad-hoc use. Default (`base: root` or omitted) preserves full relative paths for backward compatibility. ### 7. Extract Dry-Run Mode (`--dry-run` flag) diff --git a/Sources/SubtreeLib/Commands/ExtractCommand.swift b/Sources/SubtreeLib/Commands/ExtractCommand.swift index b301f5f..058cead 100644 --- a/Sources/SubtreeLib/Commands/ExtractCommand.swift +++ b/Sources/SubtreeLib/Commands/ExtractCommand.swift @@ -152,6 +152,10 @@ public struct ExtractCommand: AsyncParsableCommand { // T066: --exclude repeatable flag for exclusion patterns @Option(name: .long, help: "Glob pattern to exclude files (can be repeated)") var exclude: [String] = [] + + // --base flag for path base mode (controls destination path structure) + @Option(name: .long, help: "Path base mode: 'root' (default, full path) or 'match' (strip literal prefix)") + var base: String? // T091: --persist flag to save extraction mapping @Flag(name: .long, help: "Save this extraction mapping to subtree.yaml for future use") @@ -174,6 +178,14 @@ public struct ExtractCommand: AsyncParsableCommand { writeStderr(" --clean removes files, --persist saves mappings for extraction\n") Foundation.exit(2) } + + // Validate --base value + if let baseValue = base { + guard baseValue == "root" || baseValue == "match" else { + writeStderr("❌ Error: --base must be 'root' or 'match', got '\(baseValue)'\n") + Foundation.exit(2) + } + } // T023: Route to clean mode if --clean flag is set if clean { @@ -249,16 +261,19 @@ public struct ExtractCommand: AsyncParsableCommand { // T039: Expand brace patterns in --from before matching (011-brace-expansion) let expandedFromPatterns = expandBracePatterns(from) - + // T040: Expand brace patterns in --exclude before matching (011-brace-expansion) let expandedExcludePatterns = expandBracePatterns(exclude) - + + // Build prefix map for base:match stripping (traces expanded patterns to original prefixes) + let expandedPrefixMap = buildExpandedPrefixMap(from: from) + // T023-T025 + T040: Multi-pattern matching with deduplication and per-pattern tracking // Process all --from patterns and collect unique files - var allMatchedFiles: [(sourcePath: String, relativePath: String)] = [] - var seenPaths = Set() // T024: Deduplicate by relative path + var allMatchedFiles: [(sourcePath: String, relativePath: String, destRelativePath: String)] = [] + var seenPaths = Set() // T024: Deduplicate by destination-relative path var patternMatchCounts: [(pattern: String, count: Int)] = [] // T040: Per-pattern tracking - + for pattern in expandedFromPatterns { let matchedFiles = try await findMatchingFiles( in: subtree.prefix, @@ -266,19 +281,28 @@ public struct ExtractCommand: AsyncParsableCommand { excludePatterns: expandedExcludePatterns, gitRoot: gitRoot ) - + + let originalPrefix = expandedPrefixMap[pattern] ?? "" + // T040: Track match count for this pattern var patternUniqueCount = 0 - + // T024: Add files not already seen (deduplication) for file in matchedFiles { - if !seenPaths.contains(file.relativePath) { - seenPaths.insert(file.relativePath) - allMatchedFiles.append(file) + let destRelPath = destinationRelativePath( + for: file.relativePath, + originalPatternPrefix: originalPrefix, + baseMode: self.base + ) + // Dedup key: use stripped path for base:match, original for base:root/nil + let dedupKey = self.base == "match" ? destRelPath : file.relativePath + if !seenPaths.contains(dedupKey) { + seenPaths.insert(dedupKey) + allMatchedFiles.append((file.sourcePath, file.relativePath, destRelPath)) patternUniqueCount += 1 } } - + patternMatchCounts.append((pattern: pattern, count: patternUniqueCount)) } @@ -301,38 +325,40 @@ public struct ExtractCommand: AsyncParsableCommand { // T047-T049: Fail-fast - validate ALL destinations upfront BEFORE copying to ANY if !force { + // Build 2-tuple view using destRelativePath for destination path construction + let destMappedFiles = allMatchedFiles.map { (sourcePath: $0.sourcePath, relativePath: $0.destRelativePath) } var allTrackedFiles: [String] = [] for normalizedDest in normalizedDestinations { let fullDestPath = gitRoot + "/" + normalizedDest let trackedFiles = try await checkForTrackedFiles( - matchedFiles: allMatchedFiles, + matchedFiles: destMappedFiles, fullDestPath: fullDestPath, gitRoot: gitRoot ) allTrackedFiles.append(contentsOf: trackedFiles) } - + if !allTrackedFiles.isEmpty { // T049: Show all conflicts across all destinations try handleOverwriteProtection(trackedFiles: allTrackedFiles) } } - + // T037: Fan-out extraction to all destinations (after validation passes) for normalizedDest in normalizedDestinations { // T074: Destination directory creation let fullDestPath = gitRoot + "/" + normalizedDest try createDestinationDirectory(at: fullDestPath) - + // T072: File copying with FileManager - // T073: Directory structure preservation + // T073: Directory structure preservation; base:match stripping applied via destRelativePath var copiedCount = 0 - for (sourcePath, relativePath) in allMatchedFiles { - let destFilePath = fullDestPath + "/" + relativePath + for (sourcePath, _, destRelativePath) in allMatchedFiles { + let destFilePath = fullDestPath + "/" + destRelativePath try copyFilePreservingStructure(from: sourcePath, to: destFilePath) copiedCount += 1 } - + // T038: Per-destination success output (FR-017) print("✅ Extracted \(copiedCount) file(s) to '\(normalizedDest)'") } @@ -344,6 +370,7 @@ public struct ExtractCommand: AsyncParsableCommand { patterns: from, destinations: deduplicatedDestinations, // T041: Use deduplicated destinations array excludePatterns: exclude, + baseMode: self.base, subtreeName: subtreeName, configPath: configPath ) @@ -619,20 +646,22 @@ public struct ExtractCommand: AsyncParsableCommand { // 011-brace-expansion: Expand brace patterns before matching let expandedFromPatterns = expandBracePatterns(mapping.from) let expandedExcludePatterns = expandBracePatterns(mapping.exclude ?? []) - + // T044 + T052: Fan-out clean to all destinations var totalDeletedCount = 0 - + for normalizedDest in normalizedDestinations { let fullDestPath = gitRoot + "/" + normalizedDest - - // Find files to clean + + // Find files to clean (pass base mode and original patterns for reverse mapping) let filesToClean = try await findFilesToClean( patterns: expandedFromPatterns, excludePatterns: expandedExcludePatterns, subtreePrefix: subtree.prefix, destinationPath: fullDestPath, - gitRoot: gitRoot + gitRoot: gitRoot, + baseMode: mapping.base, + originalPatterns: mapping.from ) // Zero files = success for this destination @@ -733,18 +762,20 @@ public struct ExtractCommand: AsyncParsableCommand { // 011-brace-expansion: Expand brace patterns before matching let expandedFromPatterns = expandBracePatterns(from) let expandedExcludePatterns = expandBracePatterns(exclude) - + // T043-T046: Fan-out clean to all destinations for normalizedDest in normalizedDestinations { let fullDestPath = gitRoot + "/" + normalizedDest - - // T025: Find files to clean in destination + + // T025: Find files to clean in destination (pass base mode for reverse mapping) let filesToClean = try await findFilesToClean( patterns: expandedFromPatterns, excludePatterns: expandedExcludePatterns, subtreePrefix: subtree.prefix, destinationPath: fullDestPath, - gitRoot: gitRoot + gitRoot: gitRoot, + baseMode: self.base, + originalPatterns: from ) // BC-007: Zero files matched = success for this destination @@ -813,55 +844,125 @@ public struct ExtractCommand: AsyncParsableCommand { } /// T025: Find files in destination that match source patterns + /// + /// When `baseMode` is "match", destination files have had their literal prefix stripped. + /// To reconstruct source paths for checksum validation, we prepend the original prefix back. + /// + /// - Parameters: + /// - patterns: Expanded glob patterns (post-brace-expansion) + /// - excludePatterns: Expanded exclusion patterns + /// - subtreePrefix: Subtree prefix directory (e.g., "Vendor/bitcoin") + /// - destinationPath: Full destination directory path + /// - gitRoot: Git repository root + /// - baseMode: Optional base mode ("match" or "root"/nil) + /// - originalPatterns: Original (pre-expansion) patterns for prefix computation private func findFilesToClean( patterns: [String], excludePatterns: [String], subtreePrefix: String, destinationPath: String, - gitRoot: String + gitRoot: String, + baseMode: String? = nil, + originalPatterns: [String] = [] ) async throws -> [CleanFileEntry] { var allFiles: [CleanFileEntry] = [] var seenPaths = Set() - + // Create exclusion matchers let excludeMatchers = try excludePatterns.map { try GlobMatcher(pattern: $0) } - + + // Build prefix map for base:match reverse mapping + let expandedPrefixMap: [String: String] + if baseMode == "match" && !originalPatterns.isEmpty { + expandedPrefixMap = buildExpandedPrefixMap(from: originalPatterns) + } else { + expandedPrefixMap = [:] + } + for pattern in patterns { let matcher = try GlobMatcher(pattern: pattern) let patternPrefix = extractLiteralPrefix(from: pattern) - + // Scan destination directory for matching files var matchedFiles: [(String, String)] = [] - + // Check if destination exists guard FileManager.default.fileExists(atPath: destinationPath) else { continue } - - try scanDirectory( - at: destinationPath, - relativeTo: destinationPath, - matcher: matcher, - excludeMatchers: excludeMatchers, - patternPrefix: patternPrefix, - results: &matchedFiles - ) - - // Convert to CleanFileEntry with source paths - let sourcePrefixPath = gitRoot + "/" + subtreePrefix - for (destPath, relativePath) in matchedFiles { - if !seenPaths.contains(relativePath) { - seenPaths.insert(relativePath) - let sourcePath = sourcePrefixPath + "/" + relativePath - allFiles.append(CleanFileEntry( - sourcePath: sourcePath, - destinationPath: destPath, - relativePath: relativePath - )) + + // For base:match, destination files are stored at stripped paths. + // We need to match against stripped patterns too. + if baseMode == "match" { + let originalPrefix = expandedPrefixMap[pattern] ?? "" + // Build a matcher for the stripped pattern (remove literal prefix) + let strippedPattern: String + if !originalPrefix.isEmpty && pattern.hasPrefix(originalPrefix) { + strippedPattern = String(pattern.dropFirst(originalPrefix.count)) + } else { + strippedPattern = pattern + } + let strippedMatcher = try GlobMatcher(pattern: strippedPattern) + let strippedPatternPrefix = extractLiteralPrefix(from: strippedPattern) + + // Build stripped exclusion matchers + let strippedExcludeMatchers = try excludePatterns.compactMap { excludePattern -> GlobMatcher? in + if !originalPrefix.isEmpty && excludePattern.hasPrefix(originalPrefix) { + return try GlobMatcher(pattern: String(excludePattern.dropFirst(originalPrefix.count))) + } + return try GlobMatcher(pattern: excludePattern) + } + + try scanDirectory( + at: destinationPath, + relativeTo: destinationPath, + matcher: strippedMatcher, + excludeMatchers: strippedExcludeMatchers, + patternPrefix: strippedPatternPrefix, + results: &matchedFiles + ) + + // Convert to CleanFileEntry: prepend original prefix to reconstruct source path + let sourcePrefixPath = gitRoot + "/" + subtreePrefix + for (destPath, relativePath) in matchedFiles { + if !seenPaths.contains(relativePath) { + seenPaths.insert(relativePath) + // Reconstruct the full source-relative path by prepending the stripped prefix + let sourceRelativePath = originalPrefix + relativePath + let sourcePath = sourcePrefixPath + "/" + sourceRelativePath + allFiles.append(CleanFileEntry( + sourcePath: sourcePath, + destinationPath: destPath, + relativePath: relativePath + )) + } + } + } else { + try scanDirectory( + at: destinationPath, + relativeTo: destinationPath, + matcher: matcher, + excludeMatchers: excludeMatchers, + patternPrefix: patternPrefix, + results: &matchedFiles + ) + + // Convert to CleanFileEntry with source paths + let sourcePrefixPath = gitRoot + "/" + subtreePrefix + for (destPath, relativePath) in matchedFiles { + if !seenPaths.contains(relativePath) { + seenPaths.insert(relativePath) + let sourcePath = sourcePrefixPath + "/" + relativePath + allFiles.append(CleanFileEntry( + sourcePath: sourcePath, + destinationPath: destPath, + relativePath: relativePath + )) + } } } } - + return allFiles } @@ -914,11 +1015,14 @@ public struct ExtractCommand: AsyncParsableCommand { // 011-brace-expansion: Expand brace patterns before matching let expandedFromPatterns = expandBracePatterns(mapping.from) let expandedExcludePatterns = expandBracePatterns(mapping.exclude ?? []) - + + // Build prefix map for base:match stripping (uses mapping.base from config) + let expandedPrefixMap = buildExpandedPrefixMap(from: mapping.from) + // T026: Find matching files from ALL patterns (multi-pattern support) - var allMatchedFiles: [(sourcePath: String, relativePath: String)] = [] - var seenPaths = Set() // Deduplicate by relative path - + var allMatchedFiles: [(sourcePath: String, relativePath: String, destRelativePath: String)] = [] + var seenPaths = Set() // Deduplicate by destination-relative path + for pattern in expandedFromPatterns { let matchedFiles = try await findMatchingFiles( in: subtree.prefix, @@ -926,33 +1030,42 @@ public struct ExtractCommand: AsyncParsableCommand { excludePatterns: expandedExcludePatterns, gitRoot: gitRoot ) - + + let originalPrefix = expandedPrefixMap[pattern] ?? "" + // Add files not already seen (deduplication) for file in matchedFiles { - if !seenPaths.contains(file.relativePath) { - seenPaths.insert(file.relativePath) - allMatchedFiles.append(file) + let destRelPath = destinationRelativePath( + for: file.relativePath, + originalPatternPrefix: originalPrefix, + baseMode: mapping.base + ) + let dedupKey = mapping.base == "match" ? destRelPath : file.relativePath + if !seenPaths.contains(dedupKey) { + seenPaths.insert(dedupKey) + allMatchedFiles.append((file.sourcePath, file.relativePath, destRelPath)) } } } - + guard !allMatchedFiles.isEmpty else { return 0 // No files matched, but not an error } - + // T047-T049 + T053: Fail-fast - validate ALL destinations upfront BEFORE copying to ANY if !force { + let destMappedFiles = allMatchedFiles.map { (sourcePath: $0.sourcePath, relativePath: $0.destRelativePath) } var allTrackedFiles: [String] = [] for normalizedDest in normalizedDestinations { let fullDestPath = gitRoot + "/" + normalizedDest let trackedFiles = try await checkForTrackedFiles( - matchedFiles: allMatchedFiles, + matchedFiles: destMappedFiles, fullDestPath: fullDestPath, gitRoot: gitRoot ) allTrackedFiles.append(contentsOf: trackedFiles) } - + if !allTrackedFiles.isEmpty { // For bulk mode, throw an error that will be caught and reported struct OverwriteProtectionError: Error, LocalizedError { @@ -964,22 +1077,22 @@ public struct ExtractCommand: AsyncParsableCommand { throw OverwriteProtectionError(trackedFiles: allTrackedFiles) } } - + // T040: Fan-out to all destinations (after validation passes) var totalCopiedCount = 0 for normalizedDest in normalizedDestinations { // Create destination directory let fullDestPath = gitRoot + "/" + normalizedDest try createDestinationDirectory(at: fullDestPath) - - // Copy files to this destination - for (sourcePath, relativePath) in allMatchedFiles { - let destFilePath = fullDestPath + "/" + relativePath + + // Copy files to this destination; base:match stripping applied via destRelativePath + for (sourcePath, _, destRelativePath) in allMatchedFiles { + let destFilePath = fullDestPath + "/" + destRelativePath try copyFilePreservingStructure(from: sourcePath, to: destFilePath) totalCopiedCount += 1 } } - + return totalCopiedCount } @@ -1110,6 +1223,47 @@ public struct ExtractCommand: AsyncParsableCommand { return expandedPatterns } + // MARK: - Base Mode Helpers + + /// Build mapping from each expanded pattern to the literal prefix of its original pattern. + /// + /// When `base: match` is active, we strip the literal prefix from destination paths. + /// Since brace expansion produces multiple patterns from a single original, we need + /// to trace each expanded pattern back to its original's literal prefix. + /// + /// - Parameter originalPatterns: The user-provided patterns (pre-expansion) + /// - Returns: Dictionary mapping expanded pattern strings to their original literal prefix + private func buildExpandedPrefixMap(from originalPatterns: [String]) -> [String: String] { + var map: [String: String] = [:] + for original in originalPatterns { + let prefix = extractLiteralPrefix(from: original) + let expanded = expandBracePatterns([original]) + for exp in expanded { + map[exp] = prefix + } + } + return map + } + + /// Compute destination-relative path, optionally stripping the original pattern's literal prefix. + /// + /// - Parameters: + /// - relativePath: Full relative path from subtree root (e.g., "src/crc32c/include/crc32c.h") + /// - originalPatternPrefix: Literal prefix of the original pattern (e.g., "src/crc32c/") + /// - baseMode: "match" to strip prefix, "root" or nil to preserve full path + /// - Returns: Path to use at destination (stripped or original) + private func destinationRelativePath( + for relativePath: String, + originalPatternPrefix: String, + baseMode: String? + ) -> String { + guard baseMode == "match" else { return relativePath } + if !originalPatternPrefix.isEmpty && relativePath.hasPrefix(originalPatternPrefix) { + return String(relativePath.dropFirst(originalPatternPrefix.count)) + } + return relativePath + } + // MARK: - T070: Glob Pattern Matching /// Find all files matching the glob pattern @@ -1216,8 +1370,7 @@ public struct ExtractCommand: AsyncParsableCommand { // T071: Check exclusion patterns let excluded = excludeMatchers.contains { $0.matches(relativePath) } if !excluded { - // Preserve full relative path (industry standard behavior) - // Future: --flatten flag could strip pattern prefix + // Full relative path preserved here; base:match stripping applied in copy loop results.append((itemPath, relativePath)) } } @@ -1282,6 +1435,7 @@ public struct ExtractCommand: AsyncParsableCommand { /// - patterns: Array of glob patterns (from field) /// - destinations: Destination paths (to field) - T041: now supports array /// - excludePatterns: Exclusion patterns (exclude field) + /// - baseMode: Optional base mode ("root" or "match") /// - subtreeName: Name of subtree to save mapping to /// - configPath: Path to subtree.yaml /// - Returns: true if mapping was saved, false if duplicate detected @@ -1290,6 +1444,7 @@ public struct ExtractCommand: AsyncParsableCommand { patterns: [String], destinations: [String], excludePatterns: [String], + baseMode: String?, subtreeName: String, configPath: String ) async throws -> Bool { @@ -1297,56 +1452,61 @@ public struct ExtractCommand: AsyncParsableCommand { // Use appropriate initializer based on single vs multiple patterns/destinations let mapping: ExtractionMapping let excludeValue = excludePatterns.isEmpty ? nil : excludePatterns - + if patterns.count == 1 && destinations.count == 1 { // Single pattern, single destination mapping = ExtractionMapping( from: patterns[0], to: destinations[0], - exclude: excludeValue + exclude: excludeValue, + base: baseMode ) } else if patterns.count == 1 { // Single pattern, multiple destinations mapping = ExtractionMapping( from: patterns[0], toDestinations: destinations, - exclude: excludeValue + exclude: excludeValue, + base: baseMode ) } else if destinations.count == 1 { // Multiple patterns, single destination mapping = ExtractionMapping( fromPatterns: patterns, to: destinations[0], - exclude: excludeValue + exclude: excludeValue, + base: baseMode ) } else { // Multiple patterns, multiple destinations mapping = ExtractionMapping( fromPatterns: patterns, toDestinations: destinations, - exclude: excludeValue + exclude: excludeValue, + base: baseMode ) } - + // Check for duplicate mapping let config = try await ConfigFileManager.loadConfig(from: configPath) - + if let subtree = try config.findSubtree(name: subtreeName.normalized()), let existingMappings = subtree.extractions { - // Check if exact same mapping already exists + // Check if exact same mapping already exists (including base field) for existing in existingMappings { if existing.from == mapping.from && existing.to == mapping.to && - existing.exclude == mapping.exclude { + existing.exclude == mapping.exclude && + existing.base == mapping.base { // Duplicate found - skip saving return false } } } - + // T092 + T094: Save using ConfigFileManager (atomic operation) try await ConfigFileManager.appendExtraction(mapping, to: subtreeName, in: configPath) - + return true } diff --git a/Sources/SubtreeLib/Configuration/ExtractionMapping.swift b/Sources/SubtreeLib/Configuration/ExtractionMapping.swift index 1db1c3b..87154f4 100644 --- a/Sources/SubtreeLib/Configuration/ExtractionMapping.swift +++ b/Sources/SubtreeLib/Configuration/ExtractionMapping.swift @@ -21,13 +21,19 @@ public struct ExtractionMapping: Equatable, Sendable { /// Optional array of glob patterns to exclude from matches public let exclude: [String]? - + + /// Optional base path mode for controlling destination path structure + /// - "root" (default when nil): preserves full relative path from subtree root + /// - "match": strips literal prefix before first glob/brace character + public let base: String? + // MARK: - CodingKeys - + private enum CodingKeys: String, CodingKey { case from case to case exclude + case base } // MARK: - Initializers @@ -38,10 +44,11 @@ public struct ExtractionMapping: Equatable, Sendable { /// - from: Single glob pattern matching source files (e.g., "docs/**/*.md") /// - to: Single destination path for copied files (e.g., "project-docs/") /// - exclude: Optional array of glob patterns to exclude (e.g., ["docs/internal/**"]) - public init(from: String, to: String, exclude: [String]? = nil) { + public init(from: String, to: String, exclude: [String]? = nil, base: String? = nil) { self.from = [from] self.to = [to] self.exclude = exclude + self.base = base } /// Initialize an extraction mapping with multiple patterns and single destination @@ -61,10 +68,11 @@ public struct ExtractionMapping: Equatable, Sendable { /// - fromPatterns: Array of glob patterns matching source files (processed as union) /// - to: Single destination path for copied files (relative to repository root) /// - exclude: Optional array of glob patterns to exclude (applies to all patterns) - public init(fromPatterns: [String], to: String, exclude: [String]? = nil) { + public init(fromPatterns: [String], to: String, exclude: [String]? = nil, base: String? = nil) { self.from = fromPatterns self.to = [to] self.exclude = exclude + self.base = base } /// Initialize an extraction mapping with a single pattern and multiple destinations (012-multi-destination) @@ -84,10 +92,11 @@ public struct ExtractionMapping: Equatable, Sendable { /// - from: Single glob pattern matching source files /// - toDestinations: Array of destination paths (each receives all matched files) /// - exclude: Optional array of glob patterns to exclude - public init(from: String, toDestinations: [String], exclude: [String]? = nil) { + public init(from: String, toDestinations: [String], exclude: [String]? = nil, base: String? = nil) { self.from = [from] self.to = toDestinations self.exclude = exclude + self.base = base } /// Initialize an extraction mapping with multiple patterns and multiple destinations (012-multi-destination) @@ -107,10 +116,11 @@ public struct ExtractionMapping: Equatable, Sendable { /// - fromPatterns: Array of glob patterns (processed as union) /// - toDestinations: Array of destination paths (each receives all matched files) /// - exclude: Optional array of glob patterns to exclude - public init(fromPatterns: [String], toDestinations: [String], exclude: [String]? = nil) { + public init(fromPatterns: [String], toDestinations: [String], exclude: [String]? = nil, base: String? = nil) { self.from = fromPatterns self.to = toDestinations self.exclude = exclude + self.base = base } } @@ -157,6 +167,20 @@ extension ExtractionMapping: Codable { } self.exclude = try container.decodeIfPresent([String].self, forKey: .exclude) + + // Decode `base`: optional string, validate allowed values + if let baseValue = try container.decodeIfPresent(String.self, forKey: .base) { + guard baseValue == "root" || baseValue == "match" else { + throw DecodingError.dataCorruptedError( + forKey: .base, + in: container, + debugDescription: "base must be 'root' or 'match', got '\(baseValue)'" + ) + } + self.base = baseValue + } else { + self.base = nil + } } /// Custom encoder that outputs string for single value, array for multiple @@ -178,5 +202,6 @@ extension ExtractionMapping: Codable { } try container.encodeIfPresent(exclude, forKey: .exclude) + try container.encodeIfPresent(base, forKey: .base) } } diff --git a/Tests/IntegrationTests/ExtractIntegrationTests.swift b/Tests/IntegrationTests/ExtractIntegrationTests.swift index e2e0b87..7933854 100644 --- a/Tests/IntegrationTests/ExtractIntegrationTests.swift +++ b/Tests/IntegrationTests/ExtractIntegrationTests.swift @@ -1834,4 +1834,293 @@ struct ExtractIntegrationTests { #expect(result.stderr.contains("empty") || result.stderr.contains("Empty"), "Error should mention empty alternative: \(result.stderr)") } + + // MARK: - Base Mode Integration Tests + + @Test("Ad-hoc extraction with --base match strips literal prefix") + func testAdHocExtractionBaseMatch() async throws { + let harness = TestHarness() + let fixture = try await GitRepositoryFixture() + defer { try? fixture.tearDown() } + + // Create subtree with nested structure mimicking bitcoin/crc32c + let subtreePrefix = "vendor/bitcoin" + let files = [ + "\(subtreePrefix)/src/crc32c/include/crc32c/crc32c.h", + "\(subtreePrefix)/src/crc32c/src/crc32c.cc", + "\(subtreePrefix)/src/crc32c/src/crc32c_arm64.cc", + ] + + for file in files { + let fullPath = fixture.path.string + "/" + file + let dir = (fullPath as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + try "// Code".write(toFile: fullPath, atomically: true, encoding: .utf8) + } + + try writeSubtreeConfig( + name: "bitcoin", + remote: "https://example.com/bitcoin.git", + prefix: subtreePrefix, + commit: "abc123", + to: fixture.path.string + "/subtree.yaml" + ) + + try await fixture.runGit(["add", "."]) + try await fixture.runGit(["commit", "-m", "Add subtree"]) + + // Extract with --base match: each --from pattern strips its own literal prefix independently + // --from "src/crc32c/include/**/*.h" → prefix "src/crc32c/include/" stripped → "crc32c/crc32c.h" + // --from "src/crc32c/src/**/*.cc" → prefix "src/crc32c/src/" stripped → "crc32c.cc" + let result = try await harness.run( + arguments: ["extract", "--name", "bitcoin", + "--from", "src/crc32c/include/**/*.h", + "--from", "src/crc32c/src/**/*.cc", + "--to", "Sources/crc32c/", + "--base", "match"], + workingDirectory: fixture.path + ) + + #expect(result.exitCode == 0, "Extract should succeed. stderr: \(result.stderr)") + + // Per-pattern independent prefix stripping: + // "src/crc32c/include/**/*.h" strips "src/crc32c/include/" → "crc32c/crc32c.h" + // "src/crc32c/src/**/*.cc" strips "src/crc32c/src/" → "crc32c.cc", "crc32c_arm64.cc" + let expectedFiles = [ + "Sources/crc32c/crc32c/crc32c.h", + "Sources/crc32c/crc32c.cc", + "Sources/crc32c/crc32c_arm64.cc", + ] + + for file in expectedFiles { + let fullPath = fixture.path.string + "/" + file + #expect(FileManager.default.fileExists(atPath: fullPath), + "File \(file) should exist (per-pattern prefix stripped). stderr: \(result.stderr)") + } + + // Verify old (un-stripped) paths do NOT exist + let unexpectedFiles = [ + "Sources/crc32c/src/crc32c/include/crc32c/crc32c.h", + "Sources/crc32c/src/crc32c/src/crc32c.cc", + ] + + for file in unexpectedFiles { + let fullPath = fixture.path.string + "/" + file + #expect(!FileManager.default.fileExists(atPath: fullPath), + "File \(file) should NOT exist (prefix should be stripped)") + } + } + + @Test("Brace expansion with --base match strips original (pre-expansion) prefix") + func testBraceExpansionBaseMatch() async throws { + let harness = TestHarness() + let fixture = try await GitRepositoryFixture() + defer { try? fixture.tearDown() } + + let subtreePrefix = "vendor/lib" + let files = [ + "\(subtreePrefix)/src/crc32c/include/header.h", + "\(subtreePrefix)/src/crc32c/src/impl.c", + ] + + for file in files { + let fullPath = fixture.path.string + "/" + file + let dir = (fullPath as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + try "// Code".write(toFile: fullPath, atomically: true, encoding: .utf8) + } + + try writeSubtreeConfig( + name: "lib", + remote: "https://example.com/lib.git", + prefix: subtreePrefix, + commit: "abc123", + to: fixture.path.string + "/subtree.yaml" + ) + + try await fixture.runGit(["add", "."]) + try await fixture.runGit(["commit", "-m", "Add subtree"]) + + // Use brace expansion: {include,src} - prefix is "src/crc32c/" + let result = try await harness.run( + arguments: ["extract", "--name", "lib", + "--from", "src/crc32c/{include,src}/**/*.{c,h}", + "--to", "Sources/crc32c/", + "--base", "match"], + workingDirectory: fixture.path + ) + + #expect(result.exitCode == 0, "Extract should succeed. stderr: \(result.stderr)") + + // Prefix "src/crc32c/" should be stripped (computed from original pattern before expansion) + let expectedFiles = [ + "Sources/crc32c/include/header.h", + "Sources/crc32c/src/impl.c", + ] + + for file in expectedFiles { + let fullPath = fixture.path.string + "/" + file + #expect(FileManager.default.fileExists(atPath: fullPath), + "File \(file) should exist (brace prefix stripped). stderr: \(result.stderr)") + } + } + + @Test("--persist --base match saves base field to YAML") + func testPersistBaseMatchSavesToConfig() async throws { + let harness = TestHarness() + let fixture = try await GitRepositoryFixture() + defer { try? fixture.tearDown() } + + let subtreePrefix = "vendor/lib" + let filePath = "\(subtreePrefix)/src/file.c" + let fullFilePath = fixture.path.string + "/" + filePath + let dir = (fullFilePath as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + try "// Code".write(toFile: fullFilePath, atomically: true, encoding: .utf8) + + try writeSubtreeConfig( + name: "lib", + remote: "https://example.com/lib.git", + prefix: subtreePrefix, + commit: "abc123", + to: fixture.path.string + "/subtree.yaml" + ) + + try await fixture.runGit(["add", "."]) + try await fixture.runGit(["commit", "-m", "Add subtree"]) + + let result = try await harness.run( + arguments: ["extract", "--name", "lib", + "--from", "src/**/*.c", + "--to", "Sources/", + "--base", "match", + "--persist"], + workingDirectory: fixture.path + ) + + #expect(result.exitCode == 0, "Extract with --persist should succeed. stderr: \(result.stderr)") + + // Read config and verify base field was saved + let configContent = try String(contentsOfFile: fixture.path.string + "/subtree.yaml", encoding: .utf8) + #expect(configContent.contains("base: match"), + "Config should contain 'base: match'. Config:\n\(configContent)") + } + + @Test("Bulk extraction reads and respects base: match from saved config") + func testBulkExtractionRespectsBaseMatch() async throws { + let harness = TestHarness() + let fixture = try await GitRepositoryFixture() + defer { try? fixture.tearDown() } + + let subtreePrefix = "vendor/mylib" + let files = [ + "\(subtreePrefix)/src/core/engine.c", + "\(subtreePrefix)/src/utils/helper.c", + ] + + for file in files { + let fullPath = fixture.path.string + "/" + file + let dir = (fullPath as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + try "// Code".write(toFile: fullPath, atomically: true, encoding: .utf8) + } + + // Write config with base: match directly in YAML + let yaml = """ + subtrees: + - name: mylib + remote: https://example.com/mylib.git + prefix: \(subtreePrefix) + commit: abc123 + extractions: + - from: "src/**/*.c" + to: Sources/MyLib/ + base: match + """ + try yaml.write(toFile: fixture.path.string + "/subtree.yaml", atomically: true, encoding: .utf8) + + try await fixture.runGit(["add", "."]) + try await fixture.runGit(["commit", "-m", "Add subtree with base:match config"]) + + // Run bulk extraction + let result = try await harness.run( + arguments: ["extract", "--name", "mylib"], + workingDirectory: fixture.path + ) + + #expect(result.exitCode == 0, "Bulk extract should succeed. stderr: \(result.stderr)") + + // Verify "src/" prefix was stripped (base: match) + let expectedFiles = [ + "Sources/MyLib/core/engine.c", // NOT Sources/MyLib/src/core/engine.c + "Sources/MyLib/utils/helper.c", + ] + + for file in expectedFiles { + let fullPath = fixture.path.string + "/" + file + #expect(FileManager.default.fileExists(atPath: fullPath), + "File \(file) should exist (bulk with base:match). stderr: \(result.stderr)") + } + + // Verify un-stripped paths don't exist + let unexpectedFile = "Sources/MyLib/src/core/engine.c" + #expect(!FileManager.default.fileExists(atPath: fixture.path.string + "/" + unexpectedFile), + "Un-stripped path should NOT exist") + } + + @Test("Default behavior (no --base) preserves full paths (regression guard)") + func testDefaultBehaviorPreservesFullPaths() async throws { + let harness = TestHarness() + let fixture = try await GitRepositoryFixture() + defer { try? fixture.tearDown() } + + let subtreePrefix = "vendor/mylib" + let files = [ + "\(subtreePrefix)/src/core/engine.c", + "\(subtreePrefix)/src/utils/helper.c", + ] + + for file in files { + let fullPath = fixture.path.string + "/" + file + let dir = (fullPath as NSString).deletingLastPathComponent + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + try "// Code".write(toFile: fullPath, atomically: true, encoding: .utf8) + } + + try writeSubtreeConfig( + name: "mylib", + remote: "https://example.com/mylib.git", + prefix: subtreePrefix, + commit: "abc123", + to: fixture.path.string + "/subtree.yaml" + ) + + try await fixture.runGit(["add", "."]) + try await fixture.runGit(["commit", "-m", "Add subtree"]) + + // Extract WITHOUT --base (default = base:root, preserve full paths) + let result = try await harness.run( + arguments: ["extract", "--name", "mylib", "--from", "src/**/*.c", "--to", "Sources/MyLib/"], + workingDirectory: fixture.path + ) + + #expect(result.exitCode == 0, "Extract should succeed. stderr: \(result.stderr)") + + // Full paths should be preserved (src/ included) + let expectedFiles = [ + "Sources/MyLib/src/core/engine.c", + "Sources/MyLib/src/utils/helper.c", + ] + + for file in expectedFiles { + let fullPath = fixture.path.string + "/" + file + #expect(FileManager.default.fileExists(atPath: fullPath), + "File \(file) should exist (default preserves full paths). stderr: \(result.stderr)") + } + + // Stripped paths should NOT exist (we didn't use --base match) + let unexpectedFile = "Sources/MyLib/core/engine.c" + #expect(!FileManager.default.fileExists(atPath: fixture.path.string + "/" + unexpectedFile), + "Stripped path should NOT exist without --base match") + } } diff --git a/Tests/SubtreeLibTests/Commands/ExtractCommandTests.swift b/Tests/SubtreeLibTests/Commands/ExtractCommandTests.swift index edd400c..899e1b9 100644 --- a/Tests/SubtreeLibTests/Commands/ExtractCommandTests.swift +++ b/Tests/SubtreeLibTests/Commands/ExtractCommandTests.swift @@ -452,21 +452,117 @@ struct ExtractCommandTests { // Zero-match error should mention pattern let pattern = "*.xyz" let prefix = "vendor/lib" - + let errorMessage = "No files matched pattern '\(pattern)' in subtree" #expect(errorMessage.contains(pattern), "Should include pattern in error") - + // Suggestions should be actionable let suggestions = [ "Check pattern syntax", "Verify files exist in \(prefix)/", "Try a broader pattern" ] - + for suggestion in suggestions { #expect(suggestion.count > 0, "Suggestion should not be empty") #expect(suggestion.contains("Check") || suggestion.contains("Verify") || suggestion.contains("Try"), "Should use actionable verbs") } } + + // MARK: - Base Mode Prefix Stripping Tests + + @Test("base:match strips literal prefix from destination paths") + func testDirectoryStructurePreservationBaseMatch() { + struct TestCase { + let pattern: String + let matchedPath: String + let expectedDest: String + } + + let testCases = [ + // Simple prefix stripping + TestCase(pattern: "src/**/*.c", matchedPath: "src/core/file.c", expectedDest: "core/file.c"), + TestCase(pattern: "docs/api/**/*.md", matchedPath: "docs/api/guide/README.md", expectedDest: "guide/README.md"), + // No prefix to strip (pattern starts with glob) + TestCase(pattern: "**/*.txt", matchedPath: "any/path/file.txt", expectedDest: "any/path/file.txt"), + // Multi-level prefix + TestCase(pattern: "src/crc32c/include/**/*.h", matchedPath: "src/crc32c/include/crc32c/crc32c.h", expectedDest: "crc32c/crc32c.h"), + ] + + for testCase in testCases { + let prefix = extractLiteralPrefix(from: testCase.pattern) + let result = destinationRelativePath( + for: testCase.matchedPath, + originalPatternPrefix: prefix, + baseMode: "match" + ) + + #expect(result == testCase.expectedDest, + "base:match pattern '\(testCase.pattern)' on '\(testCase.matchedPath)' should produce '\(testCase.expectedDest)', got '\(result)'") + } + } + + @Test("Pre-expansion prefix computation stops at brace") + func testPreExpansionPrefixWithBraces() { + // Brace should stop prefix extraction at the component containing it + let pattern1 = "src/crc32c/{include,src}/**/*.h" + let prefix1 = extractLiteralPrefix(from: pattern1) + #expect(prefix1 == "src/crc32c/", "Prefix should stop before brace component. Got: '\(prefix1)'") + + let pattern2 = "{include,src}/**/*.h" + let prefix2 = extractLiteralPrefix(from: pattern2) + #expect(prefix2 == "", "Brace at start should produce empty prefix. Got: '\(prefix2)'") + + let pattern3 = "a/b/c/{x,y}/**" + let prefix3 = extractLiteralPrefix(from: pattern3) + #expect(prefix3 == "a/b/c/", "Multi-level prefix stops at brace. Got: '\(prefix3)'") + } + + @Test("base:root preserves full path (no stripping)") + func testBaseRootPreservesFullPath() { + let prefix = extractLiteralPrefix(from: "src/**/*.c") + + let resultRoot = destinationRelativePath( + for: "src/core/file.c", + originalPatternPrefix: prefix, + baseMode: "root" + ) + #expect(resultRoot == "src/core/file.c", "base:root should preserve full path") + + let resultNil = destinationRelativePath( + for: "src/core/file.c", + originalPatternPrefix: prefix, + baseMode: nil + ) + #expect(resultNil == "src/core/file.c", "nil base should preserve full path") + } + + @Test("Duplicate detection considers base field") + func testDuplicateDetectionConsidersBase() { + let withMatch = ExtractionMapping(from: "src/**/*.c", to: "vendor/", base: "match") + let withRoot = ExtractionMapping(from: "src/**/*.c", to: "vendor/", base: "root") + let withNil = ExtractionMapping(from: "src/**/*.c", to: "vendor/") + + #expect(withMatch != withRoot, "Different base should not match for dedup") + #expect(withMatch != withNil, "base:match should not match nil for dedup") + #expect(withRoot != withNil, "base:root should not match nil for dedup") + + // Same everything including base + let withMatch2 = ExtractionMapping(from: "src/**/*.c", to: "vendor/", base: "match") + #expect(withMatch == withMatch2, "Same base should be detected as duplicate") + } + + // Helper: mirrors ExtractCommand.destinationRelativePath + private func destinationRelativePath( + for relativePath: String, + originalPatternPrefix: String, + baseMode: String? + ) -> String { + guard baseMode == "match" else { return relativePath } + if !originalPatternPrefix.isEmpty && relativePath.hasPrefix(originalPatternPrefix) { + return String(relativePath.dropFirst(originalPatternPrefix.count)) + } + return relativePath + } } diff --git a/Tests/SubtreeLibTests/ConfigurationTests/ExtractionMappingTests.swift b/Tests/SubtreeLibTests/ConfigurationTests/ExtractionMappingTests.swift index 8832d9e..aa37cec 100644 --- a/Tests/SubtreeLibTests/ConfigurationTests/ExtractionMappingTests.swift +++ b/Tests/SubtreeLibTests/ConfigurationTests/ExtractionMappingTests.swift @@ -418,9 +418,121 @@ struct ExtractionMappingTests { let patterns = ["include/**/*.h", "src/**/*.c"] let destinations = ["Lib/", "Vendor/"] let mapping = ExtractionMapping(fromPatterns: patterns, toDestinations: destinations, exclude: ["**/internal/**"]) - + #expect(mapping.from == patterns) #expect(mapping.to == destinations) #expect(mapping.exclude == ["**/internal/**"]) } + + // MARK: - Base Field (base:match prefix stripping) + + @Test("Decode base: match from YAML") + func testDecodeBaseMatch() throws { + let yaml = """ + from: "src/crc32c/{include,src}/**/*.{cc,h}" + to: "Sources/crc32c/" + base: match + """ + + let decoder = YAMLDecoder() + let mapping = try decoder.decode(ExtractionMapping.self, from: yaml) + + #expect(mapping.base == "match") + #expect(mapping.from == ["src/crc32c/{include,src}/**/*.{cc,h}"]) + #expect(mapping.to == ["Sources/crc32c/"]) + } + + @Test("Decode base: root from YAML") + func testDecodeBaseRoot() throws { + let yaml = """ + from: "ssl/**/*.c" + to: "Sources/libssl/" + base: root + """ + + let decoder = YAMLDecoder() + let mapping = try decoder.decode(ExtractionMapping.self, from: yaml) + + #expect(mapping.base == "root") + } + + @Test("Decode without base field (nil, backward compatible)") + func testDecodeWithoutBase() throws { + let yaml = """ + from: "include/**/*.h" + to: "vendor/" + """ + + let decoder = YAMLDecoder() + let mapping = try decoder.decode(ExtractionMapping.self, from: yaml) + + #expect(mapping.base == nil, "Omitted base should decode as nil") + } + + @Test("Reject invalid base value") + func testRejectInvalidBaseValue() throws { + let yaml = """ + from: "src/**/*.c" + to: "vendor/" + base: flatten + """ + + let decoder = YAMLDecoder() + + #expect(throws: Error.self) { + _ = try decoder.decode(ExtractionMapping.self, from: yaml) + } + } + + @Test("Encode with base: match includes base in YAML") + func testEncodeBaseMatch() throws { + let mapping = ExtractionMapping(from: "src/**/*.c", to: "vendor/", base: "match") + + let encoder = YAMLEncoder() + let yaml = try encoder.encode(mapping) + + #expect(yaml.contains("base: match"), "base: match should appear in YAML. Got: \(yaml)") + } + + @Test("Encode without base omits base from YAML") + func testEncodeWithoutBase() throws { + let mapping = ExtractionMapping(from: "src/**/*.c", to: "vendor/") + + let encoder = YAMLEncoder() + let yaml = try encoder.encode(mapping) + + #expect(!yaml.contains("base"), "Nil base should not appear in YAML. Got: \(yaml)") + } + + @Test("Equatable considers base field") + func testEquatableConsidersBase() { + let withMatch = ExtractionMapping(from: "src/**/*.c", to: "vendor/", base: "match") + let withRoot = ExtractionMapping(from: "src/**/*.c", to: "vendor/", base: "root") + let withNil = ExtractionMapping(from: "src/**/*.c", to: "vendor/") + let withMatch2 = ExtractionMapping(from: "src/**/*.c", to: "vendor/", base: "match") + + #expect(withMatch == withMatch2, "Same base should be equal") + #expect(withMatch != withRoot, "Different base should not be equal") + #expect(withMatch != withNil, "base:match should not equal nil base") + #expect(withRoot != withNil, "base:root should not equal nil base") + } + + @Test("Codable round-trip preserves base field") + func testCodableRoundTripWithBase() throws { + let original = ExtractionMapping( + fromPatterns: ["include/**/*.h", "src/**/*.c"], + to: "vendor/", + exclude: ["**/test/**"], + base: "match" + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(original) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(ExtractionMapping.self, from: data) + + #expect(decoded == original) + #expect(decoded.base == "match") + } } diff --git a/specs/009-multi-pattern-extraction/spec.md b/specs/009-multi-pattern-extraction/spec.md index 7c53483..05fdc07 100644 --- a/specs/009-multi-pattern-extraction/spec.md +++ b/specs/009-multi-pattern-extraction/spec.md @@ -147,4 +147,4 @@ As a developer, I want to be warned when a pattern matches no files so that I ca - Q: When using `--persist` with multiple patterns, what should happen if a mapping to the same destination already exists? → A: Error — reject with "mapping to this destination already exists" (consistent with current extract behavior). -- Q: Should pattern prefix be stripped from extracted paths (e.g., `src/**/*.c` → `dest/foo.c`) or preserved (→ `dest/src/foo.c`)? → A: **Preserve full paths** (industry standard, matches rsync/cp behavior). Pattern prefix stripping was the original 008 behavior but is non-standard. A future `--flatten` flag (see backlog) will provide prefix stripping for users who prefer it. +- Q: Should pattern prefix be stripped from extracted paths (e.g., `src/**/*.c` → `dest/foo.c`) or preserved (→ `dest/src/foo.c`)? → A: **Preserve full paths by default** (`base: root`). Users can opt into prefix stripping with `base: match` (or `--base match`), which strips the literal prefix before the first glob/brace character from destination paths.