Skip to content

Commit d1e189d

Browse files
authored
feat: copy .gitignore-d files into new session worktrees (#26)
## 概要 セッションを新しく立ち上げるときの git worktree は、**git 追跡対象のファイルしか引き継ぎません**。そのため `.gitignore` された `node_modules/` や `.env` などはコピーされず、各セッションで依存の再インストールや環境変数の再設定が必要でした。 この PR で、worktree 作成時にリポジトリルートの **ignore された未追跡ファイルを複製**するようにします(既定で有効)。 ## 変更点 - **`ignoredCopyEntries()`(純関数・新規)**: `git ls-files --others --ignored --exclude-standard --directory` の出力をパースし、コピー対象エントリを返す。 - `--directory` によりディレクトリ全体が ignore されている場合は末尾 `/` 付きの1エントリに畳まれる → `node_modules/` を数万ファイル列挙せず1エントリで扱える。 - `.codiva/`(worktree 群を再帰コピーしてしまう)と `.git`(内部状態破壊)は必ず除外。 - **`WorktreeManager.copyIgnoredFiles()`**: `fs.cp` でエントリ単位のベストエフォート複製。1件の失敗で worktree 作成全体を止めない。 - **`add()`**: `copyIgnored` が有効なら worktree 作成後にコピーを実行。 - **config**: `copyIgnored`(既定 `true`)を追加。`toConfig()` で検証。`"copyIgnored": false` で無効化可能。 - **docs**: README の設定セクション + ARCHITECTURE の WorktreeManager 記述を更新。 > 未ステージのファイル(作業途中の tracked 変更)は git worktree の仕様上そのまま引き継がれないため対象外です。この PR は「ignore されたファイルの複製」にフォーカスしています。 ## 設計判断 - 純粋ロジック(コピー対象の決定)と I/O(コピー実行)を分離(`ignoredCopyEntries` は純関数、テーブルドリブンでテスト)。 - パフォーマンス配慮で `--directory` を使い、大量ファイルの列挙を回避。 - 破壊を避けるため `.codiva/` / `.git` を確実に除外。 ## テスト計画 - [x] `ignoredCopyEntries` の純関数テスト(`.codiva/`・`.git` 除外、空入力) - [x] 実 git リポジトリで `node_modules/` と `.env` が worktree に複製されることを検証 - [x] `.codiva/` が複製されないことを検証 - [x] `copyIgnored: false` で複製がスキップされることを検証 - [x] `toConfig()` の `copyIgnored` 検証(boolean 受理・不正値ドロップ・全キー結合) - [x] `npm test`(479 passed) / `npm run typecheck` / `npm run lint`(変更ファイルは警告0)
1 parent 8343a35 commit d1e189d

7 files changed

Lines changed: 153 additions & 8 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,11 +58,13 @@ codiva
5858

5959
```json
6060
{
61-
"language": "auto"
61+
"language": "auto",
62+
"copyIgnored": true
6263
}
6364
```
6465

6566
- `language`: `"ja"` / `"en"` / `"auto"`(OS ロケール準拠)。環境変数 `CODIVA_LANG``ja` / `en`)が最優先です。
67+
- `copyIgnored`: セッション用 worktree を作るとき、`.gitignore` された未追跡ファイル(`node_modules/``.env` など)をリポジトリルートから複製するか。既定 `true`。git worktree は追跡対象しか引き継がないため、これにより依存の再インストールや環境変数の再設定なしにセッションを即実行できます(`false` で無効化)。
6668

6769
## 開発
6870

docs/ARCHITECTURE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ interface SessionState {
158158
- 前提チェック: Gitリポジトリか、HEAD が存在するか(コミット0のリポジトリでは worktree を作れない)。
159159
- `add(slug)`: `git worktree add .codiva/worktrees/<slug> -b codiva/<slug>` を現在の HEAD から作成。slug 衝突時は `-2`, `-3` を付与。
160160
- `.git/info/exclude``.codiva/` を自動追記(初回のみ)。
161+
- 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 作成を止めない)。
161162
- `diffStat(session)`: `git -C <worktree> diff <base>...HEAD --stat` 相当。未コミット変更がある場合はその旨も返す。
162163
- `merge(session)`: セッションブランチをベースブランチへマージ(squash はしない。コンフリクト時はエラーを返し、手動解決を促すメッセージを表示するのみ)。
163164
- `remove(session, { force })`: `git worktree remove` + `git branch -D`

src/core/config.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,17 @@ describe('toConfig', () => {
8080
expect(toConfig({ notifications })).toEqual({});
8181
});
8282

83+
it.each([
84+
[true, true],
85+
[false, false],
86+
])('keeps boolean copyIgnored %o', (input, expected) => {
87+
expect(toConfig({ copyIgnored: input })).toEqual({ copyIgnored: expected });
88+
});
89+
90+
it.each([['yes'], [1], [null]])('drops invalid copyIgnored: %o', (copyIgnored) => {
91+
expect(toConfig({ copyIgnored })).toEqual({});
92+
});
93+
8394
it('collects all valid keys together', () => {
8495
expect(
8596
toConfig({
@@ -89,6 +100,7 @@ describe('toConfig', () => {
89100
permissionMode: 'acceptEdits',
90101
maxBudgetUsd: 2.5,
91102
notifications: false,
103+
copyIgnored: false,
92104
}),
93105
).toEqual({
94106
language: 'en',
@@ -97,6 +109,7 @@ describe('toConfig', () => {
97109
permissionMode: 'acceptEdits',
98110
maxBudgetUsd: 2.5,
99111
notifications: false,
112+
copyIgnored: false,
100113
});
101114
});
102115
});

src/core/config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ export interface CodivaConfig {
2424
* 有効中は端末のテキスト選択が通常ドラッグでできない(Shift+ドラッグは可)。
2525
*/
2626
mouse?: boolean;
27+
/**
28+
* セッション用 worktree 作成時に `.gitignore` された未追跡ファイル
29+
* (`node_modules/`・`.env` など)をリポジトリルートから複製するか。未設定は有効(true)。
30+
* git worktree は追跡対象しか引き継がないため、無効化すると依存や環境変数を
31+
* セッション側で用意し直す必要がある。
32+
*/
33+
copyIgnored?: boolean;
2734
}
2835

2936
/** SDK 由来 union の実行時検証用リテラル。型が変われば型エラーで気付ける。 */
@@ -46,6 +53,7 @@ interface CodivaConfigJson {
4653
maxBudgetUsd?: unknown;
4754
notifications?: unknown;
4855
mouse?: unknown;
56+
copyIgnored?: unknown;
4957
}
5058

5159
function toLangSetting(value: unknown): Lang | 'auto' | undefined {
@@ -111,5 +119,9 @@ export function toConfig(json: unknown): CodivaConfig {
111119
if (mouse !== undefined) {
112120
config.mouse = mouse;
113121
}
122+
const copyIgnored = toBoolean(raw.copyIgnored);
123+
if (copyIgnored !== undefined) {
124+
config.copyIgnored = copyIgnored;
125+
}
114126
return config;
115127
}

src/core/worktree.spec.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import { execFile } from 'node:child_process';
2-
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
2+
import { 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';
66
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
77
import { uniqueSlug } from '@/core/slug';
8-
import { WorktreeManager } from '@/core/worktree';
8+
import { ignoredCopyEntries, WorktreeManager } from '@/core/worktree';
99

1010
const execFileAsync = promisify(execFile);
1111
const g = (cwd: string, ...args: string[]) => execFileAsync('git', args, { cwd });
@@ -127,6 +127,51 @@ describe('WorktreeManager', () => {
127127
});
128128
});
129129

130+
describe('ignoredCopyEntries', () => {
131+
it('keeps ignored files and dirs but drops .codiva and .git', () => {
132+
const raw = ['.codiva/', '.env', '.env.local', '.git/', 'node_modules/', ''].join('\n');
133+
expect(ignoredCopyEntries(raw)).toEqual(['.env', '.env.local', 'node_modules/']);
134+
});
135+
136+
it('returns an empty list for empty output', () => {
137+
expect(ignoredCopyEntries('')).toEqual([]);
138+
});
139+
});
140+
141+
describe('copying .gitignore-d files into a new worktree', () => {
142+
beforeEach(async () => {
143+
repo = await makeRepo(true);
144+
// ignore node_modules/ and .env, then leave them untracked on disk
145+
await writeFile(join(repo, '.gitignore'), 'node_modules/\n.env\n.codiva/\n');
146+
await g(repo, 'add', '.gitignore');
147+
await g(repo, 'commit', '-m', 'add gitignore');
148+
await mkdir(join(repo, 'node_modules', 'dep'), { recursive: true });
149+
await writeFile(join(repo, 'node_modules', 'dep', 'index.js'), 'module.exports = 1\n');
150+
await writeFile(join(repo, '.env'), 'SECRET=1\n');
151+
});
152+
153+
it('copies ignored files/dirs from the repo root by default', async () => {
154+
const wm = new WorktreeManager(repo);
155+
const wt = await wm.add('with-ignored');
156+
expect(await readFile(join(wt.path, '.env'), 'utf8')).toBe('SECRET=1\n');
157+
expect(await readFile(join(wt.path, 'node_modules', 'dep', 'index.js'), 'utf8')).toBe(
158+
'module.exports = 1\n',
159+
);
160+
});
161+
162+
it('does not copy .codiva (would recurse into worktrees)', async () => {
163+
const wm = new WorktreeManager(repo);
164+
const wt = await wm.add('no-codiva');
165+
await expect(readFile(join(wt.path, '.codiva', 'state.json'), 'utf8')).rejects.toBeTruthy();
166+
});
167+
168+
it('skips copying when copyIgnored is false', async () => {
169+
const wm = new WorktreeManager(repo, { copyIgnored: false });
170+
const wt = await wm.add('bare');
171+
await expect(readFile(join(wt.path, '.env'), 'utf8')).rejects.toBeTruthy();
172+
});
173+
});
174+
130175
describe('slug collision handling', () => {
131176
it('avoids reusing an existing codiva branch slug', async () => {
132177
repo = await makeRepo(true);

src/core/worktree.ts

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
1-
import { appendFile, mkdir, readFile } from 'node:fs/promises';
2-
import { join } from 'node:path';
1+
import { appendFile, cp, mkdir, readFile } from 'node:fs/promises';
2+
import { dirname, join } from 'node:path';
33
import { GitError, git } from '@/utils';
44

55
const CODIVA_DIR = '.codiva';
66
const WORKTREES_SUBDIR = join(CODIVA_DIR, 'worktrees');
77
const EXCLUDE_MARKER = '# codiva';
88

9+
/**
10+
* `git worktree add` が引き継ぐのは追跡対象ファイルだけなので、`.gitignore` された
11+
* `node_modules/` や `.env` などは新しい worktree に現れない。これらをリポジトリ
12+
* ルートから複製すると、セッションが即座にビルド/実行できる(依存や環境変数を
13+
* 手で用意し直さなくてよい)。既定で有効。
14+
*/
15+
export interface WorktreeOptions {
16+
/** `.gitignore` された未追跡ファイルを新しい worktree へコピーするか。未設定は true。 */
17+
copyIgnored?: boolean;
18+
}
19+
920
export interface Worktree {
1021
slug: string;
1122
branch: string;
@@ -19,14 +30,41 @@ export interface DiffStat {
1930
uncommitted: string[];
2031
}
2132

33+
/**
34+
* `git ls-files --others --ignored --exclude-standard --directory` の生出力から、
35+
* 新しい worktree へコピーすべき ignore 済みエントリだけを取り出す純関数。
36+
*
37+
* `--directory` によりディレクトリ全体が ignore されている場合は末尾 `/` 付きの
38+
* 1エントリに畳まれる(`node_modules/` を数万ファイル列挙せずに済む)。codiva 自身の
39+
* 作業ディレクトリ(`.codiva/`)と `.git` は、worktree 群を再帰コピーしたり内部状態を
40+
* 壊したりするため必ず除外する。
41+
*/
42+
export function ignoredCopyEntries(raw: string): string[] {
43+
return raw
44+
.split('\n')
45+
.map((line) => line.trim())
46+
.filter(Boolean)
47+
.filter((entry) => {
48+
const normalized = entry.replace(/\/$/, '');
49+
return normalized !== CODIVA_DIR && normalized !== '.git';
50+
});
51+
}
52+
2253
/**
2354
* Creates and tears down git worktrees for sessions. Every worktree lives under
2455
* `.codiva/worktrees/<slug>` on branch `codiva/<slug>`, branched from the repo's
2556
* current HEAD. The repo's own files are never modified except a one-time
2657
* `.git/info/exclude` entry for `.codiva/`.
2758
*/
2859
export class WorktreeManager {
29-
constructor(private readonly repoRoot: string) {}
60+
private readonly copyIgnored: boolean;
61+
62+
constructor(
63+
private readonly repoRoot: string,
64+
options: WorktreeOptions = {},
65+
) {
66+
this.copyIgnored = options.copyIgnored !== false;
67+
}
3068

3169
/** The base branch worktrees are cut from and merged back into. */
3270
async baseBranch(): Promise<string> {
@@ -89,7 +127,39 @@ export class WorktreeManager {
89127
const relPath = join(WORKTREES_SUBDIR, slug);
90128
const branch = `codiva/${slug}`;
91129
await git(this.repoRoot, ['worktree', 'add', relPath, '-b', branch]);
92-
return { slug, branch, path: join(this.repoRoot, relPath) };
130+
const worktreePath = join(this.repoRoot, relPath);
131+
if (this.copyIgnored) {
132+
await this.copyIgnoredFiles(worktreePath);
133+
}
134+
return { slug, branch, path: worktreePath };
135+
}
136+
137+
/**
138+
* `.gitignore` された未追跡ファイル(`node_modules/`・`.env` など)をリポジトリ
139+
* ルートから新しい worktree へ複製する。git worktree は追跡対象しか引き継がないため、
140+
* これがないとセッション側で依存の再インストールや環境変数の再設定が必要になる。
141+
*
142+
* ベストエフォート: 個々のコピー失敗(競合・権限等)は worktree 作成を巻き込まず
143+
* スキップする(環境ファイルが1つ欠けても致命ではない)。
144+
*/
145+
private async copyIgnoredFiles(worktreePath: string): Promise<void> {
146+
const raw = await git(this.repoRoot, [
147+
'ls-files',
148+
'--others',
149+
'--ignored',
150+
'--exclude-standard',
151+
'--directory',
152+
]).catch(() => '');
153+
for (const entry of ignoredCopyEntries(raw)) {
154+
const from = join(this.repoRoot, entry);
155+
const to = join(worktreePath, entry);
156+
try {
157+
await mkdir(dirname(to), { recursive: true });
158+
await cp(from, to, { recursive: true, force: true, errorOnExist: false });
159+
} catch {
160+
// best-effort: 1エントリの失敗で worktree 作成全体を止めない
161+
}
162+
}
93163
}
94164

95165
/** Committed diff stat vs. the base branch plus any uncommitted paths. */

src/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ async function main(): Promise<void> {
3838
const t = messages[lang];
3939

4040
const repoRoot = process.cwd();
41-
const worktrees = new WorktreeManager(repoRoot);
41+
// `.gitignore` された node_modules/.env 等は git worktree に引き継がれないため、
42+
// 既定でリポジトリルートから複製する(`"copyIgnored": false` で無効化)。
43+
const worktrees = new WorktreeManager(repoRoot, { copyIgnored: config.copyIgnored !== false });
4244

4345
try {
4446
await worktrees.preflight();

0 commit comments

Comments
 (0)