From 9f75e2394b956b3c4c73ad25404e3e7984e0feb7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 02:16:13 +0000 Subject: [PATCH 1/4] Initial plan From 6a4dfc7b1fd946a824d7aa598f4edfd287cf5218 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 02:20:46 +0000 Subject: [PATCH 2/4] refactor: remove redundant doctor command Agent-Logs-Url: https://github.com/satococoa/git-worktreeinclude/sessions/c3828947-de6e-4d73-84e3-b3a342bb7d15 Co-authored-by: satococoa <31448+satococoa@users.noreply.github.com> --- README.md | 18 +---- internal/cli/cli.go | 90 ---------------------- internal/cli/cli_integration_test.go | 39 +++++----- internal/cli/cli_unit_test.go | 9 --- internal/engine/engine.go | 40 ---------- internal/engine/engine_integration_test.go | 44 +++++------ 6 files changed, 44 insertions(+), 196 deletions(-) diff --git a/README.md b/README.md index 337746c..78dd67a 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ git-worktreeinclude apply [--from auto|] [--include ] [--dry-run] [- - relative path: resolved from source worktree root only - 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 - `--force`: overwrite differing target files - `--json`: emit a single JSON object to stdout - `--quiet`: suppress human-readable output @@ -92,23 +93,6 @@ Safe defaults: - Never overwrites by default (differences become conflicts, exit code `3`) - Missing source `.worktreeinclude` is a no-op success (exit code `0`) -### `git-worktreeinclude doctor` - -Diagnostic command. Produces a dry-run style summary. - -```sh -git-worktreeinclude doctor [--from auto|] [--include ] [--quiet] [--verbose] -``` - -Shows: - -- target repository root -- source selection result -- include file status and pattern count - - source include path resolution - - no-op reason when include file is missing in source -- matched / copy planned / conflicts / missing source / skipped same / errors - ## JSON output `apply --json` emits a single JSON object to stdout. diff --git a/internal/cli/cli.go b/internal/cli/cli.go index 8ba7370..ee445cf 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -59,7 +59,6 @@ func (a *App) newRootCommand() *ucli.Command { ExitErrHandler: a.handleExitError, Commands: []*ucli.Command{ a.newApplyCommand(), - a.newDoctorCommand(), }, } } @@ -176,87 +175,6 @@ func (a *App) runApply(ctx context.Context, cmd *ucli.Command) error { return exitWithCode(code) } -func (a *App) newDoctorCommand() *ucli.Command { - return &ucli.Command{ - Name: "doctor", - Usage: "print dry-run diagnostics", - OnUsageError: a.onUsageError, - Flags: []ucli.Flag{ - &ucli.StringFlag{Name: "from", Value: "auto", Usage: "source worktree path or 'auto'"}, - &ucli.StringFlag{Name: "include", Value: ".worktreeinclude", Usage: "path to include file", TakesFile: true}, - &ucli.BoolFlag{Name: "quiet", Usage: "suppress per-action output"}, - &ucli.BoolFlag{Name: "verbose", Usage: "enable verbose output"}, - }, - Action: a.runDoctor, - } -} - -func (a *App) runDoctor(ctx context.Context, cmd *ucli.Command) error { - if cmd.Args().Len() != 0 { - return a.onUsageError(ctx, cmd, errors.New("doctor does not accept positional arguments"), true) - } - - from := cmd.String("from") - include := cmd.String("include") - quiet := cmd.Bool("quiet") - verbose := cmd.Bool("verbose") - - if quiet && verbose { - return a.onUsageError(ctx, cmd, errors.New("--quiet and --verbose cannot be used together"), true) - } - if from == "" { - return a.onUsageError(ctx, cmd, errors.New("--from must not be empty"), true) - } - - wd, err := currentWorkdir() - if err != nil { - return ucli.Exit(err.Error(), exitcode.Env) - } - - report, err := a.engine.Doctor(ctx, wd, engine.DoctorOptions{ - From: from, - Include: include, - }) - if err != nil { - return ucli.Exit(err, codedOrDefault(err, exitcode.Internal)) - } - - writef(a.stdout, "TARGET repo root: %s\n", report.TargetRoot) - writef(a.stdout, "SOURCE (--from %s): %s\n", report.FromMode, report.SourceRoot) - writeln( - a.stdout, - formatIncludeStatusLine( - report.IncludePath, - report.IncludeFound, - report.IncludeOrigin, - report.IncludeMissingHint, - report.TargetIncludePath, - report.PatternCount, - ), - ) - writef( - a.stdout, - "SUMMARY matched=%d copy_planned=%d conflicts=%d missing_src=%d skipped_same=%d errors=%d\n", - report.Result.Summary.Matched, - report.Result.Summary.Copied, - report.Result.Summary.Conflicts, - report.Result.Summary.SkippedMissingSrc, - report.Result.Summary.SkippedSame, - report.Result.Summary.Errors, - ) - - if !quiet { - for _, action := range report.Result.Actions { - writeln(a.stdout, formatActionLine(action, false)) - } - } - if verbose && report.Result.Summary.Matched == 0 { - writeln(a.stdout, "No matched ignored files.") - } - - return nil -} - func (a *App) handleExitError(_ context.Context, _ *ucli.Command, err error) { var exitErr ucli.ExitCoder if !errors.As(err, &exitErr) { @@ -350,14 +268,6 @@ func formatIncludeStatusLine(path string, found bool, origin, hint, targetPath s return fmt.Sprintf("INCLUDE file: %s (not found in source; no-op)", path) } -func codedOrDefault(err error, fallback int) int { - var coded *engine.CLIError - if errors.As(err, &coded) { - return coded.Code - } - return fallback -} - func currentWorkdir() (string, error) { wd, err := os.Getwd() if err != nil { diff --git a/internal/cli/cli_integration_test.go b/internal/cli/cli_integration_test.go index 8e1a282..89b9cef 100644 --- a/internal/cli/cli_integration_test.go +++ b/internal/cli/cli_integration_test.go @@ -213,12 +213,12 @@ func TestApplyNoopWhenSourceIncludeMissingEvenIfTargetHasInclude(t *testing.T) { t.Fatalf("apply output missing compatibility hint: %s", humanStdout) } - doctorOut, _, doctorCode := runCmd(t, fx.wt, nil, testBinary, "doctor", "--from", "auto", "--include", testIncludeFile) - if doctorCode != 0 { - t.Fatalf("doctor exit code = %d", doctorCode) + dryRunOut, _, dryRunCode := runCmd(t, fx.wt, nil, testBinary, "apply", "--from", "auto", "--include", testIncludeFile, "--dry-run", "--verbose") + if dryRunCode != 0 { + t.Fatalf("apply --dry-run --verbose exit code = %d", dryRunCode) } - if !strings.Contains(doctorOut, "not found in source; found at target path") { - t.Fatalf("doctor output missing source/target compatibility hint: %s", doctorOut) + if !strings.Contains(dryRunOut, "not found in source; found at target path") { + t.Fatalf("apply --dry-run --verbose output missing source/target compatibility hint: %s", dryRunOut) } } @@ -319,20 +319,23 @@ func TestApplyWithLongIncludeLine(t *testing.T) { } } -func TestDoctorCommand(t *testing.T) { +func TestApplyDryRunVerboseOutput(t *testing.T) { fx := setupFixture(t) - stdout, _, code := runCmd(t, fx.wt, nil, testBinary, "doctor", "--from", "auto", "--include", testIncludeFile) + stdout, _, code := runCmd(t, fx.wt, nil, testBinary, "apply", "--from", "auto", "--include", testIncludeFile, "--dry-run", "--verbose") if code != 0 { - t.Fatalf("doctor exit code = %d", code) + t.Fatalf("apply --dry-run --verbose exit code = %d", code) } - if !strings.Contains(stdout, "TARGET repo root:") { - t.Fatalf("doctor output missing target root: %s", stdout) + if !strings.Contains(stdout, "APPLY from:") { + t.Fatalf("apply --dry-run output missing source root: %s", stdout) + } + if !strings.Contains(stdout, "APPLY to:") { + t.Fatalf("apply --dry-run output missing target root: %s", stdout) } if !strings.Contains(stdout, "SUMMARY matched=") { - t.Fatalf("doctor output missing summary: %s", stdout) + t.Fatalf("apply --dry-run output missing summary: %s", stdout) } if !strings.Contains(stdout, "INCLUDE file:") { - t.Fatalf("doctor output missing include status: %s", stdout) + t.Fatalf("apply --dry-run output missing include status: %s", stdout) } } @@ -436,21 +439,21 @@ func TestApplyUsageValidationErrorsGoToStderr(t *testing.T) { } } -func TestDoctorUsageValidationErrorsGoToStderr(t *testing.T) { +func TestApplyQuietVerboseUsageValidationErrorsGoToStderr(t *testing.T) { fx := setupFixture(t) - stdout, stderr, code := runCmd(t, fx.wt, nil, testBinary, "doctor", "--quiet", "--verbose") + stdout, stderr, code := runCmd(t, fx.wt, nil, testBinary, "apply", "--dry-run", "--quiet", "--verbose") if code != 2 { - t.Fatalf("expected exit code 2 for doctor usage error, got %d", code) + t.Fatalf("expected exit code 2 for apply usage error, got %d", code) } if strings.TrimSpace(stdout) != "" { - t.Fatalf("expected no stdout for doctor usage error, got: %q", stdout) + t.Fatalf("expected no stdout for apply usage error, got: %q", stdout) } if !strings.Contains(stderr, "--quiet and --verbose cannot be used together") { - t.Fatalf("stderr should contain doctor usage detail: %s", stderr) + t.Fatalf("stderr should contain apply usage detail: %s", stderr) } if !strings.Contains(stderr, "USAGE:") { - t.Fatalf("stderr should include doctor help: %s", stderr) + t.Fatalf("stderr should include apply help: %s", stderr) } } diff --git a/internal/cli/cli_unit_test.go b/internal/cli/cli_unit_test.go index c4fcca3..b3216a3 100644 --- a/internal/cli/cli_unit_test.go +++ b/internal/cli/cli_unit_test.go @@ -130,15 +130,6 @@ func TestFormatActionLine(t *testing.T) { } } -func TestCodedOrDefault(t *testing.T) { - if got := codedOrDefault(&engine.CLIError{Code: exitcode.Env, Msg: "x"}, exitcode.Internal); got != exitcode.Env { - t.Fatalf("codedOrDefault(CLIError) = %d, want %d", got, exitcode.Env) - } - if got := codedOrDefault(nil, exitcode.Internal); got != exitcode.Internal { - t.Fatalf("codedOrDefault(nil) = %d, want %d", got, exitcode.Internal) - } -} - func TestHandleExitErrorPrintsPlainError(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer diff --git a/internal/engine/engine.go b/internal/engine/engine.go index 89e2210..99e7ee9 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -56,24 +56,6 @@ type ApplyOptions struct { Force bool } -type DoctorOptions struct { - From string - Include string -} - -type DoctorReport struct { - TargetRoot string - SourceRoot string - FromMode string - IncludePath string - IncludeFound bool - IncludeOrigin string - IncludeMissingHint string - TargetIncludePath string - PatternCount int - Result Result -} - type Engine struct { git *gitexec.Runner } @@ -269,28 +251,6 @@ func (e *Engine) executePrepared(prep prepared, dryRun, force bool) (Result, int return result, exitcode.OK } -func (e *Engine) Doctor(ctx context.Context, cwd string, opts DoctorOptions) (DoctorReport, error) { - prep, err := e.prepare(ctx, cwd, opts.From, opts.Include) - if err != nil { - return DoctorReport{}, err - } - - res, _ := e.executePrepared(prep, true, false) - - return DoctorReport{ - TargetRoot: prep.targetRoot, - SourceRoot: prep.sourceRoot, - FromMode: prep.fromMode, - IncludePath: prep.includePath, - IncludeFound: prep.includeFound, - IncludeOrigin: prep.includeOrigin, - IncludeMissingHint: prep.includeMissingHint, - TargetIncludePath: prep.targetIncludePath, - PatternCount: prep.patternCount, - Result: res, - }, nil -} - func (e *Engine) prepare(ctx context.Context, cwd, fromOpt, includeOpt string) (prepared, error) { targetRoot, err := e.repoRoot(ctx, cwd) if err != nil { diff --git a/internal/engine/engine_integration_test.go b/internal/engine/engine_integration_test.go index 5ae9974..2477681 100644 --- a/internal/engine/engine_integration_test.go +++ b/internal/engine/engine_integration_test.go @@ -178,19 +178,11 @@ func TestEngineApplyNoopWhenSourceIncludeMissingEvenIfTargetHasInclude(t *testin if res.Summary.Matched != 0 || res.Summary.Copied != 0 || len(res.Actions) != 0 { t.Fatalf("expected source-missing include no-op, got %+v", res.Summary) } - - report, err := e.Doctor(context.Background(), fx.wt, DoctorOptions{ - From: "auto", - Include: testIncludeFile, - }) - if err != nil { - t.Fatalf("Doctor returned error: %v", err) - } - if report.IncludeFound { + if res.IncludeFound { t.Fatalf("expected include to be missing") } - if report.IncludeMissingHint != IncludeMissingHintSourceMissingTargetExists { - t.Fatalf("unexpected include hint: %q", report.IncludeMissingHint) + if res.IncludeMissingHint != IncludeMissingHintSourceMissingTargetExists { + t.Fatalf("unexpected include hint: %q", res.IncludeMissingHint) } } @@ -219,7 +211,7 @@ func TestEngineApplyReadsIncludeFileIgnoredByGlobalExcludes(t *testing.T) { } } -func TestEngineDoctorHintsWhenTargetIncludeIsSymlink(t *testing.T) { +func TestEngineApplyHintsWhenTargetIncludeIsSymlink(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("symlink behavior and permissions vary on Windows") } @@ -239,34 +231,42 @@ func TestEngineDoctorHintsWhenTargetIncludeIsSymlink(t *testing.T) { t.Fatalf("create target symlink include: %v", err) } - report, err := e.Doctor(context.Background(), fx.wt, DoctorOptions{ + res, code, err := e.Apply(context.Background(), fx.wt, ApplyOptions{ From: "auto", Include: testIncludeFile, + DryRun: true, }) if err != nil { - t.Fatalf("Doctor returned error: %v", err) + t.Fatalf("Apply returned error: %v", err) } - if report.IncludeMissingHint != IncludeMissingHintSourceMissingTargetExists { - t.Fatalf("expected target-only include hint, got %q", report.IncludeMissingHint) + if code != exitcode.OK { + t.Fatalf("Apply exit code = %d, want %d", code, exitcode.OK) + } + if res.IncludeMissingHint != IncludeMissingHintSourceMissingTargetExists { + t.Fatalf("expected target-only include hint, got %q", res.IncludeMissingHint) } } -func TestEngineDoctor(t *testing.T) { +func TestEngineApplyDryRunIncludesMetadata(t *testing.T) { fx := setupEngineFixture(t) e := NewEngine() - report, err := e.Doctor(context.Background(), fx.wt, DoctorOptions{ + res, code, err := e.Apply(context.Background(), fx.wt, ApplyOptions{ From: "auto", Include: testIncludeFile, + DryRun: true, }) if err != nil { - t.Fatalf("Doctor returned error: %v", err) + t.Fatalf("Apply returned error: %v", err) + } + if code != exitcode.OK { + t.Fatalf("Apply exit code = %d, want %d", code, exitcode.OK) } - if !report.IncludeFound { + if !res.IncludeFound { t.Fatalf("expected include file to be found") } - if report.PatternCount != 3 { - t.Fatalf("unexpected pattern count: got %d want 3", report.PatternCount) + if res.PatternCount != 3 { + t.Fatalf("unexpected pattern count: got %d want 3", res.PatternCount) } } From 2383046840a1dc396ada7ccf6deb8208bf6cd88b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 13:57:22 +0000 Subject: [PATCH 3/4] Initial plan From 5ebb9ea274083c92001f8d42a6b7daf5f28d9930 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 5 Apr 2026 14:03:19 +0000 Subject: [PATCH 4/4] feat: add dry_run field and split copied/copy_planned in summary - Add DryRun bool field to Result struct (JSON: "dry_run") - Split Summary.Copied into Copied (real mode) and CopyPlanned (dry-run mode) with omitempty for mutual exclusivity - Update executePrepared to increment CopyPlanned in dry-run, Copied in real mode - Update SUMMARY human output to use copy_planned= in dry-run mode - Update jsonResult struct in CLI integration tests - Add TestApplyDryRunJSON CLI integration test - Add TestEngineApplyDryRunCopyPlanned engine integration test - Update README with separate dry-run and normal JSON output examples Agent-Logs-Url: https://github.com/satococoa/git-worktreeinclude/sessions/b43b6731-413f-4d2f-ae93-143bd52bfa32 Co-authored-by: satococoa <31448+satococoa@users.noreply.github.com> --- README.md | 30 ++++++++++++++ internal/cli/cli.go | 33 +++++++++++----- internal/cli/cli_integration_test.go | 46 +++++++++++++++++++++- internal/engine/engine.go | 17 ++++++-- internal/engine/engine_integration_test.go | 32 +++++++++++++++ 5 files changed, 143 insertions(+), 15 deletions(-) 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 {