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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 14 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ Taskflow 不管理需求、任务进度、AI session、提交、推送、PR、

- 一个任务按稳定顺序关联多个本地 Git 仓库
- 使用 Git worktree 隔离任务开发环境
- 创建新 worktree 时复制 source 的完整工作目录(包括未提交修改、untracked 和 ignored 文件)
- dry-run、全量 preflight、任务锁和 source/branch 锁
- 基于实时 Git 事实的幂等创建和中断后重试
- 基于实时 Git 事实和 source-copy 状态的幂等创建与中断后重试
- bundled skill 根据 taskflow.yaml 生成原生 Codex/Claude 命令,将所有仓库关联到工作区
- 基于 ownership manifest 的任务资源 dry-run 和安全清理
- 将 bundled Taskflow skill 安装到 Codex 或 Claude 的全局或项目级目录
Expand Down Expand Up @@ -85,19 +86,21 @@ taskflow --tasks-root ~/tasks delete REFUND-123 --dry-run
taskflow --tasks-root ~/tasks delete REFUND-123 --execute
```

`create` 没有 `--execute` 时默认是 dry-run。dry-run 不创建任务目录、taskflow.yaml、worktree、分支或锁目录;新任务的 execute 会在完整 preflight 后写入初始配置并创建缺失的 worktree。已有任务的 execute 只读取 taskflow.yaml 并创建或复用其中声明的 worktree;只有实际由 Taskflow 创建的 worktree 才会写入 ownership manifest。
`create` 没有 `--execute` 时默认是 dry-run。dry-run 不创建任务目录、taskflow.yaml、ownership、worktree、分支或锁目录,也不枚举或读取将要复制的内容;它列出每个仓库的 worktree action 和 source-copy action。新任务的 execute 会在完整 preflight 后写入初始配置、记录 pending source-copy 状态,再用 `git worktree add --no-checkout` 注册缺失的 worktree、把 index 重建为 base 内容,最后把 source 工作目录完整复制进目标。已有任务的 execute 只读取 taskflow.yaml 并创建或复用其中声明的 worktree;只有实际由 Taskflow 创建的 worktree 才会写入 ownership manifest。

复制覆盖 source 工作目录的全部内容:tracked 文件的未提交修改、untracked 文件和 ignored 文件。`git status` 在新 worktree 中因此通常显示为 dirty,这是预期行为。除 source 根目录及任意嵌套层级的 `.git` 条目外不会排除任何文件;嵌套的 Git 元数据(其他 checkout 的注册文件或内嵌仓库)不会被复制。source 和 target 不允许互相包含。完成后的快照不会随 source 后续变化刷新。

新任务先用带 `--repo` 的 dry-run 预览,用户批准后执行 create;execute 完成后,bundled skill 必须再次运行不带 `--repo` 的 `taskflow create <task-id> --dry-run`,只有所有 repository 都报告 `reuse` 时才生成命令。已有任务也从这次不带 `--repo` 的 dry-run 开始。它使用第一个 worktree 作为 cwd,将后续 worktree 和任务根目录作为绝对路径 `--add-dir` 参数,并按用户目标 shell 进行安全引用和转义:POSIX shell 使用单引号,PowerShell 使用 `Set-Location -LiteralPath` 和 `$env:...`,cmd.exe 使用 `cd /d "..."` 和 `set "...=1"`。复杂 cmd 路径无法可靠转义时改用 PowerShell。命令由用户在自己的终端执行,匹配但 dirty 的 worktree 不会阻止生成。不要加入 `--worktree` 或 `--worktree=...`,避免嵌套 worktree。

## 重试和修改配置

创建是基于实时 Git 事实的 reconciliation,不依赖持久 action state
创建基于实时 Git 事实和 source-copy 状态做 reconciliation

```bash
taskflow --tasks-root ~/tasks create REFUND-123 --execute
```

已存在且 source common directory、branch、target path 都匹配的 worktree 会被复用;缺失的会被创建;不匹配的目标不会被删除或覆盖。若一次创建在中途失败,修复外部原因后重新执行相同命令即可
已存在且 source common directory、branch、target path 都匹配的 worktree 会被复用;缺失的会被创建;不匹配的目标不会被删除或覆盖。若完整复制中断,ownership.json 会保留 pending 的 source-copy 状态;修复外部原因后重试会对该目标重新执行完整复制(pending 目标目录缺失时先重新注册再复制)。完成后的快照是创建时快照,不会随 source 后续变化刷新;pending 期间不要在目标中工作,等待重试完成

已有任务的仓库集合由用户或 AI 直接维护 taskflow.yaml。修改配置后,先运行不带 `--repo` 的 dry-run,再显式执行:

Expand All @@ -108,11 +111,11 @@ taskflow --tasks-root ~/tasks create REFUND-123 --dry-run
taskflow --tasks-root ~/tasks create REFUND-123 --execute
```

taskflow.yaml 中删除仓库不会删除已有 worktree;修改 source、branch、baseworktree 后如果实时 Git 状态不匹配,create 会在 mutation 前返回冲突。已有 taskflow.yaml 时传入 `--repo` 会返回 `CONFIG_EDIT_REQUIRED`,不会执行追加或修改。删除任务要求 ownership manifest 与当前 taskflow.yaml 完全匹配;手工创建或已被修改配置引用的 worktree 不会被自动删除。
taskflow.yaml 中删除仓库不会删除已有 worktree;修改 source、branch、baseworktree 后如果实时 Git/ownership 状态不匹配,create 会在 mutation 前返回冲突。已有 taskflow.yaml 时传入 `--repo` 会返回 `CONFIG_EDIT_REQUIRED`,不会执行追加或修改。删除任务要求 ownership manifest 与当前 taskflow.yaml 完全匹配;手工创建或已被修改配置引用的 worktree 不会被自动删除。

## 删除任务

删除默认只预览,不改变 Git 或文件系统:
删除默认只预览,不改变 Git 或文件系统。复制的快照通常使 worktree dirty(tracked 修改和 untracked 文件都是普通工作区变更;仅含 ignored 文件的快照按 Git 语义视为干净)

```bash
taskflow --tasks-root ~/tasks delete REFUND-123 --dry-run
Expand Down Expand Up @@ -162,6 +165,7 @@ repositories:
```

`source` 使用绝对路径,`base` 必须在本地可解析,`worktree` 必须位于任务的 `worktrees/` 目录内。Taskflow 不隐式 fetch;请在 source 仓库准备好 base 后再重试 create。
创建新 worktree 时 source 的完整工作目录会被复制进目标(含 ignored 文件),因此 source 中的敏感或超大未忽略内容也会进入 worktree;dry-run 和 execute 输出都会显示复制 action 及其条目与字节统计。

首次通过 `--repo` 声明仓库时,Taskflow 默认读取该 source 的 `origin/HEAD`,并将其解析到本地可用的远程默认分支作为 base;同时生成 `feature/<task-id>` 分支,但只使用该远程分支的提交作为起点,不建立 upstream 关联。例如 `origin/HEAD` 指向 `origin/main` 时,配置中的 base 是 `origin/main`,但生成的 worktree 分支不会默认关联 `origin/main`;`origin/master` 等其他远程默认分支同理。`origin/HEAD` 缺失或对应引用不可用时,create 会在写入初始配置或创建 worktree 前失败。已存在配置中的显式 `base` 和 `branch` 保持不变;已有配置的后续修改由用户或 AI 直接编辑 YAML。

Expand All @@ -171,11 +175,11 @@ execute-mode create 会:

1. 获取任务锁;
2. 按 canonical Git common directory 和 branch 获取 source lock;
3. 检查所有 source、base、branch 占用、target 和 worktree identity
4. 对新任务通过 atomic write 写入初始 taskflow.yaml;已有任务不重写用户配置;
5. 只创建缺失的 worktree。
3. 检查所有 source、base、branch 占用、target、worktree identity 和 source/target 复制边界
4. 对新任务通过 atomic write 写入初始 taskflow.yaml 和 pending source-copy 状态;已有任务不重写用户配置;
5. 只创建缺失的 worktree(`--no-checkout` 注册并将 index 重建为 base),然后复制 source 完整工作目录并在成功后标记 complete

任何 preflight 冲突都会在 Git mutation 前返回。Taskflow 的 ownership manifest 只记录由 Taskflow 实际创建的 worktree;结构匹配的手工 worktree 可以被 `create` 复用,但不会被 `delete` 清理。
任何 preflight 冲突都会在 Git 或目标文件 mutation 前返回。Taskflow 的 ownership manifest 只记录由 Taskflow 实际创建的 worktree;结构匹配的手工 worktree 可以被 `create` 复用,但不会被注入 source 快照或被 `delete` 清理。

## 破坏性兼容边界

Expand Down
109 changes: 109 additions & 0 deletions cmd/e2e_binary_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package cmd

import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)

func TestE2EBuiltBinaryReportsSourceCopyAction(t *testing.T) {
repo := e2eGitRepo(t)
localName := "local settings.env"
if runtime.GOOS == "windows" {
localName = "local settings.env"
}
if err := os.WriteFile(filepath.Join(repo, localName), []byte("PORT=4310\n"), 0600); err != nil {
t.Fatal(err)
}
tasks := t.TempDir()
binaryName := "taskflow"
if runtime.GOOS == "windows" {
binaryName += ".exe"
}
binary := filepath.Join(t.TempDir(), binaryName)
moduleRoot, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
moduleRoot = filepath.Dir(moduleRoot)
build := exec.Command("go", "build", "-o", binary, ".")
build.Dir = moduleRoot
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build taskflow: %v: %s", err, output)
}
run := func(args ...string) []byte {
t.Helper()
command := exec.Command(binary, args...)
output, err := command.CombinedOutput()
if err != nil {
t.Fatalf("taskflow %v: %v: %s", args, err, output)
}
return output
}
preview := run("--tasks-root", tasks, "--json", "create", "BINARY", "--repo", "app="+repo, "--dry-run")
var envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(preview, &envelope); err != nil || !envelope.OK {
t.Fatalf("binary preview: err=%v output=%s", err, preview)
}
var data struct {
Actions []struct {
Kind string `json:"kind"`
Status string `json:"status"`
Source string `json:"source"`
Target string `json:"target"`
FileCount int `json:"fileCount"`
} `json:"actions"`
}
if err := json.Unmarshal(envelope.Data, &data); err != nil {
t.Fatal(err)
}
if len(data.Actions) != 2 || data.Actions[0].Kind != "worktree" || data.Actions[0].Status != "create" || data.Actions[1].Kind != "source-copy" || data.Actions[1].Status != "copy" {
t.Fatalf("binary source-copy preview: %#v", data.Actions)
}
if data.Actions[1].Source == "" || filepath.Base(data.Actions[1].Source) != "repo" || !strings.HasSuffix(data.Actions[1].Target, filepath.Join("BINARY", "worktrees", "app")) {
t.Fatalf("source-copy action paths: %#v", data.Actions[1])
}
textPreview := run("--tasks-root", tasks, "create", "BINARY", "--repo", "app="+repo, "--dry-run")
encodedRepo, err := json.Marshal(repo)
if err != nil {
t.Fatal(err)
}
encodedRepoText := string(encodedRepo[1 : len(encodedRepo)-1])
if !strings.Contains(string(textPreview), "COPY source") || !strings.Contains(string(textPreview), encodedRepoText) {
t.Fatalf("binary text source-copy preview: %s", textPreview)
}
run("--tasks-root", tasks, "--json", "create", "BINARY", "--repo", "app="+repo, "--execute")
copied, err := os.ReadFile(filepath.Join(tasks, "BINARY", "worktrees", "app", localName))
if err != nil || string(copied) != "PORT=4310\n" {
t.Fatalf("binary source copy is incomplete: %q err=%v", copied, err)
}
if _, err := os.Stat(filepath.Join(tasks, "BINARY", "worktrees", "app", ".git")); err != nil {
t.Fatalf("binary worktree lost its git metadata: %v", err)
}
repeat := run("--tasks-root", tasks, "--json", "create", "BINARY", "--dry-run")
if !strings.Contains(string(repeat), `"status": "reuse"`) || !strings.Contains(string(repeat), `"kind": "source-copy"`) {
t.Fatalf("binary repeat did not report source-copy reuse: %s", repeat)
}
executeItems := run("--tasks-root", tasks, "--json", "create", "BINARY", "--execute")
var executeEnvelope struct {
Data struct {
Actions []struct {
Kind string `json:"kind"`
Status string `json:"status"`
} `json:"actions"`
} `json:"data"`
}
if err := json.Unmarshal(executeItems, &executeEnvelope); err != nil {
t.Fatal(err)
}
if len(executeEnvelope.Data.Actions) != 2 || executeEnvelope.Data.Actions[0].Status != "reuse" || executeEnvelope.Data.Actions[1].Status != "reuse" {
t.Fatalf("binary repeat execute actions: %#v", executeEnvelope.Data.Actions)
}
}
99 changes: 99 additions & 0 deletions cmd/e2e_binary_unix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
//go:build unix

package cmd

import (
"bytes"
"encoding/json"
"os"
"os/exec"
"path/filepath"
"syscall"
"testing"
)

func TestE2EBuiltBinaryRetriesFailedSourceCopy(t *testing.T) {
repo := e2eGitRepo(t)
if err := os.WriteFile(filepath.Join(repo, "a-first.env"), []byte("first"), 0600); err != nil {
t.Fatal(err)
}
if err := syscall.Mkfifo(filepath.Join(repo, "m-pipe"), 0644); err != nil {
t.Skipf("mkfifo unavailable: %v", err)
}
if err := os.WriteFile(filepath.Join(repo, "z-last.env"), []byte("last"), 0600); err != nil {
t.Fatal(err)
}
tasks := t.TempDir()
binary := filepath.Join(t.TempDir(), "taskflow")
moduleRoot, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
moduleRoot = filepath.Dir(moduleRoot)
build := exec.Command("go", "build", "-o", binary, ".")
build.Dir = moduleRoot
if output, err := build.CombinedOutput(); err != nil {
t.Fatalf("build taskflow: %v: %s", err, output)
}

failing := exec.Command(binary, "--tasks-root", tasks, "--json", "create", "BINARY-RETRY", "--repo", "app="+repo, "--execute")
output, err := failing.CombinedOutput()
if err == nil {
t.Fatalf("expected copy failure, output=%s", output)
}
if exitErr, ok := err.(*exec.ExitError); !ok || exitErr.ExitCode() != 4 {
t.Fatalf("expected partial exit code 4, got %v output=%s", err, output)
}
var envelope struct {
OK bool `json:"ok"`
Errors []struct {
Code string `json:"code"`
Repo string `json:"repo"`
} `json:"errors"`
Data struct {
Actions []struct {
Kind string `json:"kind"`
Status string `json:"status"`
} `json:"actions"`
} `json:"data"`
}
// The binary prints the JSON envelope first and cobra's error line after;
// decode only the leading JSON value.
decoder := json.NewDecoder(bytes.NewReader(output))
if err := decoder.Decode(&envelope); err != nil {
t.Fatalf("unparseable failure output: %v: %s", err, output)
}
if envelope.OK || len(envelope.Errors) != 1 || envelope.Errors[0].Code != "SOURCE_COPY_UNSUPPORTED_ENTRY" || envelope.Errors[0].Repo != "app" {
t.Fatalf("failure envelope: %#v", envelope)
}
if len(envelope.Data.Actions) != 2 || envelope.Data.Actions[0].Status != "created" || envelope.Data.Actions[1].Status != "failed" {
t.Fatalf("failure actions: %#v", envelope.Data.Actions)
}
target := filepath.Join(tasks, "BINARY-RETRY", "worktrees", "app")
if _, statErr := os.Stat(filepath.Join(target, "a-first.env")); statErr != nil {
t.Fatalf("entries before the failure were not copied: %v", statErr)
}
if _, statErr := os.Stat(filepath.Join(target, "z-last.env")); !os.IsNotExist(statErr) {
t.Fatalf("entries after the failure were copied: %v", statErr)
}

if err := os.Remove(filepath.Join(repo, "m-pipe")); err != nil {
t.Fatal(err)
}
retryOutput, err := exec.Command(binary, "--tasks-root", tasks, "--json", "create", "BINARY-RETRY", "--execute").CombinedOutput()
if err != nil {
t.Fatalf("retry: %v: %s", err, retryOutput)
}
if err := json.Unmarshal(retryOutput, &envelope); err != nil {
t.Fatalf("unparseable retry output: %v: %s", err, retryOutput)
}
if !envelope.OK || len(envelope.Errors) != 0 {
t.Fatalf("retry envelope: %#v", envelope)
}
if len(envelope.Data.Actions) != 2 || envelope.Data.Actions[0].Status != "reuse" || envelope.Data.Actions[1].Status != "repaired" {
t.Fatalf("retry actions: %#v", envelope.Data.Actions)
}
if _, statErr := os.Stat(filepath.Join(target, "z-last.env")); statErr != nil {
t.Fatalf("retry did not complete the source copy: %v", statErr)
}
}
27 changes: 26 additions & 1 deletion cmd/e2e_safety_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,13 +82,14 @@ func TestE2ECreateJSONAndReuseDirtyWorktree(t *testing.T) {
var reuseData struct {
Actions []struct {
Repo string `json:"repo"`
Kind string `json:"kind"`
Status string `json:"status"`
} `json:"actions"`
}
if err := json.Unmarshal(reuse.Data, &reuseData); err != nil {
t.Fatalf("reuse data: %v", err)
}
if len(reuseData.Actions) != 2 || reuseData.Actions[0].Repo != "one" || reuseData.Actions[0].Status != "reuse" || reuseData.Actions[1].Repo != "two" || reuseData.Actions[1].Status != "reuse" {
if len(reuseData.Actions) != 4 || reuseData.Actions[0].Repo != "one" || reuseData.Actions[0].Kind != "worktree" || reuseData.Actions[0].Status != "reuse" || reuseData.Actions[1].Kind != "source-copy" || reuseData.Actions[1].Status != "reuse" || reuseData.Actions[2].Repo != "two" || reuseData.Actions[2].Kind != "worktree" || reuseData.Actions[2].Status != "reuse" || reuseData.Actions[3].Kind != "source-copy" || reuseData.Actions[3].Status != "reuse" {
t.Fatalf("reuse actions: %#v", reuseData.Actions)
}
if _, err := runE2E(t, tasks, "--json", "create", "FLOW", "--execute"); err != nil {
Expand Down Expand Up @@ -268,6 +269,30 @@ func TestE2EDeleteRefusesUnownedAndDirtyWorktrees(t *testing.T) {
}
}

func TestE2EDeleteTreatsCopiedSnapshotAsDirty(t *testing.T) {
repo := e2eGitRepo(t)
if err := os.WriteFile(filepath.Join(repo, "local.env"), []byte("local"), 0600); err != nil {
t.Fatal(err)
}
tasks := t.TempDir()
if output, err := runE2E(t, tasks, "create", "SNAPSHOT-DELETE", "--repo", "repo="+repo, "--execute"); err != nil {
t.Fatalf("snapshot create: %v: %s", err, output)
}
target := filepath.Join(tasks, "SNAPSHOT-DELETE", "worktrees", "repo")
if _, err := os.Stat(filepath.Join(target, "local.env")); err != nil {
t.Fatalf("copied snapshot is incomplete: %v", err)
}
if output, err := runE2E(t, tasks, "--json", "delete", "SNAPSHOT-DELETE", "--execute"); err == nil || !strings.Contains(output, "WORKTREE_DIRTY") {
t.Fatalf("expected copied-snapshot dirty refusal: %v: %s", err, output)
}
if _, err := os.Stat(filepath.Join(target, "local.env")); err != nil {
t.Fatalf("snapshot file was removed by refused delete: %v", err)
}
if output, err := runE2E(t, tasks, "delete", "SNAPSHOT-DELETE", "--execute", "--force"); err != nil {
t.Fatalf("forced delete: %v: %s", err, output)
}
}

func TestE2EDeleteRefusesUnmanagedTaskFiles(t *testing.T) {
repo := e2eGitRepo(t)
tasks := t.TempDir()
Expand Down
Loading
Loading