Skip to content

Commit 634b491

Browse files
committed
chore: detect conflict
1 parent ea07b40 commit 634b491

8 files changed

Lines changed: 775 additions & 0 deletions

File tree

command-snapshot.json

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
[
2+
{
3+
"alias": [],
4+
"command": "devops:conflict:detect",
5+
"flagAliases": [],
6+
"flagChars": ["o", "p", "w"],
7+
"flags": ["api-version", "flags-dir", "json", "repo-path", "target-org", "work-item-id"],
8+
"plugin": "@salesforce/plugin-devops-center"
9+
},
210
{
311
"alias": [],
412
"command": "devops:pipeline:activate",

messages/devops.conflict.detect.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# summary
2+
3+
Detect merge conflicts for a work item branch.
4+
5+
# description
6+
7+
Detect merge conflicts for a work item branch against the next pipeline stage branch. Uses git merge-tree to perform an exact three-way merge check without modifying the working tree. If conflicts are found, the command returns the conflicting files. Resolve conflicts before you promote the work item.
8+
9+
# flags.target-org.summary
10+
11+
Username or alias of the DevOps Center org.
12+
13+
# flags.work-item-id.summary
14+
15+
ID of the work item.
16+
17+
# flags.repo-path.summary
18+
19+
Path to the local git repository clone.
20+
21+
# examples
22+
23+
- Detect conflicts for a work item.
24+
25+
<%= config.bin %> <%= command.id %> --target-org my-devops-org --work-item-id 0Wx000000000001 --repo-path ./my-repo
26+
27+
- Detect conflicts for a work item and return JSON output for scripting.
28+
29+
<%= config.bin %> <%= command.id %> --target-org my-devops-org --work-item-id 0Wx000000000001 --repo-path ./my-repo --json
30+
31+
# error.WorkItemNotFound
32+
33+
Work item '%s' not found.
34+
35+
# error.NoBranch
36+
37+
Work item %s doesn't have an associated branch. Create a branch for the work item first.
38+
39+
# error.NoTargetBranch
40+
41+
Can't determine target branch for work item %s. Ensure the work item is assigned to a pipeline stage.
42+
43+
# error.GitFetchFailed
44+
45+
Failed to fetch branches from remote: %s
46+
47+
# info.ConflictsFound
48+
49+
%d conflict(s) found. Resolve them before you promote the work item.
50+
51+
# info.NoConflicts
52+
53+
No conflicts detected for %s.

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,9 @@
9393
"pull-request": {
9494
"description": "Commands for managing DevOps Center pull requests."
9595
},
96+
"conflict": {
97+
"description": "Commands for detecting and managing merge conflicts."
98+
},
9699
"pipeline": {
97100
"description": "Commands for managing DevOps Center pipelines.",
98101
"subtopics": {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$ref": "#/definitions/DetectConflictsResult",
4+
"definitions": {
5+
"DetectConflictsResult": {
6+
"type": "object",
7+
"properties": {
8+
"workItemId": {
9+
"type": "string"
10+
},
11+
"workItemName": {
12+
"type": "string"
13+
},
14+
"sourceBranch": {
15+
"type": "string"
16+
},
17+
"targetBranch": {
18+
"type": "string"
19+
},
20+
"hasConflicts": {
21+
"type": "boolean"
22+
},
23+
"conflicts": {
24+
"type": "array",
25+
"items": {
26+
"$ref": "#/definitions/ConflictEntry"
27+
}
28+
}
29+
},
30+
"required": ["workItemId", "workItemName", "sourceBranch", "targetBranch", "hasConflicts", "conflicts"],
31+
"additionalProperties": false
32+
},
33+
"ConflictEntry": {
34+
"type": "object",
35+
"properties": {
36+
"filePath": {
37+
"type": "string"
38+
},
39+
"conflictType": {
40+
"type": "string"
41+
}
42+
},
43+
"required": ["filePath", "conflictType"],
44+
"additionalProperties": false
45+
}
46+
}
47+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/*
2+
* Copyright 2026, Salesforce, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { Messages, Org } from '@salesforce/core';
18+
import { SfCommand, Flags } from '@salesforce/sf-plugins-core';
19+
import { detectConflicts, fetchBranches, DetectConflictsResult } from '../../../utils/detectConflicts.js';
20+
import { fetchWorkItemDetail } from '../../../utils/createPullRequest.js';
21+
22+
Messages.importMessagesDirectoryFromMetaUrl(import.meta.url);
23+
const messages = Messages.loadMessages('@salesforce/plugin-devops-center', 'devops.conflict.detect');
24+
const commonErrorMessages = Messages.loadMessages('@salesforce/plugin-devops-center', 'commonErrors');
25+
26+
export default class DevopsConflictDetect extends SfCommand<DetectConflictsResult> {
27+
public static readonly summary = messages.getMessage('summary');
28+
public static readonly description = messages.getMessage('description');
29+
public static readonly examples = messages.getMessages('examples');
30+
31+
public static readonly flags = {
32+
'target-org': Flags.requiredOrg(),
33+
'api-version': Flags.orgApiVersion(),
34+
'work-item-id': Flags.salesforceId({
35+
summary: messages.getMessage('flags.work-item-id.summary'),
36+
required: true,
37+
char: 'w',
38+
}),
39+
'repo-path': Flags.directory({
40+
summary: messages.getMessage('flags.repo-path.summary'),
41+
char: 'p',
42+
required: true,
43+
exists: true,
44+
}),
45+
};
46+
47+
public async run(): Promise<DetectConflictsResult> {
48+
const { flags } = await this.parse(DevopsConflictDetect);
49+
const org: Org = flags['target-org'];
50+
const connection = org.getConnection(flags['api-version']);
51+
const workItemId = flags['work-item-id'];
52+
const repoPath = flags['repo-path'];
53+
54+
const detail = await this.fetchWorkItem(connection, workItemId);
55+
56+
if (!detail.branchName) {
57+
this.error(messages.getMessage('error.NoBranch', [detail.workItemName]));
58+
}
59+
if (!detail.targetBranch) {
60+
this.error(messages.getMessage('error.NoTargetBranch', [detail.workItemName]));
61+
}
62+
63+
try {
64+
fetchBranches(repoPath, detail.branchName, detail.targetBranch);
65+
} catch (error: unknown) {
66+
const errMsg = error instanceof Error ? error.message : String(error);
67+
this.error(messages.getMessage('error.GitFetchFailed', [errMsg]));
68+
}
69+
70+
const conflicts = detectConflicts({
71+
repoPath,
72+
sourceBranch: detail.branchName,
73+
targetBranch: detail.targetBranch,
74+
});
75+
76+
const result: DetectConflictsResult = {
77+
workItemId,
78+
workItemName: detail.workItemName,
79+
sourceBranch: detail.branchName,
80+
targetBranch: detail.targetBranch,
81+
hasConflicts: conflicts.length > 0,
82+
conflicts,
83+
};
84+
85+
this.printOutput(result);
86+
87+
return result;
88+
}
89+
90+
private async fetchWorkItem(
91+
connection: Parameters<typeof fetchWorkItemDetail>[0],
92+
workItemId: string
93+
): ReturnType<typeof fetchWorkItemDetail> {
94+
try {
95+
return await fetchWorkItemDetail(connection, { id: workItemId });
96+
} catch (error: unknown) {
97+
const errMsg = error instanceof Error ? error.message : String(error);
98+
if (errMsg.includes('sObject type') && errMsg.includes('is not supported')) {
99+
this.error(commonErrorMessages.getMessage('error.DevopsCenterNotEnabled'));
100+
}
101+
if (errMsg.includes('not found')) {
102+
this.error(messages.getMessage('error.WorkItemNotFound', [workItemId]));
103+
}
104+
throw error;
105+
}
106+
}
107+
108+
private printOutput(result: DetectConflictsResult): void {
109+
if (result.hasConflicts) {
110+
this.styledHeader(`Conflicts Detected for ${result.workItemName}`);
111+
this.log(`Source Branch: ${result.sourceBranch}`);
112+
this.log(`Target Branch: ${result.targetBranch}`);
113+
this.table({
114+
data: result.conflicts.map((c) => ({
115+
'File Path': c.filePath,
116+
'Conflict Type': c.conflictType,
117+
})),
118+
columns: ['File Path', 'Conflict Type'],
119+
});
120+
this.log(messages.getMessage('info.ConflictsFound', [result.conflicts.length]));
121+
} else {
122+
this.log(messages.getMessage('info.NoConflicts', [result.workItemName]));
123+
this.log(` Source Branch: ${result.sourceBranch}`);
124+
this.log(` Target Branch: ${result.targetBranch}`);
125+
this.log(' Ready to promote.');
126+
}
127+
}
128+
}

src/utils/detectConflicts.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/*
2+
* Copyright 2026, Salesforce, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { execSync } from 'node:child_process';
18+
19+
export type ConflictEntry = {
20+
filePath: string;
21+
conflictType: string;
22+
};
23+
24+
export type DetectConflictsParams = {
25+
repoPath: string;
26+
sourceBranch: string;
27+
targetBranch: string;
28+
};
29+
30+
export type DetectConflictsResult = {
31+
workItemId: string;
32+
workItemName: string;
33+
sourceBranch: string;
34+
targetBranch: string;
35+
hasConflicts: boolean;
36+
conflicts: ConflictEntry[];
37+
};
38+
39+
/**
40+
* Fetches the latest refs for source and target branches from the remote.
41+
*/
42+
export function fetchBranches(repoPath: string, sourceBranch: string, targetBranch: string): void {
43+
execSync(`git fetch origin ${sourceBranch} ${targetBranch}`, {
44+
cwd: repoPath,
45+
encoding: 'utf-8',
46+
stdio: ['pipe', 'pipe', 'pipe'],
47+
});
48+
}
49+
50+
/**
51+
* Detects merge conflicts using `git merge-tree` which performs a
52+
* three-way merge in memory without touching the working tree or index.
53+
*
54+
* Exit code 0 = clean merge, non-zero = conflicts.
55+
* Conflicting file paths are extracted from the output.
56+
*/
57+
export function detectConflicts(params: DetectConflictsParams): ConflictEntry[] {
58+
const { repoPath, sourceBranch, targetBranch } = params;
59+
60+
try {
61+
execSync(`git merge-tree --write-tree origin/${sourceBranch} origin/${targetBranch}`, {
62+
cwd: repoPath,
63+
encoding: 'utf-8',
64+
stdio: ['pipe', 'pipe', 'pipe'],
65+
});
66+
return [];
67+
} catch (error: unknown) {
68+
const stderr = (error as { stderr?: string }).stderr ?? '';
69+
const stdout = (error as { stdout?: string }).stdout ?? '';
70+
const exitCode = (error as { status?: number }).status;
71+
72+
if (exitCode === 1) {
73+
return parseConflicts(stdout + '\n' + stderr);
74+
}
75+
throw new Error(`git merge-tree failed: ${stderr || stdout}`);
76+
}
77+
}
78+
79+
/**
80+
* Parses `git merge-tree --write-tree` output to extract conflicting file paths.
81+
* Lines matched: CONFLICT (content): Merge conflict in path,
82+
* CONFLICT (modify/delete): path deleted in ..., CONFLICT (add/add): ...
83+
*/
84+
function parseConflicts(output: string): ConflictEntry[] {
85+
const conflicts: ConflictEntry[] = [];
86+
const seen = new Set<string>();
87+
88+
const contentRe = /^CONFLICT \(([^)]+)\): Merge conflict in (.+)$/;
89+
const modDeleteRe = /^CONFLICT \(([^)]+)\): (.+?) deleted in /;
90+
91+
for (const line of output.split('\n')) {
92+
const contentMatch = line.match(contentRe);
93+
if (contentMatch) {
94+
addConflict(conflicts, seen, contentMatch[2].trim(), contentMatch[1]);
95+
continue;
96+
}
97+
const modDeleteMatch = line.match(modDeleteRe);
98+
if (modDeleteMatch) {
99+
addConflict(conflicts, seen, modDeleteMatch[2].trim(), modDeleteMatch[1]);
100+
}
101+
}
102+
103+
return conflicts;
104+
}
105+
106+
function addConflict(conflicts: ConflictEntry[], seen: Set<string>, filePath: string, conflictType: string): void {
107+
if (filePath && !seen.has(filePath)) {
108+
seen.add(filePath);
109+
conflicts.push({ filePath, conflictType: capitalizeFirst(conflictType.trim()) });
110+
}
111+
}
112+
113+
function capitalizeFirst(s: string): string {
114+
return s.charAt(0).toUpperCase() + s.slice(1);
115+
}

0 commit comments

Comments
 (0)