@@ -214,85 +214,147 @@ shift_history() {
214214 echo " ✓ Done. Remember to: git push --force-with-lease"
215215}
216216
217+ parse_windows_to_intervals () {
218+ local spec=" $1 "
219+ WIN_STARTS=()
220+ WIN_ENDS=()
221+ IFS=' ,' read -ra PARTS <<< " $spec"
222+ for part in " ${PARTS[@]} " ; do
223+ [[ " $part " =~ ^([0-1][0-9]| 2[0-3])\- ([0-1][0-9]| 2[0-3])$ ]] \
224+ || { echo " Invalid window segment '$part ' (use HH-HH)" ; exit 1; }
225+ local s=" ${BASH_REMATCH[1]} " e=" ${BASH_REMATCH[2]} "
226+ local ssec=$(( 10 #$s * 3600 ))
227+ local esec=$(( (10 #$e + 1 ) * 3600 )) # end is exclusive
228+ if (( esec > 86400 )) ; then esec=86400; fi
229+ if (( 10 #$s <= 10 #$e )) ; then
230+ WIN_STARTS+=(" $ssec " ); WIN_ENDS+=(" $esec " )
231+ else
232+ # wrap across midnight, split into two intervals
233+ WIN_STARTS+=(" $ssec " ); WIN_ENDS+=(" 86400" )
234+ WIN_STARTS+=(" 0" ); WIN_ENDS+=(" $esec " )
235+ fi
236+ done
237+ # sort by start (tiny N, simple insertion sort)
238+ local i j keyS keyE
239+ for (( i= 1 ; i< ${# WIN_STARTS[@]} ; i++ )) ; do
240+ keyS=${WIN_STARTS[i]} ; keyE=${WIN_ENDS[i]} ; j=$(( i- 1 ))
241+ while (( j>= 0 && WIN_STARTS[j] > keyS )) ; do
242+ WIN_STARTS[j+1]=${WIN_STARTS[j]} ; WIN_ENDS[j+1]=${WIN_ENDS[j]} ; j=$(( j- 1 ))
243+ done
244+ WIN_STARTS[j+1]=$keyS ; WIN_ENDS[j+1]=$keyE
245+ done
246+ # merge overlaps / adjacents
247+ local mergedS=() mergedE=()
248+ for (( i= 0 ;i< ${# WIN_STARTS[@]} ;i++ )) ; do
249+ if (( ${# mergedS[@]} == 0 )) ; then
250+ mergedS+=(" ${WIN_STARTS[i]} " ); mergedE+=(" ${WIN_ENDS[i]} " )
251+ else
252+ local last=$(( ${# mergedS[@]} - 1 ))
253+ if (( WIN_STARTS[i] <= mergedE[last] )) ; then
254+ # extend
255+ if (( WIN_ENDS[i] > mergedE[last] )) ; then mergedE[last]=${WIN_ENDS[i]} ; fi
256+ else
257+ mergedS+=(" ${WIN_STARTS[i]} " ); mergedE+=(" ${WIN_ENDS[i]} " )
258+ fi
259+ fi
260+ done
261+ WIN_STARTS=(" ${mergedS[@]} " ); WIN_ENDS=(" ${mergedE[@]} " )
262+ # total allowed seconds
263+ ALLOWED_LEN=0
264+ for (( i= 0 ;i< ${# WIN_STARTS[@]} ;i++ )) ; do
265+ ALLOWED_LEN=$(( ALLOWED_LEN + WIN_ENDS[i] - WIN_STARTS[i]))
266+ done
267+ (( ALLOWED_LEN > 0 )) || { echo " Window has zero length" ; exit 1; }
268+ }
269+
270+ # Map a position p in [0, ALLOWED_LEN-1] to absolute seconds since local midnight
271+ # inside the union of intervals.
272+ union_pos_to_seconds () {
273+ local p=$1
274+ for (( i= 0 ;i< ${# WIN_STARTS[@]} ;i++ )) ; do
275+ local seglen=$(( WIN_ENDS[i] - WIN_STARTS[i] ))
276+ if (( p < seglen )) ; then
277+ echo $(( WIN_STARTS[i] + p ))
278+ return
279+ fi
280+ p=$(( p - seglen ))
281+ done
282+ echo $(( WIN_ENDS[${# WIN_ENDS[@]} - 1 ] - 1 ))
283+ }
284+
285+ # --- move_history(): preserves per-day order & keeps same local day ---
217286move_history () {
218287 local target=" $1 "
219-
220- local hours_list=" "
288+ local window_spec
221289 if [[ " $target " == " day" ]]; then
222- hours_list =" $( expand_windows_to_hours " $ DAY_WINDOW" ) "
223- echo " Moving ALL commits into DAY hours [${ DAY_WINDOW} ] in tz=${TZ_OFFSET} "
290+ window_spec =" $DAY_WINDOW "
291+ echo " Moving ALL commits into DAY hours [$DAY_WINDOW ] in tz=${TZ_OFFSET} "
224292 else
225- hours_list =" $( expand_windows_to_hours " $ NIGHT_WINDOW" ) "
226- echo " Moving ALL commits into NIGHT hours [${ NIGHT_WINDOW} ] in tz=${TZ_OFFSET} "
293+ window_spec =" $NIGHT_WINDOW "
294+ echo " Moving ALL commits into NIGHT hours [$NIGHT_WINDOW ] in tz=${TZ_OFFSET} "
227295 fi
228296
229- # Pack the hours list into a bash array initializer string
230- local hours_csv=" ${hours_list// / ,} "
231-
232- git filter-branch -f --tag-name-filter cat --env-filter "
233- tz='${TZ_OFFSET} '
234- tz_secs=$( tz_to_seconds " ${TZ_OFFSET} " )
235- " -- --branches --tags > /dev/null 2>&1 && true
236-
237- # We need the tz_to_seconds helper inside the filter; inject it plus logic:
238- git filter-branch -f --tag-name-filter cat --env-filter "
239- tz='${TZ_OFFSET} '
240- tz_to_seconds() {
241- local tz=\"\$ 1\" sign hh mm secs
242- [[ \"\$ tz\" =~ ^[+-][0-9]{4}\$ ]] || { echo 'bad tz' >&2; exit 1; }
243- sign=\"\$ {tz:0:1}\" ; hh=\$ ((10#\$ {tz:1:2})); mm=\$ ((10#\$ {tz:3:2}))
244- secs=\$ ((hh*3600 + mm*60)); [[ \"\$ sign\" == '-' ]] && secs=\$ ((-secs))
245- echo \"\$ secs\"
246- }
247-
248- tz_secs=\$ (tz_to_seconds \"\$ tz\" )
249- to_epoch() { $DATE_BIN -d \"\$ 1\" +%s; }
250- from_local_YmdHMS_to_epoch() { # args: Y M D H M S, interpret as LOCAL (tz offset), return UTC epoch
251- local Y=\"\$ 1\" Mo=\"\$ 2\" D=\"\$ 3\" H=\"\$ 4\" Mi=\"\$ 5\" S=\"\$ 6\"
252- $DATE_BIN -d \"\$ {Y}-\$ {Mo}-\$ {D} \$ {H}:\$ {Mi}:\$ {S} UTC\" +%s
253- }
254-
255- # Pick a random allowed hour (uniform)
256- pick_hour() {
257- local IFS=','; local raw='${hours_csv} '
258- read -ra H <<< \"\$ raw\"
259- local count=\$ {#H[@]}
260- local idx=\$ ((RANDOM % count))
261- echo \$ {H[\$ idx]}
262- }
263-
264- # For each commit:
265- a_ep=\$ (to_epoch \"\$ GIT_AUTHOR_DATE\" )
266- c_ep=\$ (to_epoch \"\$ GIT_COMMITTER_DATE\" )
267-
268- # Convert to 'local' by applying tz offset (so windows are in given tz)
269- a_local=\$ ((a_ep + tz_secs))
270- c_local=\$ ((c_ep + tz_secs))
271-
272- # Extract local Y-m-d; choose random HH:MM:SS in allowed window
273- Y=\$ ($DATE_BIN -u -d @\$ a_local +%Y)
274- Mo=\$ ($DATE_BIN -u -d @\$ a_local +%m)
275- D=\$ ($DATE_BIN -u -d @\$ a_local +%d)
276-
277- H=\$ (pick_hour)
278- Mi=\$ ((RANDOM % 60))
279- S=\$ ((RANDOM % 60))
280-
281- new_local_epoch=\$ ($DATE_BIN -u -d \"\$ {Y}-\$ {Mo}-\$ {D} \$ {H}:\$ {Mi}:\$ {S}\" +%s)
282- a_new=\$ ((new_local_epoch - tz_secs))
283-
284- # Mirror author -> committer with same new time on that commit's day
285- Yc=\$ ($DATE_BIN -u -d @\$ c_local +%Y)
286- Moc=\$ ($DATE_BIN -u -d @\$ c_local +%m)
287- Dc=\$ ($DATE_BIN -u -d @\$ c_local +%d)
288- new_local_epoch_c=\$ ($DATE_BIN -u -d \"\$ {Yc}-\$ {Moc}-\$ {Dc} \$ {H}:\$ {Mi}:\$ {S}\" +%s)
289- c_new=\$ ((new_local_epoch_c - tz_secs))
290-
291- export GIT_AUTHOR_DATE=\"\$ a_new \$ tz\"
292- export GIT_COMMITTER_DATE=\"\$ c_new \$ tz\"
293- " -- --branches --tags > /dev/null
297+ parse_windows_to_intervals " $window_spec "
298+
299+ # 1) First pass: count commits per local day (by committer date)
300+ declare -A DAY_COUNT=()
301+ local tz_secs
302+ tz_secs=" $( tz_to_seconds " $TZ_OFFSET " ) "
303+
304+ while IFS=' ' read -r sha ct; do
305+ [[ -n " $sha " ]] || continue
306+ # local day key in target tz
307+ local local_ep=$(( ct + tz_secs ))
308+ local day
309+ day=" $( $DATE_BIN -u -d " @$local_ep " +%Y-%m-%d) "
310+ DAY_COUNT[" $day " ]=$(( ${DAY_COUNT["$day"]:- 0} + 1 ))
311+ done < <( git log --all --reverse --pretty=format:' %H %ct' )
312+
313+ # 2) Second pass: assign evenly spaced times inside window per day, preserving order
314+ local STATE
315+ STATE=" $( mktemp -d) "
316+ trap ' rm -rf "$STATE"' EXIT
317+ mkdir -p " $STATE /map"
318+
319+ declare -A DAY_INDEX=()
320+ while IFS=' ' read -r sha ct; do
321+ [[ -n " $sha " ]] || continue
322+ local local_ep=$(( ct + tz_secs ))
323+ local day
324+ day=" $( $DATE_BIN -u -d " @$local_ep " +%Y-%m-%d) "
325+ local idx=${DAY_INDEX["$day"]:- 0}
326+ local n=${DAY_COUNT["$day"]}
327+
328+ # Even spacing strictly preserves order; places commits inside the window.
329+ # pos = floor( (idx+1) * (ALLOWED_LEN-1) / (n+1) )
330+ local pos=$(( ((idx + 1 ) * (ALLOWED_LEN - 1 )) / (n + 1) ))
331+ (( pos < 0 )) && pos=0
332+ (( pos >= ALLOWED_LEN )) && pos=$(( ALLOWED_LEN - 1 ))
333+
334+ local sec_in_day
335+ sec_in_day=" $( union_pos_to_seconds " $pos " ) "
336+
337+ # Build final epoch: interpret as local time in TZ_OFFSET, then convert to UTC epoch
338+ local new_epoch
339+ new_epoch=" $( $DATE_BIN -d " ${day} 00:00:00 ${TZ_OFFSET} " +%s) "
340+ new_epoch=$(( new_epoch + sec_in_day ))
341+
342+ printf ' %s' " $new_epoch " > " $STATE /map/$sha "
343+ DAY_INDEX[" $day " ]=$(( idx + 1 ))
344+ done < <( git log --all --reverse --pretty=format:' %H %ct' )
345+
346+ # 3) Apply mapping in one pass
347+ MAPPING_DIR=" $STATE /map" TZ_OFFSET_APPLY=" $TZ_OFFSET " \
348+ git filter-branch -f --tag-name-filter cat --env-filter '
349+ if [ -f "$MAPPING_DIR/$GIT_COMMIT" ]; then
350+ new_epoch=$(cat "$MAPPING_DIR/$GIT_COMMIT")
351+ export GIT_AUTHOR_DATE="$new_epoch $TZ_OFFSET_APPLY"
352+ export GIT_COMMITTER_DATE="$new_epoch $TZ_OFFSET_APPLY"
353+ fi
354+ ' -- --branches --tags > /dev/null
294355
295- echo " ✓ Done. Remember to: git push --force-with-lease"
356+ echo " ✓ Done. Per-day order preserved; commits stay on the same local day."
357+ echo " Next: git push --force-with-lease"
296358}
297359
298360# ---------- Dispatch ----------
0 commit comments