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
11 changes: 11 additions & 0 deletions docs/non-git-projects/intent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Intent

SpireCode currently treats every Project as a Git repository. Opening a directory runs Git discovery immediately, watcher setup requires Git metadata, and the Renderer always exposes Changes and Worktree actions. This prevents users from using SpireCode for an existing source folder that has not been initialized as a repository.

SpireCode should support both Git repositories and ordinary directories as first-class Projects. A directory Project must retain the useful workspace capabilities—file browsing and editing, terminals, Chat, project-local settings and durable restoration—without pretending that Git exists. Git status, diff, commit-related surfaces, and managed Worktree operations must be absent from the UI and rejected by Main when invoked for a directory Project.

The implementation must preserve the existing security boundary: Renderer operations continue to use opaque IDs and relative paths. Renderer must never receive generic filesystem or arbitrary-path IPC. SpireCode only opens existing directories and does not expose a New Project or directory-creation flow.

## Risk

High risk. This changes the durable Project schema, path handling, watcher semantics, and cross-process capability checks. Implementation requires explicit approval after review of this intent, specification, and plan.
88 changes: 88 additions & 0 deletions docs/non-git-projects/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Plan

## 1. Establish red tests for the Project schema and directory semantics

Update `electron/domains/projects/projects.test.ts` before production code:

- Git open returns `kind: "git"` and preserves subdirectory-to-repository-root behavior.
- Existing empty and non-empty ordinary directories open as `kind: "directory"` with one primary workspace.
- canonical aliases deduplicate; repeated opens preserve IDs.
- ordinary files and missing/inaccessible paths fail without catalog mutation.
- a directory Project upgraded by a later `git init` preserves IDs.
- version 2 state migrates to version 3 as Git.
- malformed version 3 kind/ownership data is rejected.
- Renderer exposes no New Project or directory-creation action.

## 2. Implement durable Project capabilities

Change `electron/domains/projects/index.ts`:

- add `ProjectKind` and `ProjectSummary.kind`;
- bump `PROJECT_STATE_VERSION` to 3 and add a pure v2 migration;
- separate canonical selected-directory validation from Git repository discovery;
- add directory-root registration and existing-directory-to-Git upgrade;
- add Main-owned capability lookup/assertion helpers by Project/workspace ID;
- preserve atomic catalog writes and opaque workspace IDs.

Update the shared Renderer DTO in `src/bindings/generated.ts` and affected fixtures.

## 3. Make watcher behavior capability-aware

First add tests in `electron/domains/filesystem/watcher.test.ts` proving that a directory watcher emits filesystem events but never Git events.

Then change:

- `electron/domains/filesystem/watcher.ts` to accept optional Git metadata and gate Git invalidation;
- `electron/appState.ts` startup/open paths to register filesystem-only watchers for directory Projects and Git-aware watchers for Git Projects.

## 4. Enforce Main-side Git and Worktree gates

Add regression tests to:

- `electron/domains/git/git.test.ts`;
- `electron/domains/worktrees/worktrees.test.ts`;
- relevant IPC tests.

Then:

- give `GitService` a Main-derived capability resolver and reject directory workspaces before Git execution;
- make `WorktreeService` reject directory Projects before origin lookup, validation, managed-root creation, or Git mutation;
- retain select/list/reveal for the synthetic primary workspace.

## 5. Implement capability-aware Renderer UX

Update tests first, then production code:

- `src/features/projects/ProjectRail.test.tsx` / `ProjectRail.tsx`: Open Folder remains the only Project-add action; use a folder icon and direct selection for directory Projects; hide Worktree UI.
- `src/features/workbench/Workbench.test.tsx` / `Workbench.tsx`: no Git refresh, no Changes tab, and Files fallback for directory Projects.
- `src/features/workbench/BreadcrumbSwitcher.test.tsx` / `BreadcrumbSwitcher.tsx`: Project-only breadcrumb for directory Projects.
- `src/features/editor/editorStore.test.ts` and `EditorPane.test.tsx`: clear or ignore directory diff tabs without affecting file, terminal, or Chat tabs.
- `src/i18n/en.ts` and `src/i18n/zh-CN.ts`: neutral folder/open labels.

## 6. Prove retained non-Git capabilities

Add focused integration/regression coverage where missing:

- `electron/domains/filesystem/filesystem.test.ts`: list/read/write ordinary roots and preserve containment.
- `electron/domains/terminal/service.test.ts`: the ordinary directory is passed as PTY `cwd`.
- `electron/domains/chat/chatService.test.ts`: Chat adapter receives the ordinary directory and ownership isolation remains intact.
- `src/features/projects/projectsStore.test.ts`: directory primary workspace hydration, selection, and close fallback.

## 7. Verification

Run focused red-green suites during implementation, then:

```bash
pnpm test -- electron/domains/projects/projects.test.ts
pnpm test -- electron/domains/filesystem/watcher.test.ts
pnpm test -- electron/domains/git/git.test.ts
pnpm test -- electron/domains/worktrees/worktrees.test.ts
pnpm test -- electron/ipc.test.ts electron/contracts.test.ts
pnpm test -- src/features/projects/ProjectRail.test.tsx
pnpm test -- src/features/workbench/Workbench.test.tsx
pnpm test -- src/features/workbench/BreadcrumbSwitcher.test.tsx
pnpm test -- src/features/editor/EditorPane.test.tsx
pnpm check
```

Finally inspect `git diff --check`, the complete diff, schema migration behavior, and all new IPC fields. No implementation is complete until the full gate passes.
111 changes: 111 additions & 0 deletions docs/non-git-projects/spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# Spec

## 1. Project model

Add an explicit Project capability discriminator:

```ts
type ProjectKind = "git" | "directory";

interface ProjectSummary {
kind: ProjectKind;
// existing fields remain
}
```

The durable Project catalog moves from version 2 to version 3.

- Existing version 2 Projects migrate deterministically to `kind: "git"` without probing the filesystem or changing IDs.
- A directory Project has exactly one synthetic primary workspace represented by the existing `WorktreeSummary` shape.
- The synthetic workspace uses `kind: "main"`, the canonical Project path, the Project display name, and empty `branch` / `baseRef` strings. Project capability is determined only by `ProjectSummary.kind`, never inferred from these placeholder fields.
- `activeWorktreeId` remains the active workspace identity. This intentionally avoids a broad rename of filesystem, terminal, Chat, editor, event, and settings contracts.
- Parsed catalogs reject unknown Project kinds and inconsistent workspace ownership.

## 2. Open an existing folder

The existing Open Project action accepts:

- a Git repository root;
- a subdirectory of a Git worktree, normalized to its repository top level;
- an empty or non-empty ordinary directory, retained at the selected canonical root;
- a directory symlink, normalized with `realpath` so aliases do not create duplicate Projects.

It rejects missing paths, regular files, inaccessible paths, and paths that cannot be canonicalized. Such failures do not modify the catalog.

Git discovery must distinguish an ordinary directory from operational Git failures. A missing Git executable, timeout, permission failure, unsafe repository condition, or other unexpected failure must not silently downgrade a repository to a directory Project.

Opening the same canonical root reuses the existing Project and primary workspace IDs. If an existing directory Project later becomes a Git repository and is reopened, its existing IDs are retained while its kind and primary workspace branch metadata are upgraded to Git.

## 3. Runtime capabilities

Directory Projects retain:

- file tree and UTF-8 file editing;
- filesystem change refresh;
- terminal creation with the directory as `cwd`;
- Chat sessions with the directory as `cwd`;
- project-local settings, skills, and extensions resolved from that root;
- reveal, close, durable restore, and Project switching.

Directory Projects do not support:

- Git status;
- staged, unstaged, or untracked diff;
- Changes or commit-related UI;
- origin branch listing;
- managed Worktree create, rename, inspect-delete, or delete.

Main is authoritative. Git and managed Worktree domain methods reject directory Projects with `NOT_A_GIT_REPOSITORY` even if Renderer IPC is called directly. Renderer capability flags are never trusted for authorization.

## 4. Watchers and events

Watcher registration accepts optional Git metadata.

- Git Projects watch the workspace root and external Git directory when needed, preserving existing filesystem and `git://changed` events.
- Directory Projects watch only their root and emit only `filesystem://changed`.
- Directory file changes never trigger Git refresh.
- Startup restoration must register directory watchers without executing Git.

## 5. Renderer behavior

### Project rail

- The add control opens an existing folder. SpireCode does not expose a New Project or directory-creation action.
- Git Projects retain repository icon, expand/collapse, Worktree rows, and Create Worktree.
- Directory Projects use a folder icon, hide all Worktree controls and rows, and select their synthetic primary workspace when the Project row is clicked.
- Reveal and Close remain available for both kinds.

### Workbench

- Directory Projects never call Git status refresh.
- The Changes tab is hidden for directory Projects.
- If the current in-memory right panel preference is Changes, a directory Project renders Files without destroying the preference; switching back to Git restores normal behavior.
- Files, editor, terminal, and Chat remain available.

### Breadcrumb

- Git Projects retain Project > Worktree > Branch.
- Directory Projects display only the Project-level switcher and no synthetic Worktree or branch metadata.

### Diff tabs

A restored or stale Git diff tab must not execute `git_diff_file` for a directory Project. Directory workspaces remove or ignore diff tabs while preserving file, terminal, and Chat tabs. Chat tool output and patches remain visible because they are Chat content, not the Git Changes feature.

## 6. Compatibility and security

- Existing Git Project behavior and IDs remain stable after migration.
- Renderer continues to use `worktreeId + relativePath`; no raw filesystem or shell IPC is added.
- Filesystem containment, symlink escape rejection, `.git` exclusion, hardened Git execution, and atomic catalog persistence remain unchanged.
- Git capability checks are derived from Main's durable catalog.

## 7. Acceptance criteria

1. Opening an existing empty or non-empty non-Git directory creates or reuses a directory Project.
2. SpireCode exposes no New Project or directory-creation action.
3. Directory Projects support Files, editing, Terminal, and Chat before and after restart.
4. Directory Projects show no Changes, diff, commit-related, or Worktree UI.
5. Direct Git and managed Worktree calls for directory Projects return `NOT_A_GIT_REPOSITORY` before running Git or mutating disk.
6. Directory watcher events do not produce Git invalidations.
7. Existing Git Projects, linked worktrees, managed Worktrees, Changes, and diffs continue to work.
8. Version 2 catalogs migrate to version 3 without changing existing IDs.
9. `pnpm check` passes.
26 changes: 18 additions & 8 deletions electron/appState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ export class AppState {
this.diagnostics = new DiagnosticsService(dataDirectory, "0.1.0");
const root = (id: string) => projects.root(id);
this.filesystem = new FilesystemService(root);
this.git = new GitService(root);
this.git = new GitService(root, (id) => projects.assertGitWorktree(id));
this.memory = new MemoryService();
this.models = new ModelCatalogService();
this.terminals = new TerminalService(root, (subscriptionId, payload) => {
Expand All @@ -75,7 +75,7 @@ export class AppState {
for (const project of await projects.list()) {
for (const worktree of project.worktrees) {
try {
await state.ensureWatcher(worktree.id, worktree.path);
await state.ensureWatcher(project.kind, worktree.id, worktree.path);
} catch (error) {
console.warn(
`Unable to watch persisted worktree ${worktree.id}`,
Expand Down Expand Up @@ -156,7 +156,7 @@ export class AppState {
async openProject(selectedPath: string) {
const project = await this.projects.openPath(selectedPath);
for (const worktree of project.worktrees)
await this.ensureWatcher(worktree.id, worktree.path);
await this.ensureWatcher(project.kind, worktree.id, worktree.path);
return project;
}

Expand Down Expand Up @@ -201,7 +201,7 @@ export class AppState {
async createWorktree(projectId: string, name: string, baseRef: string) {
const worktree = await this.worktrees.create(projectId, name, baseRef);
try {
await this.ensureWatcher(worktree.id, worktree.path);
await this.ensureWatcher("git", worktree.id, worktree.path);
return worktree;
} catch (error) {
await this.worktrees.rollbackCreated(worktree.id);
Expand All @@ -214,15 +214,15 @@ export class AppState {
await this.watchers.close(worktreeId);
try {
const renamed = await this.worktrees.rename(worktreeId, name);
await this.ensureWatcher(renamed.id, renamed.path);
await this.ensureWatcher("git", renamed.id, renamed.path);
return renamed;
} catch (error) {
try {
const current = await this.projects.worktree(worktreeId);
if (current.name !== original.name)
await this.worktrees.rename(worktreeId, original.name);
const restored = await this.projects.worktree(worktreeId);
await this.ensureWatcher(restored.id, restored.path);
await this.ensureWatcher("git", restored.id, restored.path);
} catch {
// Preserve the primary failure; recovery is best effort here.
}
Expand All @@ -248,7 +248,7 @@ export class AppState {
} catch (error) {
try {
const current = await this.projects.worktree(worktreeId);
await this.ensureWatcher(current.id, current.path);
await this.ensureWatcher("git", current.id, current.path);
} catch {
// The worktree may already be gone.
}
Expand All @@ -267,7 +267,17 @@ export class AppState {
console.warn("Application cleanup was incomplete", result.reason);
}

private async ensureWatcher(worktreeId: string, root: string): Promise<void> {
private async ensureWatcher(
kind: "git" | "directory",
worktreeId: string,
root: string,
): Promise<void> {
if (kind === "directory") {
await this.watchers.ensure(worktreeId, root, (event) =>
this.emitWatchEvent(event),
);
return;
}
const gitDir = (
await gitText(root, ["rev-parse", "--path-format=absolute", "--git-dir"])
).trim();
Expand Down
64 changes: 64 additions & 0 deletions electron/domains/filesystem/watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,70 @@ describe("WatcherRegistry", () => {
expect(watcherBackendForPlatform("win32")).toBe("chokidar");
});

it.each([
["omitted", undefined],
["null", null],
] as const)(
"emits only filesystem changes when Git metadata is %s",
async (_label, gitDirectory) => {
const root = await temporaryDirectory();
const events: WatchEvent[] = [];
const registry = new WatcherRegistry();
const sink = (event: WatchEvent) => events.push(event);

if (gitDirectory === undefined) {
await registry.ensure("directory-1", root, sink);
} else {
await registry.ensure("directory-1", root, gitDirectory, sink);
}
await writeFile(path.join(root, "file.ts"), "export {};\n");
await vi.waitFor(
() => {
expect(
events.some(
(event) =>
event.topic === "filesystem://changed" &&
event.payload.paths.includes("file.ts"),
),
).toBe(true);
},
{ timeout: 5_000 },
);
await new Promise((resolve) => setTimeout(resolve, 250));

expect(events.some((event) => event.topic === "git://changed")).toBe(
false,
);
await registry.dispose();
},
);

it("preserves filesystem and Git invalidations for Git watchers", async () => {
const root = await temporaryDirectory();
const gitDirectory = path.join(root, ".git");
await mkdir(gitDirectory);
const events: WatchEvent[] = [];
const registry = new WatcherRegistry();

await registry.ensure("git-1", root, gitDirectory, (event) =>
events.push(event),
);
await writeFile(path.join(root, "file.ts"), "export {};\n");
await vi.waitFor(
() => {
expect(
events.some((event) => event.topic === "filesystem://changed"),
).toBe(true);
expect(events.some((event) => event.topic === "git://changed")).toBe(
true,
);
},
{ timeout: 5_000 },
);

await registry.dispose();
});

it.runIf(process.platform === "darwin")(
"observes nested files with the native recursive backend and releases it",
async () => {
Expand Down
Loading
Loading