feat(doctor): full implementation of doctor health checks
This commit is contained in:
parent
0c5b73154d
commit
11276fcf25
4 changed files with 410 additions and 36 deletions
33
README.md
33
README.md
|
|
@ -26,6 +26,7 @@
|
|||
- plaintext exports in multiple formats,
|
||||
- support for all [binary data](https://github.com/Llywelwyn/pda#binary),
|
||||
- [time-to-live](https://github.com/Llywelwyn/pda#ttl)/expiry support,
|
||||
- built-in [diagnostics](https://github.com/Llywelwyn/pda#doctor),
|
||||
|
||||
and more, written in pure Go, and inspired by [skate](https://github.com/charmbracelet/skate) and [nb](https://github.com/xwmx/nb).
|
||||
|
||||
|
|
@ -56,6 +57,7 @@ and more, written in pure Go, and inspired by [skate](https://github.com/charmbr
|
|||
- [TTL](https://github.com/Llywelwyn/pda#ttl)
|
||||
- [Binary](https://github.com/Llywelwyn/pda#binary)
|
||||
- [Encryption](https://github.com/Llywelwyn/pda#encryption)
|
||||
- [Doctor](https://github.com/Llywelwyn/pda#doctor)
|
||||
- [Environment](https://github.com/Llywelwyn/pda#environment)
|
||||
|
||||
<p align="center"></p><!-- spacer -->
|
||||
|
|
@ -99,6 +101,7 @@ Git commands:
|
|||
|
||||
Additional Commands:
|
||||
completion Generate the autocompletion script for the specified shell
|
||||
doctor Check environment health
|
||||
help Help about any command
|
||||
version Display pda! version
|
||||
```
|
||||
|
|
@ -727,6 +730,36 @@ pda identity --new
|
|||
|
||||
<p align="center"></p><!-- spacer -->
|
||||
|
||||
### Doctor
|
||||
|
||||
`pda doctor` runs a set of health checks of your environment.
|
||||
|
||||
```bash
|
||||
pda doctor
|
||||
# ok pda! 2025.52 Christmas release (linux/amd64)
|
||||
# ok OS: Linux 6.18.7-arch1-1
|
||||
# ok Go: go1.23.0
|
||||
# ok Git: 2.45.0
|
||||
# ok Shell: /bin/zsh
|
||||
# ok Config: /home/user/.config/pda
|
||||
# ok Non-default config:
|
||||
# ├── display_ascii_art: false
|
||||
# └── git.auto_commit: true
|
||||
# ok Data: /home/user/.local/share/pda/stores
|
||||
# ok Identity: /home/user/.config/pda/identity.txt
|
||||
# ok Git initialised on main
|
||||
# ok Git remote configured
|
||||
# ok Git in sync with remote
|
||||
# ok 3 store(s), 15 key(s), 2 secret(s), 4.2k total
|
||||
# ok No issues found
|
||||
```
|
||||
|
||||
<p align="center"></p><!-- spacer -->
|
||||
|
||||
Severity levels are colour-coded: `ok` (green), `WARN` (yellow), and `FAIL` (red). Only `FAIL` produces a non-zero exit code. `WARN` is generally not a problem, but may mean some functionality isn't being made use of, like for example version control not having been initialised yet.
|
||||
|
||||
<p align="center"></p><!-- spacer -->
|
||||
|
||||
### Environment
|
||||
|
||||
Config is stored in your user config directory in `pda/config.toml`.
|
||||
|
|
|
|||
288
cmd/doctor.go
288
cmd/doctor.go
|
|
@ -2,10 +2,15 @@ package cmd
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
var doctorCmd = &cobra.Command{
|
||||
|
|
@ -20,7 +25,17 @@ func init() {
|
|||
}
|
||||
|
||||
func doctor(cmd *cobra.Command, args []string) error {
|
||||
tty := stdoutIsTerminal()
|
||||
if runDoctor(os.Stdout) {
|
||||
os.Exit(1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runDoctor(w io.Writer) bool {
|
||||
tty := false
|
||||
if f, ok := w.(*os.File); ok {
|
||||
tty = term.IsTerminal(int(f.Fd()))
|
||||
}
|
||||
hasError := false
|
||||
|
||||
emit := func(level, msg string) {
|
||||
|
|
@ -28,101 +43,316 @@ func doctor(cmd *cobra.Command, args []string) error {
|
|||
switch level {
|
||||
case "ok":
|
||||
code = "32"
|
||||
case "info":
|
||||
code = "34"
|
||||
case "WARN":
|
||||
code = "33"
|
||||
case "FAIL":
|
||||
code = "31"
|
||||
hasError = true
|
||||
}
|
||||
fmt.Fprintf(os.Stdout, "%s %s\n", keyword(code, level, tty), msg)
|
||||
fmt.Fprintf(w, "%s %s\n", keyword(code, level, tty), msg)
|
||||
}
|
||||
|
||||
// Config
|
||||
tree := func(items []string) {
|
||||
for i, item := range items {
|
||||
connector := "├── "
|
||||
if i == len(items)-1 {
|
||||
connector = "└── "
|
||||
}
|
||||
fmt.Fprintf(w, " %s%s\n", connector, item)
|
||||
}
|
||||
}
|
||||
|
||||
// 1. Version + platform
|
||||
emit("ok", fmt.Sprintf("%s (%s/%s)", version, runtime.GOOS, runtime.GOARCH))
|
||||
|
||||
// 2. OS detail
|
||||
switch runtime.GOOS {
|
||||
case "linux":
|
||||
if out, err := exec.Command("uname", "-r").Output(); err == nil {
|
||||
emit("ok", fmt.Sprintf("OS: Linux %s", strings.TrimSpace(string(out))))
|
||||
}
|
||||
case "darwin":
|
||||
if out, err := exec.Command("sw_vers", "-productVersion").Output(); err == nil {
|
||||
emit("ok", fmt.Sprintf("OS: macOS %s", strings.TrimSpace(string(out))))
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Go version
|
||||
emit("ok", fmt.Sprintf("Go: %s", runtime.Version()))
|
||||
|
||||
// 4. Git dependency
|
||||
gitAvailable := false
|
||||
if out, err := exec.Command("git", "--version").Output(); err == nil {
|
||||
gitVer := strings.TrimSpace(string(out))
|
||||
if after, ok := strings.CutPrefix(gitVer, "git version "); ok {
|
||||
gitVer = after
|
||||
}
|
||||
emit("ok", fmt.Sprintf("Git: %s", gitVer))
|
||||
gitAvailable = true
|
||||
} else {
|
||||
emit("WARN", "git not found on PATH")
|
||||
}
|
||||
|
||||
// 5. Shell
|
||||
if shell := os.Getenv("SHELL"); shell != "" {
|
||||
emit("ok", fmt.Sprintf("Shell: %s", shell))
|
||||
} else {
|
||||
emit("WARN", "SHELL not set")
|
||||
}
|
||||
|
||||
// 6. Config directory and file
|
||||
cfgPath, err := configPath()
|
||||
if err != nil {
|
||||
emit("FAIL", fmt.Sprintf("Cannot determine config path: %v", err))
|
||||
} else if _, err := os.Stat(cfgPath); os.IsNotExist(err) {
|
||||
emit("info", "Using default configuration")
|
||||
} else if err != nil {
|
||||
emit("FAIL", fmt.Sprintf("Cannot access config: %v", err))
|
||||
} else {
|
||||
emit("ok", "Configuration loaded")
|
||||
cfgDir := filepath.Dir(cfgPath)
|
||||
envSuffix := ""
|
||||
if os.Getenv("PDA_CONFIG") != "" {
|
||||
envSuffix = " (PDA_CONFIG)"
|
||||
}
|
||||
|
||||
// Data directory
|
||||
var issues []string
|
||||
if _, statErr := os.Stat(cfgPath); statErr != nil && !os.IsNotExist(statErr) {
|
||||
issues = append(issues, fmt.Sprintf("Config file unreadable: %s", cfgPath))
|
||||
}
|
||||
if unexpectedFiles(cfgDir, map[string]bool{
|
||||
"config.toml": true,
|
||||
"identity.txt": true,
|
||||
}) {
|
||||
issues = append(issues, "Unexpected file(s) in directory")
|
||||
}
|
||||
|
||||
if len(issues) > 0 {
|
||||
emit("FAIL", fmt.Sprintf("Config: %s%s", cfgDir, envSuffix))
|
||||
tree(issues)
|
||||
} else {
|
||||
emit("ok", fmt.Sprintf("Config: %s%s", cfgDir, envSuffix))
|
||||
}
|
||||
|
||||
if _, statErr := os.Stat(cfgPath); os.IsNotExist(statErr) {
|
||||
emit("ok", "Using default configuration")
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Non-default config values
|
||||
if diffs := configDiffs(); len(diffs) > 0 {
|
||||
emit("ok", "Non-default config:")
|
||||
tree(diffs)
|
||||
}
|
||||
|
||||
// 8. Data directory
|
||||
store := &Store{}
|
||||
dataDir, err := store.path()
|
||||
if err != nil {
|
||||
emit("FAIL", fmt.Sprintf("Data directory inaccessible: %v", err))
|
||||
} else {
|
||||
emit("ok", "Data directory accessible")
|
||||
envSuffix := ""
|
||||
if os.Getenv("PDA_DATA") != "" {
|
||||
envSuffix = " (PDA_DATA)"
|
||||
}
|
||||
|
||||
// Identity
|
||||
if unexpectedDataFiles(dataDir) {
|
||||
emit("FAIL", fmt.Sprintf("Data: %s%s", dataDir, envSuffix))
|
||||
tree([]string{"Unexpected file(s) in directory"})
|
||||
} else {
|
||||
emit("ok", fmt.Sprintf("Data: %s%s", dataDir, envSuffix))
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Identity file
|
||||
idPath, err := identityPath()
|
||||
if err != nil {
|
||||
emit("FAIL", fmt.Sprintf("Cannot determine identity path: %v", err))
|
||||
} else if _, err := os.Stat(idPath); os.IsNotExist(err) {
|
||||
emit("info", "No identity file. One will be created on first encrypt.")
|
||||
emit("WARN", "No identity file found")
|
||||
} else if err != nil {
|
||||
emit("FAIL", fmt.Sprintf("Cannot access identity file: %v", err))
|
||||
} else {
|
||||
emit("ok", "Identity file present")
|
||||
info, _ := os.Stat(idPath)
|
||||
emit("ok", fmt.Sprintf("Identity: %s", idPath))
|
||||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||||
emit("WARN", fmt.Sprintf("Identity file permissions %04o (should be 0600)", perm))
|
||||
}
|
||||
if _, loadErr := loadIdentity(); loadErr != nil {
|
||||
emit("WARN", fmt.Sprintf("Identity file invalid: %v", loadErr))
|
||||
}
|
||||
}
|
||||
|
||||
// Git
|
||||
// 10. Git initialised
|
||||
gitInitialised := false
|
||||
if dataDir != "" {
|
||||
gitDir := filepath.Join(dataDir, ".git")
|
||||
if _, err := os.Stat(gitDir); os.IsNotExist(err) {
|
||||
emit("info", "Git not initialised")
|
||||
emit("WARN", "Git not initialised")
|
||||
} else if err != nil {
|
||||
emit("FAIL", fmt.Sprintf("Cannot check git status: %v", err))
|
||||
} else {
|
||||
gitInitialised = true
|
||||
emit("ok", "Git initialised")
|
||||
branch, _ := currentBranch(dataDir)
|
||||
if branch == "" {
|
||||
branch = "unknown"
|
||||
}
|
||||
emit("ok", fmt.Sprintf("Git initialised on %s", branch))
|
||||
}
|
||||
}
|
||||
|
||||
// Git remote (only when git is initialised)
|
||||
// 11. Git uncommitted changes (only if git initialised)
|
||||
if gitInitialised && gitAvailable {
|
||||
ucCmd := exec.Command("git", "status", "--porcelain")
|
||||
ucCmd.Dir = dataDir
|
||||
if out, err := ucCmd.Output(); err == nil {
|
||||
if trimmed := strings.TrimSpace(string(out)); trimmed != "" {
|
||||
count := len(strings.Split(trimmed, "\n"))
|
||||
emit("WARN", fmt.Sprintf("Git %d file(s) with uncommitted changes", count))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 12. Git remote (only if git initialised)
|
||||
hasOrigin := false
|
||||
if gitInitialised {
|
||||
hasOrigin, err := repoHasRemote(dataDir, "origin")
|
||||
var err error
|
||||
hasOrigin, err = repoHasRemote(dataDir, "origin")
|
||||
if err != nil {
|
||||
emit("FAIL", fmt.Sprintf("Cannot check git remote: %v", err))
|
||||
} else if hasOrigin {
|
||||
emit("ok", "Git remote configured")
|
||||
} else {
|
||||
emit("info", "No git remote configured")
|
||||
emit("WARN", "No git remote configured")
|
||||
}
|
||||
}
|
||||
|
||||
// Stores
|
||||
// 13. Git sync (only if git initialised AND remote exists)
|
||||
if gitInitialised && hasOrigin && gitAvailable {
|
||||
info, err := repoRemoteInfo(dataDir)
|
||||
if err != nil || info.Ref == "" {
|
||||
emit("WARN", "Git sync status unknown")
|
||||
} else {
|
||||
ahead, behind, err := repoAheadBehind(dataDir, info.Ref)
|
||||
if err != nil {
|
||||
emit("WARN", "Git sync status unknown")
|
||||
} else if ahead == 0 && behind == 0 {
|
||||
emit("ok", "Git in sync with remote")
|
||||
} else {
|
||||
var parts []string
|
||||
if ahead > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d ahead", ahead))
|
||||
}
|
||||
if behind > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d behind", behind))
|
||||
}
|
||||
emit("WARN", fmt.Sprintf("Git %s remote", strings.Join(parts, ", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 14. Stores summary
|
||||
stores, err := store.AllStores()
|
||||
if err != nil {
|
||||
emit("FAIL", fmt.Sprintf("Cannot list stores: %v", err))
|
||||
} else if len(stores) == 0 {
|
||||
emit("info", "No stores")
|
||||
emit("WARN", "No stores found")
|
||||
} else {
|
||||
var parseErrors int
|
||||
var totalKeys, totalSecrets, parseErrors int
|
||||
var totalSize int64
|
||||
for _, name := range stores {
|
||||
p, pErr := store.storePath(name)
|
||||
if pErr != nil {
|
||||
parseErrors++
|
||||
continue
|
||||
}
|
||||
if _, rErr := readStoreFile(p, nil); rErr != nil {
|
||||
if fi, sErr := os.Stat(p); sErr == nil {
|
||||
totalSize += fi.Size()
|
||||
}
|
||||
entries, rErr := readStoreFile(p, nil)
|
||||
if rErr != nil {
|
||||
parseErrors++
|
||||
continue
|
||||
}
|
||||
totalKeys += len(entries)
|
||||
for _, e := range entries {
|
||||
if e.Secret {
|
||||
totalSecrets++
|
||||
}
|
||||
}
|
||||
}
|
||||
if parseErrors > 0 {
|
||||
emit("FAIL", fmt.Sprintf("%d store(s), %d with errors", len(stores), parseErrors))
|
||||
emit("FAIL", fmt.Sprintf("%d store(s), %d with parse errors", len(stores), parseErrors))
|
||||
} else {
|
||||
emit("ok", fmt.Sprintf("%d store(s)", len(stores)))
|
||||
emit("ok", fmt.Sprintf("%d store(s), %d key(s), %d secret(s), %s total",
|
||||
len(stores), totalKeys, totalSecrets, formatSize(int(totalSize))))
|
||||
}
|
||||
}
|
||||
|
||||
if hasError {
|
||||
os.Exit(1)
|
||||
emit("FAIL", "1 or more issues found")
|
||||
} else {
|
||||
emit("ok", "No issues found")
|
||||
}
|
||||
return nil
|
||||
|
||||
return hasError
|
||||
}
|
||||
|
||||
func configDiffs() []string {
|
||||
def := defaultConfig()
|
||||
var diffs []string
|
||||
if config.DisplayAsciiArt != def.DisplayAsciiArt {
|
||||
diffs = append(diffs, fmt.Sprintf("display_ascii_art: %v", config.DisplayAsciiArt))
|
||||
}
|
||||
if config.Key.AlwaysPromptDelete != def.Key.AlwaysPromptDelete {
|
||||
diffs = append(diffs, fmt.Sprintf("key.always_prompt_delete: %v", config.Key.AlwaysPromptDelete))
|
||||
}
|
||||
if config.Key.AlwaysPromptOverwrite != def.Key.AlwaysPromptOverwrite {
|
||||
diffs = append(diffs, fmt.Sprintf("key.always_prompt_overwrite: %v", config.Key.AlwaysPromptOverwrite))
|
||||
}
|
||||
if config.Store.DefaultStoreName != def.Store.DefaultStoreName {
|
||||
diffs = append(diffs, fmt.Sprintf("store.default_store_name: %s", config.Store.DefaultStoreName))
|
||||
}
|
||||
if config.Store.AlwaysPromptDelete != def.Store.AlwaysPromptDelete {
|
||||
diffs = append(diffs, fmt.Sprintf("store.always_prompt_delete: %v", config.Store.AlwaysPromptDelete))
|
||||
}
|
||||
if config.Store.AlwaysPromptOverwrite != def.Store.AlwaysPromptOverwrite {
|
||||
diffs = append(diffs, fmt.Sprintf("store.always_prompt_overwrite: %v", config.Store.AlwaysPromptOverwrite))
|
||||
}
|
||||
if config.Git.AutoFetch != def.Git.AutoFetch {
|
||||
diffs = append(diffs, fmt.Sprintf("git.auto_fetch: %v", config.Git.AutoFetch))
|
||||
}
|
||||
if config.Git.AutoCommit != def.Git.AutoCommit {
|
||||
diffs = append(diffs, fmt.Sprintf("git.auto_commit: %v", config.Git.AutoCommit))
|
||||
}
|
||||
if config.Git.AutoPush != def.Git.AutoPush {
|
||||
diffs = append(diffs, fmt.Sprintf("git.auto_push: %v", config.Git.AutoPush))
|
||||
}
|
||||
return diffs
|
||||
}
|
||||
|
||||
func unexpectedFiles(dir string, allowed map[string]bool) bool {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !allowed[e.Name()] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func unexpectedDataFiles(dir string) bool {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() && name == ".git" {
|
||||
continue
|
||||
}
|
||||
if !e.IsDir() && (name == ".gitignore" || filepath.Ext(name) == ".ndjson") {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
118
cmd/doctor_test.go
Normal file
118
cmd/doctor_test.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDoctorCleanEnv(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("PDA_DATA", dataDir)
|
||||
t.Setenv("PDA_CONFIG", configDir)
|
||||
|
||||
var buf bytes.Buffer
|
||||
hasError := runDoctor(&buf)
|
||||
out := buf.String()
|
||||
|
||||
if hasError {
|
||||
t.Errorf("expected no errors, got hasError=true\noutput:\n%s", out)
|
||||
}
|
||||
for _, want := range []string{
|
||||
version,
|
||||
"Using default configuration",
|
||||
"No identity file found",
|
||||
"Git not initialised",
|
||||
"No stores found",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("expected %q in output, got:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorWithStores(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("PDA_DATA", dataDir)
|
||||
t.Setenv("PDA_CONFIG", configDir)
|
||||
|
||||
content := "{\"key\":\"foo\",\"value\":\"bar\",\"encoding\":\"text\"}\n" +
|
||||
"{\"key\":\"baz\",\"value\":\"qux\",\"encoding\":\"text\"}\n"
|
||||
if err := os.WriteFile(filepath.Join(dataDir, "test.ndjson"), []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
hasError := runDoctor(&buf)
|
||||
out := buf.String()
|
||||
|
||||
if hasError {
|
||||
t.Errorf("expected no errors, got hasError=true\noutput:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "1 store(s), 2 key(s), 0 secret(s)") {
|
||||
t.Errorf("expected store summary in output, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorIdentityPermissions(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("PDA_DATA", dataDir)
|
||||
t.Setenv("PDA_CONFIG", configDir)
|
||||
|
||||
idPath := filepath.Join(configDir, "identity.txt")
|
||||
if err := os.WriteFile(idPath, []byte("placeholder\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
runDoctor(&buf)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "Identity:") {
|
||||
t.Errorf("expected 'Identity:' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "should be 0600") {
|
||||
t.Errorf("expected permissions warning in output, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorGitInitialised(t *testing.T) {
|
||||
dataDir := t.TempDir()
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("PDA_DATA", dataDir)
|
||||
t.Setenv("PDA_CONFIG", configDir)
|
||||
|
||||
cmd := exec.Command("git", "init")
|
||||
cmd.Dir = dataDir
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Skipf("git not available: %v", err)
|
||||
}
|
||||
cmd = exec.Command("git", "commit", "--allow-empty", "-m", "init")
|
||||
cmd.Dir = dataDir
|
||||
cmd.Env = append(os.Environ(),
|
||||
"GIT_AUTHOR_NAME=test",
|
||||
"GIT_AUTHOR_EMAIL=test@test",
|
||||
"GIT_COMMITTER_NAME=test",
|
||||
"GIT_COMMITTER_EMAIL=test@test",
|
||||
)
|
||||
if err := cmd.Run(); err != nil {
|
||||
t.Fatalf("git commit: %v", err)
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
runDoctor(&buf)
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "Git initialised on") {
|
||||
t.Errorf("expected 'Git initialised on' in output, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "No git remote configured") {
|
||||
t.Errorf("expected 'No git remote configured' in output, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
7
testdata/doctor.ct
vendored
7
testdata/doctor.ct
vendored
|
|
@ -1,7 +0,0 @@
|
|||
# Doctor reports environment health
|
||||
$ pda doctor
|
||||
info Using default configuration
|
||||
ok Data directory accessible
|
||||
ok Identity file present
|
||||
info Git not initialised
|
||||
ok 5 store(s)
|
||||
Loading…
Add table
Add a link
Reference in a new issue