fix(projects): keep emdash's .emdash runtime dir out of git status#2682
fix(projects): keep emdash's .emdash runtime dir out of git status#2682fiorelorenzo wants to merge 2 commits into
Conversation
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 SummaryThis PR adds a fire-and-forget helper that appends
Confidence Score: 4/5Safe 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: ensure-emdash-excluded.ts — the truncated-read guard is the only thing worth revisiting before this pattern is reused for larger files.
|
| 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"]
%%{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"]
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
| : ''; | ||
|
|
There was a problem hiding this 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.
| : ''; | |
| 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.
|
Good hardening note. Pushed a follow-up that bails out when the |
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 ofgit statusif the user happened to have.emdashin a global gitignore. Without that,.emdash/shows up as untracked and clutters the working tree.This adds
.emdash/to the repo's.git/info/excludewhen a project is opened.Why info/exclude
.git/info/excludeis 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/uploadsis written).Details
ensureEmdashGitExcluded(fs)insrc/main/core/projects/ensure-emdash-excluded.ts, called fire-and-forget frombuildProviderso it runs once per project open for both local and SSH providers.FileSystemProviderabstraction (stat/exists/read/write) so it works over both local and SSH without shelling out..gitis a file (linked worktree / submodule, where the exclude is out of the fs root) and skips when.emdashis already ignored. Failures are logged, never thrown.Testing
.git, create the entry wheninfo/excludeis missing, append while preserving existing content, and no-op when.emdash(with or without trailing slash) is already present.core.excludesfile=/dev/null, a fresh repo shows.emdash/as untracked; after appending.emdash/to.git/info/excludeit disappears fromgit status.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.emdashgitignore.