Skip to content

Commit 5dff8fc

Browse files
rogeralsingclaude
andcommitted
Add --compare flag for detecting incomplete refactoring
Compare duplicates between two git commits using worktrees: quickdup --compare base..head Reports: - Lingering duplicates (occurrences reduced but not eliminated) - Fully removed patterns - New patterns introduced 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ba63a65 commit 5dff8fc

1 file changed

Lines changed: 194 additions & 0 deletions

File tree

cmd/quickdup/main.go

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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("\nScanning %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("\nScanning %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

Comments
 (0)