1818import java .nio .charset .StandardCharsets ;
1919import java .nio .file .Files ;
2020import java .nio .file .Path ;
21+ import java .time .Instant ;
22+ import java .time .ZoneId ;
23+ import java .time .format .DateTimeFormatter ;
2124import java .util .ArrayList ;
2225import java .util .LinkedHashSet ;
2326import java .util .List ;
@@ -52,6 +55,7 @@ public final class QueryPlanSnapshotCli {
5255 private static final List <String > INTERACTIVE_ACTIONS = List .of (
5356 "run query" ,
5457 "compare existing runs" ,
58+ "rename runs by commit" ,
5559 "list themes" ,
5660 "list queries" ,
5761 "help" );
@@ -61,6 +65,10 @@ public final class QueryPlanSnapshotCli {
6165 "executed" );
6266 private static final String MANUAL_QUERY_ID_ENTRY = "<manual entry>" ;
6367 private static final String MANUAL_RUN_NAME_ENTRY = "<manual entry>" ;
68+ private static final String UNKNOWN_VALUE = "<unknown>" ;
69+ private static final ZoneId LOCAL_ZONE = ZoneId .systemDefault ();
70+ private static final DateTimeFormatter LOCAL_TIME_FORMATTER = DateTimeFormatter
71+ .ofPattern ("yyyy-MM-dd HH:mm:ss z" );
6472 private static final long EXECUTION_REPEAT_SOFT_LIMIT_NANOS = TimeUnit .SECONDS .toNanos (60 );
6573 private static final int EXECUTION_REPEAT_MIN_RUNS = 2 ;
6674 private static final int EXECUTION_REPEAT_MAX_RUNS = 128 ;
@@ -102,6 +110,10 @@ void run(QueryPlanSnapshotCliOptions options) throws Exception {
102110 printThemeQueries (resolved .listQueriesTheme );
103111 return ;
104112 }
113+ if (resolved .renameRunsByCommit ) {
114+ runRenameRunsByCommit (resolved );
115+ return ;
116+ }
105117 if (resolved .compareExisting ) {
106118 runCompareExisting (resolved );
107119 return ;
@@ -127,7 +139,7 @@ private QueryPlanSnapshotCliOptions resolveInteractiveTopLevelAction(QueryPlanSn
127139 throws IOException {
128140 QueryPlanSnapshotCliOptions resolved = options .copy ();
129141 if (resolved .argumentCount != 0 || resolved .noInteractive || resolved .help || resolved .listThemes
130- || resolved .listQueriesTheme != null || resolved .compareExisting ) {
142+ || resolved .listQueriesTheme != null || resolved .compareExisting || resolved . renameRunsByCommit ) {
131143 return resolved ;
132144 }
133145
@@ -139,6 +151,10 @@ private QueryPlanSnapshotCliOptions resolveInteractiveTopLevelAction(QueryPlanSn
139151 resolved .compareExisting = true ;
140152 return resolved ;
141153 }
154+ if ("rename runs by commit" .equals (action )) {
155+ resolved .renameRunsByCommit = true ;
156+ return resolved ;
157+ }
142158 if ("list themes" .equals (action )) {
143159 resolved .listThemes = true ;
144160 return resolved ;
@@ -316,6 +332,90 @@ private void runCompareExisting(QueryPlanSnapshotCliOptions options) throws Exce
316332 runInteractiveRunBrowser (matchingRuns , resolved .diffMode );
317333 }
318334
335+ private void runRenameRunsByCommit (QueryPlanSnapshotCliOptions options ) throws Exception {
336+ Path outputDirectory = resolveRenameOutputDirectory (options );
337+ QueryPlanCapture capture = new QueryPlanCapture ();
338+ List <QueryPlanSnapshotComparator .SnapshotRun > allRuns = QueryPlanSnapshotComparator .loadRuns (outputDirectory ,
339+ capture );
340+ List <CommitRunGroup > groups = groupRunsByCommit (allRuns );
341+ if (groups .isEmpty ()) {
342+ output .println ("No runs with gitCommit metadata found in " + outputDirectory .toAbsolutePath ());
343+ return ;
344+ }
345+
346+ int selectedIndex = promptChoiceIndex ("Select commit" , toCommitChoices (groups ));
347+ CommitRunGroup selectedGroup = groups .get (selectedIndex );
348+ String newRunName = promptRequiredValue ("New run name" );
349+ int updatedRuns = renameRunsForCommit (allRuns , selectedGroup .commit (), newRunName , capture );
350+ output .println ("Renamed " + updatedRuns + " run(s) for commit " + selectedGroup .commit ()
351+ + " to runName=" + newRunName );
352+ }
353+
354+ private Path resolveRenameOutputDirectory (QueryPlanSnapshotCliOptions options ) throws IOException {
355+ if (options .outputDirectory != null ) {
356+ return options .outputDirectory ;
357+ }
358+ Path prompted = promptOptionalPath ("Output directory (blank uses default)" );
359+ return prompted != null ? prompted : QueryPlanCapture .resolveOutputDirectory ();
360+ }
361+
362+ private List <CommitRunGroup > groupRunsByCommit (List <QueryPlanSnapshotComparator .SnapshotRun > runs ) {
363+ Map <String , CommitRunGroup > byCommit = new java .util .LinkedHashMap <>();
364+ for (QueryPlanSnapshotComparator .SnapshotRun run : runs ) {
365+ Map <String , String > metadata = run .snapshot ().getMetadata ();
366+ if (metadata == null ) {
367+ continue ;
368+ }
369+ String commit = normalizedOrNull (metadata .get ("gitCommit" ));
370+ if (commit == null ) {
371+ continue ;
372+ }
373+ String branch = normalizedOrNull (metadata .get ("gitBranch" ));
374+ byCommit .computeIfAbsent (commit , CommitRunGroup ::new ).recordRun (run , branch );
375+ }
376+
377+ List <CommitRunGroup > groups = new ArrayList <>(byCommit .values ());
378+ groups .sort ((left , right ) -> {
379+ int byTime = Long .compare (right .latestEpochMillis (), left .latestEpochMillis ());
380+ return byTime != 0 ? byTime : left .commit ().compareTo (right .commit ());
381+ });
382+ return groups ;
383+ }
384+
385+ private List <String > toCommitChoices (List <CommitRunGroup > groups ) {
386+ List <String > choices = new ArrayList <>(groups .size ());
387+ for (CommitRunGroup group : groups ) {
388+ choices .add ("commit=" + group .commit ()
389+ + " branch=" + group .branchSummary ()
390+ + " localTime=" + formatLocalTime (group .latestCapturedAt ())
391+ + " runs=" + group .runCount ());
392+ }
393+ return choices ;
394+ }
395+
396+ private int renameRunsForCommit (List <QueryPlanSnapshotComparator .SnapshotRun > runs , String commit ,
397+ String newRunName , QueryPlanCapture capture ) throws IOException {
398+ int updatedRuns = 0 ;
399+ for (QueryPlanSnapshotComparator .SnapshotRun run : runs ) {
400+ Map <String , String > metadata = run .snapshot ().getMetadata ();
401+ if (metadata == null || !commit .equals (normalizedOrNull (metadata .get ("gitCommit" )))) {
402+ continue ;
403+ }
404+ Path snapshotPath = run .path ();
405+ if (snapshotPath == null ) {
406+ continue ;
407+ }
408+
409+ QueryPlanSnapshot snapshot = run .snapshot ();
410+ java .util .LinkedHashMap <String , String > updatedMetadata = new java .util .LinkedHashMap <>(metadata );
411+ updatedMetadata .put ("runName" , newRunName );
412+ snapshot .setMetadata (updatedMetadata );
413+ capture .writeSnapshot (snapshotPath , snapshot );
414+ updatedRuns ++;
415+ }
416+ return updatedRuns ;
417+ }
418+
319419 private void runInteractiveRunBrowser (List <QueryPlanSnapshotComparator .SnapshotRun > runs ,
320420 QueryPlanSnapshotCliOptions .DiffMode diffMode ) throws IOException {
321421 while (true ) {
@@ -563,6 +663,16 @@ private QueryPlanSnapshotCliOptions.ComparisonPair resolveComparisonPair(QueryPl
563663 }
564664 }
565665
666+ private String promptRequiredValue (String message ) throws IOException {
667+ while (true ) {
668+ String value = prompt (message );
669+ if (!value .isBlank ()) {
670+ return value ;
671+ }
672+ output .println (message + " cannot be blank." );
673+ }
674+ }
675+
566676 private void promptForQueryInput (QueryPlanSnapshotCliOptions options ) throws IOException {
567677 String mode = promptChoice ("Query source [themed|manual|file|all-themed]" ,
568678 List .of ("themed" , "manual" , "file" , "all-themed" ));
@@ -1023,6 +1133,28 @@ private static String normalizedOrNull(String value) {
10231133 return normalized .isEmpty () ? null : normalized ;
10241134 }
10251135
1136+ private static String formatLocalTime (String capturedAt ) {
1137+ if (capturedAt == null || capturedAt .isBlank ()) {
1138+ return UNKNOWN_VALUE ;
1139+ }
1140+ try {
1141+ return LOCAL_TIME_FORMATTER .format (Instant .parse (capturedAt ).atZone (LOCAL_ZONE ));
1142+ } catch (Exception ignored ) {
1143+ return capturedAt ;
1144+ }
1145+ }
1146+
1147+ private static long toEpochMillis (String capturedAt ) {
1148+ if (capturedAt == null || capturedAt .isBlank ()) {
1149+ return Long .MIN_VALUE ;
1150+ }
1151+ try {
1152+ return Instant .parse (capturedAt ).toEpochMilli ();
1153+ } catch (Exception ignored ) {
1154+ return Long .MIN_VALUE ;
1155+ }
1156+ }
1157+
10261158 private static String formatQueryTimeoutSeconds (Integer queryTimeoutSeconds ) {
10271159 if (queryTimeoutSeconds == null || queryTimeoutSeconds == 0 ) {
10281160 return "<none>" ;
@@ -1186,6 +1318,49 @@ private QueryExecutionVerification(int runs, long elapsedNanos, long resultCount
11861318 }
11871319 }
11881320
1321+ private static final class CommitRunGroup {
1322+ private final String commit ;
1323+ private final LinkedHashSet <String > branches = new LinkedHashSet <>();
1324+ private long latestEpochMillis = Long .MIN_VALUE ;
1325+ private String latestCapturedAt ;
1326+ private int runCount ;
1327+
1328+ private CommitRunGroup (String commit ) {
1329+ this .commit = commit ;
1330+ }
1331+
1332+ private void recordRun (QueryPlanSnapshotComparator .SnapshotRun run , String branch ) {
1333+ runCount ++;
1334+ branches .add (branch == null ? UNKNOWN_VALUE : branch );
1335+ String capturedAt = run .snapshot ().getCapturedAt ();
1336+ long epochMillis = toEpochMillis (capturedAt );
1337+ if (epochMillis >= latestEpochMillis ) {
1338+ latestEpochMillis = epochMillis ;
1339+ latestCapturedAt = capturedAt ;
1340+ }
1341+ }
1342+
1343+ private String commit () {
1344+ return commit ;
1345+ }
1346+
1347+ private String branchSummary () {
1348+ return String .join ("," , branches );
1349+ }
1350+
1351+ private long latestEpochMillis () {
1352+ return latestEpochMillis ;
1353+ }
1354+
1355+ private String latestCapturedAt () {
1356+ return latestCapturedAt ;
1357+ }
1358+
1359+ private int runCount () {
1360+ return runCount ;
1361+ }
1362+ }
1363+
11891364 static QueryPlanSnapshotCliOptions parseArgs (String [] args ) {
11901365 return QueryPlanSnapshotCliOptions .parseArgs (args );
11911366 }
0 commit comments