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
42impl PackageManager for Go {
43    fn name(&self) -> &'static str {
44        "go"
45    }
46
47    fn detect(&self, path: &Path) -> bool {
48        path.join("go.mod").exists()
49    }
50
51    fn bloat_dirs(&self, path: &Path) -> Vec<BloatDir> {
52        let mut dirs = Vec::new();
53        let vendor_path = path.join("vendor");
54        // Only a `go mod vendor` product carries `modules.txt`. A vendor tree without it
55        // was assembled some other way, and `go mod vendor` makes no promise of
56        // recreating whatever that was — so it is not this adapter's to delete.
57        if vendor_path.exists() && vendor_path.join("modules.txt").exists() {
58            dirs.push(BloatDir {
59                name: "vendor".to_string(),
60                path: vendor_path.clone(),
61                size_bytes: dir_size(&vendor_path),
62                shared_bytes: 0,
63            });
64        }
65        dirs
66    }
67
68    /// `go mod tidy` reconciles `go.mod` and `go.sum` against the real imports, and can
69    /// *remove* a requirement nothing imports any more — which is exactly why it is not
70    /// the default. With `go.sum` present the module cache is verified instead, which
71    /// never touches tracked files. `tidy` is reached only when there is no `go.sum` to
72    /// bootstrap from, or when the user opted in.
73    fn enforce_lockfile(&self, path: &Path, policy: EnforcePolicy) -> Result<()> {
74        // An in-place patch to a committed vendor tree exists nowhere but the worktree;
75        // `go mod vendor` after deletion would rebuild the tree from the module cache
76        // without it.
77        if path.join("vendor").exists() && vendor_has_uncommitted_changes(path)? {
78            return Err(anyhow!(
79                "`vendor/` has uncommitted changes — deleting it would lose them, and \
80                 `go mod vendor` would rebuild the tree without them. Commit or stash \
81                 the changes first."
82            ));
83        }
84        enforce_two_tier(
85            &path.join("go.sum"),
86            "go",
87            &["mod", "download"],
88            &["mod", "tidy"],
89            path,
90            policy,
91        )
92    }
93
94    fn restore(&self, path: &Path, timeout: std::time::Duration) -> Result<()> {
95        if path.join("vendor").exists() {
96            run_command_with_timeout("go", &["mod", "vendor"], path, timeout)
97        } else {
98            run_command_with_timeout("go", &["mod", "download"], path, timeout)
99        }
100    }
101
102    fn lockfiles(&self) -> &'static [&'static str] {
103        &["go.sum"]
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110    use std::fs;
111    use std::fs::File;
112    use tempfile::tempdir;
113
114    #[test]
115    fn test_name() {
116        let adapter = Go;
117        assert_eq!(adapter.name(), "go");
118    }
119
120    #[test]
121    fn test_detect_positive() {
122        let dir = tempdir().unwrap();
123        File::create(dir.path().join("go.mod")).unwrap();
124
125        let adapter = Go;
126        assert!(adapter.detect(dir.path()));
127    }
128
129    #[test]
130    fn test_detect_negative() {
131        let dir = tempdir().unwrap();
132
133        let adapter = Go;
134        assert!(!adapter.detect(dir.path()));
135    }
136
137    #[test]
138    fn test_bloat_dirs_present() {
139        let dir = tempdir().unwrap();
140        fs::create_dir(dir.path().join("vendor")).unwrap();
141        File::create(dir.path().join("vendor").join("modules.txt")).unwrap();
142
143        let adapter = Go;
144        let dirs = adapter.bloat_dirs(dir.path());
145        assert_eq!(dirs.len(), 1);
146        assert_eq!(dirs[0].name, "vendor");
147    }
148
149    #[test]
150    fn a_vendor_tree_without_modules_txt_is_not_claimed() {
151        // `go mod vendor` always writes modules.txt; a tree without it was assembled by
152        // hand and cannot be promised back.
153        let dir = tempdir().unwrap();
154        fs::create_dir(dir.path().join("vendor")).unwrap();
155        File::create(dir.path().join("vendor").join("some_pkg.go")).unwrap();
156
157        assert!(Go.bloat_dirs(dir.path()).is_empty());
158    }
159
160    #[test]
161    fn staged_vendor_changes_refuse_the_prune() {
162        let dir = tempdir().unwrap();
163        let vendor = dir.path().join("vendor");
164        fs::create_dir(&vendor).unwrap();
165        fs::write(vendor.join("modules.txt"), "# github.com/x/y v1.0.0\n").unwrap();
166
167        // Outside a repo git cannot answer, and "cannot answer" is an error rather
168        // than a silent all-clear — the refusal fails closed…
169        assert!(vendor_has_uncommitted_changes(dir.path()).is_err());
170
171        // …and inside one, a staged-but-uncommitted vendor entry is a refusal. Staging
172        // is enough to move the entry past `??` without needing commit identity.
173        let git = |args: &[&str]| {
174            std::process::Command::new("git")
175                .args(args)
176                .current_dir(dir.path())
177                .output()
178                .unwrap()
179        };
180        assert!(git(&["init", "-q"]).status.success());
181        git(&["add", "vendor"]);
182        assert!(vendor_has_uncommitted_changes(dir.path()).unwrap());
183    }
184
185    #[test]
186    fn test_bloat_dirs_absent() {
187        let dir = tempdir().unwrap();
188
189        let adapter = Go;
190        let dirs = adapter.bloat_dirs(dir.path());
191        assert!(dirs.is_empty());
192    }
193}