From 8f02c53039b928bb0de860b376ee53f842829dfa Mon Sep 17 00:00:00 2001
From: Norbert Szulc <norbert@icetek.io>
Date: Tue, 20 Feb 2024 18:45:15 +0100
Subject: [PATCH] feat(manager/pip-compile): Allow paths relative to repository
 root (#27272)

Co-authored-by: Rhys Arkins <rhys@arkins.net>
---
 .../manager/pip-compile/artifacts.spec.ts     | 63 ++++++------
 lib/modules/manager/pip-compile/artifacts.ts  | 39 +++-----
 .../manager/pip-compile/common.spec.ts        | 16 ++-
 lib/modules/manager/pip-compile/common.ts     | 21 ++--
 .../manager/pip-compile/extract.spec.ts       | 98 +++++++++++++++++--
 lib/modules/manager/pip-compile/extract.ts    | 25 ++++-
 lib/modules/manager/pip-compile/utils.spec.ts | 29 ++++++
 lib/modules/manager/pip-compile/utils.ts      | 45 +++++++++
 8 files changed, 257 insertions(+), 79 deletions(-)
 create mode 100644 lib/modules/manager/pip-compile/utils.spec.ts

diff --git a/lib/modules/manager/pip-compile/artifacts.spec.ts b/lib/modules/manager/pip-compile/artifacts.spec.ts
index 2bd1c0e107..ff27f6ae60 100644
--- a/lib/modules/manager/pip-compile/artifacts.spec.ts
+++ b/lib/modules/manager/pip-compile/artifacts.spec.ts
@@ -11,6 +11,7 @@ import type { StatusResult } from '../../../util/git/types';
 import * as _datasource from '../../datasource';
 import type { UpdateArtifactsConfig, Upgrade } from '../types';
 import { constructPipCompileCmd } from './artifacts';
+import { extractHeaderCommand } from './common';
 import { updateArtifacts } from '.';
 
 const datasource = mocked(_datasource);
@@ -354,8 +355,10 @@ describe('modules/manager/pip-compile/artifacts', () => {
     it('throws for garbage', () => {
       expect(() =>
         constructPipCompileCmd(
-          Fixtures.get('requirementsNoHeaders.txt'),
-          'subdir/requirements.txt',
+          extractHeaderCommand(
+            Fixtures.get('requirementsNoHeaders.txt'),
+            'subdir/requirements.txt',
+          ),
           false,
         ),
       ).toThrow(/extract/);
@@ -364,8 +367,10 @@ describe('modules/manager/pip-compile/artifacts', () => {
     it('returns extracted common arguments (like those featured in the README)', () => {
       expect(
         constructPipCompileCmd(
-          Fixtures.get('requirementsWithHashes.txt'),
-          'subdir/requirements.txt',
+          extractHeaderCommand(
+            Fixtures.get('requirementsWithHashes.txt'),
+            'subdir/requirements.txt',
+          ),
           false,
         ),
       ).toBe(
@@ -376,8 +381,10 @@ describe('modules/manager/pip-compile/artifacts', () => {
     it('returns --no-emit-index-url only once when its in the header and credentials are present in URLs', () => {
       expect(
         constructPipCompileCmd(
-          Fixtures.get('requirementsWithHashes.txt'),
-          'subdir/requirements.txt',
+          extractHeaderCommand(
+            Fixtures.get('requirementsWithHashes.txt'),
+            'subdir/requirements.txt',
+          ),
           true,
         ),
       ).toBe(
@@ -386,41 +393,33 @@ describe('modules/manager/pip-compile/artifacts', () => {
     });
 
     it('safeguard against index url leak if not explicitly set by an option', () => {
-      expect(
-        constructPipCompileCmd(simpleHeader, 'subdir/requirements.txt', false),
-      ).toBe('pip-compile --no-emit-index-url requirements.in');
-    });
-
-    it('allow explicit --emit-index-url', () => {
       expect(
         constructPipCompileCmd(
-          getCommandInHeader('pip-compile --emit-index-url requirements.in'),
-          'subdir/requirements.txt',
+          extractHeaderCommand(simpleHeader, 'subdir/requirements.txt'),
           false,
         ),
-      ).toBe('pip-compile --emit-index-url requirements.in');
+      ).toBe('pip-compile --no-emit-index-url requirements.in');
     });
 
-    // TODO(not7cd): remove when relative pahts are supported
-    it('change --output-file if differs', () => {
+    it('allow explicit --emit-index-url', () => {
       expect(
         constructPipCompileCmd(
-          getCommandInHeader(
-            'pip-compile --output-file=hey.txt requirements.in',
+          extractHeaderCommand(
+            getCommandInHeader('pip-compile --emit-index-url requirements.in'),
+            'subdir/requirements.txt',
           ),
-          'subdir/requirements.txt',
           false,
         ),
-      ).toBe(
-        'pip-compile --no-emit-index-url --output-file=requirements.txt requirements.in',
-      );
+      ).toBe('pip-compile --emit-index-url requirements.in');
     });
 
     it('throws on unknown arguments', () => {
       expect(() =>
         constructPipCompileCmd(
-          Fixtures.get('requirementsWithUnknownArguments.txt'),
-          'subdir/requirements.txt',
+          extractHeaderCommand(
+            Fixtures.get('requirementsWithUnknownArguments.txt'),
+            'subdir/requirements.txt',
+          ),
           false,
         ),
       ).toThrow(/supported/);
@@ -429,8 +428,10 @@ describe('modules/manager/pip-compile/artifacts', () => {
     it('throws on custom command', () => {
       expect(() =>
         constructPipCompileCmd(
-          Fixtures.get('requirementsCustomCommand.txt'),
-          'subdir/requirements.txt',
+          extractHeaderCommand(
+            Fixtures.get('requirementsCustomCommand.txt'),
+            'subdir/requirements.txt',
+          ),
           false,
         ),
       ).toThrow(/custom/);
@@ -439,10 +440,12 @@ describe('modules/manager/pip-compile/artifacts', () => {
     it('add --upgrade-package to command if Upgrade[] passed', () => {
       expect(
         constructPipCompileCmd(
-          getCommandInHeader(
-            'pip-compile --output-file=requirements.txt requirements.in',
+          extractHeaderCommand(
+            getCommandInHeader(
+              'pip-compile --output-file=requirements.txt requirements.in',
+            ),
+            'subdir/requirements.txt',
           ),
-          'subdir/requirements.txt',
           false,
           [
             { depName: 'foo', newVersion: '1.0.2' },
diff --git a/lib/modules/manager/pip-compile/artifacts.ts b/lib/modules/manager/pip-compile/artifacts.ts
index abbbc6ff92..cf10b4b338 100644
--- a/lib/modules/manager/pip-compile/artifacts.ts
+++ b/lib/modules/manager/pip-compile/artifacts.ts
@@ -1,5 +1,4 @@
 import { quote } from 'shlex';
-import upath from 'upath';
 import { TEMPORARY_ERROR } from '../../../constants/error-messages';
 import { logger } from '../../../logger';
 import { exec } from '../../../util/exec';
@@ -16,39 +15,22 @@ import {
   getExecOptions,
   getRegistryUrlVarsFromPackageFile,
 } from './common';
+import type { PipCompileArgs } from './types';
+import { inferCommandExecDir } from './utils';
 
 export function constructPipCompileCmd(
-  content: string,
-  outputFileName: string,
+  compileArgs: PipCompileArgs,
   haveCredentials: boolean,
   upgradePackages: Upgrade[] = [],
 ): string {
-  const compileArgs = extractHeaderCommand(content, outputFileName);
   if (compileArgs.isCustomCommand) {
     throw new Error(
       'Detected custom command, header modified or set by CUSTOM_COMPILE_COMMAND',
     );
   }
-  if (compileArgs.outputFile) {
-    // TODO(not7cd): This file path can be relative like `reqs/main.txt`
-    const file = upath.parse(outputFileName).base;
-    if (compileArgs.outputFile !== file) {
-      // we don't trust the user-supplied output-file argument;
-      // TODO(not7cd): allow relative paths
-      logger.warn(
-        { outputFile: compileArgs.outputFile, actualPath: file },
-        'pip-compile was previously executed with an unexpected `--output-file` filename',
-      );
-      // TODO(not7cd): this shouldn't be changed in extract function
-      compileArgs.outputFile = file;
-      compileArgs.argv.forEach((item, i) => {
-        if (item.startsWith('--output-file=')) {
-          compileArgs.argv[i] = `--output-file=${quote(file)}`;
-        }
-      });
-    }
-  } else {
-    logger.debug(`pip-compile: implicit output file (${outputFileName})`);
+
+  if (!compileArgs.outputFile) {
+    logger.debug(`pip-compile: implicit output file`);
   }
   // safeguard against index url leak if not explicitly set by an option
   if (
@@ -96,21 +78,22 @@ export async function updateArtifacts({
       if (config.isLockFileMaintenance) {
         await deleteLocalFile(outputFileName);
       }
+      const compileArgs = extractHeaderCommand(existingOutput, outputFileName);
+      const cwd = inferCommandExecDir(outputFileName, compileArgs.outputFile);
       const upgradePackages = updatedDeps.filter((dep) => dep.isLockfileUpdate);
       const packageFile = pipRequirements.extractPackageFile(newInputContent);
       const registryUrlVars = getRegistryUrlVarsFromPackageFile(packageFile);
       const cmd = constructPipCompileCmd(
-        existingOutput,
-        outputFileName,
+        compileArgs,
         registryUrlVars.haveCredentials,
         upgradePackages,
       );
       const execOptions = await getExecOptions(
         config,
-        inputFileName,
+        cwd,
         registryUrlVars.environmentVars,
       );
-      logger.trace({ cmd }, 'pip-compile command');
+      logger.trace({ cwd, cmd }, 'pip-compile command');
       logger.trace({ env: execOptions.extraEnv }, 'pip-compile extra env vars');
       await exec(cmd, execOptions);
       const status = await getRepoStatus();
diff --git a/lib/modules/manager/pip-compile/common.spec.ts b/lib/modules/manager/pip-compile/common.spec.ts
index 1a1f31e1d0..37acdadf46 100644
--- a/lib/modules/manager/pip-compile/common.spec.ts
+++ b/lib/modules/manager/pip-compile/common.spec.ts
@@ -5,6 +5,7 @@ import {
   extractHeaderCommand,
   getRegistryUrlVarsFromPackageFile,
 } from './common';
+import { inferCommandExecDir } from './utils';
 
 jest.mock('../../../util/host-rules', () => mockDeep());
 
@@ -132,7 +133,7 @@ describe('modules/manager/pip-compile/common', () => {
       'returned sourceFiles must not contain options',
       (argument: string) => {
         const sourceFiles = extractHeaderCommand(
-          getCommandInHeader(`pip-compile ${argument}=dd reqs.in`),
+          getCommandInHeader(`pip-compile ${argument}=reqs.txt reqs.in`),
           'reqs.txt',
         ).sourceFiles;
         expect(sourceFiles).not.toContainEqual(argument);
@@ -148,6 +149,19 @@ describe('modules/manager/pip-compile/common', () => {
         ),
       ).toHaveProperty('isCustomCommand', true);
     });
+
+    it.each([
+      { path: 'reqs.txt', arg: 'reqs.txt', result: '.' },
+      { path: 'subdir/reqs.txt', arg: 'subdir/reqs.txt', result: '.' },
+      { path: 'subdir/reqs.txt', arg: './subdir/reqs.txt', result: '.' },
+      { path: 'subdir/reqs.txt', arg: 'reqs.txt', result: 'subdir' },
+      { path: 'subdir/reqs.txt', arg: './reqs.txt', result: 'subdir' },
+    ])(
+      'infer exec directory (cwd) from output file path and header command',
+      ({ path, arg, result }) => {
+        expect(inferCommandExecDir(path, arg)).toEqual(result);
+      },
+    );
   });
 
   describe('getRegistryUrlFlagsFromPackageFile()', () => {
diff --git a/lib/modules/manager/pip-compile/common.ts b/lib/modules/manager/pip-compile/common.ts
index 1847e10af9..c0b29fd57e 100644
--- a/lib/modules/manager/pip-compile/common.ts
+++ b/lib/modules/manager/pip-compile/common.ts
@@ -1,9 +1,11 @@
 import is from '@sindresorhus/is';
 import { split } from 'shlex';
+import upath from 'upath';
 import { logger } from '../../../logger';
 import { isNotNullOrUndefined } from '../../../util/array';
 import type { ExecOptions } from '../../../util/exec/types';
 import { ensureCacheDir } from '../../../util/fs';
+import { ensureLocalPath } from '../../../util/fs/util';
 import * as hostRules from '../../../util/host-rules';
 import { regEx } from '../../../util/regex';
 import type { PackageFileContent, UpdateArtifactsConfig } from '../types';
@@ -36,13 +38,13 @@ export function getPipToolsConstraint(config: UpdateArtifactsConfig): string {
 }
 export async function getExecOptions(
   config: UpdateArtifactsConfig,
-  inputFileName: string,
+  cwd: string,
   extraEnv: Record<string, string>,
 ): Promise<ExecOptions> {
   const constraint = getPythonConstraint(config);
   const pipToolsConstraint = getPipToolsConstraint(config);
   const execOptions: ExecOptions = {
-    cwdFile: inputFileName,
+    cwd: ensureLocalPath(cwd),
     docker: {},
     toolConstraints: [
       {
@@ -94,7 +96,9 @@ export function extractHeaderCommand(
 ): PipCompileArgs {
   const compileCommand = constraintLineRegex.exec(content);
   if (compileCommand?.groups === undefined) {
-    throw new Error(`Failed to extract command from header in ${fileName}`);
+    throw new Error(
+      `Failed to extract command from header in ${fileName} ${content}`,
+    );
   }
   logger.trace(
     `pip-compile: found header in ${fileName}: \n${compileCommand[0]}`,
@@ -102,16 +106,12 @@ export function extractHeaderCommand(
   const command = compileCommand.groups.command;
   const argv = [command];
   const isCustomCommand = command !== 'pip-compile';
-  if (isCustomCommand) {
-    logger.debug(
-      `pip-compile: custom command ${command} detected (${fileName})`,
-    );
-  }
   if (compileCommand.groups.arguments) {
     argv.push(...split(compileCommand.groups.arguments));
   }
   logger.debug(
-    `pip-compile: extracted command from header: ${JSON.stringify(argv)}`,
+    { fileName, argv, isCustomCommand },
+    `pip-compile: extracted command from header`,
   );
 
   const result: PipCompileArgs = {
@@ -147,7 +147,7 @@ export function extractHeaderCommand(
         if (result.outputFile) {
           throw new Error('Cannot use multiple --output-file options');
         }
-        result.outputFile = value;
+        result.outputFile = upath.normalize(value);
       } else if (option === '--index-url') {
         if (result.indexUrl) {
           throw new Error('Cannot use multiple --index-url options');
@@ -170,7 +170,6 @@ export function extractHeaderCommand(
 
     logger.warn(`pip-compile: option ${arg} not handled`);
   }
-
   logger.trace(
     {
       ...result,
diff --git a/lib/modules/manager/pip-compile/extract.spec.ts b/lib/modules/manager/pip-compile/extract.spec.ts
index 9e9f55720f..bd45db12ff 100644
--- a/lib/modules/manager/pip-compile/extract.spec.ts
+++ b/lib/modules/manager/pip-compile/extract.spec.ts
@@ -1,10 +1,20 @@
+import { join } from 'upath';
 import { Fixtures } from '../../../../test/fixtures';
 import { fs } from '../../../../test/util';
+import { GlobalConfig } from '../../../config/global';
+import type { RepoGlobalConfig } from '../../../config/types';
 import { logger } from '../../../logger';
 import { extractAllPackageFiles, extractPackageFile } from '.';
 
 jest.mock('../../../util/fs');
 
+const adminConfig: RepoGlobalConfig = {
+  // `join` fixes Windows CI
+  localDir: join('/tmp/github/some/repo'),
+  cacheDir: join('/tmp/renovate/cache'),
+  containerbaseDir: join('/tmp/renovate/cache/containerbase'),
+};
+
 function getSimpleRequirementsFile(command: string, deps: string[] = []) {
   return `#
 # This file is autogenerated by pip-compile with Python 3.11
@@ -17,6 +27,14 @@ ${deps.join('\n')}`;
 }
 
 describe('modules/manager/pip-compile/extract', () => {
+  beforeEach(() => {
+    GlobalConfig.set(adminConfig);
+  });
+
+  afterEach(() => {
+    fs.readLocalFile.mockClear();
+  });
+
   describe('extractPackageFile()', () => {
     it('returns object for requirements.in', () => {
       const packageFile = extractPackageFile(
@@ -79,7 +97,6 @@ describe('modules/manager/pip-compile/extract', () => {
         'requirements3.txt',
       ];
       const packageFiles = await extractAllPackageFiles({}, lockFiles);
-      expect(packageFiles).toBeDefined();
       expect(packageFiles).not.toBeNull();
       expect(packageFiles!.pop()).toHaveProperty('lockFiles', lockFiles);
     });
@@ -101,7 +118,8 @@ describe('modules/manager/pip-compile/extract', () => {
 
       const lockFiles = ['foo.txt', 'bar.txt'];
       const packageFiles = await extractAllPackageFiles({}, lockFiles);
-      expect(packageFiles).toBeDefined();
+      expect(fs.readLocalFile).toHaveBeenCalledTimes(4);
+      expect(packageFiles).not.toBeNull();
       packageFiles!.forEach((packageFile) => {
         expect(packageFile).not.toHaveProperty('packageFile', 'foo.txt');
       });
@@ -119,7 +137,7 @@ describe('modules/manager/pip-compile/extract', () => {
 
       const lockFiles = ['requirements.txt'];
       const packageFiles = await extractAllPackageFiles({}, lockFiles);
-      expect(packageFiles).toBeDefined();
+      expect(packageFiles).not.toBeNull();
       packageFiles!.forEach((packageFile) => {
         expect(packageFile).not.toHaveProperty(
           'packageFile',
@@ -130,18 +148,24 @@ describe('modules/manager/pip-compile/extract', () => {
   });
 
   it('return null for malformed files', async () => {
+    // empty.txt
     fs.readLocalFile.mockResolvedValueOnce('');
+    // noHeader.txt
     fs.readLocalFile.mockResolvedValueOnce(
       Fixtures.get('requirementsNoHeaders.txt'),
     );
+    // badSource.txt
     fs.readLocalFile.mockResolvedValueOnce(
       getSimpleRequirementsFile(
-        'pip-compile --output-file=foo.txt malformed.in empty.in',
+        'pip-compile --output-file=badSource.txt malformed.in empty.in',
         ['foo==1.0.1'],
       ),
     );
-    fs.readLocalFile.mockResolvedValueOnce('!@#$'); // malformed.in
-    fs.readLocalFile.mockResolvedValueOnce(''); // empty.in
+    // malformed.in
+    fs.readLocalFile.mockResolvedValueOnce('!@#$');
+    // empty.in
+    fs.readLocalFile.mockResolvedValueOnce('');
+    // headerOnly.txt
     fs.readLocalFile.mockResolvedValueOnce(
       getSimpleRequirementsFile(
         'pip-compile --output-file=headerOnly.txt reqs.in',
@@ -157,6 +181,68 @@ describe('modules/manager/pip-compile/extract', () => {
     ];
     const packageFiles = await extractAllPackageFiles({}, lockFiles);
     expect(packageFiles).toBeNull();
+    expect(fs.readLocalFile).toHaveBeenCalledTimes(6);
+    expect(logger.warn).toHaveBeenCalledTimes(2); // malformed.in, noHeader.txt
+  });
+
+  it('return null for bad paths', async () => {
+    // ambigous.txt
+    fs.readLocalFile.mockResolvedValueOnce(
+      getSimpleRequirementsFile(
+        'pip-compile --output-file=../ambigous.txt reqs.in',
+        ['foo==1.0.1'],
+      ),
+    );
+    // badSource.txt
+    fs.readLocalFile.mockResolvedValueOnce(
+      getSimpleRequirementsFile(
+        'pip-compile --output-file=badSource.txt ../outside.in',
+        ['foo==1.0.1'],
+      ),
+    );
+
+    const packageFiles = await extractAllPackageFiles({}, [
+      'subdir/ambigous.txt',
+      'badSource.txt',
+    ]);
+    expect(packageFiles).toBeNull();
+    expect(fs.readLocalFile).toHaveBeenCalledTimes(2);
+    expect(logger.warn).toHaveBeenCalledTimes(2);
+  });
+
+  it('return for valid paths', async () => {
+    // reqs.txt
+    fs.readLocalFile.mockResolvedValueOnce(
+      getSimpleRequirementsFile('pip-compile --output-file=reqs.txt reqs.in', [
+        'foo==1.0.1',
+      ]),
+    );
+    fs.readLocalFile.mockResolvedValueOnce('foo>=1.0.0');
+    // absolute/reqs.txt
+    fs.readLocalFile.mockResolvedValueOnce(
+      getSimpleRequirementsFile(
+        'pip-compile --output-file=./absolute/reqs.txt ./absolute/reqs.in',
+        ['foo==1.0.1'],
+      ),
+    );
+    fs.readLocalFile.mockResolvedValueOnce('foo>=1.0.0');
+    // relative/reqs.txt
+    fs.readLocalFile.mockResolvedValueOnce(
+      getSimpleRequirementsFile(
+        'pip-compile --output-file=reqs.txt ../outside.in',
+        ['foo==1.0.1'],
+      ),
+    );
+    fs.readLocalFile.mockResolvedValueOnce('foo>=1.0.0');
+    const packageFiles = await extractAllPackageFiles({}, [
+      'reqs.txt',
+      'absolute/reqs.txt',
+      'relative/reqs.txt',
+    ]);
+    expect(packageFiles?.map((p) => p.packageFile).sort()).toEqual(
+      ['reqs.in', 'absolute/reqs.in', 'outside.in'].sort(),
+    );
+    expect(logger.warn).toHaveBeenCalledTimes(0);
   });
 
   it('return sorted package files', async () => {
diff --git a/lib/modules/manager/pip-compile/extract.ts b/lib/modules/manager/pip-compile/extract.ts
index e3624ffc68..ed011867f7 100644
--- a/lib/modules/manager/pip-compile/extract.ts
+++ b/lib/modules/manager/pip-compile/extract.ts
@@ -1,5 +1,7 @@
+import upath from 'upath';
 import { logger } from '../../../logger';
 import { readLocalFile } from '../../../util/fs';
+import { ensureLocalPath } from '../../../util/fs/util';
 import { normalizeDepName } from '../../datasource/pypi/common';
 import { extractPackageFile as extractRequirementsFile } from '../pip_requirements/extract';
 import { extractPackageFile as extractSetupPyFile } from '../pip_setup';
@@ -10,7 +12,11 @@ import type {
   PipCompileArgs,
   SupportedManagers,
 } from './types';
-import { generateMermaidGraph, sortPackageFiles } from './utils';
+import {
+  generateMermaidGraph,
+  inferCommandExecDir,
+  sortPackageFiles,
+} from './utils';
 
 function matchManager(filename: string): SupportedManagers | 'unknown' {
   if (filename.endsWith('setup.py')) {
@@ -63,7 +69,6 @@ export async function extractAllPackageFiles(
   logger.trace('pip-compile.extractAllPackageFiles()');
   const lockFileArgs = new Map<string, PipCompileArgs>();
   const depsBetweenFiles: DependencyBetweenFiles[] = [];
-  // for debugging only ^^^ (for now)
   const packageFiles = new Map<string, PackageFile>();
   for (const fileMatch of fileMatches) {
     const fileContent = await readLocalFile(fileMatch, 'utf8');
@@ -72,8 +77,10 @@ export async function extractAllPackageFiles(
       continue;
     }
     let compileArgs: PipCompileArgs;
+    let compileDir: string;
     try {
       compileArgs = extractHeaderCommand(fileContent, fileMatch);
+      compileDir = inferCommandExecDir(fileMatch, compileArgs.outputFile);
     } catch (error) {
       logger.warn({ fileMatch }, `pip-compile: ${error.message}`);
       continue;
@@ -95,7 +102,19 @@ export async function extractAllPackageFiles(
       continue;
     }
 
-    for (const packageFile of compileArgs.sourceFiles) {
+    for (const relativeSourceFile of compileArgs.sourceFiles) {
+      const packageFile = upath.normalizeTrim(
+        upath.join(compileDir, relativeSourceFile),
+      );
+      try {
+        ensureLocalPath(packageFile);
+      } catch (error) {
+        logger.warn(
+          { fileMatch, packageFile },
+          'pip-compile: Source file path outside of repository',
+        );
+        continue;
+      }
       depsBetweenFiles.push({
         sourceFile: packageFile,
         outputFile: fileMatch,
diff --git a/lib/modules/manager/pip-compile/utils.spec.ts b/lib/modules/manager/pip-compile/utils.spec.ts
new file mode 100644
index 0000000000..3ece0c51d8
--- /dev/null
+++ b/lib/modules/manager/pip-compile/utils.spec.ts
@@ -0,0 +1,29 @@
+import { inferCommandExecDir } from './utils';
+
+describe('modules/manager/pip-compile/utils', () => {
+  describe('inferCommandExecDir()', () => {
+    it.each([
+      {
+        fileName: 'subdir/reqs.txt',
+        outputFile: 'subdir/reqs.txt',
+        result: '.',
+      },
+      {
+        fileName: 'subdir/reqs.txt',
+        outputFile: 'reqs.txt',
+        result: 'subdir',
+      },
+    ])(
+      'returns object on correct options',
+      ({ fileName, outputFile, result }) => {
+        expect(inferCommandExecDir(fileName, outputFile)).toEqual(result);
+      },
+    );
+
+    it('throw if --output-file basename differs from path', () => {
+      expect(() =>
+        inferCommandExecDir('subdir/requirements.txt', 'hey.txt'),
+      ).toThrow(/mismatch/);
+    });
+  });
+});
diff --git a/lib/modules/manager/pip-compile/utils.ts b/lib/modules/manager/pip-compile/utils.ts
index d87a53e53f..50fcdde658 100644
--- a/lib/modules/manager/pip-compile/utils.ts
+++ b/lib/modules/manager/pip-compile/utils.ts
@@ -1,4 +1,6 @@
 import { Graph } from 'graph-data-structure';
+import upath from 'upath';
+import { logger } from '../../../logger';
 import type { PackageFile } from '../types';
 import type { DependencyBetweenFiles, PipCompileArgs } from './types';
 
@@ -50,3 +52,46 @@ export function generateMermaidGraph(
   });
   return `graph TD\n${lockFiles.join('\n')}\n${edges.join('\n')}`;
 }
+
+export function inferCommandExecDir(
+  outputFilePath: string,
+  outputFileArg: string | undefined,
+): string {
+  if (!outputFileArg) {
+    // implicit output file is in the same directory where command was executed
+    return upath.normalize(upath.dirname(outputFilePath));
+  }
+  if (upath.normalize(outputFileArg).startsWith('..')) {
+    throw new Error(
+      `Cannot infer command execution directory from path ${outputFileArg}`,
+    );
+  }
+  if (upath.basename(outputFileArg) !== upath.basename(outputFilePath)) {
+    throw new Error(
+      `Output file name mismatch: ${upath.basename(outputFileArg)} vs ${upath.basename(outputFilePath)}`,
+    );
+  }
+  const outputFileDir = upath.normalize(upath.dirname(outputFileArg));
+  let commandExecDir = upath.normalize(upath.dirname(outputFilePath));
+
+  for (const dir of outputFileDir.split('/').reverse()) {
+    if (commandExecDir.endsWith(dir)) {
+      commandExecDir = upath.join(commandExecDir.slice(0, -dir.length), '.');
+      // outputFileDir = upath.join(outputFileDir.slice(0, -dir.length), '.');
+    } else {
+      break;
+    }
+  }
+  commandExecDir = upath.normalizeTrim(commandExecDir);
+  if (commandExecDir !== '.') {
+    logger.debug(
+      {
+        commandExecDir,
+        outputFileArg,
+        outputFilePath,
+      },
+      `pip-compile: command was not executed in repository root`,
+    );
+  }
+  return commandExecDir;
+}
-- 
GitLab