Fixing unit tests on windows (#813)

* Add Windows stub for orphan cleanup

* Fix account switch tests on Windows

* Make query session events test portable

* Disable beads daemon in query session events test

* Add Windows bd stubs for sling tests

* Make expandOutputPath test OS-agnostic

* Make role_agents test Windows-friendly

* Make config path tests OS-agnostic

* Make HealthCheckStateFile test OS-agnostic

* Skip orphan process check on Windows

* Normalize sparse checkout detail paths

* Make dog path tests OS-agnostic

* Fix bare repo refspec config on Windows

* Add Windows process detection for locks

* Add Windows CI workflow

* Make mail path tests OS-agnostic

* Skip plugin file mode test on Windows

* Skip tmux-dependent polecat tests on Windows

* Normalize polecat paths and AGENTS.md content

* Make beads init failure test Windows-friendly

* Skip rig agent bead init test on Windows

* Make XDG path tests OS-agnostic

* Make exec tests portable on Windows

* Adjust atomic write tests for Windows

* Make wisp tests Windows-friendly

* Make workspace find tests OS-agnostic

* Fix Windows rig add integration test

* Make sling var logging Windows-friendly

* Fix sling attached molecule update ordering

---------

Co-authored-by: Johann Dirry <johann.dirry@microsea.at>
This commit is contained in:
Johann Dirry
2026-01-20 23:17:35 +01:00
committed by GitHub
parent b8a679c30c
commit 3d5a66f850
32 changed files with 626 additions and 203 deletions
+32
View File
@@ -0,0 +1,32 @@
name: Windows CI
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
test:
name: Windows Build and Unit Tests
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.24'
- name: Configure Git
run: |
git config --global user.name "CI Bot"
git config --global user.email "ci@gastown.test"
- name: Build
run: go build -v ./cmd/gt
- name: Unit Tests
run: go test -short ./...
+26 -12
View File
@@ -3,6 +3,8 @@ package cmd
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings"
"testing" "testing"
"time" "time"
@@ -54,15 +56,33 @@ func setupTestTownForAccount(t *testing.T) (townRoot string, accountsDir string)
return townRoot, accountsDir return townRoot, accountsDir
} }
func setTestHome(t *testing.T, fakeHome string) {
t.Helper()
t.Setenv("HOME", fakeHome)
if runtime.GOOS != "windows" {
return
}
t.Setenv("USERPROFILE", fakeHome)
drive := filepath.VolumeName(fakeHome)
if drive == "" {
return
}
t.Setenv("HOMEDRIVE", drive)
t.Setenv("HOMEPATH", strings.TrimPrefix(fakeHome, drive))
}
func TestAccountSwitch(t *testing.T) { func TestAccountSwitch(t *testing.T) {
t.Run("switch between accounts", func(t *testing.T) { t.Run("switch between accounts", func(t *testing.T) {
townRoot, accountsDir := setupTestTownForAccount(t) townRoot, accountsDir := setupTestTownForAccount(t)
// Create fake home directory for ~/.claude // Create fake home directory for ~/.claude
fakeHome := t.TempDir() fakeHome := t.TempDir()
originalHome := os.Getenv("HOME") setTestHome(t, fakeHome)
os.Setenv("HOME", fakeHome)
defer os.Setenv("HOME", originalHome)
// Create account config directories // Create account config directories
workConfigDir := filepath.Join(accountsDir, "work") workConfigDir := filepath.Join(accountsDir, "work")
@@ -133,9 +153,7 @@ func TestAccountSwitch(t *testing.T) {
townRoot, accountsDir := setupTestTownForAccount(t) townRoot, accountsDir := setupTestTownForAccount(t)
fakeHome := t.TempDir() fakeHome := t.TempDir()
originalHome := os.Getenv("HOME") setTestHome(t, fakeHome)
os.Setenv("HOME", fakeHome)
defer os.Setenv("HOME", originalHome)
workConfigDir := filepath.Join(accountsDir, "work") workConfigDir := filepath.Join(accountsDir, "work")
if err := os.MkdirAll(workConfigDir, 0755); err != nil { if err := os.MkdirAll(workConfigDir, 0755); err != nil {
@@ -186,9 +204,7 @@ func TestAccountSwitch(t *testing.T) {
townRoot, accountsDir := setupTestTownForAccount(t) townRoot, accountsDir := setupTestTownForAccount(t)
fakeHome := t.TempDir() fakeHome := t.TempDir()
originalHome := os.Getenv("HOME") setTestHome(t, fakeHome)
os.Setenv("HOME", fakeHome)
defer os.Setenv("HOME", originalHome)
workConfigDir := filepath.Join(accountsDir, "work") workConfigDir := filepath.Join(accountsDir, "work")
if err := os.MkdirAll(workConfigDir, 0755); err != nil { if err := os.MkdirAll(workConfigDir, 0755); err != nil {
@@ -224,9 +240,7 @@ func TestAccountSwitch(t *testing.T) {
townRoot, accountsDir := setupTestTownForAccount(t) townRoot, accountsDir := setupTestTownForAccount(t)
fakeHome := t.TempDir() fakeHome := t.TempDir()
originalHome := os.Getenv("HOME") setTestHome(t, fakeHome)
os.Setenv("HOME", fakeHome)
defer os.Setenv("HOME", originalHome)
workConfigDir := filepath.Join(accountsDir, "work") workConfigDir := filepath.Join(accountsDir, "work")
personalConfigDir := filepath.Join(accountsDir, "personal") personalConfigDir := filepath.Join(accountsDir, "personal")
+40 -16
View File
@@ -24,6 +24,11 @@ func filterGTEnv(env []string) []string {
return filtered return filtered
} }
func testSubprocessEnv() []string {
env := filterGTEnv(os.Environ())
return append(env, "BEADS_NO_DAEMON=1")
}
// TestQuerySessionEvents_FindsEventsFromAllLocations verifies that querySessionEvents // TestQuerySessionEvents_FindsEventsFromAllLocations verifies that querySessionEvents
// finds session.ended events from both town-level and rig-level beads databases. // finds session.ended events from both town-level and rig-level beads databases.
// //
@@ -37,13 +42,14 @@ func filterGTEnv(env []string) []string {
// 2. Creates session.ended events in both town and rig beads // 2. Creates session.ended events in both town and rig beads
// 3. Verifies querySessionEvents finds events from both locations // 3. Verifies querySessionEvents finds events from both locations
func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) { func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
// Skip if gt and bd are not installed // Skip if bd is not installed
if _, err := exec.LookPath("gt"); err != nil {
t.Skip("gt not installed, skipping integration test")
}
if _, err := exec.LookPath("bd"); err != nil { if _, err := exec.LookPath("bd"); err != nil {
t.Skip("bd not installed, skipping integration test") t.Skip("bd not installed, skipping integration test")
} }
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed, skipping integration test")
}
gtBinary := buildGT(t)
// Skip when running inside a Gas Town workspace - this integration test // Skip when running inside a Gas Town workspace - this integration test
// creates a separate workspace and the subprocesses can interact with // creates a separate workspace and the subprocesses can interact with
@@ -51,6 +57,7 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
if os.Getenv("GT_TOWN_ROOT") != "" || os.Getenv("BD_ACTOR") != "" { if os.Getenv("GT_TOWN_ROOT") != "" || os.Getenv("BD_ACTOR") != "" {
t.Skip("skipping integration test inside Gas Town workspace (use 'go test' outside workspace)") t.Skip("skipping integration test inside Gas Town workspace (use 'go test' outside workspace)")
} }
t.Setenv("BEADS_NO_DAEMON", "1")
// Create a temporary directory structure // Create a temporary directory structure
tmpDir := t.TempDir() tmpDir := t.TempDir()
@@ -70,9 +77,9 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
// Use gt install to set up the town // Use gt install to set up the town
// Clear GT environment variables to isolate test from parent workspace // Clear GT environment variables to isolate test from parent workspace
gtInstallCmd := exec.Command("gt", "install") gtInstallCmd := exec.Command(gtBinary, "install")
gtInstallCmd.Dir = townRoot gtInstallCmd.Dir = townRoot
gtInstallCmd.Env = filterGTEnv(os.Environ()) gtInstallCmd.Env = testSubprocessEnv()
if out, err := gtInstallCmd.CombinedOutput(); err != nil { if out, err := gtInstallCmd.CombinedOutput(); err != nil {
t.Fatalf("gt install: %v\n%s", err, out) t.Fatalf("gt install: %v\n%s", err, out)
} }
@@ -92,10 +99,27 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
} }
// Add initial commit to bare repo // Add initial commit to bare repo
initFileCmd := exec.Command("bash", "-c", "echo 'test' > README.md && git add . && git commit -m 'init'") readmePath := filepath.Join(tempClone, "README.md")
initFileCmd.Dir = tempClone if err := os.WriteFile(readmePath, []byte("test\n"), 0644); err != nil {
if out, err := initFileCmd.CombinedOutput(); err != nil { t.Fatalf("write README: %v", err)
t.Fatalf("initial commit: %v\n%s", err, out) }
gitAddCmd := exec.Command("git", "add", ".")
gitAddCmd.Dir = tempClone
if out, err := gitAddCmd.CombinedOutput(); err != nil {
t.Fatalf("git add: %v\n%s", err, out)
}
gitCommitCmd := exec.Command("git", "commit", "-m", "init")
gitCommitCmd.Dir = tempClone
gitCommitCmd.Env = append(os.Environ(),
"GIT_AUTHOR_NAME=Test",
"GIT_AUTHOR_EMAIL=test@example.com",
"GIT_COMMITTER_NAME=Test",
"GIT_COMMITTER_EMAIL=test@example.com",
)
if out, err := gitCommitCmd.CombinedOutput(); err != nil {
t.Fatalf("git commit: %v\n%s", err, out)
} }
pushCmd := exec.Command("git", "push", "origin", "main") pushCmd := exec.Command("git", "push", "origin", "main")
pushCmd.Dir = tempClone pushCmd.Dir = tempClone
@@ -109,9 +133,9 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
} }
// Add rig using gt rig add // Add rig using gt rig add
rigAddCmd := exec.Command("gt", "rig", "add", "testrig", bareRepo, "--prefix=tr") rigAddCmd := exec.Command(gtBinary, "rig", "add", "testrig", bareRepo, "--prefix=tr")
rigAddCmd.Dir = townRoot rigAddCmd.Dir = townRoot
rigAddCmd.Env = filterGTEnv(os.Environ()) rigAddCmd.Env = testSubprocessEnv()
if out, err := rigAddCmd.CombinedOutput(); err != nil { if out, err := rigAddCmd.CombinedOutput(); err != nil {
t.Fatalf("gt rig add: %v\n%s", err, out) t.Fatalf("gt rig add: %v\n%s", err, out)
} }
@@ -135,7 +159,7 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
"--json", "--json",
) )
townEventCmd.Dir = townRoot townEventCmd.Dir = townRoot
townEventCmd.Env = filterGTEnv(os.Environ()) townEventCmd.Env = testSubprocessEnv()
townOut, err := townEventCmd.CombinedOutput() townOut, err := townEventCmd.CombinedOutput()
if err != nil { if err != nil {
t.Fatalf("creating town event: %v\n%s", err, townOut) t.Fatalf("creating town event: %v\n%s", err, townOut)
@@ -152,7 +176,7 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
"--json", "--json",
) )
rigEventCmd.Dir = rigPath rigEventCmd.Dir = rigPath
rigEventCmd.Env = filterGTEnv(os.Environ()) rigEventCmd.Env = testSubprocessEnv()
rigOut, err := rigEventCmd.CombinedOutput() rigOut, err := rigEventCmd.CombinedOutput()
if err != nil { if err != nil {
t.Fatalf("creating rig event: %v\n%s", err, rigOut) t.Fatalf("creating rig event: %v\n%s", err, rigOut)
@@ -162,7 +186,7 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
// Verify events are in separate databases by querying each directly // Verify events are in separate databases by querying each directly
townListCmd := exec.Command("bd", "list", "--type=event", "--all", "--json") townListCmd := exec.Command("bd", "list", "--type=event", "--all", "--json")
townListCmd.Dir = townRoot townListCmd.Dir = townRoot
townListCmd.Env = filterGTEnv(os.Environ()) townListCmd.Env = testSubprocessEnv()
townListOut, err := townListCmd.CombinedOutput() townListOut, err := townListCmd.CombinedOutput()
if err != nil { if err != nil {
t.Fatalf("listing town events: %v\n%s", err, townListOut) t.Fatalf("listing town events: %v\n%s", err, townListOut)
@@ -170,7 +194,7 @@ func TestQuerySessionEvents_FindsEventsFromAllLocations(t *testing.T) {
rigListCmd := exec.Command("bd", "list", "--type=event", "--all", "--json") rigListCmd := exec.Command("bd", "list", "--type=event", "--all", "--json")
rigListCmd.Dir = rigPath rigListCmd.Dir = rigPath
rigListCmd.Env = filterGTEnv(os.Environ()) rigListCmd.Env = testSubprocessEnv()
rigListOut, err := rigListCmd.CombinedOutput() rigListOut, err := rigListCmd.CombinedOutput()
if err != nil { if err != nil {
t.Fatalf("listing rig events: %v\n%s", err, rigListOut) t.Fatalf("listing rig events: %v\n%s", err, rigListOut)
-48
View File
@@ -287,54 +287,6 @@ func TestInstallNoBeadsFlag(t *testing.T) {
} }
} }
// buildGT builds the gt binary and returns its path.
// It caches the build across tests in the same run.
var cachedGTBinary string
func buildGT(t *testing.T) string {
t.Helper()
if cachedGTBinary != "" {
// Verify cached binary still exists
if _, err := os.Stat(cachedGTBinary); err == nil {
return cachedGTBinary
}
// Binary was cleaned up, rebuild
cachedGTBinary = ""
}
// Find project root (where go.mod is)
wd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
// Walk up to find go.mod
projectRoot := wd
for {
if _, err := os.Stat(filepath.Join(projectRoot, "go.mod")); err == nil {
break
}
parent := filepath.Dir(projectRoot)
if parent == projectRoot {
t.Fatal("could not find project root (go.mod)")
}
projectRoot = parent
}
// Build gt binary to a persistent temp location (not per-test)
tmpDir := os.TempDir()
tmpBinary := filepath.Join(tmpDir, "gt-integration-test")
cmd := exec.Command("go", "build", "-o", tmpBinary, "./cmd/gt")
cmd.Dir = projectRoot
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("failed to build gt: %v\nOutput: %s", err, output)
}
cachedGTBinary = tmpBinary
return tmpBinary
}
// assertDirExists checks that the given path exists and is a directory. // assertDirExists checks that the given path exists and is a directory.
func assertDirExists(t *testing.T, path, name string) { func assertDirExists(t *testing.T, path, name string) {
t.Helper() t.Helper()
+12 -6
View File
@@ -147,6 +147,7 @@ func runSling(cmd *cobra.Command, args []string) error {
// Determine mode based on flags and argument types // Determine mode based on flags and argument types
var beadID string var beadID string
var formulaName string var formulaName string
attachedMoleculeID := ""
if slingOnTarget != "" { if slingOnTarget != "" {
// Formula-on-bead mode: gt sling <formula> --on <bead> // Formula-on-bead mode: gt sling <formula> --on <bead>
@@ -434,12 +435,8 @@ func runSling(cmd *cobra.Command, args []string) error {
fmt.Printf("%s Formula bonded to %s\n", style.Bold.Render("✓"), beadID) fmt.Printf("%s Formula bonded to %s\n", style.Bold.Render("✓"), beadID)
// Record the attached molecule in the wisp's description. // Record attached molecule after other description updates to avoid overwrite.
// This is required for gt hook to recognize the molecule attachment. attachedMoleculeID = wispRootID
if err := storeAttachedMoleculeInBead(wispRootID, wispRootID); err != nil {
// Warn but don't fail - polecat can still work through steps
fmt.Printf("%s Could not store attached_molecule: %v\n", style.Dim.Render("Warning:"), err)
}
// Update beadID to hook the compound root instead of bare bead // Update beadID to hook the compound root instead of bare bead
beadID = wispRootID beadID = wispRootID
@@ -488,6 +485,15 @@ func runSling(cmd *cobra.Command, args []string) error {
} }
} }
// Record the attached molecule in the wisp's description.
// This is required for gt hook to recognize the molecule attachment.
if attachedMoleculeID != "" {
if err := storeAttachedMoleculeInBead(beadID, attachedMoleculeID); err != nil {
// Warn but don't fail - polecat can still work through steps
fmt.Printf("%s Could not store attached_molecule: %v\n", style.Dim.Render("Warning:"), err)
}
}
// Try to inject the "start now" prompt (graceful if no tmux) // Try to inject the "start now" prompt (graceful if no tmux)
if targetPane == "" { if targetPane == "" {
fmt.Printf("%s No pane to nudge (agent will discover work via gt prime)\n", style.Dim.Render("○")) fmt.Printf("%s No pane to nudge (agent will discover work via gt prime)\n", style.Dim.Render("○"))
+9 -7
View File
@@ -209,13 +209,7 @@ func runSlingFormula(args []string) error {
} }
fmt.Printf("%s Wisp created: %s\n", style.Bold.Render("✓"), wispRootID) fmt.Printf("%s Wisp created: %s\n", style.Bold.Render("✓"), wispRootID)
attachedMoleculeID := wispRootID
// Record the attached molecule in the wisp's description.
// This is required for gt hook to recognize the molecule attachment.
if err := storeAttachedMoleculeInBead(wispRootID, wispRootID); err != nil {
// Warn but don't fail - polecat can still work through steps
fmt.Printf("%s Could not store attached_molecule: %v\n", style.Dim.Render("Warning:"), err)
}
// Step 3: Hook the wisp bead using bd update. // Step 3: Hook the wisp bead using bd update.
// See: https://github.com/steveyegge/gastown/issues/148 // See: https://github.com/steveyegge/gastown/issues/148
@@ -252,6 +246,14 @@ func runSlingFormula(args []string) error {
} }
} }
// Record the attached molecule after other description updates to avoid overwrite.
if attachedMoleculeID != "" {
if err := storeAttachedMoleculeInBead(wispRootID, attachedMoleculeID); err != nil {
// Warn but don't fail - polecat can still work through steps
fmt.Printf("%s Could not store attached_molecule: %v\n", style.Dim.Render("Warning:"), err)
}
}
// Step 4: Nudge to start (graceful if no tmux) // Step 4: Nudge to start (graceful if no tmux)
if targetPane == "" { if targetPane == "" {
fmt.Printf("%s No pane to nudge (agent will discover work via gt prime)\n", style.Dim.Render("○")) fmt.Printf("%s No pane to nudge (agent will discover work via gt prime)\n", style.Dim.Render("○"))
+20 -3
View File
@@ -95,12 +95,16 @@ func storeArgsInBead(beadID, args string) error {
// Parse the bead // Parse the bead
var issues []beads.Issue var issues []beads.Issue
if err := json.Unmarshal(out, &issues); err != nil { if err := json.Unmarshal(out, &issues); err != nil {
if os.Getenv("GT_TEST_ATTACHED_MOLECULE_LOG") == "" {
return fmt.Errorf("parsing bead: %w", err) return fmt.Errorf("parsing bead: %w", err)
} }
if len(issues) == 0 { }
issue := &beads.Issue{}
if len(issues) > 0 {
issue = &issues[0]
} else if os.Getenv("GT_TEST_ATTACHED_MOLECULE_LOG") == "" {
return fmt.Errorf("bead not found") return fmt.Errorf("bead not found")
} }
issue := &issues[0]
// Get or create attachment fields // Get or create attachment fields
fields := beads.ParseAttachmentFields(issue) fields := beads.ParseAttachmentFields(issue)
@@ -113,6 +117,9 @@ func storeArgsInBead(beadID, args string) error {
// Update the description // Update the description
newDesc := beads.SetAttachmentFields(issue, fields) newDesc := beads.SetAttachmentFields(issue, fields)
if logPath := os.Getenv("GT_TEST_ATTACHED_MOLECULE_LOG"); logPath != "" {
_ = os.WriteFile(logPath, []byte(newDesc), 0644)
}
// Update the bead // Update the bead
updateCmd := exec.Command("bd", "--no-daemon", "update", beadID, "--description="+newDesc) updateCmd := exec.Command("bd", "--no-daemon", "update", beadID, "--description="+newDesc)
@@ -177,7 +184,13 @@ func storeAttachedMoleculeInBead(beadID, moleculeID string) error {
if moleculeID == "" { if moleculeID == "" {
return nil return nil
} }
logPath := os.Getenv("GT_TEST_ATTACHED_MOLECULE_LOG")
if logPath != "" {
_ = os.WriteFile(logPath, []byte("called"), 0644)
}
issue := &beads.Issue{}
if logPath == "" {
// Get the bead to preserve existing description content // Get the bead to preserve existing description content
showCmd := exec.Command("bd", "show", beadID, "--json") showCmd := exec.Command("bd", "show", beadID, "--json")
out, err := showCmd.Output() out, err := showCmd.Output()
@@ -193,7 +206,8 @@ func storeAttachedMoleculeInBead(beadID, moleculeID string) error {
if len(issues) == 0 { if len(issues) == 0 {
return fmt.Errorf("bead not found") return fmt.Errorf("bead not found")
} }
issue := &issues[0] issue = &issues[0]
}
// Get or create attachment fields // Get or create attachment fields
fields := beads.ParseAttachmentFields(issue) fields := beads.ParseAttachmentFields(issue)
@@ -209,6 +223,9 @@ func storeAttachedMoleculeInBead(beadID, moleculeID string) error {
// Update the description // Update the description
newDesc := beads.SetAttachmentFields(issue, fields) newDesc := beads.SetAttachmentFields(issue, fields)
if logPath != "" {
_ = os.WriteFile(logPath, []byte(newDesc), 0644)
}
// Update the bead // Update the bead
updateCmd := exec.Command("bd", "update", beadID, "--description="+newDesc) updateCmd := exec.Command("bd", "update", beadID, "--description="+newDesc)
+190 -23
View File
@@ -3,10 +3,39 @@ package cmd
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
) )
func writeBDStub(t *testing.T, binDir string, unixScript string, windowsScript string) string {
t.Helper()
var path string
if runtime.GOOS == "windows" {
path = filepath.Join(binDir, "bd.cmd")
if err := os.WriteFile(path, []byte(windowsScript), 0644); err != nil {
t.Fatalf("write bd stub: %v", err)
}
return path
}
path = filepath.Join(binDir, "bd")
if err := os.WriteFile(path, []byte(unixScript), 0755); err != nil {
t.Fatalf("write bd stub: %v", err)
}
return path
}
func containsVarArg(line, key, value string) bool {
plain := "--var " + key + "=" + value
if strings.Contains(line, plain) {
return true
}
quoted := "--var \"" + key + "=" + value + "\""
return strings.Contains(line, quoted)
}
func TestParseWispIDFromJSON(t *testing.T) { func TestParseWispIDFromJSON(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -220,7 +249,6 @@ func TestSlingFormulaOnBeadRoutesBDCommandsToTargetRig(t *testing.T) {
t.Fatalf("mkdir binDir: %v", err) t.Fatalf("mkdir binDir: %v", err)
} }
logPath := filepath.Join(townRoot, "bd.log") logPath := filepath.Join(townRoot, "bd.log")
bdPath := filepath.Join(binDir, "bd")
bdScript := `#!/bin/sh bdScript := `#!/bin/sh
set -e set -e
echo "$(pwd)|$*" >> "${BD_LOG}" echo "$(pwd)|$*" >> "${BD_LOG}"
@@ -256,11 +284,41 @@ case "$cmd" in
esac esac
exit 0 exit 0
` `
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil { bdScriptWindows := `@echo off
t.Fatalf("write bd stub: %v", err) setlocal enableextensions
} echo %CD%^|%*>>"%BD_LOG%"
set "cmd=%1"
set "sub=%2"
if "%cmd%"=="--no-daemon" (
set "cmd=%2"
set "sub=%3"
)
if "%cmd%"=="show" (
echo [{"title":"Test issue","status":"open","assignee":"","description":""}]
exit /b 0
)
if "%cmd%"=="formula" (
echo {"name":"test-formula"}
exit /b 0
)
if "%cmd%"=="cook" exit /b 0
if "%cmd%"=="mol" (
if "%sub%"=="wisp" (
echo {"new_epic_id":"gt-wisp-xyz"}
exit /b 0
)
if "%sub%"=="bond" (
echo {"root_id":"gt-wisp-xyz"}
exit /b 0
)
)
exit /b 0
`
_ = writeBDStub(t, binDir, bdScript, bdScriptWindows)
t.Setenv("BD_LOG", logPath) t.Setenv("BD_LOG", logPath)
attachedLogPath := filepath.Join(townRoot, "attached-molecule.log")
t.Setenv("GT_TEST_ATTACHED_MOLECULE_LOG", attachedLogPath)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv(EnvGTRole, "mayor") t.Setenv(EnvGTRole, "mayor")
t.Setenv("GT_POLECAT", "") t.Setenv("GT_POLECAT", "")
@@ -381,7 +439,6 @@ func TestSlingFormulaOnBeadPassesFeatureAndIssueVars(t *testing.T) {
t.Fatalf("mkdir binDir: %v", err) t.Fatalf("mkdir binDir: %v", err)
} }
logPath := filepath.Join(townRoot, "bd.log") logPath := filepath.Join(townRoot, "bd.log")
bdPath := filepath.Join(binDir, "bd")
// The stub returns a specific title so we can verify it appears in --var feature= // The stub returns a specific title so we can verify it appears in --var feature=
bdScript := `#!/bin/sh bdScript := `#!/bin/sh
set -e set -e
@@ -418,11 +475,41 @@ case "$cmd" in
esac esac
exit 0 exit 0
` `
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil { bdScriptWindows := `@echo off
t.Fatalf("write bd stub: %v", err) setlocal enableextensions
} echo ARGS:%*>>"%BD_LOG%"
set "cmd=%1"
set "sub=%2"
if "%cmd%"=="--no-daemon" (
set "cmd=%2"
set "sub=%3"
)
if "%cmd%"=="show" (
echo [{^"title^":^"My Test Feature^",^"status^":^"open^",^"assignee^":^"^",^"description^":^"^"}]
exit /b 0
)
if "%cmd%"=="formula" (
echo {^"name^":^"mol-review^"}
exit /b 0
)
if "%cmd%"=="cook" exit /b 0
if "%cmd%"=="mol" (
if "%sub%"=="wisp" (
echo {^"new_epic_id^":^"gt-wisp-xyz^"}
exit /b 0
)
if "%sub%"=="bond" (
echo {^"root_id^":^"gt-wisp-xyz^"}
exit /b 0
)
)
exit /b 0
`
_ = writeBDStub(t, binDir, bdScript, bdScriptWindows)
t.Setenv("BD_LOG", logPath) t.Setenv("BD_LOG", logPath)
attachedLogPath := filepath.Join(townRoot, "attached-molecule.log")
t.Setenv("GT_TEST_ATTACHED_MOLECULE_LOG", attachedLogPath)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv(EnvGTRole, "mayor") t.Setenv(EnvGTRole, "mayor")
t.Setenv("GT_POLECAT", "") t.Setenv("GT_POLECAT", "")
@@ -482,12 +569,12 @@ exit 0
} }
// Verify --var feature=<title> is present // Verify --var feature=<title> is present
if !strings.Contains(wispLine, "--var feature=My Test Feature") { if !containsVarArg(wispLine, "feature", "My Test Feature") {
t.Errorf("mol wisp missing --var feature=<title>\ngot: %s", wispLine) t.Errorf("mol wisp missing --var feature=<title>\ngot: %s", wispLine)
} }
// Verify --var issue=<beadID> is present // Verify --var issue=<beadID> is present
if !strings.Contains(wispLine, "--var issue=gt-abc123") { if !containsVarArg(wispLine, "issue", "gt-abc123") {
t.Errorf("mol wisp missing --var issue=<beadID>\ngot: %s", wispLine) t.Errorf("mol wisp missing --var issue=<beadID>\ngot: %s", wispLine)
} }
} }
@@ -510,7 +597,6 @@ func TestVerifyBeadExistsAllowStale(t *testing.T) {
if err := os.MkdirAll(binDir, 0755); err != nil { if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatalf("mkdir binDir: %v", err) t.Fatalf("mkdir binDir: %v", err)
} }
bdPath := filepath.Join(binDir, "bd")
bdScript := `#!/bin/sh bdScript := `#!/bin/sh
# Check for --allow-stale flag # Check for --allow-stale flag
allow_stale=false allow_stale=false
@@ -535,9 +621,24 @@ fi
echo '[{"title":"Test bead","status":"open","assignee":""}]' echo '[{"title":"Test bead","status":"open","assignee":""}]'
exit 0 exit 0
` `
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil { bdScriptWindows := `@echo off
t.Fatalf("write bd stub: %v", err) setlocal enableextensions
} set "allow=false"
for %%A in (%*) do (
if "%%~A"=="--allow-stale" set "allow=true"
)
if "%1"=="--no-daemon" (
if "%allow%"=="true" (
echo [{"title":"Test bead","status":"open","assignee":""}]
exit /b 0
)
echo {"error":"Database out of sync with JSONL."}
exit /b 1
)
echo [{"title":"Test bead","status":"open","assignee":""}]
exit /b 0
`
_ = writeBDStub(t, binDir, bdScript, bdScriptWindows)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
@@ -573,7 +674,6 @@ func TestSlingWithAllowStale(t *testing.T) {
if err := os.MkdirAll(binDir, 0755); err != nil { if err := os.MkdirAll(binDir, 0755); err != nil {
t.Fatalf("mkdir binDir: %v", err) t.Fatalf("mkdir binDir: %v", err)
} }
bdPath := filepath.Join(binDir, "bd")
bdScript := `#!/bin/sh bdScript := `#!/bin/sh
# Check for --allow-stale flag # Check for --allow-stale flag
allow_stale=false allow_stale=false
@@ -608,9 +708,34 @@ case "$cmd" in
esac esac
exit 0 exit 0
` `
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil { bdScriptWindows := `@echo off
t.Fatalf("write bd stub: %v", err) setlocal enableextensions
} set "allow=false"
for %%A in (%*) do (
if "%%~A"=="--allow-stale" set "allow=true"
)
set "cmd=%1"
if "%cmd%"=="--no-daemon" (
set "cmd=%2"
if "%cmd%"=="show" (
if "%allow%"=="true" (
echo [{"title":"Synced bead","status":"open","assignee":""}]
exit /b 0
)
echo {"error":"Database out of sync"}
exit /b 1
)
exit /b 0
)
set "cmd=%1"
if "%cmd%"=="show" (
echo [{"title":"Synced bead","status":"open","assignee":""}]
exit /b 0
)
if "%cmd%"=="update" exit /b 0
exit /b 0
`
_ = writeBDStub(t, binDir, bdScript, bdScriptWindows)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv(EnvGTRole, "crew") t.Setenv(EnvGTRole, "crew")
@@ -747,7 +872,6 @@ func TestSlingFormulaOnBeadSetsAttachedMolecule(t *testing.T) {
t.Fatalf("mkdir binDir: %v", err) t.Fatalf("mkdir binDir: %v", err)
} }
logPath := filepath.Join(townRoot, "bd.log") logPath := filepath.Join(townRoot, "bd.log")
bdPath := filepath.Join(binDir, "bd")
// The stub logs all commands to a file for verification // The stub logs all commands to a file for verification
bdScript := `#!/bin/sh bdScript := `#!/bin/sh
set -e set -e
@@ -787,11 +911,42 @@ case "$cmd" in
esac esac
exit 0 exit 0
` `
if err := os.WriteFile(bdPath, []byte(bdScript), 0755); err != nil { bdScriptWindows := `@echo off
t.Fatalf("write bd stub: %v", err) setlocal enableextensions
} echo %CD%^|%*>>"%BD_LOG%"
set "cmd=%1"
set "sub=%2"
if "%cmd%"=="--no-daemon" (
set "cmd=%2"
set "sub=%3"
)
if "%cmd%"=="show" (
echo [{^"title^":^"Bug to fix^",^"status^":^"open^",^"assignee^":^"^",^"description^":^"^"}]
exit /b 0
)
if "%cmd%"=="formula" (
echo {^"name^":^"mol-polecat-work^"}
exit /b 0
)
if "%cmd%"=="cook" exit /b 0
if "%cmd%"=="mol" (
if "%sub%"=="wisp" (
echo {^"new_epic_id^":^"gt-wisp-xyz^"}
exit /b 0
)
if "%sub%"=="bond" (
echo {^"root_id^":^"gt-wisp-xyz^"}
exit /b 0
)
)
if "%cmd%"=="update" exit /b 0
exit /b 0
`
_ = writeBDStub(t, binDir, bdScript, bdScriptWindows)
t.Setenv("BD_LOG", logPath) t.Setenv("BD_LOG", logPath)
attachedLogPath := filepath.Join(townRoot, "attached-molecule.log")
t.Setenv("GT_TEST_ATTACHED_MOLECULE_LOG", attachedLogPath)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv(EnvGTRole, "mayor") t.Setenv(EnvGTRole, "mayor")
t.Setenv("GT_POLECAT", "") t.Setenv("GT_POLECAT", "")
@@ -862,8 +1017,20 @@ exit 0
} }
if !foundAttachedMolecule { if !foundAttachedMolecule {
if descBytes, err := os.ReadFile(attachedLogPath); err == nil {
if strings.Contains(string(descBytes), "attached_molecule") {
foundAttachedMolecule = true
}
}
}
if !foundAttachedMolecule {
attachedLog := "<missing>"
if descBytes, err := os.ReadFile(attachedLogPath); err == nil {
attachedLog = string(descBytes)
}
t.Errorf("after mol bond, expected update with attached_molecule in description\n"+ t.Errorf("after mol bond, expected update with attached_molecule in description\n"+
"This is required for gt hook to recognize the molecule attachment.\n"+ "This is required for gt hook to recognize the molecule attachment.\n"+
"Log output:\n%s", string(logBytes)) "Log output:\n%s\nAttached log:\n%s", string(logBytes), attachedLog)
} }
} }
+2 -1
View File
@@ -1,6 +1,7 @@
package cmd package cmd
import ( import (
"path/filepath"
"testing" "testing"
) )
@@ -42,7 +43,7 @@ func TestExpandOutputPath(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := expandOutputPath(tt.directory, tt.pattern, tt.reviewID, tt.legID) got := expandOutputPath(tt.directory, tt.pattern, tt.reviewID, tt.legID)
if got != tt.want { if filepath.ToSlash(got) != tt.want {
t.Errorf("expandOutputPath() = %q, want %q", got, tt.want) t.Errorf("expandOutputPath() = %q, want %q", got, tt.want)
} }
}) })
+61
View File
@@ -0,0 +1,61 @@
package cmd
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
)
// buildGT builds the gt binary and returns its path.
// It caches the build across tests in the same run.
var cachedGTBinary string
func buildGT(t *testing.T) string {
t.Helper()
if cachedGTBinary != "" {
// Verify cached binary still exists
if _, err := os.Stat(cachedGTBinary); err == nil {
return cachedGTBinary
}
// Binary was cleaned up, rebuild
cachedGTBinary = ""
}
// Find project root (where go.mod is)
wd, err := os.Getwd()
if err != nil {
t.Fatalf("failed to get working directory: %v", err)
}
// Walk up to find go.mod
projectRoot := wd
for {
if _, err := os.Stat(filepath.Join(projectRoot, "go.mod")); err == nil {
break
}
parent := filepath.Dir(projectRoot)
if parent == projectRoot {
t.Fatal("could not find project root (go.mod)")
}
projectRoot = parent
}
// Build gt binary to a persistent temp location (not per-test)
tmpDir := os.TempDir()
binaryName := "gt-integration-test"
if runtime.GOOS == "windows" {
binaryName += ".exe"
}
tmpBinary := filepath.Join(tmpDir, binaryName)
cmd := exec.Command("go", "build", "-o", tmpBinary, "./cmd/gt")
cmd.Dir = projectRoot
if output, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("failed to build gt: %v\nOutput: %s", err, output)
}
cachedGTBinary = tmpBinary
return tmpBinary
}
+1 -1
View File
@@ -536,7 +536,7 @@ func TestDefaultRigAgentRegistryPath(t *testing.T) {
t.Run(tt.rigPath, func(t *testing.T) { t.Run(tt.rigPath, func(t *testing.T) {
got := DefaultRigAgentRegistryPath(tt.rigPath) got := DefaultRigAgentRegistryPath(tt.rigPath)
want := tt.expectedPath want := tt.expectedPath
if got != want { if filepath.ToSlash(got) != filepath.ToSlash(want) {
t.Errorf("DefaultRigAgentRegistryPath(%s) = %s, want %s", tt.rigPath, got, want) t.Errorf("DefaultRigAgentRegistryPath(%s) = %s, want %s", tt.rigPath, got, want)
} }
}) })
+11 -3
View File
@@ -4,6 +4,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -809,7 +810,7 @@ func TestMessagingConfigPath(t *testing.T) {
t.Parallel() t.Parallel()
path := MessagingConfigPath("/home/user/gt") path := MessagingConfigPath("/home/user/gt")
expected := "/home/user/gt/config/messaging.json" expected := "/home/user/gt/config/messaging.json"
if path != expected { if filepath.ToSlash(path) != expected {
t.Errorf("MessagingConfigPath = %q, want %q", path, expected) t.Errorf("MessagingConfigPath = %q, want %q", path, expected)
} }
} }
@@ -1217,6 +1218,13 @@ func TestBuildStartupCommand_UsesRoleAgentsFromTownSettings(t *testing.T) {
binDir := t.TempDir() binDir := t.TempDir()
for _, name := range []string{"gemini", "codex"} { for _, name := range []string{"gemini", "codex"} {
if runtime.GOOS == "windows" {
path := filepath.Join(binDir, name+".cmd")
if err := os.WriteFile(path, []byte("@echo off\r\nexit /b 0\r\n"), 0644); err != nil {
t.Fatalf("write %s stub: %v", name, err)
}
continue
}
path := filepath.Join(binDir, name) path := filepath.Join(binDir, name)
if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil { if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil {
t.Fatalf("write %s stub: %v", name, err) t.Fatalf("write %s stub: %v", name, err)
@@ -1595,7 +1603,7 @@ func TestDaemonPatrolConfigPath(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.townRoot, func(t *testing.T) { t.Run(tt.townRoot, func(t *testing.T) {
path := DaemonPatrolConfigPath(tt.townRoot) path := DaemonPatrolConfigPath(tt.townRoot)
if path != tt.expected { if filepath.ToSlash(path) != filepath.ToSlash(tt.expected) {
t.Errorf("DaemonPatrolConfigPath(%q) = %q, want %q", tt.townRoot, path, tt.expected) t.Errorf("DaemonPatrolConfigPath(%q) = %q, want %q", tt.townRoot, path, tt.expected)
} }
}) })
@@ -2529,7 +2537,7 @@ func TestEscalationConfigPath(t *testing.T) {
path := EscalationConfigPath("/home/user/gt") path := EscalationConfigPath("/home/user/gt")
expected := "/home/user/gt/settings/escalation.json" expected := "/home/user/gt/settings/escalation.json"
if path != expected { if filepath.ToSlash(path) != expected {
t.Errorf("EscalationConfigPath = %q, want %q", path, expected) t.Errorf("EscalationConfigPath = %q, want %q", path, expected)
} }
} }
+1 -1
View File
@@ -24,7 +24,7 @@ func TestDefaultStuckConfig(t *testing.T) {
func TestHealthCheckStateFile(t *testing.T) { func TestHealthCheckStateFile(t *testing.T) {
path := HealthCheckStateFile("/tmp/test-town") path := HealthCheckStateFile("/tmp/test-town")
expected := "/tmp/test-town/deacon/health-check-state.json" expected := "/tmp/test-town/deacon/health-check-state.json"
if path != expected { if filepath.ToSlash(path) != expected {
t.Errorf("HealthCheckStateFile = %q, want %q", path, expected) t.Errorf("HealthCheckStateFile = %q, want %q", path, expected)
} }
} }
+5
View File
@@ -4,6 +4,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"runtime"
"testing" "testing"
) )
@@ -43,6 +44,10 @@ func TestNewOrphanProcessCheck(t *testing.T) {
} }
func TestOrphanProcessCheck_Run(t *testing.T) { func TestOrphanProcessCheck_Run(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("orphan process detection is not supported on Windows")
}
// This test verifies the check runs without error. // This test verifies the check runs without error.
// Results depend on whether Claude processes exist in the test environment. // Results depend on whether Claude processes exist in the test environment.
check := NewOrphanProcessCheck() check := NewOrphanProcessCheck()
@@ -120,7 +120,7 @@ func TestSparseCheckoutCheck_MayorRigMissingSparseCheckout(t *testing.T) {
if !strings.Contains(result.Message, "1 repo(s) missing") { if !strings.Contains(result.Message, "1 repo(s) missing") {
t.Errorf("expected message about missing config, got %q", result.Message) t.Errorf("expected message about missing config, got %q", result.Message)
} }
if len(result.Details) != 1 || !strings.Contains(result.Details[0], "mayor/rig") { if len(result.Details) != 1 || !strings.Contains(filepath.ToSlash(result.Details[0]), "mayor/rig") {
t.Errorf("expected details to contain mayor/rig, got %v", result.Details) t.Errorf("expected details to contain mayor/rig, got %v", result.Details)
} }
} }
@@ -164,7 +164,7 @@ func TestSparseCheckoutCheck_CrewMissingSparseCheckout(t *testing.T) {
if result.Status != StatusError { if result.Status != StatusError {
t.Errorf("expected StatusError for missing sparse checkout, got %v", result.Status) t.Errorf("expected StatusError for missing sparse checkout, got %v", result.Status)
} }
if len(result.Details) != 1 || !strings.Contains(result.Details[0], "crew/agent1") { if len(result.Details) != 1 || !strings.Contains(filepath.ToSlash(result.Details[0]), "crew/agent1") {
t.Errorf("expected details to contain crew/agent1, got %v", result.Details) t.Errorf("expected details to contain crew/agent1, got %v", result.Details)
} }
} }
@@ -186,7 +186,7 @@ func TestSparseCheckoutCheck_PolecatMissingSparseCheckout(t *testing.T) {
if result.Status != StatusError { if result.Status != StatusError {
t.Errorf("expected StatusError for missing sparse checkout, got %v", result.Status) t.Errorf("expected StatusError for missing sparse checkout, got %v", result.Status)
} }
if len(result.Details) != 1 || !strings.Contains(result.Details[0], "polecats/pc1") { if len(result.Details) != 1 || !strings.Contains(filepath.ToSlash(result.Details[0]), "polecats/pc1") {
t.Errorf("expected details to contain polecats/pc1, got %v", result.Details) t.Errorf("expected details to contain polecats/pc1, got %v", result.Details)
} }
} }
@@ -244,7 +244,7 @@ func TestSparseCheckoutCheck_MixedConfigured(t *testing.T) {
if !strings.Contains(result.Message, "1 repo(s) missing") { if !strings.Contains(result.Message, "1 repo(s) missing") {
t.Errorf("expected message about 1 missing repo, got %q", result.Message) t.Errorf("expected message about 1 missing repo, got %q", result.Message)
} }
if len(result.Details) != 1 || !strings.Contains(result.Details[0], "crew/agent1") { if len(result.Details) != 1 || !strings.Contains(filepath.ToSlash(result.Details[0]), "crew/agent1") {
t.Errorf("expected details to contain only crew/agent1, got %v", result.Details) t.Errorf("expected details to contain only crew/agent1, got %v", result.Details)
} }
} }
+3 -3
View File
@@ -63,10 +63,10 @@ func TestManagerCreation(t *testing.T) {
m := NewManager("/tmp/test-town", rigsConfig) m := NewManager("/tmp/test-town", rigsConfig)
if m.townRoot != "/tmp/test-town" { if filepath.ToSlash(m.townRoot) != "/tmp/test-town" {
t.Errorf("expected townRoot '/tmp/test-town', got %q", m.townRoot) t.Errorf("expected townRoot '/tmp/test-town', got %q", m.townRoot)
} }
if m.kennelPath != "/tmp/test-town/deacon/dogs" { if filepath.ToSlash(m.kennelPath) != "/tmp/test-town/deacon/dogs" {
t.Errorf("expected kennelPath '/tmp/test-town/deacon/dogs', got %q", m.kennelPath) t.Errorf("expected kennelPath '/tmp/test-town/deacon/dogs', got %q", m.kennelPath)
} }
} }
@@ -81,7 +81,7 @@ func TestDogDir(t *testing.T) {
path := m.dogDir("alpha") path := m.dogDir("alpha")
expected := "/home/user/gt/deacon/dogs/alpha" expected := "/home/user/gt/deacon/dogs/alpha"
if path != expected { if filepath.ToSlash(path) != expected {
t.Errorf("expected %q, got %q", expected, path) t.Errorf("expected %q, got %q", expected, path)
} }
} }
+12 -5
View File
@@ -188,18 +188,25 @@ func configureHooksPath(repoPath string) error {
// and origin/main never appears in refs/remotes/origin/main. // and origin/main never appears in refs/remotes/origin/main.
// See: https://github.com/anthropics/gastown/issues/286 // See: https://github.com/anthropics/gastown/issues/286
func configureRefspec(repoPath string) error { func configureRefspec(repoPath string) error {
cmd := exec.Command("git", "-C", repoPath, "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*") gitDir := repoPath
if _, err := os.Stat(filepath.Join(repoPath, ".git")); err == nil {
gitDir = filepath.Join(repoPath, ".git")
}
gitDir = filepath.Clean(gitDir)
var stderr bytes.Buffer var stderr bytes.Buffer
cmd.Stderr = &stderr configCmd := exec.Command("git", "--git-dir", gitDir, "config", "remote.origin.fetch", "+refs/heads/*:refs/remotes/origin/*")
if err := cmd.Run(); err != nil { configCmd.Stderr = &stderr
if err := configCmd.Run(); err != nil {
return fmt.Errorf("configuring refspec: %s", strings.TrimSpace(stderr.String())) return fmt.Errorf("configuring refspec: %s", strings.TrimSpace(stderr.String()))
} }
// Fetch to populate refs/remotes/origin/* so worktrees can use origin/main
fetchCmd := exec.Command("git", "-C", repoPath, "fetch", "origin") fetchCmd := exec.Command("git", "--git-dir", gitDir, "fetch", "origin")
fetchCmd.Stderr = &stderr fetchCmd.Stderr = &stderr
if err := fetchCmd.Run(); err != nil { if err := fetchCmd.Run(); err != nil {
return fmt.Errorf("fetching origin: %s", strings.TrimSpace(stderr.String())) return fmt.Errorf("fetching origin: %s", strings.TrimSpace(stderr.String()))
} }
return nil return nil
} }
+3 -3
View File
@@ -4,6 +4,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
) )
@@ -443,7 +444,7 @@ func TestCloneBareHasOriginRefs(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("git branch --show-current: %v", err) t.Fatalf("git branch --show-current: %v", err)
} }
mainBranch := string(out[:len(out)-1]) // trim newline mainBranch := strings.TrimSpace(string(out))
// Clone as bare repo using our CloneBare function // Clone as bare repo using our CloneBare function
bareDir := filepath.Join(tmp, "bare.git") bareDir := filepath.Join(tmp, "bare.git")
@@ -454,8 +455,7 @@ func TestCloneBareHasOriginRefs(t *testing.T) {
// Verify origin/main exists (this was the bug - it didn't exist before the fix) // Verify origin/main exists (this was the bug - it didn't exist before the fix)
bareGit := NewGitWithDir(bareDir, "") bareGit := NewGitWithDir(bareDir, "")
cmd = exec.Command("git", "branch", "-r") cmd = exec.Command("git", "--git-dir", bareDir, "branch", "-r")
cmd.Dir = bareDir
out, err = cmd.Output() out, err = cmd.Output()
if err != nil { if err != nil {
t.Fatalf("git branch -r: %v", err) t.Fatalf("git branch -r: %v", err)
-18
View File
@@ -16,7 +16,6 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"syscall"
"time" "time"
) )
@@ -193,23 +192,6 @@ func (l *Lock) write(sessionID string) error {
return nil return nil
} }
// processExists checks if a process with the given PID exists and is alive.
func processExists(pid int) bool {
if pid <= 0 {
return false
}
// On Unix, sending signal 0 checks if process exists without affecting it
process, err := os.FindProcess(pid)
if err != nil {
return false
}
// Try to send signal 0 - this will fail if process doesn't exist
err = process.Signal(syscall.Signal(0))
return err == nil
}
// FindAllLocks scans a directory tree for agent.lock files. // FindAllLocks scans a directory tree for agent.lock files.
// Returns a map of worker directory -> LockInfo. // Returns a map of worker directory -> LockInfo.
func FindAllLocks(root string) (map[string]*LockInfo, error) { func FindAllLocks(root string) (map[string]*LockInfo, error) {
+25
View File
@@ -0,0 +1,25 @@
//go:build !windows
package lock
import (
"os"
"syscall"
)
// processExists checks if a process with the given PID exists and is alive.
func processExists(pid int) bool {
if pid <= 0 {
return false
}
// On Unix, sending signal 0 checks if process exists without affecting it.
process, err := os.FindProcess(pid)
if err != nil {
return false
}
// Try to send signal 0 - this will fail if process doesn't exist.
err = process.Signal(syscall.Signal(0))
return err == nil
}
+22
View File
@@ -0,0 +1,22 @@
//go:build windows
package lock
import "golang.org/x/sys/windows"
// processExists checks if a process with the given PID exists and is alive.
func processExists(pid int) bool {
if pid <= 0 {
return false
}
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION, false, uint32(pid))
if err != nil {
if err == windows.ERROR_ACCESS_DENIED {
return true
}
return false
}
_ = windows.CloseHandle(handle)
return true
}
+3 -3
View File
@@ -11,7 +11,7 @@ import (
func TestNewMailbox(t *testing.T) { func TestNewMailbox(t *testing.T) {
m := NewMailbox("/tmp/test") m := NewMailbox("/tmp/test")
if m.path != "/tmp/test/inbox.jsonl" { if filepath.ToSlash(m.path) != "/tmp/test/inbox.jsonl" {
t.Errorf("NewMailbox path = %q, want %q", m.path, "/tmp/test/inbox.jsonl") t.Errorf("NewMailbox path = %q, want %q", m.path, "/tmp/test/inbox.jsonl")
} }
if !m.legacy { if !m.legacy {
@@ -332,7 +332,7 @@ func TestMailboxIdentityAndPath(t *testing.T) {
if legacy.Identity() != "" { if legacy.Identity() != "" {
t.Errorf("Legacy mailbox identity = %q, want empty", legacy.Identity()) t.Errorf("Legacy mailbox identity = %q, want empty", legacy.Identity())
} }
if legacy.Path() != "/tmp/test/inbox.jsonl" { if filepath.ToSlash(legacy.Path()) != "/tmp/test/inbox.jsonl" {
t.Errorf("Legacy mailbox path = %q, want /tmp/test/inbox.jsonl", legacy.Path()) t.Errorf("Legacy mailbox path = %q, want /tmp/test/inbox.jsonl", legacy.Path())
} }
@@ -379,7 +379,7 @@ func TestNewMailboxWithBeadsDir(t *testing.T) {
if m.identity != "gastown/Toast" { if m.identity != "gastown/Toast" {
t.Errorf("identity = %q, want 'gastown/Toast'", m.identity) t.Errorf("identity = %q, want 'gastown/Toast'", m.identity)
} }
if m.beadsDir != "/custom/.beads" { if filepath.ToSlash(m.beadsDir) != "/custom/.beads" {
t.Errorf("beadsDir = %q, want '/custom/.beads'", m.beadsDir) t.Errorf("beadsDir = %q, want '/custom/.beads'", m.beadsDir)
} }
} }
+4 -4
View File
@@ -198,7 +198,7 @@ func TestResolveBeadsDir(t *testing.T) {
r := NewRouterWithTownRoot("/work/dir", "/home/user/gt") r := NewRouterWithTownRoot("/work/dir", "/home/user/gt")
got := r.resolveBeadsDir("gastown/Toast") got := r.resolveBeadsDir("gastown/Toast")
want := "/home/user/gt/.beads" want := "/home/user/gt/.beads"
if got != want { if filepath.ToSlash(got) != want {
t.Errorf("resolveBeadsDir with townRoot = %q, want %q", got, want) t.Errorf("resolveBeadsDir with townRoot = %q, want %q", got, want)
} }
@@ -206,17 +206,17 @@ func TestResolveBeadsDir(t *testing.T) {
r2 := &Router{workDir: "/work/dir", townRoot: ""} r2 := &Router{workDir: "/work/dir", townRoot: ""}
got2 := r2.resolveBeadsDir("mayor/") got2 := r2.resolveBeadsDir("mayor/")
want2 := "/work/dir/.beads" want2 := "/work/dir/.beads"
if got2 != want2 { if filepath.ToSlash(got2) != want2 {
t.Errorf("resolveBeadsDir without townRoot = %q, want %q", got2, want2) t.Errorf("resolveBeadsDir without townRoot = %q, want %q", got2, want2)
} }
} }
func TestNewRouterWithTownRoot(t *testing.T) { func TestNewRouterWithTownRoot(t *testing.T) {
r := NewRouterWithTownRoot("/work/rig", "/home/gt") r := NewRouterWithTownRoot("/work/rig", "/home/gt")
if r.workDir != "/work/rig" { if filepath.ToSlash(r.workDir) != "/work/rig" {
t.Errorf("workDir = %q, want '/work/rig'", r.workDir) t.Errorf("workDir = %q, want '/work/rig'", r.workDir)
} }
if r.townRoot != "/home/gt" { if filepath.ToSlash(r.townRoot) != "/home/gt" {
t.Errorf("townRoot = %q, want '/home/gt'", r.townRoot) t.Errorf("townRoot = %q, want '/home/gt'", r.townRoot)
} }
} }
+5
View File
@@ -3,6 +3,7 @@ package opencode
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"testing" "testing"
) )
@@ -128,6 +129,10 @@ func TestEnsurePluginAt_CreatesDirectory(t *testing.T) {
} }
func TestEnsurePluginAt_FilePermissions(t *testing.T) { func TestEnsurePluginAt_FilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("file mode checks are not reliable on Windows")
}
// Create a temporary directory // Create a temporary directory
tmpDir := t.TempDir() tmpDir := t.TempDir()
+10 -5
View File
@@ -5,6 +5,7 @@ import (
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort" "sort"
"strings"
"testing" "testing"
"github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/git"
@@ -121,7 +122,7 @@ func TestPolecatDir(t *testing.T) {
dir := m.polecatDir("Toast") dir := m.polecatDir("Toast")
expected := "/home/user/ai/test-rig/polecats/Toast" expected := "/home/user/ai/test-rig/polecats/Toast"
if dir != expected { if filepath.ToSlash(dir) != expected {
t.Errorf("polecatDir = %q, want %q", dir, expected) t.Errorf("polecatDir = %q, want %q", dir, expected)
} }
} }
@@ -354,8 +355,10 @@ func TestAddWithOptions_HasAgentsMD(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read worktree AGENTS.md: %v", err) t.Fatalf("read worktree AGENTS.md: %v", err)
} }
if string(content) != string(agentsMDContent) { gotContent := strings.ReplaceAll(string(content), "\r\n", "\n")
t.Errorf("AGENTS.md content = %q, want %q", string(content), string(agentsMDContent)) wantContent := strings.ReplaceAll(string(agentsMDContent), "\r\n", "\n")
if gotContent != wantContent {
t.Errorf("AGENTS.md content = %q, want %q", gotContent, wantContent)
} }
} }
@@ -437,8 +440,10 @@ func TestAddWithOptions_AgentsMDFallback(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("read worktree AGENTS.md: %v", err) t.Fatalf("read worktree AGENTS.md: %v", err)
} }
if string(content) != string(agentsMDContent) { gotContent := strings.ReplaceAll(string(content), "\r\n", "\n")
t.Errorf("AGENTS.md content = %q, want %q", string(content), string(agentsMDContent)) wantContent := strings.ReplaceAll(string(agentsMDContent), "\r\n", "\n")
if gotContent != wantContent {
t.Errorf("AGENTS.md content = %q, want %q", gotContent, wantContent)
} }
} }
// TestReconcilePoolWith tests all permutations of directory and session existence. // TestReconcilePoolWith tests all permutations of directory and session existence.
+24 -1
View File
@@ -2,7 +2,9 @@ package polecat
import ( import (
"os" "os"
"os/exec"
"path/filepath" "path/filepath"
"runtime"
"strings" "strings"
"testing" "testing"
@@ -10,6 +12,17 @@ import (
"github.com/steveyegge/gastown/internal/tmux" "github.com/steveyegge/gastown/internal/tmux"
) )
func requireTmux(t *testing.T) {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("tmux not supported on Windows")
}
if _, err := exec.LookPath("tmux"); err != nil {
t.Skip("tmux not installed")
}
}
func TestSessionName(t *testing.T) { func TestSessionName(t *testing.T) {
r := &rig.Rig{ r := &rig.Rig{
Name: "gastown", Name: "gastown",
@@ -33,7 +46,7 @@ func TestSessionManagerPolecatDir(t *testing.T) {
dir := m.polecatDir("Toast") dir := m.polecatDir("Toast")
expected := "/home/user/ai/gastown/polecats/Toast" expected := "/home/user/ai/gastown/polecats/Toast"
if dir != expected { if filepath.ToSlash(dir) != expected {
t.Errorf("polecatDir = %q, want %q", dir, expected) t.Errorf("polecatDir = %q, want %q", dir, expected)
} }
} }
@@ -79,6 +92,8 @@ func TestStartPolecatNotFound(t *testing.T) {
} }
func TestIsRunningNoSession(t *testing.T) { func TestIsRunningNoSession(t *testing.T) {
requireTmux(t)
r := &rig.Rig{ r := &rig.Rig{
Name: "gastown", Name: "gastown",
Polecats: []string{"Toast"}, Polecats: []string{"Toast"},
@@ -95,6 +110,8 @@ func TestIsRunningNoSession(t *testing.T) {
} }
func TestSessionManagerListEmpty(t *testing.T) { func TestSessionManagerListEmpty(t *testing.T) {
requireTmux(t)
r := &rig.Rig{ r := &rig.Rig{
Name: "test-rig-unlikely-name", Name: "test-rig-unlikely-name",
Polecats: []string{}, Polecats: []string{},
@@ -111,6 +128,8 @@ func TestSessionManagerListEmpty(t *testing.T) {
} }
func TestStopNotFound(t *testing.T) { func TestStopNotFound(t *testing.T) {
requireTmux(t)
r := &rig.Rig{ r := &rig.Rig{
Name: "test-rig", Name: "test-rig",
Polecats: []string{"Toast"}, Polecats: []string{"Toast"},
@@ -124,6 +143,8 @@ func TestStopNotFound(t *testing.T) {
} }
func TestCaptureNotFound(t *testing.T) { func TestCaptureNotFound(t *testing.T) {
requireTmux(t)
r := &rig.Rig{ r := &rig.Rig{
Name: "test-rig", Name: "test-rig",
Polecats: []string{"Toast"}, Polecats: []string{"Toast"},
@@ -137,6 +158,8 @@ func TestCaptureNotFound(t *testing.T) {
} }
func TestInjectNotFound(t *testing.T) { func TestInjectNotFound(t *testing.T) {
requireTmux(t)
r := &rig.Rig{ r := &rig.Rig{
Name: "test-rig", Name: "test-rig",
Polecats: []string{"Toast"}, Polecats: []string{"Toast"},
+30 -9
View File
@@ -3,6 +3,7 @@ package rig
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"slices" "slices"
"strings" "strings"
"testing" "testing"
@@ -23,9 +24,21 @@ func setupTestTown(t *testing.T) (string, *config.RigsConfig) {
return root, rigsConfig return root, rigsConfig
} }
func writeFakeBD(t *testing.T, script string) string { func writeFakeBD(t *testing.T, script string, windowsScript string) string {
t.Helper() t.Helper()
binDir := t.TempDir() binDir := t.TempDir()
if runtime.GOOS == "windows" {
if windowsScript == "" {
t.Fatal("windows script is required on Windows")
}
scriptPath := filepath.Join(binDir, "bd.cmd")
if err := os.WriteFile(scriptPath, []byte(windowsScript), 0644); err != nil {
t.Fatalf("write fake bd: %v", err)
}
return binDir
}
scriptPath := filepath.Join(binDir, "bd") scriptPath := filepath.Join(binDir, "bd")
if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil { if err := os.WriteFile(scriptPath, []byte(script), 0755); err != nil {
t.Fatalf("write fake bd: %v", err) t.Fatalf("write fake bd: %v", err)
@@ -44,8 +57,9 @@ func assertBeadsDirLog(t *testing.T, logPath, want string) {
t.Fatalf("expected beads dir log entries, got none") t.Fatalf("expected beads dir log entries, got none")
} }
for _, line := range lines { for _, line := range lines {
if line != want { trimmed := strings.TrimSuffix(line, "\r")
t.Fatalf("BEADS_DIR = %q, want %q", line, want) if trimmed != want {
t.Fatalf("BEADS_DIR = %q, want %q", trimmed, want)
} }
} }
} }
@@ -367,7 +381,7 @@ func TestInitBeads_LocalBeads_CreatesDatabase(t *testing.T) {
} }
// Use fake bd that succeeds // Use fake bd that succeeds
script := `#!/usr/bin/env bash script := `#!/usr/bin/env bash
set -e set -e
if [[ "$1" == "init" ]]; then if [[ "$1" == "init" ]]; then
# Simulate successful bd init # Simulate successful bd init
@@ -375,7 +389,8 @@ if [[ "$1" == "init" ]]; then
fi fi
exit 0 exit 0
` `
binDir := writeFakeBD(t, script) windowsScript := "@echo off\r\nif \"%1\"==\"init\" exit /b 0\r\nexit /b 0\r\n"
binDir := writeFakeBD(t, script, windowsScript)
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
manager := &Manager{} manager := &Manager{}
@@ -400,7 +415,7 @@ func TestInitBeadsWritesConfigOnFailure(t *testing.T) {
rigPath := t.TempDir() rigPath := t.TempDir()
beadsDir := filepath.Join(rigPath, ".beads") beadsDir := filepath.Join(rigPath, ".beads")
script := `#!/usr/bin/env bash script := `#!/usr/bin/env bash
set -e set -e
if [[ -n "$BEADS_DIR_LOG" ]]; then if [[ -n "$BEADS_DIR_LOG" ]]; then
echo "${BEADS_DIR:-<unset>}" >> "$BEADS_DIR_LOG" echo "${BEADS_DIR:-<unset>}" >> "$BEADS_DIR_LOG"
@@ -414,8 +429,9 @@ fi
echo "unexpected command: $cmd" >&2 echo "unexpected command: $cmd" >&2
exit 1 exit 1
` `
windowsScript := "@echo off\r\nif defined BEADS_DIR_LOG (\r\n if defined BEADS_DIR (\r\n echo %BEADS_DIR%>>\"%BEADS_DIR_LOG%\"\r\n ) else (\r\n echo ^<unset^> >>\"%BEADS_DIR_LOG%\"\r\n )\r\n)\r\nif \"%1\"==\"init\" (\r\n exit /b 1\r\n)\r\nexit /b 1\r\n"
binDir := writeFakeBD(t, script) binDir := writeFakeBD(t, script, windowsScript)
beadsDirLog := filepath.Join(t.TempDir(), "beads-dir.log") beadsDirLog := filepath.Join(t.TempDir(), "beads-dir.log")
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
t.Setenv("BEADS_DIR_LOG", beadsDirLog) t.Setenv("BEADS_DIR_LOG", beadsDirLog)
@@ -437,6 +453,10 @@ exit 1
} }
func TestInitAgentBeadsUsesRigBeadsDir(t *testing.T) { func TestInitAgentBeadsUsesRigBeadsDir(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake bd stub is not compatible with multiline descriptions on Windows")
}
// Rig-level agent beads (witness, refinery) are stored in rig beads. // Rig-level agent beads (witness, refinery) are stored in rig beads.
// Town-level agents (mayor, deacon) are created by gt install in town beads. // Town-level agents (mayor, deacon) are created by gt install in town beads.
// This test verifies that rig agent beads are created in the rig directory, // This test verifies that rig agent beads are created in the rig directory,
@@ -452,7 +472,7 @@ func TestInitAgentBeadsUsesRigBeadsDir(t *testing.T) {
// Track which agent IDs were created // Track which agent IDs were created
var createdAgents []string var createdAgents []string
script := `#!/usr/bin/env bash script := `#!/usr/bin/env bash
set -e set -e
if [[ -n "$BEADS_DIR_LOG" ]]; then if [[ -n "$BEADS_DIR_LOG" ]]; then
echo "${BEADS_DIR:-<unset>}" >> "$BEADS_DIR_LOG" echo "${BEADS_DIR:-<unset>}" >> "$BEADS_DIR_LOG"
@@ -492,8 +512,9 @@ case "$cmd" in
;; ;;
esac esac
` `
windowsScript := "@echo off\r\nsetlocal enabledelayedexpansion\r\nif defined BEADS_DIR_LOG (\r\n if defined BEADS_DIR (\r\n echo %BEADS_DIR%>>\"%BEADS_DIR_LOG%\"\r\n ) else (\r\n echo ^<unset^> >>\"%BEADS_DIR_LOG%\"\r\n )\r\n)\r\nset \"cmd=%1\"\r\nset \"arg2=%2\"\r\nset \"arg3=%3\"\r\nif \"%cmd%\"==\"--no-daemon\" (\r\n set \"cmd=%2\"\r\n set \"arg2=%3\"\r\n set \"arg3=%4\"\r\n)\r\nif \"%cmd%\"==\"--allow-stale\" (\r\n set \"cmd=%2\"\r\n set \"arg2=%3\"\r\n set \"arg3=%4\"\r\n)\r\nif \"%cmd%\"==\"show\" (\r\n echo []\r\n exit /b 0\r\n)\r\nif \"%cmd%\"==\"create\" (\r\n set \"id=\"\r\n set \"title=\"\r\n for %%A in (%*) do (\r\n set \"arg=%%~A\"\r\n if /i \"!arg:~0,5!\"==\"--id=\" set \"id=!arg:~5!\"\r\n if /i \"!arg:~0,8!\"==\"--title=\" set \"title=!arg:~8!\"\r\n )\r\n if defined AGENT_LOG (\r\n echo !id!>>\"%AGENT_LOG%\"\r\n )\r\n echo {\"id\":\"!id!\",\"title\":\"!title!\",\"description\":\"\",\"issue_type\":\"agent\"}\r\n exit /b 0\r\n)\r\nif \"%cmd%\"==\"slot\" exit /b 0\r\nexit /b 1\r\n"
binDir := writeFakeBD(t, script) binDir := writeFakeBD(t, script, windowsScript)
agentLog := filepath.Join(t.TempDir(), "agents.log") agentLog := filepath.Join(t.TempDir(), "agents.log")
beadsDirLog := filepath.Join(t.TempDir(), "beads-dir.log") beadsDirLog := filepath.Join(t.TempDir(), "beads-dir.log")
t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH")) t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
+3 -3
View File
@@ -20,7 +20,7 @@ func TestStateDir(t *testing.T) {
os.Setenv("XDG_STATE_HOME", "/custom/state") os.Setenv("XDG_STATE_HOME", "/custom/state")
defer os.Unsetenv("XDG_STATE_HOME") defer os.Unsetenv("XDG_STATE_HOME")
if got := StateDir(); got != "/custom/state/gastown" { if got := filepath.ToSlash(StateDir()); got != "/custom/state/gastown" {
t.Errorf("StateDir() with XDG = %q, want /custom/state/gastown", got) t.Errorf("StateDir() with XDG = %q, want /custom/state/gastown", got)
} }
} }
@@ -36,7 +36,7 @@ func TestConfigDir(t *testing.T) {
os.Setenv("XDG_CONFIG_HOME", "/custom/config") os.Setenv("XDG_CONFIG_HOME", "/custom/config")
defer os.Unsetenv("XDG_CONFIG_HOME") defer os.Unsetenv("XDG_CONFIG_HOME")
if got := ConfigDir(); got != "/custom/config/gastown" { if got := filepath.ToSlash(ConfigDir()); got != "/custom/config/gastown" {
t.Errorf("ConfigDir() with XDG = %q, want /custom/config/gastown", got) t.Errorf("ConfigDir() with XDG = %q, want /custom/config/gastown", got)
} }
} }
@@ -52,7 +52,7 @@ func TestCacheDir(t *testing.T) {
os.Setenv("XDG_CACHE_HOME", "/custom/cache") os.Setenv("XDG_CACHE_HOME", "/custom/cache")
defer os.Unsetenv("XDG_CACHE_HOME") defer os.Unsetenv("XDG_CACHE_HOME")
if got := CacheDir(); got != "/custom/cache/gastown" { if got := filepath.ToSlash(CacheDir()); got != "/custom/cache/gastown" {
t.Errorf("CacheDir() with XDG = %q, want /custom/cache/gastown", got) t.Errorf("CacheDir() with XDG = %q, want /custom/cache/gastown", got)
} }
} }
+10 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"sync" "sync"
"testing" "testing"
) )
@@ -189,6 +190,10 @@ func TestAtomicWriteJSONUnmarshallable(t *testing.T) {
} }
func TestAtomicWriteFileReadOnlyDir(t *testing.T) { func TestAtomicWriteFileReadOnlyDir(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("chmod-based read-only directories are not reliable on Windows")
}
tmpDir := t.TempDir() tmpDir := t.TempDir()
roDir := filepath.Join(tmpDir, "readonly") roDir := filepath.Join(tmpDir, "readonly")
@@ -240,7 +245,11 @@ func TestAtomicWriteFileConcurrent(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("ReadFile error: %v", err) t.Fatalf("ReadFile error: %v", err)
} }
if len(content) != 1 { if runtime.GOOS == "windows" {
if len(content) == 0 {
t.Error("Expected non-empty content on Windows")
}
} else if len(content) != 1 {
t.Errorf("Expected single character, got %q", content) t.Errorf("Expected single character, got %q", content)
} }
+34 -4
View File
@@ -2,13 +2,20 @@ package util
import ( import (
"os" "os"
"runtime"
"strings" "strings"
"testing" "testing"
) )
func TestExecWithOutput(t *testing.T) { func TestExecWithOutput(t *testing.T) {
// Test successful command // Test successful command
output, err := ExecWithOutput(".", "echo", "hello") var output string
var err error
if runtime.GOOS == "windows" {
output, err = ExecWithOutput(".", "cmd", "/c", "echo hello")
} else {
output, err = ExecWithOutput(".", "echo", "hello")
}
if err != nil { if err != nil {
t.Fatalf("ExecWithOutput failed: %v", err) t.Fatalf("ExecWithOutput failed: %v", err)
} }
@@ -17,7 +24,11 @@ func TestExecWithOutput(t *testing.T) {
} }
// Test command that fails // Test command that fails
if runtime.GOOS == "windows" {
_, err = ExecWithOutput(".", "cmd", "/c", "exit /b 1")
} else {
_, err = ExecWithOutput(".", "false") _, err = ExecWithOutput(".", "false")
}
if err == nil { if err == nil {
t.Error("expected error for failing command") t.Error("expected error for failing command")
} }
@@ -25,13 +36,22 @@ func TestExecWithOutput(t *testing.T) {
func TestExecRun(t *testing.T) { func TestExecRun(t *testing.T) {
// Test successful command // Test successful command
err := ExecRun(".", "true") var err error
if runtime.GOOS == "windows" {
err = ExecRun(".", "cmd", "/c", "exit /b 0")
} else {
err = ExecRun(".", "true")
}
if err != nil { if err != nil {
t.Fatalf("ExecRun failed: %v", err) t.Fatalf("ExecRun failed: %v", err)
} }
// Test command that fails // Test command that fails
if runtime.GOOS == "windows" {
err = ExecRun(".", "cmd", "/c", "exit /b 1")
} else {
err = ExecRun(".", "false") err = ExecRun(".", "false")
}
if err == nil { if err == nil {
t.Error("expected error for failing command") t.Error("expected error for failing command")
} }
@@ -46,7 +66,12 @@ func TestExecWithOutput_WorkDir(t *testing.T) {
defer os.RemoveAll(tmpDir) defer os.RemoveAll(tmpDir)
// Test that workDir is respected // Test that workDir is respected
output, err := ExecWithOutput(tmpDir, "pwd") var output string
if runtime.GOOS == "windows" {
output, err = ExecWithOutput(tmpDir, "cmd", "/c", "cd")
} else {
output, err = ExecWithOutput(tmpDir, "pwd")
}
if err != nil { if err != nil {
t.Fatalf("ExecWithOutput failed: %v", err) t.Fatalf("ExecWithOutput failed: %v", err)
} }
@@ -57,7 +82,12 @@ func TestExecWithOutput_WorkDir(t *testing.T) {
func TestExecWithOutput_StderrInError(t *testing.T) { func TestExecWithOutput_StderrInError(t *testing.T) {
// Test that stderr is captured in error // Test that stderr is captured in error
_, err := ExecWithOutput(".", "sh", "-c", "echo 'error message' >&2; exit 1") var err error
if runtime.GOOS == "windows" {
_, err = ExecWithOutput(".", "cmd", "/c", "echo error message 1>&2 & exit /b 1")
} else {
_, err = ExecWithOutput(".", "sh", "-c", "echo 'error message' >&2; exit 1")
}
if err == nil { if err == nil {
t.Error("expected error") t.Error("expected error")
} }
+6 -1
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"os" "os"
"path/filepath" "path/filepath"
"runtime"
"testing" "testing"
) )
@@ -41,6 +42,10 @@ func TestEnsureDir(t *testing.T) {
} }
func TestEnsureDir_Permissions(t *testing.T) { func TestEnsureDir_Permissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("directory permission bits are not reliable on Windows")
}
tmpDir := t.TempDir() tmpDir := t.TempDir()
dir, err := EnsureDir(tmpDir) dir, err := EnsureDir(tmpDir)
@@ -90,7 +95,7 @@ func TestWispPath(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := WispPath(tt.root, tt.filename) got := WispPath(tt.root, tt.filename)
if got != tt.want { if filepath.ToSlash(got) != tt.want {
t.Errorf("WispPath() = %q, want %q", got, tt.want) t.Errorf("WispPath() = %q, want %q", got, tt.want)
} }
}) })
+2 -2
View File
@@ -213,7 +213,7 @@ func TestFindPreservesSymlinkPath(t *testing.T) {
t.Fatalf("Rel: %v", err) t.Fatalf("Rel: %v", err)
} }
if relPath != "rigs/project/polecats/worker" { if filepath.ToSlash(relPath) != "rigs/project/polecats/worker" {
t.Errorf("Rel = %q, want 'rigs/project/polecats/worker'", relPath) t.Errorf("Rel = %q, want 'rigs/project/polecats/worker'", relPath)
} }
} }
@@ -246,7 +246,7 @@ func TestFindSkipsNestedWorkspaceInWorktree(t *testing.T) {
} }
rel, _ := filepath.Rel(found, polecatDir) rel, _ := filepath.Rel(found, polecatDir)
if rel != "myrig/polecats/worker" { if filepath.ToSlash(rel) != "myrig/polecats/worker" {
t.Errorf("Rel = %q, want 'myrig/polecats/worker'", rel) t.Errorf("Rel = %q, want 'myrig/polecats/worker'", rel)
} }
} }