diff --git a/README.md b/README.md index bb477ff250..47a76fe0aa 100644 --- a/README.md +++ b/README.md @@ -2059,6 +2059,7 @@ Manage the rewrites with `kcap remap`: ```bash kcap remap ~/dev/eventstore/foo-cli ~/dev/eventstore/bar-cli # add or replace a mapping +kcap remap '~/dev/my-repo/worktrees/*' ~/dev/my-repo # one rule for a family of worktrees kcap remap --list # show all mappings kcap remap --remove ~/dev/eventstore/foo-cli # drop one ``` @@ -2068,12 +2069,22 @@ Entries are stored at the top of `~/.config/kcap/config.json` under `cwd_remap` Semantics: - `from` / `to` are **path-prefix** rewrites with `~` expanding to the current user's home directory (`~\` is also accepted on Windows). The match requires a path boundary (`from` exactly equal, or `from` followed by `/` — or `\` on Windows), so `from: "~/dev/foo"` will **not** spuriously rewrite `~/dev/foo-cli`. +- `from` may contain a single `*` standing as a **whole path segment**, matching exactly one segment of the cwd. It can sit anywhere in the path (`'~/dev/worktrees/*/my-repo'` works as well as a trailing one), and everything past it is preserved, so `'~/dev/my-repo/worktrees/*' → ~/dev/my-repo` rewrites `~/dev/my-repo/worktrees/ai-2441/src` to `~/dev/my-repo/src`. There is no `**`. Quote the pattern — an unquoted `*` is expanded by your shell before kcap sees it. - Comparisons follow the host filesystem's case policy: case-insensitive on Windows, case-sensitive elsewhere. -- When multiple rules could apply to the same transcript cwd, the **longest** `from` wins. +- When multiple rules could apply to the same transcript cwd, the one matching the **most** of it wins, and a literal `from` beats a wildcard `from` reaching just as far — so a single path can be pointed elsewhere without dropping the family rule. - Rules are applied once (no chaining), so the result of one rule isn't fed into another. +- Rules are **unconditional** — they rewrite whether or not the original path still exists. Harmless for a worktree pattern, since a live worktree rewritten to its project root resolves to the same repository, but worth knowing before pointing one at an unrelated directory. +- `--list` and `--remove` take the pattern verbatim, exactly as typed. - Remaps are global, not per-profile — same rename affects all profiles' imports. -After adding a remap, re-run `kcap import --org` (or whichever scope you use). The missing-cwd report at the top of the import will show what's still unresolved. Ephemeral worktree paths under `/./worktrees/` are auto-attributed to `` when it still exists on disk, so deleted-worktree cwds don't need a remap entry. +After adding a remap, re-run `kcap import --org` (or whichever scope you use). The missing-cwd report at the top of the import will show what's still unresolved. Ephemeral worktree paths under `/./worktrees/` are auto-attributed to `` when it still exists on disk, so deleted-worktree cwds don't need a remap entry — a `worktrees/` directory you configured yourself carries no dot segment, so that one does, and the missing-cwd report prints the wildcard rule for it when it sees more than one dead sibling under a repository that still exists: + +``` +17 sessions under ~/dev/my-repo/worktrees/ (4 paths) belong to ~/dev/my-repo, which still exists: + kcap remap '~/dev/my-repo/worktrees/*' ~/dev/my-repo +``` + +Worktrees kept *outside* the project (`~/dev/worktrees//`) can't be suggested — the directory above them is not a repository, so only you know which project they belong to — but the same wildcard covers them: `kcap remap '~/dev/worktrees/my-repo/*' ~/dev/my-repo`. ### Telemetry diff --git a/docs/CHANGES.md b/docs/CHANGES.md index c7c55f1364..002e7a86f0 100644 --- a/docs/CHANGES.md +++ b/docs/CHANGES.md @@ -6,6 +6,39 @@ diff. `CLAUDE.md` holds the invariants; `docs/superpowers/specs/` holds the full Not release notes. Each entry is written as of the change that produced it and is not revised as the code moves on; where an entry disagrees with the code, the code wins. +## One remap rule covers a family of deleted worktrees + +A `from` may carry one `*` standing as a whole path segment, and the cwd's remainder past that +segment is preserved. The literal form could not express this at all: a prefix rewrite keeps the +tail, so `~/dev/repo/worktrees` -> `~/dev/repo` turns `.../worktrees/ai-1` into `~/dev/repo/ai-1`, +which is equally missing, and only an exact `cwd == from` hit uses `to` verbatim. That made it one +entry per dead worktree, growing every time a tree was cleaned up. + +One segment, never `**`. A greedy wildcard is ambiguous about where the preserved tail starts, and +every layout seen so far is one segment deep. A pattern whose `*` is not a whole segment is refused +when it is typed and skipped when it is read back, so a hand-edited config cannot turn one into a +substring match. + +Precedence is the length of the cwd the rule consumed, not the length of the pattern, so a wildcard +beats a shorter literal that it reaches past. A literal wins a tie against a wildcard of equal +reach, which is what keeps one path overridable out of a family it otherwise belongs to. + +Rules stay unconditional, as literal ones already were: they rewrite whether or not the original +path still exists. That is harmless for a worktree pattern, since a live worktree rewritten to its +project root resolves to the same repository, but it is a sharper edge than a literal `from` the +user knows is gone, so `--help` and the README say so. + +`WorktreePathResolver` is untouched. The dot form (`/./worktrees/`) stays +automatic because tooling invents those paths and no user would think to remap them; a `worktrees/` +directory the user configured carries no dot segment, and declaring it is fair. + +Discovery is the other half: a wildcard nobody finds saves nobody, and an under-scoped import is +quiet for months. The missing-cwd report groups dead siblings by their parent and prints the rule +instead of the paths when it sees more than one under a directory whose own parent is a checkout +root. That root test is what makes the suggestion honest — it names the repository the sessions +belong to. Worktrees kept outside the project can't be grouped this way, since the directory above +them is not a repository and only the user knows the mapping. + ## A session's loose ends are declared, and bounded by the server `declare_loose_end` posts one concrete piece of unfinished work — a missing test, a TODO left in the diff --git a/src/Capacitor.Cli.Core/Resources/help-remap.txt b/src/Capacitor.Cli.Core/Resources/help-remap.txt index 20142f5bcf..a9f9154696 100644 --- a/src/Capacitor.Cli.Core/Resources/help-remap.txt +++ b/src/Capacitor.Cli.Core/Resources/help-remap.txt @@ -5,11 +5,11 @@ Usage: kcap remap kcap remap --remove Historic transcripts record an absolute working directory. If you've since -renamed or moved that directory on disk (e.g. ~/dev/foo-cli → ~/dev/bar-cli), -`kcap import --org` / `--repo` can't resolve those sessions to a GitHub repo -because git can no longer be run inside a path that doesn't exist. The -import command prints a one-shot report of missing cwds at the top of every -run; add a remap for each one you want to recover. +renamed, moved or deleted that directory on disk (e.g. ~/dev/foo-cli → +~/dev/bar-cli), `kcap import --org` / `--repo` can't resolve those sessions to +a GitHub repo because git can no longer be run inside a path that doesn't +exist. The import command prints a one-shot report of missing cwds at the top +of every run; add a remap for each one you want to recover. Ephemeral worktree cwds — paths shaped `/./worktrees/`, e.g. `~/dev/my-repo/.claude/worktrees/` — are auto-attributed to @@ -20,12 +20,23 @@ Matching semantics (applied by `kcap import`): - Path-prefix rewrite: `cwd == from` or `cwd starts with from + sep`, where sep is `/` (or `\` on Windows). So `~/dev/foo` will NOT match `~/dev/foo-cli`. + - `from` may contain one `*`, standing as a whole path segment and matching + exactly one segment of the cwd. It can sit anywhere in the path, and + everything past it is preserved, so one rule covers a whole family of + deleted worktrees. Quote it, or the shell expands it before kcap sees it. - `~` (and `~\` on Windows) expand to the current user's home directory at apply time, so entries stay portable across users / hosts. - Comparison follows the host filesystem's case policy: case-insensitive on Windows, case-sensitive elsewhere. - - Longest `from` wins when multiple rules apply. + - The rule matching the most of the cwd wins; a literal `from` beats a + wildcard one reaching just as far, so a single path can be sent somewhere + else without dropping the family rule. - Rules are applied once (no chaining). + - Rules are unconditional: they rewrite whether or not the original path + still exists. That is fine for a worktree pattern, since a live worktree + rewritten to its project root still resolves to the same repository, but + it is worth knowing before pointing one at an unrelated directory. + - `--list` and `--remove` take the pattern verbatim, exactly as typed. Scope: Stored at the **top level** of `~/.config/kcap/config.json` — remaps are @@ -34,5 +45,8 @@ Scope: Examples: kcap remap ~/dev/eventstore/foo-cli ~/dev/eventstore/bar-cli + kcap remap '~/dev/my-repo/worktrees/*' ~/dev/my-repo + kcap remap '~/dev/worktrees/my-repo/*' ~/dev/my-repo + kcap remap '~/dev/worktrees/*/my-repo' ~/dev/my-repo kcap remap --list - kcap remap --remove ~/dev/eventstore/foo-cli + kcap remap --remove '~/dev/my-repo/worktrees/*' diff --git a/src/Capacitor.Cli.Core/Resources/help-usage.txt b/src/Capacitor.Cli.Core/Resources/help-usage.txt index 469b63ca91..3e3d2379f1 100644 --- a/src/Capacitor.Cli.Core/Resources/help-usage.txt +++ b/src/Capacitor.Cli.Core/Resources/help-usage.txt @@ -28,7 +28,7 @@ Configuration: allow Capture only this path (and descendants); empty list allows all allow --list List allowed paths in the active profile allow --remove Remove a path from the allow list - remap Rewrite a recorded transcript cwd prefix during import (renamed/moved repos) + remap Rewrite a recorded transcript cwd during import; takes one '*' segment remap --list List configured cwd remaps remap --remove Remove a cwd remap entry diff --git a/src/Capacitor.Cli/Commands/ImportCommand.cs b/src/Capacitor.Cli/Commands/ImportCommand.cs index 73c503d46f..042647b3aa 100644 --- a/src/Capacitor.Cli/Commands/ImportCommand.cs +++ b/src/Capacitor.Cli/Commands/ImportCommand.cs @@ -2548,17 +2548,34 @@ UserHome home var sessionsAffected = sessionCwds.Values.Count(missing.Contains); var sortedRoots = roots.OrderBy(c => c, StringComparer.Ordinal).ToList(); + var families = DetectWorktreeFamilies(sortedRoots); + var covered = families.SelectMany(f => f.Paths).ToHashSet(StringComparer.Ordinal); + var listed = sortedRoots.Where(r => !covered.Contains(r)).ToList(); + var sessionWord = sessionsAffected == 1 ? "session references" : "sessions reference"; var pathWord = sortedRoots.Count == 1 ? "path that no longer exists" : "distinct paths that no longer exist"; - display.Line($"{sessionsAffected} {sessionWord} {sortedRoots.Count} {pathWord} on disk:"); + display.Line($"{sessionsAffected} {sessionWord} {sortedRoots.Count} {pathWord} on disk{(listed.Count > 0 ? ":" : ".")}"); const int sampleSize = 5; - foreach (var cwd in sortedRoots.Take(sampleSize)) display.Line($" {ShortenHome(cwd, home.Path)}"); + foreach (var cwd in listed.Take(sampleSize)) display.Line($" {ShortenHome(cwd, home.Path)}"); + + if (listed.Count > sampleSize) { + display.Line($" ... and {listed.Count - sampleSize} more"); + } + + foreach (var family in families) { + var sessions = sessionCwds.Values.Count(c => family.Paths.Any(p => IsSelfOrDescendant(c, p))); + var parent = ShortenHome(family.Parent, home.Path); + var project = ShortenHome(family.Project, home.Path); + var sep = SeparatorOf(family.Parent); + var word = sessions == 1 ? "session" : "sessions"; - if (sortedRoots.Count > sampleSize) { - display.Line($" ... and {sortedRoots.Count - sampleSize} more"); + display.Line($"{sessions} {word} under {parent}{sep} ({family.Paths.Count} paths) belong to {project}, which still exists:"); + display.Line($" kcap remap '{parent}{sep}*' {project}"); } + if (listed.Count == 0) return; + var hint = cwdRemap is { Count: > 0 } ? "Run `kcap remap ` to update or add mappings to their new on-disk paths." : "Run `kcap remap ` to map these to their new on-disk paths."; @@ -2566,6 +2583,108 @@ UserHome home display.Line(hint); } + /// + /// Missing paths sharing a parent directory, under two or more slugs, whose + /// own parent is a still-present checkout — the shape a cleaned-up worktree + /// family leaves behind, and the one a single wildcard remap recovers. + /// + internal readonly record struct WorktreeFamily(string Parent, string Project, IReadOnlyList Paths); + + internal static List DetectWorktreeFamilies(IEnumerable missingRoots) => + DetectWorktreeFamilies(missingRoots, IsRepoOnDisk); + + // Internal seam so tests can declare which paths are repositories without + // laying out real git directories. + internal static List DetectWorktreeFamilies(IEnumerable missingRoots, Func isRepo) { + var groups = new Dictionary Paths, HashSet Slugs)>(StringComparer.Ordinal); + var repoCache = new Dictionary(StringComparer.Ordinal); + + foreach (var path in missingRoots) { + if (Locate(path) is not var (parent, project, slug)) continue; + + if (!groups.TryGetValue(parent, out var group)) { + groups[parent] = group = (project, [], new(StringComparer.Ordinal)); + } + + group.Paths.Add(path); + group.Slugs.Add(slug); + } + + var families = new List(); + + foreach (var (parent, group) in groups.OrderBy(kv => kv.Key, StringComparer.Ordinal)) { + // One dead slug is a path to name, not a family to describe — and two + // paths under the same slug are one worktree, not two. + if (group.Slugs.Count < 2) continue; + + families.Add(new(parent, group.Project, group.Paths)); + } + + return families; + + // The session's cwd is not always the worktree root: one recorded in + // /src has to reach past its own subdirectory to find the + // repository, or the family it belongs to never forms. + (string Parent, string Project, string Slug)? Locate(string path) { + var slugPath = path; + var parent = TrimLastSegment(path); + var project = parent is null ? null : TrimLastSegment(parent); + + while (parent is not null && project is not null) { + if (Repo(project)) return (parent, project, LastSegment(slugPath)); + + slugPath = parent; + parent = project; + project = TrimLastSegment(project); + } + + return null; + } + + bool Repo(string path) { + if (repoCache.TryGetValue(path, out var known)) return known; + + return repoCache[path] = isRepo(path); + } + } + + static string LastSegment(string path) { + var parent = TrimLastSegment(path); + + return (parent is null ? path : path[parent.Length..]).Trim('/', '\\'); + } + + /// + /// The separator is written with, so a suggested + /// rule is spelled the way the transcript spelled its cwd. A pattern mixing + /// the two matches nothing: the head is compared literally. + /// + static char SeparatorOf(string path) { + for (var i = path.Length - 1; i >= 0; i--) { + if (CwdRemapper.IsSeparator(path[i])) return path[i]; + } + + return '/'; + } + + /// + /// A checkout root, not merely a path inside one: the suggestion names the + /// repository the sessions belong to, and a non-root parent would produce a + /// rule that points at a directory rather than at the repo. + /// + static bool IsRepoOnDisk(string path) { + if (!Directory.Exists(path)) return false; + + var dotGit = Path.Combine(path, ".git"); + + return Directory.Exists(dotGit) || File.Exists(dotGit); + } + + static bool IsSelfOrDescendant(string path, string ancestor) => + path.Length >= ancestor.Length + && path.StartsWith(ancestor, StringComparison.Ordinal) + && (path.Length == ancestor.Length || CwdRemapper.IsSeparator(path[ancestor.Length])); + /// /// Drop any path from whose parent (at any depth) /// is also in the set. Used to collapse worktree-style descendants like @@ -2582,12 +2701,6 @@ internal static HashSet CollapseDescendants(IReadOnlySet paths) return roots; static bool HasAncestorIn(string path, IReadOnlySet set) { - // Walk parent directories: /a/b/c → /a/b → /a (stop at the first - // segment). Trim the last separator-delimited segment ourselves - // instead of Path.GetDirectoryName, which on Windows normalizes - // '/' → '\' and would break the Ordinal set comparison for - // forward-slash transcript cwds. Both '/' and '\' are - // honored so mixed-style paths collapse consistently on every OS. var parent = TrimLastSegment(path); while (parent is not null) { @@ -2596,27 +2709,33 @@ static bool HasAncestorIn(string path, IReadOnlySet set) { } return false; + } + } - static string? TrimLastSegment(string p) { - var i = p.Length - 1; + /// + /// The parent of , or null once no meaningful ancestor + /// is left ("/foo" → root). Trims the last separator-delimited segment + /// rather than calling Path.GetDirectoryName, which on Windows + /// normalizes '/' → '\' and would break Ordinal comparison against + /// forward-slash transcript cwds. Both separators are honored so + /// mixed-style paths resolve consistently on every OS. + /// + internal static string? TrimLastSegment(string p) { + var i = p.Length - 1; - // Skip trailing separators (e.g. a stray "/a/b/"). - while (i >= 0 && CwdRemapper.IsSeparator(p[i])) i--; + // Skip trailing separators (e.g. a stray "/a/b/"). + while (i >= 0 && CwdRemapper.IsSeparator(p[i])) i--; - // Find the separator that ends the parent segment. - while (i >= 0 && !CwdRemapper.IsSeparator(p[i])) i--; + // Find the separator that ends the parent segment. + while (i >= 0 && !CwdRemapper.IsSeparator(p[i])) i--; - // No separator left, or only a leading-root separator remains - // ("/foo" → root) → no further ancestor to test. - if (i <= 0) return null; + if (i <= 0) return null; - // Collapse any run of separators so "/a//b" trims cleanly to "/a". - var end = i; - while (end > 0 && CwdRemapper.IsSeparator(p[end - 1])) end--; + // Collapse any run of separators so "/a//b" trims cleanly to "/a". + var end = i; + while (end > 0 && CwdRemapper.IsSeparator(p[end - 1])) end--; - return end <= 0 ? null : p[..end]; - } - } + return end <= 0 ? null : p[..end]; } /// diff --git a/src/Capacitor.Cli/Commands/RemapCommand.cs b/src/Capacitor.Cli/Commands/RemapCommand.cs index a907c8a2f8..37c7b90935 100644 --- a/src/Capacitor.Cli/Commands/RemapCommand.cs +++ b/src/Capacitor.Cli/Commands/RemapCommand.cs @@ -5,10 +5,13 @@ namespace Capacitor.Cli.Commands; /// -/// CLI surface for managing — the -/// path-prefix rewrites that kcap import applies before repository -/// detection, so historic transcripts referencing since-renamed local repo -/// directories can still match an --org/--repo scope. +/// CLI surface for managing — the path +/// rewrites that kcap import applies before repository detection, so +/// historic transcripts referencing since-renamed or since-deleted local repo +/// directories can still match an --org/--repo scope. A +/// from is a literal prefix or carries one * segment; either way +/// it is stored and matched verbatim, so --list and --remove +/// name the pattern as typed. /// /// Entries are stored at the top of ~/.config/kcap/config.json /// (global, not per-profile) — the same rename affects every profile's @@ -40,16 +43,20 @@ public async Task HandleAsync(string[] args) { return 1; case "--remove": - return await Remove(args[2]); + return args.Length > 3 + ? await TooManyArguments("--remove takes exactly one path") + : await Remove(args[2]); default: if (args.Length < 3) return Usage(); - return await Add(args[1], args[2]); + return args.Length > 3 + ? await TooManyArguments("takes exactly two paths") + : await Add(args[1], args[2]); } } async Task Add(string from, string to) { - if (!TryNormalize(from, out var nFrom, out var fromError)) { + if (!TryNormalizeFrom(from, out var nFrom, out var fromError)) { await Console.Error.WriteLineAsync($"Invalid from path '{from}': {fromError}"); return 1; @@ -61,6 +68,13 @@ async Task Add(string from, string to) { return 1; } + if (nTo.Contains('*')) { + await Console.Error.WriteLineAsync( + $"Invalid to path '{to}': '*' matches in the from path only; the to path is literal"); + + return 1; + } + var replaced = false; await ConfigMutator.MutateAsync(root, c => { @@ -76,6 +90,9 @@ await Console.Out.WriteLineAsync(replaced return 0; } + // Removal is keyed by the stored string, and deliberately skips the + // wildcard grammar: an entry hand-written into the config that no import + // can use still has to be removable through the CLI. async Task Remove(string from) { if (!TryNormalize(from, out var nFrom, out var error)) { await Console.Error.WriteLineAsync($"Invalid from path '{from}': {error}"); @@ -165,6 +182,20 @@ internal static CwdRemap[] ApplyRemove(CwdRemap[]? current, string from, StringC static bool SameFrom(string stored, string input, StringComparison comparison) => string.Equals(Normalize(stored), Normalize(input), comparison); + /// + /// plus the wildcard grammar, so a pattern that + /// could never match is rejected at the point it is typed rather than + /// silently ignored by every import that reads it back. + /// + internal static bool TryNormalizeFrom(string path, out string normalized, out string error) { + if (!TryNormalize(path, out normalized, out error)) return false; + if (CwdRemapper.TryParseFrom(normalized, out _, out error)) return true; + + normalized = ""; + + return false; + } + static bool TryNormalize(string path, out string normalized, out string error) { var n = Normalize(path); @@ -199,10 +230,27 @@ internal static string Normalize(string? path) { return trimmed[..end]; } + /// + /// The extra arguments are usually a pattern the shell expanded, in which + /// case no * survives into args to detect it by — so the + /// quoting advice is offered rather than diagnosed. Taking the first two + /// arguments anyway would silently store, or remove, one worktree's rule. + /// + static async Task TooManyArguments(string expected) { + await Console.Error.WriteLineAsync($"Too many arguments: kcap remap {expected}."); + await Console.Error.WriteLineAsync("If you meant a wildcard pattern, quote it so the shell doesn't expand it:"); + await Console.Error.WriteLineAsync(" kcap remap '~/dev/repo/worktrees/*' ~/dev/repo"); + + return 1; + } + static int Usage() { Console.Error.WriteLine("Usage: kcap remap "); Console.Error.WriteLine(" kcap remap --list"); Console.Error.WriteLine(" kcap remap --remove "); + Console.Error.WriteLine(); + Console.Error.WriteLine(" may carry one '*' standing for a single path segment:"); + Console.Error.WriteLine(" kcap remap '~/dev/repo/worktrees/*' ~/dev/repo"); return 1; } diff --git a/src/Capacitor.Cli/CwdRemapper.cs b/src/Capacitor.Cli/CwdRemapper.cs index 632178391c..8368081f77 100644 --- a/src/Capacitor.Cli/CwdRemapper.cs +++ b/src/Capacitor.Cli/CwdRemapper.cs @@ -5,7 +5,7 @@ namespace Capacitor.Cli; /// -/// Applies user-configured path-prefix remaps to a transcript cwd so historic +/// Applies user-configured path remaps to a transcript cwd so historic /// sessions that recorded a since-renamed local repo path (e.g. /// ~/dev/kapacitor-cli after rename to ~/dev/kcap-cli) can still /// be matched to an on-disk git repository during kcap import. @@ -17,9 +17,14 @@ namespace Capacitor.Cli; /// - Path-boundary prefix: cwd == from or cwd starts with from + sep /// where sep is either / or \; so /dev/kapacitor /// does NOT spuriously match /dev/kapacitor-cli. +/// - from may carry one * standing as a whole segment, which +/// matches exactly one segment of the cwd — ~/dev/repo/worktrees/* +/// covers every worktree under that directory. /// - Comparisons are case-insensitive on Windows, case-sensitive elsewhere — /// matching the host filesystem's behavior. -/// - Longest from wins when multiple rules could apply. +/// - The rule consuming the most of the cwd wins; a literal from beats a +/// wildcard one that consumed the same amount, so a single path can be +/// overridden out of a family. /// - At most one remap is applied (no chaining), to keep behavior predictable. /// static class CwdRemapper { @@ -36,33 +41,98 @@ public static string Apply(string cwd, IReadOnlyList? rules, UserHome internal static string Apply(string cwd, IReadOnlyList? rules, string home, StringComparison comparison) { if (rules is null or { Count: 0 } || string.IsNullOrEmpty(cwd)) return cwd; - CwdRemap? best = null; - string? bestFrom = null; - string? bestTo = null; - var bestLen = -1; + string? bestTo = null; + var bestLen = -1; + var bestLiteral = false; foreach (var rule in rules) { if (string.IsNullOrEmpty(rule.From) || rule.To is null) continue; + if (!TryParseFrom(ExpandHome(rule.From, home), out var pattern, out _)) continue; - var from = ExpandHome(rule.From, home); + var matched = pattern.Match(cwd, comparison); - if (!IsPrefixMatch(cwd, from, comparison)) continue; + if (matched < 0) continue; - if (from.Length > bestLen) { - best = rule; - bestFrom = from; - bestTo = ExpandHome(rule.To, home); - bestLen = from.Length; - } + var literal = !pattern.HasWildcard; + + // Same reach: the literal rule is the more specific statement, so it + // stays usable as an override for one path inside a wildcard family. + var better = matched > bestLen || (matched == bestLen && literal && !bestLiteral); + + if (!better) continue; + + bestTo = ExpandHome(rule.To, home); + bestLen = matched; + bestLiteral = literal; + } + + if (bestTo is null) return cwd; + + return bestLen == cwd.Length ? bestTo : bestTo + cwd[bestLen..]; + } + + /// + /// A from pattern split around its wildcard: is the + /// literal text before the * and the literal text + /// after it, or null when the pattern carries no wildcard. + /// + internal readonly record struct FromPattern(string Head, string? Tail) { + public bool HasWildcard => Tail is not null; + + /// + /// How many characters of this pattern consumes, + /// or -1 when it does not match. The caller keeps the remainder. + /// + public int Match(string cwd, StringComparison comparison) { + if (Tail is null) return IsPrefixMatch(cwd, Head, comparison) ? Head.Length : -1; + if (!cwd.StartsWith(Head, comparison)) return -1; + + var segEnd = Head.Length; + while (segEnd < cwd.Length && !IsSeparator(cwd[segEnd])) segEnd++; + + if (segEnd == Head.Length) return -1; // the wildcard matches one segment, never none + if (Tail.Length == 0) return segEnd; + + var end = segEnd + Tail.Length; + + if (end > cwd.Length) return -1; + if (!cwd.AsSpan(segEnd, Tail.Length).Equals(Tail, comparison)) return -1; + + return end == cwd.Length || IsSeparator(cwd[end]) ? end : -1; + } + } + + /// + /// Parse a from into , rejecting a wildcard + /// that does not stand as a whole segment. error is a user-facing + /// sentence and is only set when parsing fails. + /// + internal static bool TryParseFrom(string from, out FromPattern pattern, out string error) { + pattern = new(from, null); + error = ""; + + var star = from.IndexOf('*'); + + if (star < 0) return true; + + if (from.IndexOf('*', star + 1) >= 0) { + error = "only one '*' is supported, and it matches a single path segment"; + + return false; + } + + var head = from[..star]; + var tail = from[(star + 1)..]; + + if (head.Length == 0 || !IsSeparator(head[^1]) || (tail.Length > 0 && !IsSeparator(tail[0]))) { + error = "'*' must stand alone as a path segment, e.g. ~/dev/repo/worktrees/*"; + + return false; } - if (best is null) return cwd; + pattern = new(head, tail); - // cwd == from → use 'to' verbatim; otherwise replace prefix + keep the - // tail (the leading separator of the tail was at position from.Length). - return cwd.Length == bestFrom!.Length - ? bestTo! - : bestTo + cwd[bestFrom.Length..]; + return true; } static string ExpandHome(string path, string home) { diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/ImportMissingCwdsReportTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/ImportMissingCwdsReportTests.cs index 3a65e170ce..bcc3a5e9e2 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/ImportMissingCwdsReportTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/ImportMissingCwdsReportTests.cs @@ -8,6 +8,10 @@ public class ImportMissingCwdsReportTests { // Every path reported below is outside this home, so the ~ shortening never fires. static readonly UserHome Home = new("/no/such/home"); + // A suggested rule is spelled with the separator the cwd was recorded with, + // which under a TempDir is the host's. + static readonly char Sep = Path.DirectorySeparatorChar; + [Test, NotInParallel] public async Task Reports_missing_cwds_with_session_count_and_sample() { using var tmp = new TempDir(); @@ -166,6 +170,149 @@ public async Task Truncates_sample_to_five_distinct_paths() { await Assert.That(output).Contains("... and 2 more"); } + [Test] + public async Task DetectWorktreeFamilies_groups_dead_siblings_under_their_repository() { + var families = ImportCommand.DetectWorktreeFamilies( + ["/dev/repo/worktrees/ai-1", "/dev/repo/worktrees/ai-2"], + isRepo: p => p == "/dev/repo"); + + await Assert.That(families).Count().IsEqualTo(1); + await Assert.That(families[0].Parent).IsEqualTo("/dev/repo/worktrees"); + await Assert.That(families[0].Project).IsEqualTo("/dev/repo"); + await Assert.That(families[0].Paths).Count().IsEqualTo(2); + } + + [Test] + public async Task DetectWorktreeFamilies_needs_more_than_one_sibling() { + var families = ImportCommand.DetectWorktreeFamilies( + ["/dev/repo/worktrees/ai-1"], + isRepo: p => p == "/dev/repo"); + + await Assert.That(families).IsEmpty(); + } + + [Test] + public async Task DetectWorktreeFamilies_reaches_past_a_subdirectory_the_session_ran_in() { + // A cwd of /src still belongs to the family its worktree does. + var families = ImportCommand.DetectWorktreeFamilies( + ["/dev/repo/worktrees/ai-1/src", "/dev/repo/worktrees/ai-2/test"], + isRepo: p => p == "/dev/repo"); + + await Assert.That(families).Count().IsEqualTo(1); + await Assert.That(families[0].Parent).IsEqualTo("/dev/repo/worktrees"); + await Assert.That(families[0].Project).IsEqualTo("/dev/repo"); + } + + [Test] + public async Task DetectWorktreeFamilies_counts_slugs_not_paths() { + // Two dead directories inside ONE worktree are one slug, so there is no + // family to describe — naming the two paths is the honest report. + var families = ImportCommand.DetectWorktreeFamilies( + ["/dev/repo/worktrees/ai-1/src", "/dev/repo/worktrees/ai-1/test"], + isRepo: p => p == "/dev/repo"); + + await Assert.That(families).IsEmpty(); + } + + [Test] + public async Task DetectWorktreeFamilies_keeps_the_recorded_separator_on_windows_paths() { + var families = ImportCommand.DetectWorktreeFamilies( + [@"C:\dev\repo\worktrees\ai-1", @"C:\dev\repo\worktrees\ai-2"], + isRepo: p => p == @"C:\dev\repo"); + + await Assert.That(families).Count().IsEqualTo(1); + await Assert.That(families[0].Parent).IsEqualTo(@"C:\dev\repo\worktrees"); + } + + [Test] + public async Task DetectWorktreeFamilies_skips_a_parent_that_is_not_under_a_repository() { + // ~/dev/worktrees//: the directory above the family is + // not a repo, so only the user knows what these belong to. + var families = ImportCommand.DetectWorktreeFamilies( + ["/dev/worktrees/capacitor/a", "/dev/worktrees/capacitor/b"], + isRepo: _ => false); + + await Assert.That(families).IsEmpty(); + } + + [Test, NotInParallel] + public async Task Report_suggests_a_wildcard_rule_instead_of_listing_the_family() { + using var tmp = new TempDir(); + var repo = tmp.CreateDir("repo"); + + repo.CreateDir(".git"); + + var sessionCwds = new Dictionary(StringComparer.Ordinal) { + ["s1"] = repo.PathTo("worktrees", "ai-1"), + ["s2"] = repo.PathTo("worktrees", "ai-1"), + ["s3"] = repo.PathTo("worktrees", "ai-2"), + }; + + var output = Capture(d => ImportCommand.ReportMissingCwds(sessionCwds, cwdRemap: null, d, Home)); + var family = repo.PathTo("worktrees") + Sep; + + await Assert.That(output).Contains($"3 sessions under {family} (2 paths) belong to {repo.Path}, which still exists:"); + await Assert.That(output).Contains($" kcap remap '{family}*' {repo.Path}"); + // The family stands for its members, so they are not also listed, and + // the generic hint would only repeat the command already printed. + await Assert.That(output).DoesNotContain($" {repo.PathTo("worktrees", "ai-1")}"); + await Assert.That(output).DoesNotContain("Run `kcap remap `"); + } + + [Test, NotInParallel] + public async Task Report_suggests_a_rule_for_a_session_that_ran_below_its_worktree() { + using var tmp = new TempDir(); + var repo = tmp.CreateDir("repo"); + + repo.CreateDir(".git"); + + var sessionCwds = new Dictionary(StringComparer.Ordinal) { + ["s1"] = repo.PathTo("worktrees", "ai-1", "src"), + ["s2"] = repo.PathTo("worktrees", "ai-2", "src"), + }; + + var output = Capture(d => ImportCommand.ReportMissingCwds(sessionCwds, cwdRemap: null, d, Home)); + + await Assert.That(output).Contains($" kcap remap '{repo.PathTo("worktrees")}{Sep}*' {repo.Path}"); + } + + [Test, NotInParallel] + public async Task Report_still_lists_paths_outside_any_family() { + using var tmp = new TempDir(); + var repo = tmp.CreateDir("repo"); + + repo.CreateDir(".git"); + + var sessionCwds = new Dictionary(StringComparer.Ordinal) { + ["s1"] = repo.PathTo("worktrees", "ai-1"), + ["s2"] = repo.PathTo("worktrees", "ai-2"), + ["s3"] = "/does/not/exist/repo-b", + }; + + var output = Capture(d => ImportCommand.ReportMissingCwds(sessionCwds, cwdRemap: null, d, Home)); + + await Assert.That(output).Contains("3 sessions reference 3 distinct paths that no longer exist on disk:"); + await Assert.That(output).Contains(" /does/not/exist/repo-b"); + await Assert.That(output).Contains($" kcap remap '{repo.PathTo("worktrees")}{Sep}*' {repo.Path}"); + await Assert.That(output).Contains("Run `kcap remap `"); + } + + [Test, NotInParallel] + public async Task Report_lists_a_family_whose_project_is_not_a_repository() { + using var tmp = new TempDir(); + var plain = tmp.CreateDir("plain"); + + var sessionCwds = new Dictionary(StringComparer.Ordinal) { + ["s1"] = plain.PathTo("worktrees", "ai-1"), + ["s2"] = plain.PathTo("worktrees", "ai-2"), + }; + + var output = Capture(d => ImportCommand.ReportMissingCwds(sessionCwds, cwdRemap: null, d, Home)); + + await Assert.That(output).DoesNotContain("kcap remap '"); + await Assert.That(output).Contains($" {plain.PathTo("worktrees", "ai-1")}"); + } + static string Capture(Action render) { using var capture = ConsoleOutput.StartCapture(); render(new() { Tty = false }); diff --git a/test/Capacitor.Cli.Tests.Unit/Commands/RemapCommandTests.cs b/test/Capacitor.Cli.Tests.Unit/Commands/RemapCommandTests.cs index 3b2a370eec..c2790dfba1 100644 --- a/test/Capacitor.Cli.Tests.Unit/Commands/RemapCommandTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/Commands/RemapCommandTests.cs @@ -1,4 +1,5 @@ using Capacitor.Cli.Commands; +using Capacitor.Cli.Core; using Capacitor.Cli.Core.Config; namespace Capacitor.Cli.Tests.Unit.Commands; @@ -101,6 +102,113 @@ public async Task Normalize_trims_whitespace_around_path() { await Assert.That(RemapCommand.Normalize(" /a/b ")).IsEqualTo("/a/b"); } + [Test, NotInParallel] + public async Task Add_refuses_extra_arguments_rather_than_storing_the_first_two() { + using var tmp = new TempDir(); + int exit; + + using (var capture = ConsoleOutput.StartErrorCapture()) { + // What an unquoted '~/dev/repo/worktrees/*' looks like once the shell + // has expanded it — the '*' is already gone by the time we see it. + exit = await new RemapCommand(new(tmp.Path)) + .HandleAsync(["remap", "/dev/repo/worktrees/ai-1", "/dev/repo/worktrees/ai-2", "/dev/repo"]); + + await Assert.That(capture.GetCapturedError()).Contains("takes exactly two paths"); + } + + await Assert.That(exit).IsEqualTo(1); + await Assert.That((await AppConfig.LoadProfileConfig(new(tmp.Path))).CwdRemap ?? []).IsEmpty(); + } + + [Test, NotInParallel] + public async Task Remove_refuses_extra_arguments_rather_than_dropping_the_first() { + using var tmp = new TempDir(); + var root = new ConfigRoot(tmp.Path); + + await new RemapCommand(root).HandleAsync(["remap", "/dev/repo/worktrees/ai-1", "/dev/repo"]); + + int exit; + + using (var capture = ConsoleOutput.StartErrorCapture()) { + exit = await new RemapCommand(root) + .HandleAsync(["remap", "--remove", "/dev/repo/worktrees/ai-1", "/dev/repo/worktrees/ai-2"]); + + await Assert.That(capture.GetCapturedError()).Contains("takes exactly one path"); + } + + await Assert.That(exit).IsEqualTo(1); + await Assert.That((await AppConfig.LoadProfileConfig(root)).CwdRemap!).Count().IsEqualTo(1); + } + + [Test, NotInParallel] + public async Task Add_refuses_a_wildcard_in_the_to_path() { + using var tmp = new TempDir(); + int exit; + + using (var capture = ConsoleOutput.StartErrorCapture()) { + exit = await new RemapCommand(new(tmp.Path)).HandleAsync(["remap", "/dev/repo", "/dev/*"]); + + await Assert.That(capture.GetCapturedError()).Contains("the to path is literal"); + } + + await Assert.That(exit).IsEqualTo(1); + } + + [Test, NotInParallel] + public async Task Remove_drops_an_entry_the_matcher_would_refuse() { + // A hand-edited config can hold a pattern no import can use; the CLI is + // the way back out, so removal must not apply the wildcard grammar. + using var tmp = new TempDir(); + var root = new ConfigRoot(tmp.Path); + + tmp.CreateFile( + Path.GetFileName(AppConfig.GetConfigPath(root)), + """{"profiles":{},"cwd_remap":[{"from":"/dev/wt-*","to":"/dev/repo"}]}"""); + + var exit = await new RemapCommand(root).HandleAsync(["remap", "--remove", "/dev/wt-*"]); + + await Assert.That(exit).IsEqualTo(0); + await Assert.That((await AppConfig.LoadProfileConfig(root)).CwdRemap ?? []).IsEmpty(); + } + + [Test, NotInParallel] + public async Task List_shows_a_stored_pattern_verbatim() { + using var tmp = new TempDir(); + var root = new ConfigRoot(tmp.Path); + + await new RemapCommand(root).HandleAsync(["remap", "~/dev/repo/worktrees/*", "~/dev/repo"]); + + using var capture = ConsoleOutput.StartCapture(); + + await new RemapCommand(root).HandleAsync(["remap", "--list"]); + + await Assert.That(capture.GetCapturedOutput()).Contains("~/dev/repo/worktrees/* → ~/dev/repo"); + } + + [Test] + public async Task TryNormalizeFrom_accepts_a_wildcard_segment() { + await Assert.That(RemapCommand.TryNormalizeFrom(" ~/dev/repo/worktrees/* ", out var from, out _)).IsTrue(); + await Assert.That(from).IsEqualTo("~/dev/repo/worktrees/*"); + } + + [Test] + public async Task TryNormalizeFrom_trims_a_trailing_separator_after_the_wildcard() { + await Assert.That(RemapCommand.TryNormalizeFrom("~/dev/repo/worktrees/*/", out var from, out _)).IsTrue(); + await Assert.That(from).IsEqualTo("~/dev/repo/worktrees/*"); + } + + [Test] + public async Task TryNormalizeFrom_rejects_a_partial_segment_wildcard() { + await Assert.That(RemapCommand.TryNormalizeFrom("~/dev/wt-*", out _, out var error)).IsFalse(); + await Assert.That(error).Contains("stand alone as a path segment"); + } + + [Test] + public async Task TryNormalizeFrom_rejects_two_wildcards() { + await Assert.That(RemapCommand.TryNormalizeFrom("~/dev/*/worktrees/*", out _, out var error)).IsFalse(); + await Assert.That(error).Contains("only one '*'"); + } + [Test] public async Task ApplyAdd_with_OrdinalIgnoreCase_replaces_case_variant_entry() { // On Windows, "C:\Users\Alice" and "c:\users\alice" refer to the same diff --git a/test/Capacitor.Cli.Tests.Unit/CwdRemapperTests.cs b/test/Capacitor.Cli.Tests.Unit/CwdRemapperTests.cs index 5b66027602..3f3964d7f1 100644 --- a/test/Capacitor.Cli.Tests.Unit/CwdRemapperTests.cs +++ b/test/Capacitor.Cli.Tests.Unit/CwdRemapperTests.cs @@ -131,6 +131,197 @@ public async Task Apply_expands_bare_tilde_in_from() { await Assert.That(result).IsEqualTo("/elsewhere/x"); } + [Test] + public async Task Apply_wildcard_segment_rewrites_to_verbatim() { + var rules = new[] { R("~/dev/repo/worktrees/*", "~/dev/repo") }; + var result = CwdRemapper.Apply("/home/u/dev/repo/worktrees/ai-2441", rules, new("/home/u")); + await Assert.That(result).IsEqualTo("/home/u/dev/repo"); + } + + [Test] + public async Task Apply_wildcard_segment_preserves_tail() { + var rules = new[] { R("~/dev/repo/worktrees/*", "~/dev/repo") }; + var result = CwdRemapper.Apply("/home/u/dev/repo/worktrees/ai-2441/src/Foo", rules, new("/home/u")); + await Assert.That(result).IsEqualTo("/home/u/dev/repo/src/Foo"); + } + + [Test] + public async Task Apply_wildcard_does_not_match_a_missing_segment() { + // The parent directory itself is not one of the family. + var rules = new[] { R("/dev/repo/worktrees/*", "/dev/repo") }; + var result = CwdRemapper.Apply("/dev/repo/worktrees", rules, new("/home/u")); + await Assert.That(result).IsEqualTo("/dev/repo/worktrees"); + } + + [Test] + public async Task Apply_wildcard_does_not_match_an_empty_segment() { + var rules = new[] { R("/dev/repo/worktrees/*", "/dev/repo") }; + var result = CwdRemapper.Apply("/dev/repo/worktrees/", rules, new("/home/u")); + await Assert.That(result).IsEqualTo("/dev/repo/worktrees/"); + } + + [Test] + public async Task Apply_interior_wildcard_matches_one_segment_and_keeps_the_tail() { + var rules = new[] { R("/dev/worktrees/*/server", "/dev/server") }; + var result = CwdRemapper.Apply("/dev/worktrees/capacitor/server/src", rules, new("/home/u")); + await Assert.That(result).IsEqualTo("/dev/server/src"); + } + + [Test] + public async Task Apply_wildcard_spans_exactly_one_segment() { + // '*' must not swallow "capacitor/nested" to reach the literal tail. + var rules = new[] { R("/dev/worktrees/*/server", "/dev/server") }; + var result = CwdRemapper.Apply("/dev/worktrees/capacitor/nested/server", rules, new("/home/u")); + await Assert.That(result).IsEqualTo("/dev/worktrees/capacitor/nested/server"); + } + + [Test] + public async Task Apply_wildcard_tail_requires_a_path_boundary() { + var rules = new[] { R("/dev/worktrees/*/server", "/dev/server") }; + var result = CwdRemapper.Apply("/dev/worktrees/capacitor/server-cli", rules, new("/home/u")); + await Assert.That(result).IsEqualTo("/dev/worktrees/capacitor/server-cli"); + } + + [Test] + public async Task Apply_wildcard_reaches_further_than_a_shorter_literal() { + var rules = new[] { + R("/dev/repo", "/dev/wrong"), + R("/dev/repo/worktrees/*", "/dev/repo"), + }; + + var result = CwdRemapper.Apply("/dev/repo/worktrees/ai-1/src", rules, new("/home/u")); + await Assert.That(result).IsEqualTo("/dev/repo/src"); + } + + [Test] + public async Task Apply_literal_beats_a_wildcard_reaching_equally_far() { + // The one-off override has to survive the family rule, whichever order + // the two were added in. + var rules = new[] { + R("/dev/repo/worktrees/*", "/dev/repo"), + R("/dev/repo/worktrees/ai-1", "/dev/elsewhere"), + }; + + await Assert.That(CwdRemapper.Apply("/dev/repo/worktrees/ai-1/src", rules, new("/home/u"))) + .IsEqualTo("/dev/elsewhere/src"); + + await Assert.That(CwdRemapper.Apply("/dev/repo/worktrees/ai-1/src", rules.Reverse().ToArray(), new("/home/u"))) + .IsEqualTo("/dev/elsewhere/src"); + } + + [Test] + public async Task Apply_prefers_the_wildcard_rule_that_reaches_further() { + var rules = new[] { + R("/dev/worktrees/*", "/dev/shallow"), + R("/dev/worktrees/*/repo", "/dev/repo"), + }; + + await Assert.That(CwdRemapper.Apply("/dev/worktrees/capacitor/repo/src", rules, new("/home/u"))) + .IsEqualTo("/dev/repo/src"); + + // ...and falls back to the shallower one where the deeper tail is absent. + await Assert.That(CwdRemapper.Apply("/dev/worktrees/capacitor/other", rules, new("/home/u"))) + .IsEqualTo("/dev/shallow/other"); + } + + [Test] + public async Task Apply_matches_a_wildcard_head_written_with_the_other_separator() { + // A transcript recorded on Windows against a rule typed with '/': the + // head has to fail cleanly rather than half-match into the segment scan. + var rules = new[] { R("/dev/repo/worktrees/*", "/dev/repo") }; + var result = CwdRemapper.Apply(@"\dev\repo\worktrees\ai-1", rules, "/home/u", StringComparison.Ordinal); + await Assert.That(result).IsEqualTo(@"\dev\repo\worktrees\ai-1"); + } + + [Test] + public async Task Apply_skips_a_rule_whose_wildcard_is_glued_to_a_following_literal() { + var rules = new[] { R("/dev/*-tree/src", "/dev/repo") }; + var result = CwdRemapper.Apply("/dev/ai-tree/src", rules, new("/home/u")); + await Assert.That(result).IsEqualTo("/dev/ai-tree/src"); + } + + [Test] + public async Task Apply_skips_a_rule_whose_wildcard_is_not_a_whole_segment() { + var rules = new[] { R("/dev/wt-*", "/dev/repo") }; + var result = CwdRemapper.Apply("/dev/wt-ai-1/src", rules, new("/home/u")); + await Assert.That(result).IsEqualTo("/dev/wt-ai-1/src"); + } + + [Test] + public async Task Apply_skips_a_rule_with_two_wildcards() { + var rules = new[] { R("/dev/*/worktrees/*", "/dev/repo") }; + var result = CwdRemapper.Apply("/dev/repo/worktrees/ai-1", rules, new("/home/u")); + await Assert.That(result).IsEqualTo("/dev/repo/worktrees/ai-1"); + } + + [Test] + public async Task Apply_wildcard_matches_at_backslash_boundaries() { + var rules = new[] { R(@"C:\dev\repo\worktrees\*", @"C:\dev\repo") }; + var result = CwdRemapper.Apply(@"C:\dev\repo\worktrees\ai-1\src", rules, "/home/u", StringComparison.OrdinalIgnoreCase); + await Assert.That(result).IsEqualTo(@"C:\dev\repo\src"); + } + + [Test] + public async Task Apply_wildcard_follows_the_case_policy() { + var rules = new[] { R(@"C:\Dev\Repo\Worktrees\*", @"C:\Dev\Repo") }; + + await Assert.That(CwdRemapper.Apply(@"c:\dev\repo\worktrees\ai-1", rules, "/home/u", StringComparison.OrdinalIgnoreCase)) + .IsEqualTo(@"C:\Dev\Repo"); + + await Assert.That(CwdRemapper.Apply(@"c:\dev\repo\worktrees\ai-1", rules, "/home/u", StringComparison.Ordinal)) + .IsEqualTo(@"c:\dev\repo\worktrees\ai-1"); + } + + [Test] + public async Task TryParseFrom_accepts_a_literal_path() { + await Assert.That(CwdRemapper.TryParseFrom("/dev/repo", out var pattern, out _)).IsTrue(); + await Assert.That(pattern.HasWildcard).IsFalse(); + } + + [Test] + public async Task TryParseFrom_accepts_a_trailing_wildcard_segment() { + await Assert.That(CwdRemapper.TryParseFrom("/dev/repo/worktrees/*", out var pattern, out _)).IsTrue(); + await Assert.That(pattern.HasWildcard).IsTrue(); + await Assert.That(pattern.Head).IsEqualTo("/dev/repo/worktrees/"); + await Assert.That(pattern.Tail).IsEqualTo(""); + } + + [Test] + public async Task TryParseFrom_accepts_an_interior_wildcard_segment() { + await Assert.That(CwdRemapper.TryParseFrom("/dev/worktrees/*/server", out var pattern, out _)).IsTrue(); + await Assert.That(pattern.Tail).IsEqualTo("/server"); + } + + [Test] + public async Task TryParseFrom_rejects_a_partial_segment_wildcard() { + await Assert.That(CwdRemapper.TryParseFrom("/dev/wt-*", out _, out var error)).IsFalse(); + await Assert.That(error).Contains("stand alone as a path segment"); + } + + [Test] + public async Task TryParseFrom_rejects_a_wildcard_glued_to_a_following_literal() { + await Assert.That(CwdRemapper.TryParseFrom("/dev/*-tree/src", out _, out var error)).IsFalse(); + await Assert.That(error).Contains("stand alone as a path segment"); + } + + [Test] + public async Task TryParseFrom_rejects_a_leading_wildcard() { + await Assert.That(CwdRemapper.TryParseFrom("*/worktrees", out _, out var error)).IsFalse(); + await Assert.That(error).Contains("stand alone as a path segment"); + } + + [Test] + public async Task TryParseFrom_rejects_more_than_one_wildcard() { + await Assert.That(CwdRemapper.TryParseFrom("/dev/*/worktrees/*", out _, out var error)).IsFalse(); + await Assert.That(error).Contains("only one '*'"); + } + + [Test] + public async Task TryParseFrom_rejects_a_double_star() { + await Assert.That(CwdRemapper.TryParseFrom("/dev/repo/**", out _, out var error)).IsFalse(); + await Assert.That(error).Contains("only one '*'"); + } + [Test] public async Task Apply_does_not_expand_tilde_username_form() { // "~alice" is the ~user form; we don't expand it. Since the resulting