From 4e8c017455eac5c81c638b36cb5cec0652096700 Mon Sep 17 00:00:00 2001 From: Lorenzo Fiore Date: Thu, 25 Jun 2026 16:39:45 +0200 Subject: [PATCH 1/2] fix(projects): keep emdash's .emdash runtime dir out of git status emdash stores per-project state under `.emdash/` inside the repo: the SSH worktree pool (`.emdash/worktrees`), saved attachments (`.emdash/attachments`), and uploaded images. Nothing ensured that directory was git-ignored, so it relied on the user already having `.emdash` in a global gitignore. Without that, `.emdash/` shows up as untracked and clutters `git status`. On project open, add `.emdash/` to the repo's `.git/info/exclude` (a local, uncommitted ignore, so it never touches the user's tracked `.gitignore`). `info/exclude` lives in the git common dir, so the single entry on the main checkout also covers every linked task worktree. Best effort and idempotent: skips repos whose `.git` is a file (linked worktree / submodule) and skips when `.emdash` is already ignored. Relates to #2680 and complements #2681, which moves SSH image uploads into `.emdash/uploads`. --- .../core/projects/create-project-provider.ts | 5 ++ .../projects/ensure-emdash-excluded.test.ts | 58 +++++++++++++++++++ .../core/projects/ensure-emdash-excluded.ts | 51 ++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 apps/emdash-desktop/src/main/core/projects/ensure-emdash-excluded.test.ts create mode 100644 apps/emdash-desktop/src/main/core/projects/ensure-emdash-excluded.ts 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..2fda94c6c1 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/projects/ensure-emdash-excluded.test.ts @@ -0,0 +1,58 @@ +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 }) { + 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: 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(); + }); +}); 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..73f4a0a62b --- /dev/null +++ b/apps/emdash-desktop/src/main/core/projects/ensure-emdash-excluded.ts @@ -0,0 +1,51 @@ +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; + + const existing = (await fs.exists(GIT_EXCLUDE_PATH)) + ? (await fs.read(GIT_EXCLUDE_PATH)).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), + }); + }); +} From 5bb46e40227a657d7e737d9de95be81753705c33 Mon Sep 17 00:00:00 2001 From: Lorenzo Fiore Date: Thu, 25 Jun 2026 16:51:07 +0200 Subject: [PATCH 2/2] fix(projects): skip exclude rewrite on a truncated read Addresses a Greptile note on #2682: fs.read caps at a byte limit and a truncated view of .git/info/exclude could miss an entry past the cut, so rewriting it would drop the tail. Bail when the read was truncated. --- .../core/projects/ensure-emdash-excluded.test.ts | 16 ++++++++++++++-- .../main/core/projects/ensure-emdash-excluded.ts | 11 ++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) 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 index 2fda94c6c1..ea95d13b82 100644 --- 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 @@ -2,7 +2,11 @@ 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 }) { +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) => @@ -11,7 +15,7 @@ function makeFs(opts: { gitType?: 'dir' | 'file'; excludeContent?: string | null exists: vi.fn(async () => opts.excludeContent != null), read: vi.fn(async () => ({ content: opts.excludeContent ?? '', - truncated: false, + truncated: opts.truncated ?? false, totalSize: 0, })), write, @@ -55,4 +59,12 @@ describe('ensureEmdashGitExcluded', () => { 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 index 73f4a0a62b..5837c35e51 100644 --- a/apps/emdash-desktop/src/main/core/projects/ensure-emdash-excluded.ts +++ b/apps/emdash-desktop/src/main/core/projects/ensure-emdash-excluded.ts @@ -22,9 +22,14 @@ export async function ensureEmdashGitExcluded(fs: FileSystemProvider): Promise null); if (gitDir?.type !== 'dir') return; - const existing = (await fs.exists(GIT_EXCLUDE_PATH)) - ? (await fs.read(GIT_EXCLUDE_PATH)).content - : ''; + 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/)