gitnexus 1.6.12-rc.27 → 1.6.12-rc.29

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.
@@ -25,7 +25,7 @@ import { isWalCorruptionError, WAL_RECOVERY_SUGGESTION } from '../../core/lbug/l
25
25
  // at MCP server startup — crashes on unsupported Node ABI versions (#89)
26
26
  // git utilities available if needed
27
27
  // import { isGitRepo, getCurrentCommit, getGitRoot } from '../../storage/git.js';
28
- import { parseDiffHunks, coalesceHunksByPath, hunksOverlapRange, findGitRootByDotGit, getCanonicalRepoRoot, getGitRoot, } from '../../storage/git.js';
28
+ import { parseDiffHunksResult, coalesceHunksByPath, hunksOverlapRange, findGitRootByDotGit, getCanonicalRepoRoot, getGitRoot, } from '../../storage/git.js';
29
29
  import { realpathSync } from 'fs';
30
30
  import { listRegisteredRepos, cleanupOldKuzuFiles, canonicalizePath, getStoragePaths, loadMeta, RegistryAmbiguousTargetError, } from '../../storage/repo-manager.js';
31
31
  import { GroupService, } from '../../core/group/service.js';
@@ -795,6 +795,12 @@ export function buildDetectChangesDiffArgs(scope, baseRef) {
795
795
  // not `--default-prefix`, which needs git >= 2.42. `--no-ext-diff` stops a
796
796
  // configured external diff driver from replacing the unified output we parse.
797
797
  const args = [
798
+ // Before the subcommand: default core.quotePath C-quotes non-ASCII so
799
+ // `diff --git` / `+++` tokens no longer match the unquoted `a/` `b/` forms
800
+ // the parser also accepts. The parser still decodes quoted tokens; this
801
+ // pin keeps production git from emitting them.
802
+ '-c',
803
+ 'core.quotePath=false',
798
804
  'diff',
799
805
  '--ignore-cr-at-eol',
800
806
  '--no-ext-diff',
@@ -4464,10 +4470,10 @@ export class LocalBackend {
4464
4470
  catch (err) {
4465
4471
  return { error: `Git diff failed: ${err.message}` };
4466
4472
  }
4467
- const fileDiffs = parseDiffHunks(diffOutput);
4473
+ const { files: fileDiffs, unparsedGitHeaders } = parseDiffHunksResult(diffOutput);
4468
4474
  if (fileDiffs.length === 0) {
4469
- // Git printed a diff but none of it parsed: the `+++ b/` headers were not
4470
- // where `parseDiffHunks` looks. That is a PARSE failure, not a clean tree,
4475
+ // Git printed a diff but none of it parsed: no `diff --git` / `+++`
4476
+ // file header was recognised. That is a PARSE failure, not a clean tree,
4471
4477
  // and the clean branch below would report it to the pre-commit gate as
4472
4478
  // `risk_level:'none'`, no `partial`, exit 0 — a false all-clear (#2915).
4473
4479
  const parseFailed = diffOutput.trim().length > 0;
@@ -4499,7 +4505,9 @@ export class LocalBackend {
4499
4505
  const changedSymbols = new Map();
4500
4506
  // Set if a swallowed graph query fails below — surfaces `partial:true` so a
4501
4507
  // degraded run cannot report a false-clean `risk_level:'low'` (#2283).
4502
- let queryDegraded = false;
4508
+ // An unparsed `diff --git` is the same class: later hunks must not make
4509
+ // the gate look complete.
4510
+ let queryDegraded = unparsedGitHeaders > 0;
4503
4511
  // Hunks arrive grouped per path and already in the graph's 0-based line
4504
4512
  // space, so every comparison below is base-neutral (#2377).
4505
4513
  const hunksByPath = coalesceHunksByPath(fileDiffs);
@@ -268,10 +268,20 @@ export interface FileDiff {
268
268
  filePath: string;
269
269
  hunks: DiffHunk[];
270
270
  }
271
+ /** `parseDiffHunks` plus how many `diff --git` headers could not be decoded. */
272
+ export interface DiffHunkParseResult {
273
+ files: FileDiff[];
274
+ unparsedGitHeaders: number;
275
+ }
271
276
  /**
272
277
  * Parse unified diff output (with -U0) into per-file hunk ranges.
273
278
  * Extracts the new-file line ranges from @@ hunk headers.
274
279
  *
280
+ * The `diff --git` header is also retained as a file entry. This matters for
281
+ * binary, rename-only, and mode-only changes, which have no `+++ b/` header.
282
+ * Such entries intentionally have no hunks: callers can count the changed
283
+ * path without pretending that a symbol line range was touched.
284
+ *
275
285
  * A pure deletion adds no new lines, and unified diff spells that empty range
276
286
  * as the line BEFORE it: `@@ -4,2 +3,0 @@` removed old lines 4–5 from between
277
287
  * new lines 3 and 4 (git emits `+0,0` when the deletion is at the head of the
@@ -287,6 +297,13 @@ export interface FileDiff {
287
297
  * gap — the widening {@link coalesceHunks} is careful never to do.
288
298
  */
289
299
  export declare function parseDiffHunks(diffOutput: string): FileDiff[];
300
+ /**
301
+ * Same as {@link parseDiffHunks}, plus a count of `diff --git` lines that
302
+ * could not be decoded. `detect_changes` uses the count to fail closed
303
+ * (`partial` + `risk_level:'unknown'`) instead of attaching later hunks to a
304
+ * previous file.
305
+ */
306
+ export declare function parseDiffHunksResult(diffOutput: string): DiffHunkParseResult;
290
307
  /**
291
308
  * Merge a file's hunks into sorted, non-touching ranges.
292
309
  *
@@ -696,10 +696,183 @@ export const parseRepoNameFromUrl = (url) => {
696
696
  export const getInferredRepoName = (repoPath) => {
697
697
  return parseRepoNameFromUrl(getRemoteOriginUrl(repoPath));
698
698
  };
699
+ const DIFF_GIT_PREFIX = 'diff --git ';
700
+ /**
701
+ * Decode one Git C-quoted token (`"a/\\344\\270\\255.png"`). Returns
702
+ * `undefined` when the quotes are unbalanced or a trailing escape is bare.
703
+ */
704
+ function unquoteCStyleGitToken(quoted) {
705
+ if (quoted.length < 2 || quoted[0] !== '"' || quoted[quoted.length - 1] !== '"') {
706
+ return undefined;
707
+ }
708
+ // Git C-quotes are byte-oriented: non-ASCII is `\nnn` octal of the UTF-8
709
+ // code units, not JS UTF-16 characters.
710
+ const bytes = [];
711
+ const pushChar = (ch) => {
712
+ const code = ch.charCodeAt(0);
713
+ if (code < 0x80)
714
+ bytes.push(code);
715
+ else
716
+ bytes.push(...new TextEncoder().encode(ch));
717
+ };
718
+ for (let i = 1; i < quoted.length - 1; i++) {
719
+ const ch = quoted[i];
720
+ if (ch !== '\\') {
721
+ pushChar(ch);
722
+ continue;
723
+ }
724
+ const next = quoted[++i];
725
+ if (next === undefined)
726
+ return undefined;
727
+ switch (next) {
728
+ case '\\':
729
+ case '"':
730
+ pushChar(next);
731
+ break;
732
+ case 'n':
733
+ bytes.push(0x0a);
734
+ break;
735
+ case 't':
736
+ bytes.push(0x09);
737
+ break;
738
+ case 'r':
739
+ bytes.push(0x0d);
740
+ break;
741
+ case 'a':
742
+ bytes.push(0x07);
743
+ break;
744
+ case 'b':
745
+ bytes.push(0x08);
746
+ break;
747
+ case 'v':
748
+ bytes.push(0x0b);
749
+ break;
750
+ case 'f':
751
+ bytes.push(0x0c);
752
+ break;
753
+ default: {
754
+ if (next < '0' || next > '7') {
755
+ pushChar(next);
756
+ break;
757
+ }
758
+ let oct = next;
759
+ while (oct.length < 3 && i + 1 < quoted.length - 1) {
760
+ const digit = quoted[i + 1];
761
+ if (digit < '0' || digit > '7')
762
+ break;
763
+ oct += digit;
764
+ i++;
765
+ }
766
+ bytes.push(parseInt(oct, 8));
767
+ }
768
+ }
769
+ }
770
+ return new TextDecoder('utf-8').decode(Uint8Array.from(bytes));
771
+ }
772
+ function takeCQuotedToken(source, start) {
773
+ if (source[start] !== '"')
774
+ return undefined;
775
+ for (let i = start + 1; i < source.length; i++) {
776
+ if (source[i] === '\\') {
777
+ i++;
778
+ continue;
779
+ }
780
+ if (source[i] === '"')
781
+ return { token: source.slice(start, i + 1), end: i + 1 };
782
+ }
783
+ return undefined;
784
+ }
785
+ function stripGitDstPrefix(raw) {
786
+ return raw.startsWith('b/') ? raw.slice(2) : undefined;
787
+ }
788
+ /** Unified-diff paths end at the first TAB (timestamp / empty terminator). */
789
+ function stripUnifiedDiffTab(pathWithOptionalTab) {
790
+ const tab = pathWithOptionalTab.indexOf('\t');
791
+ return tab === -1 ? pathWithOptionalTab : pathWithOptionalTab.slice(0, tab);
792
+ }
793
+ function decodeGitPathToken(raw) {
794
+ const trimmed = stripUnifiedDiffTab(raw);
795
+ if (trimmed.startsWith('"'))
796
+ return unquoteCStyleGitToken(trimmed);
797
+ return trimmed;
798
+ }
799
+ /**
800
+ * Destination path from `diff --git a/<src> b/<dst>`.
801
+ *
802
+ * Same-path headers recover `name` from `a/${name} b/${name}` so a dest that
803
+ * itself contains ` b/` is not split at the last occurrence. C-quoted tokens
804
+ * (default `core.quotePath`) are decoded. Renames that the greedy split would
805
+ * mis-parse stay a best-effort dest; `rename to` / `+++` correct them.
806
+ */
807
+ function takeGitHeaderPathToken(source, start) {
808
+ if (start >= source.length)
809
+ return undefined;
810
+ if (source[start] === '"') {
811
+ const tok = takeCQuotedToken(source, start);
812
+ if (!tok)
813
+ return undefined;
814
+ const path = unquoteCStyleGitToken(tok.token);
815
+ if (path === undefined)
816
+ return undefined;
817
+ return { path, end: tok.end };
818
+ }
819
+ if (source.startsWith('b/', start)) {
820
+ return { path: stripUnifiedDiffTab(source.slice(start)), end: source.length };
821
+ }
822
+ if (source.startsWith('a/', start)) {
823
+ for (let i = start + 2; i < source.length; i++) {
824
+ if (source.startsWith(' b/', i) || source.startsWith(' "', i)) {
825
+ return { path: source.slice(start, i), end: i };
826
+ }
827
+ }
828
+ }
829
+ return undefined;
830
+ }
831
+ function filePathFromGitHeader(line) {
832
+ if (!line.startsWith(DIFF_GIT_PREFIX))
833
+ return undefined;
834
+ const rest = line.slice(DIFF_GIT_PREFIX.length);
835
+ if (rest.startsWith('a/')) {
836
+ for (let i = 2; i < rest.length; i++) {
837
+ if (!rest.startsWith(' b/', i))
838
+ continue;
839
+ const nameA = rest.slice(2, i);
840
+ const nameB = rest.slice(i + 3);
841
+ if (nameA.length > 0 && nameA === nameB)
842
+ return nameA;
843
+ }
844
+ }
845
+ const src = takeGitHeaderPathToken(rest, 0);
846
+ if (!src)
847
+ return undefined;
848
+ let i = src.end;
849
+ while (rest[i] === ' ')
850
+ i++;
851
+ const dest = takeGitHeaderPathToken(rest, i);
852
+ return dest ? stripGitDstPrefix(dest.path) : undefined;
853
+ }
854
+ function pathFromPlusPlusPlus(line) {
855
+ if (!line.startsWith('+++ '))
856
+ return undefined;
857
+ const raw = decodeGitPathToken(line.slice(4));
858
+ if (!raw || raw === '/dev/null')
859
+ return undefined;
860
+ return stripGitDstPrefix(raw);
861
+ }
862
+ function pathFromRenameTo(line) {
863
+ if (!line.startsWith('rename to '))
864
+ return undefined;
865
+ return decodeGitPathToken(line.slice('rename to '.length));
866
+ }
699
867
  /**
700
868
  * Parse unified diff output (with -U0) into per-file hunk ranges.
701
869
  * Extracts the new-file line ranges from @@ hunk headers.
702
870
  *
871
+ * The `diff --git` header is also retained as a file entry. This matters for
872
+ * binary, rename-only, and mode-only changes, which have no `+++ b/` header.
873
+ * Such entries intentionally have no hunks: callers can count the changed
874
+ * path without pretending that a symbol line range was touched.
875
+ *
703
876
  * A pure deletion adds no new lines, and unified diff spells that empty range
704
877
  * as the line BEFORE it: `@@ -4,2 +3,0 @@` removed old lines 4–5 from between
705
878
  * new lines 3 and 4 (git emits `+0,0` when the deletion is at the head of the
@@ -715,14 +888,58 @@ export const getInferredRepoName = (repoPath) => {
715
888
  * gap — the widening {@link coalesceHunks} is careful never to do.
716
889
  */
717
890
  export function parseDiffHunks(diffOutput) {
891
+ return parseDiffHunksResult(diffOutput).files;
892
+ }
893
+ /**
894
+ * Same as {@link parseDiffHunks}, plus a count of `diff --git` lines that
895
+ * could not be decoded. `detect_changes` uses the count to fail closed
896
+ * (`partial` + `risk_level:'unknown'`) instead of attaching later hunks to a
897
+ * previous file.
898
+ */
899
+ export function parseDiffHunksResult(diffOutput) {
718
900
  const files = [];
719
901
  let current = null;
902
+ let unparsedGitHeaders = 0;
903
+ // `+++` after the first `@@` of a file is hunk body (`+` plus source text
904
+ // that itself starts `++ …`), not another file header.
905
+ let inHunk = false;
720
906
  for (const line of diffOutput.split('\n')) {
721
- if (line.startsWith('+++ b/')) {
722
- current = { filePath: line.slice(6), hunks: [] };
723
- files.push(current);
907
+ if (line.startsWith(DIFF_GIT_PREFIX)) {
908
+ // Drop the previous file first: an unparsed header must not leave
909
+ // `current` live for a later `@@` / quoted `+++` to steal.
910
+ current = null;
911
+ inHunk = false;
912
+ const filePath = filePathFromGitHeader(line);
913
+ if (filePath) {
914
+ current = { filePath, hunks: [] };
915
+ files.push(current);
916
+ }
917
+ else {
918
+ unparsedGitHeaders++;
919
+ }
920
+ }
921
+ else if (line.startsWith('rename to ')) {
922
+ const filePath = pathFromRenameTo(line);
923
+ if (!filePath)
924
+ continue;
925
+ if (current)
926
+ current.filePath = filePath;
927
+ else {
928
+ current = { filePath, hunks: [] };
929
+ files.push(current);
930
+ }
931
+ }
932
+ else if (!inHunk && line.startsWith('+++ ')) {
933
+ const filePath = pathFromPlusPlusPlus(line);
934
+ if (!filePath)
935
+ continue;
936
+ if (!current || current.filePath !== filePath) {
937
+ current = { filePath, hunks: [] };
938
+ files.push(current);
939
+ }
724
940
  }
725
941
  else if (line.startsWith('@@') && current) {
942
+ inHunk = true;
726
943
  const match = line.match(/@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
727
944
  if (match) {
728
945
  const start = parseInt(match[1], 10);
@@ -739,7 +956,7 @@ export function parseDiffHunks(diffOutput) {
739
956
  }
740
957
  }
741
958
  }
742
- return files;
959
+ return { files, unparsedGitHeaders };
743
960
  }
744
961
  /**
745
962
  * Merge a file's hunks into sorted, non-touching ranges.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.12-rc.27",
3
+ "version": "1.6.12-rc.29",
4
4
  "description": "Graph-powered code intelligence for AI agents. Index any codebase, query via MCP or CLI.",
5
5
  "author": "Abhigyan Patwari",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",