gitnexus 1.6.13-rc.68 → 1.6.13-rc.69

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.
@@ -4,14 +4,16 @@
4
4
  * against a real group, which is how `STALE (-1 commits behind)` went unnoticed
5
5
  * (#3256).
6
6
  */
7
+ import type { StalenessStatus } from '../core/staleness-status.js';
7
8
  /** The fields of a `groupStatus` repo row the index column reads. */
8
9
  export interface GroupRepoIndexRow {
9
10
  indexStale: boolean;
10
11
  commitsBehind?: number;
12
+ status?: StalenessStatus;
11
13
  }
12
14
  /**
13
- * The index column of a `group status` row. The output is unchanged except
14
- * for one case: a count that is not a real count renders as `?`.
15
+ * The index column of a `group status` row. Diverged indexes differ from HEAD
16
+ * without a forward commit count; an unavailable count renders as `?`.
15
17
  *
16
18
  * `group/service.ts` has always reported a repo with no recorded commit as
17
19
  * `{ indexStale: true, commitsBehind: -1 }`. The previous `?? '?'` fallback
@@ -1,12 +1,6 @@
1
1
  /**
2
- * Rendering for `gitnexus group status` rows, kept out of the Commander action
3
- * so it can be tested. Inline, the cell was only reachable by booting a backend
4
- * against a real group, which is how `STALE (-1 commits behind)` went unnoticed
5
- * (#3256).
6
- */
7
- /**
8
- * The index column of a `group status` row. The output is unchanged except
9
- * for one case: a count that is not a real count renders as `?`.
2
+ * The index column of a `group status` row. Diverged indexes differ from HEAD
3
+ * without a forward commit count; an unavailable count renders as `?`.
10
4
  *
11
5
  * `group/service.ts` has always reported a repo with no recorded commit as
12
6
  * `{ indexStale: true, commitsBehind: -1 }`. The previous `?? '?'` fallback
@@ -15,6 +9,8 @@
15
9
  export const formatIndexStatusCell = (row) => {
16
10
  if (!row.indexStale)
17
11
  return 'OK ';
12
+ if (row.status === 'diverged')
13
+ return 'STALE (index differs from HEAD)';
18
14
  const n = row.commitsBehind;
19
15
  const count = typeof n === 'number' && n >= 0 ? String(n) : '?';
20
16
  return `STALE (${count} commits behind)`;
@@ -9,8 +9,8 @@ import { readRegistry } from '../storage/repo-manager.js';
9
9
  import { findGitRootByDotGit, getCurrentCommit, getRemoteUrl } from '../storage/git.js';
10
10
  const execFileAsync = promisify(execFile);
11
11
  /**
12
- * Ceiling for one `git rev-list` staleness probe. Generous for the local
13
- * history walk this is, and short enough that an unresponsive working tree
12
+ * Per-command ceiling for async `git rev-list` and both HEAD probes.
13
+ * Generous for local Git queries, and short enough that an unresponsive working tree
14
14
  * degrades to "not stale" quickly rather than holding a request open.
15
15
  */
16
16
  const STALENESS_TIMEOUT_MS = 5_000;
@@ -20,10 +20,20 @@ const behindHint = (n) => `⚠️ Index is ${n} commit${n > 1 ? 's' : ''} behind
20
20
  // commit left history — that is the usual cause (a pruned `fetch --depth 1`),
21
21
  // but any other `rev-list` failure lands here too, so the cause is hedged.
22
22
  const DIVERGED_HINT = "⚠️ Index is not at HEAD and the commit gap could not be counted — the recorded commit may no longer be in this clone's history. Run analyze tool to update.";
23
+ // `rev-list --count lastCommit..HEAD` answering 0 does NOT mean "HEAD is the
24
+ // indexed commit" — it means "HEAD has no commits lastCommit lacks", which is
25
+ // also true when HEAD is an *ancestor* of lastCommit (the working tree checked
26
+ // out an older commit than the one indexed, or switched to a line of history
27
+ // behind it). That read a rollback as `current` until this hint existed.
28
+ const REGRESSED_HINT = '⚠️ Index is not at HEAD — the indexed commit is not reachable from the checked-out commit (the working tree may have checked out an older commit, or a different line of history). Run analyze tool to update.';
23
29
  const unknown = () => ({ isStale: false, commitsBehind: 0, status: 'unknown' });
24
- const fromCount = (commitsBehind) => commitsBehind > 0
25
- ? { isStale: true, commitsBehind, hint: behindHint(commitsBehind), status: 'behind' }
26
- : { isStale: false, commitsBehind: 0, status: 'current' };
30
+ // Called only once a positive HEAD-only count is in hand.
31
+ const behind = (commitsBehind) => ({
32
+ isStale: true,
33
+ commitsBehind,
34
+ hint: behindHint(commitsBehind),
35
+ status: 'behind',
36
+ });
27
37
  /**
28
38
  * `rev-list` could not answer. Asking for HEAD alone needs no history walk and
29
39
  * still separates all three answers: HEAD unreadable is `unknown`, HEAD past the
@@ -38,6 +48,28 @@ const fromHead = (head, lastCommit) => {
38
48
  return { isStale: false, commitsBehind: 0, status: 'current' };
39
49
  return { isStale: false, commitsBehind: 0, hint: DIVERGED_HINT, status: 'diverged' };
40
50
  };
51
+ /**
52
+ * `rev-list --left-right --count lastCommit...HEAD` measures both sides in
53
+ * one process, so a later HEAD change cannot mix two snapshots. The left
54
+ * count identifies a rollback even when the HEAD-only (right) count is 0.
55
+ * Positive right counts keep the existing `behind` behavior, including when
56
+ * both sides have commits (divergent branches or a re-shallowed clone).
57
+ */
58
+ const fromCounts = (output) => {
59
+ const counts = /^(\d+)\s+(\d+)$/.exec(output.trim());
60
+ if (!counts)
61
+ return unknown();
62
+ const indexedOnly = Number(counts[1]);
63
+ const headOnly = Number(counts[2]);
64
+ if (!Number.isSafeInteger(indexedOnly) || !Number.isSafeInteger(headOnly))
65
+ return unknown();
66
+ if (headOnly > 0)
67
+ return behind(headOnly);
68
+ if (indexedOnly > 0) {
69
+ return { isStale: true, commitsBehind: 0, hint: REGRESSED_HINT, status: 'diverged' };
70
+ }
71
+ return { isStale: false, commitsBehind: 0, status: 'current' };
72
+ };
41
73
  const readHeadSync = (repoPath) => {
42
74
  try {
43
75
  return (execFileSync('git', ['rev-parse', 'HEAD'], {
@@ -45,6 +77,7 @@ const readHeadSync = (repoPath) => {
45
77
  encoding: 'utf-8',
46
78
  stdio: ['pipe', 'pipe', 'pipe'],
47
79
  windowsHide: true,
80
+ timeout: STALENESS_TIMEOUT_MS,
48
81
  }).trim() || null);
49
82
  }
50
83
  catch {
@@ -73,13 +106,13 @@ export function checkStaleness(repoPath, lastCommit) {
73
106
  if (!lastCommit)
74
107
  return unknown();
75
108
  try {
76
- const result = execFileSync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], {
109
+ const result = execFileSync('git', ['rev-list', '--left-right', '--count', `${lastCommit}...HEAD`], {
77
110
  cwd: repoPath,
78
111
  encoding: 'utf-8',
79
112
  stdio: ['pipe', 'pipe', 'pipe'],
80
113
  windowsHide: true,
81
- }).trim();
82
- return fromCount(parseInt(result, 10) || 0);
114
+ });
115
+ return fromCounts(result);
83
116
  }
84
117
  catch {
85
118
  return fromHead(readHeadSync(repoPath), lastCommit);
@@ -96,7 +129,7 @@ export async function checkStalenessAsync(repoPath, lastCommit) {
96
129
  try {
97
130
  // Note: promisified execFile captures stdout/stderr by default (no stdio option needed,
98
131
  // unlike the sync variant which requires explicit stdio: ['pipe','pipe','pipe']).
99
- const { stdout } = await execFileAsync('git', ['rev-list', '--count', `${lastCommit}..HEAD`], {
132
+ const { stdout } = await execFileAsync('git', ['rev-list', '--left-right', '--count', `${lastCommit}...HEAD`], {
100
133
  cwd: repoPath,
101
134
  encoding: 'utf-8',
102
135
  windowsHide: true,
@@ -109,7 +142,7 @@ export async function checkStalenessAsync(repoPath, lastCommit) {
109
142
  // below reports it as `unknown` — still the fail-closed `isStale: false`.
110
143
  timeout: STALENESS_TIMEOUT_MS,
111
144
  });
112
- return fromCount(parseInt(stdout.trim(), 10) || 0);
145
+ return fromCounts(stdout);
113
146
  }
114
147
  catch (err) {
115
148
  // A rev-list that timed out means the working tree is not answering. Asking
@@ -17,21 +17,35 @@
17
17
  * provably stale index indistinguishable from a fresh one. `status` is the
18
18
  * additive channel that separates them for a caller that wants to act on it:
19
19
  *
20
- * - `current` — the index is at HEAD: `rev-list` answered 0, or it could not
21
- * answer but HEAD alone resolved to the indexed commit.
22
- * - `behind` — `rev-list` answered N > 0; `commitsBehind` is N.
23
- * - `diverged` — `rev-list` could not answer, but HEAD resolved and is not the
24
- * indexed commit. The index is provably not at HEAD; only the count is
25
- * unknown. A branch-pinned `serve` clone reaches this once git prunes the
26
- * commit a failed re-index left behind — the pinned update is a
27
- * `fetch --depth 1`, which orphans it — and a rewritten history reaches it
28
- * directly. It is the rule the Claude hook already applies:
29
- * HEAD !== lastCommit.
30
- * - `unknown` — HEAD could not be resolved at all: not a git repository, git
31
- * timed out, or no commit was recorded.
20
+ * The successful probe is `rev-list --left-right --count lastCommit...HEAD`:
21
+ * the left count is indexed-only commits, and the right is HEAD-only commits.
22
+ * Both counts come from one HEAD snapshot, without a follow-up process.
32
23
  *
33
- * `isStale` and `commitsBehind` keep their historical values in every case, so
34
- * no existing consumer changes behaviour unless it reads `status`.
24
+ * - `current` — both counts are 0, or `rev-list` could not answer but a
25
+ * fallback `rev-parse HEAD` resolved to the indexed commit.
26
+ * - `behind` — the right count is N > 0; `commitsBehind` is N, including
27
+ * when the left count is positive too (divergent or shallow history).
28
+ * - `diverged` — the index is provably not at HEAD, reached two different ways:
29
+ * - The left count is positive and the right is 0: HEAD is an ancestor
30
+ * of the indexed commit (#3127). The working tree checked out an older
31
+ * commit than the one indexed, or a release branch behind the indexed
32
+ * tip. The mismatch is established: `isStale` is `true` and
33
+ * `commitsBehind` stays 0 (there is no forward count to report).
34
+ * - `rev-list` could not answer at all, but HEAD resolved and is not the
35
+ * indexed commit: only the count is unknown. A branch-pinned `serve`
36
+ * clone reaches this once git prunes the commit a failed re-index left
37
+ * behind — the pinned update is a `fetch --depth 1`, which orphans it —
38
+ * and a rewritten history reaches it directly. This arm keeps the
39
+ * historical fail-open `isStale: false` (see below).
40
+ * - `unknown` — the probe could not establish the relationship: no readable
41
+ * HEAD, a timeout, no recorded commit, or malformed count output.
42
+ *
43
+ * `isStale` and `commitsBehind` keep their historical fail-open values
44
+ * (`false` / `0`) whenever the check could not fully answer — every `unknown`,
45
+ * and the `rev-list`-failure arm of `diverged` — so no existing consumer
46
+ * changes behaviour there unless it reads `status`. The other arm of
47
+ * `diverged` (the confirmed rollback) is a successful, computed
48
+ * answer rather than a failure, so `isStale` reflects it (`true`) instead.
35
49
  */
36
50
  export type StalenessStatus = 'current' | 'behind' | 'diverged' | 'unknown';
37
51
  export interface StalenessInfo {
@@ -281,7 +281,7 @@ async function getContextResource(backend, repoName) {
281
281
  const incompleteReasons = getIndexIncompleteReasons(freshMeta);
282
282
  // Check staleness using the current on-disk lastCommit (not the cached handle)
283
283
  const repoPath = repo.repoPath;
284
- const lastCommit = freshMeta?.lastCommit ?? repo.lastCommit ?? 'HEAD';
284
+ const lastCommit = freshMeta?.lastCommit ?? repo.lastCommit ?? '';
285
285
  const staleness = repoPath
286
286
  ? checkStaleness(repoPath, lastCommit)
287
287
  : { isStale: false, commitsBehind: 0 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitnexus",
3
- "version": "1.6.13-rc.68",
3
+ "version": "1.6.13-rc.69",
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",