diff --git a/src/daemon/application.c b/src/daemon/application.c index 22b6f0d1a..32b407743 100644 --- a/src/daemon/application.c +++ b/src/daemon/application.c @@ -449,8 +449,8 @@ static bool application_regular_db_exists(const char *project) { if (written <= 0 || (size_t)written >= sizeof(path)) { return false; } - struct stat status; - return stat(path, &status) == 0 && S_ISREG(status.st_mode); + cbm_path_info_t status; + return cbm_path_info_utf8(path, &status) == CBM_PATH_INFO_OK && status.is_regular; } static bool application_canonical_directory_exists(const char *path) { diff --git a/src/watcher/watcher.c b/src/watcher/watcher.c index f8d04c4c6..ac7c47b7f 100644 --- a/src/watcher/watcher.c +++ b/src/watcher/watcher.c @@ -522,14 +522,28 @@ static watcher_git_status_t git_repo_status(cbm_watcher_t *w, project_state_t *s * a `.git` file (the gitlink form used by linked worktrees and initialized * submodules). Deliberately a filesystem check, not a git invocation — the whole * point is to learn something `rev-parse` cannot tell us, because it walks up. */ +/* Preserve stat's link-following behavior while inspecting UTF-8 paths. */ +static int watcher_path_info(const char *path, cbm_path_info_t *info) { + int status = cbm_path_info_utf8(path, info); + if (status == CBM_PATH_INFO_OK && info->is_symlink) { + char canonical[CBM_SZ_4K]; + if (!cbm_canonical_path(path, canonical, sizeof(canonical))) { + return CBM_PATH_INFO_UNAVAILABLE; + } + status = cbm_path_info_utf8(canonical, info); + } + return status; +} + static bool git_has_own_dot_git(const char *root_path) { char path[CBM_SZ_4K]; int written = snprintf(path, sizeof(path), "%s/.git", root_path); if (written <= 0 || (size_t)written >= sizeof(path)) { return false; } - struct stat st; - return stat(path, &st) == 0 && (S_ISDIR(st.st_mode) || S_ISREG(st.st_mode)); + cbm_path_info_t info; + return watcher_path_info(path, &info) == CBM_PATH_INFO_OK && + (info.is_directory || info.is_regular); } /* Does the ancestor repository actually track anything inside this directory? @@ -990,13 +1004,17 @@ static root_status_t root_status(const char *root_path, int *out_errno) { if (!root_path) { return ROOT_UNCERTAIN; } - struct stat st; - if (stat(root_path, &st) == 0) { - /* Exists but is no longer a directory → the root directory is gone. */ - return S_ISDIR(st.st_mode) ? ROOT_PRESENT : ROOT_MISSING; + cbm_path_info_t info; + int status = watcher_path_info(root_path, &info); + if (status == CBM_PATH_INFO_OK) { + return info.is_directory ? ROOT_PRESENT : ROOT_MISSING; } +#ifdef _WIN32 + *out_errno = status == CBM_PATH_INFO_ABSENT ? ENOENT : EIO; +#else *out_errno = errno; - return cbm_watcher_root_missing_errno(errno) ? ROOT_MISSING : ROOT_UNCERTAIN; +#endif + return status == CBM_PATH_INFO_ABSENT ? ROOT_MISSING : ROOT_UNCERTAIN; } /* Sustained-absence window (seconds) before a missing root may be pruned. @@ -1338,8 +1356,8 @@ static void publish_status(project_state_t *s, bool scanned) { /* Init baseline for a project: check if git, get HEAD, count files */ static bool init_baseline(cbm_watcher_t *w, project_state_t *s) { - struct stat st; - if (stat(s->root_path, &st) != 0) { + cbm_path_info_t info; + if (watcher_path_info(s->root_path, &info) != CBM_PATH_INFO_OK || !info.is_directory) { cbm_log_warn("watcher.root_gone", "project", s->project_name, "path", s->root_path); s->baseline_done = true; s->is_git = false; diff --git a/tests/test_watcher.c b/tests/test_watcher.c index e0b78cffc..8be483ca8 100644 --- a/tests/test_watcher.c +++ b/tests/test_watcher.c @@ -1103,8 +1103,8 @@ TEST(watcher_stop_and_unwatch_cancel_blocked_git_without_backstop) { watcher_windows_image_identity_t expected_git; ASSERT_TRUE(watcher_windows_capture_image_identity(fake_git, &expected_git)); - const char *old_path_raw = getenv("PATH"); - const char *old_marker_raw = getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); + const char *old_path_raw = cbm_runtime_getenv("PATH"); + const char *old_marker_raw = cbm_runtime_getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); char *old_path = old_path_raw ? cbm_strdup(old_path_raw) : NULL; char *old_marker = old_marker_raw ? cbm_strdup(old_marker_raw) : NULL; size_t path_size = strlen(bin) + 2 + (old_path ? strlen(old_path) : 0); @@ -1238,8 +1238,8 @@ TEST(watcher_stop_and_unwatch_cancel_blocked_git_without_backstop) { ASSERT_TRUE(watcher_test_self_image(self)); ASSERT_EQ(symlink(self, fake_git), 0); - const char *old_path_raw = getenv("PATH"); - const char *old_marker_raw = getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); + const char *old_path_raw = cbm_runtime_getenv("PATH"); + const char *old_marker_raw = cbm_runtime_getenv(WATCHER_TEST_BLOCKING_GIT_MARKER_ENV); char *old_path = old_path_raw ? strdup(old_path_raw) : NULL; char *old_marker = old_marker_raw ? strdup(old_marker_raw) : NULL; size_t path_size = strlen(bin) + 2 + (old_path ? strlen(old_path) : 0); @@ -3995,7 +3995,41 @@ TEST(watcher_null_watch_count) { * SUITE * ══════════════════════════════════════════════════════════════════ */ +TEST(watcher_unicode_root_is_not_pruned_as_missing) { + char *parent = th_mktempdir("cbm_watch_utf8"); + ASSERT_NOT_NULL(parent); + char root[CBM_SZ_4K]; + snprintf(root, sizeof(root), "%s/\xE6\x97\xA5\xE6\x9C\xAC\xE8\xAA\x9E", parent); + ASSERT_EQ(th_mkdir_p(root), 0); + const char *saved = cbm_runtime_getenv("CBM_WATCHER_PRUNE_GRACE_S"); + char *saved_copy = saved ? strdup(saved) : NULL; + cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", "0", 1); + cbm_store_t *store = cbm_store_open_memory(); + cbm_store_upsert_project(store, "unicode-root", root); + cbm_watcher_t *watcher = cbm_watcher_new(store, NULL, NULL); + cbm_watcher_watch(watcher, "unicode-root", root); + for (int i = 0; i < 8; i++) { + cbm_watcher_poll_once(watcher); + } + cbm_project_t info = {0}; + bool kept = cbm_watcher_watch_count(watcher) == 1 && + cbm_store_get_project(store, "unicode-root", &info) == CBM_STORE_OK; + cbm_project_free_fields(&info); + cbm_watcher_free(watcher); + cbm_store_close(store); + if (saved_copy) { + cbm_setenv("CBM_WATCHER_PRUNE_GRACE_S", saved_copy, 1); + } else { + cbm_unsetenv("CBM_WATCHER_PRUNE_GRACE_S"); + } + free(saved_copy); + ASSERT_EQ(th_rmtree(parent), 0); + ASSERT_TRUE(kept); + PASS(); +} + SUITE(watcher) { + RUN_TEST(watcher_unicode_root_is_not_pruned_as_missing); /* Adaptive interval */ RUN_TEST(poll_interval_base); RUN_TEST(poll_interval_scaling);