Files
renovate/lib/modules/manager/nuget/package-tree.ts
Thinh Nguyen ab105e6e42 feat(gomod): add gomodTidyAll option for indirect dependency updates in Go monorepos (#37138)
* feat(gomod): add `gomodTidyAll` option for indirect dependency updates in Go monorepos

In Go monorepos it is common for one module to depend on another in the
same repo via a local `replace` directive (e.g. `replace example.com/shared
=> ../shared`). When Renovate updates the shared module, today `go mod
tidy` runs only in that module, leaving dependent `go.sum` files stale.

Add a new `gomodTidyAll` post-update option that:

- Discovers every `go.mod` in the repo and parses its local `replace`
  directives (both inline and block form).
- Builds a dependency graph with `graph-data-structure`, with edges from
  dependency → dependent, matching the existing NuGet convention.
- Runs `go mod tidy` on every transitive dependent in topological order,
  propagating any `gomodTidy1.17` / `gomodTidyE` flags to the dependent
  commands.
- Collects updated `go.mod` and `go.sum` files from each dependent
  directory into the artifact result.
- Implies `gomodTidy` so users only need to enable one option.

Supporting changes:

- Extract `getMatchingFiles` and `resolveRelativePathToRoot` into
  `lib/util/fs/util.ts` so NuGet and gomod can share them instead of
  each having its own glob/resolve helper.
- Refactor `lib/modules/manager/nuget/package-tree.ts` to use the
  shared `getMatchingFiles` helper and rename local variables for
  clarity (`deps` → `visited`, `dep` → `dependent`) per review feedback.

Docs and config:

- Register `gomodTidyAll` in `postUpdateOptions` `allowedValues`.
- Add a row for `gomodTidyAll` to the `postUpdateOptions` table in
  `docs/usage/configuration-options.md`.
- Add a "Monorepo tidying for local `replace` directives" subsection
  to `docs/usage/golang.md` describing when to use the option.

Tests and coverage:

- `lib/modules/manager/gomod/package-tree.spec.ts` — parse/graph/topo
  order, 100% line/branch/function coverage.
- `lib/modules/manager/gomod/artifacts-gomodtidyall.spec.ts` — six
  integration cases covering subshell commands, `tidyOpts` propagation,
  result collection, no-op with no dependents, error handling, and
  the disabled path.
- Full `lib/modules/manager/gomod` suite (151 tests) and NuGet
  package-tree suite (9 tests) pass; new code is fully covered.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* refactor(gomod): use named capture groups and direct fs util imports

* fix(gomod): use `go -C` for dependent module tidy commands

A subshell only works in Docker mode, where all commands are concatenated
into a single `bash -l -c`. With `binarySource=install` or `global` each
command is split with shlex and run without a shell, so `(cd dir && ...)`
is treated as the name of a binary.

* refactor(gomod): simplify parsing of local `replace` directives

A single regex now covers both the single line form and the lines inside
a `replace (...)` block. The previous block pattern stopped at the first
`)`, so a block containing a comment with a bracket was skipped, and the
single line pattern did not allow a trailing comment.

Also keeps the NuGet package tree unchanged apart from the two helpers it
now shares.

* docs(gomod): warn against combining `gomodTidyAll` with `gomodMassage`

Massaging comments out the relative `replace` directives that
`gomodTidyAll` follows, so the two options work against each other.

* refactor(fs): use `matchRegexOrGlob` in `getMatchingFiles`

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-08-13 08:35:38 +00:00

138 lines
3.5 KiB
TypeScript

import { isNonEmptyString } from '@sindresorhus/is';
import { Graph, hasCycle } from 'graph-data-structure';
import upath from 'upath';
import { logger } from '../../../logger/index.ts';
import {
getMatchingFiles,
resolveRelativePathToRoot,
} from '../../../util/fs/util.ts';
import { scm } from '../../platform/scm.ts';
import type { ProjectFile } from './types.ts';
import { readFileAsXmlDocument } from './util.ts';
export const GLOBAL_JSON = 'global.json';
export const NUGET_CENTRAL_FILE = 'Directory.Packages.props';
export const MSBUILD_CENTRAL_FILE = 'Packages.props';
export const DIRECTORY_BUILD_PROPS = 'Directory.Build.props';
/**
* Get all leaf package files of ancestry that depend on packageFileName.
*/
export async function getDependentPackageFiles(
packageFileName: string,
isPropsFile = false,
isGlobalJson = false,
): Promise<ProjectFile[]> {
const packageFiles = await getAllPackageFiles();
const graph = new Graph();
if (isPropsFile) {
graph.addNode(packageFileName);
}
if (isGlobalJson) {
graph.addNode(GLOBAL_JSON);
}
const parentDir =
packageFileName === NUGET_CENTRAL_FILE ||
packageFileName === MSBUILD_CENTRAL_FILE ||
packageFileName === GLOBAL_JSON ||
packageFileName === DIRECTORY_BUILD_PROPS
? ''
: upath.dirname(packageFileName);
for (const f of packageFiles) {
graph.addNode(f);
if (
(isPropsFile || isGlobalJson) &&
upath.dirname(f).startsWith(parentDir)
) {
graph.addEdge(packageFileName, f);
}
}
for (const f of packageFiles) {
const doc = await readFileAsXmlDocument(f);
if (!doc) {
continue;
}
const projectReferenceAttributes = doc
.childrenNamed('ItemGroup')
.map((ig) => ig.childrenNamed('ProjectReference'))
.flat()
.map((pf) => pf.attr.Include)
.filter(isNonEmptyString);
const projectReferences = projectReferenceAttributes.map((a) =>
upath.normalize(a),
);
const normalizedRelativeProjectReferences = projectReferences.map((r) =>
resolveRelativePathToRoot(f, r),
);
for (const ref of normalizedRelativeProjectReferences) {
graph.addEdge(ref, f);
}
if (hasCycle(graph)) {
throw new Error('Circular reference detected in NuGet package files');
}
}
const deps = new Map<string, boolean>();
recursivelyGetDependentPackageFiles(packageFileName, graph, deps);
if (isPropsFile || isGlobalJson) {
// remove props file, as we don't need it
deps.delete(packageFileName);
}
// deduplicate
return Array.from(deps).map(([name, isLeaf]) => ({ name, isLeaf }));
}
/**
* Traverse graph and find dependent package files at any level of ancestry
*/
function recursivelyGetDependentPackageFiles(
packageFileName: string,
graph: Graph,
deps: Map<string, boolean>,
): void {
if (deps.has(packageFileName)) {
// we have already visited this package file
return;
}
const dependents = graph.adjacent(packageFileName);
if (!dependents || dependents.size === 0) {
deps.set(packageFileName, true);
return;
}
deps.set(packageFileName, false);
for (const dep of dependents) {
recursivelyGetDependentPackageFiles(dep, graph, deps);
}
}
/**
* Get a list of package files in localDir
*/
async function getAllPackageFiles(): Promise<string[]> {
const allFiles = await scm.getFileList();
const filteredPackageFiles = getMatchingFiles(
'**/*.{cs,vb,fs}proj',
allFiles,
);
logger.trace({ filteredPackageFiles }, 'Found package files');
return filteredPackageFiles;
}