diff --git a/internal/cmd/doctor.go b/internal/cmd/doctor.go index db42dfef..a3212ed6 100644 --- a/internal/cmd/doctor.go +++ b/internal/cmd/doctor.go @@ -53,6 +53,7 @@ func runDoctor(cmd *cobra.Command, args []string) error { d := doctor.NewDoctor() // Register built-in checks + d.Register(doctor.NewTownGitCheck()) d.Register(doctor.NewDaemonCheck()) d.Register(doctor.NewBeadsDatabaseCheck()) d.Register(doctor.NewOrphanSessionCheck()) diff --git a/internal/doctor/town_git_check.go b/internal/doctor/town_git_check.go new file mode 100644 index 00000000..a0543c63 --- /dev/null +++ b/internal/doctor/town_git_check.go @@ -0,0 +1,71 @@ +package doctor + +import ( + "os" + "path/filepath" +) + +// TownGitCheck verifies that the town root directory is under version control. +// Having the town harness in git is optional but recommended for: +// - Backing up personal Gas Town configuration and operating history +// - Tracking mail and coordination beads +// - Easier federation across machines +type TownGitCheck struct { + BaseCheck +} + +// NewTownGitCheck creates a new town git version control check. +func NewTownGitCheck() *TownGitCheck { + return &TownGitCheck{ + BaseCheck: BaseCheck{ + CheckName: "town-git", + CheckDescription: "Verify town root is under version control", + }, + } +} + +// Run checks if the town root has a .git directory. +func (c *TownGitCheck) Run(ctx *CheckContext) *CheckResult { + gitDir := filepath.Join(ctx.TownRoot, ".git") + info, err := os.Stat(gitDir) + + if os.IsNotExist(err) { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: "Town root is not under version control", + Details: []string{ + "Your town harness contains personal configuration and operating history", + "Version control makes it easier to backup and federate across machines", + }, + FixHint: "Run 'git init' in your town root to initialize a repository", + } + } + + if err != nil { + return &CheckResult{ + Name: c.Name(), + Status: StatusError, + Message: "Failed to check town git status: " + err.Error(), + } + } + + // Verify it's actually a directory (not a file named .git) + if !info.IsDir() { + return &CheckResult{ + Name: c.Name(), + Status: StatusWarning, + Message: "Town root .git is not a directory", + Details: []string{ + "Expected .git to be a directory, but it's a file", + "This may indicate a git worktree or submodule configuration", + }, + } + } + + return &CheckResult{ + Name: c.Name(), + Status: StatusOK, + Message: "Town root is under version control", + } +} diff --git a/internal/doctor/town_git_check_test.go b/internal/doctor/town_git_check_test.go new file mode 100644 index 00000000..6fcb2bb7 --- /dev/null +++ b/internal/doctor/town_git_check_test.go @@ -0,0 +1,88 @@ +package doctor + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNewTownGitCheck(t *testing.T) { + check := NewTownGitCheck() + + if check.Name() != "town-git" { + t.Errorf("expected name 'town-git', got %q", check.Name()) + } + + if check.Description() == "" { + t.Error("expected non-empty description") + } + + if check.CanFix() { + t.Error("expected CanFix() to return false") + } +} + +func TestTownGitCheck_NoGitDir(t *testing.T) { + // Create temp directory without .git + tmpDir, err := os.MkdirTemp("", "town-git-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + ctx := &CheckContext{TownRoot: tmpDir} + check := NewTownGitCheck() + result := check.Run(ctx) + + if result.Status != StatusWarning { + t.Errorf("expected StatusWarning, got %v", result.Status) + } + + if result.FixHint == "" { + t.Error("expected non-empty FixHint") + } +} + +func TestTownGitCheck_WithGitDir(t *testing.T) { + // Create temp directory with .git + tmpDir, err := os.MkdirTemp("", "town-git-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + gitDir := filepath.Join(tmpDir, ".git") + if err := os.Mkdir(gitDir, 0755); err != nil { + t.Fatalf("failed to create .git dir: %v", err) + } + + ctx := &CheckContext{TownRoot: tmpDir} + check := NewTownGitCheck() + result := check.Run(ctx) + + if result.Status != StatusOK { + t.Errorf("expected StatusOK, got %v", result.Status) + } +} + +func TestTownGitCheck_GitIsFile(t *testing.T) { + // Create temp directory with .git as a file (worktree case) + tmpDir, err := os.MkdirTemp("", "town-git-test-*") + if err != nil { + t.Fatalf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tmpDir) + + gitFile := filepath.Join(tmpDir, ".git") + if err := os.WriteFile(gitFile, []byte("gitdir: /some/path"), 0644); err != nil { + t.Fatalf("failed to create .git file: %v", err) + } + + ctx := &CheckContext{TownRoot: tmpDir} + check := NewTownGitCheck() + result := check.Run(ctx) + + if result.Status != StatusWarning { + t.Errorf("expected StatusWarning for .git file, got %v", result.Status) + } +}