isomorphic-jj 1.2.0 → 1.4.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.2.0",
3
+ "version": "1.4.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
  /**
@@ -1085,6 +1084,11 @@ export async function createJJ(options) {
1085
1084
  change.timestamp = new Date().toISOString();
1086
1085
  }
1087
1086
 
1087
+ // Update metadata
1088
+ if (args.metadata !== undefined) {
1089
+ change.metadata = args.metadata;
1090
+ }
1091
+
1088
1092
  // Only snapshot files if describing working copy
1089
1093
  if (isWorkingCopy) {
1090
1094
  // Snapshot current file contents for conflict detection
@@ -1689,10 +1693,11 @@ export async function createJJ(options) {
1689
1693
  * @param {Object} [args={}] - Arguments
1690
1694
  * @param {string} [args.message] - Description for current change
1691
1695
  * @param {Object} [args.author] - Author for current change
1696
+ * @param {Object} [args.metadata] - Metadata to attach to the change
1692
1697
  * @returns {Promise<Object>} The newly created change
1693
1698
  */
1694
1699
  async commit(args = {}) {
1695
- await this.describe({ message: args.message, author: args.author });
1700
+ await this.describe({ message: args.message, author: args.author, metadata: args.metadata });
1696
1701
  return await this.new({ message: args.nextMessage });
1697
1702
  },
1698
1703
 
@@ -2516,7 +2521,7 @@ export async function createJJ(options) {
2516
2521
  * });
2517
2522
  */
2518
2523
  async rebase(args) {
2519
- return await this._moveChange(args);
2524
+ return await this.moveChange(args);
2520
2525
  },
2521
2526
 
2522
2527
  /**
package/src/types.d.ts CHANGED
@@ -76,6 +76,7 @@ export interface Change {
76
76
  description: string;
77
77
  timestamp: Date;
78
78
  fileSnapshot?: Record<string, FileSnapshot>;
79
+ metadata?: Record<string, any>;
79
80
  }
80
81
 
81
82
  /**
@@ -330,6 +331,24 @@ export interface MoveFileArgs {
330
331
  to: string;
331
332
  }
332
333
 
334
+ /**
335
+ * Move change (rebase) arguments
336
+ */
337
+ export interface MoveChangeArgs {
338
+ /** Change ID to move (also accepts 'from' or 'source') */
339
+ changeId?: ChangeID;
340
+ /** Source change ID (alias for changeId) */
341
+ from?: ChangeID;
342
+ /** Source change ID (alias for changeId) */
343
+ source?: ChangeID;
344
+ /** New parent change ID (also accepts 'to' or 'destination') */
345
+ newParent?: ChangeID;
346
+ /** Destination change ID (alias for newParent) */
347
+ to?: ChangeID;
348
+ /** Destination change ID (alias for newParent) */
349
+ destination?: ChangeID;
350
+ }
351
+
333
352
  /**
334
353
  * Remove file arguments
335
354
  */
@@ -460,6 +479,8 @@ export interface UnabandonArgs {
460
479
  export interface LogOptions {
461
480
  revset?: Revset;
462
481
  limit?: number;
482
+ /** If true, return count of commits instead of commit objects */
483
+ count?: boolean;
463
484
  }
464
485
 
465
486
  /**
@@ -856,6 +877,122 @@ export interface MergeResult {
856
877
  conflicts: Conflict[];
857
878
  }
858
879
 
880
+ /**
881
+ * Absorb operation arguments
882
+ */
883
+ export interface AbsorbArgs {
884
+ /** Change to absorb into (optional, defaults to working copy parent) */
885
+ changeId?: ChangeID;
886
+ /** Destination change to absorb into (alias for changeId) */
887
+ destination?: ChangeID;
888
+ /** Dry run mode - don't apply changes */
889
+ dryRun?: boolean;
890
+ /** Specific paths to absorb */
891
+ paths?: string[];
892
+ }
893
+
894
+ /**
895
+ * Absorb operation result
896
+ */
897
+ export interface AbsorbResult {
898
+ /** Changes that were absorbed */
899
+ absorbed: Array<{
900
+ path: string;
901
+ hunks: number;
902
+ destination: ChangeID;
903
+ }>;
904
+ /** Changes affected by the absorb */
905
+ affectedChanges: ChangeID[];
906
+ /** Changes that would be absorbed (dry run mode) */
907
+ wouldAbsorb?: Array<{
908
+ path: string;
909
+ hunks: number;
910
+ destination: ChangeID;
911
+ }>;
912
+ }
913
+
914
+ /**
915
+ * Bisect start arguments
916
+ */
917
+ export interface BisectStartArgs {
918
+ /** Good change ID */
919
+ good: ChangeID;
920
+ /** Bad change ID */
921
+ bad: ChangeID;
922
+ }
923
+
924
+ /**
925
+ * Bisect state
926
+ */
927
+ export interface BisectState {
928
+ /** Whether bisect is active */
929
+ active: boolean;
930
+ /** Good change */
931
+ good?: ChangeID;
932
+ /** Bad change */
933
+ bad?: ChangeID;
934
+ /** Current change being tested */
935
+ current?: ChangeID;
936
+ /** Remaining changes to test */
937
+ remaining?: number;
938
+ }
939
+
940
+ /**
941
+ * Sparse checkout set arguments
942
+ */
943
+ export interface SparseSetArgs {
944
+ /** Patterns to set (replaces existing patterns) */
945
+ patterns: string[];
946
+ }
947
+
948
+ /**
949
+ * Sparse checkout add arguments
950
+ */
951
+ export interface SparseAddArgs {
952
+ /** Patterns to add to existing sparse checkout */
953
+ patterns: string[];
954
+ }
955
+
956
+ /**
957
+ * Sparse checkout remove arguments
958
+ */
959
+ export interface SparseRemoveArgs {
960
+ /** Patterns to remove from sparse checkout */
961
+ patterns: string[];
962
+ }
963
+
964
+ /**
965
+ * Merge driver for custom file merging
966
+ */
967
+ export interface MergeDriver {
968
+ /** Driver name */
969
+ name: string;
970
+ /** Detect if this driver should handle a file */
971
+ detect(path: string, content: string): boolean;
972
+ /** Merge three versions of a file */
973
+ merge(args: {
974
+ path: string;
975
+ base: string;
976
+ ours: string;
977
+ theirs: string;
978
+ }): {
979
+ merged: string;
980
+ conflicts?: Array<{ line: number; message: string }>;
981
+ };
982
+ }
983
+
984
+ /**
985
+ * Merge driver registry
986
+ */
987
+ export interface MergeDriverRegistry {
988
+ /** Register a custom merge driver */
989
+ register(driver: MergeDriver): void;
990
+ /** Get driver for a file path */
991
+ getDriver(path: string, content: string): MergeDriver | null;
992
+ /** List all registered drivers */
993
+ list(): MergeDriver[];
994
+ }
995
+
859
996
  // ============================================================================
860
997
  // Main JJ Repository Interface
861
998
  // ============================================================================
@@ -875,6 +1012,7 @@ export interface JJ {
875
1012
  revset: any;
876
1013
  gitBackend?: any;
877
1014
  backgroundOps?: any;
1015
+ mergeDrivers: MergeDriverRegistry;
878
1016
 
879
1017
  // Repository lifecycle
880
1018
  init(opts?: InitOptions): Promise<void>;
@@ -883,7 +1021,12 @@ export interface JJ {
883
1021
  write(args: WriteArgs): Promise<WriteResult>;
884
1022
  read(args: ReadArgs): Promise<string | Uint8Array>;
885
1023
  cat(args: ReadArgs): Promise<string | Uint8Array>;
886
- move(args: MoveFileArgs): Promise<MoveResult>;
1024
+ moveFile(args: MoveFileArgs): Promise<MoveResult>;
1025
+ moveChange(args: MoveChangeArgs): Promise<Change>;
1026
+ /**
1027
+ * @deprecated Use moveFile() for files or moveChange() for history operations
1028
+ */
1029
+ move(args: MoveFileArgs | MoveChangeArgs): Promise<MoveResult | Change>;
887
1030
  remove(args: RemoveArgs): Promise<RemoveResult>;
888
1031
  listFiles(args?: ListFilesArgs): Promise<string[]>;
889
1032
  readStream(args: ReadStreamArgs): Promise<any>;
@@ -904,9 +1047,11 @@ export interface JJ {
904
1047
  rebase(args: RebaseArgs): Promise<Change>;
905
1048
  abandon(args: AbandonArgs): Promise<void>;
906
1049
  unabandon(args: UnabandonArgs): Promise<void>;
1050
+ absorb(args?: AbsorbArgs): Promise<AbsorbResult>;
907
1051
 
908
1052
  // Queries
909
- log(opts?: LogOptions): Promise<LogEntry[]>;
1053
+ log(opts?: LogOptions & { count?: false }): Promise<LogEntry[]>;
1054
+ log(opts: LogOptions & { count: true }): Promise<number>;
910
1055
  show(args: ShowArgs): Promise<Change>;
911
1056
  obslog(opts?: ObslogOptions): Promise<Operation[]>;
912
1057
 
@@ -1104,6 +1249,25 @@ export interface JJ {
1104
1249
  }>;
1105
1250
  parent: ChangeID;
1106
1251
  }>;
1252
+
1253
+ // Bisect operations
1254
+ bisect: {
1255
+ start(args: BisectStartArgs): Promise<void>;
1256
+ good(): Promise<void>;
1257
+ bad(): Promise<void>;
1258
+ status(): Promise<BisectState>;
1259
+ reset(): Promise<void>;
1260
+ };
1261
+
1262
+ // Sparse checkout
1263
+ sparse: {
1264
+ list(): Promise<string[]>;
1265
+ set(args: SparseSetArgs): Promise<void>;
1266
+ add(args: SparseAddArgs): Promise<void>;
1267
+ remove(args: SparseRemoveArgs): Promise<void>;
1268
+ reset(): Promise<void>;
1269
+ clear(): Promise<void>;
1270
+ };
1107
1271
  }
1108
1272
 
1109
1273
  // ============================================================================