22 * Owners command - Show code ownership and developer contributions
33 */
44
5+ import { execSync } from 'node:child_process' ;
56import * as path from 'node:path' ;
67import { getStoragePath , MetricsStore } from '@lytics/dev-agent-core' ;
78import chalk from 'chalk' ;
89import { Command } from 'commander' ;
9- import { loadConfig } from '../utils/config.js' ;
1010import { 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 */
225338function 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