dev_prune/adapters/go.rs
1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Go package manager adapter.
5
6use super::{
7 BloatDir, EnforcePolicy, PackageManager, dir_size, enforce_two_tier, run_command_with_timeout,
8};
9use anyhow::{Result, anyhow};
10use std::path::Path;
11
12/// Adapter for Go modules.
13pub struct Go;
14
15/// Whether `vendor/` carries uncommitted changes Git knows about.
16///
17/// A team that commits its vendor tree sometimes patches a dependency in place. Until
18/// that patch is committed it exists nowhere but the worktree, and `go mod vendor` would
19/// regenerate the tree from the module cache without it. Untracked entries (`??`) are
20/// not counted: an untracked vendor tree is the ordinary gitignored-or-fresh case, and
21/// `vendor/modules.txt` already vouches for how it was built.
22/// This is the one refusal in this adapter that guards real data, so it fails
23/// closed: when git cannot answer (missing binary, dubious-ownership refusal), the
24/// answer is an error, not "no changes" — the old fail-open reading deleted a
25/// patched vendor tree precisely when git was least able to vouch for it.
26fn vendor_has_uncommitted_changes(path: &Path) -> Result<bool> {
27 let output = crate::scanner::git::git_in(path)
28 .args(["status", "--porcelain", "--", "vendor"])
29 .output()
30 .map_err(|e| anyhow!("could not run `git status` to check `vendor/`: {e}"))?;
31 if !output.status.success() {
32 return Err(anyhow!(
33 "`git status` could not inspect `vendor/` for uncommitted changes: {}",
34 String::from_utf8_lossy(&output.stderr).trim()
35 ));
36 }
37 Ok(String::from_utf8_lossy(&output.stdout)
38 .lines()
39 .any(|line| !line.trim().is_empty() && !line.starts_with("??")))
40}
41
42/// The rebuild command for one recorded directory name.
43///
44/// Pure, so the choice is testable without a `go` binary. Only `vendor` is ever claimed
45/// by [`Go::bloat_dirs`], so anything else in a record is treated as a plain module
46/// refill rather than a reason to *create* a vendor tree in a repository that may never
47/// have had one.
48fn restore_args(dir_name: &str) -> &'static [&'static str] {
49 if dir_name == "vendor" {
50 &["mod", "vendor"]
51 } else {
52 &["mod", "download"]
53 }
54}
55
56impl PackageManager for Go {
57 fn name(&self) -> &'static str {
58 "go"
59 }
60
61 fn detect(&self, path: &Path) -> bool {
62 path.join("go.mod").exists()
63 }
64
65 fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
66 let mut dirs = Vec::new();
67 let vendor_path = path.join("vendor");
68 // Only a `go mod vendor` product carries `modules.txt`. A vendor tree without it
69 // was assembled some other way, and `go mod vendor` makes no promise of
70 // recreating whatever that was — so it is not this adapter's to delete.
71 if vendor_path.exists() && vendor_path.join("modules.txt").exists() {
72 dirs.push(BloatDir {
73 name: "vendor".to_string(),
74 path: vendor_path.clone(),
75 size_bytes: dir_size(&vendor_path),
76 shared_bytes: 0,
77 });
78 }
79 dirs
80 }
81
82 /// `go mod tidy` reconciles `go.mod` and `go.sum` against the real imports, and can
83 /// *remove* a requirement nothing imports any more — which is exactly why it is not
84 /// the default. With `go.sum` present the module cache is verified instead, which
85 /// never touches tracked files. `tidy` is reached only when there is no `go.sum` to
86 /// bootstrap from, or when the user opted in.
87 fn enforce_lockfile(&self, path: &Path, policy: EnforcePolicy) -> Result<()> {
88 // An in-place patch to a committed vendor tree exists nowhere but the worktree;
89 // `go mod vendor` after deletion would rebuild the tree from the module cache
90 // without it.
91 if path.join("vendor").exists() && vendor_has_uncommitted_changes(path)? {
92 return Err(anyhow!(
93 "`vendor/` has uncommitted changes — deleting it would lose them, and \
94 `go mod vendor` would rebuild the tree without them. Commit or stash \
95 the changes first."
96 ));
97 }
98 enforce_two_tier(
99 &path.join("go.sum"),
100 "go",
101 &["mod", "download"],
102 &["mod", "tidy"],
103 path,
104 policy,
105 )
106 }
107
108 /// The no-record path (`devp restore <path>`), where whether this repository
109 /// vendored is a guess: `vendor/` on disk means regenerate it, otherwise refill the
110 /// module cache. After a prune the guess is always wrong for a vendored repository —
111 /// the prune is what removed `vendor/` — which is why `restore --last-run` goes
112 /// through [`Self::restore_named`] with the recorded name instead of through here.
113 fn restore(&self, path: &Path, timeout: std::time::Duration) -> Result<()> {
114 if path.join("vendor").exists() {
115 run_command_with_timeout("go", &["mod", "vendor"], path, timeout)
116 } else {
117 run_command_with_timeout("go", &["mod", "download"], path, timeout)
118 }
119 }
120
121 /// Rebuild the directory the prune recorded, not the one a guess suggests.
122 ///
123 /// The default `restore_named` fell through to [`Self::restore`], whose
124 /// vendor-exists check runs *after* the prune deleted `vendor/` — so it always chose
125 /// `go mod download`, which exits 0 without recreating the tree, and the pass
126 /// printed "restored" over a directory that was still gone.
127 fn restore_named(
128 &self,
129 path: &Path,
130 dir_name: &str,
131 _runtime: Option<&str>,
132 timeout: std::time::Duration,
133 ) -> Result<()> {
134 run_command_with_timeout("go", restore_args(dir_name), path, timeout)?;
135 // "Restored" is a claim about the directory, not about the command's exit code —
136 // the bug above was an exit 0 with nothing on disk.
137 if dir_name == "vendor" && !path.join(dir_name).exists() {
138 return Err(anyhow!(
139 "`go mod vendor` reported success but `vendor/` was not recreated"
140 ));
141 }
142 Ok(())
143 }
144
145 fn lockfiles(&self) -> &'static [&'static str] {
146 &["go.sum"]
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use std::fs;
154 use std::fs::File;
155 use tempfile::tempdir;
156
157 #[test]
158 fn test_name() {
159 let adapter = Go;
160 assert_eq!(adapter.name(), "go");
161 }
162
163 #[test]
164 fn test_detect_positive() {
165 let dir = tempdir().unwrap();
166 File::create(dir.path().join("go.mod")).unwrap();
167
168 let adapter = Go;
169 assert!(adapter.detect(dir.path()));
170 }
171
172 #[test]
173 fn test_detect_negative() {
174 let dir = tempdir().unwrap();
175
176 let adapter = Go;
177 assert!(!adapter.detect(dir.path()));
178 }
179
180 #[test]
181 fn test_bloat_dirs_present() {
182 let dir = tempdir().unwrap();
183 fs::create_dir(dir.path().join("vendor")).unwrap();
184 File::create(dir.path().join("vendor").join("modules.txt")).unwrap();
185
186 let adapter = Go;
187 let dirs = adapter.bloat_dirs(dir.path());
188 assert_eq!(dirs.len(), 1);
189 assert_eq!(dirs[0].name, "vendor");
190 }
191
192 #[test]
193 fn a_vendor_tree_without_modules_txt_is_not_claimed() {
194 // `go mod vendor` always writes modules.txt; a tree without it was assembled by
195 // hand and cannot be promised back.
196 let dir = tempdir().unwrap();
197 fs::create_dir(dir.path().join("vendor")).unwrap();
198 File::create(dir.path().join("vendor").join("some_pkg.go")).unwrap();
199
200 assert!(Go.bloat_dirs(dir.path()).is_empty());
201 }
202
203 #[test]
204 fn staged_vendor_changes_refuse_the_prune() {
205 let dir = tempdir().unwrap();
206 let vendor = dir.path().join("vendor");
207 fs::create_dir(&vendor).unwrap();
208 fs::write(vendor.join("modules.txt"), "# github.com/x/y v1.0.0\n").unwrap();
209
210 // Outside a repo git cannot answer, and "cannot answer" is an error rather
211 // than a silent all-clear — the refusal fails closed…
212 assert!(vendor_has_uncommitted_changes(dir.path()).is_err());
213
214 // …and inside one, a staged-but-uncommitted vendor entry is a refusal. Staging
215 // is enough to move the entry past `??` without needing commit identity.
216 let git = |args: &[&str]| {
217 crate::scanner::git::git_in(dir.path())
218 .args(args)
219 .output()
220 .unwrap()
221 };
222 assert!(git(&["init", "-q"]).status.success());
223 git(&["add", "vendor"]);
224 assert!(vendor_has_uncommitted_changes(dir.path()).unwrap());
225 }
226
227 #[test]
228 fn a_recorded_vendor_restore_never_falls_back_to_download() {
229 // `restore` guessed from `vendor/` existing on disk, but a restore runs after
230 // the prune deleted it — so the guess was always `go mod download`, which exits
231 // 0 without recreating the tree, and `restore --last-run` printed "restored"
232 // over a directory that was still gone.
233 assert_eq!(restore_args("vendor"), ["mod", "vendor"]);
234 }
235
236 #[test]
237 fn an_unrecognised_record_refills_the_cache_rather_than_creating_vendor() {
238 // A record naming anything else never reaches this adapter today, but if a
239 // mangled one did, `go mod vendor` would *create* a vendor tree in a repository
240 // that may never have vendored — silently switching it to vendored builds.
241 assert_eq!(restore_args("modules"), ["mod", "download"]);
242 }
243
244 #[test]
245 fn test_bloat_dirs_absent() {
246 let dir = tempdir().unwrap();
247
248 let adapter = Go;
249 let dirs = adapter.bloat_dirs(dir.path());
250 assert!(dirs.is_empty());
251 }
252}