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
41 changes: 5 additions & 36 deletions src/commands/branch/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { UserError } from '../../errors';
import { withProgress } from '../../utils/progress';
import { CLI_NAME } from '../../config/constants';
import { initializeServices, getBranchWithProject } from '../../utils/service-factory';
import { buildBranchTree, renderBranchTree } from '../../utils/tree-renderer';
import type { Branch } from '../../types/state';

// Helper function to collect all descendant branches recursively (depth-first, post-order)
Expand Down Expand Up @@ -45,42 +46,10 @@ export async function branchDeleteCommand(name: string, options: { force?: boole
if (descendants.length > 0 && !options.force) {
console.log(`Branch '${chalk.bold(name)}' has ${descendants.length} child branch(es):`);

// Build tree structure for display
interface BranchNode {
branch: Branch;
children: BranchNode[];
}

const branchMap = new Map<string, BranchNode>();

// Create nodes for target branch and all descendants
branchMap.set(branch.id, { branch, children: [] });
for (const desc of descendants) {
branchMap.set(desc.id, { branch: desc, children: [] });
}

// Build parent-child relationships
for (const desc of descendants) {
const node = branchMap.get(desc.id)!;
if (desc.parentBranchId) {
const parent = branchMap.get(desc.parentBranchId);
if (parent) {
parent.children.push(node);
}
}
}

// Render tree (same logic as project delete)
function renderBranch(node: BranchNode, depth: number = 0) {
const indent = depth > 0 ? ' '.repeat(depth) + '↳ ' : ' ';
console.log(chalk.dim(`${indent}${node.branch.name}`));
for (const child of node.children) {
renderBranch(child, depth + 1);
}
}

const rootNode = branchMap.get(branch.id)!;
renderBranch(rootNode, 0);
// Build and render tree structure
const { nodeMap } = buildBranchTree([branch, ...descendants]);
const rootNode = nodeMap.get(branch.id)!;
renderBranchTree([rootNode]);

console.log();
console.log(`Use ${chalk.bold('--force')} to delete branch and all child branches`);
Expand Down
99 changes: 31 additions & 68 deletions src/commands/branch/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { parseNamespace } from '../../utils/namespace';
import { UserError } from '../../errors';
import { CLI_NAME } from '../../config/constants';
import { initializeServices } from '../../utils/service-factory';
import { buildBranchTree, traverseBranchTree, getTreeIndent } from '../../utils/tree-renderer';

export async function branchListCommand(projectName?: string) {
const { state, zfs } = await initializeServices();
Expand Down Expand Up @@ -40,78 +41,40 @@ export async function branchListCommand(projectName?: string) {
}
});

// Helper to build tree and render branches
interface BranchNode {
branch: any;
children: BranchNode[];
}

async function renderBranch(node: BranchNode, depth: number = 0) {
const branch = node.branch;
const statusIcon = branch.status === 'running' ? '●' : '';
const statusText = branch.status === 'running' ? 'running' : 'stopped';
const port = branch.status === 'running' ? branch.port.toString() : '-';

// Build name with tree structure
const indent = depth > 0 ? ' '.repeat(depth) + '↳ ' : '';
const namespace = parseNamespace(branch.name);
const displayName = depth > 0 ? namespace.branch : branch.name;
const name = indent + displayName;
const type = branch.isPrimary ? chalk.dim(' (main)') : '';

// Query size on-demand from ZFS
const datasetName = getDatasetName(namespace.project, namespace.branch);
let sizeBytes = 0;
try {
sizeBytes = await zfs.getUsedSpace(datasetName);
} catch {
// If dataset doesn't exist, show 0
}

table.push([
statusIcon,
name + type,
statusText,
port,
formatBytes(sizeBytes)
]);

// Render children
for (const child of node.children) {
await renderBranch(child, depth + 1);
}
}

// Process each project
for (const proj of filtered) {
// Build tree structure
const branchMap = new Map<string, BranchNode>();
const roots: BranchNode[] = [];

// Create nodes for all branches
for (const branch of proj.branches) {
branchMap.set(branch.id, { branch, children: [] });
}

// Build parent-child relationships
for (const branch of proj.branches) {
const node = branchMap.get(branch.id)!;
if (branch.parentBranchId) {
const parent = branchMap.get(branch.parentBranchId);
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
} else {
roots.push(node);
const { roots } = buildBranchTree(proj.branches);

await traverseBranchTree(roots, async (node, depth) => {
const branch = node.branch;
const statusIcon = branch.status === 'running' ? '●' : '';
const statusText = branch.status === 'running' ? 'running' : 'stopped';
const port = branch.status === 'running' ? branch.port.toString() : '-';

// Build name with tree structure
const indent = depth > 0 ? getTreeIndent(depth).slice(2) : '';
const namespace = parseNamespace(branch.name);
const displayName = depth > 0 ? namespace.branch : branch.name;
const name = indent + displayName;
const type = branch.isPrimary ? chalk.dim(' (main)') : '';

// Query size on-demand from ZFS
const datasetName = getDatasetName(namespace.project, namespace.branch);
let sizeBytes = 0;
try {
sizeBytes = await zfs.getUsedSpace(datasetName);
} catch {
// If dataset doesn't exist, show 0
}
}

// Render tree
for (const root of roots) {
await renderBranch(root, 0);
}
table.push([
statusIcon,
name + type,
statusText,
port,
formatBytes(sizeBytes)
]);
});
}

console.log();
Expand Down
50 changes: 6 additions & 44 deletions src/commands/project/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { UserError } from '../../errors';
import { withProgress } from '../../utils/progress';
import { CLI_NAME } from '../../config/constants';
import { initializeServices, getProject } from '../../utils/service-factory';
import type { Branch } from '../../types/state';
import { buildBranchTree, renderBranchTree } from '../../utils/tree-renderer';

export async function projectDeleteCommand(name: string, options: { force?: boolean }) {
console.log();
Expand All @@ -20,49 +20,11 @@ export async function projectDeleteCommand(name: string, options: { force?: bool
if (nonMainBranches.length > 0 && !options.force) {
console.log(`Project '${chalk.bold(name)}' has ${nonMainBranches.length} branch(es):`);

// Build tree structure
interface BranchNode {
branch: Branch;
children: BranchNode[];
}

const branchMap = new Map<string, BranchNode>();
const roots: BranchNode[] = [];

// Create nodes for all branches (including main for parent lookups)
for (const branch of project.branches) {
branchMap.set(branch.id, { branch, children: [] });
}

// Build parent-child relationships
for (const branch of project.branches) {
const node = branchMap.get(branch.id)!;
if (branch.parentBranchId) {
const parent = branchMap.get(branch.parentBranchId);
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
} else {
roots.push(node);
}
}

// Render tree (only non-main branches)
function renderBranch(node: BranchNode, depth: number = 0) {
if (!node.branch.isPrimary) {
const indent = depth > 0 ? ' '.repeat(depth) + '↳ ' : ' ';
console.log(chalk.dim(`${indent}${node.branch.name}`));
}
for (const child of node.children) {
renderBranch(child, node.branch.isPrimary ? depth : depth + 1);
}
}

for (const root of roots) {
renderBranch(root, 0);
}
// Build and render tree (skip main branch)
const { roots } = buildBranchTree(project.branches);
renderBranchTree(roots, {
skip: (branch) => branch.isPrimary,
});

console.log();
console.log(`Use ${chalk.bold('--force')} to delete project and all branches`);
Expand Down
120 changes: 120 additions & 0 deletions src/utils/tree-renderer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import chalk from 'chalk';
import type { Branch } from '../types/state';

/**
* Node in a branch tree structure
*/
export interface BranchNode {
branch: Branch;
children: BranchNode[];
}

/**
* Result of building a branch tree
*/
export interface BranchTree {
roots: BranchNode[];
nodeMap: Map<string, BranchNode>;
}

/**
* Options for rendering a branch tree
*/
export interface RenderOptions {
/** Skip rendering nodes matching this predicate (children still rendered) */
skip?: (branch: Branch) => boolean;
/** Custom formatter for branch display (default: chalk.dim with name) */
format?: (branch: Branch, indent: string) => string;
}

/**
* Build a tree structure from a flat list of branches
*/
export function buildBranchTree(branches: Branch[]): BranchTree {
const nodeMap = new Map<string, BranchNode>();
const roots: BranchNode[] = [];

// Create nodes for all branches
for (const branch of branches) {
nodeMap.set(branch.id, { branch, children: [] });
}

// Build parent-child relationships
for (const branch of branches) {
const node = nodeMap.get(branch.id)!;
if (branch.parentBranchId) {
const parent = nodeMap.get(branch.parentBranchId);
if (parent) {
parent.children.push(node);
} else {
roots.push(node);
}
} else {
roots.push(node);
}
}

return { roots, nodeMap };
}

/**
* Get tree indent string for a given depth
*/
export function getTreeIndent(depth: number): string {
return depth > 0 ? ' '.repeat(depth) + '↳ ' : ' ';
}

/**
* Render a branch tree to console
*/
export function renderBranchTree(
roots: BranchNode[],
options: RenderOptions = {}
): void {
const { skip, format } = options;

function renderNode(node: BranchNode, depth: number): void {
const shouldSkip = skip?.(node.branch) ?? false;

if (!shouldSkip) {
const indent = getTreeIndent(depth);
const line = format
? format(node.branch, indent)
: chalk.dim(`${indent}${node.branch.name}`);
console.log(line);
}

// Render children (adjust depth if we skipped this node)
for (const child of node.children) {
renderNode(child, shouldSkip ? depth : depth + 1);
}
}

for (const root of roots) {
renderNode(root, 0);
}
}

/**
* Traverse a branch tree in depth-first order, calling visitor for each node
* Async visitor support for operations like fetching ZFS data
*/
export async function traverseBranchTree<T>(
roots: BranchNode[],
visitor: (node: BranchNode, depth: number) => Promise<T> | T
): Promise<T[]> {
const results: T[] = [];

async function traverse(node: BranchNode, depth: number): Promise<void> {
results.push(await visitor(node, depth));
for (const child of node.children) {
await traverse(child, depth + 1);
}
}

for (const root of roots) {
await traverse(root, 0);
}

return results;
}