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.
A
packageDirectorydeclared insfdx-project.jsonand reached through a symlink contributes no components to a deploy. No error, no warning, and the deploy reportsSucceeded. The metadata never arrives and the failure surfaces later as something unrelated.@salesforce/source-trackingpasses declaredpackageDirectoriesto isomorphic-git's working-directory walker and relies on that walker descending symlinks.isomorphic-gitstopped 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.statusMatrixto show how different versions ofisomorphic-githandle symlinks. Save asrepro.mjsin an empty directory:Output
Under 1.41.7 the only entry for the symlinked package directory is the symlink itself, typed as a blob.
Linked.clsis 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
ShadowRepoin@salesforce/source-trackingpasses packageDirs toisomorphic-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 howisomorphic-githandles symlinks. This behavior changed in 1.38.10 and so how Salesforce-cli handles symlinks is now at the mercy of whatever version ofisomorphic-gityou get installed. (The declared range includes both good and bad versions.)ShadowRepo(src/shared/local/localShadowRepo.ts) rebases eachpackageDirectoryto a project-relative path and relies on the working-directory walker to enumerate files:In isomorphic-git's
GitWalkerFs, entry types come fromlstat, which describes the link rather than its target, so a symlinked directory types asblob(a regular directory has typetree).1.38.10 added a guard making
readdirrespect that type:fs.readdirdoes follow symlinks, so before 1.38.10 the walker descended symlinked directories regardless of type. Butgitdoes 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:
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.jsondeclares:{ "path": "sf-lib-links/psenterprise-lib/force-app", "default": false }where
sf-lib-links/psenterprise-libis 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
fsintoisomorphic-gitwe have the opportunity to wrap it. We could wrap it so that it sees symlinks as typetree(isDirectory() === true) and the walker will descend into them. This usesisomorphic-git's own extension point to get the behavior we want. I have validated this approach is viable.(We can't resolve declared
packageDirectoriesto their real paths before walking them. Looking at the parameters we pass tostatusMatrix,filepathsis relative todir- a resolved symlink frequently points outside dir, so there's no relative path you could pass.)Other ideas:
^1.34.2allowed 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.packageDirectorycontributes 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.