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