Files
renovate/lib/util/fs/util.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

73 lines
2.1 KiB
TypeScript

import upath from 'upath';
import { GlobalConfig } from '../../config/global.ts';
import { FILE_ACCESS_VIOLATION_ERROR } from '../../constants/error-messages.ts';
import { logger } from '../../logger/index.ts';
import { matchRegexOrGlob } from '../string-match.ts';
/**
* Take a relative path reference from a repo-relative file and resolve it
* to a repo-relative path. Uses a virtual root to avoid upath.resolve
* prepending the real cwd for relative paths.
*/
export function resolveRelativePathToRoot(
baseFilePath: string,
relativePath: string,
): string {
const virtualRoot = '/';
const absoluteBase = upath.resolve(virtualRoot, baseFilePath);
const absoluteResolved = upath.resolve(
upath.dirname(absoluteBase),
relativePath,
);
return upath.relative(virtualRoot, absoluteResolved);
}
/**
* Filter a list of repo-relative file paths by a glob or regex pattern.
*/
export function getMatchingFiles(
pattern: string,
allFiles: string[],
): string[] {
return allFiles.filter((file) => matchRegexOrGlob(file, pattern));
}
function assertBaseDir(path: string, allowedDir: string): void {
if (!path.startsWith(allowedDir)) {
logger.debug(
{ path, allowedDir },
'Preventing access to file outside allowed directory',
);
throw new Error(FILE_ACCESS_VIOLATION_ERROR);
}
}
function ensurePath(path: string, key: 'localDir' | 'cacheDir'): string {
const baseDir = upath.resolve(GlobalConfig.get(key));
const fullPath = upath.resolve(
upath.isAbsolute(path) ? path : upath.join(baseDir, path),
);
assertBaseDir(fullPath, baseDir);
return fullPath;
}
export function ensureLocalPath(path: string): string {
return ensurePath(path, 'localDir');
}
export function ensureCachePath(path: string): string {
return ensurePath(path, 'cacheDir');
}
export function isValidPath(
path: string,
key: 'localDir' | 'cacheDir',
): boolean {
const baseDir = upath.resolve(GlobalConfig.get(key));
const fullPath = upath.resolve(
upath.isAbsolute(path) ? path : upath.join(baseDir, path),
);
return fullPath.startsWith(baseDir);
}