diff --git a/README.md b/README.md index 78dd67a..25dc3b9 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ git-worktreeinclude apply [--from auto|] [--include ] [--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 @@ -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", @@ -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 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index ee445cf..2ad82ab 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -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, + ) + } } } diff --git a/internal/cli/cli_integration_test.go b/internal/cli/cli_integration_test.go index 89b9cef..f602d49 100644 --- a/internal/cli/cli_integration_test.go +++ b/internal/cli/cli_integration_test.go @@ -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"` @@ -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) } } @@ -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) } @@ -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) diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 99e7ee9..4a5f895 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -26,7 +26,8 @@ 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"` SkippedSame int `json:"skipped_same"` SkippedMissingSrc int `json:"skipped_missing_src"` Conflicts int `json:"conflicts"` @@ -34,6 +35,7 @@ type Summary struct { } type Result struct { + DryRun bool `json:"dry_run"` From string `json:"from"` To string `json:"to"` IncludeFile string `json:"include_file"` @@ -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, @@ -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 } @@ -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 { diff --git a/internal/engine/engine_integration_test.go b/internal/engine/engine_integration_test.go index 2477681..6261cf8 100644 --- a/internal/engine/engine_integration_test.go +++ b/internal/engine/engine_integration_test.go @@ -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 {