Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,12 +59,16 @@ codiva
```json
{
"language": "auto",
"copyIgnored": true
"ignoredFiles": "symlink"
}
```

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

## 開発

Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ interface SessionState {
- 前提チェック: Gitリポジトリか、HEAD が存在するか(コミット0のリポジトリでは worktree を作れない)。
- `add(slug)`: `git worktree add .codiva/worktrees/<slug> -b codiva/<slug>` を現在の HEAD から作成。slug 衝突時は `-2`, `-3` を付与。
- `.git/info/exclude` に `.codiva/` を自動追記(初回のみ)。
- 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 作成を止めない)。
- 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'`)。
- `diffStat(session)`: `git -C <worktree> diff <base>...HEAD --stat` 相当。未コミット変更がある場合はその旨も返す。
- `merge(session)`: セッションブランチをベースブランチへマージ(squash はしない。コンフリクト時はエラーを返し、手動解決を促すメッセージを表示するのみ)。
- `remove(session, { force })`: `git worktree remove` + `git branch -D`。
Expand Down
39 changes: 37 additions & 2 deletions src/core/config.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { type CodivaConfig, toConfig } from '@/core/config';
import { type CodivaConfig, resolveIgnoredFilesMode, toConfig } from '@/core/config';

describe('toConfig', () => {
it.each([
Expand Down Expand Up @@ -101,14 +101,25 @@ describe('toConfig', () => {
it.each([
[true, true],
[false, false],
])('keeps boolean copyIgnored %o', (input, expected) => {
])('keeps boolean copyIgnored %o (deprecated, kept for back-compat)', (input, expected) => {
expect(toConfig({ copyIgnored: input })).toEqual({ copyIgnored: expected });
});

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

it.each([['symlink'], ['copy'], ['none']] as const)(
'keeps valid ignoredFiles %o',
(ignoredFiles) => {
expect(toConfig({ ignoredFiles })).toEqual({ ignoredFiles });
},
);

it.each([['link'], [true], [1], [null]])('drops invalid ignoredFiles: %o', (ignoredFiles) => {
expect(toConfig({ ignoredFiles })).toEqual({});
});

it('collects all valid keys together', () => {
expect(
toConfig({
Expand All @@ -120,6 +131,7 @@ describe('toConfig', () => {
notifications: false,
followOrigin: false,
autoPr: true,
ignoredFiles: 'copy',
copyIgnored: false,
}),
).toEqual({
Expand All @@ -131,7 +143,30 @@ describe('toConfig', () => {
notifications: false,
followOrigin: false,
autoPr: true,
ignoredFiles: 'copy',
copyIgnored: false,
});
});
});

describe('resolveIgnoredFilesMode', () => {
it('defaults to symlink when nothing is set', () => {
expect(resolveIgnoredFilesMode({})).toBe('symlink');
});

it.each([['symlink'], ['copy'], ['none']] as const)('uses ignoredFiles when set: %o', (mode) => {
expect(resolveIgnoredFilesMode({ ignoredFiles: mode })).toBe(mode);
});

it('falls back to deprecated copyIgnored: true → copy', () => {
expect(resolveIgnoredFilesMode({ copyIgnored: true })).toBe('copy');
});

it('falls back to deprecated copyIgnored: false → none', () => {
expect(resolveIgnoredFilesMode({ copyIgnored: false })).toBe('none');
});

it('prefers ignoredFiles over deprecated copyIgnored', () => {
expect(resolveIgnoredFilesMode({ ignoredFiles: 'symlink', copyIgnored: true })).toBe('symlink');
});
});
40 changes: 37 additions & 3 deletions src/core/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { EffortLevel, PermissionMode } from '@anthropic-ai/claude-agent-sdk';
import type { Lang } from './i18n';
import type { IgnoredFilesMode } from './worktree';

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

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

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

function toIgnoredFilesMode(value: unknown): IgnoredFilesMode | undefined {
return IGNORED_FILES_MODES.includes(value as IgnoredFilesMode)
? (value as IgnoredFilesMode)
: undefined;
}

/**
* 設定から worktree の ignore ファイル引き継ぎモードを決める。新しい `ignoredFiles` を
* 優先し、無ければ非推奨の `copyIgnored`(`true`→`'copy'` / `false`→`'none'`)へ後方互換
* フォールバック、どちらも無ければ既定の `'symlink'`。純粋(副作用なし)。
*/
export function resolveIgnoredFilesMode(config: CodivaConfig): IgnoredFilesMode {
if (config.ignoredFiles !== undefined) {
return config.ignoredFiles;
}
if (config.copyIgnored !== undefined) {
return config.copyIgnored ? 'copy' : 'none';
}
return 'symlink';
}

/**
* 外部 JSON(設定ファイル内容)を CodivaConfig へ検証変換する。未知・不正な値は
* 落として無視する(TUI を設定ミスでクラッシュさせないため、寛容に既定へフォールバック)。
Expand Down Expand Up @@ -140,6 +170,10 @@ export function toConfig(json: unknown): CodivaConfig {
if (autoPr !== undefined) {
config.autoPr = autoPr;
}
const ignoredFiles = toIgnoredFilesMode(raw.ignoredFiles);
if (ignoredFiles !== undefined) {
config.ignoredFiles = ignoredFiles;
}
const copyIgnored = toBoolean(raw.copyIgnored);
if (copyIgnored !== undefined) {
config.copyIgnored = copyIgnored;
Expand Down
15 changes: 11 additions & 4 deletions src/core/worktree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,19 @@ export const CODIVA_DIR = '.codiva';
/**
* `git worktree add` が引き継ぐのは追跡対象ファイルだけなので、`.gitignore` された
* `node_modules/` や `.env` などは新しい worktree に現れない。これらをリポジトリ
* ルートから複製すると、セッションが即座にビルド/実行できる(依存や環境変数を
* 手で用意し直さなくてよい)。既定で有効。
* ルートから引き継ぐ方法を選ぶ:
*
* - `'symlink'`(既定): 元へのシンボリックリンクを張るだけ。複製コストゼロで即起動できるが、
* worktree 間で実体を共有する(ビルド生成物などの書き込みが元やほかの worktree に波及しうる)。
* - `'copy'`: リポジトリルートから実体を複製する。worktree 完全独立で作業が絶対に重複しないが、
* `node_modules/` が巨大だとコピーが重い。
* - `'none'`: 何も引き継がない(依存や環境変数はセッション側で用意し直す)。
*/
export type IgnoredFilesMode = 'symlink' | 'copy' | 'none';

export interface WorktreeOptions {
/** `.gitignore` された未追跡ファイルを新しい worktree へコピーするか。未設定は true。 */
copyIgnored?: boolean;
/** `.gitignore` された未追跡ファイルを新しい worktree へどう引き継ぐか。未設定は 'symlink'。 */
ignoredFiles?: IgnoredFilesMode;
}

export interface Worktree {
Expand Down
15 changes: 12 additions & 3 deletions src/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { createRequire } from 'node:module';
import { render } from 'ink';
import { errorMessage, messages, resolveLang, type SessionManager } from '@/core';
import {
errorMessage,
messages,
resolveIgnoredFilesMode,
resolveLang,
type SessionManager,
} from '@/core';
import { defaultStatePath, loadConfig, openUrl, WorktreeManager } from '@/utils';
import { App } from './app';
import {
Expand Down Expand Up @@ -31,8 +37,11 @@ async function main(): Promise<void> {

const repoRoot = process.cwd();
// `.gitignore` された node_modules/.env 等は git worktree に引き継がれないため、
// 既定でリポジトリルートから複製する(`"copyIgnored": false` で無効化)。
const worktrees = new WorktreeManager(repoRoot, { copyIgnored: config.copyIgnored !== false });
// 既定でリポジトリルートへシンボリックリンクを張る(設定 `"ignoredFiles"`: 'symlink' |
// 'copy' | 'none' で切替。非推奨の `copyIgnored` も後方互換で解釈する)。
const worktrees = new WorktreeManager(repoRoot, {
ignoredFiles: resolveIgnoredFilesMode(config),
});
try {
await worktrees.preflight();
} catch (err) {
Expand Down
34 changes: 28 additions & 6 deletions src/utils/worktree-manager.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { execFile } from 'node:child_process';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { lstat, mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
Expand Down Expand Up @@ -208,7 +208,7 @@ describe('WorktreeManager', () => {
});
});

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

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

it('does not copy .codiva (would recurse into worktrees)', async () => {
it('copies real files (not symlinks) when ignoredFiles is "copy"', async () => {
const wm = new WorktreeManager(repo, { ignoredFiles: 'copy' });
const wt = await wm.add('copied');
expect(await readFile(join(wt.path, '.env'), 'utf8')).toBe('SECRET=1\n');
expect(await readFile(join(wt.path, 'node_modules', 'dep', 'index.js'), 'utf8')).toBe(
'module.exports = 1\n',
);
expect((await lstat(join(wt.path, '.env'))).isSymbolicLink()).toBe(false);
expect((await lstat(join(wt.path, 'node_modules'))).isSymbolicLink()).toBe(false);
});

it('copy mode keeps the worktree fully independent from the repo root', async () => {
const wm = new WorktreeManager(repo, { ignoredFiles: 'copy' });
const wt = await wm.add('independent');
// worktree 側を書き換えても元へ波及しない(symlink との差)
await writeFile(join(wt.path, '.env'), 'SECRET=changed\n');
expect(await readFile(join(repo, '.env'), 'utf8')).toBe('SECRET=1\n');
});

it('does not link .codiva (would recurse into worktrees)', async () => {
const wm = new WorktreeManager(repo);
const wt = await wm.add('no-codiva');
await expect(readFile(join(wt.path, '.codiva', 'state.json'), 'utf8')).rejects.toBeTruthy();
});

it('skips copying when copyIgnored is false', async () => {
const wm = new WorktreeManager(repo, { copyIgnored: false });
it('skips linking when ignoredFiles is "none"', async () => {
const wm = new WorktreeManager(repo, { ignoredFiles: 'none' });
const wt = await wm.add('bare');
await expect(readFile(join(wt.path, '.env'), 'utf8')).rejects.toBeTruthy();
});
Expand Down
Loading
Loading