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
48 changes: 31 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ git-worktreeinclude apply [--from auto|<path>] [--include <path>] [--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
Expand All @@ -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|<path>] [--include <path>] [--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",
Expand All @@ -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

Expand Down
123 changes: 23 additions & 100 deletions internal/cli/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,6 @@ func (a *App) newRootCommand() *ucli.Command {
ExitErrHandler: a.handleExitError,
Commands: []*ucli.Command{
a.newApplyCommand(),
a.newDoctorCommand(),
},
}
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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 {
Expand Down
85 changes: 65 additions & 20 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 All @@ -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)
}
}

Expand Down Expand Up @@ -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")
}
}

Expand Down Expand Up @@ -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)
}
}

Expand Down
9 changes: 0 additions & 9 deletions internal/cli/cli_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading