Skip to content
Merged
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
7 changes: 7 additions & 0 deletions src/api/list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { listWorktrees, type WorktreeInfo, type ListWorktreesResultData } from './list.js';

Check warning on line 6 in src/api/list.test.ts

View workflow job for this annotation

GitHub Actions / Lint

'ListWorktreesResultData' is defined but never used. Allowed unused vars must match /^_/u

// Mock dependencies
vi.mock('../lib/git.js', () => ({
Expand All @@ -14,6 +14,13 @@
isGhInstalled: vi.fn(),
}));

vi.mock('../lib/config.js', () => ({
loadConfig: vi.fn(() => ({
baseBranch: 'main',
worktreePattern: 'pr{number}.{slug}',
})),
}));

vi.mock('../lib/lswt/index.js', () => ({
gatherWorktreeInfo: vi.fn(),
createDefaultDeps: vi.fn(() => ({})),
Expand Down
4 changes: 4 additions & 0 deletions src/api/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from '../lib/json-output.js';
import * as git from '../lib/git.js';
import * as github from '../lib/github.js';
import { loadConfig } from '../lib/config.js';

/**
* Information about a single worktree
Expand Down Expand Up @@ -116,11 +117,14 @@ export async function listWorktrees(
}

// Build options for the lib function
const config = loadConfig(repoRoot);
const listOptions: ListOptions = {
json: true, // We always want structured output
verbose: true, // Include all info
showStatus: effectiveShowStatus,
interactive: false, // Never interactive for API
worktreePattern: config.worktreePattern,
baseBranch: config.baseBranch,
};

// Gather worktree info
Expand Down
1 change: 1 addition & 0 deletions src/cli/lswt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ async function main(): Promise<void> {
// Load config for worktree pattern
const config = loadConfig(repoRoot);
options.worktreePattern = config.worktreePattern;
options.baseBranch = config.baseBranch;

// Gather worktree info
const deps = createDefaultDeps();
Expand Down
8 changes: 7 additions & 1 deletion src/cli/wt/interactive-menu.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,13 @@ describe('Interactive Menu Flows', () => {

expect(gatherWorktreeInfo).toHaveBeenCalledWith(
'/mock/repo',
{ verbose: false, json: false, showStatus: false },
{
verbose: false,
json: false,
showStatus: false,
worktreePattern: '{repo}.pr{number}',
baseBranch: 'main',
},
expect.anything()
);
expect(runInteractiveMode).toHaveBeenCalled();
Expand Down
9 changes: 8 additions & 1 deletion src/cli/wt/interactive-menu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,10 +260,17 @@ async function handleListWorktrees(): Promise<FlowResult> {
console.log();
try {
const repoRoot = git.getRepoRoot();
const config = loadConfig(repoRoot);
const deps = createLswtDeps();
const worktrees = await gatherWorktreeInfo(
repoRoot,
{ verbose: false, json: false, showStatus: false },
{
verbose: false,
json: false,
showStatus: false,
worktreePattern: config.worktreePattern,
baseBranch: config.baseBranch,
Comment on lines +271 to +272

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward canonical list options into interactive refreshes

The main-menu flow uses the configured pattern and base branch for its initial gather, but passes a fresh options object without either value to runInteractiveMode. After an action requests refresh, src/lib/lswt/interactive.ts re-runs gatherWorktreeInfo with that incomplete object, so a bare layout using develop or a custom PR pattern immediately reclassifies its canonical and PR worktrees incorrectly. Pass the same configured options into the interactive loop.

Useful? React with 👍 / 👎.

},
deps
);
// Run interactive mode (same as standalone lswt with no args in TTY)
Expand Down
1 change: 1 addition & 0 deletions src/cli/wt/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ export const listCommand: CommandModule<object, ListArgs> = {

// Pass worktreePattern through to extractPrNumber
options.worktreePattern = config.worktreePattern;
options.baseBranch = config.baseBranch;

// Gather worktree info
const deps = createDefaultDeps();
Expand Down
155 changes: 145 additions & 10 deletions src/integration/worktree-layout.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,16 @@ import fs from 'fs';
import path from 'path';
import os from 'os';
import * as git from '../lib/git.js';
import { getDefaultConfig, generateWorktreePath } from '../lib/config.js';
import {
getDefaultConfig,
getConfigPath,
generateWorktreePath,
loadConfigWithValidation,
} from '../lib/config.js';
import {
gatherWorktreeInfo,
createDefaultDeps as createLswtDeps,
} from '../lib/lswt/worktree-info.js';

/**
* Integration tests for worktree layout anchoring against a REAL bare-repository
Expand All @@ -21,6 +30,7 @@ function normalizePath(p: string): string {

describe('worktree layout anchoring integration', () => {
let tempDir: string;
let seedRepo: string;
let container: string;
let mainWorktree: string;
let prWorktree: string;
Expand All @@ -30,18 +40,22 @@ describe('worktree layout anchoring integration', () => {

// Seed a normal repo with one commit, then clone it bare — the standard
// way to set up a .bare/ + worktrees layout.
const seed = path.join(tempDir, 'seed');
fs.mkdirSync(seed);
execSync('git init -q -b main', { cwd: seed });
execSync('git config user.email test@test.com', { cwd: seed });
execSync('git config user.name Test', { cwd: seed });
fs.writeFileSync(path.join(seed, 'README.md'), 'seed\n');
execSync('git add README.md', { cwd: seed });
execSync('git commit -q -m initial', { cwd: seed });
seedRepo = path.join(tempDir, 'seed');
fs.mkdirSync(seedRepo);
execSync('git init -q -b main', { cwd: seedRepo });
execSync('git config user.email test@test.com', { cwd: seedRepo });
execSync('git config user.name Test', { cwd: seedRepo });
fs.writeFileSync(path.join(seedRepo, 'README.md'), 'seed\n');
fs.writeFileSync(
path.join(seedRepo, '.worktreerc'),
JSON.stringify({ worktreeParent: '.worktrees', worktreePattern: 'pr{number}.{slug}' })
);
execSync('git add README.md .worktreerc', { cwd: seedRepo });
execSync('git commit -q -m initial', { cwd: seedRepo });

container = path.join(tempDir, 'container');
fs.mkdirSync(container);
execSync(`git clone --bare -q "${seed}" "${path.join(container, '.bare')}"`);
execSync(`git clone --bare -q "${seedRepo}" "${path.join(container, '.bare')}"`);

mainWorktree = path.join(container, 'main');
execSync(`git worktree add -q "${mainWorktree}" main`, {
Expand All @@ -53,6 +67,10 @@ describe('worktree layout anchoring integration', () => {
execSync(`git worktree add -q -b feat/existing-feature "${prWorktree}" main`, {
cwd: path.join(container, '.bare'),
});
fs.writeFileSync(
path.join(mainWorktree, '.worktreerc.local'),
JSON.stringify({ worktreeParent: '../pr' })
);
});

afterAll(() => {
Expand All @@ -72,6 +90,123 @@ describe('worktree layout anchoring integration', () => {
expect(normalizePath(git.getMainWorktreeRoot(prWorktree))).toBe(normalizePath(container));
});

it('finds the canonical main checkout from a linked pr worktree', () => {
expect(normalizePath(git.getMainWorktree(prWorktree)?.path ?? '')).toBe(
normalizePath(mainWorktree)
);
});

it('loads the main checkout local override when invoked from a linked worktree', () => {
const result = loadConfigWithValidation(prWorktree);

expect(result.config.worktreeParent).toBe('../pr');
expect(normalizePath(result.configPath ?? '')).toBe(
normalizePath(path.join(mainWorktree, '.worktreerc.local'))
);
});

it('selects the canonical local override for config operations from a linked worktree', () => {
expect(normalizePath(getConfigPath(prWorktree) ?? '')).toBe(
normalizePath(path.join(mainWorktree, '.worktreerc.local'))
);
});

it('resolves a canonical local override under the nested workspace container', () => {
const config = loadConfigWithValidation(prWorktree).config;
const result = generateWorktreePath(
config,
prWorktree,
'container',
2600,
'feat/new-feature',
git.getMainWorktreeRoot(prWorktree)
);

expect(normalizePath(result)).toBe(
normalizePath(path.join(container, 'pr', 'pr2600.new-feature'))
);
});

it('lists canonical main correctly when invoked from a linked worktree', async () => {
const config = loadConfigWithValidation(prWorktree).config;
const result = await gatherWorktreeInfo(
prWorktree,
{
showStatus: false,
json: true,
verbose: true,
worktreePattern: 'pr{number}.{slug}',
baseBranch: config.baseBranch,
},
createLswtDeps()
);

expect(
result.find((worktree) => normalizePath(worktree.path) === normalizePath(mainWorktree))?.type
).toBe('main');
expect(
result.find((worktree) => normalizePath(worktree.path) === normalizePath(prWorktree))?.type
).toBe('pr');
});

it('bootstraps a local-only non-default base branch with a custom bare directory', async () => {
const developContainer = path.join(tempDir, 'develop-container');
const bareRepo = path.join(developContainer, 'repository.git');
const developWorktree = path.join(developContainer, 'develop');
const featureWorktree = path.join(developContainer, 'pr', 'pr2.feature');
fs.mkdirSync(developContainer);
execSync(`git clone --bare -q "${seedRepo}" "${bareRepo}"`);
execSync('git update-ref refs/heads/develop refs/heads/main', { cwd: bareRepo });
execSync('git symbolic-ref HEAD refs/heads/develop', { cwd: bareRepo });
execSync('git update-ref -d refs/heads/main', { cwd: bareRepo });
execSync(`git worktree add -q "${developWorktree}" develop`, { cwd: bareRepo });
fs.mkdirSync(path.dirname(featureWorktree));
execSync(`git worktree add -q -b feat/feature "${featureWorktree}" develop`, {
cwd: bareRepo,
});
fs.writeFileSync(
path.join(developWorktree, '.worktreerc.local'),
JSON.stringify({ baseBranch: 'develop', worktreeParent: '../pr' })
);

const loaded = loadConfigWithValidation(featureWorktree);
const worktrees = await gatherWorktreeInfo(
featureWorktree,
{
showStatus: false,
json: true,
verbose: true,
worktreePattern: loaded.config.worktreePattern,
baseBranch: loaded.config.baseBranch,
},
createLswtDeps()
);
const generatedPath = generateWorktreePath(
loaded.config,
featureWorktree,
'develop-container',
2601,
'feat/another-feature',
git.getMainWorktreeRoot(featureWorktree)
);

expect(loaded.config.baseBranch).toBe('develop');
expect(loaded.config.worktreeParent).toBe('../pr');
expect(normalizePath(loaded.configPath ?? '')).toBe(
normalizePath(path.join(developWorktree, '.worktreerc.local'))
);
expect(normalizePath(git.getMainWorktree(featureWorktree, 'develop')?.path ?? '')).toBe(
normalizePath(developWorktree)
);
expect(
worktrees.find((worktree) => normalizePath(worktree.path) === normalizePath(developWorktree))
?.type
).toBe('main');
expect(normalizePath(generatedPath)).toBe(
normalizePath(path.join(developContainer, 'pr', 'pr2601.another-feature'))
);
});

it('places a new pr worktree under the container, invoked from main', () => {
const config = {
...getDefaultConfig(),
Expand Down
Loading
Loading