diff --git a/.claude/rules/git-and-io.md b/.claude/rules/git-and-io.md
index 76f680c..ee9822d 100644
--- a/.claude/rules/git-and-io.md
+++ b/.claude/rules/git-and-io.md
@@ -19,8 +19,13 @@
- 起動時 `preflight()`: git リポジトリか / HEAD があるか(コミット 0 では worktree を作れない)。
- 作成は現在の HEAD、`followOrigin` が有効なら `syncedStartPoint(base)` = `origin/` から切る。
**稼働中の worktree へ pull しない**(未コミット変更と競合する)。
-- `.git/info/exclude` へ `# codiva` マーカー付きで `.codiva/` を追記(冪等)。
- **対象リポジトリの `.gitignore` を書き換えない。**
+- **`.codiva/` を隠すのは `.codiva/.gitignore`(中身は `*` の 1 行)だけ**(`ensureIgnored()`。無いときだけ書く=冪等、
+ 失敗は握り潰す)。`*` は除外ファイル自身にも一致するのでディレクトリごと消える。
+ **対象リポジトリの `.gitignore` を書き換えない。`.git/info/exclude` にも戻さない** —
+ `.git` は linked worktree / submodule では**ファイル**なので追記が ENOTDIR で失敗する。
+ 副作用として ls-files が `.codiva/` を 1 件に畳まなくなるため、引き継ぎフィルタは
+ **先頭セグメント**で `.codiva` / `.git` 配下を落とす(`isInternalEntry()`。完全一致に戻すと
+ worktree の中へ worktree 群自身のリンクが張られ `worktree remove` が ELOOP で失敗する)。
- ignore 済みファイルの引き継ぎ `ignoredFiles`: `'symlink'`(既定・複製コストゼロ)/
`'copy'`(完全独立)/ `'none'`。列挙結果のフィルタは純関数 `ignoredCopyEntries()` で、
**`.codiva/` と `.git` は必ず除外**(再帰・内部状態破壊の防止)。実体化は entry 単位の
@@ -83,7 +88,7 @@
| `/.codiva/worktrees/` | セッションの worktree | `WorktreeManager` |
| `/.codiva/state.json` | 復元用メタ(会話ログは入れない) | `utils/state-store.ts` |
| `/.codiva/prompt.md` | リポジトリ追加指示(空保存で削除) | `utils/repo-prompt.ts` |
-| `/.git/info/exclude` | `# codiva` + `.codiva/` を追記 | `WorktreeManager` |
+| `/.codiva/.gitignore` | `*` の 1 行(`.codiva/` を丸ごと ignore) | `WorktreeManager.ensureIgnored` |
| `~/.codiva/config.json` | ユーザー設定 | `utils/config.ts` |
| `~/.codiva/logs/crash-<時刻>-.log` | クラッシュレポート(20 件でローテーション。**同期書き込み**) | `utils/crash-log.ts` |
| `~/.codiva/logs/report.*.json` | Node の診断レポート(OOM 等 JS が動けない死に方の唯一の記録) | Node(`process.report`) |
diff --git a/README.md b/README.md
index a187cca..6e08d01 100644
--- a/README.md
+++ b/README.md
@@ -69,7 +69,7 @@ codiva
3. セッションを選ぶと詳細ビューに入り、ログ閲覧・追加指示・許可応答ができます。
4. 完了したら diff stat を確認し、マージまたは破棄します。
-> worktree ディレクトリ `.codiva/` は対象リポジトリの `.git/info/exclude` に自動追記されるため、対象リポジトリの `.gitignore` を汚しません。
+> worktree ディレクトリ `.codiva/` には `*` の 1 行だけを書いた `.codiva/.gitignore` が自動生成され、`.codiva/` 以下がまるごと git から見えなくなります(除外ファイル自身も `*` に一致するので自己完結します)。対象リポジトリの `.gitignore` も `.git/` の中も書き換えません。
### 入力欄
@@ -281,7 +281,7 @@ Codiva v0.3.1 3 セッション
**既存のセッション worktree に残っているリンクは起動時に自動で外します**(以前のバージョンで作った worktree にも効きます)。外すのは**シンボリックリンクだけ**なので、リンク先(元リポジトリの中身)と、worktree 内に実体として存在するビルド結果には触りません。
-なお、監視対象そのものを減らしたい場合は、対象プロジェクト側で `.codiva` を `.gitignore` と開発サーバの監視除外設定に追加するのが確実です(codiva は対象リポジトリの `.gitignore` を書き換えないので、git 用には `.git/info/exclude` に `.codiva/` を追記するところまでを自動で行います)。
+なお、監視対象そのものを減らしたい場合は、対象プロジェクト側で `.codiva` を開発サーバの監視除外設定に追加するのが確実です(git から隠す方は codiva が `.codiva/.gitignore` を置いて自動で済ませます。対象リポジトリの `.gitignore` は書き換えません)。
### デスクトップ通知
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 6900809..f1b2e5c 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -424,8 +424,8 @@ interface SessionState {
- 前提チェック: Gitリポジトリか、HEAD が存在するか(コミット0のリポジトリでは worktree を作れない)。
- `add(slug)`: `git worktree add .codiva/worktrees/ -b codiva/` を現在の HEAD から作成。slug 衝突時は `-2`, `-3` を付与。
-- `.git/info/exclude` に `.codiva/` を自動追記(初回のみ)。
-- ignore 済みファイルの引き継ぎ: `ignoredFiles`(`'symlink'` | `'copy'` | `'none'`、既定 `'symlink'`)が `'none'` 以外なら、`git ls-files --others --ignored --exclude-standard --directory` で列挙した `.gitignore` 対象(`node_modules/`・`.env` など)をリポジトリルートから worktree へ引き継ぐ。git worktree は追跡対象しか引き継がないため、これで依存の再インストールや環境変数の再設定なしにセッションが即実行できる。`'symlink'` は `fs.symlink` で元へのリンクを張るだけ(複製コストゼロ・実体共有)、`'copy'` は `fs.cp` で実体を複製(worktree 完全独立・大きいと重い)。既定を `'symlink'` にしているのは、`node_modules/` 等の複製コストを避けて起動を速くするため。列挙結果のフィルタは純関数 `ignoredCopyEntries()` に切り出し(`.codiva/`・`.git` は再帰・内部状態破壊を避けるため必ず除外)、実体化はエントリ単位のベストエフォート(1件の失敗で worktree 作成を止めない)。設定値からモードへの解決は純関数 `resolveIgnoredFilesMode()`(非推奨 `copyIgnored` の後方互換: `true`→`'copy'` / `false`→`'none'`)。
+- `.codiva/.gitignore`(中身は `*` の 1 行)を自動生成(無いときだけ。起動時と `add()` の両方から `ensureIgnored()`)。`*` は同じディレクトリの `.gitignore` 自身にも一致するので、この 1 ファイルだけで `.codiva/` が丸ごと git から消える(cargo の `target/.gitignore` と同じ手)。**かつては `.git/info/exclude` へ追記していたが、`.git` はディレクトリとは限らない** — linked worktree / submodule では `gitdir:` を書いたただのファイルなので、そこで codiva を起動すると追記が ENOTDIR で失敗し、握り潰していなかったため worktree 作成ごと失敗していた。作業ツリー側のファイルなら git の内部配置に依存しない。既存の `.codiva/.gitignore` は上書きしない(利用者が例外を足しているかもしれない)。副作用として `git ls-files --others --ignored --directory` が `.codiva/` を 1 件に畳まなくなる(除外の出所がそのディレクトリの中にあるため git が中へ降りる)ので、引き継ぎ対象のフィルタは**先頭セグメント**で `.codiva` / `.git` を落とす(完全一致だと `.codiva/worktrees/` が引き継がれ、新 worktree の中に worktree 群自身へのリンクが張られて以後の `worktree remove` が ELOOP で失敗した)。
+- ignore 済みファイルの引き継ぎ: `ignoredFiles`(`'symlink'` | `'copy'` | `'none'`、既定 `'symlink'`)が `'none'` 以外なら、`git ls-files --others --ignored --exclude-standard --directory` で列挙した `.gitignore` 対象(`node_modules/`・`.env` など)をリポジトリルートから worktree へ引き継ぐ。git worktree は追跡対象しか引き継がないため、これで依存の再インストールや環境変数の再設定なしにセッションが即実行できる。`'symlink'` は `fs.symlink` で元へのリンクを張るだけ(複製コストゼロ・実体共有)、`'copy'` は `fs.cp` で実体を複製(worktree 完全独立・大きいと重い)。既定を `'symlink'` にしているのは、`node_modules/` 等の複製コストを避けて起動を速くするため。列挙結果のフィルタは純関数 `ignoredCopyEntries()` に切り出し(`.codiva/`・`.git` は再帰・内部状態破壊を避けるため、**配下も含めて先頭セグメントで**必ず除外)、実体化はエントリ単位のベストエフォート(1件の失敗で worktree 作成を止めない)。設定値からモードへの解決は純関数 `resolveIgnoredFilesMode()`(非推奨 `copyIgnored` の後方互換: `true`→`'copy'` / `false`→`'none'`)。
- **ビルド生成物・キャッシュはモードに関係なく引き継がない**(`DEFAULT_IGNORED_EXCLUDES`)。`.gitignore` は依存(引き継ぎたい)と生成物(引き継ぎたくない)を区別しないため、`.next` / `dist` / `target` / `coverage` / `*.tsbuildinfo` 等の**既知の名前を列挙**して除外する。理由は (1) 生成物なのでセッション側で作り直せる、(2) 共有すると元リポジトリと各 worktree の開発サーバ/ビルドが同じ実体へ同時に書き込む、(3) worktree がリポジトリ配下(`.codiva/worktrees/`)にあるため、**ルートで再帰監視している開発サーバ(Next.js / Turbopack)からは自分が書き込んでいるディレクトリが worktree の数だけ別経路として見え**、変更通知が多重に跳ね返って OS ごとフリーズする(issue #81 の実測: worktree 6 個 + `next dev --turbopack`)。判定は純関数 `isExcludedIgnoredEntry()`(`/` 無しのパターンは**最終セグメント**に一致 = ネストした `apps/web/.next/` にも効く / `*` 前置は接尾一致 / **最後に一致したパターンが勝つ**)。名前の列挙は必ず外れるので、設定 `ignoredFilesExclude` で追加(`.venv`)と打ち消し(`!dist`)ができる(`ignoredExcludePatterns()` が既定の後ろに連結)。**リポジトリの `.gitignore` は書き換えない**方針は維持し、監視除外は利用者の設定に委ねる(README の該当節)。
- **既存 worktree の後片付け**: 除外リストを増やしても、以前のバージョンが張ったリンクは残る(= フリーズ要因も残る)ので、起動時に `pruneExcludedLinks()`(`index.tsx` の preflight 直後・best-effort)で「いまの設定なら引き継がないエントリ」のリンクだけを外す。対象の列挙は純関数 `excludedIgnoredEntries()`(`ignoredCopyEntries()` の裏返し)で、worktree の中を走査しないのでコストは一定。**シンボリックリンクしか消さない**のが安全弁で、実体のディレクトリ(セッション自身のビルド結果でありうる)とリンク先(元リポジトリ)には触らない。
- `diffStat(session)`: `git -C diff ...HEAD --stat` 相当。未コミット変更がある場合はその旨も返す。
@@ -918,7 +918,7 @@ Ink 7.1.1 は `measure-text.js` と `wrap-text.js` で**上限のないモジュ
| セッション = SDK `query()` 1本(サブプロセス1本) | SDK の設計単位に素直。プロセス分離により1セッションのクラッシュが他に波及しない |
| streaming input を常用(単発 prompt を使わない) | 追加指示(F-6)と質問への回答(F-7)を同一機構で実現でき、セッションを開いたまま維持できる |
| コアと UI の分離 + queryFn の DI | SDK もネットワークも不要なユニットテストを可能にする(N-3 の 80% カバレッジはこれが前提) |
-| worktree は `.codiva/worktrees/` 配下、exclude は `.git/info/exclude` | 対象リポジトリのファイルを一切汚染しない |
+| worktree は `.codiva/worktrees/` 配下、ignore は `.codiva/.gitignore`(`*`)で自己完結 | 対象リポジトリのファイルも `.git/` の中も汚染しない。`.git` がファイルの環境(linked worktree / submodule)でも動く |
| アプリ終了時に worktree を消さない | N-4(作業内容の保全)。明示的な削除操作のみで消す |
## リスクと対応
diff --git a/docs/PRD.md b/docs/PRD.md
index 7683c5f..2931580 100644
--- a/docs/PRD.md
+++ b/docs/PRD.md
@@ -62,7 +62,7 @@ codiva は、対象のGitリポジトリで起動する TUI アプリケーシ
- 実行環境: Node.js >= 20、macOS / Linux。ユーザーは `claude` CLI にログイン済み(または `ANTHROPIC_API_KEY` 設定済み)であること。
- 技術スタック: TypeScript (strict, ESM) + Ink 7 + @anthropic-ai/claude-agent-sdk。詳細は TECH_NOTES.md。
-- worktree ディレクトリ `.codiva/` は対象リポジトリの `.git/info/exclude` に自動追記する(対象リポジトリの `.gitignore` を汚さない)。
+- worktree ディレクトリ `.codiva/` は `.codiva/.gitignore`(中身は `*` の 1 行)で自己完結的に ignore する(対象リポジトリの `.gitignore` も `.git/` の中も書き換えない)。
## 成功基準(MVP受け入れシナリオ)
diff --git a/docs/TECH_NOTES.md b/docs/TECH_NOTES.md
index 0085605..29674da 100644
--- a/docs/TECH_NOTES.md
+++ b/docs/TECH_NOTES.md
@@ -544,7 +544,9 @@ git merge --no-ff codiva/
- コミットが1つもないリポジトリでは worktree を作れない → 起動時チェックで弾く(F-1)。
- 同名ブランチ/worktree の衝突: slug に連番を付与。
-- `.git/info/exclude` への追記は `# codiva` マーカー行で冪等にする。
+- **`.codiva/` を git から隠すのは `.codiva/.gitignore`(中身は `*` の 1 行)**。`*` は同じディレクトリの `.gitignore` 自身にも一致するので、この 1 ファイルだけでディレクトリ全体が ignore される(cargo の `target/.gitignore` と同じ手)。無いときだけ書くので冪等。
+ - **`.git/info/exclude` への追記に戻さない**。`.git` はディレクトリとは限らない — linked worktree(`git worktree add` で作った作業ツリー)と submodule では `gitdir: ` を書いた**ただのファイル**なので、`/.git/info/exclude` への `appendFile` が **ENOTDIR** で失敗する(実測)。codiva 自身のリポジトリを codiva の worktree の中から開いた場合がまさにこれで、握り潰していなかったため worktree 作成ごと失敗していた。
+ - **副作用**: `.gitignore` の出所がそのディレクトリの中にあると、`git ls-files --others --ignored --exclude-standard --directory` は `.codiva/` を 1 件に畳まず中身まで列挙する(実測: `.codiva/` / `.codiva/.gitignore` / `.codiva/state.json` / `.codiva/worktrees/` の 4 件)。ignore 済みファイルの引き継ぎフィルタが完全一致(`entry === '.codiva'`)だと `.codiva/worktrees/` が引き継ぎ対象に化け、新しい worktree の中に worktree 群自身へのリンクが張られる → 以後の `git worktree remove` が `Too many levels of symbolic links` で失敗した。**先頭セグメントで判定する**(`isInternalEntry()`)。
- git 実行は必ず `execFile`(シェル経由禁止。slug はサニタイズ済みだが多層防御)。
- **worktree がリポジトリ配下にあることの副作用(issue #81 の実測)**: `.codiva/worktrees/` は
プロジェクトルートの下なので、**ルートから再帰的にファイル監視する開発サーバ**(Next.js /
@@ -554,9 +556,9 @@ git merge --no-ff codiva/
CPU / メモリ / FD を食い潰し **OS ごとフリーズ**(`node_modules` も同様に多重経路になる)。
→ 対策として、ビルド生成物・キャッシュは引き継ぎ対象から外した(`DEFAULT_IGNORED_EXCLUDES`。
`node_modules` は依存なので引き継ぎを維持 —— Next.js / Vite 等の監視は `node_modules` を
- 既定で除外する)。監視そのものを軽くしたい場合は、利用者側で `.codiva` を `.gitignore` と
+ 既定で除外する)。監視そのものを軽くしたい場合は、利用者側で `.codiva` を
開発サーバの監視除外に足すのが確実(**codiva は対象リポジトリの `.gitignore` を書き換えない**ので、
- git 用の除外は `.git/info/exclude` までに留める)。worktree の置き場所をリポジトリ外へ移す案は
+ git 用の除外は `.codiva/.gitignore` = 自分の持ち物の中までに留める)。worktree の置き場所をリポジトリ外へ移す案は
取らない(`.codiva/worktrees/` 前提のパス・復元・`takenSlugs()` を崩さないため)。
## テスト戦略
diff --git a/src/core/worktree.spec.ts b/src/core/worktree.spec.ts
index 2736669..5dd9464 100644
--- a/src/core/worktree.spec.ts
+++ b/src/core/worktree.spec.ts
@@ -17,6 +17,21 @@ describe('ignoredCopyEntries', () => {
expect(ignoredCopyEntries('')).toEqual([]);
});
+ // `.codiva/.gitignore`(中身 `*`)を置くと ls-files が `.codiva/` を 1 件に畳まず
+ // 中身まで列挙する。`.codiva/worktrees/` を引き継ぐと新 worktree の中へ worktree 群
+ // 自身へのリンクが張られ、以後の `git worktree remove` が ELOOP で失敗する。
+ it('drops everything under .codiva/ and .git/, not just the exact dir', () => {
+ const raw = [
+ '.codiva/',
+ '.codiva/.gitignore',
+ '.codiva/state.json',
+ '.codiva/worktrees/',
+ '.git/hooks/',
+ '.env',
+ ].join('\n');
+ expect(ignoredCopyEntries(raw)).toEqual(['.env']);
+ });
+
// issue #81: 生成物を共有すると開発サーバ同士が同じ実体へ書き込み、
// ルートで再帰監視する開発サーバからは worktree の数だけ同じ木が見えてフリーズする。
it('drops build output and caches by default', () => {
diff --git a/src/core/worktree.ts b/src/core/worktree.ts
index b844633..d20c142 100644
--- a/src/core/worktree.ts
+++ b/src/core/worktree.ts
@@ -167,6 +167,22 @@ export function ignoredExcludePatterns(extra?: readonly string[]): readonly stri
: DEFAULT_IGNORED_EXCLUDES;
}
+/**
+ * codiva 自身の作業ディレクトリ(`.codiva/`)と git の内部(`.git`)配下か。
+ *
+ * **先頭セグメントで判定する**(`entry === '.codiva'` の完全一致では足りない)。
+ * `.codiva/.gitignore` を置いてからは `git ls-files --others --ignored --directory` が
+ * ディレクトリを 1 件に畳まず、`.codiva/` に加えて `.codiva/.gitignore` /
+ * `.codiva/worktrees/` まで個別に列挙する(除外の出所がそのディレクトリの中にあるため
+ * git が中へ降りる)。完全一致だけだと `.codiva/worktrees/` が引き継ぎ対象に化け、
+ * 新しい worktree の中へ worktree 群自身へのリンクが張られる(実測: 以後の
+ * `git worktree remove` が ELOOP「Too many levels of symbolic links」で失敗した)。
+ */
+function isInternalEntry(entry: string): boolean {
+ const head = entry.replace(/\/+$/, '').split('/')[0];
+ return head === CODIVA_DIR || head === '.git';
+}
+
/**
* `ignoredCopyEntries()` の裏返し: 生出力のうち**除外されたエントリ**を返す純関数
* (`.codiva/` と `.git` はそもそも引き継がないのでここにも含めない)。
@@ -183,13 +199,7 @@ export function excludedIgnoredEntries(
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
- .filter((entry) => {
- const normalized = entry.replace(/\/$/, '');
- if (normalized === CODIVA_DIR || normalized === '.git') {
- return false;
- }
- return isExcludedIgnoredEntry(entry, excludes);
- });
+ .filter((entry) => !isInternalEntry(entry) && isExcludedIgnoredEntry(entry, excludes));
}
/**
@@ -210,11 +220,5 @@ export function ignoredCopyEntries(
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
- .filter((entry) => {
- const normalized = entry.replace(/\/$/, '');
- if (normalized === CODIVA_DIR || normalized === '.git') {
- return false;
- }
- return !isExcludedIgnoredEntry(entry, excludes);
- });
+ .filter((entry) => !isInternalEntry(entry) && !isExcludedIgnoredEntry(entry, excludes));
}
diff --git a/src/main.tsx b/src/main.tsx
index 04f3829..af9e9df 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -90,6 +90,10 @@ async function main(): Promise {
process.stderr.write(`codiva: ${errorMessage(err)}\n`);
process.exit(1);
}
+ // `.codiva/` を git から隠す(中身が `*` だけの `.codiva/.gitignore`)。セッションを 1 つも
+ // 作らなくても `.codiva/state.json` / `prompt.md` は書かれるので、worktree 作成時ではなく
+ // 起動時に置く。best-effort で起動は止めない。
+ await worktrees.ensureIgnored().catch(() => undefined);
// 以前のバージョン(あるいは前回の設定)が張った「もう引き継がないパス」のリンクを外す。
// 残っていると生成物の共有=開発サーバのフリーズ要因も残るため(issue #81)。リンクだけを
// 外すので指し先は無傷。best-effort で起動は止めない。
diff --git a/src/utils/worktree-manager.spec.ts b/src/utils/worktree-manager.spec.ts
index 4e88b10..bb06df8 100644
--- a/src/utils/worktree-manager.spec.ts
+++ b/src/utils/worktree-manager.spec.ts
@@ -78,13 +78,42 @@ describe('WorktreeManager', () => {
repo = await makeRepo(true);
});
- it('creates a worktree on a codiva/ branch and excludes .codiva', async () => {
+ it('creates a worktree on a codiva/ branch and ignores .codiva', async () => {
const wm = new WorktreeManager(repo);
const wt = await wm.add('feature');
expect(wt.branch).toBe('codiva/feature');
expect(wt.path).toContain(join('.codiva', 'worktrees', 'feature'));
- const exclude = await readFile(join(repo, '.git', 'info', 'exclude'), 'utf8');
- expect(exclude).toContain('.codiva/');
+ // `.codiva/.gitignore` の `*` は自分自身にも一致するので、`.codiva/` は
+ // 中身ごと git から消える(対象リポジトリの .gitignore も .git も触らない)。
+ expect(await readFile(join(repo, '.codiva', '.gitignore'), 'utf8')).toBe('*\n');
+ expect((await g(repo, 'status', '--porcelain')).stdout).toBe('');
+ expect((await g(repo, 'check-ignore', '.codiva/.gitignore')).stdout).toContain(
+ '.codiva/.gitignore',
+ );
+ });
+
+ it('leaves an existing .codiva/.gitignore alone', async () => {
+ await mkdir(join(repo, '.codiva'), { recursive: true });
+ await writeFile(join(repo, '.codiva', '.gitignore'), '*\n!keep-me\n');
+ await new WorktreeManager(repo).add('feature');
+ expect(await readFile(join(repo, '.codiva', '.gitignore'), 'utf8')).toBe('*\n!keep-me\n');
+ });
+
+ // `.git` は linked worktree / submodule では `gitdir:` を書いたただのファイル。
+ // `.git/info/exclude` へ追記していた頃はここで ENOTDIR になり worktree 作成ごと失敗した。
+ it('works when the repo root is itself a linked worktree (.git is a file)', async () => {
+ const parent = await mkdtemp(join(tmpdir(), 'codiva-linked-'));
+ const linked = join(parent, 'checkout');
+ try {
+ await g(repo, 'worktree', 'add', linked, '-b', 'linked');
+ expect((await lstat(join(linked, '.git'))).isFile()).toBe(true);
+ const wt = await new WorktreeManager(linked).add('nested');
+ expect(wt.branch).toBe('codiva/nested');
+ expect(await readFile(join(linked, '.codiva', '.gitignore'), 'utf8')).toBe('*\n');
+ expect((await g(linked, 'status', '--porcelain')).stdout).toBe('');
+ } finally {
+ await rm(parent, { recursive: true, force: true });
+ }
});
it('reports committed and uncommitted changes via diffStat', async () => {
diff --git a/src/utils/worktree-manager.ts b/src/utils/worktree-manager.ts
index 6aca0eb..9074d10 100644
--- a/src/utils/worktree-manager.ts
+++ b/src/utils/worktree-manager.ts
@@ -1,14 +1,4 @@
-import {
- appendFile,
- cp,
- lstat,
- mkdir,
- readdir,
- readFile,
- rm,
- symlink,
- unlink,
-} from 'node:fs/promises';
+import { cp, lstat, mkdir, readdir, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import {
CODIVA_DIR,
@@ -25,7 +15,12 @@ import {
import { GitError, git } from './git';
const WORKTREES_SUBDIR = join(CODIVA_DIR, 'worktrees');
-const EXCLUDE_MARKER = '# codiva';
+/**
+ * `.codiva/.gitignore` の中身。`*` は同じディレクトリにある `.gitignore` 自身にも
+ * 一致するので、この 1 行だけで `.codiva/` が丸ごと(この除外ファイルごと)git から
+ * 見えなくなる(cargo が `target/.gitignore` でやっているのと同じ手)。
+ */
+const SELF_IGNORE = '*\n';
/**
* Paths out of `git status --porcelain` output.
@@ -46,8 +41,9 @@ function porcelainPaths(raw: string): string[] {
/**
* Creates and tears down git worktrees for sessions. Every worktree lives under
* `.codiva/worktrees/` on branch `codiva/`, branched from the repo's
- * current HEAD. The repo's own files are never modified except a one-time
- * `.git/info/exclude` entry for `.codiva/`.
+ * current HEAD. The repo's own files are never modified — the only thing codiva
+ * writes outside its worktrees is `.codiva/.gitignore` (a single `*`), which hides
+ * `.codiva/` from git without touching the repo's `.gitignore` or git internals.
*
* I/O ラッパ(fs + git 実行の具象)なので utils レイヤに置く。純粋な型・判定
* (Worktree / DiffStat / MergeConflictError / ignoredCopyEntries)は core/worktree.ts。
@@ -122,14 +118,30 @@ export class WorktreeManager {
return taken;
}
- private async ensureExcluded(): Promise {
- const excludePath = join(this.repoRoot, '.git', 'info', 'exclude');
- const current = await readFile(excludePath, 'utf8').catch(() => '');
- if (current.includes(EXCLUDE_MARKER)) {
+ /**
+ * `.codiva/` を git から隠す。作業ツリー側に `.codiva/.gitignore`(中身は `*`)を置くだけで、
+ * 除外ファイル自身を含むディレクトリ全体が ignore される(`SELF_IGNORE` 参照)。
+ *
+ * かつては `.git/info/exclude` へ `.codiva/` を追記していたが、**`.git` はディレクトリとは
+ * 限らない**(linked worktree や submodule では `gitdir:` を書いたただのファイル)。そこで
+ * codiva を起動すると追記が ENOTDIR で失敗し、握り潰していなかったため worktree 作成ごと
+ * 失敗していた。作業ツリー側のファイルなら git の内部配置に依存せず、対象リポジトリの
+ * `.gitignore` も汚さない(`.codiva/` の中は codiva の持ち物)。
+ *
+ * 既にファイルがあれば触らない(利用者が例外パターンを足しているかもしれない)。
+ * 呼び出し側は失敗を握り潰す — 除外はあくまで気遣いで、セッションの動作には要らない。
+ */
+ async ensureIgnored(): Promise {
+ const path = join(this.repoRoot, CODIVA_DIR, '.gitignore');
+ const exists = await stat(path).then(
+ () => true,
+ () => false,
+ );
+ if (exists) {
return;
}
- const prefix = current.length > 0 && !current.endsWith('\n') ? '\n' : '';
- await appendFile(excludePath, `${prefix}${EXCLUDE_MARKER}\n${CODIVA_DIR}/\n`);
+ await mkdir(join(this.repoRoot, CODIVA_DIR), { recursive: true });
+ await writeFile(path, SELF_IGNORE);
}
/**
@@ -139,7 +151,7 @@ export class WorktreeManager {
* origin-follow starts work from the latest upstream commit.
*/
async add(slug: string, startPoint?: string): Promise {
- await this.ensureExcluded();
+ await this.ensureIgnored().catch(() => undefined);
await mkdir(join(this.repoRoot, WORKTREES_SUBDIR), { recursive: true });
const relPath = join(WORKTREES_SUBDIR, slug);
const branch = `codiva/${slug}`;