Skip to content

feat(git): replace isomorphic-git with Effect Git - W-22711959 - #888

Open
mshanemc wants to merge 17 commits into
mainfrom
sm/W-22711959-shadowrepo-effect
Open

mshanemc wants to merge 17 commits into
mainfrom
sm/W-22711959-shadowrepo-effect

Conversation

@mshanemc

@mshanemc mshanemc commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

What does this PR do?

  • Replace isomorphic-git with an Effect-native Git implementation while keeping the shadow repository at .sf/orgs/<orgId>/localSourceTracking.
  • Replace the ShadowRepo class with a factory and cached, stream-derived added / modified / deleted status; use recordSync for advancing the local baseline.
  • Preserve symlink targets and modes, atomically replace the index, and serialize commit ancestry under the index lock.
  • Migrate the existing worktree changes, including remote tracking and type/name resolution improvements, onto current main dependencies.

Configuration behavior and compatibility

Source-tracking settings are parsed and validated once from a ConfigProvider, then shared through a process-wide ManagedRuntime. Invalid values use documented defaults and emit lifecycle warnings; provider read failures still fail. Tests inject a provider or config layer without changing process.env.

Setting Accepted value Missing/invalid default
SF_SOURCE_TRACKING_FD_PERMITS Positive safe integer. Deprecated batch-size aliases are capped at the platform default; an explicit canonical value is not capped. 64 on Windows; 256 elsewhere
SF_SOURCE_TRACKING_LOCK_TIMEOUT_SECONDS Positive finite number; fractions allowed. 600 seconds
SF_SOURCE_TRACKING_LOCK_AUTOCLEAR_SECONDS Positive finite number; fractions allowed. 300 seconds
SF_SOURCE_MEMBER_POLLING_TIMEOUT Positive finite number; fractions allowed. Without an override, timeout remains Math.ceil(memberCount * 0.05) + 5 seconds. Count-based timeout; 120 empty results
SF_SOURCE_TRACKING_FILE_VERSION Non-negative safe integer, including zero. 1 selects the current format; other accepted values select the legacy format. 0

Numeric strings with radix prefixes (0x, 0b, 0o), non-finite values, malformed text, and whitespace are rejected and defaulted. Positive fractional timeouts remain valid; permits and file version require integers. With the normal environment provider, an empty string is treated as missing.

Boolean flags use Effect Config.Boolean literals: lowercase true/false, yes/no, on/off, y/n, and 1/0. Unlike Kit, parsing is case-sensitive; for example, TRUE warns and defaults to false, while lowercase yes now enables the flag. Other invalid non-empty values warn and default to false.

Legacy SFDX_DISABLE_SOURCE_MEMBER_POLLING and SFDX_SOURCE_MEMBER_POLLING_TIMEOUT remain supported with deprecation warnings. Canonical settings take precedence. The unused shadow-integrity-check flag was dropped.

Known limitation: stale-lock cleanup is mtime-based (five minutes by default); a competing writer could treat an unusually long live lock as stale.

Validation: yarn build, yarn test, yarn check:effect, and yarn check:circular-deps.

What issues does this PR fix or reference?

forcedotcom/salesforcedx-vscode#7174, forcedotcom/salesforcedx-vscode#7278, @W-22711959@

@peternhale peternhale left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approve in the context that this change solves the event loop perf issues while consuming STL in vscode services extension.

Comment thread src/git/lock.ts
);
const ageMs = now - mtimeMs;
if (ageMs <= autoclearMs) return false;
yield* fs.remove(lockPath).pipe(Effect.catchReason('PlatformError', 'NotFound', () => Effect.void));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can delete a lock held by a healthy writer. If writer A holds index.lock longer than autoclear, writer B removes it and acquires a new lock. When A completes, releaseLock renames the path now owned by B, so A may publish B’s index alongside A’s updated ref.

The PR description notes this limitation, but this can corrupt the index/ref transaction. Can we fail with RepoLockedError after the wait timeout instead of deleting locks automatically, unless we have a reliable ownership/liveness check?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how are you going to unlock it? how would someone get out of that bind to avoid being perma-stuck?

I chose the timeout based on "how long could an STL write take" after making it do some really, really big ones.

Comment thread src/shared/local/localShadowRepo.ts Outdated

const EMPTY_CHANGES: LocalChanges = { added: [], modified: [], deleted: [] };

const FD_PERMITS_ENV = 'SF_SOURCE_TRACKING_FD_PERMITS';

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This introduces SF_SOURCE_TRACKING_FD_PERMITS, but no longer honors SF_SOURCE_TRACKING_BATCH_SIZE or SFDX_SOURCE_TRACKING_BATCH_SIZE. Those variables were the documented escape hatch for EMFILE in constrained developer and CI environments.

Could the new setting take precedence while the existing variables remain fallbacks? If the old settings are intentionally being retired, we should at least warn when either is set but ignored, so customers can migrate to SF_SOURCE_TRACKING_FD_PERMITS instead of silently losing their descriptor-limit mitigation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I centralized the config stuff; there was a bunch of nonsense kit would allow (infinity, decimals, etc) that didn't make sense here, but it does change things a tiny bit.

Comment thread src/shared/local/localShadowRepo.ts Outdated
dir: options.projectPath,
gitdir: getGitDir(options.orgId, options.projectPath),
roots: options.packageDirs.map((directory) =>
ensurePosix(path.relative(options.projectPath, directory.fullPath))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A project can validly use its root as a package directory (path: "."). In that case path.relative(options.projectPath, directory.fullPath) returns "", but RepoConfig.roots is decoded as RepoPath, which rejects empty strings. That makes the first local-tracking operation fail for root-package projects.

The directory walker already uses "" to represent the root, so this seems to be an integration-boundary mismatch. Could we model a root separately from tracked file paths, and ensure a root matches every project-relative file?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path: "." case appears valid, but I couldn’t find a public GitHub project that uses it. Going to see if it actually works with other stuff besides STL. If not, I'd recommend stopping that in sfdx-core rather than trying support it here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The path: "." case appears valid, but I couldn’t find a public GitHub project that uses it. Going to see if it actually works with other stuff besides STL. If not, I'd recommend stopping that in sfdx-core rather than trying support it here.

@mshanemc mshanemc Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, turns out that edge case doesn't currently work the CLI/STL. The other stuff (scratch org creation, SDR deploy) seems to so I think it's just an STL bug we could fix as part of this PR if you want to support that

Created the isolated project at rootPackageDot/ and scratch org root-package-dot-test (test-pziuoybhyxmh@example.com, expires Oct 31, 2026). The org was authenticated and reports source tracking enabled. No existing project files, global default org, or STL PR files were changed.

Creating the scratch org succeeded.
Deploying RootPackageDotProbe from the root package succeeded.
A follow-up deploy without an explicit source path said “Nothing to deploy” despite the local edit.
Deploying that edited Apex class explicitly with --source-dir succeeded.

Comment thread src/git/objects.ts
* → ObjectNotFoundError; truncated/malformed → ObjectCorruptError.
*/
/** @internal */
export const readLooseObject = Effect.fnUntraced(function* (gitdir: string, oid: Oid) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two staging fibers can hash identical content, both observe that the object path does not exist, and then concurrently write the same final path. A reader can observe a partial zlib stream, and an interruption can leave a corrupt object at that OID.

Could we write to a unique temporary file in the object directory and atomically link/rename it into the final OID path, treating an already-created destination as success? That is the normal Git loose-object publication pattern.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this came up when I was building it as a gap between isogit and real git;
I preserved isogit's behavior because I didn't want to slow it down more by doubling the amount of fs writes

not sure it has ever caused a problem before.

Comment thread src/git/statusMatrix.ts Outdated
head.mode === workdirMode &&
index.mode === workdirMode
? 'unmodified'
: 'modified',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A file that is present in the index and worktree but absent from HEAD is a staged addition. This branch classifies it as modified, because the only unmodified path requires a HEAD entry and every other tracked worktree file falls through to modified.

This matters when interpreting existing/interrupted shadow repositories. The accompanying test accepts added, modified, or unmodified for staged-add.txt, so it does not protect the prior contract. Could we classify head === undefined && index !== undefined && hasWorkdir as added and make that assertion exact?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

will do, probably a good idea for the sake of git-correctness and some weird interruption edge case.

for us, it's normally moot. STL only ever does stage-then-immediately-commit; we don't actually use staging for anything so that's never had any tests or nuts to cover it.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done

Comment thread src/shared/local/localShadowRepo.ts Outdated
const FD_PERMITS_ENV = 'SF_SOURCE_TRACKING_FD_PERMITS';
const FD_PERMITS_FALLBACK = IS_WINDOWS ? 64 : 256;

const readNumberEnv = (name: string, fallback: number) =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already depend on @salesforce/kit, which provides environment helpers, but this migration adds local Effect wrappers here, a separate numeric reader in lock.ts, and more inline parsing in remote tracking. The resulting behavior is inconsistent: malformed values fall back here but are fatal in fileOperations.ts.

If Effect-native parsing is the goal, could we centralize it in one shared helper and deliberately preserve the current Kit semantics and legacy aliases? Otherwise, using Kit at the boundary and lifting the result into Effect would avoid duplicating configuration policy.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

working on this one.

Comment thread src/sourceTracking.ts
@@ -691,14 +742,8 @@ export class SourceTracking extends AsyncCreatable {
]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SourceTracking#setIgnoreConflicts existed on main but is absent from this version. Current VS Code consumers call it when retrying deploy and retrieve operations after a conflict: salesforcedx-vscode-core/src/commands/deployExecutor.ts and salesforcedx-vscode-core/src/commands/retrieveExecutor.ts.

Updating that consumer to this release will fail TypeScript compilation; if types are bypassed, the retry path fails at runtime. Could we restore this public mutator and add a compatibility test covering the consumer flow?

@mshanemc mshanemc Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current VS Code consumers call it when retrying deploy and retrieve operations after a conflict

no, none of that code exists anymore
https://github.com/search?q=repo%3Aforcedotcom%2Fsalesforcedx-vscode+setIgnoreConflicts&type=code

@WillieRuemmele

WillieRuemmele commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Could we separate the implementation-required changes from the repository/tooling changes, or document why they need to ship together?

This PR adds several top-level files (.c8rc.json, .circular-deps.json, .gitattributes, .prettierignore, CONTEXT.md, and knip.json) and new package commands/configuration such as knip, circular-dependency checking, Effect diagnostics, and c8 coverage.

A few questions:

  • Which of these are required for the Effect Git migration versus local developer/agent workflow?
  • Is .circular-deps.json intentionally a checked-in generated artifact?
  • Why should NUTs now run under c8? They are longer-running integration tests, and coverage instrumentation can change performance and file-system behavior. Is coverage from NUTs a deliberate CI requirement, or should coverage remain scoped to unit tests?
  • If the tooling additions are intentional, can we add a short rationale to the PR description and ideally split nonessential tooling/process files into a follow-up?

@mshanemc

mshanemc commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor Author
  • Which of these are required for the Effect Git migration versus local developer/agent workflow?

none are necessary, they're dev tooling like lint, tests, snapshots, ts types, coverage docs, etc...all unnecessary

  • Is .circular-deps.json intentionally a checked-in generated artifact?

no, that should be gitignored, fixing now

  • Why should NUTs now run under c8? They are longer-running integration tests, and coverage instrumentation can change performance and file-system behavior. Is coverage from NUTs a deliberate CI requirement, or should coverage remain scoped to unit tests?

I found it useful to find nut coverage gaps; not all guardrails are CI requirements.

  • If the tooling additions are intentional, can we add a short rationale to the PR description and ideally split nonessential tooling/process files into a follow-up?

I'll can remove them if you like once we're done iterating on this PR.

@mshanemc

mshanemc commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Could we separate the implementation-required changes from the repository/tooling changes, or document why they need to ship together?

done, the tooling parts are on a stacked pr. hope that helps

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants