isomorphic-jj 1.1.3 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "isomorphic-jj",
3
- "version": "1.1.3",
3
+ "version": "1.3.0",
4
4
  "description": "A pure-JavaScript library that brings Jujutsu (jj) version control semantics to Node.js and browsers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -756,69 +756,116 @@ export async function createJJ(options) {
756
756
  * @param {string[]} [args.paths] - Paths to move between changes (for history operations)
757
757
  * @returns {Promise<Object>} Returns change object for history operations, file info for file operations
758
758
  */
759
- async move(args) {
760
- if (!args || typeof args !== 'object') {
761
- throw new JJError('INVALID_ARGUMENT', 'Missing or invalid arguments', {
762
- suggestion: 'Use file.move({ from, to }) for files or rebase({ changeId, newParent }) for history',
759
+ /**
760
+ * Move a file in the working copy
761
+ *
762
+ * @param {Object} args - Move file arguments
763
+ * @param {string} args.from - Source file path
764
+ * @param {string} args.to - Destination file path
765
+ * @returns {Promise<Object>} Operation result including from, to paths and file stats
766
+ */
767
+ async moveFile(args) {
768
+ if (!args.from || typeof args.from !== 'string') {
769
+ throw new JJError('INVALID_ARGUMENT', 'Missing or invalid from path', {
770
+ suggestion: 'Provide a valid source file path',
771
+ provided: { from: args.from, type: typeof args.from },
763
772
  });
764
773
  }
765
774
 
766
- // Detect operation type based on arguments
767
- const isHistoryOp = this._detectHistoryOperation(args);
775
+ if (!args.to || typeof args.to !== 'string') {
776
+ throw new JJError('INVALID_ARGUMENT', 'Missing or invalid to path', {
777
+ suggestion: 'Provide a valid destination file path',
778
+ provided: { to: args.to, type: typeof args.to },
779
+ });
780
+ }
768
781
 
769
- if (isHistoryOp) {
770
- return await this._moveChange(args);
771
- } else {
772
- return await this._moveFile(args);
782
+ // Prevent moving to the same location
783
+ if (args.from === args.to) {
784
+ throw new JJError('INVALID_OPERATION', 'Source and destination paths are the same', {
785
+ path: args.from,
786
+ suggestion: 'Specify a different destination path',
787
+ });
773
788
  }
774
- },
775
789
 
776
- /**
777
- * Detect if move operation is for history (rebase) or files
778
- *
779
- * @private
780
- * @param {Object} args - Move arguments
781
- * @returns {boolean} True if history operation, false if file operation
782
- */
783
- _detectHistoryOperation(args) {
784
- // Explicit history operation indicators
785
- if (args.paths || args.changeId || args.newParent) {
786
- return true;
790
+ // Prevent absolute paths
791
+ if (args.from.startsWith('/') || args.to.startsWith('/')) {
792
+ throw new JJError('INVALID_PATH', 'Absolute paths are not allowed', {
793
+ suggestion: 'Use paths relative to the repository root',
794
+ provided: { from: args.from, to: args.to },
795
+ });
787
796
  }
788
797
 
789
- // If only from/to are present, check if they look like change IDs
790
- if (args.from && args.to && !args.changeId && !args.newParent && !args.paths) {
791
- // Change IDs are 32-character hex strings
792
- const changeIdPattern = /^[0-9a-f]{32}$/;
793
- const fromLooksLikeChangeId = changeIdPattern.test(args.from);
794
- const toLooksLikeChangeId = changeIdPattern.test(args.to);
798
+ // Prevent parent directory traversal
799
+ if (args.from.includes('..') || args.to.includes('..')) {
800
+ throw new JJError('INVALID_PATH', 'Parent directory traversal (..) is not allowed', {
801
+ suggestion: 'Use paths within the repository',
802
+ provided: { from: args.from, to: args.to },
803
+ });
804
+ }
795
805
 
796
- // If both look like change IDs, it's likely a history operation
797
- // However, we err on the side of file operations to be safe
798
- // Users should use explicit { changeId, newParent } for clarity
799
- if (fromLooksLikeChangeId && toLooksLikeChangeId) {
800
- // This is ambiguous - throw a helpful error
801
- throw new JJError('AMBIGUOUS_OPERATION',
802
- 'Ambiguous move operation: arguments look like change IDs', {
803
- suggestion: 'Use { changeId, newParent } for history operations or ensure file paths don\'t match change ID pattern',
804
- args,
805
- });
806
- }
806
+ const fromPath = `${dir}/${args.from}`;
807
+ const toPath = `${dir}/${args.to}`;
807
808
 
808
- return false; // Treat as file operation
809
+ // Check if source file exists
810
+ try {
811
+ await fs.promises.stat(fromPath);
812
+ } catch (error) {
813
+ throw new JJError('FILE_NOT_FOUND', `Source file not found: ${args.from}`, {
814
+ path: args.from,
815
+ suggestion: 'Check that the source file exists',
816
+ });
809
817
  }
810
818
 
811
- return false;
819
+ // Ensure destination directory exists
820
+ const toDir = toPath.substring(0, toPath.lastIndexOf('/'));
821
+ try {
822
+ await fs.promises.mkdir(toDir, { recursive: true });
823
+ } catch (error) {
824
+ throw new JJError('FILE_SYSTEM_ERROR', `Failed to create destination directory: ${error.message}`, {
825
+ path: toDir,
826
+ error: error.message,
827
+ });
828
+ }
829
+
830
+ // Move the file
831
+ try {
832
+ await fs.promises.rename(fromPath, toPath);
833
+ } catch (error) {
834
+ throw new JJError('FILE_SYSTEM_ERROR', `Failed to move file: ${error.message}`, {
835
+ from: args.from,
836
+ to: args.to,
837
+ error: error.message,
838
+ });
839
+ }
840
+
841
+ // Update working copy tracking
842
+ await workingCopy.load();
843
+ await workingCopy.untrackFile(args.from);
844
+ const stats = await fs.promises.stat(toPath);
845
+ await workingCopy.trackFile(args.to, {
846
+ mtime: stats.mtime,
847
+ size: stats.size,
848
+ mode: stats.mode,
849
+ });
850
+
851
+ return {
852
+ from: args.from,
853
+ to: args.to,
854
+ size: stats.size,
855
+ mode: stats.mode,
856
+ mtime: stats.mtime,
857
+ };
812
858
  },
813
859
 
814
860
  /**
815
- * Move/rebase a change to a new parent (history operation)
861
+ * Move (rebase) a change to a new parent
816
862
  *
817
- * @private
818
- * @param {Object} args - Move arguments
863
+ * @param {Object} args - Move change arguments
864
+ * @param {string} args.changeId - Change ID to move (also accepts 'from' or 'source')
865
+ * @param {string} args.newParent - New parent change ID (also accepts 'to' or 'destination')
819
866
  * @returns {Promise<Object>} Updated change object
820
867
  */
821
- async _moveChange(args) {
868
+ async moveChange(args) {
822
869
  await graph.load();
823
870
 
824
871
  // Support multiple parameter names for flexibility
@@ -906,110 +953,62 @@ export async function createJJ(options) {
906
953
  },
907
954
 
908
955
  /**
909
- * Move/rename a file in the working copy (file operation)
910
- *
911
- * @private
912
- * @param {Object} args - Move arguments
913
- * @returns {Promise<Object>} Operation result including from, to paths and file stats
956
+ * @deprecated Use moveFile() for files or moveChange() for history operations
957
+ * Move operation - delegates to moveFile or moveChange based on arguments
914
958
  */
915
- async _moveFile(args) {
916
- if (!args.from || typeof args.from !== 'string') {
917
- throw new JJError('INVALID_ARGUMENT', 'Missing or invalid from path', {
918
- suggestion: 'Provide a valid source file path',
919
- provided: { from: args.from, type: typeof args.from },
920
- });
921
- }
922
-
923
- if (!args.to || typeof args.to !== 'string') {
924
- throw new JJError('INVALID_ARGUMENT', 'Missing or invalid to path', {
925
- suggestion: 'Provide a valid destination file path',
926
- provided: { to: args.to, type: typeof args.to },
959
+ async move(args) {
960
+ if (!args || typeof args !== 'object') {
961
+ throw new JJError('INVALID_ARGUMENT', 'Missing or invalid arguments', {
962
+ suggestion: 'Use moveFile({ from, to }) for files or moveChange({ changeId, newParent }) for history',
927
963
  });
928
964
  }
929
965
 
930
- // Prevent moving to the same location
931
- if (args.from === args.to) {
932
- throw new JJError('INVALID_OPERATION', 'Source and destination paths are the same', {
933
- path: args.from,
934
- suggestion: 'Specify a different destination path',
935
- });
936
- }
966
+ // Detect operation type based on arguments
967
+ const isHistoryOp = this._detectHistoryOperation(args);
937
968
 
938
- // Prevent absolute paths
939
- if (args.from.startsWith('/') || args.to.startsWith('/')) {
940
- throw new JJError('INVALID_PATH', 'Absolute paths are not allowed', {
941
- suggestion: 'Use paths relative to the repository root',
942
- provided: { from: args.from, to: args.to },
943
- });
969
+ if (isHistoryOp) {
970
+ return await this.moveChange(args);
971
+ } else {
972
+ return await this.moveFile(args);
944
973
  }
974
+ },
945
975
 
946
- // Prevent parent directory traversal
947
- if (args.from.includes('..') || args.to.includes('..')) {
948
- throw new JJError('INVALID_PATH', 'Parent directory traversal (..) is not allowed', {
949
- suggestion: 'Use paths within the repository',
950
- provided: { from: args.from, to: args.to },
951
- });
976
+ /**
977
+ * Detect if move operation is for history (rebase) or files
978
+ *
979
+ * @private
980
+ * @param {Object} args - Move arguments
981
+ * @returns {boolean} True if history operation, false if file operation
982
+ */
983
+ _detectHistoryOperation(args) {
984
+ // Explicit history operation indicators
985
+ if (args.paths || args.changeId || args.newParent) {
986
+ return true;
952
987
  }
953
988
 
954
- const fromPath = `${dir}/${args.from}`;
955
- const toPath = `${dir}/${args.to}`;
956
-
957
- // Check if source file exists
958
- try {
959
- await fs.promises.access(fromPath);
960
- } catch (error) {
961
- throw new JJError('FILE_NOT_FOUND', `Source file not found: ${args.from}`, {
962
- path: args.from,
963
- fullPath: fromPath,
964
- suggestion: 'Check that the file exists',
965
- });
966
- }
989
+ // If only from/to are present, check if they look like change IDs
990
+ if (args.from && args.to && !args.changeId && !args.newParent && !args.paths) {
991
+ // Change IDs are 32-character hex strings
992
+ const changeIdPattern = /^[0-9a-f]{32}$/;
993
+ const fromLooksLikeChangeId = changeIdPattern.test(args.from);
994
+ const toLooksLikeChangeId = changeIdPattern.test(args.to);
967
995
 
968
- // Ensure destination directory exists
969
- const pathParts = args.to.split('/');
970
- if (pathParts.length > 1) {
971
- const dirPath = pathParts.slice(0, -1).join('/');
972
- const fullDirPath = `${dir}/${dirPath}`;
973
- try {
974
- await fs.promises.mkdir(fullDirPath, { recursive: true });
975
- } catch (error) {
976
- throw new JJError('DIRECTORY_CREATE_FAILED',
977
- `Failed to create destination directory: ${dirPath}`, {
978
- directory: dirPath,
979
- originalError: error.message,
996
+ // If both look like change IDs, it's likely a history operation
997
+ // However, we err on the side of file operations to be safe
998
+ // Users should use explicit { changeId, newParent } for clarity
999
+ if (fromLooksLikeChangeId && toLooksLikeChangeId) {
1000
+ // This is ambiguous - throw a helpful error
1001
+ throw new JJError('AMBIGUOUS_OPERATION',
1002
+ 'Ambiguous move operation: arguments look like change IDs', {
1003
+ suggestion: 'Use { changeId, newParent } for history operations or ensure file paths don\'t match change ID pattern',
1004
+ args,
980
1005
  });
981
1006
  }
982
- }
983
1007
 
984
- // Move the file
985
- try {
986
- await fs.promises.rename(fromPath, toPath);
987
- } catch (error) {
988
- throw new JJError('FILE_MOVE_FAILED', `Failed to move file: ${error.message}`, {
989
- from: args.from,
990
- to: args.to,
991
- originalError: error.message,
992
- });
1008
+ return false; // Treat as file operation
993
1009
  }
994
1010
 
995
- // Update working copy tracking
996
- await workingCopy.load();
997
- await workingCopy.untrackFile(args.from);
998
- const stats = await fs.promises.stat(toPath);
999
- await workingCopy.trackFile(args.to, {
1000
- mtime: stats.mtime,
1001
- size: stats.size,
1002
- mode: stats.mode,
1003
- });
1004
-
1005
- // Return useful information about the move operation
1006
- return {
1007
- from: args.from,
1008
- to: args.to,
1009
- size: stats.size,
1010
- mode: stats.mode,
1011
- mtime: stats.mtime,
1012
- };
1011
+ return false;
1013
1012
  },
1014
1013
 
1015
1014
  /**
@@ -1554,7 +1553,8 @@ export async function createJJ(options) {
1554
1553
  * @param {Object} args - Arguments
1555
1554
  * @param {string} [args.revset='all()'] - Revset expression to filter changes
1556
1555
  * @param {number} [args.limit] - Maximum number of changes to return
1557
- * @returns {Promise<Array>} Array of changes
1556
+ * @param {boolean} [args.count=false] - If true, return count instead of changes (v0.36.0)
1557
+ * @returns {Promise<Array|number>} Array of changes or count if args.count is true
1558
1558
  */
1559
1559
  async log(args = {}) {
1560
1560
  await graph.load();
@@ -1563,6 +1563,11 @@ export async function createJJ(options) {
1563
1563
  const revsetExpr = args.revset || 'all()';
1564
1564
  const changeIds = await revset.evaluate(revsetExpr);
1565
1565
 
1566
+ // If count mode, return count early (v0.36.0 feature)
1567
+ if (args.count) {
1568
+ return changeIds.length;
1569
+ }
1570
+
1566
1571
  let changes = [];
1567
1572
  for (const changeId of changeIds) {
1568
1573
  const change = await graph.getChange(changeId);
@@ -2510,7 +2515,7 @@ export async function createJJ(options) {
2510
2515
  * });
2511
2516
  */
2512
2517
  async rebase(args) {
2513
- return await this._moveChange(args);
2518
+ return await this.moveChange(args);
2514
2519
  },
2515
2520
 
2516
2521
  /**
@@ -2583,9 +2588,18 @@ export async function createJJ(options) {
2583
2588
  * @returns {Promise<Object>} The new backout change including changeId, description, and backedOut field
2584
2589
  */
2585
2590
  async backout(args) {
2591
+ // Normalize: accept common parameter aliases
2592
+ if (args && args.change && !args.revision) {
2593
+ args = { ...args, revision: args.change };
2594
+ } else if (args && args.changeId && !args.revision) {
2595
+ args = { ...args, revision: args.changeId };
2596
+ } else if (args && args.target && !args.revision) {
2597
+ args = { ...args, revision: args.target };
2598
+ }
2599
+
2586
2600
  if (!args || !args.revision) {
2587
2601
  throw new JJError('INVALID_ARGUMENT', 'Missing revision argument', {
2588
- suggestion: 'Provide the change ID to back out: { revision: "abc123..." }',
2602
+ suggestion: 'Provide the change ID to back out: { revision: "abc123..." } (or use change/changeId/target)',
2589
2603
  });
2590
2604
  }
2591
2605
 
@@ -3620,6 +3634,15 @@ export async function createJJ(options) {
3620
3634
  * @param {string[]} [args.paths] - Specific paths to include in first split (full implementation planned for future)
3621
3635
  */
3622
3636
  async split(args) {
3637
+ // Normalize: accept common parameter aliases
3638
+ if (args && args.change && !args.changeId) {
3639
+ args = { ...args, changeId: args.change };
3640
+ } else if (args && args.revision && !args.changeId) {
3641
+ args = { ...args, changeId: args.revision };
3642
+ } else if (args && args.target && !args.changeId) {
3643
+ args = { ...args, changeId: args.target };
3644
+ }
3645
+
3623
3646
  // Check for interactive mode (not supported)
3624
3647
  if (args.interactive) {
3625
3648
  throw new JJError(
@@ -5861,21 +5884,13 @@ export async function createJJ(options) {
5861
5884
  });
5862
5885
  }
5863
5886
 
5864
- await userConfig.load();
5865
-
5866
- // Handle nested keys (e.g., 'user.name')
5867
- const parts = key.split('.');
5868
- let value = userConfig.config;
5869
-
5870
- for (const part of parts) {
5871
- if (value && typeof value === 'object') {
5872
- value = value[part];
5873
- } else {
5874
- return null;
5875
- }
5887
+ // Ensure config is loaded at least once
5888
+ if (!userConfig.config) {
5889
+ await userConfig.load();
5876
5890
  }
5877
5891
 
5878
- return value;
5892
+ // Use userConfig.get() to preserve programmatic config
5893
+ return userConfig.get(key);
5879
5894
  },
5880
5895
 
5881
5896
  /**
@@ -5924,6 +5939,87 @@ export async function createJJ(options) {
5924
5939
  await userConfig.load();
5925
5940
  return userConfig.config;
5926
5941
  },
5942
+
5943
+ /**
5944
+ * Reload configuration (including workspace-specific config)
5945
+ * v0.35.0: Loads workspace-config.json if present
5946
+ * v0.36.0: Accepts optional config object for programmatic configuration
5947
+ *
5948
+ * @param {Object} [opts] - Optional config options
5949
+ * @param {Object} [opts.workspace] - Workspace-specific config to merge (highest priority)
5950
+ * @param {Object} [opts.override] - Config object to merge over loaded config
5951
+ * @returns {Promise<void>}
5952
+ *
5953
+ * @example
5954
+ * // Load from files only
5955
+ * await repo.config.load();
5956
+ *
5957
+ * @example
5958
+ * // Programmatic workspace config (no file I/O needed)
5959
+ * await repo.config.load({
5960
+ * workspace: { user: { email: 'test@example.com' } }
5961
+ * });
5962
+ *
5963
+ * @example
5964
+ * // Override global config programmatically
5965
+ * await repo.config.load({
5966
+ * override: { test: { value: 'override' } }
5967
+ * });
5968
+ */
5969
+ async load(opts) {
5970
+ await userConfig.load(opts);
5971
+ },
5972
+ },
5973
+
5974
+ /**
5975
+ * Template helper functions (v0.36.0)
5976
+ *
5977
+ * Utilities for displaying and formatting JJ data
5978
+ */
5979
+ template: {
5980
+ /**
5981
+ * Get files modified in a change
5982
+ *
5983
+ * @param {string} changeId - Change ID
5984
+ * @returns {Promise<string[]>} Array of file paths
5985
+ */
5986
+ async files(changeId) {
5987
+ await graph.load();
5988
+ const change = await graph.getChange(changeId);
5989
+ if (!change || !change.fileSnapshot) {
5990
+ return [];
5991
+ }
5992
+ return Object.keys(change.fileSnapshot);
5993
+ },
5994
+
5995
+ /**
5996
+ * Join array elements with a separator
5997
+ *
5998
+ * @param {string[]} array - Array of strings
5999
+ * @param {string} separator - Separator string
6000
+ * @returns {string} Joined string
6001
+ */
6002
+ join(array, separator) {
6003
+ if (!Array.isArray(array)) {
6004
+ return '';
6005
+ }
6006
+ return array.join(separator);
6007
+ },
6008
+
6009
+ /**
6010
+ * Format path for display
6011
+ *
6012
+ * @param {string} path - File path
6013
+ * @returns {string} Formatted path
6014
+ */
6015
+ format_path(path) {
6016
+ if (!path) {
6017
+ return '';
6018
+ }
6019
+ // Simple formatting: just return the path as-is for now
6020
+ // In real JJ, this might apply colors, truncation, etc.
6021
+ return path;
6022
+ },
5927
6023
  },
5928
6024
 
5929
6025
  /**
@@ -15,6 +15,7 @@ export class BookmarkStore {
15
15
  this.storage = storage;
16
16
  this.local = new Map();
17
17
  this.remote = new Map(); // remote → (bookmark → changeId)
18
+ this.tracking = {}; // bookmark → { remote, ref } tracking info (v0.35.0)
18
19
  }
19
20
 
20
21
  /**
@@ -57,6 +58,7 @@ export class BookmarkStore {
57
58
 
58
59
  this.local.clear();
59
60
  this.remote.clear();
61
+ this.tracking = data.tracked || {}; // Load tracking info (v0.35.0)
60
62
 
61
63
  // Load local bookmarks
62
64
  for (const [name, target] of Object.entries(data.local)) {
@@ -94,7 +96,7 @@ export class BookmarkStore {
94
96
  version: 1,
95
97
  local,
96
98
  remote,
97
- tracked: {},
99
+ tracked: this.tracking || {}, // Save tracking info (v0.35.0)
98
100
  });
99
101
  }
100
102
 
@@ -207,13 +209,23 @@ export class BookmarkStore {
207
209
  async list() {
208
210
  const result = [];
209
211
 
210
- // Add local bookmarks
212
+ // Add local bookmarks with tracking info (v0.35.0)
211
213
  for (const [name, target] of this.local.entries()) {
212
- result.push({
214
+ const bookmark = {
213
215
  name,
214
216
  changeId: target,
215
217
  remote: null,
216
- });
218
+ };
219
+
220
+ // Include tracking info if available
221
+ if (this.tracking && this.tracking[name]) {
222
+ bookmark.tracking = {
223
+ remote: this.tracking[name].remote,
224
+ ref: this.tracking[name].remoteName || name,
225
+ };
226
+ }
227
+
228
+ result.push(bookmark);
217
229
  }
218
230
 
219
231
  // Add remote bookmarks
@@ -129,6 +129,24 @@ export class RevsetEngine {
129
129
  }
130
130
  }
131
131
 
132
+ // v0.36.0: visible() - non-abandoned changes
133
+ if (trimmed === 'visible()') {
134
+ await this.graph.load();
135
+ const all = this.graph.getAll();
136
+ return all
137
+ .filter(c => !c.abandoned)
138
+ .map(c => c.changeId);
139
+ }
140
+
141
+ // v0.36.0: hidden() - abandoned changes
142
+ if (trimmed === 'hidden()') {
143
+ await this.graph.load();
144
+ const all = this.graph.getAll();
145
+ return all
146
+ .filter(c => c.abandoned === true)
147
+ .map(c => c.changeId);
148
+ }
149
+
132
150
  // ancestors(changeId) - all ancestors including the change itself
133
151
  const ancestorsMatch = trimmed.match(/^ancestors\(([0-9a-f]{32})\)$/);
134
152
  if (ancestorsMatch) {
@@ -37,9 +37,15 @@ export class UserConfig {
37
37
  }
38
38
 
39
39
  /**
40
- * Load config from storage
40
+ * Load config from storage and/or programmatic object
41
+ * v0.35.0: Also loads workspace-specific config from workspace-config.json
42
+ * v0.36.0: Accepts optional config object for programmatic configuration
43
+ *
44
+ * @param {Object} [opts] - Optional config options
45
+ * @param {Object} [opts.workspace] - Workspace-specific config to merge (highest priority)
46
+ * @param {Object} [opts.override] - Config object to merge over loaded config
41
47
  */
42
- async load() {
48
+ async load(opts = {}) {
43
49
  try {
44
50
  const data = await this.storage.read('config.json');
45
51
  if (data) {
@@ -49,6 +55,45 @@ export class UserConfig {
49
55
  // No config file yet, use defaults
50
56
  await this.init();
51
57
  }
58
+
59
+ // Merge programmatic override config if provided
60
+ if (opts.override) {
61
+ this.config = this._deepMerge(this.config, opts.override);
62
+ }
63
+
64
+ // Load workspace-specific config from file (v0.35.0)
65
+ try {
66
+ const workspaceData = await this.storage.read('workspace-config.json');
67
+ if (workspaceData) {
68
+ // Merge workspace config over global config (workspace takes precedence)
69
+ this.config = this._deepMerge(this.config, workspaceData);
70
+ }
71
+ } catch (error) {
72
+ // No workspace config, that's fine
73
+ }
74
+
75
+ // Merge programmatic workspace config if provided (highest priority)
76
+ if (opts.workspace) {
77
+ this.config = this._deepMerge(this.config, opts.workspace);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Deep merge two config objects (workspace overrides global)
83
+ * @private
84
+ */
85
+ _deepMerge(target, source) {
86
+ const result = { ...target };
87
+
88
+ for (const key in source) {
89
+ if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
90
+ result[key] = this._deepMerge(target[key] || {}, source[key]);
91
+ } else {
92
+ result[key] = source[key];
93
+ }
94
+ }
95
+
96
+ return result;
52
97
  }
53
98
 
54
99
  /**