Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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<void> {
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),
});
});
}
Loading