@smbcloud / cli / commits / 907c9ca

fix(deploy): use try_exists for prune, add cycle/root symlink tests

Review follow-ups on the dangling-symlink prune: - Swap exists() for try_exists()? in both branches so a stat error on a symlink target propagates instead of being misread as "dangling" and deleting a link we merely failed to stat. - Note in the docstring that the walk depends on pnpm's layout, where every package dir is a real directory reachable directly (so nested dangling links are always visited). - Print the success symbol on the prune line to match the other step output. - Add a cycle-safety test (a dir symlink pointing back at the root must not be followed) and a root-is-dangling-symlink test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Seto Elkahfi committed Jul 8, 2026 at 21:40 UTC 907c9ca3da5de4f7806471c0010e99d63583dd94
1 file changed +57 -5
crates/cli/src/deploy/process_deploy_nextjs_ssr.rs
+57 -5
@@ -35,13 +35,22 @@ use {
35 /// the link is safe. See the `smbcloud-deploy-nextjs` skill for the write-up.
36 ///
37 /// Real (non-symlink) directories are traversed; a symlinked directory is
38 -/// treated as a leaf and never followed, which also avoids symlink cycles.
38 +/// treated as a leaf and never followed, which also avoids symlink cycles. This
39 +/// relies on pnpm's layout, where every package directory is a real directory
40 +/// reachable directly in the walk (symlinks only ever point *into* it): a
41 +/// dangling link nested under a real dir is always visited and pruned. A
42 +/// dangling link reachable *only* through a valid directory symlink would be
43 +/// skipped, but rsync would follow the same valid link and hit it — that shape
44 +/// does not occur in standalone output.
45 fn prune_dangling_symlinks(root: &std::path::Path) -> std::io::Result<usize> {
46 let meta = std::fs::symlink_metadata(root)?;
47
48 if meta.file_type().is_symlink() {
49 // A symlink at the walk root: drop it only if it dangles, never follow.
44 - if !root.exists() {
50 + // `try_exists` follows the link and reports Ok(false) only when the
51 + // target is genuinely absent; a real IO error propagates instead of
52 + // being misread as "dangling".
53 + if !root.try_exists()? {
54 std::fs::remove_file(root)?;
55 return Ok(1);
56 }
@@ -61,8 +70,10 @@ fn prune_dangling_symlinks(root: &std::path::Path) -> std::io::Result<usize> {
70 let path = entry.path();
71
72 if file_type.is_symlink() {
64 - // `exists()` follows the link; `false` means the target is missing.
65 - if !path.exists() {
73 + // `try_exists` follows the link; Ok(false) means the target is
74 + // missing (dangling). An IO error propagates rather than deleting a
75 + // link we merely failed to stat.
76 + if !path.try_exists()? {
77 std::fs::remove_file(&path)?;
78 removed += 1;
79 }
@@ -222,7 +233,8 @@ pub async fn process_deploy_nextjs_ssr(env: Environment, config: Config) -> Resu
233 match prune_dangling_symlinks(standalone_path) {
234 Ok(0) => {}
235 Ok(n) => println!(
225 - "{}",
236 + "{} {}",
237 + succeed_symbol(),
238 succeed_message(&format!(
239 "Pruned {} dangling symlink{} from .next/standalone before upload.",
240 n,
@@ -826,4 +838,44 @@ mod tests {
838 std::fs::write(root.path().join("a/b/f.txt"), "x").unwrap();
839 assert_eq!(prune_dangling_symlinks(root.path()).unwrap(), 0);
840 }
841 +
842 + /// A valid symlink *to a directory* is treated as a leaf and never followed.
843 + /// The link here points back at the walk root, so following it would recurse
844 + /// forever; terminating (and leaving the link in place) proves it is not
845 + /// followed — the same property that keeps pnpm's symlink graph cycle-free.
846 + #[test]
847 + fn does_not_follow_valid_directory_symlink() {
848 + let root = tempdir().unwrap();
849 + let base = root.path();
850 +
851 + std::fs::write(base.join("server.js"), "// server").unwrap();
852 + // A valid directory symlink that points back at the root: following it
853 + // would loop. `read_dir` reports it as a symlink, so the leaf branch
854 + // keeps it without descending.
855 + let dir_link = base.join("cycle");
856 + symlink(".", &dir_link).unwrap();
857 +
858 + let removed = prune_dangling_symlinks(base).unwrap();
859 +
860 + assert_eq!(removed, 0, "no dangling links, nothing removed");
861 + assert!(
862 + std::fs::symlink_metadata(&dir_link).is_ok(),
863 + "the valid directory symlink itself is preserved"
864 + );
865 + }
866 +
867 + /// The walk root being a dangling symlink is handled by the top-level branch:
868 + /// it is removed and counted.
869 + #[test]
870 + fn dangling_symlink_at_root_is_removed() {
871 + let root = tempdir().unwrap();
872 + let link = root.path().join("root_link");
873 + symlink("./missing_target", &link).unwrap();
874 +
875 + assert_eq!(prune_dangling_symlinks(&link).unwrap(), 1);
876 + assert!(
877 + std::fs::symlink_metadata(&link).is_err(),
878 + "the dangling root symlink is gone"
879 + );
880 + }
881 }