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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,9 @@
"main": "src/index.js",
"scripts": {
"dev": "node bin/autopilot.js",
"test": "node --test",
"test": "node --test --test-concurrency=1",
"lint": "node -c bin/autopilot.js && node -c src/index.js",
"verify": "node bin/autopilot.js --help && node bin/autopilot.js doctor && node --test",
"verify": "node bin/autopilot.js --help && node bin/autopilot.js doctor && npm test",
"prepublishOnly": "npm run verify",
"release:patch": "npm run verify && npm version patch && git push --follow-tags && echo \"\n🚀 Release initiated! GitHub Action will publish to NPM.\"",
"release:minor": "npm run verify && npm version minor && git push --follow-tags && echo \"\n🚀 Release initiated! GitHub Action will publish to NPM.\"",
Expand Down
28 changes: 16 additions & 12 deletions src/commands/dashboard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const e = React.createElement;
const Dashboard = () => {
const { exit } = useApp();
const root = process.cwd();

const [status, setStatus] = useState('loading');
const [pid, setPid] = useState(null);
const [lastCommit, setLastCommit] = useState(null);
Expand All @@ -34,18 +34,18 @@ const Dashboard = () => {
// 1. Check process status
const currentPid = await getRunningPid(root);
setPid(currentPid);

// 2. Check Paused State
const stateManager = new StateManager(root);
if (stateManager.isPaused()) {
setStatus('paused');
setPausedState(stateManager.getState());
setStatus('paused');
setPausedState(stateManager.getState());
} else if (currentPid) {
setStatus('running');
setPausedState(null);
setStatus('running');
setPausedState(null);
} else {
setStatus('stopped');
setPausedState(null);
setStatus('stopped');
setPausedState(null);
}

// 3. Last Commit
Expand All @@ -56,7 +56,7 @@ const Dashboard = () => {
// 4. Pending Files
const statusObj = await git.getPorcelainStatus(root);
if (statusObj.ok) {
setPendingFiles(statusObj.files);
setPendingFiles(statusObj.files);
}

// 5. Today Stats (Simple count from history)
Expand Down Expand Up @@ -126,10 +126,10 @@ const Dashboard = () => {
e(Box, { flexDirection: "column", marginBottom: 1 },
e(Text, { underline: true }, `Pending Changes (${pendingFiles.length})`),
e(Box, { flexDirection: "column" },
pendingFiles.length === 0 ?
pendingFiles.length === 0 ?
e(Text, { color: "gray" }, "No pending changes") :
pendingFiles.slice(0, 5).map((f) =>
e(Text, { key: f.file, color: "yellow" }, ` ${f.status} ${f.file}`)
pendingFiles.slice(0, 5).map((f, idx) =>
e(Text, { key: `${f.file}-${idx}`, color: "yellow" }, ` ${f.status} ${f.file}`)
)
),
pendingFiles.length > 5 && e(Text, { color: "gray" }, ` ...and ${pendingFiles.length - 5} more`)
Expand All @@ -143,5 +143,9 @@ const Dashboard = () => {
};

export default function runDashboard() {
if (!process.stdin.isTTY && !process.env.AUTOPILOT_TEST_MODE) {
console.error('Error: Dashboard requires an interactive terminal (TTY).');
process.exit(1);
}
render(e(Dashboard));
}
18 changes: 14 additions & 4 deletions src/commands/doctor.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const git = require('../core/git');
const doctor = async () => {
const repoPath = process.cwd();
let issues = 0;

logger.section('Autopilot Doctor');
logger.info('Diagnosing environment...');

Expand Down Expand Up @@ -50,7 +50,17 @@ const doctor = async () => {

// Check remote type
if (remoteUrl.startsWith('http')) {
logger.warn('Remote uses HTTPS. Ensure credential helper is configured for non-interactive push.');
let hasHelper = false;
try {
const { stdout: helper } = await execa('git', ['config', '--get', 'credential.helper'], { cwd: repoPath });
if (helper.trim()) hasHelper = true;
} catch (e) { /* ignore */ }

if (hasHelper) {
logger.success('Remote uses HTTPS with credential helper configured.');
} else {
logger.warn('Remote uses HTTPS. Ensure credential helper is configured for non-interactive push.');
}
} else if (remoteUrl.startsWith('git@') || remoteUrl.startsWith('ssh://')) {
logger.success('Remote uses SSH (recommended).');
} else {
Expand Down Expand Up @@ -102,8 +112,8 @@ const doctor = async () => {
logger.success('Branch is up to date with remote.');
}
} else {
// Could be no upstream configured, which is fine for local-only initially
logger.info('Could not check remote status (upstream might not be set).');
// Could be no upstream configured, which is fine for local-only initially
logger.info('Could not check remote status (upstream might not be set).');
}
} catch (error) {
logger.info('Skipping remote status check.');
Expand Down
100 changes: 33 additions & 67 deletions src/commands/insights.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,86 +6,52 @@ const { createObjectCsvWriter } = require('csv-writer');

async function getGitStats(repoPath) {
try {
// Get commit log with stats
// We use custom delimiters to safely parse multi-line bodies and stats
const { stdout } = await git.runGit(repoPath, [
'log',
'--pretty=format:====COMMIT====%n%H|%an|%ad|%s|%b%n====BODY_END====',
'--pretty=format:===C===%H|%an|%ad|%s|%b===E===',
'--date=iso',
'--numstat'
]);

if (!stdout) return [];

const commits = [];
const rawCommits = stdout.split('====COMMIT====');
const rawCommits = stdout.split('===C===').filter(Boolean);

for (const raw of rawCommits) {
if (!raw.trim()) continue;

const [metadataPart, statsPart] = raw.split('====BODY_END====');
if (!metadataPart) continue;

const lines = metadataPart.trim().split('\n');
const header = lines[0]; // hash|author|date|subject|body_start...
// The body might continue on next lines if %b has newlines.
// Actually, my format puts %b starting on the first line.
// But let's be safer: split header by | first 4 times only.

// header format: hash|author|date|subject|rest...
// But wait, if body has newlines, "lines" array has them.

// Let's reconstruct the full message body
const fullMetadata = metadataPart.trim();
const firstPipe = fullMetadata.indexOf('|');
const secondPipe = fullMetadata.indexOf('|', firstPipe + 1);
const thirdPipe = fullMetadata.indexOf('|', secondPipe + 1);
const fourthPipe = fullMetadata.indexOf('|', thirdPipe + 1);

if (firstPipe === -1 || fourthPipe === -1) continue;

const hash = fullMetadata.substring(0, firstPipe);
const author = fullMetadata.substring(firstPipe + 1, secondPipe);
const dateStr = fullMetadata.substring(secondPipe + 1, thirdPipe);
const subject = fullMetadata.substring(thirdPipe + 1, fourthPipe);
const body = fullMetadata.substring(fourthPipe + 1);

// TRUST VERIFICATION
// Check for Autopilot trailers
if (!body.includes('Autopilot-Commit: true')) {
continue; // Skip non-autopilot commits
}
const [metadataPlusBody, ...statsParts] = raw.split('===E===');
if (!metadataPlusBody) continue;

const [hash, author, dateStr, subject, ...bodyParts] = metadataPlusBody.trim().split('|');
const body = bodyParts.join('|'); // Rejoin in case body had pipes

// TODO: Verify Signature (Optional but recommended for strict mode)
// const signature = extractTrailer(body, 'Autopilot-Signature');
// if (!verifySignature(signature, ...)) continue;
// Trust Verification: Only process autopilot commits
if (!body.includes('Autopilot-Commit: true')) continue;

const commit = {
hash,
author,
date: new Date(dateStr),
message: subject + '\n' + body,
message: `${subject}\n${body}`.trim(),
files: [],
additions: 0,
deletions: 0
};

// Parse Stats
if (statsPart) {
const statLines = statsPart.trim().split('\n');
for (const statLine of statLines) {
if (!statLine.trim()) continue;
const parts = statLine.split(/\s+/);
if (parts.length >= 3) {
const additions = parseInt(parts[0]) || 0;
const deletions = parseInt(parts[1]) || 0;
const file = parts.slice(2).join(' '); // handle spaces in filenames

const statsText = statsParts.join('===E===').trim();
if (statsText) {
const statLines = statsText.split('\n');
for (const line of statLines) {
const [add, del, file] = line.trim().split(/\s+/);
if (file) {
const additions = parseInt(add) || 0;
const deletions = parseInt(del) || 0;
commit.files.push({ file, additions, deletions });
commit.additions += additions;
commit.deletions += deletions;
}
}
}

commits.push(commit);
}

Expand Down Expand Up @@ -130,7 +96,7 @@ function calculateMetrics(commits) {
// Time analysis
const dateStr = c.date.toISOString().split('T')[0];
const hour = c.date.getHours();

stats.commitsByDay[dateStr] = (stats.commitsByDay[dateStr] || 0) + 1;
stats.commitsByHour[hour] = (stats.commitsByHour[hour] || 0) + 1;
dates.add(dateStr);
Expand All @@ -145,7 +111,7 @@ function calculateMetrics(commits) {
// Calculate Averages
stats.totalFilesCount = stats.totalFilesChanged.size;
stats.quality.avgLength = commits.length ? Math.round(totalMessageLength / commits.length) : 0;

// Calculate Score (0-100)
// 40% Conventional, 30% Message Length (>30 chars), 30% Consistency
const convScore = commits.length ? (stats.quality.conventional / commits.length) * 40 : 0;
Expand All @@ -165,7 +131,7 @@ function calculateMetrics(commits) {
currentStreak = 1;
} else {
const diffTime = Math.abs(d - lastDate);
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
if (diffDays === 1) {
currentStreak++;
} else {
Expand All @@ -176,12 +142,12 @@ function calculateMetrics(commits) {
lastDate = d;
});
stats.streak.max = Math.max(maxStreak, currentStreak);

// Check if streak is active (last commit today or yesterday)
const today = new Date().toISOString().split('T')[0];
const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0];
const lastCommitDate = sortedDates[sortedDates.length - 1];

if (lastCommitDate === today || lastCommitDate === yesterday) {
stats.streak.current = currentStreak;
} else {
Expand Down Expand Up @@ -217,10 +183,10 @@ async function insights(options) {
console.log(`Lines Added: ${metrics.totalAdditions}`);
console.log(`Lines Deleted: ${metrics.totalDeletions}`);
console.log(`Current Streak: ${metrics.streak.current} days (Max: ${metrics.streak.max})`);

// Find most productive hour
const productiveHour = Object.entries(metrics.commitsByHour)
.sort(([,a], [,b]) => b - a)[0];
.sort(([, a], [, b]) => b - a)[0];
console.log(`Peak Productivity: ${productiveHour ? productiveHour[0] + ':00' : 'N/A'}`);

console.log('');
Expand All @@ -241,12 +207,12 @@ async function insights(options) {
const csvWriter = createObjectCsvWriter({
path: csvPath,
header: [
{id: 'hash', title: 'Hash'},
{id: 'date', title: 'Date'},
{id: 'author', title: 'Author'},
{id: 'message', title: 'Message'},
{id: 'additions', title: 'Additions'},
{id: 'deletions', title: 'Deletions'}
{ id: 'hash', title: 'Hash' },
{ id: 'date', title: 'Date' },
{ id: 'author', title: 'Author' },
{ id: 'message', title: 'Message' },
{ id: 'additions', title: 'Additions' },
{ id: 'deletions', title: 'Deletions' }
]
});

Expand Down
1 change: 1 addition & 0 deletions src/commands/leaderboard.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ async function leaderboard(options) {
await syncLeaderboard(apiUrl, options);
} else {
logger.info(`Opening leaderboard at ${apiUrl}/leaderboard...`);
const { default: open } = await import('open');
await open(`${apiUrl}/leaderboard`);
}
}
Expand Down
11 changes: 6 additions & 5 deletions src/core/git.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,14 @@ async function getPorcelainStatus(root) {
try {
const { stdout } = await execa('git', ['status', '--porcelain'], { cwd: root });
const raw = stdout.trim();

if (!raw) {
return { ok: true, files: [], raw: '' };
}

const files = raw
.split(/\r?\n/)
.filter(line => line.trim().length > 0)
.map(line => {
const status = line.slice(0, 2).trim();
const file = line.slice(3).trim();
Expand Down Expand Up @@ -134,7 +135,7 @@ async function isRemoteAhead(root) {

const { stdout } = await execa('git', ['rev-list', '--left-right', '--count', `${branch}...origin/${branch}`], { cwd: root });
const [aheadCount, behindCount] = stdout.trim().split(/\s+/).map(Number);

return {
ok: true,
ahead: aheadCount > 0,
Expand Down Expand Up @@ -259,10 +260,10 @@ async function isMergeInProgress(root) {
'REVERT_HEAD',
'BISECT_LOG'
];

// Check if .git/rebase-merge or .git/rebase-apply exists (directory check)
if (await fs.pathExists(path.join(gitDir, 'rebase-merge')) ||
await fs.pathExists(path.join(gitDir, 'rebase-apply'))) {
if (await fs.pathExists(path.join(gitDir, 'rebase-merge')) ||
await fs.pathExists(path.join(gitDir, 'rebase-apply'))) {
return true;
}

Expand Down
10 changes: 8 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ const { doctor } = require('./commands/doctor');
const { insights } = require('./commands/insights');
const pauseCommand = require('./commands/pause');
const resumeCommand = require('./commands/resume');
const runDashboard = require('./commands/dashboard');
const { leaderboard } = require('./commands/leaderboard');
const pkg = require('../package.json');

Expand Down Expand Up @@ -65,7 +64,14 @@ function run() {
program
.command('dashboard')
.description('View real-time Autopilot dashboard')
.action(runDashboard);
.action(async () => {
try {
const { default: runDashboard } = await import('./commands/dashboard.mjs');
runDashboard();
} catch (error) {
console.error('Failed to launch dashboard:', error);
}
});

program
.command('doctor')
Expand Down
Loading
Loading