Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 2 additions & 1 deletion docs/architecture/context-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ large repository, with generous p95 budgets enforced in CI.
|---|---|
| `project.package` | the nearest declarative manifest up to the repository root (`package.json`, `Cargo.toml`, `pyproject.toml`, `go.mod`, `deno.json`, `composer.json`, `pubspec.yaml`, `pom.xml`, `Project.toml`, `setup.cfg`) and lockfile names |
| `runtime.node`, `runtime.python`, `runtime.go`, `runtime.rust`, `runtime.java` | version pins (`.nvmrc`, `.python-version`, `go.mod`, `rust-toolchain.toml`, `.java-version`, `.tool-versions`, `mise.toml`, …), virtualenv/conda metadata, rustup settings, and the version of the executable the shell would run **from its install layout** (`node_version.h`, `GOROOT/VERSION`, the JDK `release` file, a versioned interpreter path) — never by running it |
| `vcs.hg`, `vcs.jj` | the nearest `.hg` or `.jj` directory found upward from the working directory (stopping at home when below it): `.hg/branch` and `.hg/bookmarks.current`; whether `.jj/repo` is the store or a secondary workspace's pointer and whether Git is colocated. `hg` and `jj` are never run, hgrc/hooks and jj's binary operation log are never read |
| `env.toolVersions` | the `[tools]` table of `mise.toml` or `.tool-versions` lines |
| `env.direnv` | whether `.envrc` exists (never its content) and what direnv itself recorded in the shell (`DIRENV_FILE`) |
| `infra.kubernetes`, `infra.docker` | `current-context` and contexts of KUBECONFIG files (users and `exec` entries are never read); the Docker CLI's current context |
Expand Down Expand Up @@ -125,7 +126,7 @@ first-party catalog adds modules through bundled Context Packs:
| Infrastructure (`nmsh.infrastructure`) | Terraform/OpenTofu, Helm, Pulumi (show on command) |
| Cloud (`nmsh.cloud`) | AWS, Google Cloud, Azure (show on command) |
| System (`nmsh.system`) | OS, user/host/SSH, jobs, session duration, time, battery, memory |
| VCS (`nmsh.vcs`) | Git stash, upstream (in a repository) |
| VCS (`nmsh.vcs`) | Git stash, upstream (in a repository); Mercurial bookmark/branch and Jujutsu repository (start hidden) |
| Agents (`nmsh.agents`) | Claude Code session, Claude Code limits (show on command) |

Show-on-command modules appear while you type a related command (`kubectl`,
Expand Down
68 changes: 64 additions & 4 deletions src/context/capabilities/vcs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {isAbsolute, join, resolve} from 'node:path';
import {defineCapability, type CapabilityDefinition} from '../capability.js';
import {isDirectory, LARGE_METADATA_BYTES, parseIniData, readMetadataText, text} from '../services.js';
import {dirname, isAbsolute, join, normalize, resolve} from 'node:path';
import {defineCapability, type CapabilityContext, type CapabilityDefinition} from '../capability.js';
import {isDirectory, isRegularFile, LARGE_METADATA_BYTES, parseIniData, readMetadataText, text, within} from '../services.js';

/**
* Richer Git facts beyond the existing Rich Git status, read as plain files
Expand Down Expand Up @@ -51,4 +51,64 @@ export const gitExtras = defineCapability<GitExtrasFact>({
},
});

export const VCS_CAPABILITIES = [gitExtras] as CapabilityDefinition<unknown>[];
/**
* The nearest directory holding `marker` (a `.hg` or `.jj` directory), searched upward from the working directory the way
* Mercurial and Jujutsu find their repositories. The Git-derived repository root does not bound this search (these
* repositories may have no Git directory); it stops at the home directory when the working directory is below it, and
* after 32 levels otherwise.
*/
async function nearestRepository(context: Pick<CapabilityContext, 'cwd' | 'home' | 'signal'>, marker: string): Promise<string | undefined> {
const home = context.home && isAbsolute(context.home) ? normalize(context.home) : undefined;
let directory = normalize(context.cwd);
for (let depth = 0; depth < 32 && !context.signal.aborted; depth += 1) {
if (await isDirectory(join(directory, marker))) return directory;
// A search that starts below home ends there; one that starts elsewhere (/srv, /opt, a volume) climbs to the filesystem root.
if ((home && directory === home && within(context.cwd, home)) || dirname(directory) === directory) return undefined;
directory = dirname(directory);
}
return undefined;
}

/** A Mercurial name as Mercurial stores it: one line, bounded; anything else is not a name. */
const hgName = (value: string | undefined): string | undefined => {
const line = value?.split(/\r?\n/u, 1)[0]?.trim();
return line && line.length <= 255 ? line : undefined;
};

export interface MercurialFact {branch: string; bookmark?: string}

export const mercurial = defineCapability<MercurialFact>({
id: 'vcs.hg', title: 'Mercurial branch and bookmark',
reads: ['the nearest .hg directory: .hg/branch and .hg/bookmarks.current (hg is never run; hgrc and hooks are never read)'],
scope: 'workspace', family: 'metadata', cost: 'bounded-async', trust: 'workspace', sensitivity: 'public', persistence: 'snapshot-safe',
fields: ['branch', 'bookmark'], env: [], ttlMs: 10_000, timeoutMs: 1000, invalidateOn: ['command'],
preview: {branch: 'default', bookmark: 'feature'},
async resolve(context) {
const repository = await nearestRepository(context, '.hg');
if (!repository) return undefined;
// No .hg/branch means the default branch: Mercurial writes the file only for a named branch.
const branch = hgName(await readMetadataText(join(repository, '.hg', 'branch'), 4096)) ?? 'default';
const bookmark = context.fields.has('bookmark') ? hgName(await readMetadataText(join(repository, '.hg', 'bookmarks.current'), 4096)) : undefined;
return {value: {branch, ...(bookmark ? {bookmark} : {})}, evidence: '.hg/branch, .hg/bookmarks.current'};
},
});

export interface JujutsuFact {repository: true; colocated: boolean; workspace?: 'main' | 'secondary'}

export const jujutsu = defineCapability<JujutsuFact>({
id: 'vcs.jj', title: 'Jujutsu repository',
reads: ['the nearest .jj directory: whether .jj/repo is the store or points to one (a secondary workspace), and whether Git is colocated; jj is never run and its binary operation log is not parsed'],
scope: 'workspace', family: 'metadata', cost: 'bounded-async', trust: 'workspace', sensitivity: 'public', persistence: 'snapshot-safe',
fields: ['repository', 'colocated', 'workspace'], env: [], ttlMs: 10_000, timeoutMs: 1000, invalidateOn: ['command'],
preview: {repository: true, colocated: true, workspace: 'main'},
async resolve(context) {
const repository = await nearestRepository(context, '.jj');
if (!repository) return undefined;
// `jj workspace add` writes .jj/repo as a file holding the main workspace's path; the main workspace has the directory.
const workspace = await isDirectory(join(repository, '.jj', 'repo')) ? 'main' : await isRegularFile(join(repository, '.jj', 'repo')) ? 'secondary' : undefined;
const colocated = await isDirectory(join(repository, '.git')) || (await readMetadataText(join(repository, '.git'), 8192))?.startsWith('gitdir:') === true;
return {value: {repository: true, colocated, ...(workspace ? {workspace} : {})}, evidence: '.jj directory'};
},
});

export const VCS_CAPABILITIES = [gitExtras, mercurial, jujutsu] as CapabilityDefinition<unknown>[];
29 changes: 26 additions & 3 deletions src/context/packs/builtin/nmsh.vcs.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
"schema": "nmsh.context-pack/v1",
"id": "nmsh.vcs",
"version": "1.0.0",
"name": "Git extras",
"description": "Stash depth and configured upstream, read from the repository's Git files alongside Rich Git.",
"name": "Version control extras",
"description": "Git stash depth and upstream from the repository's Git files alongside Rich Git; Mercurial branch or bookmark and Jujutsu repository state from their own directories.",
"license": "GPL-3.0-only",
"provenance": {"author": "notMyShell", "notes": "First-party pack shipped with NMSh. Independent implementation."},
"compatibility": {"contextApi": 1, "nmsh": ">=0.17.0"},
"requires": ["vcs.git"],
"requires": ["vcs.git", "vcs.hg", "vcs.jj"],
"modules": [
{
"id": "stash", "label": "Git stash", "description": "Number of stashed changes.",
Expand All @@ -20,6 +20,29 @@
"category": "vcs", "priority": 28, "role": "vcs", "icon": "upstream", "condition": "inRepository",
"surfaces": {"preferred": "rightContext"},
"segments": [{"label": "up", "parts": [{"fact": "vcs.git", "field": "upstream"}]}]
},
{
"id": "hg", "label": "Mercurial", "description": "Mercurial bookmark, or the branch when no bookmark is active, from the .hg directory; hg is never run.",
"category": "vcs", "priority": 31, "role": "vcs", "icon": "gitBranch", "condition": "always",
"surfaces": {"preferred": "mainPrompt"},
"segments": [{"label": "hg", "parts": [
{"fact": "vcs.hg", "field": "bookmark", "optional": true},
{"fact": "vcs.hg", "field": "branch", "optional": true, "when": {"field": "bookmark", "present": false}}
]}]
},
{
"id": "jj", "label": "Jujutsu", "description": "Marks a Jujutsu repository: colocated with Git or not, main or secondary workspace; jj is never run.",
"category": "vcs", "priority": 31, "role": "vcs", "condition": "always",
"surfaces": {"preferred": "mainPrompt"},
"segments": [{"icon": false, "parts": [
{"text": "jj", "when": {"fact": "vcs.jj", "field": "repository", "equals": true}},
{"text": "colocated", "join": " · ", "optional": true, "when": {"fact": "vcs.jj", "field": "colocated", "equals": true}},
{"text": "secondary workspace", "join": " · ", "optional": true, "when": {"fact": "vcs.jj", "field": "workspace", "equals": "secondary"}}
]}]
}
],
"recommend": [
{"module": "hg", "evidence": [{"kind": "workspaceFile", "names": [".hg"]}]},
{"module": "jj", "evidence": [{"kind": "workspaceFile", "names": [".jj"]}]}
]
}
45 changes: 44 additions & 1 deletion tests/contextCapabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {direnv, toolVersions} from '../src/context/capabilities/environment.js';
import {docker, helmChart, kubernetes, pulumi, terraform} from '../src/context/capabilities/infrastructure.js';
import {aws, azure, gcp} from '../src/context/capabilities/cloud.js';
import {clock, operatingSystem, sessionDuration, sessionJobs, sessionUser} from '../src/context/capabilities/system.js';
import {gitExtras} from '../src/context/capabilities/vcs.js';
import {gitExtras, jujutsu, mercurial} from '../src/context/capabilities/vcs.js';
import {resetServiceCaches, versionFromInstallPath} from '../src/context/services.js';
import {CORE_CAPABILITIES} from '../src/context/registry.js';

Expand Down Expand Up @@ -342,3 +342,46 @@ test('vcs.git reads stash depth and upstream from Git files, including linked wo
assert.equal(await resolve(gitExtras, {cwd: root}), undefined, 'outside a repository there is nothing to read');
} finally { await rm(root, {recursive: true, force: true}); }
});

test('vcs.hg and vcs.jj: Mercurial and Jujutsu state from their own directories, found upward without Git, never by running hg or jj', async () => {
const home = await workspace();
try {
const repo = join(home, 'src', 'project');
const deep = join(repo, 'a', 'b');
await mkdir(join(repo, '.hg'), {recursive: true});
await mkdir(deep, {recursive: true});
// No .hg/branch: the default branch. The Git-derived root is absent, as it is in a Mercurial-only repository.
assert.deepEqual(await resolve(mercurial, {cwd: deep, home}), {branch: 'default'});
await writeFile(join(repo, '.hg', 'branch'), 'stable\n');
await writeFile(join(repo, '.hg', 'bookmarks.current'), 'feature-x');
assert.deepEqual(await resolve(mercurial, {cwd: deep, home}), {branch: 'stable', bookmark: 'feature-x'});
assert.deepEqual(await resolve(mercurial, {cwd: deep, home, fields: ['branch']}), {branch: 'stable'}, 'the bookmark is read only when demanded');
// Hostile names are bounded; control characters are removed at the fact boundary (sanitizeFactValue), not trusted here.
await writeFile(join(repo, '.hg', 'branch'), `${'x'.repeat(300)}\n`);
assert.equal((await resolve(mercurial, {cwd: repo, home}))?.branch, 'default', 'an oversized name is not a name');
await rm(join(repo, '.hg', 'branch'));
await symlink('/etc/passwd', join(repo, '.hg', 'branch'));
assert.equal((await resolve(mercurial, {cwd: repo, home}))?.branch, 'default', 'a symlinked branch file is not read');

// The search stops at home: a .hg above home is not this directory's repository.
const outerHome = join(home, 'users', 'me');
await mkdir(join(outerHome, 'work'), {recursive: true});
await mkdir(join(home, 'users', '.hg'));
assert.equal(await resolve(mercurial, {cwd: join(outerHome, 'work'), home: outerHome}), undefined);
await mkdir(join(outerHome, '.hg'));
assert.deepEqual(await resolve(mercurial, {cwd: join(outerHome, 'work'), home: outerHome}), {branch: 'default'}, 'a repository at home itself is found');

// Jujutsu: the main workspace holds .jj/repo as a directory, a secondary one as a file; Git may be colocated.
const jjRepo = join(home, 'jj');
await mkdir(join(jjRepo, '.jj', 'repo'), {recursive: true});
await mkdir(join(jjRepo, 'src'));
assert.deepEqual(await resolve(jujutsu, {cwd: join(jjRepo, 'src'), home}), {repository: true, colocated: false, workspace: 'main'});
await mkdir(join(jjRepo, '.git'));
assert.deepEqual(await resolve(jujutsu, {cwd: jjRepo, home}), {repository: true, colocated: true, workspace: 'main'});
const second = join(home, 'jj-second');
await mkdir(join(second, '.jj'), {recursive: true});
await writeFile(join(second, '.jj', 'repo'), join(jjRepo, '.jj', 'repo'));
assert.deepEqual(await resolve(jujutsu, {cwd: second, home}), {repository: true, colocated: false, workspace: 'secondary'});
assert.equal(await resolve(jujutsu, {cwd: join(home, 'src'), home}), undefined);
} finally { await rm(home, {recursive: true, force: true}); }
});
13 changes: 13 additions & 0 deletions tests/contextPacks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,3 +295,16 @@ test('`nmsh packs`: list, inspect with disclosure, confirmed install adds hidden
assert.match(err, /Invalid Context Pack/u);
} finally { await rm(root, {recursive: true, force: true}); }
});

test('Mercurial and Jujutsu modules: a bookmark wins over the branch; jj states only what its directory shows', () => {
const now = Date.now();
const hg = moduleDefinition('nmsh.vcs:hg')!;
const jj = moduleDefinition('nmsh.vcs:jj')!;
const render = (definition: typeof hg, facts: ContextFacts) => declarativeSegments(definition, facts, {icons: 'nerd', glyphs: 'safe', now, purpose: 'display'}).map(segment => segment.text);
assert.deepEqual(render(hg, {'vcs.hg': fact({branch: 'stable', bookmark: 'feature-x'})} as ContextFacts), ['hg feature-x']);
assert.deepEqual(render(hg, {'vcs.hg': fact({branch: 'stable'})} as ContextFacts), ['hg stable']);
assert.deepEqual(render(jj, {'vcs.jj': fact({repository: true, colocated: true, workspace: 'main'})} as ContextFacts), ['jj · colocated']);
assert.deepEqual(render(jj, {'vcs.jj': fact({repository: true, colocated: false, workspace: 'secondary'})} as ContextFacts), ['jj · secondary workspace']);
assert.deepEqual(render(jj, {} as ContextFacts), [], 'nothing outside a Jujutsu repository');
assert.ok(!firstPartyDefaultModules().find(module => module.id === 'nmsh.vcs:hg')?.visible, 'new modules start hidden: no demand until chosen');
});
Loading