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
197 changes: 190 additions & 7 deletions src/core/sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,17 +238,21 @@ function cleanPathSegments(pathStr: string): string {
if (!pathStr) return "";
let p = pathStr.replace(/\\/g, "/");

const isWindows = /^[a-zA-Z]:/.test(p);
const isPosixAbsolute = p.startsWith("/");
const isWindowsDrive = /^[a-zA-Z]:/.test(p);
const isUnc = p.startsWith("//") && !p.startsWith("///");
const isPosixAbsolute = !isUnc && p.startsWith("/");
let prefix = "";

if (isWindows) {
if (isWindowsDrive) {
prefix = p.slice(0, 2);
p = p.slice(2);
} else if (isUnc) {
prefix = "//";
p = p.slice(2);
}

const isAbsolute = isPosixAbsolute || (isWindows && p.startsWith("/"));
if (isWindows && p.startsWith("/")) {
const isAbsolute = isPosixAbsolute || isUnc || (isWindowsDrive && p.startsWith("/"));
if (isWindowsDrive && p.startsWith("/")) {
prefix += "/";
p = p.slice(1);
} else if (isPosixAbsolute) {
Expand All @@ -262,7 +266,11 @@ function cleanPathSegments(pathStr: string): string {
for (const part of parts) {
if (part === ".") continue;
if (part === "..") {
if (resolved.length > 0 && resolved[resolved.length - 1] !== "..") {
if (isUnc) {
if (resolved.length > 2) {
resolved.pop();
}
} else if (resolved.length > 0 && resolved[resolved.length - 1] !== "..") {
resolved.pop();
} else if (!isAbsolute) {
resolved.push("..");
Expand All @@ -272,11 +280,39 @@ function cleanPathSegments(pathStr: string): string {
}
}

if (isUnc && resolved.length < 2) {
return "";
}

const result = prefix + resolved.join("/");
if (!result && isAbsolute) return prefix;
return result;
}

function isWindowsPath(p: string): boolean {
return /^[a-zA-Z]:/.test(p) || p.startsWith("//");
}

function isValidRepoRoot(normalizedRepo: string): boolean {
if (!normalizedRepo || normalizedRepo.trim().length === 0) {
return false;
}
if (
normalizedRepo === "." ||
normalizedRepo === ".." ||
normalizedRepo.startsWith("./") ||
normalizedRepo.startsWith("../")
) {
return false;
}
const isWindowsDrive = /^[a-zA-Z]:(\/|$)/.test(normalizedRepo);
const isUnc = /^\/\/[^/]+\/[^/]+(\/|$)/.test(normalizedRepo);
const isPosixAbsolute =
normalizedRepo.startsWith("/") && !normalizedRepo.startsWith("//");

return isWindowsDrive || isUnc || isPosixAbsolute;
}

export function normalizePath(filePath: string, repoRoot?: string): string {
if (typeof filePath !== "string" || filePath.length === 0) return "";

Expand All @@ -287,7 +323,7 @@ export function normalizePath(filePath: string, repoRoot?: string): string {
const resolvedRoot = cleanPathSegments(repoRoot);
if (resolvedRoot) {
const isWindows =
/^[a-zA-Z]:/.test(resolvedPath) || /^[a-zA-Z]:/.test(resolvedRoot);
isWindowsPath(resolvedPath) || isWindowsPath(resolvedRoot);
const pComp = isWindows ? resolvedPath.toLowerCase() : resolvedPath;
const rComp = isWindows ? resolvedRoot.toLowerCase() : resolvedRoot;

Expand All @@ -303,3 +339,150 @@ export function normalizePath(filePath: string, repoRoot?: string): string {

return resolvedPath;
}

/**
* Match result explaining whether and why a session belongs to a repository.
*/
export interface RepositorySessionMatch {
/** Whether the session was matched to the repository. */
matched: boolean;
/** Human-readable explanation of why the session matched or failed to match. */
reason: string;
/** Normalized Git repository root path against which the session was checked. */
repoRoot: string;
/** The session's normalized working directory / project path, if available. */
sessionPath?: string;
}

/**
* Any session representation that contains working directory / repository evidence.
*/
export type MatchableSession = NormalizedSession | NormalizedSessionSummary;

/**
* Determine whether a single session belongs to the specified repository.
*
* Evidence-driven and conservative:
* - Matches if session working directory equals repository root.
* - Matches if session working directory is a descendant of repository root.
* - Does not match similar path prefixes (/work/app vs /work/application).
* - Does not match on close timestamps or similar names.
* - Returns matched=false when path evidence is missing.
*/
export function matchSessionToRepository(
session: MatchableSession,
repoRoot: string,
): RepositorySessionMatch {
if (typeof repoRoot !== "string" || repoRoot.trim().length === 0) {
const rawProjPath =
session.projectPath ?? ("repoRoot" in session ? session.repoRoot : undefined);
return {
matched: false,
reason: "Repository root path is required.",
repoRoot: "",
sessionPath:
typeof rawProjPath === "string" && rawProjPath.trim().length > 0
? cleanPathSegments(rawProjPath)
: undefined,
};
}

const normalizedRepo = cleanPathSegments(repoRoot);
if (!isValidRepoRoot(normalizedRepo)) {
const rawProjPath =
session.projectPath ?? ("repoRoot" in session ? session.repoRoot : undefined);
return {
matched: false,
reason: "Repository root path is invalid.",
repoRoot: normalizedRepo,
sessionPath:
typeof rawProjPath === "string" && rawProjPath.trim().length > 0
? cleanPathSegments(rawProjPath)
: undefined,
};
}

const rawPath =
session.projectPath ?? ("repoRoot" in session ? session.repoRoot : undefined);

if (typeof rawPath !== "string" || rawPath.trim().length === 0) {
return {
matched: false,
reason: "Session has no working directory or project path evidence.",
repoRoot: normalizedRepo,
sessionPath: undefined,
};
}

const normalizedSession = cleanPathSegments(rawPath);
if (!normalizedSession) {
return {
matched: false,
reason: "Session has no working directory or project path evidence.",
repoRoot: normalizedRepo,
sessionPath: undefined,
};
}

const isWindows =
isWindowsPath(normalizedSession) || isWindowsPath(normalizedRepo);
const sComp = isWindows ? normalizedSession.toLowerCase() : normalizedSession;
const rComp = isWindows ? normalizedRepo.toLowerCase() : normalizedRepo;

// Exact match
if (sComp === rComp) {
return {
matched: true,
reason: "Working directory exactly matches repository root.",
repoRoot: normalizedRepo,
sessionPath: normalizedSession,
};
}

// Descendant subdirectory match (using guarded prefix boundary to prevent prefix substring collision)
const rootPrefix = rComp.endsWith("/") ? rComp : `${rComp}/`;
if (sComp.startsWith(rootPrefix)) {
const relativeSubdir = normalizedSession.slice(rootPrefix.length);
return {
matched: true,
reason: `Working directory is inside repository subdirectory "${relativeSubdir}".`,
repoRoot: normalizedRepo,
sessionPath: normalizedSession,
};
}

// Outside repository (different directory, ancestor directory, or prefix collision)
return {
matched: false,
reason: "Working directory is outside repository.",
repoRoot: normalizedRepo,
sessionPath: normalizedSession,
};
}

/**
* Filter an array of sessions, returning only those that belong to the repository.
* Preserves the input array order deterministically.
*/
export function filterSessionsForRepository<T extends MatchableSession>(
sessions: readonly T[],
repoRoot: string,
): T[] {
return sessions.filter(
(session) => matchSessionToRepository(session, repoRoot).matched,
);
}

/**
* Match an array of sessions against a repository, returning each session paired with its match report.
* Preserves the input array order deterministically.
*/
export function matchSessionsToRepository<T extends MatchableSession>(
sessions: readonly T[],
repoRoot: string,
): Array<{ session: T; match: RepositorySessionMatch }> {
return sessions.map((session) => ({
session,
match: matchSessionToRepository(session, repoRoot),
}));
}
Loading