Skip to content

Commit 184e60d

Browse files
authored
vpm: refuse to overwrite an installed module with uncommitted or unpushed git changes (#27014)
1 parent 2b80888 commit 184e60d

1 file changed

Lines changed: 38 additions & 0 deletions

File tree

cmd/tools/vpm/install.v

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,15 @@ fn (m Module) install() InstallResult {
104104
defer {
105105
os.rmdir_all(m.tmp_path) or {}
106106
}
107+
// Run this check unconditionally — `m.is_installed` is computed via
108+
// `git ls-remote`, which itself fails when `.git` is corrupted or
109+
// inaccessible, so relying on it here would skip the guard in exactly
110+
// the cases we most need to fail closed.
111+
reason := local_git_changes_reason(m.install_path)
112+
if reason != '' {
113+
vpm_error('refusing to install `${m.name}`: `${m.install_path_fmted}` has local git work that would be lost (${reason}). Commit and push your changes, or remove the directory manually before retrying.')
114+
exit(1)
115+
}
107116
if m.is_installed {
108117
// Case: installed, but not an explicit version. Update instead of continuing the installation.
109118
if m.version == '' && m.installed_version == '' {
@@ -166,6 +175,35 @@ fn (m Module) confirm_install() bool {
166175
}
167176
}
168177

178+
// local_git_changes_reason returns a non-empty reason string if `path` is a
179+
// git repository whose contents should not be silently overwritten — either
180+
// because it has uncommitted/unpushed work, or because git could not be
181+
// queried at all (in which case we fail closed rather than risk data loss).
182+
// Returns '' when the path is safe to overwrite (not a git repo, or a clean
183+
// repo fully in sync with its remote).
184+
fn local_git_changes_reason(path string) string {
185+
if !os.exists(os.join_path(path, '.git')) {
186+
return ''
187+
}
188+
quoted := os.quoted_path(path)
189+
status := os.execute_opt('git -C ${quoted} status --porcelain') or {
190+
return 'failed to run `git status`: ${err.msg()}'
191+
}
192+
if status.output.trim_space() != '' {
193+
return 'uncommitted changes detected'
194+
}
195+
// Include `HEAD` so commits made on a detached HEAD (e.g. after
196+
// `git clone -b <tag>`, the layout vpm uses for versioned installs) are
197+
// also detected. `--branches` alone only walks local branch refs.
198+
unpushed := os.execute_opt('git -C ${quoted} rev-list HEAD --branches --not --remotes') or {
199+
return 'failed to run `git rev-list`: ${err.msg()}'
200+
}
201+
if unpushed.output.trim_space() != '' {
202+
return 'unpushed local commits detected'
203+
}
204+
return ''
205+
}
206+
169207
fn (m Module) remove() ! {
170208
verbose_println('Removing `${m.name}` from `${m.install_path_fmted}`...')
171209
rmdir_all(m.install_path)!

0 commit comments

Comments
 (0)