Skip to content

fix(projects): keep emdash's .emdash runtime dir out of git status#2682

Open
fiorelorenzo wants to merge 2 commits into
generalaction:mainfrom
fiorelorenzo:fix/emdash-runtime-dir-git-ignored
Open

fix(projects): keep emdash's .emdash runtime dir out of git status#2682
fiorelorenzo wants to merge 2 commits into
generalaction:mainfrom
fiorelorenzo:fix/emdash-runtime-dir-git-ignored

Conversation

@fiorelorenzo

Copy link
Copy Markdown
Contributor

What

emdash keeps per-project runtime state under .emdash/ inside the repo: the SSH worktree pool (getDefaultSshWorktreeDirectory -> .emdash/worktrees), saved attachments (saveAttachment -> .emdash/attachments), and uploaded images (#2681 -> .emdash/uploads). Nothing in the app ensured .emdash/ was git-ignored, so it only stayed out of git status if the user happened to have .emdash in a global gitignore. Without that, .emdash/ shows up as untracked and clutters the working tree.

This adds .emdash/ to the repo's .git/info/exclude when a project is opened.

Why info/exclude

.git/info/exclude is a local, uncommitted ignore file, so this never touches the user's tracked .gitignore. It lives in the git common dir, so a single entry on the main checkout also covers every linked task worktree (where the per-worktree .emdash/uploads is written).

Details

  • New helper ensureEmdashGitExcluded(fs) in src/main/core/projects/ensure-emdash-excluded.ts, called fire-and-forget from buildProvider so it runs once per project open for both local and SSH providers.
  • Uses the FileSystemProvider abstraction (stat / exists / read / write) so it works over both local and SSH without shelling out.
  • Best effort and idempotent: skips repos whose .git is a file (linked worktree / submodule, where the exclude is out of the fs root) and skips when .emdash is already ignored. Failures are logged, never thrown.

Testing

  • Unit test covering: skip on non-directory .git, create the entry when info/exclude is missing, append while preserving existing content, and no-op when .emdash (with or without trailing slash) is already present.
  • Manually verified the underlying behavior: with core.excludesfile=/dev/null, a fresh repo shows .emdash/ as untracked; after appending .emdash/ to .git/info/exclude it disappears from git status.
  • format, lint, typecheck and tests pass.

Relationship

Relates to #2680 and complements #2681. #2681 moves SSH image uploads into .emdash/uploads; this PR makes sure .emdash/ (uploads, worktree pool, and attachments) is actually ignored even for users without a global .emdash gitignore.

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 generalaction#2680 and complements generalaction#2681, which moves SSH image uploads into
`.emdash/uploads`.
@greptile-apps

greptile-apps Bot commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds a fire-and-forget helper that appends .emdash/ to .git/info/exclude on each project open, keeping emdash's runtime state directory (worktree pool, attachments, uploads) out of git status without touching any tracked file. The approach is clean: it uses the existing FileSystemProvider abstraction so it works over SSH transparently, and it is idempotent and best-effort by design.

  • New ensureEmdashGitExcluded / ensureEmdashGitExcludedSafe in ensure-emdash-excluded.ts: skips linked worktrees (.git is a file), skips when the pattern is already present, otherwise appends .emdash/ and preserves existing content.
  • Integrated into buildProvider with a single fire-and-forget call that covers both local and SSH project providers.
  • Unit tests cover the main branches: no .git, .git as file, missing exclude, append, and both slash/no-slash variants of the already-excluded check.

Confidence Score: 4/5

Safe to merge; the change is non-blocking and isolated to a best-effort file write that can only fail silently.

The implementation is well-structured and the happy paths are thoroughly tested. The one gap worth noting: ReadResult.truncated is not checked before rewriting the exclude file, which could theoretically overwrite content if the file were ever read in truncated form — in practice .git/info/exclude files are always tiny, so this is a hardening concern rather than a real risk today.

ensure-emdash-excluded.ts — the truncated-read guard is the only thing worth revisiting before this pattern is reused for larger files.

Important Files Changed

Filename Overview
apps/emdash-desktop/src/main/core/projects/ensure-emdash-excluded.ts New helper that appends .emdash/ to .git/info/exclude; logic is correct and well-guarded, but ignores ReadResult.truncated which could corrupt the file in an unlikely edge case.
apps/emdash-desktop/src/main/core/projects/ensure-emdash-excluded.test.ts Good unit test coverage for the main happy paths and skip conditions; no test for the truncated-read branch or write-failure propagation, but those are minor gaps.
apps/emdash-desktop/src/main/core/projects/create-project-provider.ts Minimal, correct integration: calls ensureEmdashGitExcludedSafe fire-and-forget from buildProvider, which runs for both local and SSH providers on every project open.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["buildProvider(projectFs, projectId, ...)"] --> B["ensureEmdashGitExcludedSafe(fs, projectId)"]
    B --> C["ensureEmdashGitExcluded(fs) — async"]
    C --> D["fs.stat('.git')"]
    D -->|"null or type !== 'dir'"| E["return — skip linked worktree / no git"]
    D -->|"type === 'dir'"| F["fs.exists('.git/info/exclude')"]
    F -->|"false"| G["existing = ''"]
    F -->|"true"| H["fs.read('.git/info/exclude')"]
    H --> I["check ReadResult.truncated ⚠️ currently ignored"]
    I --> J["existing = content"]
    G & J --> K["split lines, trim, check for '.emdash' or '.emdash/'"]
    K -->|"already present"| L["return — idempotent no-op"]
    K -->|"not present"| M["build new content: existing + '.emdash/\\n'"]
    M --> N["fs.write('.git/info/exclude', next)"]
    N -->|"result.success === false"| O["throw Error — caught by Safe wrapper"]
    N -->|"success"| P["done"]
    O --> Q["log.warn with projectId + message"]
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A["buildProvider(projectFs, projectId, ...)"] --> B["ensureEmdashGitExcludedSafe(fs, projectId)"]
    B --> C["ensureEmdashGitExcluded(fs) — async"]
    C --> D["fs.stat('.git')"]
    D -->|"null or type !== 'dir'"| E["return — skip linked worktree / no git"]
    D -->|"type === 'dir'"| F["fs.exists('.git/info/exclude')"]
    F -->|"false"| G["existing = ''"]
    F -->|"true"| H["fs.read('.git/info/exclude')"]
    H --> I["check ReadResult.truncated ⚠️ currently ignored"]
    I --> J["existing = content"]
    G & J --> K["split lines, trim, check for '.emdash' or '.emdash/'"]
    K -->|"already present"| L["return — idempotent no-op"]
    K -->|"not present"| M["build new content: existing + '.emdash/\\n'"]
    M --> N["fs.write('.git/info/exclude', next)"]
    N -->|"result.success === false"| O["throw Error — caught by Safe wrapper"]
    N -->|"success"| P["done"]
    O --> Q["log.warn with projectId + message"]
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
apps/emdash-desktop/src/main/core/projects/ensure-emdash-excluded.ts:27-28
**Truncated read could corrupt `info/exclude`**

`fs.read` silently truncates files larger than the default 200 KB cap, and the returned `ReadResult.truncated` flag is never inspected. In that scenario the code would see a partial view of the file, fail to detect an existing `.emdash` entry that sits past the truncation point, and then overwrite the file with the truncated prefix plus the new entry — destroying any rules that followed the cut-off. A `.git/info/exclude` file is almost never that large in practice, but the guard is cheap to add.

```suggestion
  let existing = '';
  if (await fs.exists(GIT_EXCLUDE_PATH)) {
    const readResult = await fs.read(GIT_EXCLUDE_PATH);
    if (readResult.truncated) return; // can't safely rewrite a file we can't fully read
    existing = readResult.content;
  }
```

Reviews (1): Last reviewed commit: "fix(projects): keep emdash's .emdash run..." | Re-trigger Greptile

Comment on lines +27 to +28
: '';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Truncated read could corrupt info/exclude

fs.read silently truncates files larger than the default 200 KB cap, and the returned ReadResult.truncated flag is never inspected. In that scenario the code would see a partial view of the file, fail to detect an existing .emdash entry that sits past the truncation point, and then overwrite the file with the truncated prefix plus the new entry — destroying any rules that followed the cut-off. A .git/info/exclude file is almost never that large in practice, but the guard is cheap to add.

Suggested change
: '';
let existing = '';
if (await fs.exists(GIT_EXCLUDE_PATH)) {
const readResult = await fs.read(GIT_EXCLUDE_PATH);
if (readResult.truncated) return; // can't safely rewrite a file we can't fully read
existing = readResult.content;
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/emdash-desktop/src/main/core/projects/ensure-emdash-excluded.ts
Line: 27-28

Comment:
**Truncated read could corrupt `info/exclude`**

`fs.read` silently truncates files larger than the default 200 KB cap, and the returned `ReadResult.truncated` flag is never inspected. In that scenario the code would see a partial view of the file, fail to detect an existing `.emdash` entry that sits past the truncation point, and then overwrite the file with the truncated prefix plus the new entry — destroying any rules that followed the cut-off. A `.git/info/exclude` file is almost never that large in practice, but the guard is cheap to add.

```suggestion
  let existing = '';
  if (await fs.exists(GIT_EXCLUDE_PATH)) {
    const readResult = await fs.read(GIT_EXCLUDE_PATH);
    if (readResult.truncated) return; // can't safely rewrite a file we can't fully read
    existing = readResult.content;
  }
```

How can I resolve this? If you propose a fix, please make it concise.

Addresses a Greptile note on generalaction#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.
@fiorelorenzo

Copy link
Copy Markdown
Contributor Author

Good hardening note. Pushed a follow-up that bails out when the .git/info/exclude read comes back truncated, so a partial view can never overwrite the tail of the file. Added a test for it too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant