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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ git-worktreeinclude apply [--from auto|<path>] [--include <path>] [--dry-run] [-
- absolute path: must be inside source worktree root
- `--dry-run`: plan only, make no changes
- use `--dry-run --verbose` when you want diagnostics about source/target selection, include file resolution, and planned actions
- in dry-run mode, the human-readable summary uses `copy_planned=` instead of `copied=`, and the JSON summary uses `"copy_planned"` instead of `"copied"`
- `--force`: overwrite differing target files
- `--json`: emit a single JSON object to stdout
- `--quiet`: suppress human-readable output
Expand All @@ -97,8 +98,11 @@ Safe defaults:

`apply --json` emits a single JSON object to stdout.

Normal execution (`apply --json`):

```json
{
"dry_run": false,
"from": "/abs/path/source",
"to": "/abs/path/target",
"include_file": ".worktreeinclude",
Expand All @@ -118,6 +122,32 @@ Safe defaults:
}
```

Dry-run mode (`apply --dry-run --json`):

```json
{
"dry_run": true,
"from": "/abs/path/source",
"to": "/abs/path/target",
"include_file": ".worktreeinclude",
"summary": {
"matched": 12,
"copy_planned": 8,
"skipped_same": 3,
"skipped_missing_src": 1,
"conflicts": 0,
"errors": 0
},
"actions": [
{"op": "copy", "path": ".env", "status": "planned"},
{"op": "skip", "path": ".mise.local.toml", "status": "same"},
{"op": "conflict", "path": ".vscode/settings.json", "status": "diff"}
]
}
```

- `"dry_run": true` indicates no files were written
- In dry-run mode `"copy_planned"` is used instead of `"copied"` in the summary (they are mutually exclusive)
- `path` is repo-root relative and slash-separated
- File contents and secrets are never output

Expand Down
33 changes: 23 additions & 10 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,16 +159,29 @@ func (a *App) runApply(ctx context.Context, cmd *ucli.Command) error {
writeln(a.stdout, formatActionLine(action, force))
}
if verbose || result.Summary.Matched > 0 {
writef(
a.stdout,
"SUMMARY matched=%d copied=%d skipped_same=%d skipped_missing_src=%d conflicts=%d errors=%d\n",
result.Summary.Matched,
result.Summary.Copied,
result.Summary.SkippedSame,
result.Summary.SkippedMissingSrc,
result.Summary.Conflicts,
result.Summary.Errors,
)
if dryRun {
writef(
a.stdout,
"SUMMARY matched=%d copy_planned=%d skipped_same=%d skipped_missing_src=%d conflicts=%d errors=%d\n",
result.Summary.Matched,
result.Summary.CopyPlanned,
result.Summary.SkippedSame,
result.Summary.SkippedMissingSrc,
result.Summary.Conflicts,
result.Summary.Errors,
)
} else {
writef(
a.stdout,
"SUMMARY matched=%d copied=%d skipped_same=%d skipped_missing_src=%d conflicts=%d errors=%d\n",
result.Summary.Matched,
result.Summary.Copied,
result.Summary.SkippedSame,
result.Summary.SkippedMissingSrc,
result.Summary.Conflicts,
result.Summary.Errors,
)
}
}
}

Expand Down
46 changes: 44 additions & 2 deletions internal/cli/cli_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,14 @@ type fixture struct {
}

type jsonResult struct {
DryRun bool `json:"dry_run"`
From string `json:"from"`
To string `json:"to"`
IncludeFile string `json:"include_file"`
Summary struct {
Matched int `json:"matched"`
Copied int `json:"copied"`
CopyPlanned int `json:"copy_planned"`
SkippedSame int `json:"skipped_same"`
SkippedMissingSrc int `json:"skipped_missing_src"`
Conflicts int `json:"conflicts"`
Expand Down Expand Up @@ -166,7 +168,7 @@ func TestApplyAC8MissingIncludeIsNoop(t *testing.T) {
}

res := decodeSingleJSON(t, stdout)
if res.Summary.Matched != 0 || res.Summary.Copied != 0 || len(res.Actions) != 0 {
if res.Summary.Matched != 0 || res.Summary.Copied != 0 || res.Summary.CopyPlanned != 0 || len(res.Actions) != 0 {
t.Fatalf("expected noop summary, got %+v", res.Summary)
}
}
Expand Down Expand Up @@ -201,7 +203,7 @@ func TestApplyNoopWhenSourceIncludeMissingEvenIfTargetHasInclude(t *testing.T) {
}

res := decodeSingleJSON(t, stdout)
if res.Summary.Matched != 0 || res.Summary.Copied != 0 || len(res.Actions) != 0 {
if res.Summary.Matched != 0 || res.Summary.Copied != 0 || res.Summary.CopyPlanned != 0 || len(res.Actions) != 0 {
t.Fatalf("expected source-missing include no-op, got summary=%+v", res.Summary)
}

Expand Down Expand Up @@ -334,11 +336,51 @@ func TestApplyDryRunVerboseOutput(t *testing.T) {
if !strings.Contains(stdout, "SUMMARY matched=") {
t.Fatalf("apply --dry-run output missing summary: %s", stdout)
}
if !strings.Contains(stdout, "copy_planned=") {
t.Fatalf("apply --dry-run output should use copy_planned= not copied=: %s", stdout)
}
if strings.Contains(stdout, "copied=") {
t.Fatalf("apply --dry-run output should not use copied=: %s", stdout)
}
if !strings.Contains(stdout, "INCLUDE file:") {
t.Fatalf("apply --dry-run output missing include status: %s", stdout)
}
}

func TestApplyDryRunJSON(t *testing.T) {
fx := setupFixture(t)

if err := os.Remove(filepath.Join(fx.wt, ".env")); err != nil && !errors.Is(err, os.ErrNotExist) {
t.Fatalf("remove .env: %v", err)
}

stdout, stderr, code := runCmd(t, fx.wt, nil, testBinary, "apply", "--from", "auto", "--include", testIncludeFile, "--dry-run", "--json")
if code != 0 {
t.Fatalf("apply --dry-run --json exit code = %d, stderr=%s", code, stderr)
}

res := decodeSingleJSON(t, stdout)
if !res.DryRun {
t.Fatalf("expected dry_run=true in JSON output")
}
if res.Summary.CopyPlanned == 0 {
t.Fatalf("expected copy_planned > 0 in dry-run JSON summary, got %+v", res.Summary)
}
if res.Summary.Copied != 0 {
t.Fatalf("expected copied=0 in dry-run JSON summary, got %+v", res.Summary)
}

for _, a := range res.Actions {
if a.Op == "copy" && a.Status != "planned" {
t.Fatalf("expected all copy actions to have status=planned in dry-run, got %+v", a)
}
}

if _, err := os.Stat(filepath.Join(fx.wt, ".env")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("dry-run should not create .env")
}
}

func TestGitExtensionInvocation(t *testing.T) {
fx := setupFixture(t)

Expand Down
17 changes: 14 additions & 3 deletions internal/engine/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,14 +26,16 @@ type Action struct {

type Summary struct {
Matched int `json:"matched"`
Copied int `json:"copied"`
Copied int `json:"copied,omitempty"`
CopyPlanned int `json:"copy_planned,omitempty"`
Comment on lines +29 to +30
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve mode-specific summary key when count is zero

omitempty on both Summary.Copied and Summary.CopyPlanned causes both counters to disappear whenever the value is 0 (for example, apply --dry-run --json when all matched files are already identical, or a normal no-op run). That makes dry-run JSON omit copy_planned even though the CLI/docs describe it as the dry-run counter, and it also changes the previous JSON shape where copied was always emitted, which can break consumers that key off field presence.

Useful? React with 👍 / 👎.

SkippedSame int `json:"skipped_same"`
SkippedMissingSrc int `json:"skipped_missing_src"`
Conflicts int `json:"conflicts"`
Errors int `json:"errors"`
}

type Result struct {
DryRun bool `json:"dry_run"`
From string `json:"from"`
To string `json:"to"`
IncludeFile string `json:"include_file"`
Expand Down Expand Up @@ -124,6 +126,7 @@ func (e *Engine) Apply(ctx context.Context, cwd string, opts ApplyOptions) (Resu

func (e *Engine) executePrepared(prep prepared, dryRun, force bool) (Result, int) {
result := Result{
DryRun: dryRun,
From: prep.sourceRoot,
To: prep.targetRoot,
IncludeFile: prep.includeArg,
Expand Down Expand Up @@ -199,7 +202,11 @@ func (e *Engine) executePrepared(prep prepared, dryRun, force bool) (Result, int
status = "done"
}
result.Actions = append(result.Actions, Action{Op: "copy", Path: rel, Status: status})
result.Summary.Copied++
if dryRun {
result.Summary.CopyPlanned++
} else {
result.Summary.Copied++
}
continue
}

Expand Down Expand Up @@ -239,7 +246,11 @@ func (e *Engine) executePrepared(prep prepared, dryRun, force bool) (Result, int
status = "done"
}
result.Actions = append(result.Actions, Action{Op: "copy", Path: rel, Status: status})
result.Summary.Copied++
if dryRun {
result.Summary.CopyPlanned++
} else {
result.Summary.Copied++
}
}

if result.Summary.Errors > 0 {
Expand Down
32 changes: 32 additions & 0 deletions internal/engine/engine_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,38 @@ func TestEngineApplyDryRunIncludesMetadata(t *testing.T) {
}
}

func TestEngineApplyDryRunCopyPlanned(t *testing.T) {
fx := setupEngineFixture(t)
e := NewEngine()

res, code, err := e.Apply(context.Background(), fx.wt, ApplyOptions{
From: "auto",
Include: testIncludeFile,
DryRun: true,
})
if err != nil {
t.Fatalf("Apply dry-run returned error: %v", err)
}
if code != exitcode.OK {
t.Fatalf("Apply dry-run exit code = %d, want %d", code, exitcode.OK)
}
if !res.DryRun {
t.Fatalf("expected DryRun=true in result")
}
if res.Summary.CopyPlanned == 0 {
t.Fatalf("expected CopyPlanned > 0 in dry-run summary, got %+v", res.Summary)
}
if res.Summary.Copied != 0 {
t.Fatalf("expected Copied=0 in dry-run summary, got %+v", res.Summary)
}

for _, a := range res.Actions {
if a.Op == "copy" && a.Status != "planned" {
t.Fatalf("expected copy actions to have status=planned in dry-run, got %+v", a)
}
}
}

func TestErrorCodeFromCLIError(t *testing.T) {
err := &CLIError{Code: exitcode.Env, Msg: "x"}
if got := errorCode(err); got != exitcode.Env {
Expand Down