Skip to content

Commit 4f7761b

Browse files
fix: commit changes without Sandbox deployment (#314)
* made commit deployment free * remove test which failed on windows * update logic to work on windows * dummy commit --------- Co-authored-by: Manan Dey <[email protected]>
1 parent 9ab6fdb commit 4f7761b

File tree

4 files changed

+474
-67
lines changed

4 files changed

+474
-67
lines changed
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import axios from 'axios';
2+
import { getConnection, getRequiredOrgs } from './shared/auth.js';
3+
import { execFileSync } from 'child_process';
4+
import { normalizeAndValidateRepoPath } from './shared/pathUtils.js';
5+
import path from 'path';
6+
import * as fs from 'fs';
7+
import * as os from 'os';
8+
import { RegistryAccess } from '@salesforce/source-deploy-retrieve';
9+
import { convertToSourceComponents } from './shared/sfdxService.js';
10+
11+
interface Change {
12+
fullName: string;
13+
type: string;
14+
operation: string;
15+
}
16+
17+
interface CommitWorkItemParams {
18+
workItem: { id: string };
19+
requestId: string;
20+
commitMessage: string;
21+
username: string;
22+
repoPath?: string;
23+
}
24+
25+
26+
export async function commitWorkItem({
27+
workItem,
28+
requestId,
29+
commitMessage,
30+
username,
31+
repoPath
32+
}: CommitWorkItemParams): Promise<any> {
33+
const connection = await getConnection(username);
34+
const accessToken = connection.accessToken;
35+
const instanceUrl = connection.instanceUrl;
36+
37+
if (!accessToken || !instanceUrl) {
38+
throw new Error('Missing access token or instance URL. Please check if you are authenticated to the org.');
39+
}
40+
41+
42+
const workingDir = normalizeAndValidateRepoPath(repoPath);
43+
44+
const computedChanges = buildComputedChanges(workingDir);
45+
46+
const { commitSha, branch } = AddAndCommitChanges(workingDir, commitMessage);
47+
48+
const url = `${instanceUrl}/services/data/v65.0/connect/devops/workItems/${workItem.id}/commitlite`;
49+
50+
const headers = {
51+
'Authorization': `Bearer ${accessToken}`,
52+
'Content-Type': 'application/json'
53+
};
54+
55+
const requestBody = {
56+
requestId,
57+
commitMessage,
58+
'commitId': commitSha,
59+
changes: computedChanges
60+
};
61+
62+
try {
63+
const response = await axios.post(url, requestBody, { headers });
64+
65+
return {
66+
content: [{
67+
type: "text",
68+
text: `Changes committed successfully.
69+
Commit SHA: ${commitSha}
70+
Agent execution guide (perform these steps now)
71+
- Push the commit: 'git push'`,
72+
actionRequired: true
73+
}],
74+
isError: false
75+
};
76+
} catch (error: any) {
77+
return {
78+
content: [{
79+
type: "text",
80+
text: `Failed to commit work item: ${error.message}`
81+
}]
82+
};
83+
}
84+
}
85+
86+
function buildComputedChanges(workingDir: string): Change[] {
87+
const deletedRel = execFileSync('git', ['ls-files', '-d'], { cwd: workingDir, encoding: 'utf8' })
88+
.split('\n').map(l => l.trim()).filter(Boolean);
89+
const modifiedRel = execFileSync('git', ['ls-files', '-m'], { cwd: workingDir, encoding: 'utf8' })
90+
.split('\n').map(l => l.trim()).filter(Boolean);
91+
const untrackedRel = execFileSync('git', ['ls-files', '--others', '--exclude-standard'], { cwd: workingDir, encoding: 'utf8' })
92+
.split('\n').map(l => l.trim()).filter(Boolean);
93+
const stagedRel = execFileSync('git', ['diff', '--cached', '--name-only'], { cwd: workingDir, encoding: 'utf8' })
94+
.split('\n').map(l => l.trim()).filter(Boolean);
95+
96+
const computedChanges: Change[] = [];
97+
98+
const allRelPaths = Array.from(new Set([
99+
...modifiedRel,
100+
...untrackedRel,
101+
...stagedRel
102+
]));
103+
104+
const registry = new RegistryAccess();
105+
const componentsExisting = convertToSourceComponents(workingDir, registry, allRelPaths);
106+
107+
const toPosix = (p: string) => p.replace(/\\/g, '/');
108+
const untrackedSet = new Set(untrackedRel.map(toPosix));
109+
const modifiedSet = new Set(modifiedRel.map(toPosix));
110+
const stagedSet = new Set(stagedRel.map(toPosix));
111+
112+
for (const comp of componentsExisting) {
113+
const relPath = toPosix(path.relative(workingDir, comp.filePath));
114+
let operation: 'delete' | 'add' | 'modify' | undefined;
115+
116+
if (untrackedSet.has(relPath)) {
117+
operation = 'add';
118+
} else if (modifiedSet.has(relPath) || stagedSet.has(relPath)) {
119+
operation = 'modify';
120+
}
121+
122+
if (operation) {
123+
computedChanges.push({ fullName: comp.fullName, type: comp.type.name, operation });
124+
}
125+
}
126+
127+
if (deletedRel.length > 0) {
128+
const componentsDeleted = getComponentsForDeletedPaths(workingDir, deletedRel);
129+
for (const comp of componentsDeleted) {
130+
computedChanges.push({ fullName: comp.fullName, type: comp.type.name, operation: 'delete' });
131+
}
132+
}
133+
134+
if (computedChanges.length === 0) {
135+
throw new Error('No eligible changes to commit (only Unchanged components detected).');
136+
}
137+
138+
return computedChanges;
139+
}
140+
141+
function getComponentsForDeletedPaths(workingDir: string, deletedRel: string[]) {
142+
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'deleted-components-'));
143+
const registry = new RegistryAccess();
144+
145+
const restoreFileFromGit = (rel: string) => {
146+
const dest = path.join(tempDir, rel);
147+
fs.mkdirSync(path.dirname(dest), { recursive: true });
148+
const content = execFileSync('git', ['show', `HEAD:${rel}`], { cwd: workingDir });
149+
fs.writeFileSync(dest, content);
150+
};
151+
152+
const restoreBundleFromGit = (bundleRelDir: string) => {
153+
const files = execFileSync('git', ['ls-tree', '-r', '--name-only', 'HEAD', bundleRelDir], { cwd: workingDir, encoding: 'utf8' })
154+
.split('\n').map(l => l.trim()).filter(Boolean);
155+
for (const f of files) {
156+
restoreFileFromGit(f);
157+
}
158+
};
159+
160+
const isBundleType = (rel: string): { isBundle: boolean; bundleRoot?: string } => {
161+
const parts = rel.split(/[\\/]/g);
162+
const idxAura = parts.indexOf('aura');
163+
const idxLwc = parts.indexOf('lwc');
164+
const idxExp = parts.indexOf('experiences');
165+
const idxStatic = parts.indexOf('staticresources');
166+
if (idxAura >= 0 && parts.length >= idxAura + 2) {
167+
const root = parts.slice(0, idxAura + 2).join('/');
168+
return { isBundle: true, bundleRoot: root };
169+
}
170+
if (idxLwc >= 0 && parts.length >= idxLwc + 2) {
171+
const root = parts.slice(0, idxLwc + 2).join('/');
172+
return { isBundle: true, bundleRoot: root };
173+
}
174+
if (idxExp >= 0 && parts.length >= idxExp + 2) {
175+
const root = parts.slice(0, idxExp + 2).join('/');
176+
return { isBundle: true, bundleRoot: root };
177+
}
178+
if (idxStatic >= 0 && parts.length >= idxStatic + 1) {
179+
if (parts[parts.length - 1].endsWith('.resource') || parts[parts.length - 1].endsWith('.resource-meta.xml')) {
180+
return { isBundle: false };
181+
}
182+
const root = parts.slice(0, idxStatic + 2).join('/');
183+
return { isBundle: true, bundleRoot: root };
184+
}
185+
return { isBundle: false };
186+
};
187+
188+
for (const rel of deletedRel) {
189+
const { isBundle, bundleRoot } = isBundleType(rel);
190+
try {
191+
if (isBundle && bundleRoot) {
192+
restoreBundleFromGit(bundleRoot);
193+
} else {
194+
restoreFileFromGit(rel);
195+
if (!rel.endsWith('-meta.xml')) {
196+
const metaRel = rel + '-meta.xml';
197+
try { restoreFileFromGit(metaRel); } catch {}
198+
}
199+
}
200+
} catch {
201+
// ignore failures for paths that may not exist in HEAD
202+
}
203+
}
204+
205+
const componentsDeleted = convertToSourceComponents(tempDir, registry, deletedRel);
206+
fs.rmSync(tempDir, { recursive: true, force: true });
207+
return componentsDeleted;
208+
}
209+
210+
export function AddAndCommitChanges(
211+
workingDir: string,
212+
commitMessage: string,
213+
): { commitSha: string; branch: string } {
214+
215+
216+
// Stage all changes (adds/modifies/deletes)
217+
execFileSync('git', ['add', '--all'], { cwd: workingDir, encoding: 'utf8' });
218+
219+
// If nothing to commit, surface clearly
220+
const status = execFileSync('git', ['status', '--porcelain'], { cwd: workingDir, encoding: 'utf8' }).trim();
221+
if (!status) {
222+
throw new Error('No file changes to commit. Working tree is clean.');
223+
}
224+
225+
// Create commit
226+
execFileSync('git', ['commit', '-m', commitMessage], { cwd: workingDir, encoding: 'utf8' });
227+
228+
// Get commit SHA
229+
const commitSha = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: workingDir, encoding: 'utf8' }).trim();
230+
231+
return { commitSha, branch: '' };
232+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import {
2+
ComponentSet,
3+
ComponentSetBuilder,
4+
DestructiveChangesType,
5+
MetadataApiDeploy,
6+
MetadataApiDeployOptions,
7+
MetadataResolver,
8+
NodeFSTreeContainer,
9+
RegistryAccess,
10+
RetrieveResult,
11+
SourceComponent
12+
} from '@salesforce/source-deploy-retrieve';
13+
import path from 'node:path';
14+
15+
export interface ExtendedSourceComponent extends SourceComponent {
16+
filePath: string;
17+
}
18+
19+
/**
20+
* Use the SFDX registry to convert a list of file paths to SourceComponents
21+
* @param baseDir Absolute or project root directory
22+
* @param registry SDR RegistryAccess instance
23+
* @param paths Relative paths from baseDir
24+
* @param logInfo Optional logger for informational messages
25+
*/
26+
export function convertToSourceComponents(
27+
baseDir: string,
28+
registry: RegistryAccess,
29+
paths: string[],
30+
logInfo?: (message: string) => void
31+
): ExtendedSourceComponent[] {
32+
const resolver = new MetadataResolver(registry, undefined, false);
33+
const results: ExtendedSourceComponent[] = [];
34+
paths.forEach((p) => {
35+
try {
36+
const absPath = path.join(baseDir, p);
37+
resolver.getComponentsFromPath(absPath).forEach((cs) => {
38+
results.push({
39+
...cs,
40+
fullName: cs.fullName,
41+
filePath: absPath
42+
} as ExtendedSourceComponent);
43+
});
44+
} catch (e: any) {
45+
if (e?.name === 'TypeInferenceError') {
46+
if (logInfo) {
47+
logInfo('Unable to determine type for ' + p + ', ignoring');
48+
}
49+
} else {
50+
throw e;
51+
}
52+
}
53+
});
54+
return results;
55+
}

0 commit comments

Comments
 (0)