Fork of daniellemaywood.uk/gleam — Wasm codegen work
2

Configure Feed

Select the types of activity you want to include in your feed.

Add path field support for git dependencies

Allow specifying a subdirectory within a git repository using an
optional `path` field.

+539 -34
+7
.github/workflows/ci.yaml
··· 270 270 working-directory: ./test/project_git_deps 271 271 if: ${{ matrix.run-integration-tests }} 272 272 273 + - name: test/project_git_deps_path 274 + run: ./test.sh 275 + working-directory: ./test/project_git_deps_path 276 + if: ${{ matrix.run-integration-tests }} 277 + env: 278 + GLEAM_COMMAND: gleam 279 + 273 280 - name: Test project generation 274 281 run: | 275 282 gleam new lib_project
+11
CHANGELOG.md
··· 48 48 link or copy file or directory. 49 49 ([Andrey Kozhev](https://github.com/ankddev)) 50 50 51 + - Git dependencies now support an optional `path` field to specify a 52 + subdirectory within the repository. This is useful for monorepos that 53 + contain multiple Gleam packages. For example: 54 + 55 + ```toml 56 + [dependencies] 57 + my_package = { git = "https://github.com/example/monorepo", ref = "main", path = "packages/my_package" } 58 + ``` 59 + 60 + ([John Downey](https://github.com/jtdowney)) 61 + 51 62 ### Language server 52 63 53 64 - The language server now supports go-to-definition, find-references and rename
+1
Cargo.lock
··· 1203 1203 "toml_edit", 1204 1204 "tracing", 1205 1205 "tracing-subscriber", 1206 + "xxhash-rust", 1206 1207 "zip", 1207 1208 ] 1208 1209
+3
Cargo.toml
··· 83 83 num-bigint = { version = "0.4.6", features = ["serde"] } 84 84 # Unicode grapheme traversal 85 85 unicode-segmentation = "1.13.2" 86 + # Checksums 87 + xxhash-rust = { version = "0", features = ["xxh3"] } 88 +
+1
compiler-cli/Cargo.toml
··· 69 69 termcolor.workspace = true 70 70 toml.workspace = true 71 71 tracing.workspace = true 72 + xxhash-rust.workspace = true 72 73 73 74 [dev-dependencies] 74 75 # Setting file modification times for tests
+1
compiler-cli/src/config.rs
··· 120 120 source: ManifestPackageSource::Git { 121 121 repo: "repo".into(), 122 122 commit: "commit".into(), 123 + path: None, 123 124 }, 124 125 }; 125 126 assert_eq!(
+117 -25
compiler-cli/src/dependencies.rs
··· 517 517 .download_hex_packages(missing_hex_packages, &project_name) 518 518 .await?; 519 519 for package in missing_git_packages { 520 - let ManifestPackageSource::Git { repo, commit } = &package.source else { 520 + let ManifestPackageSource::Git { repo, commit, path } = &package.source else { 521 521 continue; 522 522 }; 523 - let _ = download_git_package(&package.name, repo, commit, paths)?; 523 + let _ = download_git_package(&package.name, repo, commit, path.as_deref(), paths)?; 524 524 } 525 525 telemetry.packages_downloaded(start, num_to_download); 526 526 } ··· 801 801 802 802 #[derive(Clone, Eq, Debug)] 803 803 enum ProvidedPackageSource { 804 - Git { repo: EcoString, commit: EcoString }, 805 - Local { path: Utf8PathBuf }, 804 + Git { 805 + repo: EcoString, 806 + commit: EcoString, 807 + path: Option<Utf8PathBuf>, 808 + }, 809 + Local { 810 + path: Utf8PathBuf, 811 + }, 806 812 } 807 813 808 814 impl ProvidedPackage { ··· 853 859 impl ProvidedPackageSource { 854 860 fn to_manifest_package_source(&self) -> ManifestPackageSource { 855 861 match self { 856 - Self::Git { repo, commit } => ManifestPackageSource::Git { 862 + Self::Git { repo, commit, path } => ManifestPackageSource::Git { 857 863 repo: repo.clone(), 858 864 commit: commit.clone(), 865 + path: path.clone(), 859 866 }, 860 867 Self::Local { path } => ManifestPackageSource::Local { path: path.clone() }, 861 868 } ··· 863 870 864 871 fn to_toml(&self) -> String { 865 872 match self { 866 - Self::Git { repo, commit } => { 867 - format!(r#"{{ repo: "{repo}", commit: "{commit}" }}"#) 868 - } 873 + Self::Git { repo, commit, path } => match path { 874 + Some(path) => { 875 + format!(r#"{{ repo: "{repo}", commit: "{commit}", path: "{path}" }}"#) 876 + } 877 + None => format!(r#"{{ repo: "{repo}", commit: "{commit}" }}"#), 878 + }, 869 879 Self::Local { path } => { 870 880 format!(r#"{{ path: "{path}" }}"#) 871 881 } ··· 884 894 Self::Git { 885 895 repo: own_repo, 886 896 commit: own_commit, 897 + path: own_path, 887 898 }, 888 899 Self::Git { 889 900 repo: other_repo, 890 901 commit: other_commit, 902 + path: other_path, 891 903 }, 892 - ) => own_repo == other_repo && own_commit == other_commit, 904 + ) => own_repo == other_repo && own_commit == other_commit && own_path == other_path, 893 905 894 906 (Self::Git { .. }, Self::Local { .. }) | (Self::Local { .. }, Self::Git { .. }) => { 895 907 false ··· 954 966 } 955 967 } 956 968 969 + fn git_repo_dir_name(repo: &str, ref_: &str) -> String { 970 + let sanitized = repo 971 + .trim_end_matches(".git") 972 + .replace("://", "-") 973 + .replace(['/', ':', '@'], "-"); 974 + let sanitized = sanitized.trim_matches('-'); 975 + let hash = xxhash_rust::xxh3::xxh3_64(format!("{repo}\0{ref_}").as_bytes()); 976 + format!("{sanitized}-{hash:016x}") 977 + } 978 + 979 + /// Reject paths that escape the repository root via `..` components or absolute 980 + /// paths so that typos like `path = "../oops"` fail early with a clear error 981 + /// instead of silently linking the wrong directory. 982 + fn validate_git_dependency_path(package_name: &str, path: &Utf8Path, repo: &str) -> Result<()> { 983 + if path.is_absolute() || path.as_str().starts_with('/') { 984 + return Err(Error::GitDependencyPathNotFound { 985 + package: package_name.into(), 986 + path: path.to_string(), 987 + repo: repo.into(), 988 + }); 989 + } 990 + for component in path.components() { 991 + if matches!(component, camino::Utf8Component::ParentDir) { 992 + return Err(Error::GitDependencyPathNotFound { 993 + package: package_name.into(), 994 + path: path.to_string(), 995 + repo: repo.into(), 996 + }); 997 + } 998 + } 999 + Ok(()) 1000 + } 1001 + 957 1002 /// Downloads a git package from a remote repository. The commands that are run 958 1003 /// looks like this: 959 1004 /// ··· 988 1033 package_name: &str, 989 1034 repo: &str, 990 1035 ref_: &str, 1036 + path: Option<&Utf8Path>, 991 1037 project_paths: &ProjectPaths, 992 1038 ) -> Result<EcoString> { 993 - let package_path = project_paths.build_packages_package(package_name); 1039 + // When a subdirectory path is specified, clone to a staging area. 1040 + // Otherwise clone directly to the package directory. 1041 + let (clone_path, subdir) = match path { 1042 + None => (project_paths.build_packages_package(package_name), None), 1043 + Some(subdir) => { 1044 + validate_git_dependency_path(package_name, subdir, repo)?; 994 1045 995 - // If the package path exists but is not inside a git work tree, we need to 1046 + let staging_name = git_repo_dir_name(repo, ref_); 1047 + let staging_path = project_paths.build_git_repo(&staging_name); 1048 + (staging_path, Some(subdir)) 1049 + } 1050 + }; 1051 + 1052 + // If the clone path exists but is not inside a git work tree, we need to 996 1053 // remove the directory because running `git init` in a non-empty directory 997 1054 // followed by `git checkout ...` is an error. See 998 1055 // https://github.com/gleam-lang/gleam/issues/4488 for details. 999 - if !fs::is_git_work_tree_root(&package_path) { 1000 - fs::delete_directory(&package_path)?; 1056 + if !fs::is_git_work_tree_root(&clone_path) { 1057 + fs::delete_directory(&clone_path)?; 1001 1058 } 1002 1059 1003 - fs::mkdir(&package_path)?; 1060 + fs::mkdir(&clone_path)?; 1004 1061 1005 - let _ = execute_command(Command::new("git").arg("init").current_dir(&package_path))?; 1062 + let _ = execute_command(Command::new("git").arg("init").current_dir(&clone_path))?; 1006 1063 1007 1064 // If this directory already exists, but the remote URL has been edited in 1008 1065 // `gleam.toml` without a `gleam clean`, `git remote add` will fail, causing ··· 1014 1071 .arg("remote") 1015 1072 .arg("remove") 1016 1073 .arg("origin") 1017 - .current_dir(&package_path) 1074 + .current_dir(&clone_path) 1018 1075 .output(); 1019 1076 1020 1077 let _ = execute_command( ··· 1023 1080 .arg("add") 1024 1081 .arg("origin") 1025 1082 .arg(repo) 1026 - .current_dir(&package_path), 1083 + .current_dir(&clone_path), 1027 1084 )?; 1028 1085 1029 1086 let _ = execute_command( 1030 1087 Command::new("git") 1031 1088 .arg("fetch") 1032 1089 .arg("origin") 1033 - .current_dir(&package_path), 1090 + .current_dir(&clone_path), 1034 1091 )?; 1035 1092 1036 1093 let _ = execute_command( 1037 1094 Command::new("git") 1038 1095 .arg("checkout") 1039 1096 .arg(ref_) 1040 - .current_dir(&package_path), 1097 + .current_dir(&clone_path), 1041 1098 )?; 1042 1099 1043 1100 let output = execute_command( 1044 1101 Command::new("git") 1045 1102 .arg("rev-parse") 1046 1103 .arg("HEAD") 1047 - .current_dir(&package_path), 1104 + .current_dir(&clone_path), 1048 1105 )?; 1049 1106 1050 1107 let commit = String::from_utf8(output.stdout) ··· 1052 1109 .trim() 1053 1110 .into(); 1054 1111 1112 + // If a subdirectory was specified, hard-link it into the package directory 1113 + if let Some(subdir) = subdir { 1114 + let subdir_source = clone_path.join(subdir); 1115 + if !subdir_source.is_dir() { 1116 + return Err(Error::GitDependencyPathNotFound { 1117 + package: package_name.into(), 1118 + path: subdir.to_string(), 1119 + repo: repo.into(), 1120 + }); 1121 + } 1122 + 1123 + let package_path = project_paths.build_packages_package(package_name); 1124 + fs::delete_directory(&package_path)?; 1125 + fs::mkdir(&package_path)?; 1126 + fs::hardlink_dir(&subdir_source, &package_path)?; 1127 + } 1128 + 1055 1129 Ok(commit) 1056 1130 } 1057 1131 ··· 1061 1135 repo: &str, 1062 1136 // A git ref, such as a branch name, commit hash or tag name 1063 1137 ref_: &str, 1138 + path: Option<Utf8PathBuf>, 1064 1139 project_paths: &ProjectPaths, 1065 1140 provided: &mut HashMap<EcoString, ProvidedPackage>, 1066 1141 parents: &mut Vec<EcoString>, 1067 1142 ) -> Result<hexpm::version::Range> { 1068 - let commit = download_git_package(&package_name, repo, ref_, project_paths)?; 1143 + let commit = download_git_package(&package_name, repo, ref_, path.as_deref(), project_paths)?; 1069 1144 1070 1145 let package_source = ProvidedPackageSource::Git { 1071 1146 repo: repo.into(), 1072 1147 commit, 1148 + path: path.clone(), 1073 1149 }; 1074 1150 1075 - let package_path = fs::canonicalise(&project_paths.build_packages_package(&package_name))?; 1151 + // When a subdirectory path is used, resolve the package from the staging 1152 + // clone so that transitive path dependencies (e.g. `../sibling`) resolve 1153 + // against the original repository structure rather than build/packages/. 1154 + let package_path = match path { 1155 + Some(ref subdir) => { 1156 + let staging_name = git_repo_dir_name(repo, ref_); 1157 + let staging_path = project_paths.build_git_repo(&staging_name); 1158 + fs::canonicalise(&staging_path.join(subdir))? 1159 + } 1160 + None => fs::canonicalise(&project_paths.build_packages_package(&package_name))?, 1161 + }; 1076 1162 1077 1163 provide_package( 1078 1164 package_name, ··· 1150 1236 parents, 1151 1237 )? 1152 1238 } 1153 - Requirement::Git { git, ref_ } => { 1154 - provide_git_package(name.clone(), &git, &ref_, project_paths, provided, parents)? 1155 - } 1239 + Requirement::Git { git, ref_, path } => provide_git_package( 1240 + name.clone(), 1241 + &git, 1242 + &ref_, 1243 + path, 1244 + project_paths, 1245 + provided, 1246 + parents, 1247 + )?, 1156 1248 }; 1157 1249 let _ = requirements.insert(name, version); 1158 1250 }
+2 -1
compiler-cli/src/dependencies/dependency_manager.rs
··· 260 260 &mut provided_packages, 261 261 &mut vec![], 262 262 )?, 263 - Requirement::Git { git, ref_ } => { 263 + Requirement::Git { git, ref_, path } => { 264 264 // If this package is locked and we already resolved a commit 265 265 // hash for it, we want to use that hash rather than pulling 266 266 // the latest commit. ··· 283 283 name.clone(), 284 284 &git, 285 285 ref_to_use, 286 + path, 286 287 project_paths, 287 288 &mut provided_packages, 288 289 &mut Vec::new(),
+86
compiler-cli/src/dependencies/tests.rs
··· 683 683 source: ProvidedPackageSource::Git { 684 684 repo: "https://github.com/gleam-lang/gleam.git".into(), 685 685 commit: "bd9fe02f72250e6a136967917bcb1bdccaffa3c8".into(), 686 + path: None, 686 687 }, 687 688 requirements: [ 688 689 ( ··· 779 780 source: ProvidedPackageSource::Git { 780 781 repo: "https://github.com/gleam-lang/gleam.git".into(), 781 782 commit: "bd9fe02f72250e6a136967917bcb1bdccaffa3c8".into(), 783 + path: None, 782 784 }, 783 785 requirements: [ 784 786 ( ··· 802 804 source: ManifestPackageSource::Git { 803 805 repo: "https://github.com/gleam-lang/gleam.git".into(), 804 806 commit: "bd9fe02f72250e6a136967917bcb1bdccaffa3c8".into(), 807 + path: None, 805 808 }, 806 809 }; 807 810 808 811 assert_eq!( 809 812 provided_package.to_manifest_package("package"), 810 813 manifest_package 814 + ); 815 + } 816 + 817 + #[test] 818 + fn validate_git_dependency_path_accepts_subdir() { 819 + assert!( 820 + validate_git_dependency_path( 821 + "package", 822 + Utf8Path::new("subdir"), 823 + "https://github.com/gleam-lang/gleam.git" 824 + ) 825 + .is_ok() 826 + ); 827 + } 828 + 829 + #[test] 830 + fn validate_git_dependency_path_accepts_nested_subdir() { 831 + assert!( 832 + validate_git_dependency_path( 833 + "package", 834 + Utf8Path::new("packages/subdir"), 835 + "https://github.com/gleam-lang/gleam.git" 836 + ) 837 + .is_ok() 838 + ); 839 + } 840 + 841 + #[test] 842 + fn validate_git_dependency_path_rejects_parent_traversal() { 843 + let result = validate_git_dependency_path( 844 + "package", 845 + Utf8Path::new("../escape"), 846 + "https://github.com/gleam-lang/gleam.git", 847 + ); 848 + assert!(matches!( 849 + result, 850 + Err(Error::GitDependencyPathNotFound { .. }) 851 + )); 852 + } 853 + 854 + #[test] 855 + fn validate_git_dependency_path_rejects_nested_parent_traversal() { 856 + let result = validate_git_dependency_path( 857 + "package", 858 + Utf8Path::new("packages/../../escape"), 859 + "https://github.com/gleam-lang/gleam.git", 860 + ); 861 + assert!(matches!( 862 + result, 863 + Err(Error::GitDependencyPathNotFound { .. }) 864 + )); 865 + } 866 + 867 + #[test] 868 + fn validate_git_dependency_path_rejects_absolute_path() { 869 + let result = validate_git_dependency_path( 870 + "package", 871 + Utf8Path::new("/etc/passwd"), 872 + "https://github.com/gleam-lang/gleam.git", 873 + ); 874 + assert!(matches!( 875 + result, 876 + Err(Error::GitDependencyPathNotFound { .. }) 877 + )); 878 + } 879 + 880 + #[test] 881 + fn git_repo_dir_name_produces_expected_name() { 882 + assert_eq!( 883 + git_repo_dir_name("https://github.com/gleam-lang/gleam.git", "main"), 884 + "https-github.com-gleam-lang-gleam-edfec3bb6bbeb6c1" 885 + ); 886 + } 887 + 888 + #[test] 889 + fn git_repo_dir_name_different_refs_produce_different_names() { 890 + assert_eq!( 891 + git_repo_dir_name("https://github.com/gleam-lang/gleam.git", "main"), 892 + "https-github.com-gleam-lang-gleam-edfec3bb6bbeb6c1" 893 + ); 894 + assert_eq!( 895 + git_repo_dir_name("https://github.com/gleam-lang/gleam.git", "v1.0.0"), 896 + "https-github.com-gleam-lang-gleam-90203c669cc91eee" 811 897 ); 812 898 } 813 899
+59
compiler-cli/src/fs.rs
··· 733 733 .map(|_| ()) 734 734 } 735 735 736 + pub fn hardlink_dir( 737 + from: impl AsRef<Utf8Path> + Debug, 738 + to: impl AsRef<Utf8Path> + Debug, 739 + ) -> Result<(), Error> { 740 + tracing::debug!(from=?from, to=?to, "hardlinking_directory"); 741 + hardlink_dir_recursive(from.as_ref(), from.as_ref(), to.as_ref()) 742 + } 743 + 744 + fn hardlink_dir_recursive( 745 + base: &Utf8Path, 746 + current: &Utf8Path, 747 + dest_base: &Utf8Path, 748 + ) -> Result<(), Error> { 749 + let entries = std::fs::read_dir(current).map_err(|err| Error::FileIo { 750 + action: FileIoAction::Read, 751 + kind: FileKind::Directory, 752 + path: current.to_path_buf(), 753 + err: Some(err.to_string()), 754 + })?; 755 + 756 + for entry in entries { 757 + let entry = entry.map_err(|err| Error::FileIo { 758 + action: FileIoAction::Read, 759 + kind: FileKind::Directory, 760 + path: current.to_path_buf(), 761 + err: Some(err.to_string()), 762 + })?; 763 + 764 + let source_path = 765 + Utf8PathBuf::from_path_buf(entry.path()).expect("Non-UTF8 path in hardlink_dir"); 766 + 767 + let relative = source_path 768 + .strip_prefix(base) 769 + .expect("Source path should be under base"); 770 + let dest_path = dest_base.join(relative); 771 + 772 + let file_type = entry.file_type().map_err(|err| Error::FileIo { 773 + action: FileIoAction::Read, 774 + kind: FileKind::File, 775 + path: source_path.clone(), 776 + err: Some(err.to_string()), 777 + })?; 778 + 779 + // Skip symlinks to prevent path traversal outside the source tree 780 + if file_type.is_symlink() { 781 + continue; 782 + } 783 + 784 + if file_type.is_dir() { 785 + mkdir(&dest_path)?; 786 + hardlink_dir_recursive(base, &source_path, dest_base)?; 787 + } else { 788 + hardlink(&source_path, &dest_path)?; 789 + } 790 + } 791 + 792 + Ok(()) 793 + } 794 + 736 795 /// Check if the given path is inside a git work tree. 737 796 /// This is done by running `git rev-parse --is-inside-work-tree --quiet` in the 738 797 /// given path. If git is not installed then we assume we're not in a git work
+83 -1
compiler-cli/src/fs/tests.rs
··· 1 1 // SPDX-License-Identifier: Apache-2.0 2 2 // SPDX-FileCopyrightText: 2022 The Gleam contributors 3 3 4 - use camino::Utf8Path; 4 + use camino::{Utf8Path, Utf8PathBuf}; 5 5 use itertools::Itertools; 6 6 7 7 #[test] ··· 190 190 "id first" 191 191 ); 192 192 } 193 + 194 + #[test] 195 + fn hardlink_dir_copies_files_and_directories() { 196 + let tmp = tempfile::tempdir().unwrap(); 197 + let base = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); 198 + let src = base.join("src"); 199 + let dest = base.join("dest"); 200 + 201 + super::mkdir(&src).unwrap(); 202 + super::mkdir(&src.join("sub")).unwrap(); 203 + super::write(&src.join("a.txt"), "hello").unwrap(); 204 + super::write(&src.join("sub").join("b.txt"), "world").unwrap(); 205 + 206 + super::mkdir(&dest).unwrap(); 207 + super::hardlink_dir(&src, &dest).unwrap(); 208 + 209 + assert_eq!( 210 + std::fs::read_to_string(dest.join("a.txt")).unwrap(), 211 + "hello" 212 + ); 213 + assert_eq!( 214 + std::fs::read_to_string(dest.join("sub").join("b.txt")).unwrap(), 215 + "world" 216 + ); 217 + } 218 + 219 + #[test] 220 + fn hardlink_dir_skips_git_directory() { 221 + let tmp = tempfile::tempdir().unwrap(); 222 + let base = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); 223 + let src = base.join("src"); 224 + let dest = base.join("dest"); 225 + 226 + super::mkdir(&src).unwrap(); 227 + super::mkdir(&src.join(".git")).unwrap(); 228 + super::write(&src.join("a.txt"), "content").unwrap(); 229 + super::write(&src.join(".git").join("config"), "gitdata").unwrap(); 230 + 231 + super::mkdir(&dest).unwrap(); 232 + super::hardlink_dir(&src, &dest).unwrap(); 233 + 234 + assert!(dest.join("a.txt").exists()); 235 + assert!(!dest.join(".git").exists()); 236 + } 237 + 238 + #[test] 239 + fn hardlink_dir_empty_source() { 240 + let tmp = tempfile::tempdir().unwrap(); 241 + let base = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); 242 + let src = base.join("src"); 243 + let dest = base.join("dest"); 244 + 245 + super::mkdir(&src).unwrap(); 246 + super::mkdir(&dest).unwrap(); 247 + super::hardlink_dir(&src, &dest).unwrap(); 248 + 249 + assert!(dest.exists()); 250 + assert_eq!(std::fs::read_dir(&dest).unwrap().count(), 0); 251 + } 252 + 253 + #[cfg(unix)] 254 + #[test] 255 + fn hardlink_dir_skips_symlinks() { 256 + let tmp = tempfile::tempdir().unwrap(); 257 + let base = Utf8PathBuf::from_path_buf(tmp.path().to_path_buf()).unwrap(); 258 + let src = base.join("src"); 259 + let dest = base.join("dest"); 260 + let outside = base.join("outside"); 261 + 262 + super::mkdir(&src).unwrap(); 263 + super::mkdir(&dest).unwrap(); 264 + super::mkdir(&outside).unwrap(); 265 + super::write(&src.join("real.txt"), "kept").unwrap(); 266 + super::write(&outside.join("secret.txt"), "stolen").unwrap(); 267 + 268 + std::os::unix::fs::symlink(outside.as_std_path(), src.join("escape").as_std_path()).unwrap(); 269 + 270 + super::hardlink_dir(&src, &dest).unwrap(); 271 + 272 + assert!(dest.join("real.txt").exists()); 273 + assert!(!dest.join("escape").exists()); 274 + }
+1 -1
compiler-core/Cargo.toml
··· 28 28 # cross platform single glob and glob set matching 29 29 globset = { version = "0", features = ["serde1"] } 30 30 # Checksums 31 - xxhash-rust = { version = "0", features = ["xxh3"] } 31 + xxhash-rust.workspace = true 32 32 # Pubgrub dependency resolution algorithm 33 33 pubgrub = "0.3" 34 34 # Used for converting absolute path to relative path
+2
compiler-core/src/dependency.rs
··· 1064 1064 requirement::Requirement::Git { 1065 1065 git: "git".into(), 1066 1066 ref_: "ref".into(), 1067 + path: None, 1067 1068 }, 1068 1069 )] 1069 1070 .into_iter() ··· 1077 1078 source: ManifestPackageSource::Git { 1078 1079 repo: "repo".into(), 1079 1080 commit: "commit".into(), 1081 + path: None, 1080 1082 }, 1081 1083 }], 1082 1084 };
+26
compiler-core/src/error.rs
··· 336 336 source_2: String, 337 337 }, 338 338 339 + #[error("The path {path} does not exist in the git repository {repo} for package {package}")] 340 + GitDependencyPathNotFound { 341 + package: String, 342 + path: String, 343 + repo: String, 344 + }, 345 + 339 346 #[error("The package was missing required fields for publishing")] 340 347 MissingHexPublishFields { 341 348 description_missing: bool, ··· 2271 2278 2272 2279 vec![Diagnostic { 2273 2280 title: "Conflicting provided dependencies".into(), 2281 + text, 2282 + hint: None, 2283 + location: None, 2284 + level: Level::Error, 2285 + }] 2286 + } 2287 + 2288 + Error::GitDependencyPathNotFound { 2289 + package, 2290 + path, 2291 + repo, 2292 + } => { 2293 + let text = format!( 2294 + "The path `{path}` does not exist in the git repository `{repo}` \ 2295 + for package `{package}`." 2296 + ); 2297 + 2298 + vec![Diagnostic { 2299 + title: "Git dependency path not found".into(), 2274 2300 text, 2275 2301 hint: None, 2276 2302 location: None,
+14 -2
compiler-core/src/manifest.rs
··· 96 96 buffer.push_str(&outer_checksum.base_16_encoded_string()); 97 97 buffer.push('"'); 98 98 } 99 - ManifestPackageSource::Git { repo, commit } => { 99 + ManifestPackageSource::Git { repo, commit, path } => { 100 100 buffer.push_str(r#", source = "git", repo = ""#); 101 101 buffer.push_str(repo); 102 102 buffer.push_str(r#"", commit = ""#); 103 103 buffer.push_str(commit); 104 104 buffer.push('"'); 105 + if let Some(path) = path { 106 + buffer.push_str(r#", path = ""#); 107 + buffer.push_str(path.as_str()); 108 + buffer.push('"'); 109 + } 105 110 } 106 111 ManifestPackageSource::Local { path } => { 107 112 buffer.push_str(r#", source = "local", path = ""#); ··· 241 246 #[serde(rename = "hex")] 242 247 Hex { outer_checksum: Base16Checksum }, 243 248 #[serde(rename = "git")] 244 - Git { repo: EcoString, commit: EcoString }, 249 + Git { 250 + repo: EcoString, 251 + commit: EcoString, 252 + #[serde(default, skip_serializing_if = "Option::is_none")] 253 + path: Option<Utf8PathBuf>, 254 + }, 245 255 #[serde(rename = "local")] 246 256 Local { path: Utf8PathBuf }, // should be the canonical path 247 257 } ··· 351 361 source: ManifestPackageSource::Git { 352 362 repo: "https://github.com/gleam-lang/gleam.git".into(), 353 363 commit: "bd9fe02f72250e6a136967917bcb1bdccaffa3c8".into(), 364 + path: None, 354 365 }, 355 366 }, 356 367 ManifestPackage { ··· 466 477 source: ManifestPackageSource::Git { 467 478 repo: "https://github.com/gleam-lang/gleam.git".into(), 468 479 commit: "bd9fe02f72250e6a136967917bcb1bdccaffa3c8".into(), 480 + path: None, 469 481 }, 470 482 }, 471 483 ManifestPackage {
+8
compiler-core/src/paths.rs
··· 66 66 self.build_directory().join("packages") 67 67 } 68 68 69 + pub fn build_git_repos_directory(&self) -> Utf8PathBuf { 70 + self.build_directory().join("git_repos") 71 + } 72 + 73 + pub fn build_git_repo(&self, name: &str) -> Utf8PathBuf { 74 + self.build_git_repos_directory().join(name) 75 + } 76 + 69 77 pub fn build_packages_toml(&self) -> Utf8PathBuf { 70 78 self.build_packages_directory().join("packages.toml") 71 79 }
+29 -4
compiler-core/src/requirement.rs
··· 30 30 git: EcoString, 31 31 #[serde(rename = "ref")] 32 32 ref_: EcoString, 33 + #[serde(default)] 34 + path: Option<Utf8PathBuf>, 33 35 }, 34 36 } 35 37 ··· 51 53 Requirement::Git { 52 54 git: url.into(), 53 55 ref_: ref_.into(), 56 + path: None, 57 + } 58 + } 59 + 60 + pub fn git_with_path(url: &str, ref_: &str, path: &str) -> Requirement { 61 + Requirement::Git { 62 + git: url.into(), 63 + ref_: ref_.into(), 64 + path: Some(path.into()), 54 65 } 55 66 } 56 67 ··· 65 76 make_relative(root_path, path).as_str().replace('\\', "/") 66 77 ) 67 78 } 68 - Requirement::Git { git: url, ref_ } => { 69 - format!(r#"{{ git = "{url}", ref = "{ref_}" }}"#) 70 - } 79 + Requirement::Git { 80 + git: url, 81 + ref_, 82 + path, 83 + } => match path { 84 + Some(path) => { 85 + format!(r#"{{ git = "{url}", ref = "{ref_}", path = "{path}" }}"#) 86 + } 87 + None => format!(r#"{{ git = "{url}", ref = "{ref_}" }}"#), 88 + }, 71 89 } 72 90 } 73 91 } ··· 83 101 match self { 84 102 Requirement::Hex { version: range } => map.serialize_entry("version", range)?, 85 103 Requirement::Path { path } => map.serialize_entry("path", path)?, 86 - Requirement::Git { git: url, ref_ } => { 104 + Requirement::Git { 105 + git: url, 106 + ref_, 107 + path, 108 + } => { 87 109 map.serialize_entry("git", url)?; 88 110 map.serialize_entry("ref", ref_)?; 111 + if let Some(path) = path { 112 + map.serialize_entry("path", path)?; 113 + } 89 114 } 90 115 } 91 116 map.end()
+4
language-server/src/tests.rs
··· 340 340 ManifestPackageSource::Git { 341 341 ref repo, 342 342 ref commit, 343 + .. 343 344 } => Requirement::Git { 344 345 git: repo.clone(), 345 346 ref_: commit.clone(), 347 + path: None, 346 348 }, 347 349 }, 348 350 ); ··· 365 367 ManifestPackageSource::Git { 366 368 ref repo, 367 369 ref commit, 370 + .. 368 371 } => Requirement::Git { 369 372 git: repo.clone(), 370 373 ref_: commit.clone(), 374 + path: None, 371 375 }, 372 376 }, 373 377 );
+3
test/project_git_deps_path/.gitignore
··· 1 + build 2 + gleam.toml 3 + manifest.toml
+8
test/project_git_deps_path/src/git_deps_path.gleam
··· 1 + // SPDX-License-Identifier: Apache-2.0 2 + // SPDX-FileCopyrightText: 2026 The Gleam contributors 3 + 4 + import package_a 5 + 6 + pub fn main() { 7 + package_a.hello() 8 + }
+73
test/project_git_deps_path/test.sh
··· 1 + # SPDX-License-Identifier: Apache-2.0 2 + # SPDX-FileCopyrightText: 2026 The Gleam contributors 3 + 4 + #!/bin/sh 5 + 6 + set -eu 7 + 8 + GLEAM_COMMAND=${GLEAM_COMMAND:-"cargo run --quiet --"} 9 + 10 + g() { 11 + echo "Running: $GLEAM_COMMAND $@" 12 + $GLEAM_COMMAND "$@" 13 + } 14 + 15 + # Create a monorepo with two sibling packages in a temp directory 16 + REPO_DIR=$(mktemp -d) 17 + trap 'rm -rf "$REPO_DIR"' EXIT 18 + 19 + mkdir -p "$REPO_DIR/package_a/src" "$REPO_DIR/package_b/src" 20 + 21 + cat > "$REPO_DIR/package_b/gleam.toml" << 'TOML' 22 + name = "package_b" 23 + version = "0.1.0" 24 + 25 + [dependencies] 26 + TOML 27 + 28 + cat > "$REPO_DIR/package_b/src/package_b.gleam" << 'GLEAM' 29 + pub fn greeting() -> String { 30 + "hello from package_b" 31 + } 32 + GLEAM 33 + 34 + cat > "$REPO_DIR/package_a/gleam.toml" << 'TOML' 35 + name = "package_a" 36 + version = "0.1.0" 37 + 38 + [dependencies] 39 + package_b = { path = "../package_b" } 40 + TOML 41 + 42 + cat > "$REPO_DIR/package_a/src/package_a.gleam" << 'GLEAM' 43 + import package_b 44 + 45 + pub fn hello() -> String { 46 + package_b.greeting() 47 + } 48 + GLEAM 49 + 50 + cd "$REPO_DIR" 51 + git init -q 52 + git add . 53 + git -c user.name="Test" -c user.email="test@example.com" commit -q -m "Initial commit" 54 + REF=$(git rev-parse HEAD) 55 + cd "$OLDPWD" 56 + 57 + echo Resetting the build directory to get to a known state 58 + rm -fr build 59 + 60 + cat > gleam.toml << EOF 61 + name = "git_deps_path" 62 + version = "0.1.0" 63 + 64 + [dependencies] 65 + package_a = { git = "file://${REPO_DIR}", ref = "${REF}", path = "package_a" } 66 + EOF 67 + 68 + g update 69 + g check 70 + 71 + echo 72 + echo Success! 💖 73 + echo