Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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 `<project>/.<anything>/worktrees/<slug>` are auto-attributed to `<project>` 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 `<project>/.<anything>/worktrees/<slug>` are auto-attributed to `<project>` 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/<project>/<tree>`) 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

Expand Down
33 changes: 33 additions & 0 deletions docs/CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<project>/.<dotseg>/worktrees/<slug>`) 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
Expand Down
28 changes: 21 additions & 7 deletions src/Capacitor.Cli.Core/Resources/help-remap.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ Usage: kcap remap <from> <to>
kcap remap --remove <from>

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 `<project>/.<anything>/worktrees/<slug>`,
e.g. `~/dev/my-repo/.claude/worktrees/<slug>` — are auto-attributed to
Expand All @@ -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
Expand All @@ -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/*'
2 changes: 1 addition & 1 deletion src/Capacitor.Cli.Core/Resources/help-usage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Configuration:
allow <path> Capture only this path (and descendants); empty list allows all
allow --list List allowed paths in the active profile
allow --remove <path> Remove a path from the allow list
remap <from> <to> Rewrite a recorded transcript cwd prefix during import (renamed/moved repos)
remap <from> <to> Rewrite a recorded transcript cwd during import; <from> takes one '*' segment
remap --list List configured cwd remaps
remap --remove <from> Remove a cwd remap entry

Expand Down
169 changes: 144 additions & 25 deletions src/Capacitor.Cli/Commands/ImportCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2548,24 +2548,143 @@ 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 <from> <to>` to update or add mappings to their new on-disk paths."
: "Run `kcap remap <from> <to>` to map these to their new on-disk paths.";

display.Line(hint);
}

/// <summary>
/// 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.
/// </summary>
internal readonly record struct WorktreeFamily(string Parent, string Project, IReadOnlyList<string> Paths);

internal static List<WorktreeFamily> DetectWorktreeFamilies(IEnumerable<string> missingRoots) =>
DetectWorktreeFamilies(missingRoots, IsRepoOnDisk);

// Internal seam so tests can declare which paths are repositories without
// laying out real git directories.
internal static List<WorktreeFamily> DetectWorktreeFamilies(IEnumerable<string> missingRoots, Func<string, bool> isRepo) {
var groups = new Dictionary<string, (string Project, List<string> Paths, HashSet<string> Slugs)>(StringComparer.Ordinal);
var repoCache = new Dictionary<string, bool>(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<WorktreeFamily>();

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
// <worktree>/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('/', '\\');
}

/// <summary>
/// The separator <paramref name="path"/> 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.
/// </summary>
static char SeparatorOf(string path) {
for (var i = path.Length - 1; i >= 0; i--) {
if (CwdRemapper.IsSeparator(path[i])) return path[i];
}

return '/';
}

/// <summary>
/// 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.
/// </summary>
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]));

/// <summary>
/// Drop any path from <paramref name="paths"/> whose parent (at any depth)
/// is also in the set. Used to collapse worktree-style descendants like
Expand All @@ -2582,12 +2701,6 @@ internal static HashSet<string> CollapseDescendants(IReadOnlySet<string> paths)
return roots;

static bool HasAncestorIn(string path, IReadOnlySet<string> 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) {
Expand All @@ -2596,27 +2709,33 @@ static bool HasAncestorIn(string path, IReadOnlySet<string> set) {
}

return false;
}
}

static string? TrimLastSegment(string p) {
var i = p.Length - 1;
/// <summary>
/// The parent of <paramref name="p"/>, or null once no meaningful ancestor
/// is left ("/foo" → root). Trims the last separator-delimited segment
/// rather than calling <c>Path.GetDirectoryName</c>, 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.
/// </summary>
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];
}

/// <summary>
Expand Down
Loading