diff --git a/gix-fs/src/symlink.rs b/gix-fs/src/symlink.rs index 55798daa3e9..c29d030d0f8 100644 --- a/gix-fs/src/symlink.rs +++ b/gix-fs/src/symlink.rs @@ -31,10 +31,20 @@ pub fn remove(path: &Path) -> io::Result<()> { #[cfg(windows)] /// Create a new symlink at `link` which points to `original`. +/// +/// Note that if a symlink target (the `original`) isn't present on disk, it's assumed to be a +/// file, creating a dangling file symlink. This is similar to a dangling symlink on Unix, +/// which doesn't have to care about the target type though. pub fn create(original: &Path, link: &Path) -> io::Result<()> { use std::os::windows::fs::{symlink_dir, symlink_file}; // TODO: figure out if links to links count as files or whatever they point at - if std::fs::metadata(link.parent().expect("dir for link").join(original))?.is_dir() { + let orig_abs = link.parent().expect("dir for link").join(original); + let is_dir = match std::fs::metadata(orig_abs) { + Ok(m) => m.is_dir(), + Err(err) if err.kind() == io::ErrorKind::NotFound => false, + Err(err) => return Err(err), + }; + if is_dir { symlink_dir(original, link) } else { symlink_file(original, link) diff --git a/gix-worktree-state/tests/fixtures/generated-archives/make_dangling_symlink.tar.xz b/gix-worktree-state/tests/fixtures/generated-archives/make_dangling_symlink.tar.xz new file mode 100644 index 00000000000..4d737dbd1f1 Binary files /dev/null and b/gix-worktree-state/tests/fixtures/generated-archives/make_dangling_symlink.tar.xz differ diff --git a/gix-worktree-state/tests/fixtures/make_dangling_symlink.sh b/gix-worktree-state/tests/fixtures/make_dangling_symlink.sh new file mode 100644 index 00000000000..eb2b6a8edf6 --- /dev/null +++ b/gix-worktree-state/tests/fixtures/make_dangling_symlink.sh @@ -0,0 +1,11 @@ +#!/bin/bash +set -eu -o pipefail + +git init -q + +target_oid=$(echo -n "non-existing-target" | git hash-object -w --stdin) + git update-index --index-info <<-EOF +120000 $target_oid dangling +EOF + +git commit -m "dangling symlink in index" diff --git a/gix-worktree-state/tests/state/checkout.rs b/gix-worktree-state/tests/state/checkout.rs index 27a1df1dd48..c38a81877af 100644 --- a/gix-worktree-state/tests/state/checkout.rs +++ b/gix-worktree-state/tests/state/checkout.rs @@ -42,6 +42,11 @@ fn driver_exe() -> String { exe } +fn assure_is_empty(dir: impl AsRef) -> std::io::Result<()> { + assert_eq!(std::fs::read_dir(dir)?.count(), 0); + Ok(()) +} + #[test] fn submodules_are_instantiated_as_directories() -> crate::Result { let mut opts = opts_from_probe(); @@ -57,11 +62,6 @@ fn submodules_are_instantiated_as_directories() -> crate::Result { Ok(()) } -fn assure_is_empty(dir: impl AsRef) -> std::io::Result<()> { - assert_eq!(std::fs::read_dir(dir)?.count(), 0); - Ok(()) -} - #[test] fn accidental_writes_through_symlinks_are_prevented_if_overwriting_is_forbidden() { let mut opts = opts_from_probe(); @@ -125,7 +125,7 @@ fn writes_through_symlinks_are_prevented_even_if_overwriting_is_allowed() { if cfg!(windows) { "A-dir\\a" } else { "A-dir/a" }, "A-file", "FAKE-DIR", - if cfg!(windows) { "fake-file" } else { "FAKE-FILE" } + "FAKE-FILE" ]), ); assert!(outcome.collisions.is_empty()); @@ -257,6 +257,30 @@ fn symlinks_become_files_if_disabled() -> crate::Result { Ok(()) } +#[test] +fn dangling_symlinks_can_be_created() -> crate::Result { + let opts = opts_from_probe(); + if !opts.fs.symlink { + eprintln!("Skipping dangling symlink test on filesystem that doesn't support it"); + return Ok(()); + } + + let (_source_tree, destination, _index, outcome) = + checkout_index_in_tmp_dir(opts.clone(), "make_dangling_symlink")?; + let worktree_files = dir_structure(&destination); + let worktree_files_stripped = stripped_prefix(&destination, &worktree_files); + + assert_eq!(worktree_files_stripped, paths(["dangling"])); + let symlink_path = &worktree_files[0]; + assert!(symlink_path + .symlink_metadata() + .expect("dangling symlink is on disk") + .is_symlink()); + assert_eq!(std::fs::read_link(symlink_path)?, Path::new("non-existing-target")); + assert!(outcome.collisions.is_empty()); + Ok(()) +} + #[test] fn allow_or_disallow_symlinks() -> crate::Result { let mut opts = opts_from_probe(); @@ -303,12 +327,7 @@ fn keep_going_collects_results() { .iter() .map(|r| r.path.to_path_lossy().into_owned()) .collect::>(), - paths(if cfg!(unix) { - [".gitattributes", "dir/content"] - } else { - // not actually a symlink anymore, even though symlinks are supported but git think differently. - ["dir/content", "dir/sub-dir/symlink"] - }) + paths([".gitattributes", "dir/content"]) ); } @@ -322,11 +341,15 @@ fn keep_going_collects_results() { } else { assert_eq!( stripped_prefix(&destination, &dir_structure(&destination)), - paths(if cfg!(unix) { - Box::new(["dir/sub-dir/symlink", "empty", "executable"].into_iter()) as Box> - } else { - Box::new(["empty", "executable"].into_iter()) - }), + paths([ + if cfg!(unix) { + "dir/sub-dir/symlink" + } else { + "dir\\sub-dir\\symlink" + }, + "empty", + "executable", + ]), "some files could not be created" ); } @@ -550,8 +573,10 @@ fn probe_gitoxide_dir() -> crate::Result { } fn opts_from_probe() -> gix_worktree_state::checkout::Options { + static CAPABILITIES: Lazy = Lazy::new(|| probe_gitoxide_dir().unwrap()); + gix_worktree_state::checkout::Options { - fs: probe_gitoxide_dir().unwrap(), + fs: *CAPABILITIES, destination_is_initially_empty: true, thread_limit: gix_features::parallel::num_threads(None).into(), ..Default::default()