Skip to content
Open
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
222 changes: 222 additions & 0 deletions src/utils/__tests__/jj.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { execSync } from 'child_process';
import {
beforeEach,
describe,
expect,
it,
vi
} from 'vitest';

import type { RenderContext } from '../../types/RenderContext';
import {
getJjChangeCounts,
getJjCurrentWorkspace,
isInsideJjWorkspace,
resolveJjCwd,
runJj
} from '../jj';

vi.mock('child_process', () => ({ execSync: vi.fn() }));

const mockExecSync = execSync as unknown as {
mock: { calls: unknown[][] };
mockImplementation: (impl: () => never) => void;
mockReturnValue: (value: string) => void;
mockReturnValueOnce: (value: string) => void;
};

describe('jj utils', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('resolveJjCwd', () => {
it('prefers context.data.cwd when available', () => {
const context: RenderContext = {
data: {
cwd: '/repo/from/cwd',
workspace: {
current_dir: '/repo/from/current-dir',
project_dir: '/repo/from/project-dir'
}
}
};

expect(resolveJjCwd(context)).toBe('/repo/from/cwd');
});

it('falls back to workspace.current_dir', () => {
const context: RenderContext = {
data: {
workspace: {
current_dir: '/repo/from/current-dir',
project_dir: '/repo/from/project-dir'
}
}
};

expect(resolveJjCwd(context)).toBe('/repo/from/current-dir');
});

it('falls back to workspace.project_dir', () => {
const context: RenderContext = { data: { workspace: { project_dir: '/repo/from/project-dir' } } };

expect(resolveJjCwd(context)).toBe('/repo/from/project-dir');
});

it('skips empty candidate values', () => {
const context: RenderContext = {
data: {
cwd: ' ',
workspace: {
current_dir: '',
project_dir: '/repo/from/project-dir'
}
}
};

expect(resolveJjCwd(context)).toBe('/repo/from/project-dir');
});

it('returns undefined when no candidates are available', () => {
expect(resolveJjCwd({})).toBeUndefined();
});
});

describe('runJj', () => {
it('runs jj command with resolved cwd and trims output', () => {
mockExecSync.mockReturnValue(' some-output \n');
const context: RenderContext = { data: { cwd: '/tmp/repo' } };

const result = runJj('log --limit 1', context);

expect(result).toBe('some-output');
expect(mockExecSync.mock.calls[0]?.[0]).toBe('jj log --limit 1');
expect(mockExecSync.mock.calls[0]?.[1]).toEqual({
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
cwd: '/tmp/repo'
});
});

it('runs jj command without cwd when no context directory exists', () => {
mockExecSync.mockReturnValue('/tmp/repo\n');

const result = runJj('workspace root', {});

expect(result).toBe('/tmp/repo');
expect(mockExecSync.mock.calls[0]?.[1]).toEqual({
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore']
});
});

it('returns null when output is empty', () => {
mockExecSync.mockReturnValue(' \n');

expect(runJj('workspace root', {})).toBeNull();
});

it('returns null when the command fails', () => {
mockExecSync.mockImplementation(() => { throw new Error('jj failed'); });

expect(runJj('status', {})).toBeNull();
});

it('returns empty string when allowEmpty is true and output is empty', () => {
mockExecSync.mockReturnValue(' \n');

expect(runJj('workspace root', {}, true)).toBe('');
});

it('returns non-empty string when allowEmpty is true and output has content', () => {
mockExecSync.mockReturnValue(' some-output \n');

expect(runJj('log', {}, true)).toBe('some-output');
});

it('returns null when allowEmpty is true and command fails', () => {
mockExecSync.mockImplementation(() => { throw new Error('jj failed'); });

expect(runJj('status', {}, true)).toBeNull();
});
});

describe('isInsideJjWorkspace', () => {
it('returns true when jj workspace root succeeds', () => {
mockExecSync.mockReturnValue('/tmp/repo\n');

expect(isInsideJjWorkspace({})).toBe(true);
});

it('returns false when jj workspace root fails', () => {
mockExecSync.mockImplementation(() => { throw new Error('jj failed'); });

expect(isInsideJjWorkspace({})).toBe(false);
});
});

describe('getJjCurrentWorkspace', () => {
it('returns the workspace name from the first line', () => {
mockExecSync.mockReturnValue('default: kpqxywon 2f73e05c (no description set)\nfeature-work: spzqtmlo abc12345 (no description set)');

expect(getJjCurrentWorkspace({})).toBe('default');
});

it('returns non-default workspace name', () => {
mockExecSync.mockReturnValue('feature-work: spzqtmlo abc12345 (no description set)');

expect(getJjCurrentWorkspace({})).toBe('feature-work');
});

it('returns null when command fails', () => {
mockExecSync.mockImplementation(() => { throw new Error('jj failed'); });

expect(getJjCurrentWorkspace({})).toBeNull();
});

it('returns null when output is empty', () => {
mockExecSync.mockReturnValue(' \n');

expect(getJjCurrentWorkspace({})).toBeNull();
});
});

describe('getJjChangeCounts', () => {
it('parses insertions and deletions from jj diff --stat', () => {
mockExecSync.mockReturnValue('2 files changed, 5 insertions(+), 3 deletions(-)');

expect(getJjChangeCounts({})).toEqual({
insertions: 5,
deletions: 3
});
});

it('handles singular insertion/deletion forms', () => {
mockExecSync.mockReturnValue('1 file changed, 1 insertion(+), 1 deletion(-)');

expect(getJjChangeCounts({})).toEqual({
insertions: 1,
deletions: 1
});
});

it('returns zero counts when jj diff --stat returns empty', () => {
mockExecSync.mockReturnValue('\n');

expect(getJjChangeCounts({})).toEqual({
insertions: 0,
deletions: 0
});
});

it('returns zero counts when jj diff command fails', () => {
mockExecSync.mockImplementation(() => { throw new Error('jj failed'); });

expect(getJjChangeCounts({})).toEqual({
insertions: 0,
deletions: 0
});
});
});
});
76 changes: 76 additions & 0 deletions src/utils/jj.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { execSync } from 'child_process';

import type { RenderContext } from '../types/RenderContext';

export interface JjChangeCounts {
insertions: number;
deletions: number;
}

export function resolveJjCwd(context: RenderContext): string | undefined {
const candidates = [
context.data?.cwd,
context.data?.workspace?.current_dir,
context.data?.workspace?.project_dir
];

for (const candidate of candidates) {
if (typeof candidate === 'string' && candidate.trim().length > 0) {
return candidate;
}
}

return undefined;
}

// Returns trimmed stdout, or null if empty or on error.
// Pass allowEmpty=true when an empty result is semantically distinct from failure.
export function runJj(command: string, context: RenderContext, allowEmpty = false): string | null {
try {
const cwd = resolveJjCwd(context);
const output = execSync(`jj ${command}`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
...(cwd ? { cwd } : {})
}).trim();

return (allowEmpty || output.length > 0) ? output : null;
} catch {
return null;
}
}

export function isInsideJjWorkspace(context: RenderContext): boolean {
return runJj('workspace root', context) !== null;
}

function parseDiffStat(stat: string): JjChangeCounts {
const insertMatch = /(\d+)\s+insertions?/.exec(stat);
const deleteMatch = /(\d+)\s+deletions?/.exec(stat);

return {
insertions: insertMatch?.[1] ? parseInt(insertMatch[1], 10) : 0,
deletions: deleteMatch?.[1] ? parseInt(deleteMatch[1], 10) : 0
};
}

export function getJjCurrentWorkspace(context: RenderContext): string | null {
const output = runJj('workspace list', context);
if (!output)
return null;

const firstLine = output.split('\n')[0] ?? '';
const colonIndex = firstLine.indexOf(':');
if (colonIndex === -1)
return null;

const name = firstLine.slice(0, colonIndex).trim();

return name.length > 0 ? name : null;
}

export function getJjChangeCounts(context: RenderContext): JjChangeCounts {
const stat = runJj('diff --stat', context) ?? '';

return parseDiffStat(stat);
}
8 changes: 8 additions & 0 deletions src/utils/widget-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ export const WIDGET_MANIFEST: WidgetManifestEntry[] = [
{ type: 'git-deletions', create: () => new widgets.GitDeletionsWidget() },
{ type: 'git-root-dir', create: () => new widgets.GitRootDirWidget() },
{ type: 'git-worktree', create: () => new widgets.GitWorktreeWidget() },
{ type: 'jj-change', create: () => new widgets.JjChangeWidget() },
{ type: 'jj-bookmark', create: () => new widgets.JjBookmarkWidget() },
{ type: 'jj-changes', create: () => new widgets.JjChangesWidget() },
{ type: 'jj-insertions', create: () => new widgets.JjInsertionsWidget() },
{ type: 'jj-deletions', create: () => new widgets.JjDeletionsWidget() },
{ type: 'jj-root-dir', create: () => new widgets.JjRootDirWidget() },
{ type: 'jj-description', create: () => new widgets.JjDescriptionWidget() },
{ type: 'jj-workspace', create: () => new widgets.JjWorkspaceWidget() },
{ type: 'current-working-dir', create: () => new widgets.CurrentWorkingDirWidget() },
{ type: 'tokens-input', create: () => new widgets.TokensInputWidget() },
{ type: 'tokens-output', create: () => new widgets.TokensOutputWidget() },
Expand Down
20 changes: 16 additions & 4 deletions src/widgets/GitBranch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,21 @@ import {
isInsideGitWorkTree,
runGit
} from '../utils/git';
import { isInsideJjWorkspace } from '../utils/jj';

import { makeModifierText } from './shared/editor-display';
import {
getHideNoGitKeybinds,
getHideNoGitModifierText,
getHideNoGitModifiers,
handleToggleNoGitAction,
isHideNoGitEnabled
} from './shared/git-no-git';
import {
getHideWhenJjKeybinds,
getHideWhenJjModifierText,
handleToggleHideWhenJjAction,
isHideWhenJjEnabled
} from './shared/git-hide-when-jj';

export class GitBranchWidget implements Widget {
getDefaultColor(): string { return 'magenta'; }
Expand All @@ -26,12 +34,12 @@ export class GitBranchWidget implements Widget {
getEditorDisplay(item: WidgetItem): WidgetEditorDisplay {
return {
displayText: this.getDisplayName(),
modifierText: getHideNoGitModifierText(item)
modifierText: makeModifierText([...getHideNoGitModifiers(item), ...getHideWhenJjModifierText(item)])
};
}

handleEditorAction(action: string, item: WidgetItem): WidgetItem | null {
return handleToggleNoGitAction(action, item);
return handleToggleNoGitAction(action, item) ?? handleToggleHideWhenJjAction(action, item);
}

render(item: WidgetItem, context: RenderContext, settings: Settings): string | null {
Expand All @@ -41,6 +49,10 @@ export class GitBranchWidget implements Widget {
return item.rawValue ? 'main' : '⎇ main';
}

if (isHideWhenJjEnabled(item) && isInsideJjWorkspace(context)) {
return null;
}

if (!isInsideGitWorkTree(context)) {
return hideNoGit ? null : '⎇ no git';
}
Expand All @@ -57,7 +69,7 @@ export class GitBranchWidget implements Widget {
}

getCustomKeybinds(): CustomKeybind[] {
return getHideNoGitKeybinds();
return [...getHideNoGitKeybinds(), ...getHideWhenJjKeybinds()];
}

supportsRawValue(): boolean { return true; }
Expand Down
Loading