Skip to content

Declared packageDirectory reached through a symlink silently contributes zero components to a deploy #3653

Description

@jwoodwardcertinia

A packageDirectory declared in sfdx-project.json and reached through a symlink contributes no components to a deploy. No error, no warning, and the deploy reports Succeeded. The metadata never arrives and the failure surfaces later as something unrelated.

@salesforce/source-tracking passes declared packageDirectories to isomorphic-git's working-directory walker and relies on that walker descending symlinks. isomorphic-git stopped descending them in 1.38.10. The dependency is declared ^1.34.2, so this arrives on a fresh install with no CLI version change.

Reproduction

This script will generate a simple dx project setup and then call git.statusMatrix to show how different versions of isomorphic-git handle symlinks. Save as repro.mjs in an empty directory:

import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import git from 'isomorphic-git';

// A throwaway project: one package dir reached through a symlink to an ordinary
// sibling directory, and one ordinary package dir as a control.
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sf-symlink-repro-'));

const linkedTarget = path.join(root, 'shared-lib', 'force-app', 'main', 'default', 'classes');
fs.mkdirSync(linkedTarget, { recursive: true });
fs.writeFileSync(path.join(linkedTarget, 'Linked.cls'), 'public class Linked {}');
fs.mkdirSync(path.join(root, 'links'));
fs.symlinkSync(path.join('..', 'shared-lib'), path.join(root, 'links', 'shared-lib'), 'dir');

const controlDir = path.join(root, 'force-app', 'main', 'default', 'classes');
fs.mkdirSync(controlDir, { recursive: true });
fs.writeFileSync(path.join(controlDir, 'Control.cls'), 'public class Control {}');

// Mirror what ShadowRepo does: init a shadow repo rooted at the project, then ask
// for the status of each declared packageDirectory.
const gitdir = path.join(root, '.shadow');
await git.init({ fs, dir: root, gitdir, defaultBranch: 'main' });

const status = async (filepath) =>
  (await git.statusMatrix({ fs, dir: root, gitdir, filepaths: [filepath] })).map((row) => row[0]);

console.log(`isomorphic-git ${git.version()}`);
console.log(`  symlinked packageDirectory 'links/shared-lib/force-app':`);
console.log(`    ${(await status('links/shared-lib/force-app')).join('\n    ') || '(nothing)'}`);
console.log(`  control packageDirectory 'force-app':`);
console.log(`    ${(await status('force-app')).join('\n    ') || '(nothing)'}`);

fs.rmSync(root, { recursive: true, force: true });
npm i isomorphic-git@1.38.9 && node repro.mjs
npm i isomorphic-git@1.41.7 && node repro.mjs

Output

isomorphic-git 1.38.9
  symlinked packageDirectory 'links/shared-lib/force-app':
    links/shared-lib
    links/shared-lib/force-app/main/default/classes/Linked.cls
  control packageDirectory 'force-app':
    force-app/main/default/classes/Control.cls

isomorphic-git 1.41.7
  symlinked packageDirectory 'links/shared-lib/force-app':
    links/shared-lib
  control packageDirectory 'force-app':
    force-app/main/default/classes/Control.cls

Under 1.41.7 the only entry for the symlinked package directory is the symlink itself, typed as a blob. Linked.cls is gone.

1.38.9 is the last good version. 1.38.10, 1.39.x, 1.40.0 and 1.41.7 all reproduce.

I can put together a full sfdx-project reproduction if the script above isn't enough.

Cause

ShadowRepo in @salesforce/source-tracking passes packageDirs to isomorphic-git, relying on its working directory walker to descend into subfolders and find all the files to report on. The folders passed may contain symlinks that are not resolved to real paths. And so is relying on how isomorphic-git handles symlinks. This behavior changed in 1.38.10 and so how Salesforce-cli handles symlinks is now at the mercy of whatever version of isomorphic-git you get installed. (The declared range includes both good and bad versions.)

ShadowRepo (src/shared/local/localShadowRepo.ts) rebases each packageDirectory to a project-relative path and relies on the working-directory walker to enumerate files:

this.packageDirs = options.packageDirs.map(packageDirToRelativePosixPath(options.projectPath));
// ...
this.status = await git.statusMatrix({
  fs,
  dir: this.projectPath,
  gitdir: this.gitDir,
  filepaths: this.packageDirs,
  filter: fileFilter(this.packageDirs),
});

In isomorphic-git's GitWalkerFs, entry types come from lstat, which describes the link rather than its target, so a symlinked directory types as blob (a regular directory has type tree).

1.38.10 added a guard making readdir respect that type:

async readdir(entry) {
  if ((await entry.type()) !== 'tree') return null   // added in 1.38.10
  const filepath = entry._fullpath;
  const names = await fs.readdir(join(dir, filepath));

fs.readdir does follow symlinks, so before 1.38.10 the walker descended symlinked directories regardless of type. But git does not follow symlinks. 1.38.10 closed isomorphic-git#1215, which is a legit discrepancy between the behavior of git and isomorphic-git.

The shadow repo is using git as a change detector over directories the user explicitly declared. A symlinked package directory is a pointer to files the user asked to have tracked. That distinction has to be made in source-tracking.

This is not an isomorphic-git bug.

Where it showed up

From a failed deploy log:

CustomField  Budget__c.Eligible_for_Billing__c
Field $Label.common_label_yes does not exist. Check spelling. (648:13)

The label exists and is spelled correctly. It lives in a labels file in a symlinked package directory, so the label is never deployed and a field referencing it in a later package directory failed. Nothing in the output pointed at the package directory with the symlink and the label, which is where the problem actually occurred.

sfdx-project.json declares:

{ "path": "sf-lib-links/psenterprise-lib/force-app", "default": false }

where sf-lib-links/psenterprise-lib is a symlink to a sibling package in the same repo. This is an ordinary layout for a monorepo with shared library code.

Possible fixes

As we pass fs into isomorphic-git we have the opportunity to wrap it. We could wrap it so that it sees symlinks as type tree (isDirectory() === true) and the walker will descend into them. This uses isomorphic-git's own extension point to get the behavior we want. I have validated this approach is viable.

(We can't resolve declared packageDirectories to their real paths before walking them. Looking at the parameters we pass to statusMatrix, filepaths is relative to dir- a resolved symlink frequently points outside dir, so there's no relative path you could pass.)

Other ideas:

  • We could pin the isomorphic-git dependency. The range ^1.34.2 allowed a transitive behavioral change to alter source-tracking behavior with no release involved. However it doesn't give a long-term fix for sharing code via symlinks in dx projects.
  • Warn when a declared packageDirectory contributes zero components. This would make the investigation easier, as the error above is pointing in completely the wrong place.

System information

{
  "architecture": "linux-x64",
  "cliVersion": "@salesforce/cli/2.150.6",
  "nodeVersion": "node-v24.19.0",
  "osVersion": "Linux 6.1.182",
  "rootPath": "/home/jenkins/workspace/9-investigate-sf-2.150.6-upgrade/common/temp/node_modules/.pnpm/@salesforce+cli@2.150.6_@types+node@24.13.3_@types+react@18.3.31/node_modules/@salesforce/cli",
  "shell": "sh",
  "pluginVersions": [
    "@oclif/plugin-autocomplete 3.3.0 (core)",
    "@oclif/plugin-commands 4.2.0 (core)",
    "@oclif/plugin-help 6.3.0 (core)",
    "@oclif/plugin-not-found 3.3.0 (core)",
    "@oclif/plugin-plugins 5.5.1 (core)",
    "@oclif/plugin-search 1.3.0 (core)",
    "@oclif/plugin-update 4.8.0 (core)",
    "@oclif/plugin-version 2.3.0 (core)",
    "@oclif/plugin-warn-if-update-available 3.2.0 (core)",
    "@oclif/plugin-which 3.3.0 (core)",
    "@salesforce/cli 2.150.6 (core)",
    "agent 2.0.5 (core)",
    "apex 4.1.1 (core)",
    "api 2.0.9 (core)",
    "auth 5.0.6 (core)",
    "data 5.1.7 (core)",
    "deploy-retrieve 4.1.2 (core)",
    "info 4.0.9 (core)",
    "limits 4.0.4 (core)",
    "marketplace 2.0.5 (core)",
    "org 6.0.11 (core)",
    "packaging 3.0.6 (core)",
    "schema 4.0.6 (core)",
    "settings 3.0.6 (core)",
    "sobject 2.0.5 (core)",
    "telemetry 4.0.6 (core)",
    "templates 57.0.11 (core)",
    "trust 4.0.10 (core)",
    "user 5.0.2 (core)"
  ]
}

Dependency chain: @salesforce/cli@2.150.6@salesforce/source-tracking@8.1.3
isomorphic-git: "^1.34.2" → resolves to 1.41.7 on a fresh install.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    investigatingWe're actively investigating this issuevalidatedVersion information for this issue has been validated

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions