specs/030-git-worktree-action.md
Rama main · réplica de lectura del repositorio original.
# Spec: Git Worktree Action
## Status
Implemented.
This spec defines conformance behavior for the built-in `git.worktree.add` and
`git.worktree.remove` actions.
## Scope
This spec defines linked-worktree management for a local Git repository:
- `git.worktree.add`, which ensures a linked worktree exists for an explicit or
Dagu-generated branch.
- `git.worktree.remove`, which removes a linked worktree.
This spec covers:
- accepted `with` fields for both operations
- repository detection from within the step working directory's repository
- path resolution and the default worktree path
- deterministic branch generation when `branch` is omitted
- explicit branch creation from a base revision
- idempotent add and remove behavior
- conflict detection
- repository-scoped mutation serialization
- the action output contract
- validation and runtime errors
This spec does not define:
- the `git.checkout` operation
- cloning, fetching, pushing, or any network operation
- repository authentication fields
- copying untracked files into a new worktree
- worktree locking, moving, repairing, or pruning
- mutations performed outside Dagu while an action is running
## Goal
Workflow authors can give each branch an isolated working directory inside a
DAG, and later remove it, without shelling out to external worktree helper
tools.
Both operations must be safe to re-run: a repeated add reuses the existing
worktree, and a repeated remove succeeds without one.
Workflow authors select the repository through the standard step working
directory and do not repeat that path in `with`.
Workflow authors control the worktree lifecycle explicitly with
`git.worktree.remove`.
## Related Specs
- YAML schema: [Spec 002: YAML Schema](002-yaml-schema.md)
- Value resolution: [Spec 003: Value Resolution and Field Evaluation](003-value-resolution.md)
- Step output references: [Spec 007: Value Resolution Steps](007-value-resolution-steps.md)
- Step identity: [Spec 009: Step Reference](009-step-reference.md)
- Step outputs: [Spec 012: Step Outputs](012-step-outputs.md)
- Step run: [Spec 013: Step Run](013-step-run.md)
- Built-in run context: [Spec 017: Built-in Run Context](017-built-in-run-context.md)
- Cloning and checkout: [Spec 055: Git Checkout Action](055-git-checkout.md)
## Terms
The primary working tree is the working tree created with the repository
itself.
A linked worktree is an additional working tree registered to the repository
with its own checked-out branch.
The repository root is the top-level directory of the non-bare working tree
that contains the resolved step working directory. For a bare repository, the
repository root is the bare repository directory.
The common Git directory is the repository metadata directory shared by the
primary working tree and all linked worktrees.
The worktree path is the resolved directory of the linked worktree named by
one operation.
The default worktree path is the worktree path Dagu derives when `path` is
omitted.
A registration is stale when a linked worktree is registered and its directory
does not exist.
The selected branch is the explicit `branch` input when it is present and the
Dagu-generated branch when `branch` is omitted.
## Behavior
### Operation Selection
Rules:
- `action: git.worktree.add` selects the add operation.
- `action: git.worktree.remove` selects the remove operation.
- Any other `git.worktree.*` action name is a validation error.
### Field Shape
`with` fields for `git.worktree.add`:
| Field | Required | Meaning |
| --- | --- | --- |
| `branch` | No | Branch checked out in the linked worktree. When omitted, Dagu generates a branch for the current DAG run and step. |
| `path` | No | Worktree path. Defaults to the default worktree path. |
| `create_branch` | No | Permit creation of an explicitly named `branch` when it does not exist. Defaults to `false`. A generated branch is always eligible for creation. |
| `base` | No | Base revision used when the selected branch is created. Defaults to the detected repository `HEAD`. |
`with` fields for `git.worktree.remove`:
| Field | Required | Meaning |
| --- | --- | --- |
| `branch` | One of `branch`, `path` | Branch whose registered linked worktree is removed. |
| `path` | One of `branch`, `path` | Worktree path to remove. |
| `force` | No | Remove even when the worktree has local changes. Defaults to `false`. |
| `delete_branch` | No | Also delete the local branch when it is fully merged into the detected repository `HEAD`. Requires `branch`. Defaults to `false`. |
| `force_delete_branch` | No | Permit deletion of an unmerged local branch. Requires `delete_branch: true`. Defaults to `false`. |
Rules:
- `branch`, `path`, and `base` must be non-empty strings when present.
- `create_branch`, `force`, `delete_branch`, and `force_delete_branch` must be
booleans when present.
- A `with` field not listed for the selected operation is a validation error.
- When `branch` is present, `base` requires `create_branch: true`.
- When `branch` is omitted, `base` is allowed without `create_branch: true`.
- `git.worktree.remove` accepts `branch`, `path`, or both.
- `delete_branch: true` requires `branch`.
- `force_delete_branch: true` requires `delete_branch: true`.
- When `git.worktree.remove` receives both `branch` and `path` and either
resolves to a registered linked worktree, the registered worktree for
`branch` must be the one at the resolved `path`.
- When `git.worktree.remove` receives both `branch` and `path` and neither
resolves to a registered linked worktree, the remove operation follows the
no-target rules.
### Field Evaluation
Rules:
- The step `working_dir` and the reference-capable string fields `branch`,
`path`, and `base` are value-resolved at step start according to Spec 003.
- Supported Dagu references such as `${params.name}`, `${env.NAME}`, and step
output references may be used in those string-valued fields when their
namespace is available.
- Value resolution completes before repository discovery, path resolution,
branch lookup, or base revision lookup.
- Non-empty string requirements apply to the resolved runtime values as well
as literal values that can be checked during workflow validation.
- `create_branch`, `force`, `delete_branch`, and `force_delete_branch` are YAML
booleans and are not string-valued reference fields. A value such as
`create_branch: "${params.create_branch}"` is a string and is invalid.
### Branch Selection
Rules:
- When `branch` is present, its resolved value is the selected branch.
- When `branch` is omitted, Dagu generates the selected branch from the current
DAG run identity and the current executable step identity.
- A generated branch name starts with `dagu/` and is a valid local Git branch
name.
- Generation is deterministic for the same DAG run and executable step. Step
retries in the same DAG run select the same branch.
- Different executable steps in one DAG run, and executions in different DAG
runs, select different generated branches.
- Runtime step identity components that are not valid in a Git branch name are
encoded or replaced. The exact encoding and branch suffix are implementation
details.
- A generated branch is created when it does not exist; `create_branch: true`
is not required.
- `create_branch` does not disable creation of a generated branch. Its value
controls only an explicitly supplied branch.
- If a generated branch already exists after an interrupted or partially
completed attempt, the action treats it as an existing selected branch. This
permits a retry to finish creating or reuse the worktree.
- The selected branch name is published as the `branch` output so later steps
do not need to reconstruct a generated name.
- A later remove operation can receive the published `branch` and `path`
outputs to remove the generated worktree and, when requested, its branch.
### Path Resolution
Rules:
- The resolved step working directory may be the repository root or any
directory beneath it.
- The nearest containing non-bare Git working tree is selected. Repository
discovery must not cross a filesystem boundary that Git itself would not
cross.
- A primary working tree with a `.git` directory and a linked worktree with a
`.git` file are both valid discovery roots.
- Relative `path` values resolve from the detected repository root, not from
the nested step working directory.
- Resolved paths are cleaned before use.
- The default worktree path is `<repository root>.worktrees/<selected branch>`.
- `/` separators in the selected branch are preserved as directory separators
in the default worktree path.
- Branch names are not flattened: `feature/auth` and `feature-auth` resolve to
different default worktree paths.
- A bare repository is a valid step working directory for both operations.
- A bare repository has no primary working tree; rules that reference the
primary working tree are inapplicable to it.
- Linked worktrees are identified only through the repository's worktree
registration metadata.
Example: with step working directory `/work/repo/services/api` and
`branch: feature/auth`, the repository root is `/work/repo` and the default
worktree path is `/work/repo.worktrees/feature/auth`.
### Add Operation
Rules:
- In this section, `branch` means the selected branch.
- The action validates the branch name, resolves `base` when needed, and checks
the requested path and existing registrations before creating a branch or
worktree.
- If the repository has a registered linked worktree for `branch` at the
worktree path and the registration is not stale, the add operation succeeds
without changing the worktree.
- Reuse must not discard uncommitted changes in the existing worktree.
- Reuse must not move the existing worktree's `HEAD`.
- A stale registration for `branch` at the worktree path is a runtime error
for the add operation.
- If no linked worktree is registered for `branch` at the worktree path, the
add operation registers a linked worktree at the worktree path with `branch`
checked out.
- If `branch` exists, the new worktree checks out the existing branch without
moving it.
- If `branch` does not exist and `create_branch` is `false`, the operation
fails without creating a branch or worktree when `branch` was explicitly
supplied.
- If `branch` does not exist and `create_branch` is `true`, the add operation
creates it at the commit resolved from `base`.
- If `branch` was generated and does not exist, the add operation creates it at
the commit resolved from `base`.
- Whenever a branch is created and `base` is omitted, it is created at the
detected repository `HEAD` commit.
- `base` resolves in this order: commit hash, `refs/heads/<base>`,
`refs/remotes/origin/<base>`, `refs/tags/<base>`.
- `base` is ignored when `branch` already exists.
- The add operation must not fetch, push, or contact any remote.
### Remove Operation
Rules:
- The target worktree is located by `path` when `path` is present, otherwise
by the registered linked worktree of `branch`.
- Before changing the repository, the action resolves both selectors and
checks path conflicts, dirty state, primary-worktree protection, branch
checkout conflicts, and branch deletion eligibility.
- A preflight failure must leave the worktree, its registration, and the branch
unchanged.
- If the target worktree is registered, the remove operation removes its
directory and unregisters it.
- If the target registration is stale, the remove operation unregisters it,
reports `worktree_removed` `true`, and does not check dirty state.
- If no target worktree is registered, the remove operation succeeds without
removing anything.
- A worktree with uncommitted changes is removed only when `force` is `true`.
- The remove operation must refuse to remove the primary working tree.
- With `delete_branch: true`, the local branch is deleted after the worktree
is removed.
- With `delete_branch: true`, the branch must be fully merged into the detected
repository `HEAD` unless `force_delete_branch` is `true`.
- `force` does not permit deletion of an unmerged branch.
- With `force_delete_branch: true`, the branch is deleted even when it is not
merged.
- `delete_branch` must not delete a branch that is checked out in another
worktree.
- With `delete_branch: true` and no registered worktree for the branch, the
branch is still deleted when it exists.
- The remove operation must not fetch, push, or contact any remote.
### Mutation Serialization
Rules:
- Add and remove operations that resolve to the same common Git directory must
execute their inspect-and-mutate sequences serially,
including when they belong to different DAG runs in the same filesystem
environment.
- Serialization begins before worktree registrations or refs are inspected and
ends after action outputs are published or the operation's failure is
recorded.
- Waiting for serialization is context-cancellable. Abort or timeout while
waiting makes no repository change and publishes no outputs.
- Serialization does not coordinate Git mutations performed outside Dagu.
### Action Outputs
Rules:
- Both actions have a fixed output contract. Workflow authors do not declare an
`output`, declare top-level `outputs`, or decode stdout to use it.
- Action normalization makes the fixed contract available to step-output
reference validation without requiring a top-level `outputs` field in the
workflow.
- After a successful operation, the executor publishes all output fields as one
atomic set on the same lookup surface as declared step outputs.
- The built-in executor publishes these values directly. It does not use
`DAGU_OUTPUT_FILE`; this spec owns that exception to the file-based producer
defined by Spec 012.
- A dependent step reads a field with
`${steps.<step-id>.outputs.<field>}` according to Specs 007 and 012.
- A failed, aborted, or timed-out operation publishes no outputs from that
attempt. Outputs from a failed attempt must not be available to later steps
or a retry.
- Stdout is not a result channel for these actions. Workflow behavior must not
depend on parsing action stdout.
- Diagnostic text goes to stderr.
`git.worktree.add` output fields:
| Field | Type | Meaning |
| --- | --- | --- |
| `path` | string | The worktree path. |
| `branch` | string | The selected branch checked out in the worktree, including the generated name when the input omitted `branch`. |
| `commit` | string | `HEAD` commit hash of the worktree after the operation. |
| `worktree_created` | boolean | `true` when this run registered the worktree. |
| `branch_created` | boolean | `true` when this run created the branch. |
`git.worktree.remove` output fields:
| Field | Type | Meaning |
| --- | --- | --- |
| `path` | string | The worktree path, empty when no worktree was registered and `path` was omitted. |
| `branch` | string | The branch checked out in the resolved target. When no target resolves, the `branch` input; empty when neither is available. |
| `worktree_removed` | boolean | `true` when this run removed a registered worktree. |
| `branch_deleted` | boolean | `true` when this run deleted the branch. |
String fields use the `string` output type. Boolean fields use the `json`
output type and publish JSON booleans. When inserted into a string-valued field,
the booleans resolve as `true` or `false` according to Spec 003.
### Lifecycle
Rules:
- A failed operation fails the step.
- Validation, revision resolution, registration conflict checks, and remove
preflight checks complete before the first mutation.
- Output publication occurs only after every required Git mutation succeeds.
- If an unexpected failure occurs after the add operation creates the branch,
the created branch may remain.
- A failed or interrupted add operation must not leave a registered worktree
whose directory does not exist.
- After successful preflight, the remove operation removes the worktree before
deleting the branch.
- If branch deletion fails after the worktree was removed, the worktree
removal is not rolled back and the step fails.
- Workflow abort and step timeout interrupt the operation and the step follows
the abort or timeout behavior.
## Errors
### Validation Errors
Validation must fail when:
- The action name is not `git.worktree.add` or `git.worktree.remove` in the
`git.worktree.*` namespace.
- `git.worktree.add` has empty or non-string `branch` when present.
- `git.worktree.add` has empty or non-string `path` or `base`.
- `git.worktree.add` has non-boolean `create_branch`.
- `git.worktree.add` has both an explicit `branch` and `base` without
`create_branch: true`.
- `git.worktree.remove` has neither `branch` nor `path`.
- `git.worktree.remove` has empty or non-string `branch` or `path`.
- `git.worktree.remove` has `delete_branch: true` without `branch`.
- `git.worktree.remove` has `force_delete_branch: true` without
`delete_branch: true`.
- `force`, `delete_branch`, or `force_delete_branch` is present and not a
boolean.
- A `with` field is not listed for the selected operation.
Validation must not:
- Check whether the step working directory is inside a Git repository.
- Check whether `branch`, `path`, or `base` can be resolved.
### Runtime Errors
The action step must fail when:
- After value resolution, a present `branch`, `path`, or `base` resolves to an
empty string.
- The step working directory is not inside a Git repository and is not a bare
repository directory.
- `git.worktree.add` receives an invalid explicit local branch name.
- `git.worktree.add` cannot resolve `base` while the branch does not exist and
the action is permitted to create it.
- `git.worktree.add` explicitly names a branch that does not exist while
`create_branch` is `false`.
- In `git.worktree.add`, the worktree path exists and is not an empty
directory and is not the registered worktree for `branch`.
- In `git.worktree.add`, `branch` is checked out in the primary working tree.
- In `git.worktree.add`, `branch` is registered to a linked worktree at a
different path than the worktree path.
- In `git.worktree.add`, the registration for `branch` at the worktree path
is stale.
- `git.worktree.remove` targets the primary working tree.
- `git.worktree.remove` receives both `branch` and `path`, at least one of
them resolves to a registered linked worktree, and the registered worktree
for `branch` is not the one at the resolved `path`.
- In `git.worktree.remove`, the target worktree has uncommitted changes and
`force` is `false`.
- `delete_branch` is `true`, the branch is not fully merged into the detected
repository `HEAD`, and `force_delete_branch` is `false`.
- `delete_branch` is `true` and the branch is checked out in another worktree
after removal.
Runtime diagnostics must identify the selected operation, detected repository
root, relevant branch or path, and the reason for failure. A stale-registration
diagnostic must explain that `git.worktree.remove` can unregister the stale
target before retrying the add operation.
## Examples
Each example assumes a fixture repository at `./repo` with an initial commit
on branch `main`, prepared by test setup.
### Add With Generated Branch
```yaml
working_dir: ./repo
steps:
- id: create_worktree
action: git.worktree.add
```
Expected behavior:
- Dagu generates a valid branch name beginning with `dagu/`.
- The generated branch starts at the detected repository `HEAD` commit.
- The worktree is created at
`./repo.worktrees/<generated branch>`.
- The action publishes the generated name in `branch`, with
`worktree_created` and `branch_created` both `true`.
### Parameterized Inputs
```yaml
params:
- name: branch
type: string
required: true
- name: base
type: string
required: true
working_dir: ./repo
steps:
- id: create_worktree
action: git.worktree.add
with:
branch: "${params.branch}"
create_branch: true
base: "${params.base}"
```
Expected behavior:
- `branch` and `base` resolve from the run parameters when the step starts.
- `create_branch` remains a literal boolean.
### Add Creates Branch And Worktree
```yaml
working_dir: ./repo
steps:
- id: create_worktree
action: git.worktree.add
with:
branch: feature-x
create_branch: true
path: ../wt/feature-x
```
Expected behavior:
- `./wt/feature-x` is a linked worktree of `./repo`.
- Branch `feature-x` exists and is checked out in `./wt/feature-x`.
- The branch points at the commit that `HEAD` of `./repo` pointed at.
- The action outputs `worktree_created` and `branch_created` are both `true`.
### Add Is Idempotent
```yaml
working_dir: ./repo
steps:
- id: first
action: git.worktree.add
with:
branch: feature-x
create_branch: true
path: ../wt/feature-x
- id: second
depends: first
action: git.worktree.add
with:
branch: feature-x
create_branch: true
path: ../wt/feature-x
```
Expected behavior:
- Both steps succeed.
- `second` publishes `worktree_created` `false` and `branch_created` `false`.
- Files created in `./wt/feature-x` between the two steps remain.
### Add From Base Revision
```yaml
working_dir: ./repo
steps:
- id: base_tag
action: git.worktree.add
with:
branch: hotfix
create_branch: true
base: v1.0.0
path: ../wt/hotfix
```
Expected behavior:
- Branch `hotfix` is created at the commit tagged `v1.0.0`.
- `./wt/hotfix` checks out `hotfix`.
### Default Worktree Path
```yaml
working_dir: ./repo
steps:
- id: default_path
action: git.worktree.add
with:
branch: feature/auth
create_branch: true
```
Expected behavior:
- The worktree is created at `./repo.worktrees/feature/auth`.
- The `path` output is the resolved absolute form of that directory.
### Remove With Branch Delete
```yaml
working_dir: ./repo
steps:
- id: create
action: git.worktree.add
with:
branch: short-lived
create_branch: true
path: ../wt/short-lived
- id: remove
depends: create
action: git.worktree.remove
with:
branch: short-lived
delete_branch: true
```
Expected behavior:
- `./wt/short-lived` does not exist after `remove`.
- Branch `short-lived` does not exist after `remove`.
- `remove` publishes `worktree_removed` `true` and `branch_deleted` `true`.
### Remove Is Idempotent
```yaml
working_dir: ./repo
steps:
- id: remove_missing
action: git.worktree.remove
with:
branch: never-created
```
Expected behavior:
- The step succeeds.
- The action publishes `worktree_removed` `false` and `branch_deleted` `false`.
### Unmerged Branch Deletion Requires Explicit Force
```yaml
working_dir: ./repo
steps:
- id: remove_unmerged
action: git.worktree.remove
with:
branch: experimental
delete_branch: true
force_delete_branch: true
```
Expected behavior:
- Without `force_delete_branch: true`, the step fails before removing the
worktree when `experimental` is not merged into `HEAD`.
- With `force_delete_branch: true`, the worktree and unmerged branch are
removed.
- `force: true` alone does not permit unmerged branch deletion.
### Dirty Remove Requires Force
```yaml
working_dir: ./repo
steps:
- id: create
action: git.worktree.add
with:
branch: dirty
create_branch: true
path: ../wt/dirty
- id: make_dirty
depends: create
run: touch ../wt/dirty/untracked-file
- id: remove
depends: make_dirty
action: git.worktree.remove
with:
branch: dirty
```
Expected behavior:
- `remove` fails.
- `./wt/dirty` still exists with `untracked-file`.
- The same remove with `force: true` succeeds and deletes the directory.
### Occupied Path Fails
```yaml
working_dir: ./repo
steps:
- id: occupy
run: mkdir -p ../wt/taken && touch ../wt/taken/file
- id: add
depends: occupy
action: git.worktree.add
with:
branch: taken
create_branch: true
path: ../wt/taken
```
Expected behavior:
- `add` fails.
- `./wt/taken/file` still exists.
- No worktree for `taken` is registered in `./repo`.
- Branch `taken` was not created because path preflight failed.
### Use The Worktree In A Later Step
```yaml
working_dir: ./repo
steps:
- id: create_worktree
action: git.worktree.add
- id: test
depends: create_worktree
working_dir: "${steps.create_worktree.outputs.path}"
run: go test ./...
- id: remove_worktree
depends: test
action: git.worktree.remove
with:
path: "${steps.create_worktree.outputs.path}"
branch: "${steps.create_worktree.outputs.branch}"
```
Expected behavior:
- `${steps.create_worktree.outputs.path}` resolves to the absolute worktree
path published by the action without a workflow-side output declaration.
- `${steps.create_worktree.outputs.branch}` is the generated branch name and
can be passed to a later remove operation.
- `test` runs in the created or reused worktree.
- `remove_worktree` removes the same worktree without reconstructing its path or
generated branch name.