diff --git a/apps/desktop/src/main/__tests__/project-management-service.test.ts b/apps/desktop/src/main/__tests__/project-management-service.test.ts index 8b6384f5b6..e1138ed173 100644 --- a/apps/desktop/src/main/__tests__/project-management-service.test.ts +++ b/apps/desktop/src/main/__tests__/project-management-service.test.ts @@ -1,8 +1,12 @@ import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); import { createProjectCatalog, type ProjectCatalog } from '@maka/storage'; import { createProjectManagementService, @@ -67,6 +71,49 @@ test('owns Project selection and reversible lifecycle actions in Desktop', async } }); +test('adding a nested folder selects that folder instead of the parent project', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-nested-add-')); + const parentPath = join(base, 'parent-project'); + const childPath = join(parentPath, 'child-project'); + await mkdir(childPath, { recursive: true }); + await execFileAsync('git', ['init', '--quiet'], { cwd: parentPath }); + + const selected: string[] = []; + let nextDirectory = parentPath; + let nextId = 0; + const catalog = createProjectCatalog(join(base, 'storage'), { + now: () => 1_000, + createId: () => `project-${++nextId}`, + }); + const service = createProjectManagementService({ + capabilities: LOCAL_CAPABILITIES, + catalog: managementCatalog(catalog), + chooseDirectory: async () => nextDirectory, + selection: { + currentSelection: async () => ({ projectId: undefined, path: parentPath }), + setSelection: (_projectId, path) => selected.push(path), + }, + }); + + try { + const parent = await service.add(); + assert.equal(parent.ok, true); + if (!parent.ok) return; + assert.equal(parent.path, await realpath(parentPath)); + + nextDirectory = childPath; + const child = await service.add(); + assert.equal(child.ok, true); + if (!child.ok) return; + assert.notEqual(child.project.id, parent.project.id); + assert.equal(child.path, await realpath(childPath)); + assert.equal(selected.at(-1), await realpath(childPath)); + } finally { + catalog.close(); + await rm(base, { recursive: true, force: true }); + } +}); + test('rejects malformed Project identities before catalog access', async () => { const service = createProjectManagementService({ capabilities: LOCAL_CAPABILITIES, @@ -117,9 +164,7 @@ test('does not silently replace a stale Project preference with another Project' const service = createProjectManagementService({ capabilities: LOCAL_CAPABILITIES, catalog: { - list: async () => [ - { id: 'other', name: 'Other', locations: [], available: true }, - ], + list: async () => [{ id: 'other', name: 'Other', locations: [], available: true }], register: unexpected, relink: unexpected, rename: unexpected, diff --git a/packages/storage/src/__tests__/project-catalog.test.ts b/packages/storage/src/__tests__/project-catalog.test.ts index 4a11b4c154..36782d0fa7 100644 --- a/packages/storage/src/__tests__/project-catalog.test.ts +++ b/packages/storage/src/__tests__/project-catalog.test.ts @@ -10,6 +10,7 @@ import { createProjectCatalog as createProjectCatalogBase, type ProjectCatalog, ProjectUnavailableError, + ProjectPathMismatchError, type ResolvedProjectLocation, resolveProjectLocation, } from '../project-catalog.js'; @@ -116,6 +117,96 @@ test('a repository and its linked worktree resolve to one project identity', asy } }); +test('registering a nested folder keeps that folder instead of the enclosing repository', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-nested-folder-')); + try { + const parent = join(base, 'parent-project'); + const child = join(parent, 'child-project'); + await mkdir(child, { recursive: true }); + await execFileAsync('git', ['init', '--quiet'], { cwd: parent }); + const catalog = createProjectCatalog(join(base, 'storage'), { + now: () => 1_000, + createId: (() => { + let id = 0; + return () => `project-${++id}`; + })(), + }); + + const parentProject = await catalog.register(parent); + const childProject = await catalog.register(child); + const parentPath = await realpath(parent); + const childPath = await realpath(child); + + assert.notEqual(childProject.id, parentProject.id); + assert.equal(parentProject.preferredPath, parentPath); + assert.equal(childProject.preferredPath, childPath); + assert.equal(childProject.name, 'child-project'); + + // session.create → HostWorkspaceResolver.touch(projectId, preferredPath) + const touched = await catalog.touch(childProject.id, childProject.preferredPath); + assert.equal(touched.id, childProject.id); + assert.equal(touched.preferredPath, childPath); + await assert.rejects( + () => catalog.touch(childProject.id, parentPath), + (error) => error instanceof ProjectPathMismatchError && error.projectId === childProject.id, + ); + } finally { + await rm(base, { recursive: true, force: true }); + } +}); + +test('relink and relinkWithSessions keep a nested repository directory', async () => { + const base = await mkdtemp(join(tmpdir(), 'maka-project-nested-relink-')); + const storage = join(base, 'storage'); + const sessions = createSessionStore(storage); + try { + const parent = join(base, 'parent-project'); + const child = join(parent, 'child-project'); + const childTwo = join(parent, 'child-two'); + const elsewhere = join(base, 'elsewhere'); + const elsewhereTwo = join(base, 'elsewhere-two'); + await mkdir(child, { recursive: true }); + await mkdir(childTwo, { recursive: true }); + await mkdir(elsewhere, { recursive: true }); + await mkdir(elsewhereTwo, { recursive: true }); + await execFileAsync('git', ['init', '--quiet'], { cwd: parent }); + const catalog = createProjectCatalog(storage, { + now: () => 1_000, + createId: (() => { + let id = 0; + return () => `project-${++id}`; + })(), + }); + + const parentProject = await catalog.register(parent); + const original = await catalog.register(elsewhere); + const originalSessions = await catalog.register(elsewhereTwo); + const childPath = await realpath(child); + const childTwoPath = await realpath(childTwo); + const assigned = await sessions.create(sessionInput(elsewhereTwo, originalSessions.id)); + + const relinked = await catalog.relink(original.id, child); + assert.equal(relinked.id, original.id); + assert.notEqual(relinked.id, parentProject.id); + assert.equal(relinked.preferredPath, childPath); + + const { project: relinkedSessions, updatedSessionIds } = await catalog.relinkWithSessions( + originalSessions.id, + childTwo, + ); + assert.equal(relinkedSessions.id, originalSessions.id); + assert.notEqual(relinkedSessions.id, parentProject.id); + assert.equal(relinkedSessions.preferredPath, childTwoPath); + assert.deepEqual(updatedSessionIds, [assigned.id]); + const header = await sessions.readHeaderSnapshot(assigned.id); + assert.equal(header.projectId, relinkedSessions.id); + assert.equal(header.cwd, childTwoPath); + } finally { + await sessions.close?.(); + await rm(base, { recursive: true, force: true }); + } +}); + async function resolveProjectLocationWithoutGit(path: string): Promise { const stdout = await runProjectCatalogWithoutGit( 'const [moduleUrl, path] = process.argv.slice(1); const { resolveProjectLocation } = await import(moduleUrl); console.log(JSON.stringify(await resolveProjectLocation({ path })));', diff --git a/packages/storage/src/project-catalog.ts b/packages/storage/src/project-catalog.ts index 5fc0ae7b14..3882aad95e 100644 --- a/packages/storage/src/project-catalog.ts +++ b/packages/storage/src/project-catalog.ts @@ -164,7 +164,7 @@ class SqliteProjectCatalog implements ProjectCatalog { } async register(path: string): Promise { - const resolved = await resolveProjectLocation({ path }); + const resolved = await resolveUserSelectedProjectLocation(path); return this.upsertResolvedProject(resolved, this.now()); } @@ -271,27 +271,24 @@ class SqliteProjectCatalog implements ProjectCatalog { } async touch(projectId: string, path?: string): Promise { - let resolved: Awaited> | undefined; - try { - resolved = path ? await resolveProjectLocation({ path }) : undefined; - } catch { - throw new ProjectUnavailableError(projectId); + let canonicalPath: string | undefined; + if (path) { + try { + canonicalPath = normalize(await realpath(resolve(path))); + } catch { + throw new ProjectUnavailableError(projectId); + } } - const resolvedPath = resolved - ? resolved.kind === 'git' - ? resolved.git!.worktreeRoot - : resolved.canonicalPath - : undefined; const touched = await this.mutate((file) => { const project = findProjectById(file.projects, projectId); if (!project) throw new ProjectNotFoundError(projectId); - const location = resolvedPath - ? project.locations.find((item) => item.path === resolvedPath) + const location = canonicalPath + ? project.locations.find((item) => item.path === canonicalPath) : [...project.locations].sort( (a, b) => b.lastUsedAt - a.lastUsedAt || a.path.localeCompare(b.path), )[0]; - if (resolvedPath && !location) { - throw new ProjectPathMismatchError(projectId, resolvedPath); + if (canonicalPath && !location) { + throw new ProjectPathMismatchError(projectId, canonicalPath); } const timestamp = this.now(); if (location) location.lastUsedAt = timestamp; @@ -302,7 +299,7 @@ class SqliteProjectCatalog implements ProjectCatalog { } async relink(projectId: string, path: string): Promise { - const resolved = await resolveProjectLocation({ path }); + const resolved = await resolveUserSelectedProjectLocation(path); const timestamp = this.now(); const locationPath = resolved.kind === 'git' ? resolved.git!.worktreeRoot : resolved.canonicalPath; @@ -322,7 +319,7 @@ class SqliteProjectCatalog implements ProjectCatalog { projectId: string, path: string, ): Promise<{ project: ProjectRecord; updatedSessionIds: readonly string[] }> { - const resolved = await resolveProjectLocation({ path }); + const resolved = await resolveUserSelectedProjectLocation(path); const timestamp = this.now(); const locationPath = resolved.kind === 'git' ? resolved.git!.worktreeRoot : resolved.canonicalPath; @@ -835,6 +832,30 @@ export async function resolveProjectLocation(input: { }; } +/** + * A directory the user picked in the add/relink chooser. + * + * `resolveProjectLocation` still walks to the enclosing Git worktree so a + * historical session cwd inside a repository stays on that repository. + * The chooser must not do that: selecting `repo/child` would otherwise + * silently become `repo` and reopen the parent project. + */ +async function resolveUserSelectedProjectLocation(path: string): Promise { + const resolved = await resolveProjectLocation({ path }); + if ( + resolved.kind !== 'git' || + !resolved.git || + resolved.canonicalPath === resolved.git.worktreeRoot + ) { + return resolved; + } + return { + canonicalPath: resolved.canonicalPath, + identity: `folder:${resolved.canonicalPath}`, + kind: 'folder', + }; +} + async function resolveGitLocation( canonicalPath: string, ): Promise> {