From edd2b37e17b31e62a55b1f08189997b0def5030d Mon Sep 17 00:00:00 2001 From: Leo Li Date: Mon, 28 Sep 2026 09:13:01 -0700 Subject: [PATCH 1/4] Add a .worktreeinclude reader for seeding new worktrees A new agent worktree arrives without the files git does not track. Today AgentMoveScripts carries the working tree with `git add -A`, which respects .gitignore, so `.env`, local config and installed dependency directories are exactly what a fresh worktree lacks and exactly what the session then cannot run without. This adds the portable half: a `.worktreeinclude` at the repository root naming what to carry, a planner that expands it against the tree, and an applier that copies or symlinks the result. The CLI surface comes separately, since the file format is a product decision and is going to cmux#13742 first. Two departures from .gitignore syntax, both deliberate and documented in the sources. Every pattern is a path from the repository root, because gitignore's basename-anywhere matching would force a walk through the very directories (node_modules) that make a walk expensive; `**` is how a pattern opts into one. And a matched directory is taken whole, because a copy could filter its contents but a symlink cannot, so one rule serves both actions. The second departure silently disarms a `!` aimed inside such a directory, so the planner reports those as ineffectiveNegations rather than dropping them on the floor. A symlink is a leaf even when it points at a directory, the way git reads one, so the walk cannot loop through a link to its own ancestor. A pattern whose target resolves outside the repository is refused rather than followed. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../WorktreeSeed/WorktreeSeedApplier.swift | 131 ++++++++ .../WorktreeSeed/WorktreeSeedFile.swift | 54 ++++ .../WorktreeSeed/WorktreeSeedPattern.swift | 207 ++++++++++++ .../WorktreeSeed/WorktreeSeedPlan.swift | 111 +++++++ .../WorktreeSeed/WorktreeSeedPlanner.swift | 291 +++++++++++++++++ .../WorktreeSeed/WorktreeSeedRepository.swift | 86 +++++ .../WorktreeSeedFileTests.swift | 65 ++++ .../WorktreeSeedPatternTests.swift | 131 ++++++++ .../WorktreeSeedPlannerTests.swift | 263 +++++++++++++++ .../WorktreeSeedRepositoryTests.swift | 305 ++++++++++++++++++ 10 files changed, 1644 insertions(+) create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedApplier.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedFile.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPattern.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPlan.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPlanner.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedRepository.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedFileTests.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPatternTests.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPlannerTests.swift create mode 100644 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedRepositoryTests.swift diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedApplier.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedApplier.swift new file mode 100644 index 000000000000..e918939c5c14 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedApplier.swift @@ -0,0 +1,131 @@ +import Foundation + +/// A path a plan named that seeding could not deliver. +public struct WorktreeSeedFailure: Sendable, Equatable { + /// Path relative to the repository root. + public var relativePath: String + /// What went wrong, as the filesystem reported it. Not user-facing copy. + public var reason: String + + /// Creates a failure. + public init(relativePath: String, reason: String) { + self.relativePath = relativePath + self.reason = reason + } +} + +/// What applying a plan did. +public struct WorktreeSeedReport: Sendable, Equatable { + /// Paths copied into the worktree. + public var copied: [String] + /// Paths linked to the original. + public var linked: [String] + /// Paths the worktree already had, left as git checked them out. + public var skipped: [String] + /// Paths that could not be delivered. + public var failed: [WorktreeSeedFailure] + + /// Creates a report. + public init( + copied: [String] = [], + linked: [String] = [], + skipped: [String] = [], + failed: [WorktreeSeedFailure] = [] + ) { + self.copied = copied + self.linked = linked + self.skipped = skipped + self.failed = failed + } + + /// Whether every entry in the plan was delivered. + public var isComplete: Bool { failed.isEmpty } + /// How many paths the worktree gained. + public var deliveredCount: Int { copied.count + linked.count } +} + +/// Copies and links the paths a plan names into a new worktree. +/// +/// One entry failing does not stop the others: a worktree with four of its five +/// ignored files is more useful than one with none, and the report names what is +/// missing. Nothing is ever overwritten, because the file git just checked out is +/// the one the worktree should keep. +public struct WorktreeSeedApplier: Sendable { + /// Creates an applier. + public init() {} + + /// Delivers `plan` from `source` into `destination`. + /// + /// A `.link` entry becomes an absolute symlink to the source path. Absolute is + /// the right choice for what gets linked: `node_modules` and friends are + /// wanted because the original is installed where it is, and the worktree may + /// later move without the original moving with it. + public func apply(_ plan: WorktreeSeedPlan, from source: URL, to destination: URL) -> WorktreeSeedReport { + var report = WorktreeSeedReport() + let fileManager = FileManager.default + let destinationRoot = destination.standardizedFileURL.path + + for entry in plan.entries { + let from = source.appendingPathComponent(entry.relativePath) + let to = destination.appendingPathComponent(entry.relativePath) + + guard to.standardizedFileURL.path == destinationRoot + "/" + entry.relativePath else { + report.failed.append( + WorktreeSeedFailure( + relativePath: entry.relativePath, + reason: "resolves outside the worktree" + ) + ) + continue + } + guard fileManager.fileExists(atPath: from.path) else { + report.failed.append( + WorktreeSeedFailure(relativePath: entry.relativePath, reason: "no longer in the repository") + ) + continue + } + if fileExistsWithoutFollowingLinks(to) { + report.skipped.append(entry.relativePath) + continue + } + + let parent = to.deletingLastPathComponent() + do { + try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) + } catch { + report.failed.append( + WorktreeSeedFailure(relativePath: entry.relativePath, reason: String(describing: error)) + ) + continue + } + + do { + switch entry.action { + case .copy: + try fileManager.copyItem(at: from, to: to) + report.copied.append(entry.relativePath) + case .link: + try fileManager.createSymbolicLink( + atPath: to.path, + withDestinationPath: from.standardizedFileURL.path + ) + report.linked.append(entry.relativePath) + } + } catch { + report.failed.append( + WorktreeSeedFailure(relativePath: entry.relativePath, reason: String(describing: error)) + ) + } + } + return report + } + + /// Whether the path is taken, counting a dangling symlink as taken. + /// + /// `fileExists` follows links, so a symlink whose target is missing reads as + /// absent, and the write then fails on a path that is occupied after all. + /// `attributesOfItem` does not follow links. + private func fileExistsWithoutFollowingLinks(_ url: URL) -> Bool { + (try? FileManager.default.attributesOfItem(atPath: url.path)) != nil + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedFile.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedFile.swift new file mode 100644 index 000000000000..78010adff9bf --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedFile.swift @@ -0,0 +1,54 @@ +import Foundation + +/// A parsed `.worktreeinclude`. +/// +/// Parsing never fails as a whole. A file with three good lines and one bad one +/// yields three patterns and one problem, so seeding can proceed on what was +/// understood while still telling the author what to fix. Whether a problem is +/// fatal is the caller's decision, not the parser's. +public struct WorktreeSeedFile: Sendable, Equatable { + /// The name the repository root uses for this file. + public static let fileName = ".worktreeinclude" + + /// Patterns in file order. Order decides negation: the last match wins. + public var patterns: [WorktreeSeedPattern] + /// Lines that could not be read. + public var problems: [WorktreeSeedProblem] + + /// Creates a file. + public init(patterns: [WorktreeSeedPattern] = [], problems: [WorktreeSeedProblem] = []) { + self.patterns = patterns + self.problems = problems + } + + /// Whether the file selects nothing, which is worth saying out loud: an empty + /// or all-comment file and a missing file lead to the same silent no-op. + public var isEmpty: Bool { patterns.isEmpty } + + /// Reads the file's text. + /// + /// Line endings may be LF, CRLF or a lone CR. Splitting has to name all + /// three: Swift reads `\r\n` as one `Character`, so splitting on `\n` alone + /// leaves the CRLF intact and the whole file becomes one line. + public static func parse(_ text: String) -> WorktreeSeedFile { + var patterns: [WorktreeSeedPattern] = [] + var problems: [WorktreeSeedProblem] = [] + var number = 0 + for line in text.split(omittingEmptySubsequences: false, whereSeparator: isLineBreak) { + number += 1 + switch WorktreeSeedPattern.parse(line: String(line), number: number) { + case .success(let pattern?): + patterns.append(pattern) + case .success(nil): + continue + case .failure(let problem): + problems.append(problem) + } + } + return WorktreeSeedFile(patterns: patterns, problems: problems) + } + + private static func isLineBreak(_ character: Character) -> Bool { + character == "\n" || character == "\r\n" || character == "\r" + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPattern.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPattern.swift new file mode 100644 index 000000000000..6580debd8d39 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPattern.swift @@ -0,0 +1,207 @@ +import Foundation + +/// What a new worktree should do with a path the repository ignores. +public enum WorktreeSeedAction: String, Sendable, Equatable, CaseIterable { + /// Copy the file or directory into the new worktree. + case copy + /// Point a symlink at the original instead of copying it. + /// + /// This is what `node_modules`, `.venv` and build caches want: copying them + /// costs minutes and gigabytes, and the two trees are interchangeable. + case link +} + +/// One line of a `.worktreeinclude` file. +/// +/// The file names the paths a fresh `git worktree` needs but cannot get from +/// git, because they are ignored: `.env`, a local settings file, an installed +/// dependency directory. Without them the first command an agent runs in its +/// new worktree fails on missing configuration. +/// +/// The syntax is deliberately close to `.gitignore` so it is learnable, with one +/// difference that matters: **every pattern is a path from the repository root**. +/// `.gitignore`'s `foo` matches a `foo` at any depth, which here would mean +/// walking the whole tree, including the very directories (`node_modules`) that +/// make a walk expensive. So `.env` means the repository's own `.env` and +/// nothing else, and a pattern opts into a walk by writing `**`. +public struct WorktreeSeedPattern: Sendable, Equatable { + /// The pattern with its prefixes and suffixes removed, relative to the repository root. + public var glob: String + /// What to do with the paths this pattern selects. Negated patterns ignore it. + public var action: WorktreeSeedAction + /// Whether the pattern removes paths an earlier pattern selected (`!` prefix). + public var isNegated: Bool + /// Whether the pattern only matches directories (trailing `/`). + public var directoryOnly: Bool + /// 1-based line number in the file, so a problem can be reported where it is. + public var line: Int + + /// Creates a pattern. + public init( + glob: String, + action: WorktreeSeedAction = .copy, + isNegated: Bool = false, + directoryOnly: Bool = false, + line: Int = 0 + ) { + self.glob = glob + self.action = action + self.isNegated = isNegated + self.directoryOnly = directoryOnly + self.line = line + } + + /// The pattern's segments, for matching one directory level at a time. + public var segments: [String] { + glob.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + } +} + +/// A line a `.worktreeinclude` file could not be read as a pattern. +/// +/// These are reported rather than thrown one at a time: a configuration file +/// should tell its author about every mistake in one pass. +public struct WorktreeSeedProblem: Error, Sendable, Equatable, CustomStringConvertible { + /// Why the line was rejected. + public enum Reason: Sendable, Equatable { + /// A leading `/`. Patterns are already repository-relative, so it means nothing here. + case leadingSlash + /// A leading `~`. Seeding never reaches outside the repository. + case homeRelative + /// A `..` segment. The same reason. + case escapesRepository + /// `!link …`. A negation removes paths; it has no action to take on them. + case negatedActionKeyword + /// Only separators or escapes were left after the prefixes were removed. + case emptyPattern + } + + /// The line, as written, with surrounding whitespace removed. + public var text: String + /// 1-based line number. + public var line: Int + /// Why it was rejected. + public var reason: Reason + + /// Creates a problem. + public init(text: String, line: Int, reason: Reason) { + self.text = text + self.line = line + self.reason = reason + } + + /// A message naming the line and what to write instead. + public var description: String { + let advice: String + switch reason { + case .leadingSlash: + advice = "patterns are relative to the repository root already, so drop the leading slash" + case .homeRelative: + advice = "a pattern cannot start at the home directory; seeding stays inside the repository" + case .escapesRepository: + advice = "a pattern cannot contain '..'; seeding stays inside the repository" + case .negatedActionKeyword: + advice = "'!' removes paths, so it takes no action keyword; write '!\(strippedKeyword())'" + case .emptyPattern: + advice = "nothing is left of this line to match with" + } + return "line \(line): \(text.isEmpty ? "(blank)" : text): \(advice)" + } + + private func strippedKeyword() -> String { + var rest = Substring(text).dropFirst() + for action in WorktreeSeedAction.allCases where rest.hasPrefix(action.rawValue) { + let after = rest.dropFirst(action.rawValue.count) + if after.first == " " || after.first == "\t" { + rest = after.drop(while: { $0 == " " || $0 == "\t" }) + break + } + } + return String(rest) + } +} + +extension WorktreeSeedPattern { + /// Reads one line. + /// + /// Returns `nil` for a blank line and for a comment (`#`). Everything else is + /// either a pattern or a problem. + /// + /// Order of the prefixes: `!` first, then an action keyword (`link `), then + /// a single `\` escape for a pattern that really does start with `!`, `#` or + /// an action keyword. An action keyword is only a keyword when a pattern + /// follows it on the same line, so a bare `link` is a path named `link`. + public static func parse(line text: String, number: Int) -> Result { + let trimmed = trimmingUnescapedTrailingWhitespace(text) + guard !trimmed.isEmpty else { return .success(nil) } + if trimmed.hasPrefix("#") { return .success(nil) } + + func problem(_ reason: WorktreeSeedProblem.Reason) -> Result { + .failure(WorktreeSeedProblem(text: trimmed, line: number, reason: reason)) + } + + var rest = Substring(trimmed) + var isNegated = false + if rest.hasPrefix("!") { + isNegated = true + rest = rest.dropFirst().drop(while: { $0 == " " || $0 == "\t" }) + } + + var action = WorktreeSeedAction.copy + var sawKeyword = false + for candidate in WorktreeSeedAction.allCases where rest.hasPrefix(candidate.rawValue) { + let after = rest.dropFirst(candidate.rawValue.count) + guard after.first == " " || after.first == "\t" else { continue } + action = candidate + sawKeyword = true + rest = after.drop(while: { $0 == " " || $0 == "\t" }) + break + } + if sawKeyword, isNegated { return problem(.negatedActionKeyword) } + + if rest.hasPrefix("\\") { rest = rest.dropFirst() } + + if rest.hasPrefix("/") { return problem(.leadingSlash) } + if rest.hasPrefix("~") { return problem(.homeRelative) } + + var directoryOnly = false + while rest.hasSuffix("/") { + directoryOnly = true + rest = rest.dropLast() + } + + let glob = String(rest) + if glob.isEmpty { return problem(.emptyPattern) } + let segments = glob.split(separator: "/", omittingEmptySubsequences: true) + if segments.isEmpty { return problem(.emptyPattern) } + if segments.contains(where: { $0 == ".." }) { return problem(.escapesRepository) } + + return .success( + WorktreeSeedPattern( + glob: segments.joined(separator: "/"), + action: action, + isNegated: isNegated, + directoryOnly: directoryOnly, + line: number + ) + ) + } + + /// Removes trailing spaces and tabs, keeping one that a `\` escaped. + /// + /// A filename really can end in a space, and `.gitignore` spells that + /// `"foo\ "`. Leading whitespace is removed unconditionally: a pattern + /// indented for readability should still match. + private static func trimmingUnescapedTrailingWhitespace(_ text: String) -> String { + var characters = Array(text.drop(while: { $0 == " " || $0 == "\t" })) + while let last = characters.last, last == " " || last == "\t" { + let escapes = characters.dropLast().reversed().prefix(while: { $0 == "\\" }).count + if escapes % 2 == 1 { + characters.remove(at: characters.count - 2) + break + } + characters.removeLast() + } + return String(characters) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPlan.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPlan.swift new file mode 100644 index 000000000000..da8608492dfa --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPlan.swift @@ -0,0 +1,111 @@ +import Foundation + +/// One path a new worktree should receive. +public struct WorktreeSeedEntry: Sendable, Equatable { + /// Path relative to the repository root, `/`-separated, never empty. + public var relativePath: String + /// Copy or link. + public var action: WorktreeSeedAction + /// Whether the path is a directory, which decides whether a copy is recursive. + public var isDirectory: Bool + /// The `.worktreeinclude` line that decided this entry. + public var line: Int + + /// Creates an entry. + public init(relativePath: String, action: WorktreeSeedAction, isDirectory: Bool, line: Int) { + self.relativePath = relativePath + self.action = action + self.isDirectory = isDirectory + self.line = line + } +} + +/// A path a pattern selected and something else then ruled out. +public struct WorktreeSeedDecision: Sendable, Equatable { + /// Path relative to the repository root. + public var relativePath: String + /// The line that last matched the path. + public var line: Int + + /// Creates a decision. + public init(relativePath: String, line: Int) { + self.relativePath = relativePath + self.line = line + } +} + +/// A selected path that an already selected ancestor covers. +public struct WorktreeSeedShadow: Sendable, Equatable { + /// The redundant path. + public var relativePath: String + /// The ancestor that already carries it. + public var coveredBy: String + /// What the ancestor does, which is why the nested entry is dropped rather than kept. + /// + /// Under a copied ancestor the nested entry is merely redundant. Under a + /// linked one, honoring it would write through the symlink and into the + /// original repository, so it must not be honored. + public var coveringAction: WorktreeSeedAction + + /// Creates a shadow. + public init(relativePath: String, coveredBy: String, coveringAction: WorktreeSeedAction) { + self.relativePath = relativePath + self.coveredBy = coveredBy + self.coveringAction = coveringAction + } +} + +/// What a `.worktreeinclude` asks for, resolved against a repository. +/// +/// The plan is data. Nothing here touches the destination: a caller can print it, +/// diff it, or apply it, and the tests can read it without a filesystem. +public struct WorktreeSeedPlan: Sendable, Equatable { + /// The paths to seed, ordered by path. + public var entries: [WorktreeSeedEntry] + /// Paths a `!` pattern removed. + public var excluded: [WorktreeSeedDecision] + /// Paths that are symlinks out of the repository, which seeding will not follow. + public var refused: [WorktreeSeedDecision] + /// Paths dropped because a selected ancestor already covers them. + public var shadowed: [WorktreeSeedShadow] + /// Paths the destination already has, left alone rather than overwritten. + public var alreadyPresent: [WorktreeSeedDecision] + /// Patterns that matched nothing, so their author can delete or fix them. + public var unmatched: [WorktreeSeedPattern] + /// `!` patterns whose only matches sit inside a wholesale-selected directory. + /// + /// Those cannot be honored, and a silent no-op would read as an exclusion + /// that worked. See `WorktreeSeedPlanner` on why a directory is selected whole. + public var ineffectiveNegations: [WorktreeSeedShadow] + /// Whether a `**` pattern hit the directory budget, which means the plan is incomplete. + public var reachedWalkLimit: Bool + + /// Creates a plan. + public init( + entries: [WorktreeSeedEntry] = [], + excluded: [WorktreeSeedDecision] = [], + refused: [WorktreeSeedDecision] = [], + shadowed: [WorktreeSeedShadow] = [], + alreadyPresent: [WorktreeSeedDecision] = [], + unmatched: [WorktreeSeedPattern] = [], + ineffectiveNegations: [WorktreeSeedShadow] = [], + reachedWalkLimit: Bool = false + ) { + self.entries = entries + self.excluded = excluded + self.refused = refused + self.shadowed = shadowed + self.alreadyPresent = alreadyPresent + self.unmatched = unmatched + self.ineffectiveNegations = ineffectiveNegations + self.reachedWalkLimit = reachedWalkLimit + } + + /// Whether applying the plan would do nothing. + public var isEmpty: Bool { entries.isEmpty } + + /// The paths to copy, in plan order. + public var copies: [WorktreeSeedEntry] { entries.filter { $0.action == .copy } } + /// The paths to link, in plan order. + public var links: [WorktreeSeedEntry] { entries.filter { $0.action == .link } } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPlanner.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPlanner.swift new file mode 100644 index 000000000000..40dd079d22ef --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPlanner.swift @@ -0,0 +1,291 @@ +import Foundation + +/// One child of a directory, as the repository reports it. +public struct WorktreeSeedListedEntry: Sendable, Equatable { + /// The child's name, with no separators. + public var name: String + /// Whether it is a directory, following a symlink the way a copy would. + public var isDirectory: Bool + /// Whether it is a symlink whose target resolves outside the repository root. + /// + /// Seeding will not follow one: the point of a worktree is a tree of its own, + /// and a link out of the repository silently shares state with whatever it + /// points at. + public var escapesRepository: Bool + + /// Creates an entry. + public init(name: String, isDirectory: Bool, escapesRepository: Bool = false) { + self.name = name + self.isDirectory = isDirectory + self.escapesRepository = escapesRepository + } +} + +/// Resolves a `.worktreeinclude` against a repository into a plan. +/// +/// The repository is reached only through `listing`, which returns the children of +/// a repository-relative directory (`""` for the root). That keeps the planner +/// testable without a filesystem, and it keeps the walk honest: a pattern is +/// expanded one directory level at a time, so `.env` lists the root and stops. +/// Only `**` descends, and `maximumVisitedDirectories` bounds it, because the +/// directories a seeding pattern is aimed at (`node_modules`, `.venv`) are the +/// ones that would make an unbounded walk take minutes. +/// +/// A matched directory is selected whole. The planner does not plan the inside of +/// it, because the two actions differ there: a copy could filter its contents, a +/// link cannot filter anything. Selecting whole directories keeps one rule for +/// both, and `WorktreeSeedPlan.ineffectiveNegations` reports the patterns that +/// rule silently disarms. +public struct WorktreeSeedPlanner: Sendable { + /// Returns the children of a repository-relative directory. `""` is the root. + public typealias Listing = @Sendable (String) -> [WorktreeSeedListedEntry] + + /// How many directories a single expansion may list before the plan gives up. + public var maximumVisitedDirectories: Int + + private let listing: Listing + + /// Creates a planner over a repository. + public init(maximumVisitedDirectories: Int = 20_000, listing: @escaping Listing) { + self.maximumVisitedDirectories = maximumVisitedDirectories + self.listing = listing + } + + /// Resolves `file` against the repository. + /// + /// `alreadyPresent` holds repository-relative paths the destination worktree + /// already has. They are reported rather than overwritten: the file git just + /// checked out is the one the worktree should keep. + public func plan(for file: WorktreeSeedFile, alreadyPresent: Set = []) -> WorktreeSeedPlan { + var plan = WorktreeSeedPlan() + var matchesByLine: [Int: [String: WorktreeSeedListedEntry]] = [:] + var visited = 0 + + for pattern in file.patterns { + let expansion = expand(pattern, visited: &visited) + if expansion.reachedLimit { plan.reachedWalkLimit = true } + matchesByLine[pattern.line] = expansion.matches + if expansion.matches.isEmpty { plan.unmatched.append(pattern) } + } + + var candidates: [String: WorktreeSeedListedEntry] = [:] + for pattern in file.patterns where !pattern.isNegated { + for (path, entry) in matchesByLine[pattern.line] ?? [:] { + candidates[path] = entry + } + } + + var selected: [String: WorktreeSeedEntry] = [:] + for path in candidates.keys.sorted() { + guard let entry = candidates[path] else { continue } + guard let deciding = lastPattern(in: file.patterns, matching: path, isDirectory: entry.isDirectory) else { continue } + if deciding.isNegated { + plan.excluded.append(WorktreeSeedDecision(relativePath: path, line: deciding.line)) + continue + } + if entry.escapesRepository { + plan.refused.append(WorktreeSeedDecision(relativePath: path, line: deciding.line)) + continue + } + if alreadyPresent.contains(path) { + plan.alreadyPresent.append(WorktreeSeedDecision(relativePath: path, line: deciding.line)) + continue + } + selected[path] = WorktreeSeedEntry( + relativePath: path, + action: deciding.action, + isDirectory: entry.isDirectory, + line: deciding.line + ) + } + + for path in selected.keys.sorted() { + guard let entry = selected[path] else { continue } + if let ancestor = nearestSelectedAncestor(of: path, in: selected) { + plan.shadowed.append( + WorktreeSeedShadow( + relativePath: path, + coveredBy: ancestor.relativePath, + coveringAction: ancestor.action + ) + ) + continue + } + plan.entries.append(entry) + } + + for pattern in file.patterns where pattern.isNegated { + for path in (matchesByLine[pattern.line] ?? [:]).keys.sorted() { + guard selected[path] == nil, candidates[path] == nil else { continue } + guard let ancestor = nearestSelectedAncestor(of: path, in: selected) else { continue } + plan.ineffectiveNegations.append( + WorktreeSeedShadow( + relativePath: path, + coveredBy: ancestor.relativePath, + coveringAction: ancestor.action + ) + ) + } + } + + plan.entries.sort { $0.relativePath < $1.relativePath } + plan.excluded.sort { $0.relativePath < $1.relativePath } + plan.refused.sort { $0.relativePath < $1.relativePath } + plan.alreadyPresent.sort { $0.relativePath < $1.relativePath } + plan.shadowed.sort { $0.relativePath < $1.relativePath } + plan.ineffectiveNegations.sort { $0.relativePath < $1.relativePath } + return plan + } + + /// The selected directory closest to `path` that contains it, if any. + private func nearestSelectedAncestor( + of path: String, + in selected: [String: WorktreeSeedEntry] + ) -> WorktreeSeedEntry? { + var segments = path.split(separator: "/").map(String.init) + segments.removeLast() + while !segments.isEmpty { + let candidate = segments.joined(separator: "/") + if let entry = selected[candidate], entry.isDirectory { return entry } + segments.removeLast() + } + return nil + } + + /// The last pattern in file order that matches the path, which is the one that decides it. + private func lastPattern( + in patterns: [WorktreeSeedPattern], + matching path: String, + isDirectory: Bool + ) -> WorktreeSeedPattern? { + patterns.last { WorktreeSeedPlanner.pattern($0, matches: path, isDirectory: isDirectory) } + } + + private struct Expansion { + var matches: [String: WorktreeSeedListedEntry] = [:] + var reachedLimit = false + } + + /// Walks the repository for the paths a single pattern selects. + private func expand(_ pattern: WorktreeSeedPattern, visited: inout Int) -> Expansion { + var expansion = Expansion() + let segments = pattern.segments + guard !segments.isEmpty else { return expansion } + + var frontier: [(index: Int, directory: String)] = [(0, "")] + var seen: Set = [] + + while let state = frontier.popLast() { + let key = "\(state.index)\u{0}\(state.directory)" + guard seen.insert(key).inserted else { continue } + if visited >= maximumVisitedDirectories { + expansion.reachedLimit = true + return expansion + } + visited += 1 + let children = listing(state.directory) + let segment = segments[state.index] + let isLast = state.index == segments.count - 1 + + if segment == "**" { + if !isLast { frontier.append((state.index + 1, state.directory)) } + for child in children { + let path = state.directory.isEmpty ? child.name : state.directory + "/" + child.name + if isLast, !pattern.directoryOnly || child.isDirectory { + expansion.matches[path] = child + } + if child.isDirectory { frontier.append((state.index, path)) } + } + continue + } + + for child in children where WorktreeSeedPlanner.glob(segment, matches: child.name) { + let path = state.directory.isEmpty ? child.name : state.directory + "/" + child.name + if isLast { + if pattern.directoryOnly, !child.isDirectory { continue } + expansion.matches[path] = child + } else if child.isDirectory { + frontier.append((state.index + 1, path)) + } + } + } + return expansion + } +} + +extension WorktreeSeedPlanner { + /// Whether a pattern matches a repository-relative path. + /// + /// Exposed because negation asks the question about paths the expansion did + /// not produce. + public static func pattern( + _ pattern: WorktreeSeedPattern, + matches path: String, + isDirectory: Bool + ) -> Bool { + if pattern.directoryOnly, !isDirectory { return false } + let patternSegments = pattern.segments + let pathSegments = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) + return match(patternSegments, 0, pathSegments, 0) + } + + private static func match( + _ patternSegments: [String], + _ patternIndex: Int, + _ pathSegments: [String], + _ pathIndex: Int + ) -> Bool { + if patternIndex == patternSegments.count { return pathIndex == pathSegments.count } + if patternSegments[patternIndex] == "**" { + // A trailing `**` means everything below, so it must consume at least one segment. + if patternIndex == patternSegments.count - 1 { return pathIndex < pathSegments.count } + for next in pathIndex...pathSegments.count { + if match(patternSegments, patternIndex + 1, pathSegments, next) { return true } + } + return false + } + guard pathIndex < pathSegments.count else { return false } + guard glob(patternSegments[patternIndex], matches: pathSegments[pathIndex]) else { return false } + return match(patternSegments, patternIndex + 1, pathSegments, pathIndex + 1) + } + + /// Matches one path segment. + /// + /// `*` matches any run of characters within the segment, `?` matches one, and + /// `\` escapes the next character. There are no character classes: a seeding + /// list names files, and `[` is a legal character in a filename. + static func glob(_ pattern: String, matches name: String) -> Bool { + let patternCharacters = Array(pattern) + let nameCharacters = Array(name) + var memo = Set() + + func walk(_ patternIndex: Int, _ nameIndex: Int) -> Bool { + let key = patternIndex * (nameCharacters.count + 1) + nameIndex + guard !memo.contains(key) else { return false } + if patternIndex == patternCharacters.count { return nameIndex == nameCharacters.count } + switch patternCharacters[patternIndex] { + case "*": + // A run of `*` (including the in-segment `**`) behaves as one. + var next = patternIndex + while next < patternCharacters.count, patternCharacters[next] == "*" { next += 1 } + for skip in nameIndex...nameCharacters.count { + if walk(next, skip) { return true } + } + case "?": + if nameIndex < nameCharacters.count, walk(patternIndex + 1, nameIndex + 1) { return true } + case "\\" where patternIndex + 1 < patternCharacters.count: + if nameIndex < nameCharacters.count, + patternCharacters[patternIndex + 1] == nameCharacters[nameIndex], + walk(patternIndex + 2, nameIndex + 1) { return true } + case let character: + if nameIndex < nameCharacters.count, + character == nameCharacters[nameIndex], + walk(patternIndex + 1, nameIndex + 1) { return true } + } + memo.insert(key) + return false + } + + return walk(0, 0) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedRepository.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedRepository.swift new file mode 100644 index 000000000000..3f0f9f2f1bd0 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedRepository.swift @@ -0,0 +1,86 @@ +import Foundation + +/// Reads a `.worktreeinclude` and the directories its patterns reach. +/// +/// This is the only part of seeding that touches the source repository, so it is +/// also where "inside the repository" is decided. `root` is resolved once, and a +/// child is reported as escaping when it is a symlink whose target resolves +/// outside that resolved root. +public struct WorktreeSeedRepository: Sendable { + /// The repository root, as given. + public let root: URL + private let resolvedRootPath: String + + /// Creates a reader for a repository root. + public init(root: URL) { + self.root = root + resolvedRootPath = root.resolvingSymlinksInPath().standardizedFileURL.path + } + + /// The `.worktreeinclude` at the root, or `nil` when the repository has none. + /// + /// A missing file is not an error: most repositories will never have one, and + /// seeding then does nothing. + public func includeFile() throws -> WorktreeSeedFile? { + let url = root.appendingPathComponent(WorktreeSeedFile.fileName, isDirectory: false) + guard FileManager.default.fileExists(atPath: url.path) else { return nil } + let data = try Data(contentsOf: url) + return WorktreeSeedFile.parse(String(decoding: data, as: UTF8.self)) + } + + /// A planner that walks this repository. + public func planner(maximumVisitedDirectories: Int = 20_000) -> WorktreeSeedPlanner { + let resolvedRootPath = resolvedRootPath + let root = root + return WorktreeSeedPlanner(maximumVisitedDirectories: maximumVisitedDirectories) { relativeDirectory in + WorktreeSeedRepository.listing( + relativeDirectory, + root: root, + resolvedRootPath: resolvedRootPath + ) + } + } + + /// The children of a repository-relative directory. `""` is the root. + public func listing(_ relativeDirectory: String) -> [WorktreeSeedListedEntry] { + WorktreeSeedRepository.listing(relativeDirectory, root: root, resolvedRootPath: resolvedRootPath) + } + + private static func listing( + _ relativeDirectory: String, + root: URL, + resolvedRootPath: String + ) -> [WorktreeSeedListedEntry] { + let directory = relativeDirectory.isEmpty + ? root + : root.appendingPathComponent(relativeDirectory, isDirectory: true) + guard let names = try? FileManager.default.contentsOfDirectory(atPath: directory.path) else { + return [] + } + return names.sorted().map { name in + let child = directory.appendingPathComponent(name) + let values = try? child.resourceValues(forKeys: [.isDirectoryKey, .isSymbolicLinkKey]) + let isSymbolicLink = values?.isSymbolicLink ?? false + return WorktreeSeedListedEntry( + name: name, + // A symlink is a leaf even when it points at a directory, which is + // how git reads one too. The walk therefore never goes through a + // symlink, so a link that points at its own ancestor cannot make + // the walk loop, and a `build/` pattern does not match a symlink + // named `build`. The link itself is still copyable or linkable. + isDirectory: (values?.isDirectory ?? false) && !isSymbolicLink, + escapesRepository: isSymbolicLink && !isInside(child, resolvedRootPath: resolvedRootPath) + ) + } + } + + /// Whether a path resolves to somewhere under the resolved repository root. + /// + /// The comparison adds the separator so `/repo-backup` does not read as being + /// inside `/repo`. + private static func isInside(_ url: URL, resolvedRootPath: String) -> Bool { + let resolved = url.resolvingSymlinksInPath().standardizedFileURL.path + if resolved == resolvedRootPath { return true } + return resolved.hasPrefix(resolvedRootPath.hasSuffix("/") ? resolvedRootPath : resolvedRootPath + "/") + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedFileTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedFileTests.swift new file mode 100644 index 000000000000..3710fe0e9c33 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedFileTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +import CMUXAgentLaunch + +@Suite("worktreeinclude files") +struct WorktreeSeedFileTests { + @Test func theFileNameIsTheOneTheRepositoryRootUses() { + #expect(WorktreeSeedFile.fileName == ".worktreeinclude") + } + + @Test func patternsKeepFileOrderAndLineNumbers() { + let file = WorktreeSeedFile.parse( + """ + # what a fresh worktree needs + .env + + link node_modules + !.env.production + """ + ) + #expect(file.problems.isEmpty) + #expect(file.patterns.map(\.glob) == [".env", "node_modules", ".env.production"]) + #expect(file.patterns.map(\.line) == [2, 4, 5]) + #expect(file.patterns.map(\.action) == [.copy, .link, .copy]) + #expect(file.patterns.map(\.isNegated) == [false, false, true]) + } + + @Test func oneBadLineDoesNotDiscardTheGoodOnes() { + let file = WorktreeSeedFile.parse(".env\n/absolute\nconfig/local.json\n") + #expect(file.patterns.map(\.glob) == [".env", "config/local.json"]) + #expect(file.problems.count == 1) + #expect(file.problems[0].line == 2) + #expect(file.problems[0].reason == .leadingSlash) + } + + @Test func everyBadLineIsReportedInOnePass() { + let file = WorktreeSeedFile.parse("/one\n~two\n../three\n!link four\n") + #expect(file.patterns.isEmpty) + #expect(file.problems.map(\.line) == [1, 2, 3, 4]) + #expect(file.problems.map(\.reason) == [ + .leadingSlash, .homeRelative, .escapesRepository, .negatedActionKeyword, + ]) + } + + @Test func aFileOfCommentsIsEmptyRatherThanBroken() { + let file = WorktreeSeedFile.parse("# nothing here yet\n\n \n") + #expect(file.isEmpty) + #expect(file.problems.isEmpty) + } + + @Test func windowsLineEndingsDoNotBecomePartOfAFilename() { + let file = WorktreeSeedFile.parse(".env\r\nlink node_modules\r\n") + #expect(file.patterns.map(\.glob) == [".env", "node_modules"]) + } + + @Test func aFileWithoutATrailingNewlineKeepsItsLastPattern() { + #expect(WorktreeSeedFile.parse(".env").patterns.count == 1) + } + + @Test func anEmptyFileIsEmpty() { + let file = WorktreeSeedFile.parse("") + #expect(file.isEmpty) + #expect(file.problems.isEmpty) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPatternTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPatternTests.swift new file mode 100644 index 000000000000..8b7b1e85d8c9 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPatternTests.swift @@ -0,0 +1,131 @@ +import Foundation +import Testing +import CMUXAgentLaunch + +@Suite("worktreeinclude lines") +struct WorktreeSeedPatternTests { + private func parse(_ line: String, number: Int = 1) -> Result { + WorktreeSeedPattern.parse(line: line, number: number) + } + + private func pattern(_ line: String, number: Int = 1) throws -> WorktreeSeedPattern { + switch parse(line, number: number) { + case .success(let pattern?): return pattern + case .success(nil): throw TestFailure("line \(line) was skipped, expected a pattern") + case .failure(let problem): throw TestFailure("line \(line) was rejected: \(problem)") + } + } + + private func problem(_ line: String, number: Int = 1) throws -> WorktreeSeedProblem { + switch parse(line, number: number) { + case .failure(let problem): return problem + default: throw TestFailure("line \(line) was accepted, expected a problem") + } + } + + struct TestFailure: Error, CustomStringConvertible { + let description: String + init(_ description: String) { self.description = description } + } + + @Test func blankAndCommentLinesSelectNothing() { + for line in ["", " ", "\t", "# a comment", " # indented comment"] { + #expect(try! parse(line).get() == nil, "\(line.debugDescription) should be skipped") + } + } + + @Test func aPlainLineCopiesOnePath() throws { + let pattern = try pattern(".env") + #expect(pattern.glob == ".env") + #expect(pattern.action == .copy) + #expect(!pattern.isNegated) + #expect(!pattern.directoryOnly) + #expect(pattern.segments == [".env"]) + } + + @Test func theLinkKeywordChangesTheAction() throws { + #expect(try pattern("link node_modules").action == .link) + #expect(try pattern("link\tnode_modules").action == .link) + #expect(try pattern("link node_modules").glob == "node_modules") + } + + @Test func aWordStartingWithLinkIsStillAPath() throws { + let pattern = try pattern("linked-config.json") + #expect(pattern.action == .copy) + #expect(pattern.glob == "linked-config.json") + } + + @Test func aBackslashEscapesAKeywordPath() throws { + #expect(try pattern("\\link me").glob == "link me") + #expect(try pattern("\\#not-a-comment").glob == "#not-a-comment") + #expect(try pattern("\\!not-a-negation").glob == "!not-a-negation") + } + + @Test func bangNegates() throws { + let pattern = try pattern("!config/local.secret.json") + #expect(pattern.isNegated) + #expect(pattern.glob == "config/local.secret.json") + } + + @Test func aNegationTakesNoActionKeyword() throws { + let problem = try problem("!link node_modules") + #expect(problem.reason == .negatedActionKeyword) + #expect(problem.description.contains("!node_modules")) + } + + @Test func aKeywordWithNothingAfterItIsAPathNamedAfterTheKeyword() throws { + // Trailing whitespace goes before the keyword is read, so there is no + // "keyword with an empty pattern" state to report: `link` on its own is a + // path, and an absent one shows up as an unmatched pattern in the plan. + #expect(try pattern("link").glob == "link") + #expect(try pattern("link ").glob == "link") + #expect(try pattern("link\t\t").glob == "link") + #expect(try pattern("link").action == .copy) + } + + @Test func aTrailingSlashMeansDirectoryOnly() throws { + let pattern = try pattern("build/") + #expect(pattern.directoryOnly) + #expect(pattern.glob == "build") + #expect(try self.pattern("build//").directoryOnly) + } + + @Test func aLeadingSlashIsRejectedBecausePatternsAreAlreadyRelative() throws { + let problem = try problem("/Users/leo/.env", number: 4) + #expect(problem.reason == .leadingSlash) + #expect(problem.line == 4) + #expect(problem.description.contains("line 4")) + } + + @Test func aHomePathIsRejected() throws { + #expect(try problem("~/.aws/credentials").reason == .homeRelative) + } + + @Test func aDotDotSegmentIsRejected() throws { + #expect(try problem("../secrets/.env").reason == .escapesRepository) + #expect(try problem("config/../../.env").reason == .escapesRepository) + } + + @Test func aLineOfSeparatorsIsRejected() throws { + #expect(try problem("/").reason == .leadingSlash) + #expect(try problem("\\").reason == .emptyPattern) + } + + @Test func trailingWhitespaceIsNotPartOfAFilename() throws { + #expect(try pattern(".env ").glob == ".env") + #expect(try pattern("\tconfig/local.json\t").glob == "config/local.json") + } + + @Test func anEscapedTrailingSpaceIsPartOfAFilename() throws { + #expect(try pattern("odd-name\\ ").glob == "odd-name ") + } + + @Test func innerSlashesAreNormalized() throws { + #expect(try pattern("config//local.json").glob == "config/local.json") + #expect(try pattern("config/local.json").segments == ["config", "local.json"]) + } + + @Test func theLineNumberIsCarried() throws { + #expect(try pattern(".env", number: 12).line == 12) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPlannerTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPlannerTests.swift new file mode 100644 index 000000000000..9951852ad2e4 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPlannerTests.swift @@ -0,0 +1,263 @@ +import Foundation +import Testing +import CMUXAgentLaunch + +/// A repository the planner can walk without a filesystem. +/// +/// Built from paths: a path ending in `/` is a directory, and every parent +/// directory is implied. `escaping` names symlinks that resolve out of the +/// repository, `directoryCount` records what a plan actually listed. +final class WorktreeSeedFakeRepository: @unchecked Sendable { + private var children: [String: [WorktreeSeedListedEntry]] = [:] + private(set) var listedDirectories: [String] = [] + + init(_ paths: [String], escaping: Set = []) { + var directories: Set = [""] + for path in paths where path.hasSuffix("/") { + directories.insert(String(path.dropLast())) + } + for path in paths { + let trimmed = path.hasSuffix("/") ? String(path.dropLast()) : path + var segments = trimmed.split(separator: "/").map(String.init) + let name = segments.removeLast() + var parent = "" + for segment in segments { + directories.insert(parent.isEmpty ? segment : parent + "/" + segment) + parent = parent.isEmpty ? segment : parent + "/" + segment + } + let isDirectory = path.hasSuffix("/") || directories.contains(trimmed) + children[parent, default: []].append( + WorktreeSeedListedEntry( + name: name, + isDirectory: isDirectory, + escapesRepository: escaping.contains(trimmed) + ) + ) + } + for directory in directories where children[directory] == nil { + children[directory] = [] + } + for (directory, entries) in children { + children[directory] = entries.sorted { $0.name < $1.name } + } + } + + func planner(maximumVisitedDirectories: Int = 20_000) -> WorktreeSeedPlanner { + WorktreeSeedPlanner(maximumVisitedDirectories: maximumVisitedDirectories) { [self] directory in + record(directory) + return children[directory] ?? [] + } + } + + private func record(_ directory: String) { + listedDirectories.append(directory) + } +} + +@Suite("worktreeinclude plans") +struct WorktreeSeedPlannerTests { + private func plan( + _ text: String, + _ repository: WorktreeSeedFakeRepository, + alreadyPresent: Set = [], + maximumVisitedDirectories: Int = 20_000 + ) -> WorktreeSeedPlan { + repository.planner(maximumVisitedDirectories: maximumVisitedDirectories) + .plan(for: WorktreeSeedFile.parse(text), alreadyPresent: alreadyPresent) + } + + @Test func aPatternIsAPathFromTheRepositoryRoot() { + let repository = WorktreeSeedFakeRepository([".env", "web/", "web/.env"]) + let plan = plan(".env", repository) + #expect(plan.entries.map(\.relativePath) == [".env"]) + } + + @Test func aRootPatternDoesNotWalkIntoSubdirectories() { + let repository = WorktreeSeedFakeRepository([".env", "node_modules/", "node_modules/a/", "node_modules/a/b"]) + _ = plan(".env", repository) + #expect(repository.listedDirectories == [""]) + } + + @Test func aNestedPatternListsOnlyTheDirectoriesOnItsPath() { + let repository = WorktreeSeedFakeRepository([ + "config/", "config/local.json", "node_modules/", "node_modules/a/", "web/", "web/x", + ]) + _ = plan("config/local.json", repository) + #expect(repository.listedDirectories == ["", "config"]) + } + + @Test func starMatchesWithinOneSegmentOnly() { + let repository = WorktreeSeedFakeRepository([".env", ".env.local", "config/", "config/.env.ci"]) + let plan = plan(".env*", repository) + #expect(plan.entries.map(\.relativePath) == [".env", ".env.local"]) + } + + @Test func questionMarkMatchesOneCharacter() { + let repository = WorktreeSeedFakeRepository(["a.env", "ab.env", ".env"]) + #expect(plan("?.env", WorktreeSeedFakeRepository(["a.env", "ab.env", ".env"])).entries.map(\.relativePath) == ["a.env"]) + _ = repository + } + + @Test func doubleStarIsHowAPatternOptsIntoAWalk() { + let repository = WorktreeSeedFakeRepository([ + "a/", "a/.env", "a/b/", "a/b/.env", "a/b/c/", "a/b/c/other", + ]) + let plan = plan("a/**/.env", repository) + #expect(plan.entries.map(\.relativePath) == ["a/.env", "a/b/.env"]) + } + + @Test func aTrailingDoubleStarTakesEverythingBelowButNotTheDirectoryItself() { + let repository = WorktreeSeedFakeRepository(["secrets/", "secrets/a", "secrets/b/", "secrets/b/c"]) + let plan = plan("secrets/**", repository) + #expect(plan.entries.map(\.relativePath) == ["secrets/a", "secrets/b"]) + #expect(plan.shadowed.map(\.relativePath) == ["secrets/b/c"]) + } + + @Test func aTrailingSlashSelectsDirectoriesOnly() { + let repository = WorktreeSeedFakeRepository(["build", "build-dir/", "build-dir/x"]) + let plan = plan("build*/", repository) + #expect(plan.entries.map(\.relativePath) == ["build-dir"]) + #expect(plan.entries.map(\.isDirectory) == [true]) + } + + @Test func aDirectoryWithoutATrailingSlashIsStillSelectedWhole() { + let repository = WorktreeSeedFakeRepository(["node_modules/", "node_modules/a/", "node_modules/a/b"]) + let plan = plan("node_modules", repository) + #expect(plan.entries.count == 1) + #expect(plan.entries[0].relativePath == "node_modules") + #expect(plan.entries[0].isDirectory) + } + + @Test func theLinkKeywordCarriesToThePlan() { + let repository = WorktreeSeedFakeRepository([".env", "node_modules/"]) + let plan = plan(".env\nlink node_modules\n", repository) + #expect(plan.copies.map(\.relativePath) == [".env"]) + #expect(plan.links.map(\.relativePath) == ["node_modules"]) + } + + @Test func theLastMatchingLineDecides() { + let repository = WorktreeSeedFakeRepository([".env", ".env.production"]) + let plan = plan(".env*\n!.env.production\n", repository) + #expect(plan.entries.map(\.relativePath) == [".env"]) + #expect(plan.excluded.map(\.relativePath) == [".env.production"]) + #expect(plan.excluded.map(\.line) == [2]) + } + + @Test func aNegationBeforeItsPatternDoesNothing() { + let repository = WorktreeSeedFakeRepository([".env", ".env.production"]) + let plan = plan("!.env.production\n.env*\n", repository) + #expect(plan.entries.map(\.relativePath) == [".env", ".env.production"]) + #expect(plan.excluded.isEmpty) + } + + @Test func aLaterLineCanChangeAnActionWithoutRepeatingTheNegation() { + let repository = WorktreeSeedFakeRepository(["node_modules/"]) + let plan = plan("node_modules\nlink node_modules\n", repository) + #expect(plan.links.map(\.relativePath) == ["node_modules"]) + #expect(plan.copies.isEmpty) + #expect(plan.entries.map(\.line) == [2]) + } + + @Test func anEntryUnderASelectedDirectoryIsDropped() { + let repository = WorktreeSeedFakeRepository(["node_modules/", "node_modules/.bin/", "node_modules/.bin/tsc"]) + let plan = plan("link node_modules\nnode_modules/.bin/tsc\n", repository) + #expect(plan.entries.map(\.relativePath) == ["node_modules"]) + #expect(plan.shadowed == [ + WorktreeSeedShadow( + relativePath: "node_modules/.bin/tsc", + coveredBy: "node_modules", + coveringAction: .link + ), + ]) + } + + @Test func aNegationInsideASelectedDirectoryIsReportedRatherThanIgnored() { + let repository = WorktreeSeedFakeRepository([ + "node_modules/", "node_modules/.cache/", "node_modules/.cache/big", + ]) + let plan = plan("link node_modules\n!node_modules/.cache\n", repository) + #expect(plan.entries.map(\.relativePath) == ["node_modules"]) + #expect(plan.ineffectiveNegations == [ + WorktreeSeedShadow( + relativePath: "node_modules/.cache", + coveredBy: "node_modules", + coveringAction: .link + ), + ]) + } + + @Test func aSymlinkOutOfTheRepositoryIsRefused() { + let repository = WorktreeSeedFakeRepository([".env", "shared/"], escaping: ["shared"]) + let plan = plan(".env\nshared\n", repository) + #expect(plan.entries.map(\.relativePath) == [".env"]) + #expect(plan.refused.map(\.relativePath) == ["shared"]) + } + + @Test func aPathTheWorktreeAlreadyHasIsLeftAlone() { + let repository = WorktreeSeedFakeRepository([".env", "config/", "config/local.json"]) + let plan = plan(".env\nconfig/local.json\n", repository, alreadyPresent: ["config/local.json"]) + #expect(plan.entries.map(\.relativePath) == [".env"]) + #expect(plan.alreadyPresent.map(\.relativePath) == ["config/local.json"]) + } + + @Test func aPatternThatMatchesNothingIsNamed() { + let repository = WorktreeSeedFakeRepository([".env"]) + let plan = plan(".env\n.env.gone\nlink node_modules\n", repository) + #expect(plan.unmatched.map(\.glob) == [".env.gone", "node_modules"]) + #expect(plan.unmatched.map(\.line) == [2, 3]) + } + + @Test func aWalkThatHitsTheBudgetSaysSo() { + var paths: [String] = [] + var prefix = "a" + for _ in 0..<40 { + paths.append(prefix + "/") + paths.append(prefix + "/.env") + prefix += "/a" + } + let repository = WorktreeSeedFakeRepository(paths) + let plan = plan("**/.env", repository, maximumVisitedDirectories: 5) + #expect(plan.reachedWalkLimit) + #expect(repository.listedDirectories.count <= 5) + } + + @Test func aPlanThatStaysInBudgetDoesNotClaimALimit() { + let repository = WorktreeSeedFakeRepository(["a/", "a/.env"]) + #expect(!plan("**/.env", repository).reachedWalkLimit) + } + + @Test func anEmptyFilePlansNothing() { + let repository = WorktreeSeedFakeRepository([".env"]) + let plan = plan("# nothing\n", repository) + #expect(plan.isEmpty) + #expect(plan.unmatched.isEmpty) + } + + @Test func plansAreOrderedByPathSoTheyCanBeCompared() { + let repository = WorktreeSeedFakeRepository(["z.env", "a.env", "m/", "m/local.json"]) + let plan = plan("z.env\nm/local.json\na.env\n", repository) + #expect(plan.entries.map(\.relativePath) == ["a.env", "m/local.json", "z.env"]) + } + + @Test func aBadLineDoesNotStopTheGoodOnesFromBeingPlanned() { + let repository = WorktreeSeedFakeRepository([".env"]) + let file = WorktreeSeedFile.parse("/nope\n.env\n") + let plan = repository.planner().plan(for: file) + #expect(plan.entries.map(\.relativePath) == [".env"]) + #expect(file.problems.count == 1) + } + + @Test func aPathologicalGlobDoesNotHang() { + let name = String(repeating: "a", count: 200) + let repository = WorktreeSeedFakeRepository([name]) + let plan = plan("*a*a*a*a*a*b", repository) + #expect(plan.entries.isEmpty) + #expect(plan.unmatched.count == 1) + } + + @Test func anEscapedStarMatchesALiteralStar() { + let repository = WorktreeSeedFakeRepository(["star*name", "starXname"]) + let plan = plan("star\\*name", repository) + #expect(plan.entries.map(\.relativePath) == ["star*name"]) + } +} diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedRepositoryTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedRepositoryTests.swift new file mode 100644 index 000000000000..033cabcbef82 --- /dev/null +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedRepositoryTests.swift @@ -0,0 +1,305 @@ +import Foundation +import Testing +import CMUXAgentLaunch + +/// A real directory tree in a temporary location, removed when the test ends. +/// +/// A class rather than a struct so `deinit` does the cleanup: the tests can hand +/// the tree to a helper without thinking about ownership. +final class WorktreeSeedTemporaryTree { + let root: URL + + init(_ name: String = "worktree-seed") throws { + root = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + .appendingPathComponent("\(name)-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + } + + func directory(_ relativePath: String) throws -> URL { + let url = root.appendingPathComponent(relativePath, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + @discardableResult + func file(_ relativePath: String, _ contents: String = "x") throws -> URL { + let url = root.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Data(contents.utf8).write(to: url) + return url + } + + @discardableResult + func symlink(_ relativePath: String, to target: URL) throws -> URL { + let url = root.appendingPathComponent(relativePath) + try FileManager.default.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try FileManager.default.createSymbolicLink(atPath: url.path, withDestinationPath: target.path) + return url + } + + deinit { + try? FileManager.default.removeItem(at: root) + } +} + +@Suite("worktreeinclude on a real tree") +struct WorktreeSeedRepositoryTests { + @Test func aRepositoryWithoutTheFileReportsNothingRatherThanFailing() throws { + let tree = try WorktreeSeedTemporaryTree() + #expect(try WorktreeSeedRepository(root: tree.root).includeFile() == nil) + } + + @Test func theFileIsReadFromTheRepositoryRoot() throws { + let tree = try WorktreeSeedTemporaryTree() + try tree.file(".worktreeinclude", "# seed\n.env\nlink node_modules\n") + let file = try WorktreeSeedRepository(root: tree.root).includeFile() + #expect(file?.patterns.map(\.glob) == [".env", "node_modules"]) + } + + @Test func aListingSeparatesFilesFromDirectories() throws { + let tree = try WorktreeSeedTemporaryTree() + try tree.file(".env") + _ = try tree.directory("node_modules") + let listing = WorktreeSeedRepository(root: tree.root).listing("") + #expect(listing.map(\.name) == [".env", "node_modules"]) + #expect(listing.map(\.isDirectory) == [false, true]) + #expect(listing.allSatisfy { !$0.escapesRepository }) + } + + @Test func aSymlinkInsideTheRepositoryIsNotAnEscape() throws { + let tree = try WorktreeSeedTemporaryTree() + let target = try tree.file("real/.env") + try tree.symlink("link-to-env", to: target) + let listing = WorktreeSeedRepository(root: tree.root).listing("") + let entry = try #require(listing.first { $0.name == "link-to-env" }) + #expect(!entry.escapesRepository) + } + + @Test func aSymlinkOutOfTheRepositoryIsAnEscape() throws { + let tree = try WorktreeSeedTemporaryTree() + let outside = try WorktreeSeedTemporaryTree("outside") + let target = try outside.file("secrets.env") + try tree.symlink("secrets.env", to: target) + let listing = WorktreeSeedRepository(root: tree.root).listing("") + let entry = try #require(listing.first { $0.name == "secrets.env" }) + #expect(entry.escapesRepository) + } + + @Test func aSiblingDirectoryWithTheRootAsAPrefixIsOutside() throws { + let parent = try WorktreeSeedTemporaryTree("prefix") + let root = try parent.directory("repo") + let sibling = try parent.directory("repo-backup") + let target = sibling.appendingPathComponent(".env") + try Data("x".utf8).write(to: target) + try FileManager.default.createSymbolicLink( + atPath: root.appendingPathComponent(".env").path, + withDestinationPath: target.path + ) + let entry = try #require(WorktreeSeedRepository(root: root).listing("").first) + #expect(entry.escapesRepository) + } + + @Test func aSymlinkToADirectoryIsALeaf() throws { + let tree = try WorktreeSeedTemporaryTree() + let target = try tree.directory("real-modules") + try tree.symlink("node_modules", to: target) + let listing = WorktreeSeedRepository(root: tree.root).listing("") + let entry = try #require(listing.first { $0.name == "node_modules" }) + // Not a directory, so the walk never descends through it and a link that + // points at its own ancestor cannot make the walk loop. + #expect(!entry.isDirectory) + #expect(!entry.escapesRepository) + } + + @Test func aSymlinkLoopDoesNotHangTheWalk() throws { + let tree = try WorktreeSeedTemporaryTree() + let inner = try tree.directory("a/b") + try FileManager.default.createSymbolicLink( + atPath: inner.appendingPathComponent("up").path, + withDestinationPath: tree.root.path + ) + try tree.file("a/b/.env") + let repository = WorktreeSeedRepository(root: tree.root) + let plan = repository.planner().plan(for: WorktreeSeedFile.parse("**/.env\n")) + #expect(plan.copies.map(\.relativePath) == ["a/b/.env"]) + #expect(!plan.reachedWalkLimit) + } + + @Test func aSymlinkedDirectoryIsStillDeliverableWhenNamedDirectly() throws { + let tree = try WorktreeSeedTemporaryTree() + let target = try tree.directory("real-modules") + try tree.symlink("node_modules", to: target) + let repository = WorktreeSeedRepository(root: tree.root) + let plan = repository.planner().plan(for: WorktreeSeedFile.parse("link node_modules\n")) + #expect(plan.links.map(\.relativePath) == ["node_modules"]) + } + + @Test func aMissingDirectoryListsEmptyRatherThanFailing() throws { + let tree = try WorktreeSeedTemporaryTree() + #expect(WorktreeSeedRepository(root: tree.root).listing("nope").isEmpty) + } + + @Test func aPlannerOverARealTreeFindsWhatTheFileNames() throws { + let tree = try WorktreeSeedTemporaryTree() + try tree.file(".worktreeinclude", ".env\nlink node_modules\nconfig/*.local.json\n") + try tree.file(".env") + _ = try tree.directory("node_modules") + try tree.file("config/app.local.json") + try tree.file("config/app.json") + let repository = WorktreeSeedRepository(root: tree.root) + let file = try #require(try repository.includeFile()) + let plan = repository.planner().plan(for: file) + #expect(plan.copies.map(\.relativePath) == [".env", "config/app.local.json"]) + #expect(plan.links.map(\.relativePath) == ["node_modules"]) + } +} + +@Suite("worktreeinclude applied") +struct WorktreeSeedApplierTests { + private func plan(_ text: String, in tree: WorktreeSeedTemporaryTree, alreadyPresent: Set = []) -> WorktreeSeedPlan { + WorktreeSeedRepository(root: tree.root) + .planner() + .plan(for: WorktreeSeedFile.parse(text), alreadyPresent: alreadyPresent) + } + + @Test func aCopiedFileArrivesWithItsContents() throws { + let source = try WorktreeSeedTemporaryTree("source") + let destination = try WorktreeSeedTemporaryTree("destination") + try source.file(".env", "TOKEN=1") + let report = WorktreeSeedApplier().apply( + plan(".env", in: source), + from: source.root, + to: destination.root + ) + #expect(report.copied == [".env"]) + #expect(report.isComplete) + let landed = destination.root.appendingPathComponent(".env") + #expect(try String(contentsOf: landed, encoding: .utf8) == "TOKEN=1") + } + + @Test func aNestedCopyCreatesItsParentDirectories() throws { + let source = try WorktreeSeedTemporaryTree("source") + let destination = try WorktreeSeedTemporaryTree("destination") + try source.file("config/nested/local.json", "{}") + let report = WorktreeSeedApplier().apply( + plan("config/nested/local.json", in: source), + from: source.root, + to: destination.root + ) + #expect(report.copied == ["config/nested/local.json"]) + #expect(FileManager.default.fileExists( + atPath: destination.root.appendingPathComponent("config/nested/local.json").path + )) + } + + @Test func aCopiedDirectoryArrivesWholeWithoutBeingWalkedByThePlan() throws { + let source = try WorktreeSeedTemporaryTree("source") + let destination = try WorktreeSeedTemporaryTree("destination") + try source.file("cache/a/b.txt", "deep") + let report = WorktreeSeedApplier().apply( + plan("cache", in: source), + from: source.root, + to: destination.root + ) + #expect(report.copied == ["cache"]) + #expect(try String( + contentsOf: destination.root.appendingPathComponent("cache/a/b.txt"), + encoding: .utf8 + ) == "deep") + } + + @Test func aLinkPointsAtTheOriginalByAbsolutePath() throws { + let source = try WorktreeSeedTemporaryTree("source") + let destination = try WorktreeSeedTemporaryTree("destination") + _ = try source.directory("node_modules") + let report = WorktreeSeedApplier().apply( + plan("link node_modules", in: source), + from: source.root, + to: destination.root + ) + #expect(report.linked == ["node_modules"]) + let target = try FileManager.default.destinationOfSymbolicLink( + atPath: destination.root.appendingPathComponent("node_modules").path + ) + #expect(target == source.root.standardizedFileURL.appendingPathComponent("node_modules").path) + #expect(target.hasPrefix("/")) + } + + @Test func aFileTheWorktreeAlreadyHasIsNotOverwritten() throws { + let source = try WorktreeSeedTemporaryTree("source") + let destination = try WorktreeSeedTemporaryTree("destination") + try source.file("config.json", "from the source") + try destination.file("config.json", "from git") + // The plan itself does not know: this is the applier's own last check, + // for a path that appeared between planning and applying. + let report = WorktreeSeedApplier().apply( + plan("config.json", in: source), + from: source.root, + to: destination.root + ) + #expect(report.skipped == ["config.json"]) + #expect(report.copied.isEmpty) + #expect(try String( + contentsOf: destination.root.appendingPathComponent("config.json"), + encoding: .utf8 + ) == "from git") + } + + @Test func aDanglingSymlinkCountsAsOccupied() throws { + let source = try WorktreeSeedTemporaryTree("source") + let destination = try WorktreeSeedTemporaryTree("destination") + try source.file(".env", "TOKEN=1") + try destination.symlink(".env", to: URL(fileURLWithPath: "/nonexistent/target")) + let report = WorktreeSeedApplier().apply( + plan(".env", in: source), + from: source.root, + to: destination.root + ) + #expect(report.skipped == [".env"]) + #expect(report.failed.isEmpty) + } + + @Test func aPathThatLeftTheRepositoryBetweenPlanAndApplyIsReportedNotCrashed() throws { + let source = try WorktreeSeedTemporaryTree("source") + let destination = try WorktreeSeedTemporaryTree("destination") + try source.file(".env") + let plan = plan(".env", in: source) + try FileManager.default.removeItem(at: source.root.appendingPathComponent(".env")) + let report = WorktreeSeedApplier().apply(plan, from: source.root, to: destination.root) + #expect(report.failed == [ + WorktreeSeedFailure(relativePath: ".env", reason: "no longer in the repository"), + ]) + #expect(!report.isComplete) + } + + @Test func oneFailureDoesNotStopTheRest() throws { + let source = try WorktreeSeedTemporaryTree("source") + let destination = try WorktreeSeedTemporaryTree("destination") + try source.file(".env") + try source.file("config.json") + let plan = plan(".env\nconfig.json\n", in: source) + try FileManager.default.removeItem(at: source.root.appendingPathComponent(".env")) + let report = WorktreeSeedApplier().apply(plan, from: source.root, to: destination.root) + #expect(report.copied == ["config.json"]) + #expect(report.failed.map(\.relativePath) == [".env"]) + #expect(report.deliveredCount == 1) + } + + @Test func anEmptyPlanTouchesNothing() throws { + let source = try WorktreeSeedTemporaryTree("source") + let destination = try WorktreeSeedTemporaryTree("destination") + let report = WorktreeSeedApplier().apply( + WorktreeSeedPlan(), + from: source.root, + to: destination.root + ) + #expect(report == WorktreeSeedReport()) + #expect(try FileManager.default.contentsOfDirectory(atPath: destination.root.path).isEmpty) + } +} From 337ff4f0ec39da0fd09573f5867841ae4af4606b Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Mon, 28 Sep 2026 15:29:56 -0400 Subject: [PATCH 2/4] fix: harden worktree seed planning and delivery --- .../WorktreeSeed/WorktreeSeedApplier.swift | 47 +++++++++++++++++++ .../WorktreeSeed/WorktreeSeedPlanner.swift | 46 +++++++++++------- .../WorktreeSeedPlannerTests.swift | 26 ++++++++++ .../WorktreeSeedRepositoryTests.swift | 25 ++++++++++ 4 files changed, 126 insertions(+), 18 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedApplier.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedApplier.swift index e918939c5c14..b61b60f9aa8a 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedApplier.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedApplier.swift @@ -84,6 +84,18 @@ public struct WorktreeSeedApplier: Sendable { ) continue } + if let ancestor = symlinkAncestor( + of: entry.relativePath, + under: destination + ) { + report.failed.append( + WorktreeSeedFailure( + relativePath: entry.relativePath, + reason: "destination ancestor is a symlink: \(ancestor)" + ) + ) + continue + } if fileExistsWithoutFollowingLinks(to) { report.skipped.append(entry.relativePath) continue @@ -92,6 +104,18 @@ public struct WorktreeSeedApplier: Sendable { let parent = to.deletingLastPathComponent() do { try fileManager.createDirectory(at: parent, withIntermediateDirectories: true) + if let ancestor = symlinkAncestor( + of: entry.relativePath, + under: destination + ) { + report.failed.append( + WorktreeSeedFailure( + relativePath: entry.relativePath, + reason: "destination ancestor is a symlink: \(ancestor)" + ) + ) + continue + } } catch { report.failed.append( WorktreeSeedFailure(relativePath: entry.relativePath, reason: String(describing: error)) @@ -128,4 +152,27 @@ public struct WorktreeSeedApplier: Sendable { private func fileExistsWithoutFollowingLinks(_ url: URL) -> Bool { (try? FileManager.default.attributesOfItem(atPath: url.path)) != nil } + + /// The first existing symlink between the destination root and the entry's parent. + /// + /// `createDirectory` and `copyItem` follow ancestor symlinks. Reject them + /// before and after parent creation so a checked-out link cannot redirect a + /// seed write outside the new worktree. + private func symlinkAncestor(of relativePath: String, under root: URL) -> String? { + var current = root + if isSymlink(current) { return "." } + var traversed: [Substring] = [] + for component in relativePath.split(separator: "/").dropLast() { + traversed.append(component) + current.appendPathComponent(String(component), isDirectory: true) + guard fileExistsWithoutFollowingLinks(current) else { break } + if isSymlink(current) { return traversed.joined(separator: "/") } + } + return nil + } + + private func isSymlink(_ url: URL) -> Bool { + let attributes = try? FileManager.default.attributesOfItem(atPath: url.path) + return attributes?[.type] as? FileAttributeType == .typeSymbolicLink + } } diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPlanner.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPlanner.swift index 40dd079d22ef..5b722cec56fa 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPlanner.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPlanner.swift @@ -116,7 +116,7 @@ public struct WorktreeSeedPlanner: Sendable { for pattern in file.patterns where pattern.isNegated { for path in (matchesByLine[pattern.line] ?? [:]).keys.sorted() { - guard selected[path] == nil, candidates[path] == nil else { continue } + guard selected[path] == nil else { continue } guard let ancestor = nearestSelectedAncestor(of: path, in: selected) else { continue } plan.ineffectiveNegations.append( WorktreeSeedShadow( @@ -226,27 +226,37 @@ extension WorktreeSeedPlanner { if pattern.directoryOnly, !isDirectory { return false } let patternSegments = pattern.segments let pathSegments = path.split(separator: "/", omittingEmptySubsequences: true).map(String.init) - return match(patternSegments, 0, pathSegments, 0) - } + var failed = Set() - private static func match( - _ patternSegments: [String], - _ patternIndex: Int, - _ pathSegments: [String], - _ pathIndex: Int - ) -> Bool { - if patternIndex == patternSegments.count { return pathIndex == pathSegments.count } - if patternSegments[patternIndex] == "**" { - // A trailing `**` means everything below, so it must consume at least one segment. - if patternIndex == patternSegments.count - 1 { return pathIndex < pathSegments.count } - for next in pathIndex...pathSegments.count { - if match(patternSegments, patternIndex + 1, pathSegments, next) { return true } + func walk(_ patternIndex: Int, _ pathIndex: Int) -> Bool { + let key = patternIndex * (pathSegments.count + 1) + pathIndex + guard !failed.contains(key) else { return false } + if patternIndex == patternSegments.count { + return pathIndex == pathSegments.count } + if patternSegments[patternIndex] == "**" { + // A trailing `**` means everything below, so it must consume + // at least one segment. Otherwise it either consumes nothing + // or one segment; memoization bounds the state space to the + // pattern/path grid. + if patternIndex == patternSegments.count - 1 { + return pathIndex < pathSegments.count + } + if walk(patternIndex + 1, pathIndex) { return true } + if pathIndex < pathSegments.count, walk(patternIndex, pathIndex + 1) { + return true + } + } else if pathIndex < pathSegments.count, + glob(patternSegments[patternIndex], matches: pathSegments[pathIndex]), + walk(patternIndex + 1, pathIndex + 1) + { + return true + } + failed.insert(key) return false } - guard pathIndex < pathSegments.count else { return false } - guard glob(patternSegments[patternIndex], matches: pathSegments[pathIndex]) else { return false } - return match(patternSegments, patternIndex + 1, pathSegments, pathIndex + 1) + + return walk(0, 0) } /// Matches one path segment. diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPlannerTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPlannerTests.swift index 9951852ad2e4..89b0f56b64c4 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPlannerTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPlannerTests.swift @@ -186,6 +186,25 @@ struct WorktreeSeedPlannerTests { ]) } + @Test func aNegatedPositiveMatchInsideASelectedDirectoryIsStillIneffective() { + let repository = WorktreeSeedFakeRepository([ + "node_modules/", "node_modules/.cache/", "node_modules/.cache/big", + ]) + let plan = plan( + "link node_modules\nnode_modules/**\n!node_modules/.cache\n", + repository + ) + #expect(plan.entries.map(\.relativePath) == ["node_modules"]) + #expect(plan.excluded.map(\.relativePath) == ["node_modules/.cache"]) + #expect(plan.ineffectiveNegations == [ + WorktreeSeedShadow( + relativePath: "node_modules/.cache", + coveredBy: "node_modules", + coveringAction: .link + ), + ]) + } + @Test func aSymlinkOutOfTheRepositoryIsRefused() { let repository = WorktreeSeedFakeRepository([".env", "shared/"], escaping: ["shared"]) let plan = plan(".env\nshared\n", repository) @@ -255,6 +274,13 @@ struct WorktreeSeedPlannerTests { #expect(plan.unmatched.count == 1) } + @Test func repeatedDoubleStarSegmentsDoNotRevisitTheSameStates() throws { + let text = (Array(repeating: "**", count: 40) + ["never"]).joined(separator: "/") + let pattern = try #require(WorktreeSeedFile.parse(text).patterns.first) + let path = Array(repeating: "segment", count: 80).joined(separator: "/") + #expect(!WorktreeSeedPlanner.pattern(pattern, matches: path, isDirectory: false)) + } + @Test func anEscapedStarMatchesALiteralStar() { let repository = WorktreeSeedFakeRepository(["star*name", "starXname"]) let plan = plan("star\\*name", repository) diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedRepositoryTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedRepositoryTests.swift index 033cabcbef82..589bba59cfda 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedRepositoryTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedRepositoryTests.swift @@ -265,6 +265,31 @@ struct WorktreeSeedApplierTests { #expect(report.failed.isEmpty) } + @Test func aDestinationSymlinkAncestorCannotRedirectACopy() throws { + let source = try WorktreeSeedTemporaryTree("source") + let destination = try WorktreeSeedTemporaryTree("destination") + let outside = try WorktreeSeedTemporaryTree("outside") + try source.file("config/credentials", "secret") + try destination.symlink("config", to: outside.root) + + let report = WorktreeSeedApplier().apply( + plan("config/credentials", in: source), + from: source.root, + to: destination.root + ) + + #expect(report.copied.isEmpty) + #expect(report.failed == [ + WorktreeSeedFailure( + relativePath: "config/credentials", + reason: "destination ancestor is a symlink: config" + ), + ]) + #expect(!FileManager.default.fileExists( + atPath: outside.root.appendingPathComponent("credentials").path + )) + } + @Test func aPathThatLeftTheRepositoryBetweenPlanAndApplyIsReportedNotCrashed() throws { let source = try WorktreeSeedTemporaryTree("source") let destination = try WorktreeSeedTemporaryTree("destination") From 4184a50473b64c7def56c7811ecc4823a1a7c5fa Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Mon, 28 Sep 2026 15:36:15 -0400 Subject: [PATCH 3/4] fix: preserve worktree seed path semantics --- .../WorktreeSeed/WorktreeSeedApplier.swift | 2 +- .../WorktreeSeed/WorktreeSeedPattern.swift | 13 ++++++++++- .../WorktreeSeedPlannerTests.swift | 8 +++++++ .../WorktreeSeedRepositoryTests.swift | 23 +++++++++++++++++++ 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedApplier.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedApplier.swift index b61b60f9aa8a..ac9545d434e2 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedApplier.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedApplier.swift @@ -78,7 +78,7 @@ public struct WorktreeSeedApplier: Sendable { ) continue } - guard fileManager.fileExists(atPath: from.path) else { + guard fileExistsWithoutFollowingLinks(from) else { report.failed.append( WorktreeSeedFailure(relativePath: entry.relativePath, reason: "no longer in the repository") ) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPattern.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPattern.swift index 6580debd8d39..5b5e588ef9e7 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPattern.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPattern.swift @@ -159,7 +159,18 @@ extension WorktreeSeedPattern { } if sawKeyword, isNegated { return problem(.negatedActionKeyword) } - if rest.hasPrefix("\\") { rest = rest.dropFirst() } + if rest.hasPrefix("\\") { + let escaped = rest.dropFirst() + if escaped.isEmpty { return problem(.emptyPattern) } + let escapesParserPrefix = escaped.hasPrefix("!") + || escaped.hasPrefix("#") + || WorktreeSeedAction.allCases.contains { candidate in + guard escaped.hasPrefix(candidate.rawValue) else { return false } + let after = escaped.dropFirst(candidate.rawValue.count) + return after.first == " " || after.first == "\t" + } + if escapesParserPrefix { rest = escaped } + } if rest.hasPrefix("/") { return problem(.leadingSlash) } if rest.hasPrefix("~") { return problem(.homeRelative) } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPlannerTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPlannerTests.swift index 89b0f56b64c4..2278ff0d0e55 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPlannerTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPlannerTests.swift @@ -286,4 +286,12 @@ struct WorktreeSeedPlannerTests { let plan = plan("star\\*name", repository) #expect(plan.entries.map(\.relativePath) == ["star*name"]) } + + @Test func aLeadingEscapedMetacharacterStaysLiteral() { + let repository = WorktreeSeedFakeRepository([ + "*.env", "prod.env", "?.secret", "a.secret", + ]) + let plan = plan("\\*.env\n\\?.secret\n", repository) + #expect(plan.entries.map(\.relativePath) == ["*.env", "?.secret"]) + } } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedRepositoryTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedRepositoryTests.swift index 589bba59cfda..b7c5ae69d7c8 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedRepositoryTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedRepositoryTests.swift @@ -265,6 +265,29 @@ struct WorktreeSeedApplierTests { #expect(report.failed.isEmpty) } + @Test func aDanglingSourceSymlinkIsCopiedAsALinkNode() throws { + let source = try WorktreeSeedTemporaryTree("source") + let destination = try WorktreeSeedTemporaryTree("destination") + try FileManager.default.createSymbolicLink( + atPath: source.root.appendingPathComponent("local-config").path, + withDestinationPath: "missing-config" + ) + + let seedPlan = plan("local-config", in: source) + #expect(seedPlan.copies.map(\.relativePath) == ["local-config"]) + let report = WorktreeSeedApplier().apply( + seedPlan, + from: source.root, + to: destination.root + ) + + #expect(report.copied == ["local-config"]) + #expect(report.failed.isEmpty) + #expect(try FileManager.default.destinationOfSymbolicLink( + atPath: destination.root.appendingPathComponent("local-config").path + ) == "missing-config") + } + @Test func aDestinationSymlinkAncestorCannotRedirectACopy() throws { let source = try WorktreeSeedTemporaryTree("source") let destination = try WorktreeSeedTemporaryTree("destination") From cd05874d3f3e5080922c849b6badefef13935bdf Mon Sep 17 00:00:00 2001 From: teamleaderleo Date: Mon, 28 Sep 2026 15:42:07 -0400 Subject: [PATCH 4/4] fix: report operative worktree seed roots --- .../WorktreeSeed/WorktreeSeedPlanner.swift | 6 +++-- .../WorktreeSeedPlannerTests.swift | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPlanner.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPlanner.swift index 5b722cec56fa..fd64cfcb879b 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPlanner.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/WorktreeSeed/WorktreeSeedPlanner.swift @@ -99,9 +99,10 @@ public struct WorktreeSeedPlanner: Sendable { ) } + var retained: [String: WorktreeSeedEntry] = [:] for path in selected.keys.sorted() { guard let entry = selected[path] else { continue } - if let ancestor = nearestSelectedAncestor(of: path, in: selected) { + if let ancestor = nearestSelectedAncestor(of: path, in: retained) { plan.shadowed.append( WorktreeSeedShadow( relativePath: path, @@ -111,13 +112,14 @@ public struct WorktreeSeedPlanner: Sendable { ) continue } + retained[path] = entry plan.entries.append(entry) } for pattern in file.patterns where pattern.isNegated { for path in (matchesByLine[pattern.line] ?? [:]).keys.sorted() { guard selected[path] == nil else { continue } - guard let ancestor = nearestSelectedAncestor(of: path, in: selected) else { continue } + guard let ancestor = nearestSelectedAncestor(of: path, in: retained) else { continue } plan.ineffectiveNegations.append( WorktreeSeedShadow( relativePath: path, diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPlannerTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPlannerTests.swift index 2278ff0d0e55..157683ba8717 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPlannerTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/WorktreeSeedPlannerTests.swift @@ -205,6 +205,33 @@ struct WorktreeSeedPlannerTests { ]) } + @Test func shadowsAndNegationsNameTheOperativeRetainedRoot() { + let repository = WorktreeSeedFakeRepository([ + "root/", "root/sub/", "root/sub/secret", + ]) + let plan = plan( + "link root\ncopy root/sub\n!root/sub/secret\n", + repository + ) + + #expect(plan.entries.map(\.relativePath) == ["root"]) + #expect(plan.entries.map(\.action) == [.link]) + #expect(plan.shadowed == [ + WorktreeSeedShadow( + relativePath: "root/sub", + coveredBy: "root", + coveringAction: .link + ), + ]) + #expect(plan.ineffectiveNegations == [ + WorktreeSeedShadow( + relativePath: "root/sub/secret", + coveredBy: "root", + coveringAction: .link + ), + ]) + } + @Test func aSymlinkOutOfTheRepositoryIsRefused() { let repository = WorktreeSeedFakeRepository([".env", "shared/"], escaping: ["shared"]) let plan = plan(".env\nshared\n", repository)