urmama 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.MD ADDED
@@ -0,0 +1,119 @@
1
+ # urmama
2
+
3
+ urmama is a Node.js CLI tool for rewriting the full commit history of a Git branch so that every commit falls within a chosen date window while preserving the original order of commits.
4
+
5
+ It is especially useful for re-timing a branch for a hackathon, project sprint, or other date-based workflow without changing the commit contents themselves.
6
+
7
+ ## Features
8
+
9
+ - Rewrites an entire branch history from the root commit to HEAD
10
+ - Preserves commit order and message content
11
+ - Supports three distribution strategies:
12
+ - randomise (default)
13
+ - equalize
14
+ - greedyMum
15
+ - Writes to a new branch by default, or rewrites the target branch in place when requested
16
+ - Optionally force-pushes the rewritten branch to a remote
17
+ - Enforces safety checks such as valid date range, existing target branch, and clean working tree
18
+
19
+ ## Installation
20
+
21
+ Install globally with npm:
22
+
23
+ ```bash
24
+ npm install -g urmama
25
+ ```
26
+
27
+ ## Usage
28
+
29
+ ```bash
30
+ urmama \
31
+ --starts "YYYY-MM-DD HH:mm" \
32
+ --ends "YYYY-MM-DD HH:mm" \
33
+ [options]
34
+ ```
35
+
36
+ ### Required arguments
37
+
38
+ - --starts: start datetime (inclusive)
39
+ - --ends: end datetime (inclusive)
40
+
41
+ ### Optional arguments
42
+
43
+ - --targetBranch <name>: branch to rewrite; defaults to the current active branch
44
+ - --destBranch <name>: branch that will receive the rewritten history
45
+ - --useTargetAsDest [true|false]: rewrite the target branch in place instead of creating a copy
46
+ - --mode <mode>: distribution strategy; one of randomise, equalize, greedyMum
47
+ - --forcePushTo <remote>: force-push the rewritten branch after completion
48
+ - --help, -h: show usage information
49
+
50
+ ## Distribution modes
51
+
52
+ ### randomise (default)
53
+
54
+ Distributes commits randomly across the available days while keeping their original relative order.
55
+
56
+ ### equalize
57
+
58
+ Spreads commits as evenly as possible across the date range.
59
+
60
+ ### greedyMum
61
+
62
+ Preserves commits already inside the requested range and only adjusts commits that fall outside it. This mode is deterministic and is designed to avoid moving commits unnecessarily.
63
+
64
+ ## Examples
65
+
66
+ ### 1. Create a rewritten copy of the current branch
67
+
68
+ ```bash
69
+ urmama \
70
+ --starts "2026-06-01 09:00" \
71
+ --ends "2026-06-04 18:00"
72
+ ```
73
+
74
+ This creates a new branch named like currentBranch-Copy.
75
+
76
+ ### 2. Rewrite the main branch in place
77
+
78
+ ```bash
79
+ urmama \
80
+ --starts "2026-06-01 09:00" \
81
+ --ends "2026-06-04 18:00" \
82
+ --targetBranch "main" \
83
+ --useTargetAsDest true
84
+ ```
85
+
86
+ ### 3. Backup, rewrite, and force-push
87
+
88
+ ```bash
89
+ urmama \
90
+ --starts "2026-06-01 09:00" \
91
+ --ends "2026-06-04 18:00" \
92
+ --targetBranch "main" \
93
+ --destBranch "main-backup" \
94
+ --useTargetAsDest true \
95
+ --forcePushTo "origin"
96
+ ```
97
+
98
+ ## Safety and requirements
99
+
100
+ Before rewriting history, urmama checks that:
101
+
102
+ - you are inside a Git repository
103
+ - the target branch exists
104
+ - the working tree has no uncommitted changes
105
+ - --starts occurs before --ends
106
+
107
+ Because this tool rewrites history, it is safest to use it on a backup branch or a disposable branch first. When --useTargetAsDest is enabled, the tool will warn you before rewriting the target branch in place.
108
+
109
+ ## Development
110
+
111
+ Run the test suite:
112
+
113
+ ```bash
114
+ npm test
115
+ ```
116
+
117
+ ## Notes
118
+
119
+ This project uses Git’s native CLI to rebuild commit history and updates both the author and committer dates for rewritten commits.
package/bin/urmama.js ADDED
@@ -0,0 +1,306 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const readline = require('readline');
6
+
7
+ const {
8
+ isInsideGitWorktree,
9
+ hasUncommittedChanges,
10
+ getCurrentBranch,
11
+ branchExists,
12
+ getCommitsReverse,
13
+ getCommitDetails,
14
+ createCommitTree,
15
+ updateBranchRef,
16
+ resetHard,
17
+ copyBranch,
18
+ forcePush
19
+ } = require('../lib/git');
20
+
21
+ const {
22
+ parseDateTime,
23
+ toLocalISOString,
24
+ getCalendarDaysCount,
25
+ getDayWindow,
26
+ distributeCommitsInWindow
27
+ } = require('../lib/date');
28
+
29
+ const {
30
+ equalize,
31
+ randomise,
32
+ greedyMum
33
+ } = require('../lib/modes');
34
+
35
+ function askConfirmation(question) {
36
+ return new Promise((resolve) => {
37
+ const rl = readline.createInterface({
38
+ input: process.stdin,
39
+ output: process.stdout
40
+ });
41
+ rl.question(question, (answer) => {
42
+ rl.close();
43
+ resolve(answer.trim().toLowerCase());
44
+ });
45
+ });
46
+ }
47
+
48
+ function generateRandomId() {
49
+ return Math.random().toString(36).substring(2, 10);
50
+ }
51
+
52
+ function printUsage() {
53
+ console.log(`
54
+ Usage: urmama --starts "YYYY-MM-DD HH:mm" --ends "YYYY-MM-DD HH:mm" [options]
55
+
56
+ Required Options:
57
+ --starts "YYYY-MM-DD HH:mm" Start datetime (inclusive)
58
+ --ends "YYYY-MM-DD HH:mm" End datetime (inclusive)
59
+
60
+ Optional Options:
61
+ --targetBranch <name> Branch to rewrite (defaults to current active branch)
62
+ --destBranch <name> Branch to contain rewritten commits
63
+ --useTargetAsDest [true|false] Rewrite history directly on target branch (default: false)
64
+ --mode <mode> Distribution mode: "randomise", "equalize", "greedyMum" (default: "randomise")
65
+ --forcePushTo <remote> Remote name to force push to (e.g. "origin")
66
+ `);
67
+ }
68
+
69
+ async function main() {
70
+ const args = process.argv.slice(2);
71
+
72
+ let startsStr = null;
73
+ let endsStr = null;
74
+ let targetBranch = null;
75
+ let destBranch = null;
76
+ let useTargetAsDest = false;
77
+ let mode = 'randomise';
78
+ let forcePushTo = null;
79
+
80
+ for (let i = 0; i < args.length; i++) {
81
+ const arg = args[i];
82
+ if (arg === '--starts') {
83
+ startsStr = args[++i];
84
+ } else if (arg === '--ends') {
85
+ endsStr = args[++i];
86
+ } else if (arg === '--targetBranch') {
87
+ targetBranch = args[++i];
88
+ } else if (arg === '--destBranch') {
89
+ destBranch = args[++i];
90
+ } else if (arg === '--useTargetAsDest') {
91
+ const next = args[i + 1];
92
+ if (next === 'true') {
93
+ useTargetAsDest = true;
94
+ i++;
95
+ } else if (next === 'false') {
96
+ useTargetAsDest = false;
97
+ i++;
98
+ } else {
99
+ useTargetAsDest = true;
100
+ }
101
+ } else if (arg === '--mode') {
102
+ mode = args[++i];
103
+ } else if (arg === '--forcePushTo') {
104
+ forcePushTo = args[++i];
105
+ } else if (arg === '--help' || arg === '-h') {
106
+ printUsage();
107
+ process.exit(0);
108
+ }
109
+ }
110
+
111
+ if (!startsStr || !endsStr) {
112
+ console.error('Error: Both --starts and --ends must be provided.');
113
+ printUsage();
114
+ process.exit(1);
115
+ }
116
+
117
+ let S, E;
118
+ try {
119
+ S = parseDateTime(startsStr);
120
+ E = parseDateTime(endsStr);
121
+ } catch (err) {
122
+ console.error(`Error: ${err.message}`);
123
+ process.exit(1);
124
+ }
125
+
126
+ if (S.getTime() >= E.getTime()) {
127
+ console.error('Error: --starts must be before --ends');
128
+ process.exit(1);
129
+ }
130
+
131
+ const allowedModes = ['randomise', 'equalize', 'greedyMum'];
132
+ if (!allowedModes.includes(mode)) {
133
+ console.error(`Error: Invalid mode "${mode}". Must be one of: ${allowedModes.join(', ')}`);
134
+ process.exit(1);
135
+ }
136
+
137
+ if (!isInsideGitWorktree()) {
138
+ console.error('Error: Not inside a Git repository.');
139
+ process.exit(1);
140
+ }
141
+
142
+ if (hasUncommittedChanges()) {
143
+ console.error('Error: Working directory has uncommitted changes. Please commit or stash them first.');
144
+ process.exit(1);
145
+ }
146
+
147
+ const activeBranch = getCurrentBranch();
148
+ if (!targetBranch) {
149
+ targetBranch = activeBranch;
150
+ }
151
+
152
+ if (!branchExists(targetBranch)) {
153
+ console.error(`Error: Target branch "${targetBranch}" does not exist.`);
154
+ process.exit(1);
155
+ }
156
+
157
+ console.log(`Collecting commits on branch "${targetBranch}"...`);
158
+ const commitHashes = getCommitsReverse(targetBranch);
159
+ const N = commitHashes.length;
160
+ if (N === 0) {
161
+ console.error(`Error: No commits found on branch "${targetBranch}".`);
162
+ process.exit(1);
163
+ }
164
+
165
+ console.log(`Found ${N} commit(s) to rewrite.`);
166
+ const commits = commitHashes.map(hash => getCommitDetails(hash));
167
+
168
+ let commitMappings;
169
+ if (mode === 'greedyMum') {
170
+ commitMappings = greedyMum(commits, S, E);
171
+ } else {
172
+ const D = getCalendarDaysCount(S, E);
173
+ const bucketSizes = mode === 'equalize' ? equalize(N, D) : randomise(N, D);
174
+
175
+ const newCommitDates = [];
176
+ let commitIdx = 0;
177
+ for (let dayIdx = 0; dayIdx < D; dayIdx++) {
178
+ const count = bucketSizes[dayIdx];
179
+ if (count > 0) {
180
+ const { start, end } = getDayWindow(dayIdx, D, S, E);
181
+ const dates = distributeCommitsInWindow(start, end, count);
182
+ for (let j = 0; j < count; j++) {
183
+ newCommitDates[commitIdx++] = dates[j];
184
+ }
185
+ }
186
+ }
187
+
188
+ commitMappings = commits.map((c, i) => ({
189
+ hash: c.hash,
190
+ newAuthorDate: newCommitDates[i],
191
+ newCommitterDate: newCommitDates[i],
192
+ unchanged: false
193
+ }));
194
+ }
195
+
196
+ let destBranchName = destBranch;
197
+ let isBackupNeeded = false;
198
+
199
+ if (!useTargetAsDest) {
200
+ if (!destBranchName) {
201
+ const defaultName = `${targetBranch}-Copy`;
202
+ if (branchExists(defaultName)) {
203
+ const randomId = generateRandomId();
204
+ destBranchName = `${defaultName}_${randomId}`;
205
+ } else {
206
+ destBranchName = defaultName;
207
+ }
208
+ }
209
+ } else {
210
+ if (destBranchName) {
211
+ isBackupNeeded = true;
212
+ }
213
+ destBranchName = targetBranch;
214
+ }
215
+
216
+ console.log('\n--- URMAMA Rewrite Configuration ---');
217
+ console.log(`Target Branch: ${targetBranch}`);
218
+ console.log(`Destination Branch: ${destBranchName}`);
219
+ if (isBackupNeeded) {
220
+ console.log(`Backup Branch: ${destBranch}`);
221
+ }
222
+ console.log(`Distribution Mode: ${mode}`);
223
+ console.log(`Time Range (Local): ${toLocalISOString(S)} -> ${toLocalISOString(E)}`);
224
+ if (forcePushTo) {
225
+ console.log(`Force Push To: ${forcePushTo}`);
226
+ }
227
+ console.log('------------------------------------');
228
+
229
+ const willModifyTarget = useTargetAsDest;
230
+ if (willModifyTarget) {
231
+ console.log('\n[WARNING] You have enabled --useTargetAsDest.');
232
+ console.log(`This will rewrite the history of "${targetBranch}" IN-PLACE.`);
233
+ console.log('Ensure you have a backup of this branch if it is remote or shared.');
234
+
235
+ const confirm = await askConfirmation(`Are you sure you want to rewrite "${targetBranch}"? (y/N): `);
236
+ if (confirm !== 'y' && confirm !== 'yes') {
237
+ console.log('Operation cancelled by user. Exiting.');
238
+ process.exit(0);
239
+ }
240
+ }
241
+
242
+ console.log('\nRewriting commit history...');
243
+
244
+ if (isBackupNeeded) {
245
+ console.log(`Creating backup branch "${destBranch}" from "${targetBranch}"...`);
246
+ copyBranch(targetBranch, destBranch);
247
+ }
248
+
249
+ const rewrittenMap = {};
250
+
251
+ for (let i = 0; i < N; i++) {
252
+ const c = commits[i];
253
+ const mapping = commitMappings[i];
254
+
255
+ const newParents = c.parents.map(p => rewrittenMap[p] || p);
256
+
257
+ const authorDateStr = toLocalISOString(mapping.newAuthorDate);
258
+ const committerDateStr = toLocalISOString(mapping.newCommitterDate);
259
+
260
+ const newHash = createCommitTree(
261
+ c.tree,
262
+ newParents,
263
+ c.authorName,
264
+ c.authorEmail,
265
+ authorDateStr,
266
+ c.committerName,
267
+ c.committerEmail,
268
+ committerDateStr,
269
+ c.message
270
+ );
271
+
272
+ rewrittenMap[c.hash] = newHash;
273
+ console.log(` [${i + 1}/${N}] Rewrote ${c.hash.slice(0, 7)} -> ${newHash.slice(0, 7)}`);
274
+ }
275
+
276
+ const lastOldHash = commitHashes[N - 1];
277
+ const newHeadHash = rewrittenMap[lastOldHash];
278
+
279
+ const isUpdatingActiveBranch = (destBranchName === activeBranch);
280
+
281
+ if (isUpdatingActiveBranch) {
282
+ console.log(`Updating current active branch "${destBranchName}" to new HEAD...`);
283
+ resetHard(newHeadHash);
284
+ } else {
285
+ console.log(`Updating branch "${destBranchName}" reference to new HEAD...`);
286
+ updateBranchRef(destBranchName, newHeadHash);
287
+ }
288
+
289
+ console.log(`Successfully rewrote history! Branch "${destBranchName}" is now at ${newHeadHash.slice(0, 7)}.`);
290
+
291
+ if (forcePushTo) {
292
+ console.log(`Force pushing "${destBranchName}" to remote "${forcePushTo}"...`);
293
+ try {
294
+ forcePush(forcePushTo, destBranchName);
295
+ console.log('Force push completed successfully.');
296
+ } catch (err) {
297
+ console.error(`Error performing force push: ${err.message}`);
298
+ process.exit(1);
299
+ }
300
+ }
301
+ }
302
+
303
+ main().catch(err => {
304
+ console.error(`Fatal error: ${err.message}`);
305
+ process.exit(1);
306
+ });
package/lib/date.js ADDED
@@ -0,0 +1,101 @@
1
+ function parseDateTime(str) {
2
+ if (!str) {
3
+ throw new Error('Date string is required');
4
+ }
5
+ const match = str.match(/^(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})$/);
6
+ if (!match) {
7
+ throw new Error(`Invalid date format: "${str}". Expected "YYYY-MM-DD HH:mm"`);
8
+ }
9
+ const [_, year, month, day, hour, minute] = match;
10
+ const y = parseInt(year, 10);
11
+ const m = parseInt(month, 10);
12
+ const d = parseInt(day, 10);
13
+ const h = parseInt(hour, 10);
14
+ const min = parseInt(minute, 10);
15
+
16
+ if (m < 1 || m > 12) {
17
+ throw new Error(`Invalid date: "${str}"`);
18
+ }
19
+ const maxDays = new Date(y, m, 0).getDate();
20
+ if (d < 1 || d > maxDays) {
21
+ throw new Error(`Invalid date: "${str}"`);
22
+ }
23
+ if (h < 0 || h > 23) {
24
+ throw new Error(`Invalid date: "${str}"`);
25
+ }
26
+ if (min < 0 || min > 59) {
27
+ throw new Error(`Invalid date: "${str}"`);
28
+ }
29
+
30
+ return new Date(y, m - 1, d, h, min, 0, 0);
31
+ }
32
+
33
+ function toLocalISOString(date) {
34
+ const tzOffset = -date.getTimezoneOffset();
35
+ const diff = tzOffset >= 0 ? '+' : '-';
36
+ const pad = num => String(num).padStart(2, '0');
37
+
38
+ const year = date.getFullYear();
39
+ const month = pad(date.getMonth() + 1);
40
+ const day = pad(date.getDate());
41
+ const hours = pad(date.getHours());
42
+ const minutes = pad(date.getMinutes());
43
+ const seconds = pad(date.getSeconds());
44
+
45
+ const absOffsetHour = pad(Math.floor(Math.abs(tzOffset) / 60));
46
+ const absOffsetMin = pad(Math.abs(tzOffset) % 60);
47
+
48
+ return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${diff}${absOffsetHour}:${absOffsetMin}`;
49
+ }
50
+
51
+ function getCalendarDaysCount(S, E) {
52
+ const sDay = new Date(S.getFullYear(), S.getMonth(), S.getDate());
53
+ const eDay = new Date(E.getFullYear(), E.getMonth(), E.getDate());
54
+ const diffTime = eDay.getTime() - sDay.getTime();
55
+ return Math.round(diffTime / (1000 * 60 * 60 * 24)) + 1;
56
+ }
57
+
58
+ function getDayWindow(dayIndex, totalDays, S, E) {
59
+ const sDay = new Date(S.getFullYear(), S.getMonth(), S.getDate());
60
+ const dayStart = new Date(sDay.getTime() + dayIndex * 24 * 60 * 60 * 1000);
61
+ const dayEnd = new Date(dayStart.getFullYear(), dayStart.getMonth(), dayStart.getDate(), 23, 59, 59, 999);
62
+
63
+ let windowStart = new Date(dayStart.getFullYear(), dayStart.getMonth(), dayStart.getDate(), 0, 0, 0, 0);
64
+ let windowEnd = dayEnd;
65
+
66
+ if (dayIndex === 0) {
67
+ windowStart = S;
68
+ }
69
+ if (dayIndex === totalDays - 1) {
70
+ windowEnd = E;
71
+ }
72
+
73
+ return { start: windowStart, end: windowEnd };
74
+ }
75
+
76
+ function distributeCommitsInWindow(startTime, endTime, count) {
77
+ if (count <= 0) return [];
78
+ const windowDuration = endTime.getTime() - startTime.getTime();
79
+
80
+ let spacing = windowDuration / (count + 1);
81
+ if (spacing < 60000) {
82
+ if (spacing < 1000) {
83
+ spacing = Math.max(1, windowDuration / (count + 1));
84
+ }
85
+ }
86
+
87
+ const dates = [];
88
+ for (let i = 0; i < count; i++) {
89
+ const time = startTime.getTime() + (i + 1) * spacing;
90
+ dates.push(new Date(time));
91
+ }
92
+ return dates;
93
+ }
94
+
95
+ module.exports = {
96
+ parseDateTime,
97
+ toLocalISOString,
98
+ getCalendarDaysCount,
99
+ getDayWindow,
100
+ distributeCommitsInWindow
101
+ };
package/lib/git.js ADDED
@@ -0,0 +1,159 @@
1
+ const { execSync } = require('child_process');
2
+
3
+ function runGit(cmd, options = {}) {
4
+ try {
5
+ return execSync(cmd, {
6
+ encoding: 'utf-8',
7
+ stdio: ['pipe', 'pipe', 'pipe'],
8
+ ...options
9
+ }).trim();
10
+ } catch (error) {
11
+ const stderr = error.stderr ? error.stderr.toString().trim() : '';
12
+ const message = error.message || '';
13
+ throw new Error(`Git command failed: ${cmd}\nError: ${message}\nStderr: ${stderr}`);
14
+ }
15
+ }
16
+
17
+ function isInsideGitWorktree() {
18
+ try {
19
+ const res = runGit('git rev-parse --is-inside-work-tree');
20
+ return res === 'true';
21
+ } catch (e) {
22
+ return false;
23
+ }
24
+ }
25
+
26
+ function hasUncommittedChanges() {
27
+ const status = runGit('git status --porcelain');
28
+ return status.length > 0;
29
+ }
30
+
31
+ function getCurrentBranch() {
32
+ return runGit('git rev-parse --abbrev-ref HEAD');
33
+ }
34
+
35
+ function branchExists(branchName) {
36
+ try {
37
+ runGit(`git show-ref --verify refs/heads/${branchName}`);
38
+ return true;
39
+ } catch (e) {
40
+ return false;
41
+ }
42
+ }
43
+
44
+ function getCommitsReverse(branchName) {
45
+ const output = runGit(`git rev-list --reverse ${branchName}`);
46
+ return output ? output.split('\n') : [];
47
+ }
48
+
49
+ function getCommitDetails(hash) {
50
+ const raw = runGit(`git cat-file -p ${hash}`);
51
+ const lines = raw.split('\n');
52
+
53
+ let tree = '';
54
+ const parents = [];
55
+ let authorName = '';
56
+ let authorEmail = '';
57
+ let committerName = '';
58
+ let committerEmail = '';
59
+ let originalAuthorTimestamp = 0;
60
+ let originalAuthorOffset = '+0000';
61
+ let originalCommitterTimestamp = 0;
62
+ let originalCommitterOffset = '+0000';
63
+ let messageIndex = -1;
64
+
65
+ for (let i = 0; i < lines.length; i++) {
66
+ const line = lines[i];
67
+ if (line === '') {
68
+ messageIndex = i + 1;
69
+ break;
70
+ }
71
+ if (line.startsWith('tree ')) {
72
+ tree = line.slice(5).trim();
73
+ } else if (line.startsWith('parent ')) {
74
+ parents.push(line.slice(7).trim());
75
+ } else if (line.startsWith('author ')) {
76
+ const emailStart = line.indexOf('<');
77
+ const emailEnd = line.indexOf('>');
78
+ authorName = line.slice(7, emailStart).trim();
79
+ authorEmail = line.slice(emailStart + 1, emailEnd).trim();
80
+
81
+ const rest = line.slice(emailEnd + 1).trim().split(/\s+/);
82
+ originalAuthorTimestamp = parseInt(rest[0]);
83
+ originalAuthorOffset = rest[1] || '+0000';
84
+ } else if (line.startsWith('committer ')) {
85
+ const emailStart = line.indexOf('<');
86
+ const emailEnd = line.indexOf('>');
87
+ committerName = line.slice(10, emailStart).trim();
88
+ committerEmail = line.slice(emailStart + 1, emailEnd).trim();
89
+
90
+ const rest = line.slice(emailEnd + 1).trim().split(/\s+/);
91
+ originalCommitterTimestamp = parseInt(rest[0]);
92
+ originalCommitterOffset = rest[1] || '+0000';
93
+ }
94
+ }
95
+
96
+ const message = lines.slice(messageIndex).join('\n');
97
+ return {
98
+ hash,
99
+ tree,
100
+ parents,
101
+ authorName,
102
+ authorEmail,
103
+ committerName,
104
+ committerEmail,
105
+ originalAuthorDate: new Date(originalAuthorTimestamp * 1000),
106
+ originalAuthorOffset,
107
+ originalCommitterDate: new Date(originalCommitterTimestamp * 1000),
108
+ originalCommitterOffset,
109
+ message
110
+ };
111
+ }
112
+
113
+ function createCommitTree(treeHash, parents, authorName, authorEmail, authorDateStr, committerName, committerEmail, committerDateStr, message) {
114
+ const parentArgs = parents.map(p => `-p ${p}`).join(' ');
115
+ const cmd = `git commit-tree ${treeHash} ${parentArgs}`;
116
+
117
+ const env = {
118
+ ...process.env,
119
+ GIT_AUTHOR_NAME: authorName,
120
+ GIT_AUTHOR_EMAIL: authorEmail,
121
+ GIT_AUTHOR_DATE: authorDateStr,
122
+ GIT_COMMITTER_NAME: committerName,
123
+ GIT_COMMITTER_EMAIL: committerEmail,
124
+ GIT_COMMITTER_DATE: committerDateStr
125
+ };
126
+
127
+ return runGit(cmd, { input: message, env });
128
+ }
129
+
130
+ function updateBranchRef(branchName, commitHash) {
131
+ runGit(`git update-ref refs/heads/${branchName} ${commitHash}`);
132
+ }
133
+
134
+ function resetHard(commitHash) {
135
+ runGit(`git reset --hard ${commitHash}`);
136
+ }
137
+
138
+ function copyBranch(src, dest) {
139
+ runGit(`git branch -f ${dest} ${src}`);
140
+ }
141
+
142
+ function forcePush(remote, branchName) {
143
+ runGit(`git push --force ${remote} ${branchName}`);
144
+ }
145
+
146
+ module.exports = {
147
+ runGit,
148
+ isInsideGitWorktree,
149
+ hasUncommittedChanges,
150
+ getCurrentBranch,
151
+ branchExists,
152
+ getCommitsReverse,
153
+ getCommitDetails,
154
+ createCommitTree,
155
+ updateBranchRef,
156
+ resetHard,
157
+ copyBranch,
158
+ forcePush
159
+ };
package/lib/modes.js ADDED
@@ -0,0 +1,182 @@
1
+ const { distributeCommitsInWindow } = require('./date');
2
+
3
+ function equalize(N, D) {
4
+ const base = Math.floor(N / D);
5
+ const remainder = N % D;
6
+ const buckets = new Array(D).fill(base);
7
+ for (let i = 0; i < remainder; i++) {
8
+ buckets[i]++;
9
+ }
10
+ return buckets;
11
+ }
12
+
13
+ function randomise(N, D) {
14
+ const points = [];
15
+ for (let i = 0; i < D - 1; i++) {
16
+ points.push(Math.floor(Math.random() * (N + 1)));
17
+ }
18
+ points.sort((a, b) => a - b);
19
+ const buckets = [];
20
+ let prev = 0;
21
+ for (let i = 0; i < D - 1; i++) {
22
+ buckets.push(points[i] - prev);
23
+ prev = points[i];
24
+ }
25
+ buckets.push(N - prev);
26
+ return buckets;
27
+ }
28
+
29
+ function greedyMum(commits, S, E) {
30
+ const result = commits.map(c => ({
31
+ hash: c.hash,
32
+ newAuthorDate: c.originalAuthorDate,
33
+ newCommitterDate: c.originalCommitterDate,
34
+ unchanged: true
35
+ }));
36
+
37
+ let bufferMs = 60 * 60 * 1000; // 1 hour
38
+ const rangeDuration = E.getTime() - S.getTime();
39
+ if (rangeDuration < 2 * bufferMs) {
40
+ bufferMs = rangeDuration / 2;
41
+ }
42
+
43
+ const firstHourEnd = new Date(S.getTime() + bufferMs);
44
+ const lastHourStart = new Date(E.getTime() - bufferMs);
45
+
46
+ const firstDayStartOfDay = new Date(S.getFullYear(), S.getMonth(), S.getDate(), 0, 0, 0, 0);
47
+ const firstDayEndOfDay = new Date(S.getFullYear(), S.getMonth(), S.getDate(), 23, 59, 59, 999);
48
+
49
+ const lastDayStartOfDay = new Date(E.getFullYear(), E.getMonth(), E.getDate(), 0, 0, 0, 0);
50
+ const lastDayEndOfDay = new Date(E.getFullYear(), E.getMonth(), E.getDate(), 23, 59, 59, 999);
51
+
52
+ const beforeSIndices = [];
53
+ const afterEIndices = [];
54
+ const insideIndices = [];
55
+
56
+ for (let i = 0; i < commits.length; i++) {
57
+ const d = commits[i].originalAuthorDate;
58
+ if (d < S) {
59
+ beforeSIndices.push(i);
60
+ } else if (d > E) {
61
+ afterEIndices.push(i);
62
+ } else {
63
+ insideIndices.push(i);
64
+ }
65
+ }
66
+
67
+ if (beforeSIndices.length === 0 && afterEIndices.length === 0) {
68
+ return result;
69
+ }
70
+
71
+ // Case A: Commits before S
72
+ if (beforeSIndices.length > 0) {
73
+ const dates = distributeCommitsInWindow(S, firstHourEnd, beforeSIndices.length);
74
+ for (let j = 0; j < beforeSIndices.length; j++) {
75
+ const idx = beforeSIndices[j];
76
+ result[idx].newAuthorDate = dates[j];
77
+ result[idx].newCommitterDate = dates[j];
78
+ result[idx].unchanged = false;
79
+ }
80
+ }
81
+
82
+ // First-day nudges
83
+ const firstDayNudgeIndices = [];
84
+ for (const idx of insideIndices) {
85
+ const d = commits[idx].originalAuthorDate;
86
+ const isOnFirstDay = d >= firstDayStartOfDay && d <= firstDayEndOfDay;
87
+ if (isOnFirstDay && d <= firstHourEnd) {
88
+ firstDayNudgeIndices.push(idx);
89
+ }
90
+ }
91
+
92
+ if (firstDayNudgeIndices.length > 0) {
93
+ let nudgeWindowEnd = firstDayEndOfDay;
94
+ const lastNudgeIdx = firstDayNudgeIndices[firstDayNudgeIndices.length - 1];
95
+
96
+ let nextIdx = -1;
97
+ for (let i = 0; i < insideIndices.length; i++) {
98
+ if (insideIndices[i] === lastNudgeIdx) {
99
+ if (i + 1 < insideIndices.length) {
100
+ nextIdx = insideIndices[i + 1];
101
+ }
102
+ break;
103
+ }
104
+ }
105
+
106
+ if (nextIdx !== -1) {
107
+ const nextDate = commits[nextIdx].originalAuthorDate;
108
+ const nextIsOnFirstDay = nextDate >= firstDayStartOfDay && nextDate <= firstDayEndOfDay;
109
+ if (nextIsOnFirstDay) {
110
+ nudgeWindowEnd = nextDate;
111
+ }
112
+ }
113
+
114
+ const nudgeDates = distributeCommitsInWindow(firstHourEnd, nudgeWindowEnd, firstDayNudgeIndices.length);
115
+ for (let j = 0; j < firstDayNudgeIndices.length; j++) {
116
+ const idx = firstDayNudgeIndices[j];
117
+ result[idx].newAuthorDate = nudgeDates[j];
118
+ result[idx].newCommitterDate = nudgeDates[j];
119
+ result[idx].unchanged = false;
120
+ }
121
+ }
122
+
123
+ // Case B: Commits after E
124
+ if (afterEIndices.length > 0) {
125
+ const dates = distributeCommitsInWindow(lastHourStart, E, afterEIndices.length);
126
+ for (let j = 0; j < afterEIndices.length; j++) {
127
+ const idx = afterEIndices[j];
128
+ result[idx].newAuthorDate = dates[j];
129
+ result[idx].newCommitterDate = dates[j];
130
+ result[idx].unchanged = false;
131
+ }
132
+ }
133
+
134
+ // Last-day nudges
135
+ const lastDayNudgeIndices = [];
136
+ for (const idx of insideIndices) {
137
+ const d = commits[idx].originalAuthorDate;
138
+ const isOnLastDay = d >= lastDayStartOfDay && d <= lastDayEndOfDay;
139
+ if (isOnLastDay && d >= lastHourStart) {
140
+ lastDayNudgeIndices.push(idx);
141
+ }
142
+ }
143
+
144
+ if (lastDayNudgeIndices.length > 0) {
145
+ let nudgeWindowStart = lastDayStartOfDay;
146
+ const firstNudgeIdx = lastDayNudgeIndices[0];
147
+
148
+ let prevIdx = -1;
149
+ for (let i = 0; i < insideIndices.length; i++) {
150
+ if (insideIndices[i] === firstNudgeIdx) {
151
+ if (i - 1 >= 0) {
152
+ prevIdx = insideIndices[i - 1];
153
+ }
154
+ break;
155
+ }
156
+ }
157
+
158
+ if (prevIdx !== -1) {
159
+ const prevDate = commits[prevIdx].originalAuthorDate;
160
+ const prevIsOnLastDay = prevDate >= lastDayStartOfDay && prevDate <= lastDayEndOfDay;
161
+ if (prevIsOnLastDay) {
162
+ nudgeWindowStart = prevDate;
163
+ }
164
+ }
165
+
166
+ const nudgeDates = distributeCommitsInWindow(nudgeWindowStart, lastHourStart, lastDayNudgeIndices.length);
167
+ for (let j = 0; j < lastDayNudgeIndices.length; j++) {
168
+ const idx = lastDayNudgeIndices[j];
169
+ result[idx].newAuthorDate = nudgeDates[j];
170
+ result[idx].newCommitterDate = nudgeDates[j];
171
+ result[idx].unchanged = false;
172
+ }
173
+ }
174
+
175
+ return result;
176
+ }
177
+
178
+ module.exports = {
179
+ equalize,
180
+ randomise,
181
+ greedyMum
182
+ };
package/package.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "name": "urmama",
3
+ "version": "1.0.0",
4
+ "description": "Rewrite the full commit history of a Git branch so that all commits fall within a specified date range.",
5
+ "main": "bin/urmama.js",
6
+ "bin": {
7
+ "urmama": "./bin/urmama.js"
8
+ },
9
+ "scripts": {
10
+ "test": "node tests/urmama.test.js"
11
+ },
12
+ "keywords": ["git", "history", "rewrite", "dates", "commit"],
13
+ "author": "",
14
+ "license": "ISC"
15
+ }