@@ -221,8 +221,21 @@ func main() {
221221 githubLevel := flag .String ("github-level" , "warning" , "GitHub annotation level: notice, warning, or error" )
222222 gitDiff := flag .String ("git-diff" , "" , "Only annotate files changed vs this git ref (e.g., origin/main)" )
223223 exclude := flag .String ("exclude" , "" , "Exclude files matching patterns (comma-separated, e.g., '*.pb.go,*_gen.go')" )
224+ compare := flag .String ("compare" , "" , "Compare duplicates between two commits (format: base..head)" )
224225 flag .Parse ()
225226
227+ // Handle compare mode
228+ if * compare != "" {
229+ parts := strings .Split (* compare , ".." )
230+ if len (parts ) != 2 {
231+ fmt .Fprintf (os .Stderr , "Error: --compare requires format 'base..head'\n " )
232+ os .Exit (1 )
233+ }
234+ baseRef , headRef := parts [0 ], parts [1 ]
235+ runCompare (baseRef , headRef , * ext , * exclude , * minOccur , * minScore , * minSize , * minSimilarity )
236+ return
237+ }
238+
226239 // Parse exclude patterns
227240 var excludePatterns []string
228241 if * exclude != "" {
@@ -1284,3 +1297,184 @@ func computeAverageTokenSimilarity(locations []PatternLocation) float64 {
12841297 }
12851298 return totalSim / float64 (pairs )
12861299}
1300+
1301+ // runCompare compares duplicate patterns between two git commits
1302+ func runCompare (baseRef , headRef , ext , exclude string , minOccur , minScore , minSize int , minSimilarity float64 ) {
1303+ fmt .Printf ("Comparing duplicates: %s -> %s\n \n " , baseRef , headRef )
1304+
1305+ // Create temporary worktrees
1306+ baseDir , err := os .MkdirTemp ("" , "quickdup-base-" )
1307+ if err != nil {
1308+ fmt .Fprintf (os .Stderr , "Error creating temp dir: %v\n " , err )
1309+ os .Exit (1 )
1310+ }
1311+ defer os .RemoveAll (baseDir )
1312+
1313+ headDir , err := os .MkdirTemp ("" , "quickdup-head-" )
1314+ if err != nil {
1315+ fmt .Fprintf (os .Stderr , "Error creating temp dir: %v\n " , err )
1316+ os .Exit (1 )
1317+ }
1318+ defer os .RemoveAll (headDir )
1319+
1320+ // Create worktrees
1321+ fmt .Printf ("Creating worktree for %s...\n " , baseRef )
1322+ cmd := exec .Command ("git" , "worktree" , "add" , "--detach" , baseDir , baseRef )
1323+ if output , err := cmd .CombinedOutput (); err != nil {
1324+ fmt .Fprintf (os .Stderr , "Error creating base worktree: %v\n %s\n " , err , output )
1325+ os .Exit (1 )
1326+ }
1327+ defer exec .Command ("git" , "worktree" , "remove" , "--force" , baseDir ).Run ()
1328+
1329+ fmt .Printf ("Creating worktree for %s...\n " , headRef )
1330+ cmd = exec .Command ("git" , "worktree" , "add" , "--detach" , headDir , headRef )
1331+ if output , err := cmd .CombinedOutput (); err != nil {
1332+ fmt .Fprintf (os .Stderr , "Error creating head worktree: %v\n %s\n " , err , output )
1333+ os .Exit (1 )
1334+ }
1335+ defer exec .Command ("git" , "worktree" , "remove" , "--force" , headDir ).Run ()
1336+
1337+ // Build args for quickdup
1338+ args := []string {
1339+ "-ext" , ext ,
1340+ "-min" , fmt .Sprintf ("%d" , minOccur ),
1341+ "-min-score" , fmt .Sprintf ("%d" , minScore ),
1342+ "-min-size" , fmt .Sprintf ("%d" , minSize ),
1343+ "-min-similarity" , fmt .Sprintf ("%f" , minSimilarity ),
1344+ "--no-cache" ,
1345+ }
1346+ if exclude != "" {
1347+ args = append (args , "-exclude" , exclude )
1348+ }
1349+
1350+ // Run quickdup on base
1351+ fmt .Printf ("\n Scanning %s...\n " , baseRef )
1352+ baseArgs := append ([]string {"-path" , baseDir }, args ... )
1353+ cmd = exec .Command (os .Args [0 ], baseArgs ... )
1354+ cmd .Stdout = os .Stdout
1355+ cmd .Stderr = os .Stderr
1356+ if err := cmd .Run (); err != nil {
1357+ fmt .Fprintf (os .Stderr , "Warning: quickdup on base returned error: %v\n " , err )
1358+ }
1359+
1360+ // Run quickdup on head
1361+ fmt .Printf ("\n Scanning %s...\n " , headRef )
1362+ headArgs := append ([]string {"-path" , headDir }, args ... )
1363+ cmd = exec .Command (os .Args [0 ], headArgs ... )
1364+ cmd .Stdout = os .Stdout
1365+ cmd .Stderr = os .Stderr
1366+ if err := cmd .Run (); err != nil {
1367+ fmt .Fprintf (os .Stderr , "Warning: quickdup on head returned error: %v\n " , err )
1368+ }
1369+
1370+ // Load results from both
1371+ baseResults := loadJSONResults (filepath .Join (baseDir , ".quickdup" , "results.json" ))
1372+ headResults := loadJSONResults (filepath .Join (headDir , ".quickdup" , "results.json" ))
1373+
1374+ // Build hash -> occurrences maps
1375+ baseOccur := make (map [string ]int )
1376+ for _ , p := range baseResults .Patterns {
1377+ baseOccur [p .Hash ] = p .Occurrences
1378+ }
1379+
1380+ headOccur := make (map [string ]int )
1381+ headPatterns := make (map [string ]JSONPattern )
1382+ for _ , p := range headResults .Patterns {
1383+ headOccur [p .Hash ] = p .Occurrences
1384+ headPatterns [p .Hash ] = p
1385+ }
1386+
1387+ // Find lingering duplicates (reduced but not eliminated)
1388+ fmt .Printf ("\n %s\n " , strings .Repeat ("=" , 60 ))
1389+ fmt .Printf ("COMPARISON RESULTS: %s -> %s\n " , baseRef , headRef )
1390+ fmt .Printf ("%s\n \n " , strings .Repeat ("=" , 60 ))
1391+
1392+ type lingering struct {
1393+ hash string
1394+ baseCount int
1395+ headCount int
1396+ removed int
1397+ pattern JSONPattern
1398+ }
1399+ var lingeringPatterns []lingering
1400+
1401+ for hash , baseCount := range baseOccur {
1402+ headCount := headOccur [hash ]
1403+ if headCount > 0 && headCount < baseCount {
1404+ lingeringPatterns = append (lingeringPatterns , lingering {
1405+ hash : hash ,
1406+ baseCount : baseCount ,
1407+ headCount : headCount ,
1408+ removed : baseCount - headCount ,
1409+ pattern : headPatterns [hash ],
1410+ })
1411+ }
1412+ }
1413+
1414+ // Sort by removed count descending
1415+ sort .Slice (lingeringPatterns , func (i , j int ) bool {
1416+ return lingeringPatterns [i ].removed > lingeringPatterns [j ].removed
1417+ })
1418+
1419+ if len (lingeringPatterns ) == 0 {
1420+ fmt .Printf ("No lingering duplicates found. All refactoring appears complete!\n " )
1421+ } else {
1422+ fmt .Printf ("Found %d patterns with incomplete refactoring:\n \n " , len (lingeringPatterns ))
1423+ for _ , l := range lingeringPatterns {
1424+ fmt .Printf ("%s %s removed, %s lingering - potentially missed refactoring?\n " ,
1425+ hashStyle .Render (fmt .Sprintf ("[%s]" , l .hash )),
1426+ summaryStyle .Render (fmt .Sprintf ("%d" , l .removed )),
1427+ scoreStyle .Render (fmt .Sprintf ("%d" , l .headCount )))
1428+ if len (l .pattern .Pattern ) > 0 {
1429+ fmt .Printf (" Pattern preview: %s\n " , dimStyle .Render (truncate (l .pattern .Pattern [0 ], 60 )))
1430+ }
1431+ fmt .Printf (" Remaining locations:\n " )
1432+ for _ , loc := range l .pattern .Locations {
1433+ // Make path relative by stripping worktree prefix
1434+ relPath := strings .TrimPrefix (loc .Filename , headDir + "/" )
1435+ fmt .Printf (" %s\n " , locationStyle .Render (fmt .Sprintf ("%s:%d" , relPath , loc .LineStart )))
1436+ }
1437+ fmt .Println ()
1438+ }
1439+ }
1440+
1441+ // Also report completely removed patterns
1442+ var fullyRemoved int
1443+ for hash , baseCount := range baseOccur {
1444+ if headOccur [hash ] == 0 {
1445+ fullyRemoved ++
1446+ _ = baseCount // unused but shows intent
1447+ }
1448+ }
1449+ if fullyRemoved > 0 {
1450+ fmt .Printf ("\n %s duplicate patterns were completely removed.\n " , summaryStyle .Render (fmt .Sprintf ("%d" , fullyRemoved )))
1451+ }
1452+
1453+ // Report new patterns
1454+ var newPatterns int
1455+ for hash := range headOccur {
1456+ if baseOccur [hash ] == 0 {
1457+ newPatterns ++
1458+ }
1459+ }
1460+ if newPatterns > 0 {
1461+ fmt .Printf ("%s new duplicate patterns were introduced.\n " , scoreStyle .Render (fmt .Sprintf ("%d" , newPatterns )))
1462+ }
1463+ }
1464+
1465+ func loadJSONResults (path string ) JSONOutput {
1466+ data , err := os .ReadFile (path )
1467+ if err != nil {
1468+ return JSONOutput {}
1469+ }
1470+ var output JSONOutput
1471+ json .Unmarshal (data , & output )
1472+ return output
1473+ }
1474+
1475+ func truncate (s string , maxLen int ) string {
1476+ if len (s ) <= maxLen {
1477+ return s
1478+ }
1479+ return s [:maxLen - 3 ] + "..."
1480+ }
0 commit comments