git-tweezers
Version:
Advanced git staging tool with hunk and line-level control
188 lines (187 loc) • 8.38 kB
JavaScript
import { Command, Flags, Args } from '@oclif/core';
import chalk from 'chalk';
import { StagingService } from '../services/staging-service.js';
import { logger, LogLevel } from '../utils/logger.js';
import { DiffRenderer } from '../utils/diff-renderer.js';
import { GitWrapper } from '../core/git-wrapper.js';
export default class List extends Command {
static description = 'List all hunks in a file with their line numbers';
static examples = [
'<%= config.bin %> <%= command.id %> # List all changed files',
'<%= config.bin %> <%= command.id %> src/index.ts # List hunks in specific file',
'<%= config.bin %> <%= command.id %> src/*.ts # List hunks in multiple files',
'<%= config.bin %> <%= command.id %> -p src/index.ts # Use precise mode for smaller hunks',
'<%= config.bin %> <%= command.id %> --preview # Show diff preview for each hunk',
];
static flags = {
precise: Flags.boolean({
char: 'p',
description: 'Use precise mode (U0 context) for finer control',
default: false,
}),
preview: Flags.boolean({
description: 'Show full diff preview for each hunk (legacy flag, now default behavior)',
default: false,
hidden: true,
}),
inline: Flags.boolean({
char: 'i',
description: 'Show inline summary with stats and first changed line',
default: false,
}),
oneline: Flags.boolean({
char: 'o',
description: 'Show only hunk headers without preview (like git log --oneline)',
default: false,
}),
context: Flags.integer({
char: 'c',
description: 'Number of context lines to show in preview',
default: 3,
}),
exclude: Flags.string({
char: 'e',
description: 'Exclude files matching the pattern (can be used multiple times)',
multiple: true,
default: [],
}),
'respect-gitignore': Flags.boolean({
description: 'Exclude files that are in .gitignore (default: true)',
default: true,
}),
'tracked-only': Flags.boolean({
description: 'Show only tracked files (exclude untracked)',
default: false,
}),
'staged-only': Flags.boolean({
description: 'Show only files that have staged changes',
default: false,
}),
};
static args = {
files: Args.string({
description: 'Files to list hunks from (omit to show all)',
required: false,
}),
};
static strict = false; // Allow multiple files
async run() {
const { argv, flags } = await this.parse(List);
const precise = flags.precise;
if (process.env.DEBUG === '1') {
logger.setLevel(LogLevel.DEBUG);
}
try {
const git = new GitWrapper();
const staging = new StagingService(git.gitRoot);
const renderer = new DiffRenderer();
// Get files to process
let files;
if (argv.length === 0) {
// No files specified, get all changed files with filters
files = await git.getChangedFiles({
trackedOnly: flags['tracked-only'],
stagedOnly: flags['staged-only'],
respectGitignore: flags['respect-gitignore']
});
// Apply exclude patterns
if (flags.exclude && flags.exclude.length > 0) {
const excludePatterns = flags.exclude;
files = files.filter(file => {
return !excludePatterns.some(pattern => {
// Simple glob matching - supports * and **
const regex = pattern
.replace(/\*\*/g, '.*')
.replace(/\*/g, '[^/]*')
.replace(/\?/g, '.');
return new RegExp(`^${regex}$`).test(file);
});
});
}
if (files.length === 0) {
this.log(chalk.yellow('No changes found in repository (after applying filters)'));
return;
}
}
else {
// Use specified files
files = argv;
}
// New behavior: show preview by default unless --oneline is used
// Legacy --preview flag is also respected if explicitly used
const showPreview = flags.preview || (!flags.oneline && !flags.inline);
const showInline = flags.inline || (flags.oneline && !flags.preview);
let hasChanges = false;
// Display current mode at the top
this.log(chalk.dim('─'.repeat(60)));
this.log(chalk.cyan(`Mode: ${precise ? 'precise (-p)' : 'normal'} | Hunk IDs are mode-specific`));
this.log(chalk.dim('─'.repeat(60)));
for (const file of files) {
try {
const hunks = await staging.listHunksWithInfo(file, { precise });
if (hunks.length === 0) {
continue;
}
hasChanges = true;
// Show file header
this.log(chalk.bold.blue(`\n${file}:`));
hunks.forEach((hunk) => {
// Format: [index|id] header (stats) | summary
let prefix = ' ';
let color = chalk.green;
// Show staged status
if (hunk.layer === 'staged') {
prefix = '✓ ';
color = chalk.gray;
}
let line = color(`${prefix}[${hunk.index}|${hunk.id}] ${hunk.header}`);
if (showInline) {
const summary = renderer.renderHunkSummary(hunk);
if (summary) {
line += ' ' + summary;
}
}
// Add [STAGED] indicator
if (hunk.layer === 'staged') {
line += chalk.yellow(' [STAGED]');
}
this.log(line);
if (showPreview) {
const preview = renderer.renderHunk(hunk, { context: flags.context });
if (preview) {
const indentedPreview = preview.split('\n').map(l => ' ' + l).join('\n');
this.log(indentedPreview);
this.log(''); // Empty line between hunks
}
}
});
}
catch (error) {
// Skip files that can't be processed (e.g., binary files)
if (process.env.DEBUG === '1') {
logger.debug(`Skipping ${file}: ${error}`);
}
}
}
if (!hasChanges) {
this.log(chalk.yellow('No changes found in specified files'));
return;
}
this.log('');
this.log(chalk.dim('─'.repeat(60)));
const modeFlag = precise ? ' -p' : '';
this.log(`Use: ${chalk.cyan(`${this.config.bin} hunk${modeFlag} <file>:<number|id>`)} to stage a specific hunk`);
this.log(` ${chalk.cyan(`${this.config.bin} hunk${modeFlag} <file> <number|id>`)} (original syntax)`);
if (!precise) {
this.log(`\nTip: Use ${chalk.yellow('-p')} or ${chalk.yellow('--precise')} for more granular hunks`);
}
else {
this.log(`\n${chalk.dim('Note: Using precise mode - remember to use -p with hunk command too')}`);
}
}
catch (error) {
logger.error(error instanceof Error ? error.message : String(error));
this.exit(1);
}
}
}