Skip to content

Commit 2b81558

Browse files
committed
file_util: fix broken symlink on Windows
... due to using incorrect separators. Fix #8185.
1 parent da9924c commit 2b81558

File tree

3 files changed

+227
-8
lines changed

3 files changed

+227
-8
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ should not be broken.
5151
* Sub-repos are no longer tracked. Any directory containing `.jj` or `.git`
5252
is ignored. Note that git submodules are unaffected by this.
5353

54+
* On Windows, symlinks that point to a path with `/` won't be supported. This
55+
path is [invalid on Windows].
56+
57+
[invalid on Windows]: https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file#naming-conventions
58+
5459
### Deprecations
5560

5661
* The `--destination`/`-d` arguments for `jj rebase`, `jj split`, `jj revert`,
@@ -144,6 +149,8 @@ should not be broken.
144149
* Ensured that with git submodules, remnants of your submodules do not show up
145150
in the working copy after running `jj new`
146151

152+
* Broken symlink on Windows. [#6934](https://github.com/jj-vcs/jj/issues/6934).
153+
147154
## [0.35.0] - 2025-11-05
148155

149156
### Release highlights

lib/src/local_working_copy.rs

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,27 @@ use crate::working_copy::WorkingCopy;
123123
use crate::working_copy::WorkingCopyFactory;
124124
use crate::working_copy::WorkingCopyStateError;
125125

126+
fn symlink_target_convert_to_store(path: &Path) -> Option<String> {
127+
// When storing the symlink target on Windows, convert "\" to "/", so that the
128+
// symlink remains valid on Unix.
129+
//
130+
// Note that we don't use std::path to handle the conversion, because it
131+
// performs poorly with Windows verbatim paths like \\?\Global\C:\file.txt.
132+
let res = path
133+
.as_os_str()
134+
.to_str()?
135+
.replace(std::path::MAIN_SEPARATOR_STR, "/");
136+
Some(res)
137+
}
138+
139+
fn symlink_target_convert_to_disk(path: &str) -> PathBuf {
140+
// Use the main separator to reformat the input path to avoid creating a broken
141+
// symlink with the incorrect separator "/".
142+
//
143+
// See https://github.com/jj-vcs/jj/issues/6934 for the relevant bug.
144+
PathBuf::from(path.replace("/", std::path::MAIN_SEPARATOR_STR))
145+
}
146+
126147
/// On-disk state of file executable bit.
127148
// TODO: maybe better to preserve the executable bit on all platforms, and
128149
// ignore conditionally? #3949
@@ -1762,13 +1783,12 @@ impl FileSnapshotter<'_> {
17621783
message: format!("Failed to read symlink {}", disk_path.display()),
17631784
err: err.into(),
17641785
})?;
1765-
let str_target =
1766-
target
1767-
.to_str()
1768-
.ok_or_else(|| SnapshotError::InvalidUtf8SymlinkTarget {
1769-
path: disk_path.to_path_buf(),
1770-
})?;
1771-
Ok(self.store().write_symlink(path, str_target).await?)
1786+
let str_target = symlink_target_convert_to_store(&target).ok_or_else(|| {
1787+
SnapshotError::InvalidUtf8SymlinkTarget {
1788+
path: disk_path.to_path_buf(),
1789+
}
1790+
})?;
1791+
Ok(self.store().write_symlink(path, &str_target).await?)
17721792
} else {
17731793
let target = fs::read(disk_path).map_err(|err| SnapshotError::Other {
17741794
message: format!("Failed to read file {}", disk_path.display()),
@@ -1832,7 +1852,24 @@ impl TreeState {
18321852
}
18331853

18341854
fn write_symlink(&self, disk_path: &Path, target: String) -> Result<FileState, CheckoutError> {
1835-
let target = PathBuf::from(&target);
1855+
let target = symlink_target_convert_to_disk(&target);
1856+
1857+
if cfg!(windows) {
1858+
// On Windows, "/" can't be part of valid file name, and "/" is also not a valid
1859+
// separator for the symlink target. See an example of this issue in
1860+
// https://github.com/jj-vcs/jj/issues/6934.
1861+
//
1862+
// We use debug_assert_* instead of assert_* because we want to avoid panic in
1863+
// release build, and we are sure that we shouldn't create invalid symlinks in
1864+
// tests.
1865+
debug_assert_ne!(
1866+
target.as_os_str().to_str().map(|path| path.contains("/")),
1867+
Some(true),
1868+
"Invalid symlink target: {}.",
1869+
target.display()
1870+
);
1871+
}
1872+
18361873
try_symlink(&target, disk_path).map_err(|err| CheckoutError::Other {
18371874
message: format!(
18381875
"Failed to create symlink from {} to {}",

lib/tests/test_local_working_copy.rs

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use std::fs::File;
1616
#[cfg(unix)]
1717
use std::os::unix::fs::PermissionsExt as _;
18+
use std::path::Component;
1819
use std::path::Path;
1920
use std::path::PathBuf;
2021
use std::sync::Arc;
@@ -27,6 +28,7 @@ use itertools::Itertools as _;
2728
use jj_lib::backend::CopyId;
2829
use jj_lib::backend::TreeId;
2930
use jj_lib::backend::TreeValue;
31+
use jj_lib::file_util;
3032
use jj_lib::file_util::check_symlink_support;
3133
use jj_lib::file_util::try_symlink;
3234
use jj_lib::fsmonitor::FsmonitorSettings;
@@ -2141,3 +2143,176 @@ fn test_snapshot_max_new_file_size() {
21412143
UntrackedReason::FileTooLarge { .. }
21422144
);
21432145
}
2146+
2147+
#[test]
2148+
fn test_snapshot_symlink_use_forward_slash() {
2149+
if !file_util::check_symlink_support().unwrap() {
2150+
eprintln!("Symlink not supported. Skip the test.");
2151+
}
2152+
let mut test_workspace = TestWorkspace::init();
2153+
let workspace_root = test_workspace.workspace.workspace_root().to_owned();
2154+
let target = repo_path("target/link/target.txt");
2155+
let target_path = target.to_fs_path(&workspace_root).unwrap();
2156+
std::fs::create_dir_all(target_path.parent().unwrap()).unwrap();
2157+
std::fs::write(&target_path, "a\n").unwrap();
2158+
let link = repo_path("link/link.txt");
2159+
let link_path = link.to_fs_path(&workspace_root).unwrap();
2160+
let link_contents = "../target/link/target.txt";
2161+
std::fs::create_dir_all(link_path.parent().unwrap()).unwrap();
2162+
file_util::try_symlink(link_contents, link_path).unwrap();
2163+
2164+
let tree = test_workspace
2165+
.snapshot()
2166+
.expect("Snapshot with symlink should succeed.");
2167+
let tree_value = tree
2168+
.path_value(link)
2169+
.expect("Failed to retrieve the MergedTreeValue from the path.")
2170+
.into_resolved()
2171+
.expect("Shouldn't have conflicts.")
2172+
.expect("The link path should exist.");
2173+
let TreeValue::Symlink(symlink_id) = tree_value.clone() else {
2174+
panic!(
2175+
"Expect {} to be a symlink, but got {:?}",
2176+
link.as_internal_file_string(),
2177+
tree_value
2178+
);
2179+
};
2180+
let actual_link_contents = test_workspace
2181+
.repo
2182+
.store()
2183+
.read_symlink(link, &symlink_id)
2184+
.block_on()
2185+
.unwrap();
2186+
2187+
assert!(
2188+
!actual_link_contents.contains("\\"),
2189+
"Expect the symlink in the Store to use \"/\" as the separator, but got \
2190+
{actual_link_contents}."
2191+
);
2192+
}
2193+
2194+
fn is_verbatim_path(path: &Path) -> bool {
2195+
let Some(Component::Prefix(prefix)) = path.components().next() else {
2196+
return false;
2197+
};
2198+
prefix.kind().is_verbatim()
2199+
}
2200+
2201+
#[cfg(windows)]
2202+
fn absolute_path_to_verbatim_path(input: &Path) -> PathBuf {
2203+
use std::ffi::OsString;
2204+
use std::path::Prefix;
2205+
2206+
use bstr::ByteSlice as _;
2207+
2208+
assert!(input.is_absolute());
2209+
let input = input.canonicalize().unwrap();
2210+
2211+
let mut components = input.components();
2212+
let Component::Prefix(prefix_component) = components.next().unwrap() else {
2213+
panic!("target should be an absolute path after being canonicalized");
2214+
};
2215+
let mut verbatim_path = match prefix_component.kind() {
2216+
// C: -> \\?\Global\C:
2217+
// \\?\C: -> \\?\Global\C:
2218+
//
2219+
// Prefix the path with Global, so that when we read back the symlink, it's still a verbatim
2220+
// path. The symlink to a \\?\C: prefixed path(e.g., \\?\C:\file.txt) will be converted to a
2221+
// not verbatim path(e.g., C:\file.txt) when calling read_link.
2222+
Prefix::Disk(disk) | Prefix::VerbatimDisk(disk) => {
2223+
let mut verbatim_prefix = OsString::from(r"\\?\Global\");
2224+
verbatim_prefix.push([disk].to_os_str().unwrap());
2225+
verbatim_prefix.push(":");
2226+
verbatim_prefix
2227+
}
2228+
_ => panic!("Unsupported path: {}", input.display()),
2229+
};
2230+
verbatim_path.push(components.as_path().as_os_str());
2231+
let verbatim_path = PathBuf::from(verbatim_path);
2232+
assert!(is_verbatim_path(&verbatim_path));
2233+
verbatim_path
2234+
}
2235+
2236+
#[test_case(|link, target| file_util::relative_path(link.parent().unwrap(), target); "relative")]
2237+
#[test_case(|_, target| {
2238+
assert!(target.is_absolute());
2239+
target.to_owned()
2240+
}; "absolute")]
2241+
#[cfg_attr(
2242+
windows,
2243+
test_case(|_, target: &Path| absolute_path_to_verbatim_path(target); "verbatim absolute")
2244+
)]
2245+
fn test_snapshot_and_update_valid_symlink(get_link_target: impl FnOnce(&Path, &Path) -> PathBuf) {
2246+
if !file_util::check_symlink_support().unwrap() {
2247+
eprintln!("Symlink not supported. Skip the test.");
2248+
}
2249+
let mut test_workspace = TestWorkspace::init();
2250+
let workspace_root = test_workspace.workspace.workspace_root().to_owned();
2251+
let target = repo_path("target/link/target.txt");
2252+
let target_path = target.to_fs_path(&workspace_root).unwrap();
2253+
std::fs::create_dir_all(target_path.parent().unwrap()).unwrap();
2254+
// Unique contents that it's unlikely that we match accidentally.
2255+
let file_contents = b"18bHZD165T@C\n";
2256+
std::fs::write(&target_path, file_contents).unwrap();
2257+
let link = repo_path("link/link.txt");
2258+
let link_path = link.to_fs_path(&workspace_root).unwrap();
2259+
let link_contents = get_link_target(&link_path, &target_path);
2260+
std::fs::create_dir_all(link_path.parent().unwrap()).unwrap();
2261+
file_util::try_symlink(&link_contents, &link_path).unwrap();
2262+
std::fs::read_link(&link_path).expect("The symlink itself should exist.");
2263+
assert_eq!(std::fs::read(&link_path).unwrap(), file_contents);
2264+
assert_eq!(
2265+
is_verbatim_path(&std::fs::read_link(&link_path).unwrap()),
2266+
is_verbatim_path(&link_contents),
2267+
"Make sure that when we test with a verbatim path, it's still a verbatim path in the \
2268+
Store when snapshotting."
2269+
);
2270+
2271+
let tree = test_workspace
2272+
.snapshot()
2273+
.expect("Snapshot with symlink should succeed.");
2274+
let commit = commit_with_tree(test_workspace.repo.store(), tree);
2275+
2276+
// Checkout the root commit to clear the workspace.
2277+
let mut locked_ws = test_workspace
2278+
.workspace
2279+
.start_working_copy_mutation()
2280+
.unwrap();
2281+
let root_commit = test_workspace.repo.store().root_commit();
2282+
locked_ws
2283+
.locked_wc()
2284+
.check_out(&root_commit)
2285+
.block_on()
2286+
.unwrap();
2287+
locked_ws
2288+
.finish(test_workspace.repo.op_id().clone())
2289+
.unwrap();
2290+
2291+
assert!(!std::fs::exists(&link_path).unwrap());
2292+
assert!(std::fs::read_link(&link_path).is_err());
2293+
2294+
// Checkout the original commit back.
2295+
let mut locked_ws = test_workspace
2296+
.workspace
2297+
.start_working_copy_mutation()
2298+
.unwrap();
2299+
locked_ws.locked_wc().check_out(&commit).block_on().unwrap();
2300+
locked_ws
2301+
.finish(test_workspace.repo.op_id().clone())
2302+
.unwrap();
2303+
2304+
let actual_target = std::fs::read_link(&link_path).expect("The symlink itself should exist.");
2305+
let actual_contents = std::fs::read(&link_path).unwrap_or_else(|e| {
2306+
panic!(
2307+
"Failed to read from the symlink at {}, which points to {}: {e:?}",
2308+
link_path.display(),
2309+
actual_target.display()
2310+
)
2311+
});
2312+
assert_eq!(actual_contents, file_contents);
2313+
assert_eq!(
2314+
is_verbatim_path(&std::fs::read_link(&link_path).unwrap()),
2315+
is_verbatim_path(&link_contents),
2316+
"When we checkout a symlink to a verbatim path, it should still point to a verbatim path."
2317+
);
2318+
}

0 commit comments

Comments
 (0)