diff --git a/README.md b/README.md index 90d9975..1862b6a 100644 --- a/README.md +++ b/README.md @@ -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 的全局或项目级目录 @@ -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 --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,再显式执行: @@ -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、base 或 worktree 后如果实时 Git 状态不匹配,create 会在 mutation 前返回冲突。已有 taskflow.yaml 时传入 `--repo` 会返回 `CONFIG_EDIT_REQUIRED`,不会执行追加或修改。删除任务要求 ownership manifest 与当前 taskflow.yaml 完全匹配;手工创建或已被修改配置引用的 worktree 不会被自动删除。 +taskflow.yaml 中删除仓库不会删除已有 worktree;修改 source、branch、base、worktree 后如果实时 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 @@ -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/` 分支,但只使用该远程分支的提交作为起点,不建立 upstream 关联。例如 `origin/HEAD` 指向 `origin/main` 时,配置中的 base 是 `origin/main`,但生成的 worktree 分支不会默认关联 `origin/main`;`origin/master` 等其他远程默认分支同理。`origin/HEAD` 缺失或对应引用不可用时,create 会在写入初始配置或创建 worktree 前失败。已存在配置中的显式 `base` 和 `branch` 保持不变;已有配置的后续修改由用户或 AI 直接编辑 YAML。 @@ -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` 清理。 ## 破坏性兼容边界 diff --git a/cmd/e2e_binary_test.go b/cmd/e2e_binary_test.go new file mode 100644 index 0000000..4db9343 --- /dev/null +++ b/cmd/e2e_binary_test.go @@ -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) + } +} diff --git a/cmd/e2e_binary_unix_test.go b/cmd/e2e_binary_unix_test.go new file mode 100644 index 0000000..ab1233e --- /dev/null +++ b/cmd/e2e_binary_unix_test.go @@ -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) + } +} diff --git a/cmd/e2e_safety_test.go b/cmd/e2e_safety_test.go index d4046e7..3abe82d 100644 --- a/cmd/e2e_safety_test.go +++ b/cmd/e2e_safety_test.go @@ -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 { @@ -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() diff --git a/internal/app/app.go b/internal/app/app.go index 661f939..ae69935 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -51,6 +51,24 @@ type createResolution struct { trackBase bool } +type repositoryCreatePlan struct { + repository domain.Repository + sourceInfo git.Info + target string + worktreeAction int + copyAction int + worktreeStatus string // "create" or "reuse" + copyStatus string // "copy", "repair", or "reuse" + owned *ownership.Worktree +} + +type createPlan struct { + task domain.Task + actions []plan.Item + repositories []repositoryCreatePlan + manifest ownership.Manifest +} + type deleteAction struct { ID string `json:"id"` Repo string `json:"repo,omitempty"` @@ -102,12 +120,12 @@ func (s Service) Create(ctx context.Context, o CreateOptions) (report.Result, re res.Fail(*diagnostic) return res, code } - items, diagnostic, code := s.preflightCreate(ctx, resolved.task) + prepared, diagnostic, code := s.preflightCreate(ctx, resolved.task) if diagnostic != nil { res.Fail(*diagnostic) return res, code } - res.Data = createData(resolved.task, items, !o.Execute) + res.Data = createData(resolved.task, prepared.actions, !o.Execute) if !o.Execute { return res, report.ExitOK } @@ -136,39 +154,35 @@ func (s Service) Create(ctx context.Context, o CreateOptions) (report.Result, re return res, code } defer releaseSourceLocks(sourceLocks) - items, diagnostic, code = s.preflightCreate(ctx, resolved.task) + prepared, diagnostic, code = s.preflightCreate(ctx, resolved.task) if diagnostic != nil { res.Fail(*diagnostic) return res, code } - manifest, manifestExists, err := ownership.Load(resolved.task.Task.Root) - if err != nil { - res.Fail(report.Diagnostic{Code: "INVALID_OWNERSHIP", Message: err.Error(), Hint: ownership.Path(resolved.task.Task.Root)}) - return res, report.ExitConfig - } - if !manifestExists { - manifest = ownership.New(resolved.task.Task.ID) - } else if manifest.TaskID != resolved.task.Task.ID { - res.Fail(report.Diagnostic{Code: "INVALID_OWNERSHIP", Message: fmt.Sprintf("ownership taskID %q does not match %q", manifest.TaskID, resolved.task.Task.ID), Hint: ownership.Path(resolved.task.Task.Root)}) - return res, report.ExitConfig - } + items := prepared.actions + manifest := prepared.manifest ownershipChanged := false for index, repository := range resolved.task.Repositories { - if items[index].Status != "create" { + repositoryPlan := &prepared.repositories[index] + if repositoryPlan.worktreeStatus != "create" { continue } - sourceInfo, inspectErr := s.Git.Inspect(ctx, repository.Source) - if inspectErr != nil || sourceInfo.CommonDir == "" { - res.Fail(report.Diagnostic{Code: "NOT_GIT_REPOSITORY", Repo: repository.Name, Message: gitErrorMessage("inspect configured source", inspectErr)}) - return res, report.ExitEnvironment - } - manifest.Add(ownership.Worktree{ + entry := ownership.Worktree{ Repository: repository.Name, Source: repository.Source, - CommonDir: sourceInfo.CommonDir, + CommonDir: repositoryPlan.sourceInfo.CommonDir, Branch: repository.Branch, - Target: filepath.Join(resolved.task.Task.Root, repository.Worktree), - }) + Target: repositoryPlan.target, + } + // A missing target must receive a fresh snapshot even when an older + // attempt recorded a complete copy: the copied workspace is gone. + sourceCopy := ownership.SourceCopy{Source: repository.Source, Target: repositoryPlan.target, Status: "pending"} + if repositoryPlan.owned != nil && repositoryPlan.owned.SourceCopy != nil { + sourceCopy = *repositoryPlan.owned.SourceCopy + } + sourceCopy.Status = "pending" + entry.SourceCopy = &sourceCopy + manifest.Add(entry) ownershipChanged = true } if resolved.configurationChanged { @@ -188,23 +202,63 @@ func (s Service) Create(ctx context.Context, o CreateOptions) (report.Result, re return res, report.ExitExecution } for index, repository := range resolved.task.Repositories { - if items[index].Status == "reuse" { + repositoryPlan := &prepared.repositories[index] + if repositoryPlan.worktreeStatus == "create" { + if err = os.MkdirAll(filepath.Dir(repositoryPlan.target), 0755); err != nil { + items[repositoryPlan.worktreeAction].Status = "failed" + items[repositoryPlan.copyAction].Status = "blocked" + res.Data = createData(resolved.task, items, false) + res.Fail(report.Diagnostic{Code: "CREATE_WORKTREE_FAILED", Repo: repository.Name, Message: err.Error()}) + return res, report.ExitPartial + } + if err = s.Git.AddWorktree(ctx, repository.Source, repository.Branch, repositoryPlan.target, repository.Base, resolved.trackBase, true); err != nil { + items[repositoryPlan.worktreeAction].Status = "failed" + items[repositoryPlan.copyAction].Status = "blocked" + res.Data = createData(resolved.task, items, false) + res.Fail(report.Diagnostic{Code: "CREATE_WORKTREE_FAILED", Repo: repository.Name, Message: err.Error()}) + return res, report.ExitPartial + } + items[repositoryPlan.worktreeAction].Status = "created" + } + if repositoryPlan.copyStatus == "reuse" { continue } - target := filepath.Join(resolved.task.Task.Root, repository.Worktree) - if err = os.MkdirAll(filepath.Dir(target), 0755); err != nil { - items[index].Status = "failed" + // `--no-checkout` registration leaves the index empty; populate it + // from HEAD before copying so tracked source modifications surface as + // normal unstaged changes. The mixed reset is idempotent, so repairs + // of interrupted registrations stay correct. + if err = s.Git.ResetIndex(ctx, repositoryPlan.target); err != nil { + items[repositoryPlan.copyAction].Status = "failed" + res.Data = createData(resolved.task, items, false) + res.Fail(report.Diagnostic{Code: "SOURCE_INDEX_RESET_FAILED", Repo: repository.Name, Message: err.Error()}) + return res, report.ExitPartial + } + stats, err := fsx.CopyTree(repository.Source, repositoryPlan.target) + if err != nil { + items[repositoryPlan.copyAction].Status = "failed" res.Data = createData(resolved.task, items, false) - res.Fail(report.Diagnostic{Code: "CREATE_WORKTREE_FAILED", Repo: repository.Name, Message: err.Error()}) + res.Fail(copyDiagnostic(repository.Name, err)) return res, report.ExitPartial } - if err = s.Git.AddWorktree(ctx, repository.Source, repository.Branch, target, repository.Base, resolved.trackBase); err != nil { - items[index].Status = "failed" + if repositoryPlan.worktreeStatus == "create" { + items[repositoryPlan.copyAction].Status = "copied" + } else { + items[repositoryPlan.copyAction].Status = "repaired" + } + items[repositoryPlan.copyAction].FileCount = int(stats.Entries) + items[repositoryPlan.copyAction].TotalBytes = stats.Bytes + owned := manifestEntry(&manifest, repository.Name, repositoryPlan.target) + if owned == nil || owned.SourceCopy == nil { + res.Data = createData(resolved.task, items, false) + res.Fail(report.Diagnostic{Code: "WRITE_OWNERSHIP_FAILED", Repo: repository.Name, Message: "source-copy ownership entry is missing", Hint: ownership.Path(resolved.task.Task.Root)}) + return res, report.ExitPartial + } + owned.SourceCopy.Status = "complete" + if err = ownership.Save(resolved.task.Task.Root, manifest); err != nil { res.Data = createData(resolved.task, items, false) - res.Fail(report.Diagnostic{Code: "CREATE_WORKTREE_FAILED", Repo: repository.Name, Message: err.Error()}) + res.Fail(report.Diagnostic{Code: "WRITE_OWNERSHIP_FAILED", Repo: repository.Name, Message: err.Error(), Hint: ownership.Path(resolved.task.Task.Root)}) return res, report.ExitPartial } - items[index].Status = "created" } res.Data = createData(resolved.task, items, false) return res, report.ExitOK @@ -618,19 +672,19 @@ func (s Service) resolveCreate(ctx context.Context, tasksRoot string, o CreateOp } task = domain.Task{Version: domain.ConfigVersion, Task: domain.TaskInfo{ID: o.TaskID, Root: taskRoot}} } - if len(o.Repositories) == 0 { - if !configurationExists { - return createResolution{}, &report.Diagnostic{Code: "INVALID_ARGUMENT", Message: "at least one --repo is required for a new task"}, report.ExitConfig - } - return createResolution{task: task, trackBase: true}, nil, report.ExitOK - } - if configurationExists { + if configurationExists && len(o.Repositories) > 0 { return createResolution{}, &report.Diagnostic{ Code: "CONFIG_EDIT_REQUIRED", Message: "taskflow.yaml already exists; edit it directly and rerun create without --repo", Hint: configPath, }, report.ExitConfig } + if len(o.Repositories) == 0 { + if !configurationExists { + return createResolution{}, &report.Diagnostic{Code: "INVALID_ARGUMENT", Message: "at least one --repo is required for a new task"}, report.ExitConfig + } + return createResolution{task: task, trackBase: true}, nil, report.ExitOK + } for _, raw := range o.Repositories { repository, err := resolveRepository(o.TaskID, raw) @@ -725,12 +779,35 @@ func rejectLegacyRuntime(taskRoot string) error { return nil } -func (s Service) preflightCreate(ctx context.Context, task domain.Task) ([]plan.Item, *report.Diagnostic, report.ExitCode) { +func (s Service) preflightCreate(ctx context.Context, task domain.Task) (*createPlan, *report.Diagnostic, report.ExitCode) { items, err := plan.Build(task) if err != nil { return nil, &report.Diagnostic{Code: "INVALID_CONFIGURATION", Message: err.Error()}, report.ExitConfig } + manifest, exists, err := ownership.Load(task.Task.Root) + if err != nil { + return nil, &report.Diagnostic{Code: "INVALID_OWNERSHIP", Message: err.Error(), Hint: ownership.Path(task.Task.Root)}, report.ExitConfig + } + if !exists { + manifest = ownership.New(task.Task.ID) + } else if manifest.TaskID != task.Task.ID { + return nil, &report.Diagnostic{Code: "INVALID_OWNERSHIP", Message: fmt.Sprintf("ownership taskID %q does not match %q", manifest.TaskID, task.Task.ID), Hint: ownership.Path(task.Task.Root)}, report.ExitConfig + } + ownedByRepo := make(map[string]ownership.Worktree, len(manifest.Worktrees)) + for _, owned := range manifest.Worktrees { + if _, duplicate := ownedByRepo[owned.Repository]; duplicate { + return nil, &report.Diagnostic{Code: "OWNERSHIP_MISMATCH", Repo: owned.Repository, Message: "ownership manifest contains duplicate repository entries"}, report.ExitConflict + } + ownedByRepo[owned.Repository] = owned + } + prepared := &createPlan{task: task, actions: items, manifest: manifest, repositories: make([]repositoryCreatePlan, 0, len(task.Repositories))} for index, repository := range task.Repositories { + worktreeAction := index * 2 + copyAction := worktreeAction + 1 + target := filepath.Join(task.Task.Root, repository.Worktree) + items[worktreeAction].Target = target + items[copyAction].Target = target + items[copyAction].Source = repository.Source sourceInfo, err := s.Git.Inspect(ctx, repository.Source) if err != nil || sourceInfo.CommonDir == "" { return nil, &report.Diagnostic{Code: "NOT_GIT_REPOSITORY", Repo: repository.Name, Message: gitErrorMessage("inspect configured source", err)}, report.ExitEnvironment @@ -738,11 +815,13 @@ func (s Service) preflightCreate(ctx context.Context, task domain.Task) ([]plan. if !s.Git.HasRef(ctx, repository.Source, repository.Base) { return nil, &report.Diagnostic{Code: "BASE_REF_NOT_FOUND", Repo: repository.Name, Message: "base ref " + repository.Base + " does not exist locally"}, report.ExitEnvironment } + if fsx.Within(repository.Source, target) || fsx.Within(target, repository.Source) { + return nil, &report.Diagnostic{Code: "SOURCE_COPY_BOUNDARY", Repo: repository.Name, Message: fmt.Sprintf("source %s and target %s must not contain one another", repository.Source, target)}, report.ExitConflict + } worktrees, err := s.Git.Worktrees(ctx, repository.Source) if err != nil { return nil, &report.Diagnostic{Code: "WORKTREE_INSPECTION_FAILED", Repo: repository.Name, Message: err.Error()}, report.ExitEnvironment } - target := filepath.Join(task.Task.Root, repository.Worktree) matched := false for _, worktree := range worktrees { if worktree.Branch == repository.Branch && !samePath(worktree.Path, target) { @@ -761,19 +840,95 @@ func (s Service) preflightCreate(ctx context.Context, task domain.Task) ([]plan. matched = true } if !matched { - if _, err = os.Stat(target); err == nil { + if _, err = os.Lstat(target); err == nil { return nil, &report.Diagnostic{Code: "WORKTREE_MISMATCH", Repo: repository.Name, Message: fmt.Sprintf("target %s exists but is not the configured worktree", target)}, report.ExitConflict } else if !os.IsNotExist(err) { return nil, &report.Diagnostic{Code: "WORKTREE_INSPECTION_FAILED", Repo: repository.Name, Message: err.Error()}, report.ExitEnvironment } - items[index].Status = "create" - items[index].Description = fmt.Sprintf("CREATE %s -> %s", repository.Name, repository.Worktree) + items[worktreeAction].Status = "create" + items[worktreeAction].Description = fmt.Sprintf("CREATE %s -> %s", repository.Name, target) } else { - items[index].Status = "reuse" - items[index].Description = fmt.Sprintf("REUSE %s -> %s", repository.Name, repository.Worktree) + items[worktreeAction].Status = "reuse" + items[worktreeAction].Description = fmt.Sprintf("REUSE %s -> %s", repository.Name, target) + } + + repositoryPlan := repositoryCreatePlan{ + repository: repository, + sourceInfo: sourceInfo, + target: target, + worktreeAction: worktreeAction, + copyAction: copyAction, + worktreeStatus: items[worktreeAction].Status, + } + owned, hasOwned := ownedByRepo[repository.Name] + if hasOwned { + if !samePath(owned.Source, repository.Source) || owned.Branch != repository.Branch || !samePath(owned.Target, target) || !samePath(owned.CommonDir, sourceInfo.CommonDir) { + return nil, &report.Diagnostic{Code: "OWNERSHIP_MISMATCH", Repo: repository.Name, Message: "ownership entry does not match the live source, branch, common directory, or target"}, report.ExitConflict + } + copied := owned + repositoryPlan.owned = &copied + } + switch { + case !matched: + repositoryPlan.copyStatus = "copy" + case !hasOwned || owned.SourceCopy == nil: + // A matching worktree without a pending Taskflow copy record is + // never populated implicitly. + repositoryPlan.copyStatus = "reuse" + if hasOwned { + items[copyAction].Reason = "matching worktree has no source-copy record" + } else { + items[copyAction].Reason = "matching worktree is not Taskflow-owned" + } + case owned.SourceCopy.Status == "pending": + repositoryPlan.copyStatus = "repair" + default: + repositoryPlan.copyStatus = "reuse" + } + setCopyItem(&items[copyAction], repositoryPlan.copyStatus) + prepared.repositories = append(prepared.repositories, repositoryPlan) + } + return prepared, nil, report.ExitOK +} + +func setCopyItem(item *plan.Item, status string) { + item.Status = status + switch status { + case "copy": + item.Description = fmt.Sprintf("COPY source %s -> %s", item.Source, item.Target) + case "repair": + item.Description = fmt.Sprintf("REPAIR source copy %s -> %s", item.Source, item.Target) + case "reuse": + item.Description = fmt.Sprintf("REUSE source copy %s -> %s", item.Source, item.Target) + default: + item.Description = fmt.Sprintf("SKIP source copy %s", item.Target) + } +} + +func manifestEntry(manifest *ownership.Manifest, repository, target string) *ownership.Worktree { + for index := range manifest.Worktrees { + entry := &manifest.Worktrees[index] + if entry.Repository == repository && samePath(entry.Target, target) { + return entry + } + } + return nil +} + +func copyDiagnostic(repo string, err error) report.Diagnostic { + code := "SOURCE_COPY_FAILED" + var copyErr *fsx.CopyError + if errors.As(err, ©Err) { + switch copyErr.Op { + case "unsupported-entry": + code = "SOURCE_COPY_UNSUPPORTED_ENTRY" + case "symlink", "readlink": + code = "SOURCE_COPY_SYMLINK_FAILED" + case "boundary": + code = "SOURCE_COPY_BOUNDARY" } } - return items, nil, report.ExitOK + return report.Diagnostic{Code: code, Repo: repo, Message: err.Error()} } type sourceLockCandidate struct { diff --git a/internal/app/app_test.go b/internal/app/app_test.go index 38e8fc6..dbbf9c9 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -117,7 +117,7 @@ func TestCreateIsIdempotentAndDoesNotPersistState(t *testing.T) { t.Fatalf("repeat create: code=%d result=%#v", code, second) } items := second.Data.(map[string]any)["actions"].([]plan.Item) - if len(items) != 1 || items[0].Status != "reuse" { + if len(items) != 2 || items[0].Kind != "worktree" || items[0].Status != "reuse" || items[1].Kind != "source-copy" || items[1].Status != "reuse" { t.Fatalf("repeat actions: %#v", items) } } @@ -277,6 +277,317 @@ func TestCreateRejectsMismatchedTargetBeforeMutation(t *testing.T) { } } +func TestCreateCopiesCompleteSourceWorkingTree(t *testing.T) { + repo := makeGitRepo(t) + if err := os.WriteFile(filepath.Join(repo, ".gitignore"), []byte("ignored.log\n"), 0644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"-C", repo, "add", ".gitignore"}, {"-C", repo, "commit", "-m", "ignore"}} { + if output, err := exec.Command("git", args...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, output) + } + } + if err := os.WriteFile(filepath.Join(repo, "tracked.txt"), []byte("committed\n"), 0644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"-C", repo, "add", "tracked.txt"}, {"-C", repo, "commit", "-m", "tracked"}, {"-C", repo, "update-ref", "refs/remotes/origin/main", "HEAD"}} { + if output, err := exec.Command("git", args...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, output) + } + } + if err := os.WriteFile(filepath.Join(repo, "tracked.txt"), []byte("committed\nmodified\n"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "untracked.txt"), []byte("untracked"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "ignored.log"), []byte("ignored"), 0600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(repo, "config", "dev"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "config", "dev", "settings local.env"), []byte("debug=true"), 0640); err != nil { + t.Fatal(err) + } + + tasks := t.TempDir() + service := New() + preview, code := service.Create(context.Background(), CreateOptions{TasksRoot: tasks, TaskID: "SNAPSHOT", Repositories: []string{"app=" + repo}, DryRun: true}) + if code != report.ExitOK || !preview.OK { + t.Fatalf("preview: code=%d result=%#v", code, preview) + } + previewItems := preview.Data.(map[string]any)["actions"].([]plan.Item) + if len(previewItems) != 2 || previewItems[0].Status != "create" || previewItems[1].Kind != "source-copy" || previewItems[1].Status != "copy" { + t.Fatalf("preview actions: %#v", previewItems) + } + if _, err := os.Stat(filepath.Join(tasks, "SNAPSHOT")); !os.IsNotExist(err) { + t.Fatalf("dry-run changed task root: %v", err) + } + + result, code := service.Create(context.Background(), CreateOptions{TasksRoot: tasks, TaskID: "SNAPSHOT", Repositories: []string{"app=" + repo}, Execute: true}) + if code != report.ExitOK || !result.OK { + t.Fatalf("execute: code=%d result=%#v", code, result) + } + executeItems := result.Data.(map[string]any)["actions"].([]plan.Item) + if len(executeItems) != 2 || executeItems[0].Status != "created" || executeItems[1].Status != "copied" { + t.Fatalf("execute actions: %#v", executeItems) + } + task, err := service.Load(tasks, "SNAPSHOT") + if err != nil { + t.Fatal(err) + } + target := filepath.Join(task.Task.Root, task.Repositories[0].Worktree) + for _, file := range []string{"tracked.txt", "untracked.txt", "ignored.log", filepath.Join("config", "dev", "settings local.env")} { + if _, err := os.Stat(filepath.Join(target, file)); err != nil { + t.Fatalf("copied file %s missing: %v", file, err) + } + } + if contents, err := os.ReadFile(filepath.Join(target, "tracked.txt")); err != nil || string(contents) != "committed\nmodified\n" { + t.Fatalf("tracked modification not copied: %q err=%v", contents, err) + } + if _, err := os.Stat(filepath.Join(target, ".git")); err != nil { + t.Fatalf("target lost its own git metadata: %v", err) + } + manifest, exists, err := ownership.Load(task.Task.Root) + if err != nil || !exists || len(manifest.Worktrees) != 1 || manifest.Worktrees[0].SourceCopy == nil || manifest.Worktrees[0].SourceCopy.Status != "complete" { + t.Fatalf("source-copy ownership: manifest=%#v exists=%v err=%v", manifest, exists, err) + } + + status, err := exec.Command("git", "-C", target, "status", "--porcelain").CombinedOutput() + if err != nil { + t.Fatalf("target status: %v: %s", err, status) + } + statusText := string(status) + if !strings.Contains(statusText, " M tracked.txt") || !strings.Contains(statusText, "?? untracked.txt") { + t.Fatalf("copied changes are not normal working-tree changes: %q", statusText) + } + if strings.Contains(statusText, "D ") || strings.Contains(statusText, "D\t") { + t.Fatalf("empty index leaked staged deletions: %q", statusText) + } + + // A completed source copy is a creation-time snapshot: later source + // changes must not be refreshed into a reused worktree. + if err := os.WriteFile(filepath.Join(repo, "tracked.txt"), []byte("changed later\n"), 0644); err != nil { + t.Fatal(err) + } + second, code := service.Create(context.Background(), CreateOptions{TasksRoot: tasks, TaskID: "SNAPSHOT", Execute: true}) + if code != report.ExitOK || !second.OK { + t.Fatalf("repeat create: code=%d result=%#v", code, second) + } + secondItems := second.Data.(map[string]any)["actions"].([]plan.Item) + if len(secondItems) != 2 || secondItems[0].Status != "reuse" || secondItems[1].Status != "reuse" { + t.Fatalf("repeat actions: %#v", secondItems) + } + if contents, err := os.ReadFile(filepath.Join(target, "tracked.txt")); err != nil || string(contents) != "committed\nmodified\n" { + t.Fatalf("completed copy was refreshed: %q err=%v", contents, err) + } +} + +func TestCreateKeepsCleanStatusWhenSourceMatchesBase(t *testing.T) { + repo := makeGitRepo(t) + tasks := t.TempDir() + if result, code := New().Create(context.Background(), CreateOptions{TasksRoot: tasks, TaskID: "CLEAN", Repositories: []string{"app=" + repo}, Execute: true}); code != report.ExitOK || !result.OK { + t.Fatalf("create: code=%d result=%#v", code, result) + } + target := filepath.Join(tasks, "CLEAN", "worktrees", "app") + status, err := exec.Command("git", "-C", target, "status", "--porcelain").CombinedOutput() + if err != nil { + t.Fatalf("target status: %v: %s", err, status) + } + if len(strings.TrimSpace(string(status))) != 0 { + t.Fatalf("target of a clean source is dirty: %q", status) + } +} + +func TestCreateExcludesNestedGitMetadata(t *testing.T) { + repo := makeGitRepo(t) + if err := os.WriteFile(filepath.Join(repo, "tracked.txt"), []byte("base"), 0644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{{"-C", repo, "add", "tracked.txt"}, {"-C", repo, "commit", "-m", "tracked"}} { + if output, err := exec.Command("git", args...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, output) + } + } + if output, err := exec.Command("git", "-C", repo, "worktree", "add", "./wt-nested", "-b", "nested").CombinedOutput(); err != nil { + t.Fatalf("nested worktree: %v: %s", err, output) + } + if err := os.MkdirAll(filepath.Join(repo, "sub", ".git"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "sub", ".git", "HEAD"), []byte("embedded"), 0644); err != nil { + t.Fatal(err) + } + tasks := t.TempDir() + if result, code := New().Create(context.Background(), CreateOptions{TasksRoot: tasks, TaskID: "NESTED", Repositories: []string{"app=" + repo}, Execute: true}); code != report.ExitOK || !result.OK { + t.Fatalf("create: code=%d result=%#v", code, result) + } + target := filepath.Join(tasks, "NESTED", "worktrees", "app") + if _, err := os.Stat(filepath.Join(target, "wt-nested", "tracked.txt")); err != nil { + t.Fatalf("nested working files were not copied: %v", err) + } + if _, err := os.Stat(filepath.Join(target, "wt-nested", ".git")); !os.IsNotExist(err) { + t.Fatalf("nested worktree registration was copied: %v", err) + } + if _, err := os.Stat(filepath.Join(target, "sub", ".git")); !os.IsNotExist(err) { + t.Fatalf("embedded repository metadata was copied: %v", err) + } +} + +func TestCreateDoesNotCopyIntoMatchingManualWorktree(t *testing.T) { + repo := makeGitRepo(t) + if err := os.WriteFile(filepath.Join(repo, "local.env"), []byte("local"), 0600); err != nil { + t.Fatal(err) + } + tasks := t.TempDir() + taskRoot := filepath.Join(tasks, "MANUAL") + target := filepath.Join(taskRoot, "worktrees", "app") + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + t.Fatal(err) + } + client := git.Client{Runner: execx.OSRunner{}} + if err := client.AddWorktree(context.Background(), repo, "feature/manual", target, "HEAD", false, false); err != nil { + t.Fatal(err) + } + task := domain.Task{ + Version: domain.ConfigVersion, + Task: domain.TaskInfo{ID: "MANUAL", Root: taskRoot}, + Repositories: []domain.Repository{{ + Name: "app", + Source: repo, + Base: "HEAD", + Branch: "feature/manual", + Worktree: filepath.Join("worktrees", "app"), + }}, + } + raw, err := config.Marshal(task) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(taskRoot, "taskflow.yaml"), raw, 0644); err != nil { + t.Fatal(err) + } + result, code := New().Create(context.Background(), CreateOptions{TasksRoot: tasks, TaskID: "MANUAL", Execute: true}) + if code != report.ExitOK || !result.OK { + t.Fatalf("manual reuse: code=%d result=%#v", code, result) + } + items := result.Data.(map[string]any)["actions"].([]plan.Item) + if len(items) != 2 || items[0].Status != "reuse" || items[1].Status != "reuse" || items[1].Reason == "" { + t.Fatalf("manual reuse actions: %#v", items) + } + if _, err := os.Stat(filepath.Join(target, "local.env")); !os.IsNotExist(err) { + t.Fatalf("manual worktree received a source copy: %v", err) + } +} + +func TestCreateRepairsPendingSourceCopyWithoutRecreatingWorktree(t *testing.T) { + repo := makeGitRepo(t) + if err := os.WriteFile(filepath.Join(repo, "one.env"), []byte("one"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(repo, "two.env"), []byte("two"), 0600); err != nil { + t.Fatal(err) + } + tasks := t.TempDir() + service := New() + if result, code := service.Create(context.Background(), CreateOptions{TasksRoot: tasks, TaskID: "PENDING", Repositories: []string{"app=" + repo}, Execute: true}); code != report.ExitOK || !result.OK { + t.Fatalf("initial create: code=%d result=%#v", code, result) + } + task, err := service.Load(tasks, "PENDING") + if err != nil { + t.Fatal(err) + } + target := filepath.Join(task.Task.Root, task.Repositories[0].Worktree) + manifest, exists, err := ownership.Load(task.Task.Root) + if err != nil || !exists { + t.Fatalf("load manifest: exists=%v err=%v", exists, err) + } + manifest.Worktrees[0].SourceCopy.Status = "pending" + if err := ownership.Save(task.Task.Root, manifest); err != nil { + t.Fatal(err) + } + if err := os.Remove(filepath.Join(target, "two.env")); err != nil { + t.Fatal(err) + } + result, code := service.Create(context.Background(), CreateOptions{TasksRoot: tasks, TaskID: "PENDING", Execute: true}) + if code != report.ExitOK || !result.OK { + t.Fatalf("pending retry: code=%d result=%#v", code, result) + } + items := result.Data.(map[string]any)["actions"].([]plan.Item) + if len(items) != 2 || items[0].Status != "reuse" || items[1].Status != "repaired" { + t.Fatalf("pending retry actions: %#v", items) + } + if _, err := os.Stat(filepath.Join(target, "two.env")); err != nil { + t.Fatalf("pending source copy was not repaired: %v", err) + } + manifest, _, err = ownership.Load(task.Task.Root) + if err != nil || manifest.Worktrees[0].SourceCopy.Status != "complete" { + t.Fatalf("pending source-copy status: %#v err=%v", manifest, err) + } +} + +func TestCreateRegistersMissingPendingTargetBeforeCopying(t *testing.T) { + repo := makeGitRepo(t) + if err := os.WriteFile(filepath.Join(repo, "notes.txt"), []byte("notes"), 0644); err != nil { + t.Fatal(err) + } + tasks := t.TempDir() + service := New() + if result, code := service.Create(context.Background(), CreateOptions{TasksRoot: tasks, TaskID: "MISSING", Repositories: []string{"app=" + repo}, Execute: true}); code != report.ExitOK || !result.OK { + t.Fatalf("initial create: code=%d result=%#v", code, result) + } + task, err := service.Load(tasks, "MISSING") + if err != nil { + t.Fatal(err) + } + target := filepath.Join(task.Task.Root, task.Repositories[0].Worktree) + manifest, exists, err := ownership.Load(task.Task.Root) + if err != nil || !exists { + t.Fatalf("load manifest: exists=%v err=%v", exists, err) + } + manifest.Worktrees[0].SourceCopy.Status = "pending" + if err := ownership.Save(task.Task.Root, manifest); err != nil { + t.Fatal(err) + } + if output, err := exec.Command("git", "-C", repo, "worktree", "remove", "--force", target).CombinedOutput(); err != nil { + t.Fatalf("remove worktree: %v: %s", err, output) + } + if _, err := os.Stat(target); !os.IsNotExist(err) { + t.Fatalf("target still present: %v", err) + } + result, code := service.Create(context.Background(), CreateOptions{TasksRoot: tasks, TaskID: "MISSING", Execute: true}) + if code != report.ExitOK || !result.OK { + t.Fatalf("missing pending retry: code=%d result=%#v", code, result) + } + items := result.Data.(map[string]any)["actions"].([]plan.Item) + if len(items) != 2 || items[0].Status != "created" || items[1].Status != "copied" { + t.Fatalf("missing pending actions: %#v", items) + } + if _, err := os.Stat(filepath.Join(target, "notes.txt")); err != nil { + t.Fatalf("re-registered target was not copied: %v", err) + } + manifest, _, err = ownership.Load(task.Task.Root) + if err != nil || manifest.Worktrees[0].SourceCopy.Status != "complete" { + t.Fatalf("source-copy status after re-registration: %#v err=%v", manifest, err) + } +} + +func TestCreateRejectsSourceTargetOverlapBeforeMutation(t *testing.T) { + repo := makeGitRepo(t) + tasks := filepath.Join(repo, ".tasks") + if err := os.MkdirAll(tasks, 0755); err != nil { + t.Fatal(err) + } + result, code := New().Create(context.Background(), CreateOptions{TasksRoot: tasks, TaskID: "OVERLAP", Repositories: []string{"app=" + repo}, DryRun: true}) + if code != report.ExitConflict || result.OK || !hasDiagnostic(result.Errors, "SOURCE_COPY_BOUNDARY") { + t.Fatalf("expected source/target boundary conflict: code=%d result=%#v", code, result) + } + if _, err := os.Stat(filepath.Join(tasks, "OVERLAP")); !os.IsNotExist(err) { + t.Fatalf("boundary conflict changed task root: %v", err) + } +} + func TestLoadRejectsLegacyRuntimeArtifacts(t *testing.T) { repo := makeGitRepo(t) tasks := t.TempDir() diff --git a/internal/app/app_unix_test.go b/internal/app/app_unix_test.go new file mode 100644 index 0000000..554d321 --- /dev/null +++ b/internal/app/app_unix_test.go @@ -0,0 +1,66 @@ +//go:build unix + +package app + +import ( + "context" + "os" + "path/filepath" + "syscall" + "testing" + + "github.com/chenquan/taskflow/internal/ownership" + "github.com/chenquan/taskflow/internal/plan" + "github.com/chenquan/taskflow/internal/report" +) + +func TestCreateRetainsPendingSourceCopyAfterPartialFailure(t *testing.T) { + repo := makeGitRepo(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() + result, code := New().Create(context.Background(), CreateOptions{TasksRoot: tasks, TaskID: "PARTIAL", Repositories: []string{"app=" + repo}, Execute: true}) + if code != report.ExitPartial || result.OK || !hasDiagnostic(result.Errors, "SOURCE_COPY_UNSUPPORTED_ENTRY") { + t.Fatalf("partial copy: code=%d result=%#v", code, result) + } + items := result.Data.(map[string]any)["actions"].([]plan.Item) + if len(items) != 2 || items[0].Status != "created" || items[1].Status != "failed" { + t.Fatalf("partial copy actions: %#v", items) + } + target := filepath.Join(tasks, "PARTIAL", "worktrees", "app") + if _, err := os.Stat(filepath.Join(target, "a-first.env")); err != nil { + t.Fatalf("entries before the failure were not copied: %v", err) + } + if _, err := os.Stat(filepath.Join(target, "z-last.env")); !os.IsNotExist(err) { + t.Fatalf("entries after the failure were copied: %v", err) + } + manifest, exists, err := ownership.Load(filepath.Join(tasks, "PARTIAL")) + if err != nil || !exists || manifest.Worktrees[0].SourceCopy == nil || manifest.Worktrees[0].SourceCopy.Status != "pending" { + t.Fatalf("pending ownership after partial copy: manifest=%#v exists=%v err=%v", manifest, exists, err) + } + if err := os.Remove(filepath.Join(repo, "m-pipe")); err != nil { + t.Fatal(err) + } + retry, code := New().Create(context.Background(), CreateOptions{TasksRoot: tasks, TaskID: "PARTIAL", Execute: true}) + if code != report.ExitOK || !retry.OK { + t.Fatalf("retry: code=%d result=%#v", code, retry) + } + retryItems := retry.Data.(map[string]any)["actions"].([]plan.Item) + if len(retryItems) != 2 || retryItems[0].Status != "reuse" || retryItems[1].Status != "repaired" { + t.Fatalf("retry actions: %#v", retryItems) + } + if _, err := os.Stat(filepath.Join(target, "z-last.env")); err != nil { + t.Fatalf("retry did not complete the source copy: %v", err) + } + manifest, _, err = ownership.Load(filepath.Join(tasks, "PARTIAL")) + if err != nil || manifest.Worktrees[0].SourceCopy.Status != "complete" { + t.Fatalf("source-copy status after retry: %#v err=%v", manifest, err) + } +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 79eef29..54b9b0a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -76,3 +76,15 @@ func TestValidateRejectsWorktreeEscape(t *testing.T) { t.Fatalf("expected containment error, got %v", err) } } + +func TestLoadRejectsRetiredLocalField(t *testing.T) { + d := t.TempDir() + path := filepath.Join(d, "taskflow.yaml") + contents := "task:\n id: A\nrepositories:\n - name: one\n source: " + d + "\n local:\n unexpected: true\n" + if err := os.WriteFile(path, []byte(contents), 0644); err != nil { + t.Fatal(err) + } + if _, err := Load(path); err == nil || !strings.Contains(err.Error(), "field local not found") { + t.Fatalf("expected retired local field rejection, got %v", err) + } +} diff --git a/internal/domain/task.go b/internal/domain/task.go index 3bada1b..2466427 100644 --- a/internal/domain/task.go +++ b/internal/domain/task.go @@ -14,9 +14,9 @@ type TaskInfo struct { } type Repository struct { - Name string `yaml:"name" json:"name"` - Source string `yaml:"source" json:"source"` - Base string `yaml:"base" json:"base"` - Branch string `yaml:"branch" json:"branch"` - Worktree string `yaml:"worktree" json:"worktree"` + Name string `yaml:"name" json:"name"` + Source string `yaml:"source" json:"source"` + Base string `yaml:"base" json:"base"` + Branch string `yaml:"branch" json:"branch"` + Worktree string `yaml:"worktree" json:"worktree"` } diff --git a/internal/fsx/copy.go b/internal/fsx/copy.go new file mode 100644 index 0000000..8ecede7 --- /dev/null +++ b/internal/fsx/copy.go @@ -0,0 +1,149 @@ +package fsx + +import ( + "fmt" + "io" + "io/fs" + "os" + "path/filepath" +) + +// CopyStats reports how many filesystem entries were copied and how many +// regular-file bytes were written. +type CopyStats struct { + Entries int64 + Bytes int64 +} + +// CopyError identifies the failing copy operation and the path relative to the +// copied root so diagnostics stay stable across platforms. +type CopyError struct { + Op string // boundary, lstat, mkdir, readlink, symlink, open, create, read, write, chmod, unsupported-entry + Path string // relative to the copied root; "." denotes the roots themselves + Err error +} + +func (e *CopyError) Error() string { + if e.Path == "" || e.Path == "." { + return fmt.Sprintf("copy %s: %v", e.Op, e.Err) + } + return fmt.Sprintf("copy %s %s: %v", e.Op, e.Path, e.Err) +} + +func (e *CopyError) Unwrap() error { return e.Err } + +// CopyTree copies the complete contents of source into target. Every `.git` +// entry — the root entry and any entry at a nested depth — is excluded so the +// target keeps only its own Git metadata. Regular-file and directory +// permission bits are preserved, symlinks are reproduced without following +// them, and any other entry type fails the copy. Source and target must not +// contain one another. +func CopyTree(source, target string) (CopyStats, error) { + if Within(source, target) || Within(target, source) { + return CopyStats{}, &CopyError{Op: "boundary", Err: fmt.Errorf("source %q and target %q must not contain one another", source, target)} + } + if err := os.MkdirAll(target, 0755); err != nil { + return CopyStats{}, &CopyError{Op: "mkdir", Path: ".", Err: err} + } + var stats CopyStats + err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return &CopyError{Op: "lstat", Path: relativePath(source, path), Err: walkErr} + } + relative := relativePath(source, path) + if entry.Name() == ".git" { + // The target keeps its own Git metadata; a nested entry belongs to + // another checkout and must never leak into the snapshot. + if entry.IsDir() { + return filepath.SkipDir + } + return nil + } + info, err := entry.Info() + if err != nil { + return &CopyError{Op: "lstat", Path: relative, Err: err} + } + switch { + case entry.IsDir(): + if relative == "." { + return nil + } + if err := os.Mkdir(filepath.Join(target, filepath.FromSlash(relative)), info.Mode().Perm()); err != nil && !os.IsExist(err) { + return &CopyError{Op: "mkdir", Path: relative, Err: err} + } + stats.Entries++ + case info.Mode()&fs.ModeSymlink != 0: + if err := copySymlink(source, target, relative); err != nil { + return err + } + stats.Entries++ + case info.Mode().IsRegular(): + written, err := copyRegularFile(path, filepath.Join(target, filepath.FromSlash(relative)), info.Mode().Perm()) + if err != nil { + if copyErr, ok := err.(*CopyError); ok { + if copyErr.Path == "" || copyErr.Path == "." { + copyErr.Path = relative + } + return copyErr + } + return &CopyError{Op: "write", Path: relative, Err: err} + } + stats.Entries++ + stats.Bytes += written + default: + return &CopyError{Op: "unsupported-entry", Path: relative, Err: fmt.Errorf("unsupported entry type %s", info.Mode().Type())} + } + return nil + }) + if err != nil { + return stats, err + } + return stats, nil +} + +func copySymlink(source, target, relative string) error { + sourcePath := filepath.Join(source, filepath.FromSlash(relative)) + destination := filepath.Join(target, filepath.FromSlash(relative)) + linkTarget, err := os.Readlink(sourcePath) + if err != nil { + return &CopyError{Op: "readlink", Path: relative, Err: err} + } + if err := os.Remove(destination); err != nil && !os.IsNotExist(err) { + return &CopyError{Op: "symlink", Path: relative, Err: err} + } + if err := os.Symlink(linkTarget, destination); err != nil { + return &CopyError{Op: "symlink", Path: relative, Err: err} + } + return nil +} + +func copyRegularFile(source, target string, perm os.FileMode) (int64, error) { + in, err := os.Open(source) + if err != nil { + return 0, &CopyError{Op: "open", Err: err} + } + defer in.Close() + out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm) + if err != nil { + return 0, &CopyError{Op: "create", Err: err} + } + written, copyErr := io.Copy(out, in) + if chmodErr := out.Chmod(perm); chmodErr != nil && copyErr == nil { + copyErr = &CopyError{Op: "chmod", Err: chmodErr} + } + if closeErr := out.Close(); closeErr != nil && copyErr == nil { + copyErr = &CopyError{Op: "write", Err: closeErr} + } + if copyErr != nil { + return written, copyErr + } + return written, nil +} + +func relativePath(root, path string) string { + relative, err := filepath.Rel(root, path) + if err != nil { + return path + } + return relative +} diff --git a/internal/fsx/copy_test.go b/internal/fsx/copy_test.go new file mode 100644 index 0000000..2feed9f --- /dev/null +++ b/internal/fsx/copy_test.go @@ -0,0 +1,135 @@ +package fsx + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestCopyTreeCopiesCompleteTree(t *testing.T) { + source := t.TempDir() + target := filepath.Join(t.TempDir(), "target") + if err := os.MkdirAll(filepath.Join(source, "config", "dev"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "tracked.txt"), []byte("base"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "config", "dev", "settings local.env"), []byte("debug=true"), 0600); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(source, "empty"), 0750); err != nil { + t.Fatal(err) + } + stats, err := CopyTree(source, target) + if err != nil { + t.Fatalf("CopyTree: %v", err) + } + contents, err := os.ReadFile(filepath.Join(target, "config", "dev", "settings local.env")) + if err != nil || string(contents) != "debug=true" { + t.Fatalf("nested file: %q err=%v", contents, err) + } + if _, err := os.Stat(filepath.Join(target, "empty")); err != nil { + t.Fatalf("empty directory missing: %v", err) + } + info, err := os.Stat(filepath.Join(target, "tracked.txt")) + if err != nil { + t.Fatal(err) + } + if runtime.GOOS != "windows" && info.Mode().Perm() != 0644 { + t.Fatalf("file mode=%v", info.Mode().Perm()) + } + if stats.Entries != 5 || stats.Bytes != int64(len("base")+len("debug=true")) { + t.Fatalf("stats=%#v", stats) + } +} + +func TestCopyTreeExcludesGitEntriesAtRootAndNestedDepth(t *testing.T) { + source := t.TempDir() + target := filepath.Join(t.TempDir(), "target") + if err := os.MkdirAll(filepath.Join(source, ".git"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, ".git", "HEAD"), []byte("ref"), 0644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(source, "nested"), 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "nested", ".git"), []byte("gitdir: elsewhere"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "nested", "code.txt"), []byte("code"), 0644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "root.env"), []byte("root"), 0644); err != nil { + t.Fatal(err) + } + stats, err := CopyTree(source, target) + if err != nil { + t.Fatalf("CopyTree: %v", err) + } + for _, forbidden := range []string{filepath.Join(".git", "HEAD"), filepath.Join("nested", ".git")} { + if _, err := os.Stat(filepath.Join(target, forbidden)); !os.IsNotExist(err) { + t.Fatalf(".git entry %s was copied: %v", forbidden, err) + } + } + for _, required := range []string{"root.env", filepath.Join("nested", "code.txt")} { + if _, err := os.Stat(filepath.Join(target, required)); err != nil { + t.Fatalf("entry %s missing: %v", required, err) + } + } + if stats.Entries != 3 { + t.Fatalf("stats=%#v", stats) + } +} + +func TestCopyTreeRejectsOverlappingRoots(t *testing.T) { + source := t.TempDir() + nested := filepath.Join(source, "inner") + if err := os.MkdirAll(nested, 0755); err != nil { + t.Fatal(err) + } + if _, err := CopyTree(source, nested); err == nil { + t.Fatal("target inside source unexpectedly accepted") + } + if _, err := CopyTree(nested, source); err == nil { + t.Fatal("source inside target unexpectedly accepted") + } + var copyErr *CopyError + _, err := CopyTree(source, nested) + if !errors.As(err, ©Err) || copyErr.Op != "boundary" { + t.Fatalf("expected boundary error, got %v", err) + } +} + +func TestCopyTreePreservesSymlinksWithoutFollowing(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation requires privileges on windows") + } + source := t.TempDir() + target := filepath.Join(t.TempDir(), "target") + outside := filepath.Join(t.TempDir(), "outside.txt") + if err := os.WriteFile(outside, []byte("outside"), 0644); err != nil { + t.Fatal(err) + } + if err := os.Symlink(outside, filepath.Join(source, "absolute")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join("nowhere", "dangling.txt"), filepath.Join(source, "dangling")); err != nil { + t.Fatal(err) + } + if _, err := CopyTree(source, target); err != nil { + t.Fatalf("CopyTree: %v", err) + } + resolved, err := os.Readlink(filepath.Join(target, "absolute")) + if err != nil || resolved != outside { + t.Fatalf("absolute link=%q err=%v", resolved, err) + } + resolved, err = os.Readlink(filepath.Join(target, "dangling")) + if err != nil || filepath.IsAbs(resolved) { + t.Fatalf("dangling link=%q err=%v", resolved, err) + } +} diff --git a/internal/fsx/copy_unix_test.go b/internal/fsx/copy_unix_test.go new file mode 100644 index 0000000..4c08761 --- /dev/null +++ b/internal/fsx/copy_unix_test.go @@ -0,0 +1,30 @@ +//go:build unix + +package fsx + +import ( + "errors" + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestCopyTreeFailsOnUnsupportedEntry(t *testing.T) { + source := t.TempDir() + target := filepath.Join(t.TempDir(), "target") + if err := os.WriteFile(filepath.Join(source, "before.txt"), []byte("before"), 0644); err != nil { + t.Fatal(err) + } + if err := syscall.Mkfifo(filepath.Join(source, "pipe"), 0644); err != nil { + t.Skipf("mkfifo unavailable: %v", err) + } + stats, err := CopyTree(source, target) + var copyErr *CopyError + if !errors.As(err, ©Err) || copyErr.Op != "unsupported-entry" || copyErr.Path != "pipe" { + t.Fatalf("expected unsupported-entry error for pipe, got stats=%#v err=%v", stats, err) + } + if _, statErr := os.Stat(filepath.Join(target, "before.txt")); statErr != nil { + t.Fatalf("entries before the failure were not copied: %v", statErr) + } +} diff --git a/internal/git/git.go b/internal/git/git.go index 0838f27..c3e83ab 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -99,6 +99,23 @@ func (c Client) HasRef(ctx context.Context, path, ref string) bool { return err == nil } +// ResetIndex populates a worktree index from HEAD without touching working +// tree files. `git worktree add --no-checkout` leaves the index empty, so a +// mixed reset is required before copied files can be compared against the +// configured base. +func (c Client) ResetIndex(ctx context.Context, path string) error { + _, err := c.Runner.Run(ctx, execx.CommandSpec{Executable: "git", Args: []string{"-C", path, "reset", "--quiet"}}) + return err +} + +func gitCommandError(prefix string, result execx.Result) error { + message := strings.TrimSpace(result.Stderr) + if message == "" { + return fmt.Errorf("%s", prefix) + } + return fmt.Errorf("%s: %s", prefix, message) +} + // DefaultBase resolves the source repository's remote default branch without // fetching or changing Git state. func (c Client) DefaultBase(ctx context.Context, path string) (string, error) { @@ -153,8 +170,14 @@ func (c Client) Worktrees(ctx context.Context, path string) ([]Worktree, error) flush() return result, nil } -func (c Client) AddWorktree(ctx context.Context, source, branch, target, base string, trackBase bool) error { +// AddWorktree registers a worktree from the configured base. With noCheckout +// the working tree and index stay empty so the caller can populate the index +// and copy a source snapshot without overwriting base files. +func (c Client) AddWorktree(ctx context.Context, source, branch, target, base string, trackBase, noCheckout bool) error { args := []string{"-C", source, "worktree", "add"} + if noCheckout { + args = append(args, "--no-checkout") + } if c.HasRef(ctx, source, "refs/heads/"+branch) { args = append(args, target, branch) } else { diff --git a/internal/git/git_test.go b/internal/git/git_test.go index fc107dc..d2d7fee 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -44,7 +44,7 @@ func TestClientInspectsAndCreatesWorktrees(t *testing.T) { } } target := filepath.Join(t.TempDir(), "feature worktree") - if err := client.AddWorktree(context.Background(), root, "feature/test", target, "main", true); err != nil { + if err := client.AddWorktree(context.Background(), root, "feature/test", target, "main", true, false); err != nil { t.Fatal(err) } worktrees, err := client.Worktrees(context.Background(), root) @@ -114,7 +114,7 @@ func TestClientAddWorktreeDefaultDoesNotTrackRemoteBase(t *testing.T) { target := filepath.Join(t.TempDir(), "worktree") client := Client{Runner: execx.OSRunner{}} - if err := client.AddWorktree(context.Background(), root, "feature/master-base", target, "origin/master", false); err != nil { + if err := client.AddWorktree(context.Background(), root, "feature/master-base", target, "origin/master", false, false); err != nil { t.Fatal(err) } info, err := client.Inspect(context.Background(), target) @@ -129,7 +129,7 @@ func TestClientAddWorktreeDefaultDoesNotTrackRemoteBase(t *testing.T) { } trackedTarget := filepath.Join(t.TempDir(), "tracked-worktree") - if err := client.AddWorktree(context.Background(), root, "feature/explicit-master", trackedTarget, "origin/master", true); err != nil { + if err := client.AddWorktree(context.Background(), root, "feature/explicit-master", trackedTarget, "origin/master", true, false); err != nil { t.Fatal(err) } trackedInfo, err := client.Inspect(context.Background(), trackedTarget) @@ -140,3 +140,48 @@ func TestClientAddWorktreeDefaultDoesNotTrackRemoteBase(t *testing.T) { t.Fatalf("explicit upstream=%q, want origin/master", trackedInfo.Upstream) } } + +func TestClientNoCheckoutWorktreeStartsWithEmptyIndex(t *testing.T) { + root := filepath.Join(t.TempDir(), "repo") + if out, err := exec.Command("git", "init", "-b", "main", root).CombinedOutput(); err != nil { + t.Fatalf("git init: %v: %s", err, out) + } + if err := os.WriteFile(filepath.Join(root, "base.txt"), []byte("committed"), 0644); err != nil { + t.Fatal(err) + } + for _, args := range [][]string{ + {"-C", root, "config", "user.email", "test@example.com"}, + {"-C", root, "config", "user.name", "Test"}, + {"-C", root, "add", "base.txt"}, + {"-C", root, "commit", "-m", "init"}, + } { + if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v: %s", args, err, out) + } + } + target := filepath.Join(t.TempDir(), "no-checkout") + client := Client{Runner: execx.OSRunner{}} + if err := client.AddWorktree(context.Background(), root, "feature/no-checkout", target, "HEAD", true, true); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "base.txt"), []byte("copied modification"), 0644); err != nil { + t.Fatal(err) + } + status, err := exec.Command("git", "-C", target, "status", "--porcelain").CombinedOutput() + if err != nil { + t.Fatalf("status before reset: %v: %s", err, status) + } + if string(status) != "D base.txt\n?? base.txt\n" { + t.Fatalf("--no-checkout did not leave an empty index: %q", status) + } + if err := client.ResetIndex(context.Background(), target); err != nil { + t.Fatalf("ResetIndex: %v", err) + } + status, err = exec.Command("git", "-C", target, "status", "--porcelain").CombinedOutput() + if err != nil { + t.Fatalf("status after reset: %v: %s", err, status) + } + if string(status) != " M base.txt\n" { + t.Fatalf("mixed reset did not reconcile the index: %q", status) + } +} diff --git a/internal/ownership/ownership.go b/internal/ownership/ownership.go index 6c04211..150f694 100644 --- a/internal/ownership/ownership.go +++ b/internal/ownership/ownership.go @@ -21,11 +21,21 @@ type Manifest struct { } type Worktree struct { - Repository string `json:"repository"` - Source string `json:"source"` - CommonDir string `json:"commonDir"` - Branch string `json:"branch"` - Target string `json:"target"` + Repository string `json:"repository"` + Source string `json:"source"` + CommonDir string `json:"commonDir"` + Branch string `json:"branch"` + Target string `json:"target"` + SourceCopy *SourceCopy `json:"sourceCopy,omitempty"` +} + +// SourceCopy records whether Taskflow finished copying the source working +// tree into this managed worktree. It is intentionally repository-level: the +// source tree is a creation-time snapshot, not a per-file synchronization log. +type SourceCopy struct { + Source string `json:"source"` + Target string `json:"target"` + Status string `json:"status"` // pending or complete } func Path(taskRoot string) string { @@ -71,6 +81,9 @@ func Validate(manifest Manifest) error { return fmt.Errorf("duplicate ownership entry for %q", worktree.Repository) } seen[key] = true + if err := validateSourceCopy(worktree); err != nil { + return err + } } return nil } @@ -81,6 +94,7 @@ func (m *Manifest) Add(worktree Worktree) { if current.Source == worktree.Source && current.Branch == worktree.Branch && current.Target == worktree.Target { current.Repository = worktree.Repository current.CommonDir = worktree.CommonDir + current.SourceCopy = worktree.SourceCopy return } } @@ -98,3 +112,20 @@ func Save(taskRoot string, manifest Manifest) error { raw = append(raw, '\n') return fsx.AtomicWrite(Path(taskRoot), raw, 0644) } + +func validateSourceCopy(worktree Worktree) error { + sourceCopy := worktree.SourceCopy + if sourceCopy == nil { + return nil + } + if sourceCopy.Source == "" || sourceCopy.Target == "" { + return fmt.Errorf("source-copy ownership entry for %q is incomplete", worktree.Repository) + } + if sourceCopy.Status != "pending" && sourceCopy.Status != "complete" { + return fmt.Errorf("source-copy ownership entry for %q has unsupported status %q", worktree.Repository, sourceCopy.Status) + } + if sourceCopy.Source != worktree.Source || sourceCopy.Target != worktree.Target { + return fmt.Errorf("source-copy ownership entry for %q has stale source or target identity", worktree.Repository) + } + return nil +} diff --git a/internal/ownership/ownership_test.go b/internal/ownership/ownership_test.go index 2a688ed..3b200e5 100644 --- a/internal/ownership/ownership_test.go +++ b/internal/ownership/ownership_test.go @@ -62,3 +62,32 @@ func TestValidateRejectsMalformedEntries(t *testing.T) { } } } + +func TestSourceCopyOwnershipRoundTripAndLegacyCompatibility(t *testing.T) { + root := t.TempDir() + manifest := New("TASK-COPY") + manifest.Add(Worktree{ + Repository: "api", + Source: filepath.Join(root, "source"), + CommonDir: filepath.Join(root, "source", ".git"), + Branch: "feature/task-copy", + Target: filepath.Join(root, "task", "worktrees", "api"), + SourceCopy: &SourceCopy{ + Source: filepath.Join(root, "source"), + Target: filepath.Join(root, "task", "worktrees", "api"), + Status: "pending", + }, + }) + if err := Save(root, manifest); err != nil { + t.Fatal(err) + } + loaded, exists, err := Load(root) + if err != nil || !exists || loaded.Worktrees[0].SourceCopy == nil || loaded.Worktrees[0].SourceCopy.Status != "pending" { + t.Fatalf("source-copy manifest=%#v exists=%v err=%v", loaded, exists, err) + } + legacy := New("LEGACY") + legacy.Add(Worktree{Repository: "api", Source: "/source", CommonDir: "/source/.git", Branch: "feature/legacy", Target: "/task/worktrees/api"}) + if err := Validate(legacy); err != nil { + t.Fatalf("legacy manifest rejected: %v", err) + } +} diff --git a/internal/plan/plan.go b/internal/plan/plan.go index 4500b6d..05be38f 100644 --- a/internal/plan/plan.go +++ b/internal/plan/plan.go @@ -7,25 +7,39 @@ import ( ) type Item struct { - ID string `json:"id"` - Repo string `json:"repo,omitempty"` - Kind string `json:"kind"` - Description string `json:"description"` - Status string `json:"status,omitempty"` + ID string `json:"id"` + Repo string `json:"repo,omitempty"` + Kind string `json:"kind"` + Description string `json:"description"` + Status string `json:"status,omitempty"` + Source string `json:"source,omitempty"` + Target string `json:"target,omitempty"` + FileCount int `json:"fileCount,omitempty"` + TotalBytes int64 `json:"totalBytes,omitempty"` + Reason string `json:"reason,omitempty"` } func Build(task domain.Task) ([]Item, error) { if err := validateOrder(task.Repositories); err != nil { return nil, err } - items := make([]Item, 0, len(task.Repositories)) + items := make([]Item, 0, len(task.Repositories)*2) for _, repository := range task.Repositories { items = append(items, Item{ ID: "worktree-" + repository.Name, Repo: repository.Name, Kind: "worktree", + Target: repository.Worktree, Description: fmt.Sprintf("RECONCILE %s -> %s", repository.Name, repository.Worktree), }) + items = append(items, Item{ + ID: "source-copy-" + repository.Name, + Repo: repository.Name, + Kind: "source-copy", + Source: repository.Source, + Target: repository.Worktree, + Description: fmt.Sprintf("COPY source %s -> %s", repository.Source, repository.Worktree), + }) } return items, nil } diff --git a/internal/plan/plan_test.go b/internal/plan/plan_test.go index 795b8e6..04dd5df 100644 --- a/internal/plan/plan_test.go +++ b/internal/plan/plan_test.go @@ -8,10 +8,10 @@ import ( func TestBuildPreservesRepositoryOrder(t *testing.T) { items, err := Build(domain.Task{Repositories: []domain.Repository{{Name: "b", Worktree: "worktrees/b"}, {Name: "a", Worktree: "worktrees/a"}}}) - if err != nil || len(items) != 2 || items[0].Repo != "b" || items[1].Repo != "a" { + if err != nil || len(items) != 4 || items[0].Repo != "b" || items[2].Repo != "a" { t.Fatalf("%v %#v", err, items) } - if items[0].Status != "" || items[0].Kind != "worktree" { + if items[0].Status != "" || items[0].Kind != "worktree" || items[1].Kind != "source-copy" || items[3].Kind != "source-copy" { t.Fatalf("unexpected item: %#v", items[0]) } } diff --git a/openspec/changes/copy-source-working-tree/.openspec.yaml b/openspec/changes/copy-source-working-tree/.openspec.yaml new file mode 100644 index 0000000..1d9aeef --- /dev/null +++ b/openspec/changes/copy-source-working-tree/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-04 diff --git a/openspec/changes/copy-source-working-tree/design.md b/openspec/changes/copy-source-working-tree/design.md new file mode 100644 index 0000000..24238e9 --- /dev/null +++ b/openspec/changes/copy-source-working-tree/design.md @@ -0,0 +1,81 @@ +## Context + +Taskflow currently creates a target with `git worktree add`. Git prepares the configured branch and checkout from the selected base commit, but the source working directory may also contain uncommitted tracked changes, untracked files, ignored files, and local directories that are intentionally outside the index. The previous local-overlay implementation selected individual paths and persisted a per-file snapshot; that is more machinery than the desired behavior requires. + +The new contract is a creation-time copy of the source working directory. The target remains a real Git worktree whose branch and index start from the configured base, while its working-tree contents initially match the source checkout. Git metadata is excluded — the source root `.git` plus any `.git` entry at a nested depth — because the target must retain its own worktree metadata and must not adopt another checkout's. + +## Goals / Non-Goals + +**Goals:** + +- Copy the complete source working-tree contents into a newly created target, including tracked modifications, untracked files, and ignored files. +- Preserve the target Worktree's own `.git` file or directory and never copy source Git metadata, root or nested, over it. +- Keep the existing Taskflow create dry-run, locking, target containment, live Worktree identity, reuse, and deletion ownership rules. +- Keep the implementation independent of Git status classification and independent of a user-maintained file list. +- Make a failed copy retryable with one repository-level copy status, without recording per-file hashes or paths. + +**Non-Goals:** + +- Do not synchronize source and target after a completed create; a matching Worktree is reused without refreshing it. +- Do not copy source Git metadata of any kind: the root `.git`, nested `.git` entries, Git objects, refs, worktree administrative files, or locks. +- Do not add a general file synchronization engine, ignore rules, glob configuration, or a local-file selection CLI flag. +- Do not merge source changes into the target branch history; copied changes remain ordinary working-tree changes in the target. +- Do not promise an atomic transaction across Git metadata and arbitrary filesystem contents. + +## Decisions + +### 1. Create the target with `git worktree add --no-checkout`, then populate its index + +For a missing target, Taskflow will ask Git to register the branch and worktree from the configured base without checking out base files. `--no-checkout` leaves the target with no working tree and no index, so Taskflow immediately populates the index from HEAD with a mixed reset (or an equivalent read-tree) that does not touch the empty working tree. Only after index population succeeds does it copy the source working directory into the target. An index-population failure is a retryable create failure covered by the same pending source-copy status as the copy itself. Registration, index population, and copying in this order keep the target's Git metadata generated by Git while making the source snapshot authoritative without first materializing base files that would need to be overwritten. + +The populated index represents the configured base/branch. Consequently, tracked source modifications and source files that differ from the base appear as normal unstaged working-tree changes, untracked and ignored source files appear as untracked, and a source that matches the base produces a clean Worktree. Without index population, Git would compare copied files against an empty index: every HEAD file would look like a staged deletion and every copied file untracked, leaving `git status`, `git diff`, `git add`, and `git commit` unusable in the new Worktree. + +**Alternative considered:** running normal `git worktree add` and recursively copying source afterward. This works for many cases but requires overwriting base files, leaves base-only files when the source no longer has them, and makes an exact source snapshot harder to define. + +### 2. Copy the filesystem tree, not Git classifications + +The copy operation walks the source root recursively and copies every entry except `.git` entries: the source root `.git` and any `.git` entry at a nested depth. Skipping nested entries keeps another checkout's Git metadata — a registration file pointing at the source common directory, or an embedded repository — out of the target, while the nested working files themselves are still copied. The walk creates parent directories, copies regular-file contents and supported mode bits, and preserves symlink entries without following them; when the platform cannot create a symlink, the copy fails with a structured diagnostic instead of dereferencing the link. Unsupported filesystem entries fail the copy with a structured diagnostic naming the offending path instead of being silently omitted. The copier requires the source and destination to be disjoint — neither may contain the other — and rejects a violation before walking, so a target configured inside the source tree can never make the copy recurse into itself. The copy uses Go's standard library and argument-safe filesystem operations; it does not shell out to `cp`, `rsync`, or a platform-specific command. + +The destination is the target that Git has just created in this operation. The copy may replace files that were present only because a caller supplied a non-empty target, but such a target is rejected before creation; it never adopts or overwrites an existing unrelated directory. The target's `.git` entry is protected explicitly. + +**Alternative considered:** using `git ls-files` to copy only untracked or ignored files. That would omit tracked working-tree modifications and would require separate ignored-file handling, contrary to the complete-copy requirement. + +### 3. Use one repository-level copy status for retry + +The existing ownership entry for a Taskflow-created Worktree will carry a small source-copy status with the source and target identity and `pending` or `complete` state. Taskflow records `pending` before creating the missing Worktree and changes it to `complete` only after the recursive copy succeeds. No per-file list, hash snapshot, or byte-level recovery journal is added. + +If create is retried while a matching owned target has a pending source copy, Taskflow reruns the complete source-tree copy against that target. A pending status never implies the target directory exists: if the target is absent because registration itself failed or it was removed manually, the retry registers the missing Worktree first and then copies. A matching target with a complete status is reused without copying. A matching Worktree without Taskflow ownership remains manually managed and is never populated implicitly. + +This marker narrows the existing state-free reconciliation contract on one point. Registration identity, branch, and target matching continue to be derived from live Git facts; only copy completeness consults the persisted status, because an interrupted copy is indistinguishable from a complete one by inspection alone. + +Because the `open` command no longer exists, Taskflow does not gate tool launch itself; the bundled skill and documentation must instruct users not to use or edit a pending target until the retry completes. + +**Alternative considered:** no copy status and no retry behavior. That would make an interrupted copy look like a successful Worktree reuse on the next create and could leave a permanently incomplete workspace. + +### 4. Keep output and lifecycle changes narrow + +Create planning will retain the existing worktree action and add one source-copy action per repository. Dry-run reports the source, target, and that a complete copy will occur without reading or changing target contents; it never enumerates source entries and reports no entry or byte totals, so dry-run stays cheap for very large directories. Execute reports copy success or failure and a copied entry count/byte total computed from the executed copy when available. Existing exit-code, lock, configuration, and ownership rules remain authoritative. + +The old `local.paths`, `--local`, per-file overlay plan, and overlay-specific diagnostics are removed rather than supported alongside the new mode. This avoids two competing definitions of what a newly created Worktree contains. + +### 5. Preserve existing reuse and deletion boundaries + +The copy runs only after a missing Worktree has been registered by Taskflow. A live matching Worktree is still `reuse`; it is not synchronized with the source. Delete continues to require a matching ownership manifest and clean/force rules. Because the copied source snapshot normally makes the target dirty, the existing dirty-worktree gate will naturally protect it from ordinary deletion. + +## Risks / Trade-offs + +- **[Large or sensitive source directories]** All ignored content, caches, dependencies, and secrets are copied → make the complete-copy action visible in dry-run, document that this is intentional, and keep the source/target boundary explicit. +- **[Source changes during copying]** The source can change while the recursive walk runs → report copy failure when an individual read/copy operation fails; the operation is a creation-time snapshot, not a live mirror. +- **[Partial filesystem mutation]** A recursive copy cannot be atomic with Git worktree registration → persist the repository-level pending status, preserve the target for retry, and never classify a pending copy as ready. +- **[User edits during pending retry]** A retry rebuilds the incomplete Taskflow-created target and may replace files in it → do not launch tools while status is pending and document that pending targets must be repaired before use. +- **[Cross-platform filesystem behavior]** Permissions and symlink support differ → use standard-library operations, preserve only supported metadata, skip only `.git`, and fail clearly for unsupported special entries. +- **[Dirty target semantics]** Copying source tracked changes makes the new Worktree dirty relative to its base → treat this as expected and retain the existing deletion safety gate. + +## Migration Plan + +1. Replace the current explicit overlay model with the source-tree copy action and remove `local.paths`/`--local` parsing and overlay-specific ownership fields. +2. Add `--no-checkout` Worktree creation with post-registration index population, recursive source copy, repository-level pending/complete ownership status, and create output changes. +3. Update dry-run, retry, deletion, bundled skill, README, and compiled-binary tests to describe the complete-copy contract. +4. Validate that existing task configurations without local overlay fields continue to load unchanged. Since the explicit overlay implementation is not part of the main branch contract, its configuration and ownership fields are removed as part of this replacement. + +Rollback is a code revert. A target created with the new behavior remains an ordinary Git Worktree with a dirty working tree; reverting the implementation does not delete or rewrite it. Future creates use the reverted behavior after the target is manually handled according to the existing ownership rules. diff --git a/openspec/changes/copy-source-working-tree/proposal.md b/openspec/changes/copy-source-working-tree/proposal.md new file mode 100644 index 0000000..b16272f --- /dev/null +++ b/openspec/changes/copy-source-working-tree/proposal.md @@ -0,0 +1,40 @@ +## Why + +`git worktree add` 只会准备目标提交中的 Git 文件,不会带上 source 工作目录中的未提交修改、未跟踪文件或 ignored 文件。当前显式 local overlay 方案需要路径清单、文件快照和恢复状态,偏离了用户真正的目标:创建新 Worktree 时得到 source 当前完整的工作目录内容。 + +## What Changes + +- **BREAKING** 将新 Worktree 的初始化方式改为复制 source 当前工作目录的完整文件快照。 +- **BREAKING** 移除 `local.paths` 配置和 `--local` bootstrap 参数;不再要求用户逐项声明本地文件。 +- 在新 Worktree 创建过程中复制所有 source 工作目录文件,包括 tracked 文件的未提交修改、未跟踪文件和 ignored 文件。 +- 注册目标后先将新 Worktree 的 index 重建为 base 内容(mixed reset),使复制的 tracked 修改表现为普通未暂存修改,而不是对空 index 的 staged deletion。 +- 复制时排除所有 Git 元数据(source 根目录及任意嵌套层级的 `.git` 条目),保留目标 Worktree 自己的 Git 元数据,并拒绝 source 与 target 互相包含的路径。 +- 保留现有的 create dry-run、锁、目标路径保护、Worktree 复用和删除安全门禁。 +- 只对新建的 Taskflow Worktree 执行完整复制;匹配的已有 Worktree 继续复用,不进行隐式同步。 +- 在现有 ownership manifest 中为 Taskflow 创建的 Worktree 记录 pending/complete 复制状态;复制完整性是唯一改为从该持久状态派生的判定,其余判定继续只依赖 live Git facts。 +- 将复制动作及源/目标信息纳入现有文本和 JSON create 输出,并覆盖复制失败后的重试行为:已注册的 pending 目标重新复制,缺失的 pending 目标先注册再复制。 +- 更新 README、bundled skill、相关 OpenSpec capability 和端到端测试,删除旧 overlay 语义。 + +## Capabilities + +### New Capabilities + +- `complete-source-working-tree-copy`: 在新 Git Worktree 中复制 source 当前完整工作目录、排除 Git 元数据并保持创建边界。 + +### Modified Capabilities + +- `worktree-start`: 新 Worktree 创建后必须先重建 index 再复制 source 工作目录快照。 +- `worktree-reconciliation`: 区分新建目标的完整复制和已有目标的普通复用;复制完整性改由 ownership manifest 的复制状态派生,放宽原"一切判定只依赖 live Git facts"的不变量。 +- `task-workspace-initialization`: 新任务不再通过 local 文件清单初始化,而是复制 source 工作目录。 +- `environment-preflight`: 增加完整工作目录复制的 source/target 安全检查(source 可读、两者互不包含、目标自有 `.git` 受保护)。 +- `cli-output-contract`: 报告完整工作目录复制动作和结果。 +- `resumable-action-execution`: 定义复制中断后的最小重试行为。 +- `e2e-command-flow`: 覆盖完整工作目录复制、`.git` 排除、修改文件和 ignored 文件。 +- `taskflow-multirepo-skill`: 引导用户审阅完整工作目录复制,而不是配置 local overlay。 + +## Impact + +- 影响 `internal/app`、`internal/git`、`internal/fsx`、`internal/domain`、`internal/ownership`、`internal/plan`、`internal/report` 及 CLI 配置解析。 +- 需要删除或重构现有 `internal/overlay` 及其 `local.paths` 相关模型、解析和输出。 +- 不引入新的外部依赖;使用 Go 标准库递归复制 source 工作目录。 +- 新 Worktree 会继承 source 的未提交状态和 ignored 内容,通常会在创建后处于 dirty 状态;source 后续变化不会自动同步到已复用的 Worktree。 diff --git a/openspec/changes/copy-source-working-tree/specs/cli-output-contract/spec.md b/openspec/changes/copy-source-working-tree/specs/cli-output-contract/spec.md new file mode 100644 index 0000000..f19a3f5 --- /dev/null +++ b/openspec/changes/copy-source-working-tree/specs/cli-output-contract/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: Render create action facts as JSON +When create runs in dry-run or execute mode, its data SHALL identify the resolved configuration and each repository's Worktree and complete source-copy action. A source-copy action SHALL expose its source, target, status, and available copied entry/byte totals; it MUST NOT expose the source `.git` metadata as a copy item. + +#### Scenario: Render complete-copy action facts +- **WHEN** create dry-run or execute plans a new Worktree from a source repository +- **THEN** JSON data contains a stable source-copy action associated with the repository and its source and target paths + +#### Scenario: Render copy failure +- **WHEN** index population or recursive source copying fails after Worktree registration +- **THEN** the result reports a structured diagnostic, a partial completion state, and the pending source-copy action without claiming the repository is complete diff --git a/openspec/changes/copy-source-working-tree/specs/complete-source-working-tree-copy/spec.md b/openspec/changes/copy-source-working-tree/specs/complete-source-working-tree-copy/spec.md new file mode 100644 index 0000000..92da260 --- /dev/null +++ b/openspec/changes/copy-source-working-tree/specs/complete-source-working-tree-copy/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: Copy the complete source working directory +When create provisions a missing Worktree, Taskflow SHALL copy every source working-directory entry beneath the configured source root into the new target, including tracked files with uncommitted changes, untracked files, and ignored files. All source `.git` entries — the root entry and any `.git` entry at a nested depth — SHALL be excluded, and the target Worktree's own Git metadata SHALL remain intact. Before copying, Taskflow SHALL populate the new Worktree's index from HEAD so that copied tracked modifications appear as normal unstaged working-tree changes. + +#### Scenario: Copy a complete source snapshot +- **WHEN** a valid task creates a missing Worktree from a source containing committed files, uncommitted tracked changes, untracked files, and ignored files +- **THEN** the target working-tree contents match the source contents for all entries except source `.git` metadata + +#### Scenario: Preserve the target Worktree metadata +- **WHEN** the source and target both contain a `.git` entry after Worktree registration +- **THEN** the source `.git` entry is not copied over the target `.git` entry and the target remains attached to its configured branch and Git common directory + +#### Scenario: Exclude nested Git metadata +- **WHEN** the source working directory contains a nested `.git` entry belonging to another checkout +- **THEN** that entry is not copied into the target and the nested working files themselves are copied + +#### Scenario: Show copied tracked changes as unstaged changes +- **WHEN** the source contains uncommitted tracked modifications and the copy completes +- **THEN** the target reports those files as unstaged modifications with no staged deletions, and reports an empty status when the source matches the configured base + +### Requirement: Copy only a newly created or pending Taskflow Worktree +Taskflow SHALL run the complete source copy only after it creates a missing target or when it repairs a Taskflow-owned target whose source-copy status is pending. A matching existing Worktree with a complete source-copy status SHALL be reused without copying, and a matching Worktree without Taskflow ownership SHALL never receive an implicit source copy. + +#### Scenario: Reuse a completed source copy +- **WHEN** create runs again after a Taskflow-created target has a complete source-copy status +- **THEN** create reports reuse and does not refresh the target from later source changes + +#### Scenario: Do not populate a matching manual Worktree +- **WHEN** a manually created Worktree matches the configured source, branch, and target +- **THEN** create reports reuse and leaves the target contents unchanged + +### Requirement: Retry a pending complete source copy +Taskflow SHALL persist a repository-level pending or complete source-copy status for each Taskflow-created target. A retry of a pending target SHALL rerun the complete source copy; when the pending target is absent, Taskflow SHALL register the missing Worktree before copying. Taskflow SHALL mark the status complete only after success and SHALL report a partial failure while the status remains pending. + +#### Scenario: Retry an interrupted copy +- **WHEN** Worktree registration succeeds but the source copy fails before all entries are copied, and create is run again after the external fault is fixed +- **THEN** create reuses the matching target, reruns the source copy, and marks the source-copy status complete only after the retry succeeds + +#### Scenario: Register a missing pending target before retrying the copy +- **WHEN** a pending source-copy status exists but the target directory is absent because registration itself failed +- **THEN** the retry registers the missing Worktree from the configured base and then reruns the complete source copy + +#### Scenario: Preserve completed-copy immutability +- **WHEN** a source file changes after the source-copy status is complete +- **THEN** a later create does not replace the corresponding target file diff --git a/openspec/changes/copy-source-working-tree/specs/e2e-command-flow/spec.md b/openspec/changes/copy-source-working-tree/specs/e2e-command-flow/spec.md new file mode 100644 index 0000000..596edd9 --- /dev/null +++ b/openspec/changes/copy-source-working-tree/specs/e2e-command-flow/spec.md @@ -0,0 +1,31 @@ +## MODIFIED Requirements + +### Requirement: Exercise the complete CLI task lifecycle +The test suite SHALL execute the reduced user-facing flow through the Cobra command surface and SHALL verify each command's result, configuration, complete source-copy behavior, and relevant Git side effects. + +#### Scenario: In-process create succeeds with complete source copies +- **WHEN** a test runs create --dry-run and create --execute against temporary valid repositories containing tracked modifications, untracked files, and ignored files +- **THEN** dry-run reports Worktree and source-copy actions without mutation, execute writes taskflow.yaml and ownership metadata, creates Worktrees, copies the complete source contents except all `.git` entries, and copied tracked modifications appear as unstaged changes + +#### Scenario: Dry-run preserves all managed state +- **WHEN** create dry-run is executed for a new or existing task +- **THEN** taskflow.yaml, ownership, lock artifacts, Worktrees, branches, source checkout, and target directories remain unchanged + +#### Scenario: Repeated execute is idempotent +- **WHEN** create --execute is run twice for the same task +- **THEN** the second command succeeds, reuses every matching Worktree and complete source copy, and creates no duplicate branch, directory, or copy + +#### Scenario: Pending source copy retries safely +- **WHEN** source copying is interrupted after Worktree registration and the fault is removed +- **THEN** the first command reports partial completion and the retry repairs the pending source copy without invoking git worktree add again + +### Requirement: Verify executable and rendering boundaries +The test suite SHALL invoke a built taskflow binary in addition to in-process Cobra commands and SHALL verify stable JSON/text output and process exit codes for create, delete, and complete source-copy actions. + +#### Scenario: Subprocess reports a complete-copy action +- **WHEN** the built binary runs create dry-run and execute against a temporary repository containing all categories of source files +- **THEN** process exit codes, JSON envelopes, text reports, target contents, and target Git metadata match the complete-copy contract + +#### Scenario: Subprocess reports a copy failure +- **WHEN** the built binary encounters an unsupported source entry or filesystem copy failure +- **THEN** it exits with the documented partial or execution code and emits a parseable structured diagnostic without claiming complete source copying diff --git a/openspec/changes/copy-source-working-tree/specs/environment-preflight/spec.md b/openspec/changes/copy-source-working-tree/specs/environment-preflight/spec.md new file mode 100644 index 0000000..bd5e293 --- /dev/null +++ b/openspec/changes/copy-source-working-tree/specs/environment-preflight/spec.md @@ -0,0 +1,8 @@ +## MODIFIED Requirements + +### Requirement: Preflight create reconciliation +Create MUST inspect every configured source, base ref, branch occupancy, target path, Worktree identity, and complete-copy target boundary before taskflow.yaml or Git mutation. It MUST verify that a missing target can be registered as a Worktree and that source copying will exclude all source `.git` entries while the configured source and target paths remain disjoint. + +#### Scenario: Preflight all repositories +- **WHEN** any configured repository is not ready or its source-copy boundary cannot be established +- **THEN** create returns a repository diagnostic before changing configuration, registering a Worktree, or copying target files diff --git a/openspec/changes/copy-source-working-tree/specs/resumable-action-execution/spec.md b/openspec/changes/copy-source-working-tree/specs/resumable-action-execution/spec.md new file mode 100644 index 0000000..202fea2 --- /dev/null +++ b/openspec/changes/copy-source-working-tree/specs/resumable-action-execution/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: Recover from live Git and source-copy facts +Create SHALL recover from an interrupted Worktree or source-copy operation by inspecting current Git Worktrees and the repository-level source-copy status, reusing matching targets, repairing a pending source copy, and creating only missing Worktrees without a general action journal or per-file snapshot. + +#### Scenario: Retry after Worktree registration and copy interruption +- **WHEN** a Worktree is registered but complete source copying fails and create is run again after the external fault is fixed +- **THEN** create reuses the matching Worktree, reruns the complete source copy, and marks the source-copy status complete only after success + +#### Scenario: Retry after an earlier repository succeeds +- **WHEN** a multi-repository create fails after one repository has a complete source copy +- **THEN** a later create reuses the completed repository and repairs or creates only the remaining repositories diff --git a/openspec/changes/copy-source-working-tree/specs/task-workspace-initialization/spec.md b/openspec/changes/copy-source-working-tree/specs/task-workspace-initialization/spec.md new file mode 100644 index 0000000..06e11ce --- /dev/null +++ b/openspec/changes/copy-source-working-tree/specs/task-workspace-initialization/spec.md @@ -0,0 +1,46 @@ +## MODIFIED Requirements + +### Requirement: Initialize a task workspace from explicit local repositories +The CLI SHALL provide `taskflow create ` with one or more unique `--repo =` values only for a new task without taskflow.yaml. Repository order MUST be preserved and the first repository MUST be the primary launch Worktree. Execute mode MUST persist taskflow.yaml and an ownership manifest for Worktrees it creates, record a pending source-copy status, and create each Worktree from the configured base followed by a complete source working-directory copy. It MUST NOT create state, inventory, validation reports, branches, commits, or Worktrees until the create preflight succeeds. Existing task configuration MUST be edited directly and reconciled through create without repository arguments. + +#### Scenario: Create three valid repositories +- **WHEN** a user creates a new task with three existing local Git repositories in a supplied order +- **THEN** dry-run reports each Worktree and complete source-copy action without mutation, and execute writes taskflow.yaml, creates the Worktrees, copies each source working directory, and records ownership + +#### Scenario: Reject a non-Git repository +- **WHEN** an explicit repository path is not an existing non-bare Git Worktree +- **THEN** create returns a configuration or environment error and does not create a taskflow.yaml or partial Worktree + +#### Scenario: Reject repository arguments on an existing task +- **WHEN** taskflow.yaml already exists and a user invokes create with --repo +- **THEN** create returns CONFIG_EDIT_REQUIRED without changing taskflow.yaml or Git state and directs the user to edit the configuration directly + +#### Scenario: Reject removed lifecycle flags +- **WHEN** a user invokes the removed init or start command +- **THEN** the CLI reports that the command is unavailable and instructs the user to use create + +### Requirement: Initialization is idempotent and non-overwriting +Create MUST treat an existing taskflow.yaml as the complete desired configuration, MUST not rewrite it during reconciliation, and MUST reject repository arguments for that existing task with CONFIG_EDIT_REQUIRED. Direct edits to taskflow.yaml MUST be validated as a complete configuration before any Git mutation. Removing a repository declaration MUST NOT delete its existing Worktree. A matching Worktree with a complete source copy MUST NOT be refreshed from the source. + +#### Scenario: Repeat equivalent create +- **WHEN** a user repeats create without repository arguments for the same normalized task configuration +- **THEN** the command succeeds, reuses existing Worktrees and complete source copies, and does not rewrite taskflow.yaml + +#### Scenario: Detect a conflicting declaration +- **WHEN** a directly edited taskflow.yaml changes the source, branch, or Worktree of an existing repository and the live target conflicts +- **THEN** create returns a configuration or Worktree conflict and preserves taskflow.yaml and existing Git state + +#### Scenario: Preserve an unlisted Worktree +- **WHEN** a repository declaration is removed from taskflow.yaml and create is rerun +- **THEN** create does not delete, move, reset, or overwrite the previously existing Worktree + +### Requirement: Initialization serializes mutations and persists atomically +Execute-mode create MUST hold a task-scoped exclusive lock while creating an initial taskflow.yaml, registering Worktrees, or copying source directories, MUST write an initial taskflow.yaml through an atomic same-directory replacement, and MUST complete all repository preflight before the first configuration, Git, or target-file mutation. A lock conflict MUST leave taskflow.yaml and Worktrees unchanged. + +#### Scenario: Concurrent create attempts +- **WHEN** another process holds the task lock +- **THEN** the competing command exits with the lock-conflict result and does not write taskflow.yaml or mutate a Worktree + +#### Scenario: Git or source copy fails after configuration persistence +- **WHEN** an initial taskflow.yaml is persisted and a later Worktree registration or source copy fails +- **THEN** the task retains the complete desired configuration and pending ownership metadata, reports partial completion, and a later create without --repo can retry from live Git and filesystem facts diff --git a/openspec/changes/copy-source-working-tree/specs/taskflow-multirepo-skill/spec.md b/openspec/changes/copy-source-working-tree/specs/taskflow-multirepo-skill/spec.md new file mode 100644 index 0000000..9369c93 --- /dev/null +++ b/openspec/changes/copy-source-working-tree/specs/taskflow-multirepo-skill/spec.md @@ -0,0 +1,19 @@ +## MODIFIED Requirements + +### Requirement: Guide safe multi-repository orchestration +The skill SHALL instruct an agent to locate the task, inspect taskflow.yaml and ownership.json when cleanup is requested, use repeated create --repo only to bootstrap a task that has no taskflow.yaml, edit taskflow.yaml directly when the repository topology changes, use create/delete dry-run before execute, review every complete source-copy action, and compose native tool commands only after every configured Worktree and source copy is structurally ready. It MUST NOT require a local file list or shell copy replacement. + +#### Scenario: Prepare multiple repositories with complete source copies +- **WHEN** an agent receives a Taskflow task with multiple repositories +- **THEN** it reports repository order and source-copy scope, obtains explicit execute approval, runs create, and reports native tool commands only after live Worktree and source-copy checks succeed + +#### Scenario: Add a repository to an existing task +- **WHEN** an agent needs another repository after taskflow.yaml already exists +- **THEN** it edits the desired repository list in taskflow.yaml, runs create --dry-run without --repo, reviews the complete source-copy action, and only runs create --execute after approval + +### Requirement: Keep deterministic actions in the CLI +The skill MUST instruct agents to use taskflow for workspace, ownership-checked cleanup, Worktree, lock, source-copy, and native tool command composition mutations and MUST prohibit shell-composed replacements, implicit cleanup/push/PR actions, repository append arguments on existing tasks, local overlay flags, and nested Worktree launch flags. Delete MUST require explicit execute mode and MUST refuse resources without matching ownership records. Arguments explicitly supplied after -- MUST be passed through without Taskflow policy interpretation. + +#### Scenario: User requests execution +- **WHEN** a user approves a prepared create plan containing a complete source-copy action +- **THEN** the agent invokes create execute, reports its machine-readable result, and recommends the composed native commands only when every Worktree and source copy is ready diff --git a/openspec/changes/copy-source-working-tree/specs/worktree-reconciliation/spec.md b/openspec/changes/copy-source-working-tree/specs/worktree-reconciliation/spec.md new file mode 100644 index 0000000..71389fc --- /dev/null +++ b/openspec/changes/copy-source-working-tree/specs/worktree-reconciliation/spec.md @@ -0,0 +1,27 @@ +## MODIFIED Requirements + +### Requirement: Reconcile worktrees idempotently and safely +Create SHALL classify each repository as `create` when its configured target is absent, `copy` when a new target must receive the source working-tree snapshot, `repair` when a matching Taskflow-owned target has a pending source-copy status, or `reuse` when the Worktree and source copy are complete. It MUST reject an existing mismatched target or occupied branch before mutation and MUST never delete, move, reset, or overwrite an existing user path. + +#### Scenario: Reuse a matching completed Worktree +- **WHEN** create is run after a matching Worktree and complete source copy already exist +- **THEN** create succeeds with Worktree and source-copy reuse results and does not invoke `git worktree add` or copy files + +#### Scenario: Repair a pending source copy +- **WHEN** a matching Taskflow-owned Worktree has a pending source-copy status +- **THEN** create reports repair, does not invoke `git worktree add` again, and reruns the complete source copy + +#### Scenario: Reuse a matching manual Worktree +- **WHEN** a user-created Worktree structurally matches the configured source, target, and branch but has no Taskflow ownership entry +- **THEN** create reports Worktree reuse and does not copy source files into it + +#### Scenario: Reject a mismatched target +- **WHEN** the configured target exists but is not the expected Worktree from the expected source and branch +- **THEN** create returns a deterministic Worktree conflict before writing taskflow.yaml or changing Git state + +### Requirement: Execute only after complete preflight +Execute-mode create MUST acquire the task lock and all required source/branch locks, inspect every source, base ref, branch occupancy, target path, target identity, and source-copy target boundary before writing an initial taskflow.yaml or invoking a mutating Git command. A source or branch lock MUST be acquired in deterministic order and released when create returns. + +#### Scenario: Preflight blocks before mutation +- **WHEN** any repository has an unavailable base, invalid source, branch conflict, mismatched target, or unsafe source-copy boundary +- **THEN** create returns the specific diagnostic and leaves taskflow.yaml, all Git Worktrees, and all target files unchanged diff --git a/openspec/changes/copy-source-working-tree/specs/worktree-start/spec.md b/openspec/changes/copy-source-working-tree/specs/worktree-start/spec.md new file mode 100644 index 0000000..c7bc223 --- /dev/null +++ b/openspec/changes/copy-source-working-tree/specs/worktree-start/spec.md @@ -0,0 +1,26 @@ +## MODIFIED Requirements + +### Requirement: Plan a complete start operation +The CLI SHALL provide `taskflow create --dry-run` and list every configured repository's resolved target, Worktree action, and source-copy action in stable declaration order. Dry-run MUST not modify files, lock directories, taskflow.yaml, worktrees, branches, or Git state. + +#### Scenario: Dry-run three repositories +- **WHEN** a valid task has three repositories and the user requests create dry-run +- **THEN** the result lists each repository's Worktree and source-copy plan deterministically and leaves the task directory, source checkout, and Git state unchanged + +### Requirement: Create safe managed worktrees +Execute mode MUST acquire the task lock, acquire all required source-branch locks, and complete a read-only preflight for every repository before writing taskflow.yaml or invoking a mutating command. Preflight MUST verify source identity, locally resolvable base, branch occupancy, target containment, and target identity. Execute mode MUST create each missing Worktree with the configured branch and base using an argument-array Git invocation, register it without a base checkout, populate its index from HEAD, copy the complete source working directory excluding all `.git` entries, and never delete or overwrite a mismatched pre-existing target. + +#### Scenario: Create and copy a missing Worktree +- **WHEN** every repository passes preflight, a target path is absent, and its branch is available +- **THEN** create writes the desired configuration, registers the Worktree from the configured base, and copies the source working directory into the target while preserving the target Git metadata + +#### Scenario: Reject a mismatched target +- **WHEN** the target path exists but is not the expected Worktree +- **THEN** create returns conflict code 5 and preserves taskflow.yaml and the existing directory byte-for-byte + +### Requirement: Start is idempotent +Create MUST derive Worktree completion from live Git facts and the source-copy status. An existing Worktree is complete only when its canonical source/common Git directory, configured branch, configured target path, and source-copy status match. A matching Worktree with a complete copy MUST be reused without another `git worktree add` invocation or source copy. + +#### Scenario: Repeat create +- **WHEN** create is run again after successful Worktree creation and source copying +- **THEN** it reports every Worktree and source-copy action as reused and creates no duplicate branch, directory, or copy diff --git a/openspec/changes/copy-source-working-tree/tasks.md b/openspec/changes/copy-source-working-tree/tasks.md new file mode 100644 index 0000000..add91e5 --- /dev/null +++ b/openspec/changes/copy-source-working-tree/tasks.md @@ -0,0 +1,25 @@ +## 1. Remove the explicit overlay contract + +- [x] 1.1 Remove the `local.paths` domain/config model, repeatable `--local` bootstrap option, overlay-specific validation, and related CLI help while preserving existing `taskflow.yaml` repository configuration behavior. +- [x] 1.2 Remove the per-file overlay discovery, snapshot, hash, plan, report, and ownership code, and retire the superseded `worktree-local-file-overlay` change artifacts from the delivery branch. + +## 2. Register a target and copy the source working tree + +- [x] 2.1 Extend the Git Worktree creation path to support `git worktree add --no-checkout` while preserving configured base, branch, source common directory, and existing branch-tracking behavior. +- [x] 2.2 Implement a standard-library recursive source-tree copier that copies all source working-directory entries, preserves supported directories/files/symlinks and modes, excludes `.git` entries at the source root and at any nested depth, rejects source and target paths that contain one another, and fails with a structured diagnostic for unsupported entries or symlink creation failure. +- [x] 2.3 Populate a new target's index from HEAD with a mixed reset (or equivalent) immediately after registration, then run the copier so the target contains the source working-tree snapshot with tracked modifications appearing as normal unstaged changes; treat index-population failure as a retryable pending-copy failure. +- [x] 2.4 Add one repository-level pending/complete source-copy marker to the existing ownership manifest and persist it atomically before Worktree mutation and after successful copying. + +## 3. Reconcile, retry, and report the simplified lifecycle + +- [x] 3.1 Update create planning to report a source-copy action for new targets and a repair action for pending owned targets, while keeping dry-run mutation-free and matching manual Worktrees untouched. +- [x] 3.2 Update reconciliation to reuse completed targets without copying, retry pending owned targets by re-copying registered ones and registering missing ones before copying, and retain existing mismatched-target, lock, and branch-conflict behavior. +- [x] 3.3 Update text/JSON output and stable diagnostics for source-copy success, partial failure, index-population failure, unsupported entries, and pending repair, including copied entry and byte totals when available. +- [x] 3.4 Preserve deletion ownership and dirty-worktree safety gates for targets that contain a complete source snapshot; ensure delete never copies, synchronizes, or deletes source files independently. + +## 4. Documentation and verification + +- [x] 4.1 Update README, bundled Taskflow Skill, command help, and OpenSpec-facing guidance to describe complete source working-tree copying, `.git` exclusion, ignored-file inclusion, reuse, and pending retry behavior. +- [x] 4.2 Add unit tests for recursive copying, tracked modifications, untracked files, ignored files, nested paths, metadata preservation, `.git` exclusion at the root and at nested depths, symlinks pointing outside the source, symlink/special-entry handling, and mutual source/target containment errors. +- [x] 4.3 Add service and compiled-binary tests for dry-run immutability, `--no-checkout` creation with index population, post-create status semantics (tracked modifications as unstaged changes, no staged deletions, clean status when the source matches the base), complete target contents, manual Worktree reuse, pending-copy retry including a missing pending target, completed-copy reuse, output parity, and deletion safety. +- [x] 4.4 Run `go test ./...`, `go vet ./...`, `go test -race ./...`, `git diff --check`, strict OpenSpec validation, and the configured cross-platform build/test checks; resolve all failures before implementation delivery. diff --git a/skills/skill_content_test.go b/skills/skill_content_test.go index 9d3e3b3..911dd90 100644 --- a/skills/skill_content_test.go +++ b/skills/skill_content_test.go @@ -14,6 +14,11 @@ func TestTaskflowSkillGuidesCreateNativeToolsAndDelete(t *testing.T) { for _, required := range []string{ "create ", "create --execute", + "source-copy action", + "完整工作目录", + "ignored 文件", + ".git", + "pending", "delete ", "ownership.json", "OWNERSHIP_NOT_FOUND", @@ -22,6 +27,9 @@ func TestTaskflowSkillGuidesCreateNativeToolsAndDelete(t *testing.T) { "SOURCE_BRANCH_LOCKED", "WORKTREE_MISMATCH", "CREATE_WORKTREE_FAILED", + "SOURCE_INDEX_RESET_FAILED", + "SOURCE_COPY_BOUNDARY", + "SOURCE_COPY_UNSUPPORTED_ENTRY", "CONFIG_EDIT_REQUIRED", "直接编辑 taskflow.yaml", "create --dry-run", @@ -59,6 +67,10 @@ func TestTaskflowSkillGuidesCreateNativeToolsAndDelete(t *testing.T) { "open ", "TOOL_NOT_FOUND", "TOOL_EXITED", + "--local", + "local.paths", + "overlay", + "OVERLAY_", } { if strings.Contains(text, forbidden) { t.Errorf("skill contains retired guidance %q", forbidden) diff --git a/skills/taskflow/SKILL.md b/skills/taskflow/SKILL.md index 9df78cc..67f0469 100644 --- a/skills/taskflow/SKILL.md +++ b/skills/taskflow/SKILL.md @@ -5,7 +5,7 @@ description: 用 Taskflow 安全创建和清理多 Git 仓库 worktree 工作区 # Taskflow 工作区向导 -Taskflow 负责两件事:根据声明创建或复用安全的 Git worktree,以及清理有明确 ownership 记录的任务资源。对于 AI CLI,先检查工作区,再生成由用户执行的原生 Codex 或 Claude 命令。不要用手写 Git 或文件系统命令替代这些流程。 +Taskflow 负责三件事:根据声明创建或复用安全的 Git worktree,为新建的 Taskflow worktree 复制 source 的完整工作目录,以及清理有明确 ownership 记录的任务资源。对于 AI CLI,先检查工作区,再生成由用户执行的原生 Codex 或 Claude 命令。不要用手写 Git 或文件系统命令、shell copy 或覆盖操作替代这些流程。 ## 定位任务 @@ -33,7 +33,9 @@ taskflow --json --tasks-root create \ --execute ``` -执行会先检查所有 source、base ref、branch 占用、target path 和 worktree identity,再写 taskflow.yaml、ownership.json 或运行 `git worktree add`。它不会删除、移动、reset 或覆盖现有路径;复用的手工 worktree不会获得 ownership。 +创建新 worktree 时,Taskflow 会用 `git worktree add --no-checkout` 注册目标、把 index 重建为 base 内容,然后将 source 工作目录完整复制进目标,包括 tracked 文件的未提交修改、untracked 文件和 ignored 文件;`.git` 元数据(source 根目录及任意嵌套层级)不会复制,目标保留自己的 Git 元数据。复制使新 worktree 通常处于 dirty 状态,这是预期行为。source 中未忽略的敏感或超大内容也会进入快照,dry-run 阶段必须向用户明确说明。执行会先检查所有 source、base ref、branch 占用、target path、worktree identity 和 source/target 边界,再写 taskflow.yaml、ownership.json 或运行 Git 命令。它不会删除、移动、reset 或覆盖现有路径;复用的手工 worktree 不会获得 ownership,也不会被注入 source 快照。 + +dry-run 必须逐项审阅 worktree action 和 source-copy action,确认 source、target 与复制范围。`.git`、source 与 target 互相包含、路径逃逸和特殊文件都应在 execute 前修复,而不是通过 shell copy 绕过。 新声明的仓库默认从 source 的 `origin/HEAD` 解析本地远程默认分支作为 base,并使用 `feature/` 作为分支;创建新分支时只使用远程基线的提交作为起点,不建立 upstream 关联。例如 `origin/HEAD` 指向 `origin/main` 时,配置中的 base 是 `origin/main`,但 worktree 分支不会默认关联 `origin/main`;`origin/master` 等其他远程默认分支同理。Taskflow 不会隐式 fetch。若 `origin/HEAD` 缺失或目标引用不可用,先在 source 仓库修复远程引用后再重试。已有 taskflow.yaml 中明确配置的 `base` 和 `branch` 不会被覆盖。 @@ -56,7 +58,7 @@ taskflow --json --tasks-root create --dry-run taskflow --json --tasks-root create --execute ``` -删除配置中的仓库不会删除已有 worktree;如果修改后的 source、branch、base 或 target 与实时 Git 不匹配,先修复配置或现场,再重试。如果之前只创建了部分 worktree,再次 create 会复用匹配的 worktree,只补齐缺失项。只有 Taskflow 创建并登记的 worktree 才能由 delete 清理。 +配置中的仓库或快照不会被配置删除影响:删除仓库声明不会删除已有 worktree;如果修改后的 source、branch、base、target 或 ownership 与实时 Git/文件系统不匹配,先修复配置或现场,再重试。如果之前只创建了部分 worktree 或复制中断,再次 create 会复用匹配的 worktree;pending 的 source-copy 会对目标重新执行完整复制(目标目录缺失时先重新注册再复制)。完成的快照是不可刷新的创建时快照。pending 期间不要在目标中工作或启动工具;只有 Taskflow 创建并登记的 worktree 才能由 delete 清理。 ## 删除任务 @@ -72,7 +74,7 @@ taskflow --json --tasks-root delete --dry-run taskflow --json --tasks-root delete --execute ``` -delete 要求 ownership manifest 与 taskflow.yaml 完全匹配,并会在任务锁、source-branch 锁和完整 preflight 后删除登记的 worktree、本地任务分支和任务目录。没有 ownership manifest、存在未登记文件、worktree dirty、target/source/branch 不匹配或目标是默认分支时,必须停止并报告,不要改用 shell 删除命令。只有用户明确允许丢弃脏文件和未合并分支时,才使用 `--force --execute`。 +delete 要求 ownership manifest 与 taskflow.yaml 完全匹配,并会在任务锁、source-branch 锁和完整 preflight 后删除登记的 worktree、本地任务分支和任务目录。复制的快照通常使目标 dirty(tracked 修改和 untracked 文件都是普通工作区变更;仅含 ignored 文件的快照按 Git 语义视为干净);没有 ownership manifest、存在未登记文件、worktree dirty、target/source/branch 不匹配或目标是默认分支时,必须停止并报告,不要改用 shell 删除或复制命令。只有用户明确允许丢弃脏文件和未合并分支时,才使用 `--force --execute`。 ## 生成 AI CLI 命令 @@ -86,7 +88,7 @@ delete 要求 ownership manifest 与 taskflow.yaml 完全匹配,并会在任 taskflow --json --tasks-root create --dry-run ``` -只有这次输出中每个 repository 的 action 都是 `reuse` 时才继续。若有 `create` 或冲突,先向用户报告问题,不生成 AI CLI 命令。已有任务也必须先运行这个不带 `--repo` 的 dry-run。匹配但 dirty 的 worktree 仍然可以复用。 +只有这次输出中每个 worktree action 都是 `reuse` 且每个 source-copy action 都是 `reuse` 时才继续。若有 `create`、`copy`、`repair` 或冲突,先向用户报告问题,不生成 AI CLI 命令。已有任务也必须先运行这个不带 bootstrap 参数的 dry-run。匹配但 dirty 的 worktree 仍然可以复用。 然后读取 `taskflow.yaml`,使用绝对路径组合命令:第一个 repository 的 worktree 是 cwd,后续 repository worktree 和任务根目录都作为 `--add-dir` 参数。先识别用户要执行命令的 shell;不能判断时先询问,不要假定所有用户都使用 Bash。每个路径都必须按目标 shell 进行引用和转义,不能把原始路径直接插入命令: @@ -126,13 +128,16 @@ Codex 使用相同的 cwd、路径引用和 `--add-dir` 参数,只需将工具 优先使用 JSON 输出,读取 `code`、`repo` 和 `message`,再采取最小修复: - `INVALID_CONFIGURATION`、`INVALID_TASK_ID`:检查 taskflow.yaml、任务 ID 和路径,不覆盖现有配置。 -- `CONFIG_EDIT_REQUIRED`:已有 taskflow.yaml,不要使用 `--repo`;直接编辑配置后重新运行不带 `--repo` 的 create。 +- `CONFIG_EDIT_REQUIRED`:已有 taskflow.yaml,不要使用 `--repo`;直接编辑配置后重新运行不带 bootstrap 参数的 create。 - `BASE_REF_NOT_FOUND`:先在 source 仓库准备本地 base ref,再重试 create;Taskflow 不隐式 fetch。 - `WORKTREE_MISMATCH`、`WORKTREE_INVALID`、`BRANCH_OCCUPIED`:检查 `git worktree list --porcelain`,不要删除或强行覆盖冲突目标。 - `OWNERSHIP_NOT_FOUND`、`OWNERSHIP_MISMATCH`:该任务包含手工管理或配置已变化的 worktree,Taskflow 不自动删除;先人工确认资源归属。 - `WORKTREE_DIRTY`、`PROTECTED_BRANCH`、`DEFAULT_BRANCH_UNKNOWN`、`DELETE_DIRECTORY_UNSAFE`:保留现场并修复冲突;不要直接使用 `--force`,除非用户明确授权。 - `SOURCE_BRANCH_LOCKED`、`TASK_LOCKED`:报告锁冲突,等待占用操作完成后重试,不删除锁文件。 -- `CREATE_WORKTREE_FAILED`:保留当前 taskflow.yaml 和已创建 worktree,修复外部原因后重试 create。 +- `CREATE_WORKTREE_FAILED`、`SOURCE_INDEX_RESET_FAILED`:保留当前 taskflow.yaml、ownership.json 和已创建 worktree,修复外部原因后重试 create。 +- `SOURCE_COPY_BOUNDARY`:source 与 target 互相包含;调整 tasks-root 或 taskflow.yaml 中的 worktree 路径,使两者互不嵌套。 +- `SOURCE_COPY_UNSUPPORTED_ENTRY`:source 包含 fifo、socket 等不支持的条目;清理该条目后重试。 +- `SOURCE_COPY_SYMLINK_FAILED`、`SOURCE_COPY_FAILED`:保留 pending 目标,修复外部原因后重试;不要用 shell copy 替代或删除目标。 - 如果 `create --dry-run` 没有让每个 repository 都报告 `reuse`:先修复 source、base、branch、target 或 worktree identity 问题,再重新生成命令。 每次命令结束时只需给出:结果、是否发生修改、下一条安全命令或需要用户确认的事项。