Skip to content

Commit 753dfe5

Browse files
committed
feat(cli): complete context-aware dev owners implementation
Major improvements to dev owners command: **Context-Aware Modes:** - Changed files mode: Shows ownership of uncommitted changes - Root directory mode: High-level overview (packages/cli/, packages/core/) - Subdirectory mode: Detailed expertise for specific area **Smart Ownership Display:** - Asymmetric icons: No icon for your files (minimal noise) - ⚠️ flags files owned by others (actionable) - 🆕 flags truly new files with no history - Shows last touched timestamp for all files - Detects recent activity by others on your files **Technical Improvements:** - Real-time ownership via git log for uncommitted changes - Git root detection for subdirectory support - Tracks both primary owner and recent contributors - Suggests reviewers when modifying others' code **Examples:** Your file: └─ src/auth.ts @you • 50 commits • Last: 6 months ago Your file + recent activity by others: └─ src/auth.ts @you • 50 commits • Last: 6 months ago ⚠️ Recent activity by @alice (yesterday) Someone else's file: └─ ⚠️ src/session.ts @alice • 12 commits • Last: 2 years ago New file: └─ 🆕 src/feature.ts New file Removed legacy --all option and table format.
1 parent 34f9df4 commit 753dfe5

File tree

2 files changed

+163
-39
lines changed

2 files changed

+163
-39
lines changed

.changeset/perf-indexing-ux-improvements.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
---
22
"@lytics/dev-agent-core": minor
33
"@lytics/dev-agent-cli": minor
4+
"@lytics/dev-agent": patch
45
---
56

67
Massive indexing performance and UX improvements
@@ -28,11 +29,16 @@ Massive indexing performance and UX improvements
2829
- **Cleaner completion summary**: Removed storage size from index output (shown in `dev stats` instead)
2930
- **Continuous feedback**: Maximum 1-second gaps between progress updates
3031
- **Context-aware `dev owners` command**: Adapts output based on git status and current directory
31-
- **Changed files mode**: Shows ownership of uncommitted changes (for PR reviews)
32+
- **Changed files mode**: Shows ownership of uncommitted changes with real-time git log analysis
3233
- **Root directory mode**: High-level overview of top areas (packages/cli/, packages/core/)
3334
- **Subdirectory mode**: Detailed expertise for specific area
35+
- **Smart ownership display**: Asymmetric icons that only flag exceptions (⚠️ for others' files, 🆕 for new files)
36+
- **Last touched timestamps**: Shows when files were last modified (catches stale code and active development)
37+
- **Recent activity detection**: Warns when others recently touched your files (prevents conflicts)
38+
- **Suggested reviewers**: Automatically identifies who to loop in for code reviews
3439
- **Visual hierarchy**: Tree branches (├─, └─) and emojis (📝, 📁, 👤) for better readability
3540
- **Activity-focused**: Sorted by last active, not file count (no more leaderboard vibes)
41+
- **Git root detection**: Works from any subdirectory within the repository
3642
- **Better developer grouping**: `dev owners` now groups by GitHub handle instead of email (merges multiple emails for same developer)
3743
- **Graceful degradation**: Verbose mode and non-TTY environments show traditional log output
3844

packages/cli/src/commands/owners.ts

Lines changed: 156 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,11 @@
22
* Owners command - Show code ownership and developer contributions
33
*/
44

5+
import { execSync } from 'node:child_process';
56
import * as path from 'node:path';
67
import { getStoragePath, MetricsStore } from '@lytics/dev-agent-core';
78
import chalk from 'chalk';
89
import { Command } from 'commander';
9-
import { loadConfig } from '../utils/config.js';
1010
import { logger } from '../utils/logger.js';
1111

1212
/**
@@ -108,6 +108,119 @@ function getCurrentDirectory(repositoryPath: string): string {
108108
return `${cwd.replace(repositoryPath, '').replace(/^\//, '')}/`;
109109
}
110110

111+
/**
112+
* Get git repository root (or process.cwd() if not in git repo)
113+
*/
114+
function getGitRoot(): string {
115+
try {
116+
const output = execSync('git rev-parse --show-toplevel', {
117+
encoding: 'utf-8',
118+
stdio: ['pipe', 'pipe', 'ignore'],
119+
});
120+
return output.trim();
121+
} catch {
122+
return process.cwd();
123+
}
124+
}
125+
126+
/**
127+
* Get ownership for specific files using git log (for uncommitted changes)
128+
*/
129+
function getFileOwnership(
130+
repositoryPath: string,
131+
filePaths: string[]
132+
): Map<
133+
string,
134+
{
135+
owner: string;
136+
commits: number;
137+
lastActive: Date | null;
138+
recentContributor?: { name: string; lastActive: Date | null };
139+
}
140+
> {
141+
const fileOwners = new Map<
142+
string,
143+
{
144+
owner: string;
145+
commits: number;
146+
lastActive: Date | null;
147+
recentContributor?: { name: string; lastActive: Date | null };
148+
}
149+
>();
150+
151+
for (const filePath of filePaths) {
152+
try {
153+
const absolutePath = path.join(repositoryPath, filePath);
154+
const output = execSync(
155+
`git log --follow --format='%ae|%aI' --numstat -- "${absolutePath}" | head -100`,
156+
{
157+
cwd: repositoryPath,
158+
encoding: 'utf-8',
159+
stdio: ['pipe', 'pipe', 'ignore'],
160+
}
161+
);
162+
163+
const lines = output.trim().split('\n');
164+
const authors = new Map<string, { commits: number; lastActive: Date | null }>();
165+
166+
let currentEmail = '';
167+
let currentDate: Date | null = null;
168+
169+
for (const line of lines) {
170+
if (line.includes('|')) {
171+
// Author line: email|date
172+
const [email, dateStr] = line.split('|');
173+
currentEmail = email.trim();
174+
currentDate = new Date(dateStr);
175+
176+
const existing = authors.get(currentEmail);
177+
if (!existing) {
178+
authors.set(currentEmail, { commits: 1, lastActive: currentDate });
179+
} else {
180+
existing.commits++;
181+
if (!existing.lastActive || currentDate > existing.lastActive) {
182+
existing.lastActive = currentDate;
183+
}
184+
}
185+
}
186+
}
187+
188+
if (authors.size > 0) {
189+
// Get primary author (most commits)
190+
const sortedByCommits = Array.from(authors.entries()).sort(
191+
(a, b) => b[1].commits - a[1].commits
192+
);
193+
const [primaryEmail, primaryData] = sortedByCommits[0];
194+
const primaryHandle = getDisplayName(primaryEmail, repositoryPath);
195+
196+
// Find most recent contributor
197+
const sortedByRecency = Array.from(authors.entries()).sort((a, b) => {
198+
const dateA = a[1].lastActive?.getTime() || 0;
199+
const dateB = b[1].lastActive?.getTime() || 0;
200+
return dateB - dateA;
201+
});
202+
const [recentEmail, recentData] = sortedByRecency[0];
203+
const recentHandle = getDisplayName(recentEmail, repositoryPath);
204+
205+
// Check if recent contributor is different from primary owner
206+
const recentContributor =
207+
recentHandle !== primaryHandle
208+
? { name: recentHandle, lastActive: recentData.lastActive }
209+
: undefined;
210+
211+
fileOwners.set(filePath, {
212+
owner: primaryHandle,
213+
commits: primaryData.commits,
214+
lastActive: primaryData.lastActive,
215+
recentContributor,
216+
});
217+
}
218+
} catch {}
219+
}
220+
221+
return fileOwners;
222+
}
223+
111224
/**
112225
* Calculate developer ownership from indexed data (instant, no git calls!)
113226
*/
@@ -224,7 +337,15 @@ async function calculateDeveloperOwnership(
224337
*/
225338
function formatChangedFilesMode(
226339
changedFiles: string[],
227-
fileOwners: Map<string, { owner: string; commits: number; lastActive: Date | null }>,
340+
fileOwners: Map<
341+
string,
342+
{
343+
owner: string;
344+
commits: number;
345+
lastActive: Date | null;
346+
recentContributor?: { name: string; lastActive: Date | null };
347+
}
348+
>,
228349
currentUser: string,
229350
_repositoryPath: string
230351
): string {
@@ -243,19 +364,38 @@ function formatChangedFilesMode(
243364
const displayPath = file.length > 60 ? `...${file.slice(-57)}` : file;
244365

245366
if (!ownerInfo) {
246-
output += chalk.dim(` ${prefix} ${displayPath}\n`);
247-
output += chalk.dim(` ${isLast ? ' ' : '│'} Owner: Unknown (new file?)\n`);
367+
// New file - no history
368+
output += ` ${chalk.gray(prefix)} 🆕 ${chalk.white(displayPath)}\n`;
369+
output += chalk.dim(` ${isLast ? ' ' : '│'} New file\n`);
248370
} else {
249371
const isYours = ownerInfo.owner === currentUser;
250-
const icon = isYours ? '✅' : '⚠️ ';
251-
252-
output += ` ${chalk.gray(prefix)} ${icon} ${chalk.white(displayPath)}\n`;
253-
output += chalk.dim(
254-
` ${isLast ? ' ' : '│'} Owner: ${isYours ? 'You' : ownerInfo.owner} (${chalk.cyan(ownerInfo.owner)})`
255-
);
256-
output += chalk.dim(` • ${ownerInfo.commits} commits\n`);
372+
const lastTouched = ownerInfo.lastActive
373+
? formatRelativeTime(ownerInfo.lastActive)
374+
: 'unknown';
375+
376+
if (isYours) {
377+
// Your file - no icon, minimal noise
378+
output += ` ${chalk.gray(prefix)} ${chalk.white(displayPath)}\n`;
379+
output += chalk.dim(
380+
` ${isLast ? ' ' : '│'} ${chalk.cyan(ownerInfo.owner)}${ownerInfo.commits} commits • Last: ${lastTouched}\n`
381+
);
257382

258-
if (!isYours) {
383+
// Check if someone else touched it recently
384+
if (ownerInfo.recentContributor) {
385+
const recentTime = ownerInfo.recentContributor.lastActive
386+
? formatRelativeTime(ownerInfo.recentContributor.lastActive)
387+
: 'recently';
388+
output += chalk.dim(
389+
` ${isLast ? ' ' : '│'} ${chalk.yellow(`⚠️ Recent activity by ${chalk.cyan(ownerInfo.recentContributor.name)} (${recentTime})`)}\n`
390+
);
391+
reviewers.add(ownerInfo.recentContributor.name);
392+
}
393+
} else {
394+
// Someone else's file - flag for review
395+
output += ` ${chalk.gray(prefix)} ⚠️ ${chalk.white(displayPath)}\n`;
396+
output += chalk.dim(
397+
` ${isLast ? ' ' : '│'} ${chalk.cyan(ownerInfo.owner)}${ownerInfo.commits} commits • Last: ${lastTouched}\n`
398+
);
259399
reviewers.add(ownerInfo.owner);
260400
}
261401
}
@@ -411,15 +551,8 @@ export const ownersCommand = new Command('owners')
411551
.option('--json', 'Output as JSON', false)
412552
.action(async (options) => {
413553
try {
414-
const config = await loadConfig();
415-
if (!config) {
416-
logger.error('No config found. Run "dev init" first.');
417-
process.exit(1);
418-
}
419-
420-
const repositoryPath = path.resolve(
421-
config.repository?.path || config.repositoryPath || process.cwd()
422-
);
554+
// Always use git root for metrics lookup (config paths may be relative)
555+
const repositoryPath = getGitRoot();
423556
const storagePath = await getStoragePath(repositoryPath);
424557
const metricsDbPath = path.join(storagePath, 'metrics.db');
425558

@@ -463,23 +596,8 @@ export const ownersCommand = new Command('owners')
463596
if (changedFiles.length > 0) {
464597
const currentUser = getCurrentUser(repositoryPath);
465598

466-
// Build file ownership map
467-
const fileOwners = new Map<
468-
string,
469-
{ owner: string; commits: number; lastActive: Date | null }
470-
>();
471-
for (const dev of developers) {
472-
for (const fileData of dev.topFiles) {
473-
const relativePath = fileData.path.replace(`${repositoryPath}/`, '');
474-
if (!fileOwners.has(relativePath)) {
475-
fileOwners.set(relativePath, {
476-
owner: dev.displayName,
477-
commits: fileData.commits,
478-
lastActive: dev.lastActive,
479-
});
480-
}
481-
}
482-
}
599+
// Get real-time ownership for changed files using git log
600+
const fileOwners = getFileOwnership(repositoryPath, changedFiles);
483601

484602
console.log(formatChangedFilesMode(changedFiles, fileOwners, currentUser, repositoryPath));
485603
console.log('');

0 commit comments

Comments
 (0)