Skip to content

Commit 31d02a8

Browse files
committed
fix: default to creating file-symlinks if it is dangling on Windows (#1354)
This behaviour is the same as in Git.
1 parent bad5b48 commit 31d02a8

File tree

4 files changed

+65
-19
lines changed

4 files changed

+65
-19
lines changed

gix-fs/src/symlink.rs

+11-1
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,20 @@ pub fn remove(path: &Path) -> io::Result<()> {
3131

3232
#[cfg(windows)]
3333
/// Create a new symlink at `link` which points to `original`.
34+
///
35+
/// Note that if a symlink target (the `original`) isn't present on disk, it's assumed to be a
36+
/// file, creating a dangling file symlink. This is similar to a dangling symlink on Unix,
37+
/// which doesn't have to care about the target type though.
3438
pub fn create(original: &Path, link: &Path) -> io::Result<()> {
3539
use std::os::windows::fs::{symlink_dir, symlink_file};
3640
// TODO: figure out if links to links count as files or whatever they point at
37-
if std::fs::metadata(link.parent().expect("dir for link").join(original))?.is_dir() {
41+
let orig_abs = link.parent().expect("dir for link").join(original);
42+
let is_dir = match std::fs::metadata(orig_abs) {
43+
Ok(m) => m.is_dir(),
44+
Err(err) if err.kind() == io::ErrorKind::NotFound => false,
45+
Err(err) => return Err(err),
46+
};
47+
if is_dir {
3848
symlink_dir(original, link)
3949
} else {
4050
symlink_file(original, link)
Binary file not shown.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/bin/bash
2+
set -eu -o pipefail
3+
4+
git init -q
5+
6+
target_oid=$(echo -n "non-existing-target" | git hash-object -w --stdin)
7+
git update-index --index-info <<-EOF
8+
120000 $target_oid dangling
9+
EOF
10+
11+
git commit -m "dangling symlink in index"

gix-worktree-state/tests/state/checkout.rs

+43-18
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ fn driver_exe() -> String {
4242
exe
4343
}
4444

45+
fn assure_is_empty(dir: impl AsRef<Path>) -> std::io::Result<()> {
46+
assert_eq!(std::fs::read_dir(dir)?.count(), 0);
47+
Ok(())
48+
}
49+
4550
#[test]
4651
fn submodules_are_instantiated_as_directories() -> crate::Result {
4752
let mut opts = opts_from_probe();
@@ -57,11 +62,6 @@ fn submodules_are_instantiated_as_directories() -> crate::Result {
5762
Ok(())
5863
}
5964

60-
fn assure_is_empty(dir: impl AsRef<Path>) -> std::io::Result<()> {
61-
assert_eq!(std::fs::read_dir(dir)?.count(), 0);
62-
Ok(())
63-
}
64-
6565
#[test]
6666
fn accidental_writes_through_symlinks_are_prevented_if_overwriting_is_forbidden() {
6767
let mut opts = opts_from_probe();
@@ -125,7 +125,7 @@ fn writes_through_symlinks_are_prevented_even_if_overwriting_is_allowed() {
125125
if cfg!(windows) { "A-dir\\a" } else { "A-dir/a" },
126126
"A-file",
127127
"FAKE-DIR",
128-
if cfg!(windows) { "fake-file" } else { "FAKE-FILE" }
128+
"FAKE-FILE"
129129
]),
130130
);
131131
assert!(outcome.collisions.is_empty());
@@ -257,6 +257,30 @@ fn symlinks_become_files_if_disabled() -> crate::Result {
257257
Ok(())
258258
}
259259

260+
#[test]
261+
fn dangling_symlinks_can_be_created() -> crate::Result {
262+
let opts = opts_from_probe();
263+
if !opts.fs.symlink {
264+
eprintln!("Skipping dangling symlink test on filesystem that doesn't support it");
265+
return Ok(());
266+
}
267+
268+
let (_source_tree, destination, _index, outcome) =
269+
checkout_index_in_tmp_dir(opts.clone(), "make_dangling_symlink")?;
270+
let worktree_files = dir_structure(&destination);
271+
let worktree_files_stripped = stripped_prefix(&destination, &worktree_files);
272+
273+
assert_eq!(worktree_files_stripped, paths(["dangling"]));
274+
let symlink_path = &worktree_files[0];
275+
assert!(symlink_path
276+
.symlink_metadata()
277+
.expect("dangling symlink is on disk")
278+
.is_symlink());
279+
assert_eq!(std::fs::read_link(symlink_path)?, Path::new("non-existing-target"));
280+
assert!(outcome.collisions.is_empty());
281+
Ok(())
282+
}
283+
260284
#[test]
261285
fn allow_or_disallow_symlinks() -> crate::Result {
262286
let mut opts = opts_from_probe();
@@ -303,12 +327,7 @@ fn keep_going_collects_results() {
303327
.iter()
304328
.map(|r| r.path.to_path_lossy().into_owned())
305329
.collect::<Vec<_>>(),
306-
paths(if cfg!(unix) {
307-
[".gitattributes", "dir/content"]
308-
} else {
309-
// not actually a symlink anymore, even though symlinks are supported but git think differently.
310-
["dir/content", "dir/sub-dir/symlink"]
311-
})
330+
paths([".gitattributes", "dir/content"])
312331
);
313332
}
314333

@@ -322,11 +341,15 @@ fn keep_going_collects_results() {
322341
} else {
323342
assert_eq!(
324343
stripped_prefix(&destination, &dir_structure(&destination)),
325-
paths(if cfg!(unix) {
326-
Box::new(["dir/sub-dir/symlink", "empty", "executable"].into_iter()) as Box<dyn Iterator<Item = &str>>
327-
} else {
328-
Box::new(["empty", "executable"].into_iter())
329-
}),
344+
paths([
345+
if cfg!(unix) {
346+
"dir/sub-dir/symlink"
347+
} else {
348+
"dir\\sub-dir\\symlink"
349+
},
350+
"empty",
351+
"executable",
352+
]),
330353
"some files could not be created"
331354
);
332355
}
@@ -550,8 +573,10 @@ fn probe_gitoxide_dir() -> crate::Result<gix_fs::Capabilities> {
550573
}
551574

552575
fn opts_from_probe() -> gix_worktree_state::checkout::Options {
576+
static CAPABILITIES: Lazy<gix_fs::Capabilities> = Lazy::new(|| probe_gitoxide_dir().unwrap());
577+
553578
gix_worktree_state::checkout::Options {
554-
fs: probe_gitoxide_dir().unwrap(),
579+
fs: *CAPABILITIES,
555580
destination_is_initially_empty: true,
556581
thread_limit: gix_features::parallel::num_threads(None).into(),
557582
..Default::default()

0 commit comments

Comments
 (0)