Skip to main content

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}