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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ kcap import --org --session abc123 # single session

Non-interactive runs (no TTY, e.g. CI) must pass both a scope flag and `--yes`. The command is idempotent and resumable — re-running with the same scope only uploads what's missing or incomplete. A server-side tracker deduplicates events on `(stream, eventId)` so previously-imported turns don't get re-appended.

After discovery, the import surfaces a one-shot report of any transcript working directories that no longer exist on disk (typically deleted worktrees, or local repo dirs that have been renamed). Those sessions won't match an `--org` / `--repo` scope until you tell kcap how their old paths map to the new ones. See [Renamed repo directories (`kcap remap`)](#renamed-repo-directories-kcap-remap) below for the fix.
After discovery, the import surfaces a one-shot report of any transcript working directories that no longer exist on disk. Sessions whose cwd was an ephemeral worktree (e.g. `~/dev/my-repo/.claude/worktrees/<slug>` or `~/dev/my-repo/.capacitor/worktrees/<slug>`) are transparently attributed to their parent project when that project still exists, so deleted-worktree paths drop out of the missing-cwds list. What remains is typically local repo dirs that have been renamed — those won't match an `--org` / `--repo` scope until you tell kcap how their old paths map to the new ones. See [Renamed repo directories (`kcap remap`)](#renamed-repo-directories-kcap-remap) below for the fix.

### Daemon

Expand Down Expand Up @@ -489,7 +489,7 @@ Semantics:
- Rules are applied once (no chaining), so the result of one rule isn't fed into another.
- 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 — typically deleted worktrees that no remap can recover.
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.

### Uninstalling

Expand Down
5 changes: 5 additions & 0 deletions src/Capacitor.Cli.Core/Resources/help-remap.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ 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
`<project>` when the project still exists on disk, so they don't need an
entry here.

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
Expand Down
76 changes: 58 additions & 18 deletions src/Capacitor.Cli/Commands/ImportCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -517,10 +517,11 @@ await Console.Error.WriteLineAsync(
// vendor and merge. User-configured cwd remaps let historic transcripts
// referencing since-renamed local repo paths still resolve to a real
// git directory.
var profileConfig = await AppConfig.LoadProfileConfig();
var cwdRemap = profileConfig.CwdRemap;
var resolved = new Dictionary<string, (string Owner, string Name)?>(StringComparer.Ordinal);
var sessionCwds = new Dictionary<string, string>(StringComparer.Ordinal);
var profileConfig = await AppConfig.LoadProfileConfig();
var cwdRemap = profileConfig.CwdRemap;
var resolved = new Dictionary<string, (string Owner, string Name)?>(StringComparer.Ordinal);
var sessionCwds = new Dictionary<string, string>(StringComparer.Ordinal);
var worktreeAttributed = new HashSet<string>(StringComparer.Ordinal);

foreach (var vendor in new[] { "claude", "codex" }) {
var slice = allFileTuples
Expand All @@ -530,18 +531,19 @@ await Console.Error.WriteLineAsync(

if (slice.Count == 0) continue;

var partial = await ResolveTranscriptReposAsync(slice, codex: vendor == "codex", display, cwdRemap, sessionCwds);
var partial = await ResolveTranscriptReposAsync(slice, codex: vendor == "codex", display, cwdRemap, sessionCwds, worktreeAttributed);
foreach (var kv in partial) resolved[kv.Key] = kv.Value;
}

// Resolve Cursor sessions in parallel by workspace path (dedup like
// ResolveTranscriptReposAsync).
if (cursorCwds.Count > 0) {
// Remap once per session so repo detection AND the missing-cwd
// report below see the same path.
// Resolve once per session so repo detection AND the missing-cwd
// report below see the same path (user remap + worktree-strip
// fallback for ephemeral worktree paths).
var cursorRemapped = cursorCwds.ToDictionary(
kv => kv.Key,
kv => kv.Value is null ? null : CwdRemapper.Apply(kv.Value, cwdRemap),
kv => kv.Value is null ? null : ResolveCwd(kv.Value, cwdRemap, worktreeAttributed, kv.Key),
StringComparer.Ordinal
);

Expand Down Expand Up @@ -583,6 +585,7 @@ await Parallel.ForEachAsync(
// Surface transcripts whose (possibly remapped) cwd no longer exists on
// disk so the user understands why some sessions won't match an org or
// repo scope, and can fix it by adding cwd_remap entries.
ReportWorktreeAttributions(worktreeAttributed.Count, display);
ReportMissingCwds(sessionCwds, cwdRemap, display);

// --- Scope picker ---
Expand Down Expand Up @@ -1639,6 +1642,19 @@ internal sealed record CodexGitInfo(string? RemoteUrl, string? Branch, string? C
/// fail to match an --org/--repo scope. This output lets them spot the
/// gap before the import proceeds.
/// </summary>
/// <summary>
/// Surface how many sessions were transparently attributed to their parent
/// project via the worktree-path fallback (cwd lived under
/// <c>&lt;dir&gt;/.&lt;X&gt;/worktrees/&lt;slug&gt;</c> but the worktree
/// itself no longer exists on disk). Stays silent when zero.
/// </summary>
internal static void ReportWorktreeAttributions(int count, ImportDisplay display) {
if (count <= 0) return;

var sessionWord = count == 1 ? "session" : "sessions";
display.Line($"Attributed {count} {sessionWord} to a parent project via worktree path.");
}

internal static void ReportMissingCwds(
IReadOnlyDictionary<string, string> sessionCwds,
IReadOnlyList<CwdRemap>? cwdRemap,
Expand Down Expand Up @@ -1723,32 +1739,56 @@ internal static string ShortenHome(string path, string home) {
return CwdRemapper.IsSeparator(path[home.Length]) ? "~" + path[home.Length..] : path;
}

/// <summary>
/// Two-step cwd resolution shared between Claude/Codex transcripts and
/// Cursor workspaces: apply user-configured prefix remaps first, then
/// fall back to attributing ephemeral worktree paths (e.g.
/// <c>.../&lt;project&gt;/.claude/worktrees/&lt;slug&gt;</c>) to
/// <c>&lt;project&gt;</c> when the worktree itself no longer exists.
/// </summary>
static string ResolveCwd(
string raw,
IReadOnlyList<CwdRemap>? cwdRemap,
ISet<string>? worktreeAttributed,
string sessionId
) {
var remapped = CwdRemapper.Apply(raw, cwdRemap);
var (final, wasStripped) = WorktreePathResolver.Resolve(remapped);

if (wasStripped) worktreeAttributed?.Add(sessionId);

return final;
}

static async Task<Dictionary<string, (string Owner, string Name)?>> ResolveTranscriptReposAsync(
IReadOnlyList<(string SessionId, string FilePath, string EncodedCwd)> transcripts,
bool codex,
ImportDisplay display,
IReadOnlyList<CwdRemap>? cwdRemap = null,
IDictionary<string, string>? sessionCwds = null
IReadOnlyList<CwdRemap>? cwdRemap = null,
IDictionary<string, string>? sessionCwds = null,
ISet<string>? worktreeAttributed = null
) {
// Extract cwd per transcript first (cheap: ≤20-line file read).
// Apply user-configured prefix remaps so historic transcripts pointing
// at since-renamed local directories still resolve. The per-session
// (remapped) cwd is also fed back to the caller via sessionCwds so the
// import flow can report which paths are missing on disk.
// at since-renamed local directories still resolve, then transparently
// attribute ephemeral worktree cwds back to their parent project when
// the worktree itself no longer exists on disk. The per-session
// (resolved) cwd is fed back to the caller via sessionCwds so the
// import flow can report which paths are still missing.
var perTranscript = new (string SessionId, string? Cwd)[transcripts.Count];

for (var i = 0; i < transcripts.Count; i++) {
var raw = ExtractCwdFromTranscript(transcripts[i].FilePath, codex);
var remapped = raw is null ? null : CwdRemapper.Apply(raw, cwdRemap);
var raw = ExtractCwdFromTranscript(transcripts[i].FilePath, codex);
var effective = raw is null ? null : ResolveCwd(raw, cwdRemap, worktreeAttributed, transcripts[i].SessionId);

perTranscript[i] = (transcripts[i].SessionId, remapped);
perTranscript[i] = (transcripts[i].SessionId, effective);

// Indexer assignment (not Add) so duplicate SessionIds across
// project dirs / backups can't abort the import. Last-write-wins
// is fine for the missing-cwd report — the cwd is functionally
// the same path anyway.
if (remapped is not null && sessionCwds is not null) {
sessionCwds[transcripts[i].SessionId] = remapped;
if (effective is not null && sessionCwds is not null) {
sessionCwds[transcripts[i].SessionId] = effective;
}
}

Expand Down
90 changes: 90 additions & 0 deletions src/Capacitor.Cli/WorktreePathResolver.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
namespace Capacitor.Cli;

/// <summary>
/// Attributes ephemeral worktree cwds back to the parent project when the
/// worktree directory itself no longer exists on disk.
///
/// Sessions recorded inside a tool-managed worktree carry an absolute cwd of
/// the form <c>&lt;project&gt;/.&lt;dotseg&gt;/worktrees/&lt;slug&gt;[/&lt;tail&gt;]</c>
/// (e.g. <c>~/dev/kcap-cli/.claude/worktrees/adaptive-toasting-squid</c> or
/// <c>~/dev/kcap-cli/.capacitor/worktrees/agent-...</c>). When those worktrees
/// are cleaned up, the cwd no longer matches anything on disk and the session
/// drops out of any <c>--org</c> / <c>--repo</c> scope.
///
/// If the <c>&lt;project&gt;</c> prefix still exists, we transparently
/// attribute the session to it. This is intentionally pattern-based rather
/// than a hard-coded list of dot-segment names ("<c>.claude</c>",
/// "<c>.capacitor</c>", "<c>.git</c>", ...) because tools keep inventing new
/// worktree roots and we don't want to chase the list.
/// </summary>
static class WorktreePathResolver {
public static (string Path, bool Stripped) Resolve(string cwd) =>
Resolve(cwd, Directory.Exists);

// Internal seam so tests can pin filesystem existence without touching disk.
internal static (string Path, bool Stripped) Resolve(string cwd, Func<string, bool> exists) {
if (string.IsNullOrEmpty(cwd)) return (cwd, false);

// Run the cheap pure-string pattern scan FIRST. Import calls this
// for every session cwd, and the vast majority don't match the
// worktree pattern — short-circuiting before any filesystem probe
// keeps the per-session cost to a substring scan.
var stripped = StripWorktreeSuffix(cwd);

if (stripped is null) return (cwd, false);
if (exists(cwd)) return (cwd, false); // pattern matches but worktree wasn't cleaned up — leave it alone

return exists(stripped) ? (stripped, true) : (cwd, false);
}

/// <summary>
/// Scan <paramref name="path"/> for the earliest segment triple
/// <c>.&lt;dotseg&gt;/worktrees/&lt;slug&gt;</c> (where <c>dotseg</c> starts
/// with <c>.</c>, is at least 2 chars, and is not <c>..</c>) and return the
/// path prefix immediately before the dot-segment. Accepts both <c>/</c>
/// and <c>\</c> as separators so transcripts recorded on Windows
/// resolve too. Returns null when no such segment exists, or when the
/// pattern starts at the path root (no meaningful project prefix).
/// </summary>
internal static string? StripWorktreeSuffix(string path) {
for (var i = 0; i < path.Length - 1; i++) {
if (!CwdRemapper.IsSeparator(path[i]) || path[i + 1] != '.') continue;

var dotStart = i + 1;
var dotEnd = NextSeparator(path, dotStart);

if (dotEnd < 0) continue; // no following separator → no triple
if (dotEnd - dotStart < 2) continue; // ".” alone is not a dot-segment
if (dotEnd - dotStart == 2 && path[dotStart + 1] == '.') continue; // ".."

var wtStart = dotEnd + 1;
var wtEnd = NextSeparator(path, wtStart);

if (wtEnd < 0) continue; // need a separator after "worktrees"
if (!path.AsSpan(wtStart, wtEnd - wtStart).SequenceEqual("worktrees")) continue;

var slugStart = wtEnd + 1;

if (slugStart >= path.Length || CwdRemapper.IsSeparator(path[slugStart])) continue;

// Found the pattern. Refuse if it sits at the very root —
// there's no meaningful project prefix to attribute to. "Root"
// covers both Unix (`/.X/worktrees/...` → i == 0) and Windows
// drive roots (`C:\.X\worktrees\...` → i == 2, prefix `C:`,
// also `C:/.X/...`).
if (i == 0) return null;
if (i == 2 && path[1] == ':' && char.IsLetter(path[0])) return null;
return path[..i];
}

return null;
}

static int NextSeparator(string s, int from) {
for (var j = from; j < s.Length; j++) {
if (CwdRemapper.IsSeparator(s[j])) return j;
}

return -1;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,24 @@ public async Task Report_collapses_worktree_descendants_in_output() {
await Assert.That(output).DoesNotContain("worktrees/agent-2");
}

[Test, NotInParallel]
public async Task ReportWorktreeAttributions_stays_silent_when_zero() {
var output = Capture(d => ImportCommand.ReportWorktreeAttributions(0, d));
await Assert.That(output).IsEmpty();
}

[Test, NotInParallel]
public async Task ReportWorktreeAttributions_reports_singular_phrasing_for_one() {
var output = Capture(d => ImportCommand.ReportWorktreeAttributions(1, d));
await Assert.That(output).Contains("Attributed 1 session to a parent project via worktree path.");
}

[Test, NotInParallel]
public async Task ReportWorktreeAttributions_reports_plural_phrasing_for_many() {
var output = Capture(d => ImportCommand.ReportWorktreeAttributions(477, d));
await Assert.That(output).Contains("Attributed 477 sessions to a parent project via worktree path.");
}

[Test, NotInParallel]
public async Task Truncates_sample_to_five_distinct_paths() {
var sessionCwds = new Dictionary<string, string>(StringComparer.Ordinal) {
Expand Down
30 changes: 24 additions & 6 deletions test/Capacitor.Cli.Tests.Unit/PathHelpersTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,35 @@ public async Task HomeDirectory_uses_HOME_when_set_to_rooted_absolute_path() {
}
}

// The three fall-back tests below assert that PathHelpers.HomeDirectory
// did NOT return the bogus HOME verbatim, rather than comparing it
// against a fresh `Environment.GetFolderPath(UserProfile)` read.
//
// That second read goes through HOME again under the hood on Linux and
// macOS. HOME-mutating tests live in many sibling classes
// (PluginCommand*Tests, UninstallCommandTests, AgentsPathsTests, …); if
// any of them sets HOME between our two reads, the comparison flakes.
// We saw this in CI even under `--maximum-parallel-tests 1` — TUnit's
// `[NotInParallel(...)]` constraint key doesn't bind tightly enough
// across classes to fully serialize.
//
// Asserting `home != bogusInput` is the portable structural property:
// PathHelpers.HomeDirectory only has two code paths (return HOME, or
// replace it with the fallback), so seeing a value other than the
// bogus input proves the fall-back branch fired. We deliberately do
// *not* assert "non-empty / rooted" here — on macOS dev machines, the
// fallback returns "" for whitespace/relative HOME, while on Linux CI
// it returns the real `/etc/passwd` home; either is a correct fall-back
// and the test shouldn't care which.

[Test]
public async Task HomeDirectory_falls_back_when_HOME_is_empty_string() {
var originalHome = Environment.GetEnvironmentVariable("HOME");

try {
Environment.SetEnvironmentVariable("HOME", "");
var home = PathHelpers.HomeDirectory;
var expected = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
await Assert.That(home).IsEqualTo(expected);
await Assert.That(home).IsNotEqualTo("");
} finally {
Environment.SetEnvironmentVariable("HOME", originalHome);
}
Expand All @@ -39,8 +59,7 @@ public async Task HomeDirectory_falls_back_when_HOME_is_whitespace() {
try {
Environment.SetEnvironmentVariable("HOME", " ");
var home = PathHelpers.HomeDirectory;
var expected = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
await Assert.That(home).IsEqualTo(expected);
await Assert.That(home).IsNotEqualTo(" ");
} finally {
Environment.SetEnvironmentVariable("HOME", originalHome);
}
Expand All @@ -53,8 +72,7 @@ public async Task HomeDirectory_falls_back_when_HOME_is_relative_path() {
try {
Environment.SetEnvironmentVariable("HOME", "foo/bar");
var home = PathHelpers.HomeDirectory;
var expected = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
await Assert.That(home).IsEqualTo(expected);
await Assert.That(home).IsNotEqualTo("foo/bar");
} finally {
Environment.SetEnvironmentVariable("HOME", originalHome);
}
Expand Down
Loading
Loading