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