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 +119 -0
- package/bin/urmama.js +306 -0
- package/lib/date.js +101 -0
- package/lib/git.js +159 -0
- package/lib/modes.js +182 -0
- package/package.json +15 -0
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
|
+
}
|