diff --git a/README.md b/README.md index 337746c..25dc3b9 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,8 @@ 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 + - 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 @@ -92,29 +94,15 @@ 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. +Normal execution (`apply --json`): + ```json { + "dry_run": false, "from": "/abs/path/source", "to": "/abs/path/target", "include_file": ".worktreeinclude", @@ -134,6 +122,32 @@ Shows: } ``` +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 8ba7370..2ad82ab 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(), }, } } @@ -160,103 +159,35 @@ 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, + ) + } } } 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 +281,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..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) } @@ -213,12 +215,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 +321,63 @@ 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, "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("doctor output missing include status: %s", stdout) + 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") } } @@ -436,21 +481,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..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"` @@ -56,24 +58,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 } @@ -142,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, @@ -217,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 } @@ -257,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 { @@ -269,28 +262,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..6261cf8 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,74 @@ 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 code != exitcode.OK { + t.Fatalf("Apply exit code = %d, want %d", code, exitcode.OK) } - if report.IncludeMissingHint != IncludeMissingHintSourceMissingTargetExists { - t.Fatalf("expected target-only include hint, got %q", report.IncludeMissingHint) + 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 !report.IncludeFound { + if code != exitcode.OK { + t.Fatalf("Apply exit code = %d, want %d", code, exitcode.OK) + } + 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) + } +} + +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) + } } }