Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
06891b2
perf: replace per-path ls-tree with full tree index
scolladon Mar 6, 2026
761c957
refactor: remove dead BLOB_TYPE and TREE_TYPE constants
scolladon Mar 6, 2026
fcb5a6e
perf: replace individual git show with cat-file --batch process
scolladon Mar 6, 2026
44d3188
perf: parallelize git diff calls for A/M/D filters
scolladon Mar 6, 2026
4641eb3
perf: parallelize config validation phases
scolladon Mar 6, 2026
fc292c9
perf: parallelize file writes in IOExecutor
scolladon Mar 6, 2026
c782952
fix: handle root paths in tree index and centralize batch process cle…
scolladon Mar 6, 2026
9ef82b9
fix: address code review findings
scolladon Mar 6, 2026
5f35910
chore: upgrade dependencies
scolladon Mar 6, 2026
e24ec3d
test: enforce 100% coverage thresholds and add missing branch tests
scolladon Mar 6, 2026
7a7f4fd
fix: reject pending promises when git cat-file process exits unexpect…
scolladon Mar 6, 2026
f42e996
refactor(GitAdapter): widen gitGrep path param to accept string[]
scolladon Mar 6, 2026
cfd6e9f
refactor(fsHelper): widen grepContent path param to accept string[]
scolladon Mar 6, 2026
eb92410
perf(FlowTranslationProcessor): use git grep instead of tree index
scolladon Mar 6, 2026
aac08a3
perf(IOExecutor): copy single files directly via cat-file
scolladon Mar 6, 2026
959ef88
feat(GitAdapter): add preBuildTreeIndex with path scoping
scolladon Mar 6, 2026
ce110ca
feat(treeIndexScope): compute scoped paths from diff lines
scolladon Mar 6, 2026
495d89e
perf(main): pre-build scoped tree index from diff lines
scolladon Mar 6, 2026
6bb624f
test: add tree index scoping coverage for main.ts
scolladon Mar 6, 2026
eb11b03
docs: update DESIGN.md for scoped tree index and GitDirCopy
scolladon Mar 6, 2026
640492d
refactor: move IOExecutor from service to adapter layer
scolladon Mar 6, 2026
208c589
fix: apply code review findings
scolladon Mar 6, 2026
753e98e
fix: wrap lazy template expression in arrow function in fsHelper
scolladon Mar 6, 2026
886438e
chore: improve spell dictionary
scolladon Mar 6, 2026
4c590c4
refactor: extract _getGitAdapter to deduplicate config resolution in …
scolladon Mar 6, 2026
e7c2d46
refactor: simplify preBuildTreeIndex scope resolution in main
scolladon Mar 7, 2026
4ef050a
chore: upgrade @salesforce/source-deploy-retrieve to 12.31.16
scolladon Mar 7, 2026
0089229
perf: parallelize preBuildTreeIndex calls for both revisions
scolladon Mar 7, 2026
897e830
refactor: remove buildTreeIndex fallback, use pre-built index only
scolladon Mar 7, 2026
b3b7fea
chore(deps): upgrade
scolladon Mar 7, 2026
8e3ccad
docs: update DESIGN.md to reflect tree index changes
scolladon Mar 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/linters/.cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@
"omnistudio",
"oxsecurity",
"packageable",
"pathspec",
"parens",
"pastsha",
"permissionset",
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/on-pull-request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ jobs:
uses: ./.github/actions/install

- name: Check outdated dependencies
run: npm outdated
run: npm outdated --omit=dev

- name: Check unused dependencies
run: npm run lint:dependencies
Expand Down
21 changes: 15 additions & 6 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ flowchart TD

**Collectors** (`isCollector = true`) run first via `collectAll()`. They produce additional `HandlerResult` data that gets merged into the main result:

- **FlowTranslationProcessor**: when Flows are being deployed, scans all `.translation-meta.xml` files in the repo for `flowDefinitions` elements matching deployed flows. Produces pruned translation files as computed content.
- **FlowTranslationProcessor**: when Flows are being deployed, uses `git grep` with pathspec globs (`<source>/*<extension><metaFileSuffix>`) to find `.translation-meta.xml` files containing `flowDefinitions` elements matching deployed flows. This avoids requiring the tree index. Produces pruned translation files as computed content.
- **IncludeProcessor**: handles `--include` and `--include-destructive` flags. Lists all files in source directories, filters through include patterns, then processes matching lines through `DiffLineInterpreter` as synthetic additions/deletions.

**Processors** (`isCollector = false`) run last via `executeRemaining()`:
Expand All @@ -373,13 +373,14 @@ Each processor is wrapped in error isolation — failures produce warnings rathe

## Stage 6: I/O Execution

**Entry**: `IOExecutor.execute(copies)` (`src/service/ioExecutor.ts`)
**Entry**: `IOExecutor.execute(copies)` (`src/adapter/ioExecutor.ts`)

Executes the accumulated copy operations with concurrency bounded by `getConcurrencyThreshold()`. Two operation kinds:
Executes the accumulated copy operations with concurrency bounded by `getConcurrencyThreshold()`. Three operation kinds:

| Kind | Description |
| ----------------- | ------------------------------------------------------------------------------------------------------------------ |
| `GitCopy` | Reads a file from a specific git revision via `git show <rev>:<path>` and writes it to the output directory |
| `GitCopy` | Reads a single file from a specific git revision via the batch `git cat-file` process and writes it to the output directory. Does not require the tree index. |
| `GitDirCopy` | Enumerates all files under a directory path via `getFilesPath` (requires tree index), then copies each file via `git cat-file`. Used by handlers that need to copy entire directories (e.g., `ContainedDecomposedHandler` for decomposed PermissionSet holder folders). Executed serially because the outer `execute()` already parallelizes across operations. |
| `ComputedContent` | Writes a string (typically pruned XML from InFile/ObjectTranslation handlers) directly to the output directory |

`GitAdapter.getBufferContent()` handles LFS detection: if the buffer starts with an LFS pointer signature, it reads the actual object from the local LFS cache instead.
Expand Down Expand Up @@ -427,7 +428,15 @@ Logger.debug(lazy`result: ${() => JSON.stringify(largeObject)}`)

### Git Adapter

`GitAdapter` (`src/adapter/GitAdapter.ts`) wraps `simple-git` with a singleton pattern keyed by `Config` identity. It caches `pathExists` and `ls-tree` results per instance to avoid redundant git operations.
`GitAdapter` (`src/adapter/GitAdapter.ts`) wraps `simple-git` with a singleton pattern keyed by `Config` identity. It minimizes git subprocess spawns via two strategies:

**Tree index**: The tree index is a `Set<string>` of file paths per revision, populated exclusively by `preBuildTreeIndex(revision, scopePaths)` in `src/main.ts`. It serves `pathExists`, `getFilesPath`, and `listDirAtRevision` lookups without additional subprocess calls — if no index was pre-built for a revision, these methods return empty results. The index is **scoped** to reduce heap pressure: `preBuildTreeIndex` runs `git ls-tree --name-only -r <revision> -- <path1> <path2> ...` to index only the metadata directories that handlers actually need. Both revisions (`config.to` and `config.from`) are indexed in parallel via `Promise.all`. Scope computation (`computeTreeIndexScope` in `src/utils/treeIndexScope.ts`) analyzes diff lines against the metadata registry to determine which type directories require tree-index lookups — only types using `InResource`, `InFolder`, `ReportingFolder`, `InBundle`, `Lwc`, `CustomObject`, `ContainedDecomposed`, or `MetadataBoundaryResolver` deep-path resolution need the index. When `--include` or `--include-destructive` is set, the scope defaults to `config.source` since the include processor may touch any type. When `generateDelta` is false, the tree index is never built.

**Batch cat-file**: `GitBatchCatFile` (`src/adapter/gitBatchCatFile.ts`) spawns a single long-lived `git cat-file --batch` child process per adapter instance. File content reads write `<revision>:<path>\n` to stdin and parse the binary response from stdout using a FIFO queue. This replaces individual `git show` subprocess spawns.

Lifecycle: `GitAdapter.closeAll()` terminates all batch processes and clears the singleton instances map. It is called in a `finally` block in `src/main.ts` to prevent orphaned child processes on both success and error paths. If the `git cat-file` process exits unexpectedly, a `close` event handler rejects all pending promises to prevent hangs.

**Memory note**: The batch cat-file process buffers each blob's content entirely in memory before resolving. There is no upper bound on individual blob size — repositories with very large binary blobs (multi-GB) could cause high memory consumption. This is acceptable because SGD operates on trusted repositories and the previous `git show` implementation had the same characteristic.

---

Expand Down Expand Up @@ -488,7 +497,7 @@ Universal handler/processor output:
| Field | Type | Description |
| ----- | ---- | ----------- |
| `manifests` | `ManifestElement[]` | Entries for package.xml or destructiveChanges.xml |
| `copies` | `CopyOperation[]` | `GitCopy` or `ComputedContent` operations |
| `copies` | `CopyOperation[]` | `GitCopy`, `GitDirCopy`, or `ComputedContent` operations |
| `warnings` | `Error[]` | Non-fatal warnings |

### Metadata (`src/schemas/metadata.ts`)
Expand Down
113 changes: 112 additions & 1 deletion __tests__/functional/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ import {
ManifestTarget,
} from '../../src/types/handlerResult'

jest.mock('../../src/utils/LoggingService')

const mockPreBuildTreeIndex = jest.fn()
jest.mock('../../src/adapter/GitAdapter', () => ({
default: {
getInstance: jest.fn(() => ({
preBuildTreeIndex: mockPreBuildTreeIndex,
})),
closeAll: jest.fn(),
},
}))

const mockComputeTreeIndexScope = jest.fn()
jest.mock('../../src/utils/treeIndexScope', () => ({
computeTreeIndexScope: (...args: unknown[]) =>
mockComputeTreeIndexScope(...args),
}))

const mockValidateConfig = jest.fn()
jest.mock('../../src/utils/configValidator', () => {
// biome-ignore lint/suspicious/noExplicitAny: let TS know it is an object
Expand Down Expand Up @@ -70,7 +88,7 @@ jest.mock('../../src/post-processor/postProcessorManager', () => {
})

const mockExecute = jest.fn()
jest.mock('../../src/service/ioExecutor', () => {
jest.mock('../../src/adapter/ioExecutor', () => {
return {
default: jest.fn().mockImplementation(() => {
return {
Expand All @@ -85,6 +103,7 @@ beforeEach(() => {
mockProcess.mockResolvedValue(emptyResult())
mockCollectAll.mockResolvedValue(emptyResult())
mockGetLines.mockResolvedValue([] as never)
mockComputeTreeIndexScope.mockReturnValue(new Set())
})

describe('external library inclusion', () => {
Expand Down Expand Up @@ -218,4 +237,96 @@ describe('external library inclusion', () => {
expect(result.warnings).toContain(postWarning)
})
})

describe('tree index scoping', () => {
it('Given generateDelta is false, When sgd runs, Then preBuildTreeIndex is not called', async () => {
// Act
await sgd({ generateDelta: false } as Config)

// Assert
expect(mockPreBuildTreeIndex).not.toHaveBeenCalled()
})

it('Given generateDelta is true with include set, When sgd runs, Then preBuildTreeIndex is called with config.source', async () => {
// Arrange
const sut = {
generateDelta: true,
include: 'include.txt',
to: 'HEAD',
from: 'HEAD~1',
source: ['force-app'],
} as Config

// Act
await sgd(sut)

// Assert
expect(mockPreBuildTreeIndex).toHaveBeenCalledWith('HEAD', ['force-app'])
expect(mockPreBuildTreeIndex).toHaveBeenCalledWith('HEAD~1', [
'force-app',
])
expect(mockComputeTreeIndexScope).not.toHaveBeenCalled()
})

it('Given generateDelta is true with includeDestructive set, When sgd runs, Then preBuildTreeIndex is called with config.source', async () => {
// Arrange
const sut = {
generateDelta: true,
includeDestructive: 'destructive.txt',
to: 'HEAD',
from: 'HEAD~1',
source: ['src'],
} as Config

// Act
await sgd(sut)

// Assert
expect(mockPreBuildTreeIndex).toHaveBeenCalledWith('HEAD', ['src'])
expect(mockPreBuildTreeIndex).toHaveBeenCalledWith('HEAD~1', ['src'])
})

it('Given generateDelta is true with computed scope paths, When sgd runs, Then preBuildTreeIndex is called with scope paths', async () => {
// Arrange
mockComputeTreeIndexScope.mockReturnValueOnce(
new Set(['force-app/main/default/classes'])
)
const sut = {
generateDelta: true,
to: 'HEAD',
from: 'HEAD~1',
source: ['force-app'],
} as Config

// Act
await sgd(sut)

// Assert
expect(mockComputeTreeIndexScope).toHaveBeenCalled()
expect(mockPreBuildTreeIndex).toHaveBeenCalledWith('HEAD', [
'force-app/main/default/classes',
])
expect(mockPreBuildTreeIndex).toHaveBeenCalledWith('HEAD~1', [
'force-app/main/default/classes',
])
})

it('Given generateDelta is true with empty scope paths, When sgd runs, Then preBuildTreeIndex is not called', async () => {
// Arrange
mockComputeTreeIndexScope.mockReturnValueOnce(new Set())
const sut = {
generateDelta: true,
to: 'HEAD',
from: 'HEAD~1',
source: ['force-app'],
} as Config

// Act
await sgd(sut)

// Assert
expect(mockComputeTreeIndexScope).toHaveBeenCalled()
expect(mockPreBuildTreeIndex).not.toHaveBeenCalled()
})
})
})
Loading