diff --git a/apps/emdash-desktop/src/main/core/projects/create-project-provider.ts b/apps/emdash-desktop/src/main/core/projects/create-project-provider.ts index 0140fb1227..46de20f4d0 100644 --- a/apps/emdash-desktop/src/main/core/projects/create-project-provider.ts +++ b/apps/emdash-desktop/src/main/core/projects/create-project-provider.ts @@ -19,6 +19,7 @@ import { log } from '@main/lib/logger'; import { gitRepoUpdateChannel } from '@shared/core/git/events'; import { safePathSegment } from '@shared/path-name'; import type { LocalProject, SshProject } from '@shared/projects'; +import { ensureEmdashGitExcludedSafe } from './ensure-emdash-excluded'; import { ProjectProvider, type ProjectProviderTransport } from './project-provider'; import type { ProjectSettingsProvider } from './settings/provider'; import { LocalProjectSettingsProvider } from './settings/providers/local-project-settings-provider'; @@ -220,6 +221,10 @@ function buildProvider( worktreeHost, }; + // Keep emdash's `.emdash/` runtime state (worktree pool, attachments, uploads) out of + // the user's `git status`. Best effort and non-blocking. + ensureEmdashGitExcludedSafe(projectFs, projectId); + const gitRepository = new GitRepositoryService(repoLease.value, settings); const worktreeService = new WorktreeService({ repoPath, diff --git a/apps/emdash-desktop/src/main/core/projects/ensure-emdash-excluded.test.ts b/apps/emdash-desktop/src/main/core/projects/ensure-emdash-excluded.test.ts new file mode 100644 index 0000000000..ea95d13b82 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/projects/ensure-emdash-excluded.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { FileSystemProvider } from '@main/core/fs/types'; +import { ensureEmdashGitExcluded } from './ensure-emdash-excluded'; + +function makeFs(opts: { + gitType?: 'dir' | 'file'; + excludeContent?: string | null; + truncated?: boolean; +}) { + const write = vi.fn(async () => ({ success: true, bytesWritten: 1 })); + const fs = { + stat: vi.fn(async (p: string) => + p === '.git' && opts.gitType ? { type: opts.gitType } : null + ), + exists: vi.fn(async () => opts.excludeContent != null), + read: vi.fn(async () => ({ + content: opts.excludeContent ?? '', + truncated: opts.truncated ?? false, + totalSize: 0, + })), + write, + } as unknown as FileSystemProvider; + return { fs, write }; +} + +describe('ensureEmdashGitExcluded', () => { + it('skips repos without a real .git directory (linked worktree / submodule)', async () => { + const { fs, write } = makeFs({ gitType: 'file', excludeContent: '' }); + await ensureEmdashGitExcluded(fs); + expect(write).not.toHaveBeenCalled(); + }); + + it('skips when there is no .git at all', async () => { + const { fs, write } = makeFs({ excludeContent: '' }); + await ensureEmdashGitExcluded(fs); + expect(write).not.toHaveBeenCalled(); + }); + + it('creates the exclude entry when info/exclude is missing', async () => { + const { fs, write } = makeFs({ gitType: 'dir', excludeContent: null }); + await ensureEmdashGitExcluded(fs); + expect(write).toHaveBeenCalledWith('.git/info/exclude', '.emdash/\n'); + }); + + it('appends the entry, preserving existing exclude content', async () => { + const { fs, write } = makeFs({ gitType: 'dir', excludeContent: '# git ls-files\nbuild/\n' }); + await ensureEmdashGitExcluded(fs); + expect(write).toHaveBeenCalledWith('.git/info/exclude', '# git ls-files\nbuild/\n.emdash/\n'); + }); + + it('does nothing when .emdash/ is already excluded', async () => { + const { fs, write } = makeFs({ gitType: 'dir', excludeContent: 'foo\n.emdash/\n' }); + await ensureEmdashGitExcluded(fs); + expect(write).not.toHaveBeenCalled(); + }); + + it('treats a slashless .emdash entry as already excluded', async () => { + const { fs, write } = makeFs({ gitType: 'dir', excludeContent: '.emdash\n' }); + await ensureEmdashGitExcluded(fs); + expect(write).not.toHaveBeenCalled(); + }); + + it('does not rewrite when the exclude read was truncated', async () => { + // A truncated view could miss an existing entry past the cut; rewriting it would + // drop the tail of the file, so bail instead. + const { fs, write } = makeFs({ gitType: 'dir', excludeContent: 'build/\n', truncated: true }); + await ensureEmdashGitExcluded(fs); + expect(write).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/emdash-desktop/src/main/core/projects/ensure-emdash-excluded.ts b/apps/emdash-desktop/src/main/core/projects/ensure-emdash-excluded.ts new file mode 100644 index 0000000000..5837c35e51 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/projects/ensure-emdash-excluded.ts @@ -0,0 +1,56 @@ +import type { FileSystemProvider } from '@main/core/fs/types'; +import { SSH_PROJECT_STATE_DIR_NAME } from '@main/core/settings/worktree-defaults'; +import { log } from '@main/lib/logger'; + +const GIT_EXCLUDE_PATH = '.git/info/exclude'; +const IGNORE_PATTERN = `${SSH_PROJECT_STATE_DIR_NAME}/`; + +/** + * Ensure the project's `.emdash/` runtime dir is git-ignored via `.git/info/exclude`. + * + * emdash keeps per-project state under `.emdash/` inside the repo: the SSH worktree + * pool ({@link SSH_PROJECT_STATE_DIR_NAME}/worktrees), saved attachments, and uploaded + * images. None of that belongs in the user's tree, so we exclude it locally rather than + * touching a tracked `.gitignore`. `info/exclude` lives in the git common dir, so a single + * entry on the main checkout also covers every linked task worktree. + * + * Best effort and idempotent: skips repos without a real `.git` directory (linked + * worktrees / submodules use a `.git` file whose exclude is out of this fs's root) and + * skips when `.emdash` is already ignored (e.g. via a global gitignore). + */ +export async function ensureEmdashGitExcluded(fs: FileSystemProvider): Promise { + const gitDir = await fs.stat('.git').catch(() => null); + if (gitDir?.type !== 'dir') return; + + let existing = ''; + if (await fs.exists(GIT_EXCLUDE_PATH)) { + const read = await fs.read(GIT_EXCLUDE_PATH); + // `read` caps at a default byte limit; rewriting a truncated view would drop + // any rules past the cut. Bail rather than risk corrupting the file. + if (read.truncated) return; + existing = read.content; + } + + const alreadyExcluded = existing + .split(/\r?\n/) + .map((line) => line.trim()) + .some((line) => line === SSH_PROJECT_STATE_DIR_NAME || line === IGNORE_PATTERN); + if (alreadyExcluded) return; + + const base = existing.replace(/\s*$/, ''); + const next = base.length > 0 ? `${base}\n${IGNORE_PATTERN}\n` : `${IGNORE_PATTERN}\n`; + const result = await fs.write(GIT_EXCLUDE_PATH, next); + if (!result.success) { + throw new Error(result.error ?? `failed to write ${GIT_EXCLUDE_PATH}`); + } +} + +/** Fire-and-forget wrapper that never rejects; logs and moves on. */ +export function ensureEmdashGitExcludedSafe(fs: FileSystemProvider, projectId: string): void { + void ensureEmdashGitExcluded(fs).catch((error) => { + log.warn('ensureEmdashGitExcluded failed', { + projectId, + error: error instanceof Error ? error.message : String(error), + }); + }); +}