Skip to content

fix(desktop): honor the configured repos directory on Windows - #3547

Open
sumit-m wants to merge 2 commits into
block:mainfrom
sumit-m:windows-repos-dir
Open

fix(desktop): honor the configured repos directory on Windows#3547
sumit-m wants to merge 2 commits into
block:mainfrom
sumit-m:windows-repos-dir

Conversation

@sumit-m

@sumit-m sumit-m commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Honours the configured repos directory on Windows.

ensure_repos_symlink was #[cfg(unix)], and the cfg(not(unix)) fallback dropped its argument entirely:

#[cfg(not(unix))]
pub fn ensure_repos_symlink(nest_root: &Path, _repos_dir: Option<&str>) -> Result<(), String> {
    let repos_path = nest_root.join("REPOS");
    fs::create_dir_all(&repos_path).map_err(...)
}

So on Windows the community's Repos Directory setting silently did nothing: agents worked in %USERPROFILE%\.buzz\REPOS while canonical_repos_roots — which is cross-platform — pointed the desktop's own git commands at the configured path. Two halves of the app looking in different places, with no error.

The cfg(unix) gate is gone; one decision tree now runs everywhere, with three small platform helpers in util.rs:

  • create_dir_linksymlink on Unix. On Windows it tries symlink_dir first, then falls back to a junction via mklink /J. The fallback is what actually carries this: symlink_dir needs Developer Mode or elevation, and failed on every attempt on my machine. Junctions need neither. symlink_dir is still tried first because junctions cannot address UNC targets.
  • remove_dir_linkremove_file on Unix, remove_dir on Windows, which is what Windows requires to unlink a directory link. The previous code called remove_file unconditionally.
  • dir_link_points_to — canonicalizes both sides, because Windows stores link targets in verbatim \\?\C:\… form which never compares equal to the caller's path.

Every existing safety property is preserved: a non-empty real REPOS is still refused rather than deleted, an empty one still converts, and re-pointing never touches either target's contents.

Second commit: expandTilde only matched a ~/ prefix, so the Windows-native ~\Documents\repos fell through to the backend, which rejects any ~ path with a message suggesting /Users/you/Development. Its trailing-separator strip also only handled /, doubling the separator on a Windows home. Both fixed, with the join logic extracted so it is testable without mocking homeDir().

The forward-slash rewrite is deliberately gated on the home path containing a backslash — \ is not a legal filename character on Windows so rewriting is lossless there, but it is legal on Unix and would corrupt a real filename. There is a test pinning that.

Windows link creation: privilege, and an existing REPOS

Symlink privilege

create_dir_link tries std::os::windows::fs::symlink_dir first, and on failure
falls back to mklink /J (a junction), which needs neither Developer Mode nor
elevation. Without that fallback this would fail for most users, since creating a
directory symlink on Windows requires SeCreateSymbolicLinkPrivilege.

The symlink attempt comes first on purpose: junctions only address local
volumes, so trying the junction first would silently break UNC targets.

If both fail, the error carries both causes:

Err(std::io::Error::other(format!(
    "symlink failed ({symlink_error}) and junction fallback failed: {}",
    String::from_utf8_lossy(&output.stderr).trim()
)))

symlink_repos wraps that with the link and target paths, and it propagates out
of ensure_repos_symlink as Err(String). apply_workspace emits it on the
repos-dir-error channel, which useNestNotifications renders as a
Repos directory not applied toast with the message as its description — so the
failure is visible in the UI, not just on stderr.

The silent fall-through the old code had is now gone. The deleted
#[cfg(not(unix))] stub ignored repos_dir entirely and just create_dir_all'd
the in-nest REPOS, which is exactly the "silent fall-through to
%USERPROFILE%\.buzz\REPOS" worth avoiding. There is now one implementation for
all platforms and no path that discards the configured value without saying so.

When REPOS already exists

Handled as an explicit match rather than a blind create. Validation
(validate_repos_dir) runs before any filesystem mutation, so an invalid
repos_dir returns Err with REPOS untouched.

existing REPOS behaviour
absent create the link
a link already resolving to the target no-op
a link pointing elsewhere unlink and recreate — never follows the link, so the old target's contents survive
an empty real directory remove and replace with the link
a non-empty real directory refuse with holds repositories; move or delete them before pointing repos dir elsewhere. Never remove_dir_all — that would destroy repos the agent cloned in-nest
exists but is not a directory refuse

Two Windows-specific details that the unix code got away with ignoring:

  • Unlinking. remove_dir_link uses remove_dir on Windows, not
    remove_file. Windows treats both directory symlinks and junctions as
    directory entries, so remove_file refuses them outright; remove_dir
    unlinks without following. The pre-existing fs::remove_file call would have
    failed on every re-point.
  • Comparison. dir_link_points_to canonicalizes both sides before
    comparing. Windows stores junction and symlink targets in verbatim
    (\\?\C:\…) form, which never compares equal to the path the caller passed
    in, so the old read_link() == target assertion would have reported "points
    somewhere else" for a link that was in fact correct — and re-created it on
    every single apply. is_symlink() is true for junctions as well as symlinks
    on Windows, so both link kinds take the same path.

Related issue

Part of #2388 (Windows Support).

No duplicate found: nothing open touches managed_agents/repos.rs or src-tauri/src/util.rs, and the one PR touching communityStorage.ts (#2580) does not touch expandTilde. Checked by intersecting changed-file paths across all open PRs.

Testing

Verified on Windows 11 (x86_64-pc-windows-msvc):

  • The 21 tests in managed_agents::repos were all #[cfg(unix)] and had never run on Windows. Un-gated, they pass there. That required swapping raw read_link() equality assertions for dir_link_points_to and one direct std::os::unix::fs::symlink call.
  • End to end: setting the repos directory produces REPOS [\\?\C:\Users\...\repos] under dir /AL, and a file created through REPOS/ appears at the target.
  • communityStorage suite passes, including three new joinHomePath cases covering both separator styles and the Unix-backslash-filename edge.
  • cargo clippy --lib and cargo fmt --check clean.

Not verified on macOS or Linux. The Unix arms of the new helpers are the original calls unchanged, and the previously-Unix-only tests still pass here, but I have no such hardware to confirm.

Left alone deliberately: util::create_symlink is still a silent no-op on non-Unix (util.rs:60), used by nest.rs for skill and binary links and by migration.rs. Those are file links with a different failure mode and deserve their own change.

@sumit-m
sumit-m requested a review from a team as a code owner July 29, 2026 14:31
@sumit-m
sumit-m force-pushed the windows-repos-dir branch from e3b1adf to fe76699 Compare July 29, 2026 14:52

@Chessing234 Chessing234 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the cfg(not(unix)) stub that discarded repos_dir. Honouring the configured directory on Windows so agents and canonical_repos_roots agree closes a real footgun for self-hosters. I’d like a quick note in the PR on how junction/symlink creation is handled when the target already exists or when the process lacks symlink privilege — Windows is picky there, and a clear error beats a silent fall-through to %USERPROFILE%\.buzz\REPOS.

@sumit-m

sumit-m commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Added a Windows link creation: privilege, and an existing REPOS section to the description covering exactly this.

Short version: symlink_dir is tried first (so UNC targets keep working), then mklink /J as a fallback, which needs neither Developer Mode nor elevation. If both fail the error carries both causes and reaches the UI as a Repos directory not applied toast via the repos-dir-error event — no silent fall-through. The silent one was the old #[cfg(not(unix))] stub this PR deletes: it discarded repos_dir and just create_dir_all'd the in-nest REPOS.

An existing REPOS is an explicit six-case match (tabulated in the description); the two that only bite on Windows are unlinking with remove_dir rather than remove_file (Windows refuses remove_file on both junctions and directory symlinks) and canonicalizing before comparing targets, since Windows stores them in verbatim \?\C:\… form and the old read_link() == target check would have re-created a correct link on every apply.

sumit-m added 2 commits August 1, 2026 01:27
ensure_repos_symlink was cfg(unix); the Windows fallback dropped the
configured path and always made a real in-nest REPOS, so agents worked
somewhere other than the desktop's own git commands.

Link via symlink_dir, falling back to a junction so no Developer Mode or
elevation is needed. Removal uses remove_dir, which is what Windows needs
to unlink a directory link, and target comparison canonicalizes both
sides because Windows stores verbatim paths. Un-gates the 21 repos tests
that never ran on Windows.

Signed-off-by: sumit-m <33051892+sumit-m@users.noreply.github.com>
expandTilde only matched a `~/` prefix, so `~\Documents\docker` fell
through to the backend, which rejects any `~` path. Its trailing-separator
strip also only handled `/`, doubling the separator on a Windows home.

Signed-off-by: sumit-m <33051892+sumit-m@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants