Stop using git worktree due to Claude Code SDK working at repository root
This commit is contained in:
parent
83621d689e
commit
63d6932c01
19
CLAUDE.md
19
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
|
||||
|
||||
|
||||
37
README.md
37
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
|
||||
|
||||
|
||||
@ -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/ # セッションログ
|
||||
```
|
||||
|
||||
2
resources/project/.gitignore
vendored
2
resources/project/.gitignore
vendored
@ -3,6 +3,6 @@ logs/
|
||||
reports/
|
||||
completed/
|
||||
tasks/
|
||||
worktrees/
|
||||
worktree-meta/
|
||||
agent_sessions.json
|
||||
input_history
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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)'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
23
src/cli.ts
23
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);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<ReviewAction | null> {
|
||||
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<string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get worktree context: diff stat and commit log from main branch.
|
||||
* Get branch context: diff stat and commit log from main branch.
|
||||
*/
|
||||
function getWorktreeContext(projectDir: string, branch: string): string {
|
||||
function getBranchContext(projectDir: string, branch: string): string {
|
||||
const defaultBranch = detectDefaultBranch(projectDir);
|
||||
const lines: string[] = [];
|
||||
|
||||
@ -276,15 +273,14 @@ function getWorktreeContext(projectDir: string, branch: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Instruct worktree: give additional instructions to modify the worktree.
|
||||
* Executes a task on the worktree and auto-commits if successful.
|
||||
* Instruct branch: create a temp clone, give additional instructions,
|
||||
* auto-commit+push, then remove clone.
|
||||
*/
|
||||
export async function instructWorktree(
|
||||
export async function instructBranch(
|
||||
projectDir: string,
|
||||
item: WorktreeReviewItem,
|
||||
item: BranchReviewItem,
|
||||
): Promise<boolean> {
|
||||
const { branch } = item.info;
|
||||
const worktreePath = item.info.path;
|
||||
|
||||
// 1. Prompt for instruction
|
||||
const instruction = await promptInput('Enter instruction');
|
||||
@ -300,23 +296,27 @@ 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}`
|
||||
// 3. Create temp clone for the branch
|
||||
const clone = createTempCloneForBranch(projectDir, branch);
|
||||
|
||||
try {
|
||||
// 4. Build instruction with branch context
|
||||
const branchContext = getBranchContext(projectDir, branch);
|
||||
const fullInstruction = branchContext
|
||||
? `${branchContext}## 追加指示\n${instruction}`
|
||||
: instruction;
|
||||
|
||||
// 4. Execute task on worktree
|
||||
const taskSuccess = await executeTask(fullInstruction, worktreePath, selectedWorkflow, projectDir);
|
||||
// 5. Execute task on temp clone
|
||||
const taskSuccess = await executeTask(fullInstruction, clone.path, selectedWorkflow, projectDir);
|
||||
|
||||
// 5. Auto-commit if successful
|
||||
// 6. Auto-commit+push if successful
|
||||
if (taskSuccess) {
|
||||
const commitResult = autoCommitWorktree(worktreePath, item.taskSlug);
|
||||
const commitResult = autoCommitAndPush(clone.path, item.taskSlug);
|
||||
if (commitResult.success && commitResult.commitHash) {
|
||||
info(`Auto-committed: ${commitResult.commitHash}`);
|
||||
info(`Auto-committed & pushed: ${commitResult.commitHash}`);
|
||||
} else if (!commitResult.success) {
|
||||
warn(`Auto-commit skipped: ${commitResult.message}`);
|
||||
}
|
||||
@ -328,25 +328,29 @@ export async function instructWorktree(
|
||||
}
|
||||
|
||||
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<void> {
|
||||
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<void> {
|
||||
}));
|
||||
|
||||
const selected = await selectOption<string>(
|
||||
'Review Tasks (Worktrees)',
|
||||
'Review Tasks (Branches)',
|
||||
options,
|
||||
);
|
||||
|
||||
@ -382,13 +386,13 @@ export async function reviewTasks(cwd: string): Promise<void> {
|
||||
|
||||
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<void> {
|
||||
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.');
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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], {
|
||||
// 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;
|
||||
|
||||
try {
|
||||
execFileSync('git', ['worktree', 'remove', worktreePath, '--force'], {
|
||||
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',
|
||||
});
|
||||
log.info('Worktree removed', { path: worktreePath });
|
||||
|
||||
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 {
|
||||
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/', '');
|
||||
}
|
||||
}
|
||||
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),
|
||||
}));
|
||||
}
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user