| 1 | //go:build windows |
| 2 | |
| 3 | package main |
| 4 | |
| 5 | import ( |
| 6 | "context" |
| 7 | "fmt" |
| 8 | "os" |
| 9 | "os/exec" |
| 10 | "path/filepath" |
| 11 | "testing" |
| 12 | "time" |
| 13 | |
| 14 | "reasonix/internal/gitcmd" |
| 15 | ) |
| 16 | |
| 17 | const workspaceGitCreateNoWindow = 0x08000000 |
| 18 | |
| 19 | func TestWorkspaceGitProbesHideConsoleWindowOnWindows(t *testing.T) { |
| 20 | // Both startup probes use the same gitcmd.Command construction path. |
| 21 | // Assert HideWindow + CREATE_NO_WINDOW for each rev-parse flag. |
| 22 | for _, flag := range []string{"--git-dir", "--git-common-dir"} { |
| 23 | ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) |
| 24 | cmd := gitcmd.Command(ctx, t.TempDir(), "rev-parse", flag) |
| 25 | cancel() |
| 26 | if cmd.SysProcAttr == nil { |
| 27 | t.Fatalf("%s: SysProcAttr is nil", flag) |
| 28 | } |
| 29 | if !cmd.SysProcAttr.HideWindow { |
| 30 | t.Fatalf("%s: HideWindow is false", flag) |
| 31 | } |
| 32 | if cmd.SysProcAttr.CreationFlags&workspaceGitCreateNoWindow == 0 { |
| 33 | t.Fatalf("%s: CREATE_NO_WINDOW not set; CreationFlags=%#x", flag, cmd.SysProcAttr.CreationFlags) |
| 34 | } |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | func TestWorkspaceWatcherDoesNotLockNestedParentOnWindows(t *testing.T) { |
| 39 | root := t.TempDir() |
| 40 | from := filepath.Join(root, "parent") |
| 41 | if err := os.MkdirAll(filepath.Join(from, "child"), 0o700); err != nil { |
| 42 | t.Fatal(err) |
| 43 | } |
| 44 | app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}} |
| 45 | app.workspaceHub = newWorkspaceChangeHub(app) |
| 46 | t.Cleanup(func() { app.workspaceHub.close() }) |
| 47 | if state := app.WorkspaceRevisionForTab("a").WatchState; state == "unavailable" { |
| 48 | t.Fatalf("watcher unavailable: %s", state) |
| 49 | } |
| 50 | before := app.WorkspaceRevisionForTab("a").Revisions.Content |
| 51 | |
| 52 | to := filepath.Join(root, "renamed") |
| 53 | if err := os.Rename(from, to); err != nil { |
| 54 | t.Fatalf("rename parent while workspace watcher is active: %v", err) |
| 55 | } |
| 56 | waitForWorkspaceContentRevision(t, app, before) |
| 57 | before = waitForWorkspaceRevisionToSettle(t, app) |
| 58 | if err := os.WriteFile(filepath.Join(to, "child", "after.txt"), []byte("after"), 0o600); err != nil { |
| 59 | t.Fatal(err) |
| 60 | } |
| 61 | waitForWorkspaceContentRevision(t, app, before) |
| 62 | before = waitForWorkspaceRevisionToSettle(t, app) |
| 63 | if err := os.RemoveAll(to); err != nil { |
| 64 | t.Fatalf("delete renamed parent while workspace watcher is active: %v", err) |
| 65 | } |
| 66 | waitForWorkspaceContentRevision(t, app, before) |
| 67 | } |
| 68 | |
| 69 | func TestWorkspaceWatcherUsesOneRecursiveWorkspaceHandleOnWindows(t *testing.T) { |
| 70 | root := t.TempDir() |
| 71 | if err := os.MkdirAll(filepath.Join(root, "one", "two", "three"), 0o700); err != nil { |
| 72 | t.Fatal(err) |
| 73 | } |
| 74 | app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}} |
| 75 | app.workspaceHub = newWorkspaceChangeHub(app) |
| 76 | t.Cleanup(func() { app.workspaceHub.close() }) |
| 77 | app.WorkspaceRevisionForTab("a") |
| 78 | |
| 79 | key := canonicalWorkspaceRoot(root) |
| 80 | app.workspaceHub.mu.Lock() |
| 81 | r := app.workspaceHub.roots[key] |
| 82 | dirs := 0 |
| 83 | if r != nil { |
| 84 | dirs = r.dirs |
| 85 | } |
| 86 | app.workspaceHub.mu.Unlock() |
| 87 | if dirs != 1 { |
| 88 | t.Fatalf("workspace handles = %d, want 1 recursive root handle", dirs) |
| 89 | } |
| 90 | } |
| 91 | |
| 92 | func TestWindowsWorkspaceWatcherPreservesUnicodeEventPath(t *testing.T) { |
| 93 | root := t.TempDir() |
| 94 | deep := filepath.Join(root, "one", "two") |
| 95 | if err := os.MkdirAll(deep, 0o700); err != nil { |
| 96 | t.Fatal(err) |
| 97 | } |
| 98 | w, err := newWorkspaceWatcher() |
| 99 | if err != nil { |
| 100 | t.Fatal(err) |
| 101 | } |
| 102 | t.Cleanup(func() { _ = w.Close() }) |
| 103 | if err := w.Add(root, true); err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | target := filepath.Join(deep, "unicode-\u6587\u4ef6-\U0001F600.txt") |
| 107 | if err := os.WriteFile(target, []byte("deep"), 0o600); err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | timer := time.NewTimer(3 * time.Second) |
| 111 | defer timer.Stop() |
| 112 | for { |
| 113 | select { |
| 114 | case ev := <-w.Events(): |
| 115 | if filepath.Clean(ev.Name) == target { |
| 116 | return |
| 117 | } |
| 118 | case err := <-w.Errors(): |
| 119 | t.Fatalf("watcher error: %v", err) |
| 120 | case <-timer.C: |
| 121 | t.Fatalf("no event for exact Unicode path %q", target) |
| 122 | } |
| 123 | } |
| 124 | } |
| 125 | |
| 126 | func TestWorkspaceWatcherReportsPreexistingDeepFileChangesOnWindows(t *testing.T) { |
| 127 | root := t.TempDir() |
| 128 | deep := filepath.Join(root, "one", "two", "three") |
| 129 | if err := os.MkdirAll(deep, 0o700); err != nil { |
| 130 | t.Fatal(err) |
| 131 | } |
| 132 | app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}} |
| 133 | app.workspaceHub = newWorkspaceChangeHub(app) |
| 134 | t.Cleanup(func() { app.workspaceHub.close() }) |
| 135 | before := app.WorkspaceRevisionForTab("a").Revisions.Content |
| 136 | if err := os.WriteFile(filepath.Join(deep, "file.txt"), []byte("deep"), 0o600); err != nil { |
| 137 | t.Fatal(err) |
| 138 | } |
| 139 | waitForWorkspaceContentRevision(t, app, before) |
| 140 | } |
| 141 | |
| 142 | func TestWorkspaceWatcherReportsFilesInNewDeepDirectoriesOnWindows(t *testing.T) { |
| 143 | root := t.TempDir() |
| 144 | app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}} |
| 145 | app.workspaceHub = newWorkspaceChangeHub(app) |
| 146 | t.Cleanup(func() { app.workspaceHub.close() }) |
| 147 | before := app.WorkspaceRevisionForTab("a").Revisions.Content |
| 148 | deep := filepath.Join(root, "new", "nested") |
| 149 | if err := os.MkdirAll(deep, 0o700); err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | waitForWorkspaceContentRevision(t, app, before) |
| 153 | before = waitForWorkspaceRevisionToSettle(t, app) |
| 154 | if err := os.WriteFile(filepath.Join(deep, "later.txt"), []byte("later"), 0o600); err != nil { |
| 155 | t.Fatal(err) |
| 156 | } |
| 157 | waitForWorkspaceContentRevision(t, app, before) |
| 158 | } |
| 159 | |
| 160 | func TestWorkspaceWatcherIgnoresGeneratedSubtreeChurnOnWindows(t *testing.T) { |
| 161 | root := t.TempDir() |
| 162 | generated := filepath.Join(root, "node_modules", "pkg") |
| 163 | if err := os.MkdirAll(generated, 0o700); err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}} |
| 167 | app.workspaceHub = newWorkspaceChangeHub(app) |
| 168 | t.Cleanup(func() { app.workspaceHub.close() }) |
| 169 | before := app.WorkspaceRevisionForTab("a").Revisions.Content |
| 170 | for i := range 256 { |
| 171 | name := filepath.Join(generated, fmt.Sprintf("generated-%03d.js", i)) |
| 172 | if err := os.WriteFile(name, []byte("generated"), 0o600); err != nil { |
| 173 | t.Fatal(err) |
| 174 | } |
| 175 | } |
| 176 | time.Sleep(300 * time.Millisecond) |
| 177 | view := app.WorkspaceRevisionForTab("a") |
| 178 | after := view.Revisions.Content |
| 179 | if after != before { |
| 180 | t.Fatalf("generated subtree advanced content revision: before=%d after=%d", before, after) |
| 181 | } |
| 182 | if view.WatchState != "active" { |
| 183 | t.Fatalf("generated subtree degraded recursive watcher: %s", view.WatchState) |
| 184 | } |
| 185 | } |
| 186 | |
| 187 | func TestWorkspaceWatcherCloseReleasesNestedDirectoriesOnWindows(t *testing.T) { |
| 188 | root := t.TempDir() |
| 189 | from := filepath.Join(root, "parent") |
| 190 | if err := os.MkdirAll(filepath.Join(from, "child"), 0o700); err != nil { |
| 191 | t.Fatal(err) |
| 192 | } |
| 193 | app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}} |
| 194 | app.workspaceHub = newWorkspaceChangeHub(app) |
| 195 | app.WorkspaceRevisionForTab("a") |
| 196 | app.workspaceHub.close() |
| 197 | if err := os.Rename(from, filepath.Join(root, "after-close")); err != nil { |
| 198 | t.Fatalf("rename after workspace watcher close: %v", err) |
| 199 | } |
| 200 | } |
| 201 | |
| 202 | func TestWorkspaceWatcherSwitchReleasesOldWorkspaceOnWindows(t *testing.T) { |
| 203 | rootA, rootB := t.TempDir(), t.TempDir() |
| 204 | from := filepath.Join(rootA, "parent") |
| 205 | if err := os.MkdirAll(filepath.Join(from, "child"), 0o700); err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: rootA}}} |
| 209 | app.workspaceHub = newWorkspaceChangeHub(app) |
| 210 | t.Cleanup(func() { app.workspaceHub.close() }) |
| 211 | app.WorkspaceRevisionForTab("a") |
| 212 | app.mu.Lock() |
| 213 | app.tabs["a"].WorkspaceRoot = rootB |
| 214 | app.mu.Unlock() |
| 215 | app.WorkspaceRevisionForTab("a") |
| 216 | app.workspaceHub.reconcileRoots() |
| 217 | |
| 218 | if err := os.Rename(from, filepath.Join(rootA, "after-switch")); err != nil { |
| 219 | t.Fatalf("rename in released workspace: %v", err) |
| 220 | } |
| 221 | before := app.WorkspaceRevisionForTab("a").Revisions.Content |
| 222 | if err := os.WriteFile(filepath.Join(rootB, "active.txt"), []byte("active"), 0o600); err != nil { |
| 223 | t.Fatal(err) |
| 224 | } |
| 225 | waitForWorkspaceContentRevision(t, app, before) |
| 226 | } |
| 227 | |
| 228 | func TestWorkspaceWatcherKeepsGitMetadataScopeOnWindows(t *testing.T) { |
| 229 | root := t.TempDir() |
| 230 | if out, err := exec.Command("git", "-C", root, "init").CombinedOutput(); err != nil { |
| 231 | t.Fatalf("git init: %v: %s", err, out) |
| 232 | } |
| 233 | app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}} |
| 234 | app.workspaceHub = newWorkspaceChangeHub(app) |
| 235 | t.Cleanup(func() { app.workspaceHub.close() }) |
| 236 | before := app.WorkspaceRevisionForTab("a").Revisions.GitMeta |
| 237 | ref := filepath.Join(root, ".git", "refs", "heads", "watch-test") |
| 238 | if err := os.WriteFile(ref, []byte("0000000000000000000000000000000000000000\n"), 0o600); err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | waitForWorkspaceGitRevision(t, app, before) |
| 242 | before = waitForWorkspaceGitRevisionToSettle(t, app) |
| 243 | objectDir := filepath.Join(root, ".git", "objects", "ff") |
| 244 | if err := os.MkdirAll(objectDir, 0o700); err != nil { |
| 245 | t.Fatal(err) |
| 246 | } |
| 247 | if err := os.WriteFile(filepath.Join(objectDir, "ignored"), []byte("object"), 0o600); err != nil { |
| 248 | t.Fatal(err) |
| 249 | } |
| 250 | time.Sleep(200 * time.Millisecond) |
| 251 | after := app.WorkspaceRevisionForTab("a").Revisions.GitMeta |
| 252 | if after != before { |
| 253 | t.Fatalf("git object churn advanced metadata revision: before=%d after=%d", before, after) |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | func TestWorkspaceWatcherTracksExternalGitMetadataOnWindows(t *testing.T) { |
| 258 | base := t.TempDir() |
| 259 | root := filepath.Join(base, "workspace") |
| 260 | gitDir := filepath.Join(base, "git-dir") |
| 261 | if out, err := exec.Command("git", "init", "--separate-git-dir", gitDir, root).CombinedOutput(); err != nil { |
| 262 | t.Fatalf("git init --separate-git-dir: %v: %s", err, out) |
| 263 | } |
| 264 | if err := os.MkdirAll(filepath.Join(root, "parent", "child"), 0o700); err != nil { |
| 265 | t.Fatal(err) |
| 266 | } |
| 267 | app := &App{tabs: map[string]*WorkspaceTab{"a": {ID: "a", WorkspaceRoot: root}}} |
| 268 | app.workspaceHub = newWorkspaceChangeHub(app) |
| 269 | t.Cleanup(func() { app.workspaceHub.close() }) |
| 270 | before := app.WorkspaceRevisionForTab("a").Revisions.GitMeta |
| 271 | ref := filepath.Join(gitDir, "refs", "heads", "watch-test") |
| 272 | if err := os.WriteFile(ref, []byte("0000000000000000000000000000000000000000\n"), 0o600); err != nil { |
| 273 | t.Fatal(err) |
| 274 | } |
| 275 | waitForWorkspaceGitRevision(t, app, before) |
| 276 | if err := os.Rename(filepath.Join(root, "parent"), filepath.Join(root, "renamed")); err != nil { |
| 277 | t.Fatalf("external Git watches locked a workspace child: %v", err) |
| 278 | } |
| 279 | } |
| 280 | |
| 281 | func waitForWorkspaceContentRevision(t *testing.T, app *App, before uint64) uint64 { |
| 282 | t.Helper() |
| 283 | deadline := time.Now().Add(3 * time.Second) |
| 284 | for time.Now().Before(deadline) { |
| 285 | got := app.WorkspaceRevisionForTab("a").Revisions.Content |
| 286 | if got > before { |
| 287 | return got |
| 288 | } |
| 289 | time.Sleep(10 * time.Millisecond) |
| 290 | } |
| 291 | t.Fatalf("content revision did not advance beyond %d", before) |
| 292 | return before |
| 293 | } |
| 294 | |
| 295 | func waitForWorkspaceRevisionToSettle(t *testing.T, app *App) uint64 { |
| 296 | t.Helper() |
| 297 | last := app.WorkspaceRevisionForTab("a").Revisions.Content |
| 298 | stableSince := time.Now() |
| 299 | deadline := time.Now().Add(3 * time.Second) |
| 300 | for time.Now().Before(deadline) { |
| 301 | time.Sleep(10 * time.Millisecond) |
| 302 | got := app.WorkspaceRevisionForTab("a").Revisions.Content |
| 303 | if got != last { |
| 304 | last = got |
| 305 | stableSince = time.Now() |
| 306 | continue |
| 307 | } |
| 308 | if time.Since(stableSince) >= 100*time.Millisecond { |
| 309 | return got |
| 310 | } |
| 311 | } |
| 312 | t.Fatalf("content revision did not settle") |
| 313 | return last |
| 314 | } |
| 315 | |
| 316 | func waitForWorkspaceGitRevision(t *testing.T, app *App, before uint64) uint64 { |
| 317 | t.Helper() |
| 318 | deadline := time.Now().Add(3 * time.Second) |
| 319 | for time.Now().Before(deadline) { |
| 320 | got := app.WorkspaceRevisionForTab("a").Revisions.GitMeta |
| 321 | if got > before { |
| 322 | return got |
| 323 | } |
| 324 | time.Sleep(10 * time.Millisecond) |
| 325 | } |
| 326 | t.Fatalf("git metadata revision did not advance beyond %d", before) |
| 327 | return before |
| 328 | } |
| 329 | |
| 330 | func waitForWorkspaceGitRevisionToSettle(t *testing.T, app *App) uint64 { |
| 331 | t.Helper() |
| 332 | last := app.WorkspaceRevisionForTab("a").Revisions.GitMeta |
| 333 | stableSince := time.Now() |
| 334 | deadline := time.Now().Add(3 * time.Second) |
| 335 | for time.Now().Before(deadline) { |
| 336 | time.Sleep(10 * time.Millisecond) |
| 337 | got := app.WorkspaceRevisionForTab("a").Revisions.GitMeta |
| 338 | if got != last { |
| 339 | last = got |
| 340 | stableSince = time.Now() |
| 341 | continue |
| 342 | } |
| 343 | if time.Since(stableSince) >= 100*time.Millisecond { |
| 344 | return got |
| 345 | } |
| 346 | } |
| 347 | t.Fatalf("git metadata revision did not settle") |
| 348 | return last |
| 349 | } |
| 350 |