Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: install, uninstall and run 3-way merge driver for Salesforce #3

Open
wants to merge 55 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
c3aebb9
feat: backbone first implementation
scolladon Mar 5, 2025
126845e
test: add spec for services
scolladon Mar 6, 2025
a8645a5
test: add integration test
scolladon Mar 6, 2025
64c7217
docs: add README.md
scolladon Mar 6, 2025
cf5225f
refactor: spread classes into folders
scolladon Mar 6, 2025
e8ad460
feat: copy the executable in the node_modules binary
scolladon Mar 6, 2025
fe95064
build: upgrade dependencies
scolladon Mar 6, 2025
b56b285
build: fix linting issues
scolladon Mar 6, 2025
b4c01a3
feat: implement a first tripart simple json algorithm with spec
scolladon Mar 6, 2025
8be7bb8
docs: improve description and add usage
scolladon Mar 7, 2025
a0caae9
feat: add driver run command
scolladon Mar 7, 2025
c0ffdd3
fix: linting issues
scolladon Mar 7, 2025
3648ede
fix: parser and builder configuration
scolladon Mar 10, 2025
0195e6b
docs: adding TODO in jsonmerger test, export jsonvalue type and use i…
yohanim Mar 11, 2025
081f31d
fix: removing the layout merge fix while it's not fixed in code
yohanim Mar 11, 2025
a3dad92
fix: upgrade dependency @oclif\core and megalinter error
yohanim Mar 11, 2025
346e1fe
fix: readme urls
yohanim Mar 11, 2025
7c8e67a
revert: e66831bb75fe6b5798f13b4f365784b9e22d204d
scolladon Mar 13, 2025
a69a768
feat: improve merge algorithm
scolladon Mar 14, 2025
f4e973a
feat: install driver only for handled types
scolladon Mar 14, 2025
aebd99b
feat: improve uninstall regex
scolladon Mar 14, 2025
46502b3
test: improve spec coverage
scolladon Mar 17, 2025
f7a7bc6
build: upgrade dependencies
scolladon Mar 17, 2025
0fa8622
fix: fast-xml-parser configuration
scolladon Mar 17, 2025
631d736
feat: merge and conflict rewrite with recursivity
yohanim Mar 19, 2025
9e8d39f
fix: remove beta poc command
yohanim Mar 19, 2025
67aae5f
fix: install unstall xmlmerge test fix
yohanim Mar 19, 2025
ef9f90a
fix: proper jsonmerger test and fix mergearray algo
yohanim Mar 20, 2025
64a72e4
fix: change coverage to initiate branch - todo proper dealing with on…
yohanim Mar 20, 2025
bb3e699
fix: empty file input
yohanim Mar 20, 2025
0112d35
fix: lower coverage check to push
yohanim Mar 20, 2025
378591d
fix: improve tests and coverage
yohanim Mar 20, 2025
39d76df
fix: original coverage level
yohanim Mar 20, 2025
53b9523
fix: complete initial tests
yohanim Mar 21, 2025
b043e19
feat: improve perf of getAttributePrimarytype
yohanim Mar 21, 2025
646ba00
feat: finish solving cases of one key not present
yohanim Mar 21, 2025
01e7ae4
chore: update @salesforce dependencies
yohanim Mar 21, 2025
2a87002
docs: fix README duplicate command section
scolladon Mar 21, 2025
f60f2f3
test: adding test for empty files
yohanim Mar 21, 2025
d761e17
test: adding a test to check namespace dealing
yohanim Mar 21, 2025
fbd8f9a
refactor: mutualize test cases
scolladon Mar 21, 2025
9e7cada
refactor: use metadataService
scolladon Mar 21, 2025
21c2566
refactor: segregate responsibilities
scolladon Mar 23, 2025
40e2373
chore: upgrade dependencies
scolladon Mar 23, 2025
51f0ff2
test: improve spec naming
scolladon Mar 23, 2025
4bcfe22
test: fix spec naming
yohanim Mar 24, 2025
df790b3
test: adding testcase with only ancestor given as input
yohanim Mar 24, 2025
1986d68
test: adding testcases for remaining branches
yohanim Mar 24, 2025
7a2676a
fix: mergeByKeyField isEqual lacked keys resulting bad logic even tho…
yohanim Mar 24, 2025
fa475d3
feat: make sure to get an array s attribute only once for optimization
yohanim Mar 24, 2025
06213e6
feat: organizing patterns and extractors and make them complete
yohanim Mar 27, 2025
87d1c44
test: add empty result even with namespace
scolladon Mar 28, 2025
fdae771
feat: handle exit code
scolladon Mar 28, 2025
3e2c3ba
test: improve spec coverage
scolladon Mar 28, 2025
f2581d5
chore: upgrade dependencies
scolladon Mar 28, 2025
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
Next Next commit
feat: backbone first implementation
  • Loading branch information
scolladon committed Mar 6, 2025
commit c3aebb999d156da3043649b437de29b54bf0aacc
13 changes: 13 additions & 0 deletions messages/install.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# summary

Installs a local git merge driver for the given org and branch.

# description

Installs a local git merge driver for the given org and branch, by updating the `.gitattributes` files in the project and creating a new merge driver configuration file in the `.git/config` of the project.

# examples

- Install the driver for a given project:

<%= config.bin %> <%= command.id %>
13 changes: 13 additions & 0 deletions messages/uninstall.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# summary

Uninstalls the local git merge driver for the given org and branch.

# description

Uninstalls the local git merge driver for the given org and branch, by removing the merge driver content in the `.gitattributes` files in the project and deleting the merge driver configuration file in the `.git/config` of the project.

# examples

- Uninstall the driver for a given project:

<%= config.bin %> <%= command.id %>
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "sf-git-merge-driver",
"description": "git remote add origin git@github.com:scolladon/sf-git-merge-driver.git",
"version": "1.0.0",
"exports": "./lib/index.js",
"exports": "./lib/service/MergeDriver.js",
"type": "module",
"author": "Sébastien Colladon (colladonsebastien@gmail.com)",
"repository": {
Expand Down
21 changes: 21 additions & 0 deletions src/commands/git/merge/driver/install.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Messages } from '@salesforce/core'
import { SfCommand } from '@salesforce/sf-plugins-core'
import { InstallService } from '../../../../service/installService.js'
import { UninstallService } from '../../../../service/uninstallService.js'

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url)
const messages = Messages.loadMessages('sf-git-merge-driver', 'install')

export default class Install extends SfCommand<void> {
public static override readonly summary = messages.getMessage('summary')
public static override readonly description =
messages.getMessage('description')
public static override readonly examples = messages.getMessages('examples')

public static override readonly flags = {}

public async run(): Promise<void> {
await new UninstallService().uninstallMergeDriver()
await new InstallService().installMergeDriver()
}
}
19 changes: 19 additions & 0 deletions src/commands/git/merge/driver/uninstall.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { Messages } from '@salesforce/core'
import { SfCommand } from '@salesforce/sf-plugins-core'
import { UninstallService } from '../../../../service/uninstallService.js'

Messages.importMessagesDirectoryFromMetaUrl(import.meta.url)
const messages = Messages.loadMessages('sf-git-merge-driver', 'uninstall')

export default class Uninstall extends SfCommand<void> {
public static override readonly summary = messages.getMessage('summary')
public static override readonly description =
messages.getMessage('description')
public static override readonly examples = messages.getMessages('examples')

public static override readonly flags = {}

public async run(): Promise<void> {
await new UninstallService().uninstallMergeDriver()
}
}
1 change: 1 addition & 0 deletions src/constant/driverConstant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const DRIVER_NAME = 'salesforce-source'
17 changes: 17 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env -S NODE_OPTIONS="--no-warnings=ExperimentalWarning" npx ts-node --project tsconfig.json --esm
import { MergeDriver } from './service/MergeDriver.js'

if (process.argv.length >= 6) {
const [, , ancestorFile, ourFile, theirFile, outputFile] = process.argv
const mergeDriver = new MergeDriver()
mergeDriver
.mergeFiles(ancestorFile, ourFile, theirFile, outputFile)
.then(() => process.exit(0))
} else {
console.error('Usage: sf-git-merge-driver %O %A %B %P')
console.error(' %O: ancestor file')
console.error(' %A: our file')
console.error(' %B: their file')
console.error(' %P: output file path')
process.exit(1)
}
132 changes: 132 additions & 0 deletions src/service/JsonMergeService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
export class JsonMergeService {
// Deep merge function for objects
mergeObjects(ancestor, ours, theirs) {
// If types don't match, prefer our version
if (
typeof ours !== typeof theirs ||
Array.isArray(ours) !== Array.isArray(theirs)
) {
return ours
}

// Handle arrays - special case for Salesforce metadata
if (Array.isArray(ours)) {
return this.mergeArrays(ancestor, ours, theirs)
}

// Handle objects
if (typeof ours === 'object' && ours !== null) {
const result = { ...ours }

// Process all keys from both objects
const allKeys = new Set([
...Object.keys(ours || {}),
...Object.keys(theirs || {}),
])

for (const key of allKeys) {
// If key exists only in theirs, add it
if (!(key in ours)) {
result[key] = theirs[key]
continue
}

// If key exists only in ours, keep it
if (!(key in theirs)) {
continue
}

// If key exists in both, recursively merge
const ancestorValue = ancestor && ancestor[key]
result[key] = this.mergeObjects(ancestorValue, ours[key], theirs[key])
}

return result
}

// For primitive values, check if they changed from ancestor
if (theirs !== ancestor && ours === ancestor) {
// They changed it, we didn't - use their change
return theirs
}

// In all other cases, prefer our version
return ours
}

// Special handling for Salesforce metadata arrays
mergeArrays(ancestor, ours, theirs) {
// For Salesforce metadata, arrays often contain objects with unique identifiers
// Try to match items by common identifier fields
const idFields = ['fullName', 'name', 'field', 'label', 'id', '@_name']

// Find a common identifier field that exists in the arrays
const idField = idFields.find(
field =>
ours.some(item => item && typeof item === 'object' && field in item) &&
theirs.some(item => item && typeof item === 'object' && field in item)
)

if (idField) {
// If we found a common identifier, merge by that field
const result = [...ours]

// Create maps for faster lookups
const ourMap = new Map(
ours
.filter(item => item && typeof item === 'object' && idField in item)
.map(item => [item[idField], item])
)

const ancestorMap =
ancestor && Array.isArray(ancestor)
? new Map(
ancestor
.filter(
item => item && typeof item === 'object' && idField in item
)
.map(item => [item[idField], item])
)
: new Map()

// Process items from their version
for (const theirItem of theirs) {
if (
theirItem &&
typeof theirItem === 'object' &&
idField in theirItem
) {
const id = theirItem[idField]

if (ourMap.has(id)) {
// Item exists in both versions, merge them
const ourItem = ourMap.get(id)
const ancestorItem = ancestorMap.get(id)

// Find the index in our array
const index = result.findIndex(
item => item && typeof item === 'object' && item[idField] === id
)

if (index !== -1) {
// Replace with merged item
result[index] = this.mergeObjects(
ancestorItem,
ourItem,
theirItem
)
}
} else {
// Item only exists in their version, add it
result.push(theirItem)
}
}
}

return result
}

// If no common identifier found, default to our version
return ours
}
}
24 changes: 24 additions & 0 deletions src/service/MergeDriver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { readFile, writeFile } from 'node:fs/promises'
import { XmlMergeService } from './XmlMergeService.js'

export class MergeDriver {
async mergeFiles(ancestorFile, ourFile, theirFile, outputFile) {
// Read all three versions
const [ancestorContent, ourContent, theirContent] = await Promise.all([
readFile(ancestorFile, 'utf8'),
readFile(ourFile, 'utf8'),
readFile(theirFile, 'utf8'),
])

const mergeService = new XmlMergeService()

const mergedContent = await mergeService.tripartXmlMerge(
ancestorContent,
ourContent,
theirContent
)

// Write the merged content to the output file
await writeFile(outputFile, mergedContent)
}
}
45 changes: 45 additions & 0 deletions src/service/XmlMergeService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { XMLBuilder, XMLParser } from 'fast-xml-parser'
import { JsonMergeService } from './JsonMergeService.js'

const options = {
attributeNamePrefix: '@_',
commentPropName: '#comment',
format: true,
ignoreAttributes: false,
ignoreNameSpace: false,
indentBy: ' ',
parseAttributeValue: false,
parseNodeValue: false,
parseTagValue: false,
processEntities: false,
suppressEmptyNode: false,
trimValues: true,
}

export class XmlMergeService {
async tripartXmlMerge(
ancestorContent: string,
ourContent: string,
theirContent: string
) {
const parser = new XMLParser(options)

const ancestorObj = parser.parse(ancestorContent)
const ourObj = parser.parse(ourContent)
const theirObj = parser.parse(theirContent)

// Perform deep merge of XML objects

const jsonMergeService = new JsonMergeService()
const mergedObj = jsonMergeService.mergeObjects(
ancestorObj,
ourObj,
theirObj
)

// Convert back to XML and format
const builder = new XMLBuilder(options)
const mergedXml = builder.build(mergedObj)
return mergedXml
}
}
23 changes: 23 additions & 0 deletions src/service/installService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { appendFile } from 'node:fs/promises'
import { simpleGit } from 'simple-git'
import { DRIVER_NAME } from '../constant/driverConstant.js'

export class InstallService {
public async installMergeDriver() {
const git = simpleGit()

await git
.addConfig(`merge.${DRIVER_NAME}.name`, 'Salesforce source merge driver')
.addConfig(
`merge.${DRIVER_NAME}.driver`,
'node ${__dirname}/index.js %O %A %B %P' // TODO define how to do that
)
.addConfig(`merge.${DRIVER_NAME}.recursive`, 'true') // TO CHALLENGE

const content =
['*.xml'].map(pattern => `${pattern} merge=${DRIVER_NAME}`).join('\n') +
'\n'

await appendFile('.gitattributes', content, { flag: 'a' })
}
}
20 changes: 20 additions & 0 deletions src/service/uninstallService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { readFile, writeFile } from 'node:fs/promises'
import { simpleGit } from 'simple-git'
import { DRIVER_NAME } from '../constant/driverConstant.js'

const MERGE_DRIVER_CONFIG = new RegExp(`.* merge\\s*=\\s*${DRIVER_NAME}$`)

export class UninstallService {
public async uninstallMergeDriver() {
const git = simpleGit()
await git.raw(['config', '--remove-section', `merge.${DRIVER_NAME}`])

const gitAttributes = await readFile('.gitattributes', {
encoding: 'utf8',
})
const filteredAttributes = gitAttributes
.split('\n')
.filter(line => !MERGE_DRIVER_CONFIG.test(line))
await writeFile('.gitattributes', filteredAttributes.join('\n'))
}
}
Loading