From 63d6932c011c4281ba1e6aa358b90eccbdfe8633 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Thu, 29 Jan 2026 11:24:47 +0900 Subject: [PATCH] Stop using git worktree due to Claude Code SDK working at repository root --- CLAUDE.md | 19 ++- README.md | 37 ++--- docs/README.ja.md | 18 ++- resources/project/.gitignore | 2 +- src/__tests__/autoCommit.test.ts | 39 +++-- src/__tests__/cli-worktree.test.ts | 49 ++++--- src/__tests__/reviewTasks.test.ts | 106 +++++--------- src/__tests__/taskExecution.test.ts | 45 +++--- src/cli.ts | 23 +-- src/commands/help.ts | 6 +- src/commands/reviewTasks.ts | 142 +++++++++--------- src/commands/taskExecution.ts | 27 ++-- src/commands/workflowExecution.ts | 6 +- src/models/schemas.ts | 2 +- src/models/types.ts | 2 +- src/task/autoCommit.ts | 34 +++-- src/task/index.ts | 15 +- src/task/schema.ts | 8 +- src/task/summarize.ts | 2 +- src/task/worktree.ts | 220 +++++++++++++++++----------- src/workflow/engine.ts | 2 +- src/workflow/instruction-builder.ts | 6 +- 22 files changed, 435 insertions(+), 375 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 5bcf592..1cad2f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ TAKT (Task Agent Koordination Tool) is a multi-agent orchestration system for Cl | `takt /run-tasks` | `/run` | Execute all pending tasks from `.takt/tasks/` once | | `takt /watch` | | Watch `.takt/tasks/` and auto-execute tasks (resident process) | | `takt /add-task` | `/add` | Add a new task interactively (YAML format, multiline supported) | -| `takt /review-tasks` | `/review` | Review worktree task results (try merge, merge & cleanup, or delete) | +| `takt /review-tasks` | `/review` | Review task branches (try merge, merge & cleanup, or delete) | | `takt /switch` | | Switch workflow interactively | | `takt /clear` | | Clear agent conversation sessions (reset state) | | `takt /refresh-builtin` | | Update builtin resources from `resources/` to `~/.takt/` | @@ -186,13 +186,20 @@ model: opus # Default model for all steps (unless overridden) - `improve` - Needs improvement (security concerns, quality issues) - `always` - Unconditional transition -## Worktree Execution +## Isolated Execution (Shared Clone) -When tasks specify `worktree: true` or `worktree: "path"`, code runs in a git worktree (separate checkout). Key constraints: +When tasks specify `worktree: true` or `worktree: "path"`, code runs in a `git clone --shared` (lightweight clone with independent `.git` directory). Clones are ephemeral: created before task execution, auto-committed + pushed after success, then deleted. -- **Session isolation**: Claude Code sessions are stored per-cwd in `~/.claude/projects/{encoded-path}/`. Sessions from the main project cannot be resumed in a worktree. The engine skips session resume when `cwd !== projectCwd`. -- **No node_modules**: Worktrees only contain tracked files. `node_modules/` is absent. -- **Dual cwd**: `cwd` = worktree path (where agents run), `projectCwd` = project root (where `.takt/` lives). Reports, logs, and session data always write to `projectCwd`. +> **Why `worktree` in YAML but `git clone --shared` internally?** The YAML field name `worktree` is retained for backward compatibility. The original implementation used `git worktree`, but git worktrees have a `.git` file containing `gitdir: /path/to/main/.git/worktrees/...`. Claude Code follows this path and recognizes the main repository as the project root, causing agents to work on main instead of the worktree. `git clone --shared` creates an independent `.git` directory that prevents this traversal. + +Key constraints: + +- **Independent `.git`**: Shared clones have their own `.git` directory, preventing Claude Code from traversing `gitdir:` back to the main repository. +- **Ephemeral lifecycle**: Clone is created → task runs → auto-commit + push → clone is deleted. Branches are the single source of truth. +- **Session isolation**: Claude Code sessions are stored per-cwd in `~/.claude/projects/{encoded-path}/`. Sessions from the main project cannot be resumed in a clone. The engine skips session resume when `cwd !== projectCwd`. +- **No node_modules**: Clones only contain tracked files. `node_modules/` is absent. +- **Dual cwd**: `cwd` = clone path (where agents run), `projectCwd` = project root (where `.takt/` lives). Reports, logs, and session data always write to `projectCwd`. +- **Review**: Use `takt /review-tasks` to review branches. Instruct action creates a temporary clone for the branch, executes, pushes, then removes the clone. ## Error Propagation diff --git a/README.md b/README.md index 0456b5e..9bcb9f4 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ npm install -g takt ## Quick Start ```bash -# Run a task (will prompt for workflow selection and optional worktree creation) +# Run a task (will prompt for workflow selection and optional isolated clone) takt "Add a login feature" # Add a task to the queue @@ -35,7 +35,7 @@ takt /run-tasks # Watch for tasks and auto-execute takt /watch -# Review worktree results (merge or delete) +# Review task branches (merge or delete) takt /review-tasks # Switch workflow @@ -51,7 +51,7 @@ takt /switch | `takt /run-tasks` | `/run` | Run all pending tasks from `.takt/tasks/` | | `takt /watch` | | Watch `.takt/tasks/` and auto-execute tasks (stays resident) | | `takt /add-task` | `/add` | Add a new task interactively (YAML format, multiline supported) | -| `takt /review-tasks` | `/review` | Review worktree task results (try merge, merge & cleanup, or delete) | +| `takt /review-tasks` | `/review` | Review task branches (try merge, merge & cleanup, or delete) | | `takt /switch` | | Switch workflow interactively | | `takt /clear` | | Clear agent conversation sessions | | `takt /refresh-builtin` | | Update builtin agents/workflows to latest version | @@ -185,7 +185,7 @@ Available Codex models: ├── agents.yaml # Custom agent definitions ├── tasks/ # Pending task files (.yaml, .md) ├── completed/ # Completed tasks with reports -├── worktrees/ # Git worktrees for isolated task execution +├── worktree-meta/ # Metadata for task branches ├── reports/ # Execution reports (auto-generated) └── logs/ # Session logs (incremental) ├── latest.json # Pointer to current/latest session @@ -236,7 +236,7 @@ The `-r` flag preserves the agent's conversation history, allowing for natural b When running `takt "task"`, you are prompted to: 1. **Select a workflow** - Choose from available workflows (arrow keys, ESC to cancel) -2. **Create a worktree** (optional) - Optionally run the task in an isolated git worktree +2. **Create an isolated clone** (optional) - Optionally run the task in a `git clone --shared` for isolation This interactive flow ensures each task runs with the right workflow and isolation level. @@ -310,10 +310,10 @@ TAKT supports batch task processing through task files in `.takt/tasks/`. Both ` #### Adding Tasks with `/add-task` ```bash -# Quick add (no worktree) +# Quick add (no isolation) takt /add-task "Add authentication feature" -# Interactive mode (prompts for worktree, branch, workflow options) +# Interactive mode (prompts for isolation, branch, workflow options) takt /add-task ``` @@ -324,7 +324,7 @@ takt /add-task ```yaml # .takt/tasks/add-auth.yaml task: "Add authentication feature" -worktree: true # Run in isolated git worktree +worktree: true # Run in isolated shared clone branch: "feat/add-auth" # Branch name (auto-generated if omitted) workflow: "default" # Workflow override (uses current if omitted) ``` @@ -342,16 +342,18 @@ Requirements: - Error handling for failed attempts ``` -#### Git Worktree Isolation +#### Isolated Execution (Shared Clone) -YAML task files can specify `worktree` to run each task in an isolated git worktree, keeping the main working directory clean: +YAML task files can specify `worktree` to run each task in an isolated `git clone --shared`, keeping the main working directory clean: -- `worktree: true` - Auto-create at `.takt/worktrees/{timestamp}-{task-slug}/` +- `worktree: true` - Auto-create a shared clone in a sibling directory (or `worktree_dir` from config) - `worktree: "/path/to/dir"` - Create at specified path - `branch: "feat/xxx"` - Use specified branch (auto-generated as `takt/{timestamp}-{slug}` if omitted) - Omit `worktree` - Run in current working directory (default) -When a worktree task completes successfully, TAKT automatically commits all changes (`auto-commit`). Use `takt /review-tasks` to review, try-merge, or delete completed worktree branches. +> **Note**: The YAML field is named `worktree` for backward compatibility. Internally, `git clone --shared` is used instead of `git worktree` because git worktrees have a `.git` file with `gitdir:` that points back to the main repository, causing Claude Code to recognize the main repo as the project root. Shared clones have an independent `.git` directory that avoids this issue. + +Clones are ephemeral. When a task completes successfully, TAKT automatically commits all changes and pushes the branch to the main repository, then deletes the clone. Use `takt /review-tasks` to review, try-merge, or delete task branches. #### Running Tasks with `/run-tasks` @@ -374,16 +376,17 @@ Watch mode polls `.takt/tasks/` for new task files and auto-executes them as the - Automated workflows where tasks are added by external processes - Long-running development sessions where tasks are queued over time -#### Reviewing Worktree Results with `/review-tasks` +#### Reviewing Task Branches with `/review-tasks` ```bash takt /review-tasks ``` -Lists all `takt/`-prefixed worktree branches with file change counts. For each branch you can: -- **Try merge** - Attempt merge into main (dry-run check, then actual merge) -- **Merge & cleanup** - Merge and remove the worktree -- **Delete** - Remove the worktree and branch without merging +Lists all `takt/`-prefixed branches with file change counts. For each branch you can: +- **Try merge** - Squash merge into main (stage changes without committing) +- **Instruct** - Give additional instructions via a temporary clone +- **Merge & cleanup** - Merge and delete the branch +- **Delete** - Delete the branch without merging ### Session Logs diff --git a/docs/README.ja.md b/docs/README.ja.md index 8e2172f..0d8f2b3 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -73,10 +73,10 @@ TAKTは`.takt/tasks/`内のタスクファイルによるバッチ処理をサ #### `/add-task` でタスクを追加 ```bash -# クイック追加(worktreeなし) +# クイック追加(隔離なし) takt /add-task "認証機能を追加" -# 対話モード(worktree、ブランチ、ワークフローオプションを指定可能) +# 対話モード(隔離実行、ブランチ、ワークフローオプションを指定可能) takt /add-task ``` @@ -87,7 +87,7 @@ takt /add-task ```yaml # .takt/tasks/add-auth.yaml task: "認証機能を追加する" -worktree: true # 隔離されたgit worktreeで実行 +worktree: true # 隔離された共有クローンで実行 branch: "feat/add-auth" # ブランチ名(省略時は自動生成) workflow: "default" # ワークフロー指定(省略時は現在のもの) ``` @@ -105,15 +105,19 @@ workflow: "default" # ワークフロー指定(省略時は現在 - 失敗時のエラーハンドリング ``` -#### Git Worktree による隔離実行 +#### 共有クローンによる隔離実行 -YAMLタスクファイルで`worktree`を指定すると、各タスクを隔離されたgit worktreeで実行し、メインの作業ディレクトリをクリーンに保てます: +YAMLタスクファイルで`worktree`を指定すると、各タスクを`git clone --shared`で作成した隔離クローンで実行し、メインの作業ディレクトリをクリーンに保てます: -- `worktree: true` - `.takt/worktrees/{timestamp}-{task-slug}/`に自動作成 +- `worktree: true` - 隣接ディレクトリ(または`worktree_dir`設定で指定した場所)に共有クローンを自動作成 - `worktree: "/path/to/dir"` - 指定パスに作成 - `branch: "feat/xxx"` - 指定ブランチを使用(省略時は`takt/{timestamp}-{slug}`で自動生成) - `worktree`省略 - カレントディレクトリで実行(デフォルト) +> **Note**: YAMLフィールド名は後方互換のため`worktree`のままです。内部的には`git worktree`ではなく`git clone --shared`を使用しています。git worktreeの`.git`ファイルには`gitdir:`でメインリポジトリへのパスが記載されており、Claude Codeがそれを辿ってメインリポジトリをプロジェクトルートと認識してしまうためです。共有クローンは独立した`.git`ディレクトリを持つため、この問題が発生しません。 + +クローンは使い捨てです。タスク完了後に自動的にコミット+プッシュし、クローンを削除します。ブランチが唯一の永続的な成果物です。`takt /review-tasks`でブランチのレビュー・マージ・削除ができます。 + #### `/run-tasks` でタスクを実行 ```bash @@ -338,7 +342,7 @@ agents: ├── agents.yaml # カスタムエージェント定義 ├── tasks/ # 保留中のタスクファイル(.yaml, .md) ├── completed/ # 完了したタスクとレポート -├── worktrees/ # タスク隔離実行用のgit worktree +├── worktree-meta/ # タスクブランチのメタデータ ├── reports/ # 実行レポート(自動生成) └── logs/ # セッションログ ``` diff --git a/resources/project/.gitignore b/resources/project/.gitignore index ff23787..1cfee63 100644 --- a/resources/project/.gitignore +++ b/resources/project/.gitignore @@ -3,6 +3,6 @@ logs/ reports/ completed/ tasks/ -worktrees/ +worktree-meta/ agent_sessions.json input_history diff --git a/src/__tests__/autoCommit.test.ts b/src/__tests__/autoCommit.test.ts index e011818..66c22d0 100644 --- a/src/__tests__/autoCommit.test.ts +++ b/src/__tests__/autoCommit.test.ts @@ -1,9 +1,9 @@ /** - * Tests for autoCommitWorktree + * Tests for autoCommitAndPush */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { autoCommitWorktree } from '../task/autoCommit.js'; +import { autoCommitAndPush } from '../task/autoCommit.js'; // Mock child_process.execFileSync vi.mock('node:child_process', () => ({ @@ -17,9 +17,8 @@ beforeEach(() => { vi.clearAllMocks(); }); -describe('autoCommitWorktree', () => { - it('should create a commit when there are changes', () => { - // git add -A: no output needed +describe('autoCommitAndPush', () => { + it('should create a commit and push when there are changes', () => { mockExecFileSync.mockImplementation((cmd, args) => { const argsArr = args as string[]; if (argsArr[0] === 'status') { @@ -31,7 +30,7 @@ describe('autoCommitWorktree', () => { return Buffer.from(''); }); - const result = autoCommitWorktree('/tmp/worktree', 'my-task'); + const result = autoCommitAndPush('/tmp/clone', 'my-task'); expect(result.success).toBe(true); expect(result.commitHash).toBe('abc1234'); @@ -41,14 +40,21 @@ describe('autoCommitWorktree', () => { expect(mockExecFileSync).toHaveBeenCalledWith( 'git', ['add', '-A'], - expect.objectContaining({ cwd: '/tmp/worktree' }) + expect.objectContaining({ cwd: '/tmp/clone' }) ); // Verify commit was called with correct message (no co-author) expect(mockExecFileSync).toHaveBeenCalledWith( 'git', ['commit', '-m', 'takt: my-task'], - expect.objectContaining({ cwd: '/tmp/worktree' }) + expect.objectContaining({ cwd: '/tmp/clone' }) + ); + + // Verify push was called + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', + ['push', 'origin', 'HEAD'], + expect.objectContaining({ cwd: '/tmp/clone' }) ); }); @@ -61,7 +67,7 @@ describe('autoCommitWorktree', () => { return Buffer.from(''); }); - const result = autoCommitWorktree('/tmp/worktree', 'my-task'); + const result = autoCommitAndPush('/tmp/clone', 'my-task'); expect(result.success).toBe(true); expect(result.commitHash).toBeUndefined(); @@ -71,7 +77,7 @@ describe('autoCommitWorktree', () => { expect(mockExecFileSync).toHaveBeenCalledWith( 'git', ['add', '-A'], - expect.objectContaining({ cwd: '/tmp/worktree' }) + expect.objectContaining({ cwd: '/tmp/clone' }) ); // Verify commit was NOT called @@ -80,6 +86,13 @@ describe('autoCommitWorktree', () => { ['commit', '-m', expect.any(String)], expect.anything() ); + + // Verify push was NOT called + expect(mockExecFileSync).not.toHaveBeenCalledWith( + 'git', + ['push', 'origin', 'HEAD'], + expect.anything() + ); }); it('should return failure when git command fails', () => { @@ -87,7 +100,7 @@ describe('autoCommitWorktree', () => { throw new Error('git error: not a git repository'); }); - const result = autoCommitWorktree('/tmp/worktree', 'my-task'); + const result = autoCommitAndPush('/tmp/clone', 'my-task'); expect(result.success).toBe(false); expect(result.commitHash).toBeUndefined(); @@ -107,7 +120,7 @@ describe('autoCommitWorktree', () => { return Buffer.from(''); }); - autoCommitWorktree('/tmp/worktree', 'test-task'); + autoCommitAndPush('/tmp/clone', 'test-task'); // Find the commit call const commitCall = mockExecFileSync.mock.calls.find( @@ -132,7 +145,7 @@ describe('autoCommitWorktree', () => { return Buffer.from(''); }); - autoCommitWorktree('/tmp/worktree', '認証機能を追加する'); + autoCommitAndPush('/tmp/clone', '認証機能を追加する'); const commitCall = mockExecFileSync.mock.calls.find( call => (call[1] as string[])[0] === 'commit' diff --git a/src/__tests__/cli-worktree.test.ts b/src/__tests__/cli-worktree.test.ts index 68e7959..7af064a 100644 --- a/src/__tests__/cli-worktree.test.ts +++ b/src/__tests__/cli-worktree.test.ts @@ -1,5 +1,5 @@ /** - * Tests for confirmAndCreateWorktree (CLI worktree confirmation flow) + * Tests for confirmAndCreateWorktree (CLI clone confirmation flow) */ import { describe, it, expect, vi, beforeEach } from 'vitest'; @@ -11,11 +11,12 @@ vi.mock('../prompt/index.js', () => ({ })); vi.mock('../task/worktree.js', () => ({ - createWorktree: vi.fn(), + createSharedClone: vi.fn(), + removeClone: vi.fn(), })); vi.mock('../task/autoCommit.js', () => ({ - autoCommitWorktree: vi.fn(), + autoCommitAndPush: vi.fn(), })); vi.mock('../task/summarize.js', () => ({ @@ -76,13 +77,13 @@ vi.mock('../constants.js', () => ({ })); import { confirm } from '../prompt/index.js'; -import { createWorktree } from '../task/worktree.js'; +import { createSharedClone } from '../task/worktree.js'; import { summarizeTaskName } from '../task/summarize.js'; import { info } from '../utils/ui.js'; import { confirmAndCreateWorktree } from '../cli.js'; const mockConfirm = vi.mocked(confirm); -const mockCreateWorktree = vi.mocked(createWorktree); +const mockCreateSharedClone = vi.mocked(createSharedClone); const mockSummarizeTaskName = vi.mocked(summarizeTaskName); const mockInfo = vi.mocked(info); @@ -91,8 +92,8 @@ beforeEach(() => { }); describe('confirmAndCreateWorktree', () => { - it('should return original cwd when user declines worktree creation', async () => { - // Given: user says "no" to worktree creation + it('should return original cwd when user declines clone creation', async () => { + // Given: user says "no" to clone creation mockConfirm.mockResolvedValue(false); // When @@ -101,16 +102,16 @@ describe('confirmAndCreateWorktree', () => { // Then expect(result.execCwd).toBe('/project'); expect(result.isWorktree).toBe(false); - expect(mockCreateWorktree).not.toHaveBeenCalled(); + expect(mockCreateSharedClone).not.toHaveBeenCalled(); expect(mockSummarizeTaskName).not.toHaveBeenCalled(); }); - it('should create worktree and return worktree path when user confirms', async () => { - // Given: user says "yes" to worktree creation + it('should create shared clone and return clone path when user confirms', async () => { + // Given: user says "yes" to clone creation mockConfirm.mockResolvedValue(true); mockSummarizeTaskName.mockResolvedValue('fix-auth'); - mockCreateWorktree.mockReturnValue({ - path: '/project/.takt/worktrees/20260128T0504-fix-auth', + mockCreateSharedClone.mockReturnValue({ + path: '/project/../20260128T0504-fix-auth', branch: 'takt/20260128T0504-fix-auth', }); @@ -118,21 +119,21 @@ describe('confirmAndCreateWorktree', () => { const result = await confirmAndCreateWorktree('/project', 'fix-auth'); // Then - expect(result.execCwd).toBe('/project/.takt/worktrees/20260128T0504-fix-auth'); + expect(result.execCwd).toBe('/project/../20260128T0504-fix-auth'); expect(result.isWorktree).toBe(true); expect(mockSummarizeTaskName).toHaveBeenCalledWith('fix-auth', { cwd: '/project' }); - expect(mockCreateWorktree).toHaveBeenCalledWith('/project', { + expect(mockCreateSharedClone).toHaveBeenCalledWith('/project', { worktree: true, taskSlug: 'fix-auth', }); }); - it('should display worktree info when created', async () => { + it('should display clone info when created', async () => { // Given mockConfirm.mockResolvedValue(true); mockSummarizeTaskName.mockResolvedValue('my-task'); - mockCreateWorktree.mockReturnValue({ - path: '/project/.takt/worktrees/20260128T0504-my-task', + mockCreateSharedClone.mockReturnValue({ + path: '/project/../20260128T0504-my-task', branch: 'takt/20260128T0504-my-task', }); @@ -141,7 +142,7 @@ describe('confirmAndCreateWorktree', () => { // Then expect(mockInfo).toHaveBeenCalledWith( - 'Worktree created: /project/.takt/worktrees/20260128T0504-my-task (branch: takt/20260128T0504-my-task)' + 'Clone created: /project/../20260128T0504-my-task (branch: takt/20260128T0504-my-task)' ); }); @@ -160,8 +161,8 @@ describe('confirmAndCreateWorktree', () => { // Given: Japanese task name, AI summarizes to English mockConfirm.mockResolvedValue(true); mockSummarizeTaskName.mockResolvedValue('add-auth'); - mockCreateWorktree.mockReturnValue({ - path: '/project/.takt/worktrees/20260128T0504-add-auth', + mockCreateSharedClone.mockReturnValue({ + path: '/project/../20260128T0504-add-auth', branch: 'takt/20260128T0504-add-auth', }); @@ -170,18 +171,18 @@ describe('confirmAndCreateWorktree', () => { // Then expect(mockSummarizeTaskName).toHaveBeenCalledWith('認証機能を追加する', { cwd: '/project' }); - expect(mockCreateWorktree).toHaveBeenCalledWith('/project', { + expect(mockCreateSharedClone).toHaveBeenCalledWith('/project', { worktree: true, taskSlug: 'add-auth', }); }); - it('should show generating message when creating worktree', async () => { + it('should show generating message when creating clone', async () => { // Given mockConfirm.mockResolvedValue(true); mockSummarizeTaskName.mockResolvedValue('test-task'); - mockCreateWorktree.mockReturnValue({ - path: '/project/.takt/worktrees/20260128T0504-test-task', + mockCreateSharedClone.mockReturnValue({ + path: '/project/../20260128T0504-test-task', branch: 'takt/20260128T0504-test-task', }); diff --git a/src/__tests__/reviewTasks.test.ts b/src/__tests__/reviewTasks.test.ts index 51d80e9..e65ead7 100644 --- a/src/__tests__/reviewTasks.test.ts +++ b/src/__tests__/reviewTasks.test.ts @@ -4,94 +4,63 @@ import { describe, it, expect, vi } from 'vitest'; import { - parseTaktWorktrees, + parseTaktBranches, extractTaskSlug, buildReviewItems, - type WorktreeInfo, + type BranchInfo, } from '../task/worktree.js'; import { isBranchMerged, showFullDiff, type ReviewAction } from '../commands/reviewTasks.js'; -describe('parseTaktWorktrees', () => { - it('should parse takt/ branches from porcelain output', () => { +describe('parseTaktBranches', () => { + it('should parse takt/ branches from git branch output', () => { const output = [ - 'worktree /home/user/project', - 'HEAD abc1234567890', - 'branch refs/heads/main', - '', - 'worktree /home/user/project/.takt/worktrees/20260128-fix-auth', - 'HEAD def4567890abc', - 'branch refs/heads/takt/20260128-fix-auth', - '', - 'worktree /home/user/project/.takt/worktrees/20260128-add-search', - 'HEAD 789abcdef0123', - 'branch refs/heads/takt/20260128-add-search', + 'takt/20260128-fix-auth def4567', + 'takt/20260128-add-search 789abcd', ].join('\n'); - const result = parseTaktWorktrees(output); + const result = parseTaktBranches(output); expect(result).toHaveLength(2); expect(result[0]).toEqual({ - path: '/home/user/project/.takt/worktrees/20260128-fix-auth', branch: 'takt/20260128-fix-auth', - commit: 'def4567890abc', + commit: 'def4567', }); expect(result[1]).toEqual({ - path: '/home/user/project/.takt/worktrees/20260128-add-search', branch: 'takt/20260128-add-search', - commit: '789abcdef0123', + commit: '789abcd', }); }); - it('should exclude non-takt branches', () => { - const output = [ - 'worktree /home/user/project', - 'HEAD abc123', - 'branch refs/heads/main', - '', - 'worktree /home/user/project/.takt/worktrees/20260128-fix-auth', - 'HEAD def456', - 'branch refs/heads/takt/20260128-fix-auth', - '', - 'worktree /tmp/other-worktree', - 'HEAD 789abc', - 'branch refs/heads/feature/other', - ].join('\n'); - - const result = parseTaktWorktrees(output); - expect(result).toHaveLength(1); - expect(result[0]!.branch).toBe('takt/20260128-fix-auth'); - }); - it('should handle empty output', () => { - const result = parseTaktWorktrees(''); + const result = parseTaktBranches(''); expect(result).toHaveLength(0); }); - it('should handle bare worktree entry (no branch line)', () => { - const output = [ - 'worktree /home/user/project', - 'HEAD abc123', - 'bare', - ].join('\n'); - - const result = parseTaktWorktrees(output); + it('should handle output with only whitespace lines', () => { + const result = parseTaktBranches(' \n \n'); expect(result).toHaveLength(0); }); - it('should handle detached HEAD worktrees', () => { + it('should handle single branch', () => { + const output = 'takt/20260128-fix-auth abc1234'; + + const result = parseTaktBranches(output); + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + branch: 'takt/20260128-fix-auth', + commit: 'abc1234', + }); + }); + + it('should skip lines without space separator', () => { const output = [ - 'worktree /home/user/project', - 'HEAD abc123', - 'branch refs/heads/main', - '', - 'worktree /tmp/detached', - 'HEAD def456', - 'detached', + 'takt/20260128-fix-auth abc1234', + 'malformed-line', ].join('\n'); - const result = parseTaktWorktrees(output); - expect(result).toHaveLength(0); + const result = parseTaktBranches(output); + expect(result).toHaveLength(1); }); }); @@ -125,45 +94,40 @@ describe('extractTaskSlug', () => { describe('buildReviewItems', () => { it('should build items with correct task slug', () => { - const worktrees: WorktreeInfo[] = [ + const branches: BranchInfo[] = [ { - path: '/project/.takt/worktrees/20260128-fix-auth', branch: 'takt/20260128-fix-auth', commit: 'abc123', }, ]; - // We can't test getFilesChanged without a real git repo, - // so we test buildReviewItems' structure - const items = buildReviewItems('/project', worktrees, 'main'); + const items = buildReviewItems('/project', branches, 'main'); expect(items).toHaveLength(1); expect(items[0]!.taskSlug).toBe('fix-auth'); - expect(items[0]!.info).toBe(worktrees[0]); + expect(items[0]!.info).toBe(branches[0]); // filesChanged will be 0 since we don't have a real git repo expect(items[0]!.filesChanged).toBe(0); }); - it('should handle multiple worktrees', () => { - const worktrees: WorktreeInfo[] = [ + it('should handle multiple branches', () => { + const branches: BranchInfo[] = [ { - path: '/project/.takt/worktrees/20260128-fix-auth', branch: 'takt/20260128-fix-auth', commit: 'abc123', }, { - path: '/project/.takt/worktrees/20260128-add-search', branch: 'takt/20260128-add-search', commit: 'def456', }, ]; - const items = buildReviewItems('/project', worktrees, 'main'); + const items = buildReviewItems('/project', branches, 'main'); expect(items).toHaveLength(2); expect(items[0]!.taskSlug).toBe('fix-auth'); expect(items[1]!.taskSlug).toBe('add-search'); }); - it('should handle empty worktree list', () => { + it('should handle empty branch list', () => { const items = buildReviewItems('/project', [], 'main'); expect(items).toHaveLength(0); }); diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index b57b5d1..59bc654 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -15,11 +15,12 @@ vi.mock('../task/index.js', () => ({ })); vi.mock('../task/worktree.js', () => ({ - createWorktree: vi.fn(), + createSharedClone: vi.fn(), + removeClone: vi.fn(), })); vi.mock('../task/autoCommit.js', () => ({ - autoCommitWorktree: vi.fn(), + autoCommitAndPush: vi.fn(), })); vi.mock('../task/summarize.js', () => ({ @@ -55,13 +56,13 @@ vi.mock('../constants.js', () => ({ DEFAULT_LANGUAGE: 'en', })); -import { createWorktree } from '../task/worktree.js'; +import { createSharedClone } from '../task/worktree.js'; import { summarizeTaskName } from '../task/summarize.js'; import { info } from '../utils/ui.js'; import { resolveTaskExecution } from '../commands/taskExecution.js'; import type { TaskInfo } from '../task/index.js'; -const mockCreateWorktree = vi.mocked(createWorktree); +const mockCreateSharedClone = vi.mocked(createSharedClone); const mockSummarizeTaskName = vi.mocked(summarizeTaskName); const mockInfo = vi.mocked(info); @@ -88,7 +89,7 @@ describe('resolveTaskExecution', () => { isWorktree: false, }); expect(mockSummarizeTaskName).not.toHaveBeenCalled(); - expect(mockCreateWorktree).not.toHaveBeenCalled(); + expect(mockCreateSharedClone).not.toHaveBeenCalled(); }); it('should return defaults when data has no worktree option', async () => { @@ -110,7 +111,7 @@ describe('resolveTaskExecution', () => { expect(mockSummarizeTaskName).not.toHaveBeenCalled(); }); - it('should create worktree with AI-summarized slug when worktree option is true', async () => { + it('should create shared clone with AI-summarized slug when worktree option is true', async () => { // Given: Task with worktree option const task: TaskInfo = { name: 'japanese-task', @@ -123,8 +124,8 @@ describe('resolveTaskExecution', () => { }; mockSummarizeTaskName.mockResolvedValue('add-auth'); - mockCreateWorktree.mockReturnValue({ - path: '/project/.takt/worktrees/20260128T0504-add-auth', + mockCreateSharedClone.mockReturnValue({ + path: '/project/../20260128T0504-add-auth', branch: 'takt/20260128T0504-add-auth', }); @@ -133,13 +134,13 @@ describe('resolveTaskExecution', () => { // Then expect(mockSummarizeTaskName).toHaveBeenCalledWith('認証機能を追加する', { cwd: '/project' }); - expect(mockCreateWorktree).toHaveBeenCalledWith('/project', { + expect(mockCreateSharedClone).toHaveBeenCalledWith('/project', { worktree: true, branch: undefined, taskSlug: 'add-auth', }); expect(result).toEqual({ - execCwd: '/project/.takt/worktrees/20260128T0504-add-auth', + execCwd: '/project/../20260128T0504-add-auth', execWorkflow: 'default', isWorktree: true, }); @@ -158,8 +159,8 @@ describe('resolveTaskExecution', () => { }; mockSummarizeTaskName.mockResolvedValue('test-task'); - mockCreateWorktree.mockReturnValue({ - path: '/project/.takt/worktrees/test-task', + mockCreateSharedClone.mockReturnValue({ + path: '/project/../test-task', branch: 'takt/test-task', }); @@ -183,8 +184,8 @@ describe('resolveTaskExecution', () => { }; mockSummarizeTaskName.mockResolvedValue('new-feature'); - mockCreateWorktree.mockReturnValue({ - path: '/project/.takt/worktrees/new-feature', + mockCreateSharedClone.mockReturnValue({ + path: '/project/../new-feature', branch: 'takt/new-feature', }); @@ -214,7 +215,7 @@ describe('resolveTaskExecution', () => { expect(result.execWorkflow).toBe('custom-workflow'); }); - it('should pass branch option to createWorktree when specified', async () => { + it('should pass branch option to createSharedClone when specified', async () => { // Given: Task with custom branch const task: TaskInfo = { name: 'task-with-branch', @@ -228,8 +229,8 @@ describe('resolveTaskExecution', () => { }; mockSummarizeTaskName.mockResolvedValue('custom-task'); - mockCreateWorktree.mockReturnValue({ - path: '/project/.takt/worktrees/custom-task', + mockCreateSharedClone.mockReturnValue({ + path: '/project/../custom-task', branch: 'feature/custom-branch', }); @@ -237,14 +238,14 @@ describe('resolveTaskExecution', () => { await resolveTaskExecution(task, '/project', 'default'); // Then - expect(mockCreateWorktree).toHaveBeenCalledWith('/project', { + expect(mockCreateSharedClone).toHaveBeenCalledWith('/project', { worktree: true, branch: 'feature/custom-branch', taskSlug: 'custom-task', }); }); - it('should display worktree creation info', async () => { + it('should display clone creation info', async () => { // Given: Task with worktree const task: TaskInfo = { name: 'info-task', @@ -257,8 +258,8 @@ describe('resolveTaskExecution', () => { }; mockSummarizeTaskName.mockResolvedValue('info-task'); - mockCreateWorktree.mockReturnValue({ - path: '/project/.takt/worktrees/20260128-info-task', + mockCreateSharedClone.mockReturnValue({ + path: '/project/../20260128-info-task', branch: 'takt/20260128-info-task', }); @@ -267,7 +268,7 @@ describe('resolveTaskExecution', () => { // Then expect(mockInfo).toHaveBeenCalledWith( - 'Worktree created: /project/.takt/worktrees/20260128-info-task (branch: takt/20260128-info-task)' + 'Clone created: /project/../20260128-info-task (branch: takt/20260128-info-task)' ); }); }); diff --git a/src/cli.ts b/src/cli.ts index f9a8b80..68dae7b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -36,8 +36,8 @@ import { } from './commands/index.js'; import { listWorkflows } from './config/workflowLoader.js'; import { selectOptionWithDefault, confirm } from './prompt/index.js'; -import { createWorktree } from './task/worktree.js'; -import { autoCommitWorktree } from './task/autoCommit.js'; +import { createSharedClone, removeClone } from './task/worktree.js'; +import { autoCommitAndPush } from './task/autoCommit.js'; import { summarizeTaskName } from './task/summarize.js'; import { DEFAULT_WORKFLOW_NAME } from './constants.js'; @@ -49,9 +49,9 @@ export interface WorktreeConfirmationResult { } /** - * Ask user whether to create a worktree, and create one if confirmed. - * Returns the execution directory and whether a worktree was created. - * Task name is summarized to English by AI for use in branch/worktree names. + * Ask user whether to create a shared clone, and create one if confirmed. + * Returns the execution directory and whether a clone was created. + * Task name is summarized to English by AI for use in branch/clone names. */ export async function confirmAndCreateWorktree( cwd: string, @@ -67,11 +67,11 @@ export async function confirmAndCreateWorktree( info('Generating branch name...'); const taskSlug = await summarizeTaskName(task, { cwd }); - const result = createWorktree(cwd, { + const result = createSharedClone(cwd, { worktree: true, taskSlug, }); - info(`Worktree created: ${result.path} (branch: ${result.branch})`); + info(`Clone created: ${result.path} (branch: ${result.branch})`); return { execCwd: result.path, isWorktree: true }; } @@ -228,14 +228,19 @@ program const taskSuccess = await executeTask(task, execCwd, selectedWorkflow, cwd); if (taskSuccess && isWorktree) { - const commitResult = autoCommitWorktree(execCwd, task); + const commitResult = autoCommitAndPush(execCwd, task); if (commitResult.success && commitResult.commitHash) { - success(`Auto-committed: ${commitResult.commitHash}`); + success(`Auto-committed & pushed: ${commitResult.commitHash}`); } else if (!commitResult.success) { error(`Auto-commit failed: ${commitResult.message}`); } } + // Remove clone after task completion (success or failure) + if (isWorktree) { + removeClone(execCwd); + } + if (!taskSuccess) { process.exit(1); } diff --git a/src/commands/help.ts b/src/commands/help.ts index abdf5f2..5e74ba2 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -17,7 +17,7 @@ Usage: takt /run-tasks (/run) Run all pending tasks from .takt/tasks/ takt /watch Watch for tasks and auto-execute (stays resident) takt /add-task (/add) Add a new task (interactive, YAML format) - takt /review-tasks (/review) Review worktree task results (merge/delete) + takt /review-tasks (/review) Review task branches (merge/delete) takt /switch Switch workflow interactively takt /clear Clear agent conversation sessions (reset to initial state) takt /refresh-builtin Overwrite builtin agents/workflows with latest version @@ -30,13 +30,13 @@ Examples: takt /clear # Clear sessions, start fresh takt /watch # Watch & auto-execute tasks takt /refresh-builtin # Update builtin resources - takt /review-tasks # Review & merge worktree results + takt /review-tasks # Review & merge task branches takt /switch takt /run-tasks Task files (.takt/tasks/): .md files Plain text tasks (backward compatible) - .yaml files Structured tasks with worktree/branch/workflow options + .yaml files Structured tasks with isolation/branch/workflow options Configuration (.takt/config.yaml): workflow: default # Current workflow diff --git a/src/commands/reviewTasks.ts b/src/commands/reviewTasks.ts index 1dd244d..05d4c4f 100644 --- a/src/commands/reviewTasks.ts +++ b/src/commands/reviewTasks.ts @@ -1,31 +1,33 @@ /** * Review tasks command * - * Interactive UI for reviewing worktree-based task results: + * Interactive UI for reviewing branch-based task results: * try merge, merge & cleanup, or delete actions. + * Clones are ephemeral — only branches persist between sessions. */ import { execFileSync, spawnSync } from 'node:child_process'; import chalk from 'chalk'; import { - removeWorktree, detectDefaultBranch, - listTaktWorktrees, + listTaktBranches, buildReviewItems, - type WorktreeReviewItem, + createTempCloneForBranch, + removeClone, + type BranchReviewItem, } from '../task/worktree.js'; +import { autoCommitAndPush } from '../task/autoCommit.js'; import { selectOption, confirm, promptInput } from '../prompt/index.js'; import { info, success, error as logError, warn } from '../utils/ui.js'; import { createLogger } from '../utils/debug.js'; import { executeTask } from './taskExecution.js'; -import { autoCommitWorktree } from '../task/autoCommit.js'; import { listWorkflows } from '../config/workflowLoader.js'; import { getCurrentWorkflow } from '../config/paths.js'; import { DEFAULT_WORKFLOW_NAME } from '../constants.js'; const log = createLogger('review-tasks'); -/** Actions available for a reviewed worktree */ +/** Actions available for a reviewed branch */ export type ReviewAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete'; /** @@ -72,7 +74,7 @@ export function showFullDiff( async function showDiffAndPromptAction( cwd: string, defaultBranch: string, - item: WorktreeReviewItem, + item: BranchReviewItem, ): Promise { console.log(); console.log(chalk.bold.cyan(`=== ${item.info.branch} ===`)); @@ -94,10 +96,10 @@ async function showDiffAndPromptAction( `Action for ${item.info.branch}:`, [ { label: 'View diff', value: 'diff', description: 'Show full diff in pager' }, - { label: 'Instruct', value: 'instruct', description: 'Give additional instructions to modify this worktree' }, + { label: 'Instruct', value: 'instruct', description: 'Give additional instructions via temp clone' }, { label: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' }, - { label: 'Merge & cleanup', value: 'merge', description: 'Merge (if needed) and remove worktree & branch' }, - { label: 'Delete', value: 'delete', description: 'Discard changes, remove worktree and branch' }, + { label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' }, + { label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' }, ], ); @@ -106,10 +108,9 @@ async function showDiffAndPromptAction( /** * Try-merge (squash): stage changes from branch without committing. - * Keeps the worktree and branch intact for further review. * User can inspect staged changes and commit manually if satisfied. */ -export function tryMergeWorktreeBranch(projectDir: string, item: WorktreeReviewItem): boolean { +export function tryMergeBranch(projectDir: string, item: BranchReviewItem): boolean { const { branch } = item.info; try { @@ -133,18 +134,16 @@ export function tryMergeWorktreeBranch(projectDir: string, item: WorktreeReviewI } /** - * Merge & cleanup: if already merged, skip merge and just cleanup. - * Otherwise merge first, then cleanup (remove worktree + delete branch). + * Merge & cleanup: if already merged, skip merge and just delete the branch. + * Otherwise merge first, then delete the branch. + * No worktree removal needed — clones are ephemeral. */ -export function mergeWorktreeBranch(projectDir: string, item: WorktreeReviewItem): boolean { +export function mergeBranch(projectDir: string, item: BranchReviewItem): boolean { const { branch } = item.info; const alreadyMerged = isBranchMerged(projectDir, branch); try { - // 1. Remove worktree (must happen before merge to unlock branch) - removeWorktree(projectDir, item.info.path); - - // 2. Merge only if not already merged + // Merge only if not already merged if (alreadyMerged) { info(`${branch} is already merged, skipping merge.`); log.info('Branch already merged, cleanup only', { branch }); @@ -156,7 +155,7 @@ export function mergeWorktreeBranch(projectDir: string, item: WorktreeReviewItem }); } - // 3. Delete the branch + // Delete the branch try { execFileSync('git', ['branch', '-d', branch], { cwd: projectDir, @@ -168,7 +167,7 @@ export function mergeWorktreeBranch(projectDir: string, item: WorktreeReviewItem } success(`Merged & cleaned up ${branch}`); - log.info('Worktree merged & cleaned up', { branch, alreadyMerged }); + log.info('Branch merged & cleaned up', { branch, alreadyMerged }); return true; } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -180,16 +179,14 @@ export function mergeWorktreeBranch(projectDir: string, item: WorktreeReviewItem } /** - * Delete a worktree and its branch (discard changes). + * Delete a branch (discard changes). + * No worktree removal needed — clones are ephemeral. */ -export function deleteWorktreeBranch(projectDir: string, item: WorktreeReviewItem): boolean { +export function deleteBranch(projectDir: string, item: BranchReviewItem): boolean { const { branch } = item.info; try { - // 1. Remove worktree - removeWorktree(projectDir, item.info.path); - - // 2. Force-delete the branch + // Force-delete the branch execFileSync('git', ['branch', '-D', branch], { cwd: projectDir, encoding: 'utf-8', @@ -197,7 +194,7 @@ export function deleteWorktreeBranch(projectDir: string, item: WorktreeReviewIte }); success(`Deleted ${branch}`); - log.info('Worktree deleted', { branch }); + log.info('Branch deleted', { branch }); return true; } catch (err) { const msg = err instanceof Error ? err.message : String(err); @@ -233,9 +230,9 @@ async function selectWorkflowForInstruction(projectDir: string): Promise { const { branch } = item.info; - const worktreePath = item.info.path; // 1. Prompt for instruction const instruction = await promptInput('Enter instruction'); @@ -300,53 +296,61 @@ export async function instructWorktree( return false; } - log.info('Instructing worktree', { branch, worktreePath, workflow: selectedWorkflow }); + log.info('Instructing branch via temp clone', { branch, workflow: selectedWorkflow }); info(`Running instruction on ${branch}...`); - // 3. Build instruction with worktree context - const worktreeContext = getWorktreeContext(projectDir, branch); - const fullInstruction = worktreeContext - ? `${worktreeContext}## 追加指示\n${instruction}` - : instruction; + // 3. Create temp clone for the branch + const clone = createTempCloneForBranch(projectDir, branch); - // 4. Execute task on worktree - const taskSuccess = await executeTask(fullInstruction, worktreePath, selectedWorkflow, projectDir); + try { + // 4. Build instruction with branch context + const branchContext = getBranchContext(projectDir, branch); + const fullInstruction = branchContext + ? `${branchContext}## 追加指示\n${instruction}` + : instruction; - // 5. Auto-commit if successful - if (taskSuccess) { - const commitResult = autoCommitWorktree(worktreePath, item.taskSlug); - if (commitResult.success && commitResult.commitHash) { - info(`Auto-committed: ${commitResult.commitHash}`); - } else if (!commitResult.success) { - warn(`Auto-commit skipped: ${commitResult.message}`); + // 5. Execute task on temp clone + const taskSuccess = await executeTask(fullInstruction, clone.path, selectedWorkflow, projectDir); + + // 6. Auto-commit+push if successful + if (taskSuccess) { + const commitResult = autoCommitAndPush(clone.path, item.taskSlug); + if (commitResult.success && commitResult.commitHash) { + info(`Auto-committed & pushed: ${commitResult.commitHash}`); + } else if (!commitResult.success) { + warn(`Auto-commit skipped: ${commitResult.message}`); + } + success(`Instruction completed on ${branch}`); + log.info('Instruction completed', { branch }); + } else { + logError(`Instruction failed on ${branch}`); + log.error('Instruction failed', { branch }); } - success(`Instruction completed on ${branch}`); - log.info('Instruction completed', { branch }); - } else { - logError(`Instruction failed on ${branch}`); - log.error('Instruction failed', { branch }); - } - return taskSuccess; + return taskSuccess; + } finally { + // 7. Always remove temp clone + removeClone(clone.path); + } } /** - * Main entry point: review worktree tasks interactively. + * Main entry point: review branch-based tasks interactively. */ export async function reviewTasks(cwd: string): Promise { log.info('Starting review-tasks'); const defaultBranch = detectDefaultBranch(cwd); - let worktrees = listTaktWorktrees(cwd); + let branches = listTaktBranches(cwd); - if (worktrees.length === 0) { + if (branches.length === 0) { info('No tasks to review.'); return; } // Interactive loop - while (worktrees.length > 0) { - const items = buildReviewItems(cwd, worktrees, defaultBranch); + while (branches.length > 0) { + const items = buildReviewItems(cwd, branches, defaultBranch); // Build selection options const options = items.map((item, idx) => ({ @@ -356,7 +360,7 @@ export async function reviewTasks(cwd: string): Promise { })); const selected = await selectOption( - 'Review Tasks (Worktrees)', + 'Review Tasks (Branches)', options, ); @@ -382,13 +386,13 @@ export async function reviewTasks(cwd: string): Promise { switch (action) { case 'instruct': - await instructWorktree(cwd, item); + await instructBranch(cwd, item); break; case 'try': - tryMergeWorktreeBranch(cwd, item); + tryMergeBranch(cwd, item); break; case 'merge': - mergeWorktreeBranch(cwd, item); + mergeBranch(cwd, item); break; case 'delete': { const confirmed = await confirm( @@ -396,14 +400,14 @@ export async function reviewTasks(cwd: string): Promise { false, ); if (confirmed) { - deleteWorktreeBranch(cwd, item); + deleteBranch(cwd, item); } break; } } - // Refresh worktree list after action - worktrees = listTaktWorktrees(cwd); + // Refresh branch list after action + branches = listTaktBranches(cwd); } info('All tasks reviewed.'); diff --git a/src/commands/taskExecution.ts b/src/commands/taskExecution.ts index b14ec1f..975099e 100644 --- a/src/commands/taskExecution.ts +++ b/src/commands/taskExecution.ts @@ -4,8 +4,8 @@ import { loadWorkflow, loadGlobalConfig } from '../config/index.js'; import { TaskRunner, type TaskInfo } from '../task/index.js'; -import { createWorktree } from '../task/worktree.js'; -import { autoCommitWorktree } from '../task/autoCommit.js'; +import { createSharedClone, removeClone } from '../task/worktree.js'; +import { autoCommitAndPush } from '../task/autoCommit.js'; import { summarizeTaskName } from '../task/summarize.js'; import { header, @@ -24,7 +24,7 @@ const log = createLogger('task'); /** * Execute a single task with workflow * @param task - Task content - * @param cwd - Working directory (may be a worktree path) + * @param cwd - Working directory (may be a clone path) * @param workflowName - Workflow to use * @param projectCwd - Project root (where .takt/ lives). Defaults to cwd. */ @@ -57,7 +57,7 @@ export async function executeTask( } /** - * Execute a task: resolve worktree → run workflow → auto-commit → record completion. + * Execute a task: resolve clone → run workflow → auto-commit+push → remove clone → record completion. * * Shared by runAllTasks() and watchTasks() to avoid duplicated * resolve → execute → autoCommit → complete logic. @@ -81,14 +81,19 @@ export async function executeAndCompleteTask( const completedAt = new Date().toISOString(); if (taskSuccess && isWorktree) { - const commitResult = autoCommitWorktree(execCwd, task.name); + const commitResult = autoCommitAndPush(execCwd, task.name); if (commitResult.success && commitResult.commitHash) { - info(`Auto-committed: ${commitResult.commitHash}`); + info(`Auto-committed & pushed: ${commitResult.commitHash}`); } else if (!commitResult.success) { error(`Auto-commit failed: ${commitResult.message}`); } } + // Remove clone after task completion (success or failure) + if (isWorktree) { + removeClone(execCwd); + } + const taskResult = { task, success: taskSuccess, @@ -179,8 +184,8 @@ export async function runAllTasks( /** * Resolve execution directory and workflow from task data. - * If the task has worktree settings, create a worktree and use it as cwd. - * Task name is summarized to English by AI for use in branch/worktree names. + * If the task has worktree settings, create a shared clone and use it as cwd. + * Task name is summarized to English by AI for use in branch/clone names. */ export async function resolveTaskExecution( task: TaskInfo, @@ -197,20 +202,20 @@ export async function resolveTaskExecution( let execCwd = defaultCwd; let isWorktree = false; - // Handle worktree + // Handle worktree (now creates a shared clone) if (data.worktree) { // Summarize task content to English slug using AI info('Generating branch name...'); const taskSlug = await summarizeTaskName(task.content, { cwd: defaultCwd }); - const result = createWorktree(defaultCwd, { + const result = createSharedClone(defaultCwd, { worktree: data.worktree, branch: data.branch, taskSlug, }); execCwd = result.path; isWorktree = true; - info(`Worktree created: ${result.path} (branch: ${result.branch})`); + info(`Clone created: ${result.path} (branch: ${result.branch})`); } // Handle workflow override diff --git a/src/commands/workflowExecution.ts b/src/commands/workflowExecution.ts index c01c0da..b5707e7 100644 --- a/src/commands/workflowExecution.ts +++ b/src/commands/workflowExecution.ts @@ -81,7 +81,7 @@ export async function executeWorkflow( headerPrefix = 'Running Workflow:', } = options; - // projectCwd is where .takt/ lives (project root, not worktree) + // projectCwd is where .takt/ lives (project root, not the clone) const projectCwd = options.projectCwd ?? cwd; // Always continue from previous sessions (use /clear to reset) @@ -108,14 +108,14 @@ export async function executeWorkflow( displayRef.current.createHandler()(event); }; - // Load saved agent sessions for continuity (from project root or worktree-specific storage) + // Load saved agent sessions for continuity (from project root or clone-specific storage) const isWorktree = cwd !== projectCwd; const savedSessions = isWorktree ? loadWorktreeSessions(projectCwd, cwd) : loadAgentSessions(projectCwd); // Session update handler - persist session IDs when they change - // Worktree sessions are stored separately per worktree path + // Clone sessions are stored separately per clone path const sessionUpdateHandler = isWorktree ? (agentName: string, agentSessionId: string): void => { updateWorktreeSession(projectCwd, cwd, agentName, agentSessionId); diff --git a/src/models/schemas.ts b/src/models/schemas.ts index 8cb0db0..e0379f1 100644 --- a/src/models/schemas.ts +++ b/src/models/schemas.ts @@ -127,7 +127,7 @@ export const GlobalConfigSchema = z.object({ provider: z.enum(['claude', 'codex', 'mock']).optional().default('claude'), model: z.string().optional(), debug: DebugConfigSchema.optional(), - /** Directory for worktrees. If empty, uses ../{tree-name} relative to project */ + /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ worktree_dir: z.string().optional(), }); diff --git a/src/models/types.ts b/src/models/types.ts index 39cf422..53ffe30 100644 --- a/src/models/types.ts +++ b/src/models/types.ts @@ -160,7 +160,7 @@ export interface GlobalConfig { provider?: 'claude' | 'codex' | 'mock'; model?: string; debug?: DebugConfig; - /** Directory for worktrees. If empty, uses ../{tree-name} relative to project */ + /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ worktreeDir?: string; } diff --git a/src/task/autoCommit.ts b/src/task/autoCommit.ts index 3e4f57b..7910257 100644 --- a/src/task/autoCommit.ts +++ b/src/task/autoCommit.ts @@ -1,8 +1,9 @@ /** - * Auto-commit for worktree tasks + * Auto-commit and push for clone tasks * - * After a successful workflow completion in a worktree, - * automatically stages all changes and creates a commit. + * After a successful workflow completion in a shared clone, + * automatically stages all changes, creates a commit, and + * pushes to origin so the branch is reflected in the main repo. * No co-author trailer is added. */ @@ -21,29 +22,30 @@ export interface AutoCommitResult { } /** - * Auto-commit all changes in a worktree directory. + * Auto-commit all changes and push to origin. * * Steps: * 1. Stage all changes (git add -A) * 2. Check if there are staged changes (git status --porcelain) * 3. If changes exist, create a commit with "takt: {taskName}" + * 4. Push to origin (git push origin HEAD) * - * @param worktreeCwd - The worktree directory + * @param cloneCwd - The clone directory * @param taskName - Task name used in commit message */ -export function autoCommitWorktree(worktreeCwd: string, taskName: string): AutoCommitResult { - log.info('Auto-commit starting', { cwd: worktreeCwd, taskName }); +export function autoCommitAndPush(cloneCwd: string, taskName: string): AutoCommitResult { + log.info('Auto-commit starting', { cwd: cloneCwd, taskName }); try { // Stage all changes execFileSync('git', ['add', '-A'], { - cwd: worktreeCwd, + cwd: cloneCwd, stdio: 'pipe', }); // Check if there are staged changes const statusOutput = execFileSync('git', ['status', '--porcelain'], { - cwd: worktreeCwd, + cwd: cloneCwd, stdio: 'pipe', encoding: 'utf-8', }); @@ -56,23 +58,31 @@ export function autoCommitWorktree(worktreeCwd: string, taskName: string): AutoC // Create commit (no co-author) const commitMessage = `takt: ${taskName}`; execFileSync('git', ['commit', '-m', commitMessage], { - cwd: worktreeCwd, + cwd: cloneCwd, stdio: 'pipe', }); // Get the short commit hash const commitHash = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { - cwd: worktreeCwd, + cwd: cloneCwd, stdio: 'pipe', encoding: 'utf-8', }).trim(); log.info('Auto-commit created', { commitHash, message: commitMessage }); + // Push to origin so the branch is reflected in the main repo + execFileSync('git', ['push', 'origin', 'HEAD'], { + cwd: cloneCwd, + stdio: 'pipe', + }); + + log.info('Pushed to origin'); + return { success: true, commitHash, - message: `Committed: ${commitHash} - ${commitMessage}`, + message: `Committed & pushed: ${commitHash} - ${commitMessage}`, }; } catch (err) { const errorMessage = err instanceof Error ? err.message : String(err); diff --git a/src/task/index.ts b/src/task/index.ts index 6ace254..5164261 100644 --- a/src/task/index.ts +++ b/src/task/index.ts @@ -13,18 +13,19 @@ export { showTaskList } from './display.js'; export { TaskFileSchema, type TaskFileData } from './schema.js'; export { parseTaskFile, parseTaskFiles, type ParsedTask } from './parser.js'; export { - createWorktree, - removeWorktree, + createSharedClone, + removeClone, + createTempCloneForBranch, detectDefaultBranch, - parseTaktWorktrees, - listTaktWorktrees, + listTaktBranches, + parseTaktBranches, getFilesChanged, extractTaskSlug, buildReviewItems, type WorktreeOptions, type WorktreeResult, - type WorktreeInfo, - type WorktreeReviewItem, + type BranchInfo, + type BranchReviewItem, } from './worktree.js'; -export { autoCommitWorktree, type AutoCommitResult } from './autoCommit.js'; +export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js'; export { TaskWatcher, type TaskWatcherOptions } from './watcher.js'; diff --git a/src/task/schema.ts b/src/task/schema.ts index bc0c7ce..aec1e5b 100644 --- a/src/task/schema.ts +++ b/src/task/schema.ts @@ -11,14 +11,14 @@ import { z } from 'zod/v4'; * * Examples: * task: "認証機能を追加する" - * worktree: true # .takt/worktrees/{timestamp}-{task-slug}/ に作成 + * worktree: true # 共有クローンで隔離実行 * branch: "feat/add-auth" # オプション(省略時は自動生成) * workflow: "default" # オプション(省略時はcurrent workflow) * - * worktree patterns: - * - true: create at .takt/worktrees/{timestamp}-{task-slug}/ + * worktree patterns (uses git clone --shared internally): + * - true: create shared clone in sibling dir or worktree_dir * - "/path/to/dir": create at specified path - * - omitted: no worktree (run in cwd) + * - omitted: no isolation (run in cwd) * * branch patterns: * - "feat/xxx": use specified branch name diff --git a/src/task/summarize.ts b/src/task/summarize.ts index 3bda41b..76aac60 100644 --- a/src/task/summarize.ts +++ b/src/task/summarize.ts @@ -1,7 +1,7 @@ /** * Task name summarization using AI or romanization * - * Generates concise English/romaji summaries for use in branch names and worktree paths. + * Generates concise English/romaji summaries for use in branch names and clone paths. */ import * as wanakana from 'wanakana'; diff --git a/src/task/worktree.ts b/src/task/worktree.ts index 0f273ec..dffbf8e 100644 --- a/src/task/worktree.ts +++ b/src/task/worktree.ts @@ -1,7 +1,10 @@ /** - * Git worktree management + * Git shared clone management * - * Creates and removes git worktrees for task isolation. + * Creates and removes git shared clones for task isolation. + * Uses `git clone --shared` instead of worktrees so each clone + * has an independent .git directory, preventing Claude Code from + * traversing gitdir back to the main repository. */ import * as fs from 'node:fs'; @@ -23,12 +26,25 @@ export interface WorktreeOptions { } export interface WorktreeResult { - /** Absolute path to the worktree */ + /** Absolute path to the clone */ path: string; /** Branch name used */ branch: string; } +/** Branch info from `git branch --list` */ +export interface BranchInfo { + branch: string; + commit: string; +} + +/** Branch with review metadata */ +export interface BranchReviewItem { + info: BranchInfo; + filesChanged: number; + taskSlug: string; +} + /** * Generate a timestamp string for paths/branches */ @@ -37,14 +53,14 @@ function generateTimestamp(): string { } /** - * Resolve the worktree path based on options and global config. + * Resolve the clone path based on options and global config. * * Priority: * 1. Custom path in options.worktree (string) * 2. worktree_dir from config.yaml (if set) - * 3. Default: ../{tree-name} + * 3. Default: ../{dir-name} */ -function resolveWorktreePath(projectDir: string, options: WorktreeOptions): string { +function resolveClonePath(projectDir: string, options: WorktreeOptions): string { const timestamp = generateTimestamp(); const slug = slugify(options.taskSlug); const dirName = slug ? `${timestamp}-${slug}` : timestamp; @@ -96,52 +112,98 @@ function branchExists(projectDir: string, branch: string): boolean { } /** - * Create a git worktree for a task + * Create a git shared clone for a task. + * + * Uses `git clone --shared` to create a lightweight clone with + * an independent .git directory. Then checks out a new branch. * * @returns WorktreeResult with path and branch - * @throws Error if git worktree creation fails + * @throws Error if git clone creation fails */ -export function createWorktree(projectDir: string, options: WorktreeOptions): WorktreeResult { - const worktreePath = resolveWorktreePath(projectDir, options); +export function createSharedClone(projectDir: string, options: WorktreeOptions): WorktreeResult { + const clonePath = resolveClonePath(projectDir, options); const branch = resolveBranchName(options); - log.info('Creating worktree', { path: worktreePath, branch }); + log.info('Creating shared clone', { path: clonePath, branch }); // Ensure parent directory exists - fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); + fs.mkdirSync(path.dirname(clonePath), { recursive: true }); - // Create worktree (use execFileSync to avoid shell injection) - if (branchExists(projectDir, branch)) { - execFileSync('git', ['worktree', 'add', worktreePath, branch], { - cwd: projectDir, + // Create shared clone + execFileSync('git', ['clone', '--shared', projectDir, clonePath], { + cwd: projectDir, + stdio: 'pipe', + }); + + // Checkout branch + if (branchExists(clonePath, branch)) { + execFileSync('git', ['checkout', branch], { + cwd: clonePath, stdio: 'pipe', }); } else { - execFileSync('git', ['worktree', 'add', '-b', branch, worktreePath], { - cwd: projectDir, + execFileSync('git', ['checkout', '-b', branch], { + cwd: clonePath, stdio: 'pipe', }); } - log.info('Worktree created', { path: worktreePath, branch }); + log.info('Shared clone created', { path: clonePath, branch }); - return { path: worktreePath, branch }; + return { path: clonePath, branch }; } /** - * Remove a git worktree + * Create a temporary shared clone for an existing branch. + * Used by review/instruct to work on a branch that was previously pushed. + * + * @returns WorktreeResult with path and branch + * @throws Error if git clone creation fails */ -export function removeWorktree(projectDir: string, worktreePath: string): void { - log.info('Removing worktree', { path: worktreePath }); +export function createTempCloneForBranch(projectDir: string, branch: string): WorktreeResult { + const timestamp = generateTimestamp(); + const globalConfig = loadGlobalConfig(); + let clonePath: string; + + if (globalConfig.worktreeDir) { + const baseDir = path.isAbsolute(globalConfig.worktreeDir) + ? globalConfig.worktreeDir + : path.resolve(projectDir, globalConfig.worktreeDir); + clonePath = path.join(baseDir, `tmp-${timestamp}`); + } else { + clonePath = path.join(projectDir, '..', `tmp-${timestamp}`); + } + + log.info('Creating temp clone for branch', { path: clonePath, branch }); + + fs.mkdirSync(path.dirname(clonePath), { recursive: true }); + + execFileSync('git', ['clone', '--shared', projectDir, clonePath], { + cwd: projectDir, + stdio: 'pipe', + }); + + execFileSync('git', ['checkout', branch], { + cwd: clonePath, + stdio: 'pipe', + }); + + log.info('Temp clone created', { path: clonePath, branch }); + + return { path: clonePath, branch }; +} + +/** + * Remove a clone directory + */ +export function removeClone(clonePath: string): void { + log.info('Removing clone', { path: clonePath }); try { - execFileSync('git', ['worktree', 'remove', worktreePath, '--force'], { - cwd: projectDir, - stdio: 'pipe', - }); - log.info('Worktree removed', { path: worktreePath }); + fs.rmSync(clonePath, { recursive: true, force: true }); + log.info('Clone removed', { path: clonePath }); } catch (err) { - log.error('Failed to remove worktree', { path: worktreePath, error: String(err) }); + log.error('Failed to remove clone', { path: clonePath, error: String(err) }); } } @@ -149,20 +211,6 @@ export function removeWorktree(projectDir: string, worktreePath: string): void { const TAKT_BRANCH_PREFIX = 'takt/'; -/** Parsed worktree entry from git worktree list */ -export interface WorktreeInfo { - path: string; - branch: string; - commit: string; -} - -/** Worktree with review metadata */ -export interface WorktreeReviewItem { - info: WorktreeInfo; - filesChanged: number; - taskSlug: string; -} - /** * Detect the default branch name (main or master). * Falls back to 'main'. @@ -197,54 +245,48 @@ export function detectDefaultBranch(cwd: string): string { } /** - * Parse `git worktree list --porcelain` output into WorktreeInfo entries. - * Only includes worktrees on branches with the takt/ prefix. + * List all takt-managed branches. + * Uses `git branch --list 'takt/*'` instead of worktree list. */ -export function parseTaktWorktrees(porcelainOutput: string): WorktreeInfo[] { - const entries: WorktreeInfo[] = []; - const blocks = porcelainOutput.trim().split('\n\n'); +export function listTaktBranches(projectDir: string): BranchInfo[] { + try { + const output = execFileSync( + 'git', ['branch', '--list', 'takt/*', '--format=%(refname:short) %(objectname:short)'], + { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, + ); + return parseTaktBranches(output); + } catch (err) { + log.error('Failed to list takt branches', { error: String(err) }); + return []; + } +} - for (const block of blocks) { - const lines = block.split('\n'); - let wtPath = ''; - let commit = ''; - let branch = ''; +/** + * Parse `git branch --list` formatted output into BranchInfo entries. + */ +export function parseTaktBranches(output: string): BranchInfo[] { + const entries: BranchInfo[] = []; + const lines = output.trim().split('\n'); - for (const line of lines) { - if (line.startsWith('worktree ')) { - wtPath = line.slice('worktree '.length); - } else if (line.startsWith('HEAD ')) { - commit = line.slice('HEAD '.length); - } else if (line.startsWith('branch ')) { - const ref = line.slice('branch '.length); - branch = ref.replace('refs/heads/', ''); - } - } + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; - if (wtPath && branch.startsWith(TAKT_BRANCH_PREFIX)) { - entries.push({ path: wtPath, branch, commit }); + // Format: "takt/20260128-fix-auth abc1234" + const spaceIdx = trimmed.lastIndexOf(' '); + if (spaceIdx === -1) continue; + + const branch = trimmed.slice(0, spaceIdx); + const commit = trimmed.slice(spaceIdx + 1); + + if (branch.startsWith(TAKT_BRANCH_PREFIX)) { + entries.push({ branch, commit }); } } return entries; } -/** - * List all takt-managed worktrees. - */ -export function listTaktWorktrees(projectDir: string): WorktreeInfo[] { - try { - const output = execFileSync( - 'git', ['worktree', 'list', '--porcelain'], - { cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }, - ); - return parseTaktWorktrees(output); - } catch (err) { - log.error('Failed to list worktrees', { error: String(err) }); - return []; - } -} - /** * Get the number of files changed between the default branch and a given branch. */ @@ -262,7 +304,7 @@ export function getFilesChanged(cwd: string, defaultBranch: string, branch: stri /** * Extract a human-readable task slug from a takt branch name. - * e.g. "takt/20260128T032800-fix-auth" → "fix-auth" + * e.g. "takt/20260128T032800-fix-auth" -> "fix-auth" */ export function extractTaskSlug(branch: string): string { const name = branch.replace(TAKT_BRANCH_PREFIX, ''); @@ -272,16 +314,16 @@ export function extractTaskSlug(branch: string): string { } /** - * Build review items from worktree list, enriching with diff stats. + * Build review items from branch list, enriching with diff stats. */ export function buildReviewItems( projectDir: string, - worktrees: WorktreeInfo[], + branches: BranchInfo[], defaultBranch: string, -): WorktreeReviewItem[] { - return worktrees.map(wt => ({ - info: wt, - filesChanged: getFilesChanged(projectDir, defaultBranch, wt.branch), - taskSlug: extractTaskSlug(wt.branch), +): BranchReviewItem[] { + return branches.map(br => ({ + info: br, + filesChanged: getFilesChanged(projectDir, defaultBranch, br.branch), + taskSlug: extractTaskSlug(br.branch), })); } diff --git a/src/workflow/engine.ts b/src/workflow/engine.ts index adb3b9b..db76a1a 100644 --- a/src/workflow/engine.ts +++ b/src/workflow/engine.ts @@ -73,7 +73,7 @@ export class WorkflowEngine extends EventEmitter { }); } - /** Ensure report directory exists (always in project root, not worktree) */ + /** Ensure report directory exists (always in project root, not clone) */ private ensureReportDirExists(): void { const reportDirPath = join(this.projectCwd, '.takt', 'reports', this.reportDir); if (!existsSync(reportDirPath)) { diff --git a/src/workflow/instruction-builder.ts b/src/workflow/instruction-builder.ts index eb5077c..5b6a7dd 100644 --- a/src/workflow/instruction-builder.ts +++ b/src/workflow/instruction-builder.ts @@ -21,7 +21,7 @@ export interface InstructionContext { maxIterations: number; /** Current step's iteration number (how many times this step has been executed) */ stepIteration: number; - /** Working directory (agent work dir, may be a worktree) */ + /** Working directory (agent work dir, may be a clone) */ cwd: string; /** Project root directory (where .takt/ lives). Defaults to cwd. */ projectCwd?: string; @@ -37,7 +37,7 @@ export interface InstructionContext { /** Execution environment metadata prepended to agent instructions */ export interface ExecutionMetadata { - /** The agent's working directory (may be a worktree) */ + /** The agent's working directory (may be a clone) */ readonly workingDirectory: string; /** Language for metadata rendering */ readonly language: Language; @@ -183,7 +183,7 @@ export function buildInstruction( // Replace .takt/reports/{report_dir} with absolute path first, // then replace standalone {report_dir} with the directory name. // This ensures agents always use the correct project root for reports, - // even when their cwd is a worktree. + // even when their cwd is a clone. if (context.reportDir) { const projectRoot = context.projectCwd ?? context.cwd; const reportDirFullPath = join(projectRoot, '.takt', 'reports', context.reportDir);