Skip to content

Commit 320ff5a

Browse files
authored
feat: worktree の ignore ファイル引き継ぎに symlink モードを追加し既定化 (#45)
## 概要 git worktree を切るとき、`.gitignore` された未追跡ファイル(`node_modules/` や `.env` など)はこれまで常に **実体コピー**(`fs.cp`)でリポジトリルートから複製していました。`node_modules/` が巨大だとこのコピーが重く、セッション起動が遅くなります。 引き継ぎ方法を設定 `ignoredFiles` で切り替えられるようにし、**既定をシンボリックリンクに変更**しました。 | モード | 挙動 | メリット | |--------|------|----------| | `symlink`(**既定**) | 元へシンボリックリンクを張るだけ(`fs.symlink`) | 複製コストゼロで即起動。実体は共有される | | `copy` | 実体を複製(`fs.cp`、従来の挙動) | worktree が完全独立し作業が**絶対に重複しない** | | `none` | 何も引き継がない | — | ## 変更点 - `src/core/worktree.ts`: `IgnoredFilesMode` 型を追加。`WorktreeOptions.copyIgnored`(boolean)を `ignoredFiles`(3値、既定 `'symlink'`)へ変更。 - `src/core/config.ts`: 設定に `ignoredFiles` を追加+検証。設定→モードを解決する純関数 `resolveIgnoredFilesMode()` を追加。非推奨の `copyIgnored` は後方互換で解釈(`true`→`copy` / `false`→`none`、`ignoredFiles` があればそちら優先)。 - `src/utils/worktree-manager.ts`: モードに応じて `symlink` / `cp` を切替。`--directory` が返す末尾 `/` を剥がしてから実体化(`path.join` が末尾スラッシュを保持し `symlink` が ENOENT になるため)。ベストエフォート(1件失敗で worktree 作成を止めない)は維持。 - `src/index.tsx`: `resolveIgnoredFilesMode(config)` を注入。 - README / ARCHITECTURE の設定ドキュメントを更新。 ## 後方互換 - `~/.codiva/config.json` の既存 `copyIgnored` 設定はそのまま動作します(`true`→copy、`false`→none)。 - 設定していない既存ユーザーは、次回から自動的に `symlink` モードになります。 ## テスト計画 - [x] `resolveIgnoredFilesMode`(既定・3値・`copyIgnored` 後方互換・優先順位)の単体テスト - [x] `toConfig` の `ignoredFiles` 検証(有効値・不正値ドロップ・全キー統合) - [x] WorktreeManager: symlink 既定(`lstat` でリンク確認+内容読み取り)/ copy モード(非リンク+独立性)/ none スキップ / `.codiva` 非リンク - [x] `npm run typecheck` / `npm run lint` / `npm test`(677 tests pass)
1 parent c8b1691 commit 320ff5a

8 files changed

Lines changed: 162 additions & 33 deletions

File tree

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,12 +59,16 @@ codiva
5959
```json
6060
{
6161
"language": "auto",
62-
"copyIgnored": true
62+
"ignoredFiles": "symlink"
6363
}
6464
```
6565

6666
- `language`: `"ja"` / `"en"` / `"auto"`(OS ロケール準拠)。環境変数 `CODIVA_LANG``ja` / `en`)が最優先です。
67-
- `copyIgnored`: セッション用 worktree を作るとき、`.gitignore` された未追跡ファイル(`node_modules/``.env` など)をリポジトリルートから複製するか。既定 `true`。git worktree は追跡対象しか引き継がないため、これにより依存の再インストールや環境変数の再設定なしにセッションを即実行できます(`false` で無効化)。
67+
- `ignoredFiles`: セッション用 worktree を作るとき、`.gitignore` された未追跡ファイル(`node_modules/``.env` など)をどう引き継ぐか。git worktree は追跡対象しか引き継がないため、これがないと依存の再インストールや環境変数の再設定が必要になります。既定 `"symlink"`
68+
- `"symlink"`(既定): リポジトリルートへシンボリックリンクを張るだけ。複製コストがゼロで即起動できます。実体を共有するため、ビルド生成物の書き込みなどが元やほかの worktree に波及しうる点に注意。
69+
- `"copy"`: リポジトリルートから実体を複製します。worktree が完全に独立し作業が絶対に重複しませんが、`node_modules/` が巨大だとコピーが重くなります。
70+
- `"none"`: 何も引き継ぎません(依存や環境変数はセッション側で用意し直す)。
71+
- 非推奨の `copyIgnored`(真偽値)も後方互換で解釈します(`true``copy` 相当、`false``none` 相当)。`ignoredFiles` があればそちらが優先されます。
6872

6973
## 開発
7074

docs/ARCHITECTURE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@ interface SessionState {
202202
- 前提チェック: Gitリポジトリか、HEAD が存在するか(コミット0のリポジトリでは worktree を作れない)。
203203
- `add(slug)`: `git worktree add .codiva/worktrees/<slug> -b codiva/<slug>` を現在の HEAD から作成。slug 衝突時は `-2`, `-3` を付与。
204204
- `.git/info/exclude``.codiva/` を自動追記(初回のみ)。
205-
- ignore 済みファイルの複製: `copyIgnored`既定 true)が有効なら`git ls-files --others --ignored --exclude-standard --directory` で列挙した `.gitignore` 対象(`node_modules/``.env` など)をリポジトリルートから worktree `fs.cp` で複製する。git worktree は追跡対象しか引き継がないため、これで依存の再インストールや環境変数の再設定なしにセッションが即実行できる。列挙結果のフィルタは純関数 `ignoredCopyEntries()` に切り出し(`.codiva/``.git` は再帰・内部状態破壊を避けるため必ず除外)、コピー自体はエントリ単位のベストエフォート(1件の失敗で worktree 作成を止めない)。
205+
- 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'`)。
206206
- `diffStat(session)`: `git -C <worktree> diff <base>...HEAD --stat` 相当。未コミット変更がある場合はその旨も返す。
207207
- `merge(session)`: セッションブランチをベースブランチへマージ(squash はしない。コンフリクト時はエラーを返し、手動解決を促すメッセージを表示するのみ)。
208208
- `remove(session, { force })`: `git worktree remove` + `git branch -D`

src/core/config.spec.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, expect, it } from 'vitest';
2-
import { type CodivaConfig, toConfig } from '@/core/config';
2+
import { type CodivaConfig, resolveIgnoredFilesMode, toConfig } from '@/core/config';
33

44
describe('toConfig', () => {
55
it.each([
@@ -101,14 +101,25 @@ describe('toConfig', () => {
101101
it.each([
102102
[true, true],
103103
[false, false],
104-
])('keeps boolean copyIgnored %o', (input, expected) => {
104+
])('keeps boolean copyIgnored %o (deprecated, kept for back-compat)', (input, expected) => {
105105
expect(toConfig({ copyIgnored: input })).toEqual({ copyIgnored: expected });
106106
});
107107

108108
it.each([['yes'], [1], [null]])('drops invalid copyIgnored: %o', (copyIgnored) => {
109109
expect(toConfig({ copyIgnored })).toEqual({});
110110
});
111111

112+
it.each([['symlink'], ['copy'], ['none']] as const)(
113+
'keeps valid ignoredFiles %o',
114+
(ignoredFiles) => {
115+
expect(toConfig({ ignoredFiles })).toEqual({ ignoredFiles });
116+
},
117+
);
118+
119+
it.each([['link'], [true], [1], [null]])('drops invalid ignoredFiles: %o', (ignoredFiles) => {
120+
expect(toConfig({ ignoredFiles })).toEqual({});
121+
});
122+
112123
it('collects all valid keys together', () => {
113124
expect(
114125
toConfig({
@@ -120,6 +131,7 @@ describe('toConfig', () => {
120131
notifications: false,
121132
followOrigin: false,
122133
autoPr: true,
134+
ignoredFiles: 'copy',
123135
copyIgnored: false,
124136
}),
125137
).toEqual({
@@ -131,7 +143,30 @@ describe('toConfig', () => {
131143
notifications: false,
132144
followOrigin: false,
133145
autoPr: true,
146+
ignoredFiles: 'copy',
134147
copyIgnored: false,
135148
});
136149
});
137150
});
151+
152+
describe('resolveIgnoredFilesMode', () => {
153+
it('defaults to symlink when nothing is set', () => {
154+
expect(resolveIgnoredFilesMode({})).toBe('symlink');
155+
});
156+
157+
it.each([['symlink'], ['copy'], ['none']] as const)('uses ignoredFiles when set: %o', (mode) => {
158+
expect(resolveIgnoredFilesMode({ ignoredFiles: mode })).toBe(mode);
159+
});
160+
161+
it('falls back to deprecated copyIgnored: true → copy', () => {
162+
expect(resolveIgnoredFilesMode({ copyIgnored: true })).toBe('copy');
163+
});
164+
165+
it('falls back to deprecated copyIgnored: false → none', () => {
166+
expect(resolveIgnoredFilesMode({ copyIgnored: false })).toBe('none');
167+
});
168+
169+
it('prefers ignoredFiles over deprecated copyIgnored', () => {
170+
expect(resolveIgnoredFilesMode({ ignoredFiles: 'symlink', copyIgnored: true })).toBe('symlink');
171+
});
172+
});

src/core/config.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk';
22
import type { Lang } from './i18n';
3+
import type { IgnoredFilesMode } from './worktree';
34

45
/**
56
* 永続設定のドメイン型。表示言語に加え、セッション起動時に SDK へ渡す
@@ -37,9 +38,15 @@ export interface CodivaConfig {
3738
autoPr?: boolean;
3839
/**
3940
* セッション用 worktree 作成時に `.gitignore` された未追跡ファイル
40-
* (`node_modules/`・`.env` など)をリポジトリルートから複製するか。未設定は有効(true)。
41-
* git worktree は追跡対象しか引き継がないため、無効化すると依存や環境変数を
42-
* セッション側で用意し直す必要がある。
41+
* (`node_modules/`・`.env` など)をどう引き継ぐか。未設定は `'symlink'`。
42+
* - `'symlink'`: 元へシンボリックリンクを張る(複製なしで即起動、実体は共有)。
43+
* - `'copy'`: 実体を複製する(worktree 完全独立、大きいと重い)。
44+
* - `'none'`: 引き継がない。
45+
*/
46+
ignoredFiles?: IgnoredFilesMode;
47+
/**
48+
* @deprecated `ignoredFiles` を使う。後方互換のためだけに残す:
49+
* `true`→`'copy'` 相当、`false`→`'none'` 相当として解釈される(`resolveIgnoredFilesMode`)。
4350
*/
4451
copyIgnored?: boolean;
4552
}
@@ -54,6 +61,7 @@ const PERMISSION_MODES: readonly PermissionMode[] = [
5461
'dontAsk',
5562
'auto',
5663
];
64+
const IGNORED_FILES_MODES: readonly IgnoredFilesMode[] = ['symlink', 'copy', 'none'];
5765

5866
/** 設定ファイルの生 JSON 形(各フィールドは unknown として受ける)。 */
5967
interface CodivaConfigJson {
@@ -66,6 +74,7 @@ interface CodivaConfigJson {
6674
mouse?: unknown;
6775
followOrigin?: unknown;
6876
autoPr?: unknown;
77+
ignoredFiles?: unknown;
6978
copyIgnored?: unknown;
7079
}
7180

@@ -93,6 +102,27 @@ function toBoolean(value: unknown): boolean | undefined {
93102
return typeof value === 'boolean' ? value : undefined;
94103
}
95104

105+
function toIgnoredFilesMode(value: unknown): IgnoredFilesMode | undefined {
106+
return IGNORED_FILES_MODES.includes(value as IgnoredFilesMode)
107+
? (value as IgnoredFilesMode)
108+
: undefined;
109+
}
110+
111+
/**
112+
* 設定から worktree の ignore ファイル引き継ぎモードを決める。新しい `ignoredFiles` を
113+
* 優先し、無ければ非推奨の `copyIgnored`(`true`→`'copy'` / `false`→`'none'`)へ後方互換
114+
* フォールバック、どちらも無ければ既定の `'symlink'`。純粋(副作用なし)。
115+
*/
116+
export function resolveIgnoredFilesMode(config: CodivaConfig): IgnoredFilesMode {
117+
if (config.ignoredFiles !== undefined) {
118+
return config.ignoredFiles;
119+
}
120+
if (config.copyIgnored !== undefined) {
121+
return config.copyIgnored ? 'copy' : 'none';
122+
}
123+
return 'symlink';
124+
}
125+
96126
/**
97127
* 外部 JSON(設定ファイル内容)を CodivaConfig へ検証変換する。未知・不正な値は
98128
* 落として無視する(TUI を設定ミスでクラッシュさせないため、寛容に既定へフォールバック)。
@@ -140,6 +170,10 @@ export function toConfig(json: unknown): CodivaConfig {
140170
if (autoPr !== undefined) {
141171
config.autoPr = autoPr;
142172
}
173+
const ignoredFiles = toIgnoredFilesMode(raw.ignoredFiles);
174+
if (ignoredFiles !== undefined) {
175+
config.ignoredFiles = ignoredFiles;
176+
}
143177
const copyIgnored = toBoolean(raw.copyIgnored);
144178
if (copyIgnored !== undefined) {
145179
config.copyIgnored = copyIgnored;

src/core/worktree.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,19 @@ export const CODIVA_DIR = '.codiva';
44
/**
55
* `git worktree add` が引き継ぐのは追跡対象ファイルだけなので、`.gitignore` された
66
* `node_modules/` や `.env` などは新しい worktree に現れない。これらをリポジトリ
7-
* ルートから複製すると、セッションが即座にビルド/実行できる(依存や環境変数を
8-
* 手で用意し直さなくてよい)。既定で有効。
7+
* ルートから引き継ぐ方法を選ぶ:
8+
*
9+
* - `'symlink'`(既定): 元へのシンボリックリンクを張るだけ。複製コストゼロで即起動できるが、
10+
* worktree 間で実体を共有する(ビルド生成物などの書き込みが元やほかの worktree に波及しうる)。
11+
* - `'copy'`: リポジトリルートから実体を複製する。worktree 完全独立で作業が絶対に重複しないが、
12+
* `node_modules/` が巨大だとコピーが重い。
13+
* - `'none'`: 何も引き継がない(依存や環境変数はセッション側で用意し直す)。
914
*/
15+
export type IgnoredFilesMode = 'symlink' | 'copy' | 'none';
16+
1017
export interface WorktreeOptions {
11-
/** `.gitignore` された未追跡ファイルを新しい worktree へコピーするか。未設定は true。 */
12-
copyIgnored?: boolean;
18+
/** `.gitignore` された未追跡ファイルを新しい worktree へどう引き継ぐか。未設定は 'symlink'。 */
19+
ignoredFiles?: IgnoredFilesMode;
1320
}
1421

1522
export interface Worktree {

src/index.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import { createRequire } from 'node:module';
22
import { render } from 'ink';
3-
import { errorMessage, messages, resolveLang, type SessionManager } from '@/core';
3+
import {
4+
errorMessage,
5+
messages,
6+
resolveIgnoredFilesMode,
7+
resolveLang,
8+
type SessionManager,
9+
} from '@/core';
410
import { defaultStatePath, loadConfig, openUrl, WorktreeManager } from '@/utils';
511
import { App } from './app';
612
import {
@@ -31,8 +37,11 @@ async function main(): Promise<void> {
3137

3238
const repoRoot = process.cwd();
3339
// `.gitignore` された node_modules/.env 等は git worktree に引き継がれないため、
34-
// 既定でリポジトリルートから複製する(`"copyIgnored": false` で無効化)。
35-
const worktrees = new WorktreeManager(repoRoot, { copyIgnored: config.copyIgnored !== false });
40+
// 既定でリポジトリルートへシンボリックリンクを張る(設定 `"ignoredFiles"`: 'symlink' |
41+
// 'copy' | 'none' で切替。非推奨の `copyIgnored` も後方互換で解釈する)。
42+
const worktrees = new WorktreeManager(repoRoot, {
43+
ignoredFiles: resolveIgnoredFilesMode(config),
44+
});
3645
try {
3746
await worktrees.preflight();
3847
} catch (err) {

src/utils/worktree-manager.spec.ts

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { execFile } from 'node:child_process';
2-
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2+
import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
33
import { tmpdir } from 'node:os';
44
import { join } from 'node:path';
55
import { promisify } from 'node:util';
@@ -208,7 +208,7 @@ describe('WorktreeManager', () => {
208208
});
209209
});
210210

211-
describe('copying .gitignore-d files into a new worktree', () => {
211+
describe('linking/copying .gitignore-d files into a new worktree', () => {
212212
beforeEach(async () => {
213213
repo = await makeRepo(true);
214214
// ignore node_modules/ and .env, then leave them untracked on disk
@@ -220,23 +220,45 @@ describe('WorktreeManager', () => {
220220
await writeFile(join(repo, '.env'), 'SECRET=1\n');
221221
});
222222

223-
it('copies ignored files/dirs from the repo root by default', async () => {
223+
it('symlinks ignored files/dirs to the repo root by default', async () => {
224224
const wm = new WorktreeManager(repo);
225225
const wt = await wm.add('with-ignored');
226+
// symlink なので実体はリポジトリルート側と共有される(読むと元の内容が見える)
226227
expect(await readFile(join(wt.path, '.env'), 'utf8')).toBe('SECRET=1\n');
227228
expect(await readFile(join(wt.path, 'node_modules', 'dep', 'index.js'), 'utf8')).toBe(
228229
'module.exports = 1\n',
229230
);
231+
expect((await lstat(join(wt.path, '.env'))).isSymbolicLink()).toBe(true);
232+
expect((await lstat(join(wt.path, 'node_modules'))).isSymbolicLink()).toBe(true);
230233
});
231234

232-
it('does not copy .codiva (would recurse into worktrees)', async () => {
235+
it('copies real files (not symlinks) when ignoredFiles is "copy"', async () => {
236+
const wm = new WorktreeManager(repo, { ignoredFiles: 'copy' });
237+
const wt = await wm.add('copied');
238+
expect(await readFile(join(wt.path, '.env'), 'utf8')).toBe('SECRET=1\n');
239+
expect(await readFile(join(wt.path, 'node_modules', 'dep', 'index.js'), 'utf8')).toBe(
240+
'module.exports = 1\n',
241+
);
242+
expect((await lstat(join(wt.path, '.env'))).isSymbolicLink()).toBe(false);
243+
expect((await lstat(join(wt.path, 'node_modules'))).isSymbolicLink()).toBe(false);
244+
});
245+
246+
it('copy mode keeps the worktree fully independent from the repo root', async () => {
247+
const wm = new WorktreeManager(repo, { ignoredFiles: 'copy' });
248+
const wt = await wm.add('independent');
249+
// worktree 側を書き換えても元へ波及しない(symlink との差)
250+
await writeFile(join(wt.path, '.env'), 'SECRET=changed\n');
251+
expect(await readFile(join(repo, '.env'), 'utf8')).toBe('SECRET=1\n');
252+
});
253+
254+
it('does not link .codiva (would recurse into worktrees)', async () => {
233255
const wm = new WorktreeManager(repo);
234256
const wt = await wm.add('no-codiva');
235257
await expect(readFile(join(wt.path, '.codiva', 'state.json'), 'utf8')).rejects.toBeTruthy();
236258
});
237259

238-
it('skips copying when copyIgnored is false', async () => {
239-
const wm = new WorktreeManager(repo, { copyIgnored: false });
260+
it('skips linking when ignoredFiles is "none"', async () => {
261+
const wm = new WorktreeManager(repo, { ignoredFiles: 'none' });
240262
const wt = await wm.add('bare');
241263
await expect(readFile(join(wt.path, '.env'), 'utf8')).rejects.toBeTruthy();
242264
});

0 commit comments

Comments
 (0)