Skip to content

Commit 60e744a

Browse files
feat: accept human-friendly duration formats in prune --older-than (#24)
* Initial plan * feat: add human-friendly duration format support to prune command Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> * doc: update documentation to show human-friendly duration format support Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: captainsafia <1857993+captainsafia@users.noreply.github.com>
1 parent 7dfdbb7 commit 60e744a

5 files changed

Lines changed: 227 additions & 6 deletions

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ Remove worktrees older than a specific duration (bypasses merge check):
173173

174174
**Note:** When using `--older-than`, the merge status check is bypassed, and all worktrees older than the specified duration will be removed. The `--base` flag cannot be used with `--older-than`.
175175

176+
You can use human-friendly formats (e.g., `30d`, `2w`, `6M`, `1y`) or ISO 8601 duration format (e.g., `P30D`, `P2W`, `P6M`, `P1Y`):
177+
176178
```bash
177179
# Remove worktrees older than 30 days
178180
grove prune --older-than 30d
@@ -185,6 +187,9 @@ grove prune --older-than 1y
185187

186188
# Preview what would be removed for worktrees older than 2 weeks
187189
grove prune --older-than 2w --dry-run
190+
191+
# ISO 8601 format is also supported
192+
grove prune --older-than P30D
188193
```
189194

190195
### Self-update

site/index.html

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,8 +286,10 @@ <h4>Prune worktrees</h4>
286286
<pre><code>grove prune --dry-run</code></pre>
287287
<p>Remove worktrees for branches merged to main:</p>
288288
<pre><code>grove prune</code></pre>
289-
<p>Remove worktrees older than 30 days (uses ISO 8601 duration format):</p>
290-
<pre><code>grove prune --older-than P30D</code></pre>
289+
<p>Remove worktrees older than 30 days (supports human-friendly or ISO 8601 format):</p>
290+
<pre><code>grove prune --older-than 30d
291+
# or
292+
grove prune --older-than P30D</code></pre>
291293
<p>Use a different base branch:</p>
292294
<pre><code>grove prune --base develop</code></pre>
293295
</div>

src/commands/prune.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export function createPruneCommand(): Command {
3535
.option("-y, --yes", "Skip confirmation prompt", false)
3636
.option(
3737
"--older-than <duration>",
38-
"Prune worktrees older than specified duration, bypassing merge check (use ISO 8601 format like P30D, P1Y, P2W, PT1H)",
38+
"Prune worktrees older than specified duration, bypassing merge check (e.g., 30d, 2w, 6M, 1y, or ISO 8601 like P30D, P1Y)",
3939
)
4040
.action(async (options: PruneCommandOptions) => {
4141
try {

src/utils/index.ts

Lines changed: 87 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -69,21 +69,105 @@ export function extractRepoName(gitUrl: string): string {
6969
return repoName;
7070
}
7171

72+
/**
73+
* Normalize human-friendly duration strings to ISO 8601 format.
74+
* Accepts formats like: 30d, 2w, 6M, 1y, 12h, 30m
75+
* Returns ISO 8601 format: P30D, P2W, P6M, P1Y, PT12H, PT30M
76+
* Note: Uppercase M = months, lowercase m = minutes
77+
*/
78+
export function normalizeDuration(durationStr: string): string {
79+
if (!durationStr || durationStr.trim() === '') {
80+
return durationStr;
81+
}
82+
83+
const normalized = durationStr.trim();
84+
85+
// If it already starts with 'P', assume it's ISO 8601 format
86+
if (normalized.toUpperCase().startsWith('P')) {
87+
return normalized;
88+
}
89+
90+
// Match patterns like: 30d, 2w, 6M, 1y, 12h, 30m
91+
// Note: M (uppercase) = months, m (lowercase) = minutes
92+
// Case-insensitive match, but we preserve the M/m distinction
93+
const match = normalized.match(/^(\d+(?:\.\d+)?)\s*([dDwWMmyYhHsS])$/);
94+
if (!match) {
95+
// Return as-is if format doesn't match - let parseDuration handle error
96+
return normalized;
97+
}
98+
99+
const [, value, unit] = match;
100+
101+
// Map human-friendly units to ISO 8601 format
102+
// Time units (h, H, m, s, S) need PT prefix
103+
// Date units (d, D, w, W, M, y, Y) need P prefix
104+
let iso8601Unit: string;
105+
let isTimeUnit: boolean;
106+
107+
switch (unit) {
108+
case 'd':
109+
case 'D':
110+
iso8601Unit = 'D';
111+
isTimeUnit = false;
112+
break;
113+
case 'w':
114+
case 'W':
115+
iso8601Unit = 'W';
116+
isTimeUnit = false;
117+
break;
118+
case 'M': // Uppercase M = months
119+
iso8601Unit = 'M';
120+
isTimeUnit = false;
121+
break;
122+
case 'y':
123+
case 'Y':
124+
iso8601Unit = 'Y';
125+
isTimeUnit = false;
126+
break;
127+
case 'h':
128+
case 'H':
129+
iso8601Unit = 'H';
130+
isTimeUnit = true;
131+
break;
132+
case 'm': // Lowercase m = minutes
133+
iso8601Unit = 'M';
134+
isTimeUnit = true;
135+
break;
136+
case 's':
137+
case 'S':
138+
iso8601Unit = 'S';
139+
isTimeUnit = true;
140+
break;
141+
default:
142+
return normalized;
143+
}
144+
145+
// Time units need PT prefix, date units need P prefix
146+
if (isTimeUnit) {
147+
return `PT${value}${iso8601Unit}`;
148+
} else {
149+
return `P${value}${iso8601Unit}`;
150+
}
151+
}
152+
72153
export function parseDuration(durationStr: string): number {
73154
if (!durationStr || durationStr.trim() === '') {
74-
throw new Error('Duration cannot be empty (use ISO 8601 duration format like P30D, P1Y, P2W, PT1H)');
155+
throw new Error('Duration cannot be empty (use formats like: 30d, 2w, 6M, 1y, 12h, 30m or ISO 8601 like P30D, P1Y, P2W, PT1H)');
75156
}
76157

158+
// Normalize human-friendly format to ISO 8601
159+
const normalized = normalizeDuration(durationStr);
160+
77161
try {
78-
const duration = moment.duration(durationStr.toUpperCase());
162+
const duration = moment.duration(normalized.toUpperCase());
79163
if (duration.asMilliseconds() > 0) {
80164
return duration.asMilliseconds();
81165
} else {
82166
throw new Error(`Invalid or zero duration: ${durationStr}`);
83167
}
84168
} catch (error) {
85169
throw new Error(
86-
`Invalid duration format: ${durationStr} (use ISO 8601 duration format like P30D, P1Y, P2W, PT1H)`,
170+
`Invalid duration format: ${durationStr} (use formats like: 30d, 2w, 6M, 1y, 12h, 30m or ISO 8601 like P30D, P1Y, P2W, PT1H)`,
87171
);
88172
}
89173
}

test/unit/utils.test.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, test, expect } from "bun:test";
22
import {
33
extractRepoName,
44
parseDuration,
5+
normalizeDuration,
56
formatCreatedTime,
67
formatPathWithTilde,
78
isValidGitUrl,
@@ -219,6 +220,86 @@ describe("isValidGitUrl", () => {
219220
});
220221
});
221222

223+
describe("normalizeDuration", () => {
224+
describe("Human-friendly formats", () => {
225+
test("should normalize days (30d to P30D)", () => {
226+
expect(normalizeDuration("30d")).toBe("P30D");
227+
});
228+
229+
test("should normalize weeks (2w to P2W)", () => {
230+
expect(normalizeDuration("2w")).toBe("P2W");
231+
});
232+
233+
test("should normalize months (6M to P6M)", () => {
234+
expect(normalizeDuration("6M")).toBe("P6M");
235+
});
236+
237+
test("should normalize years (1y to P1Y)", () => {
238+
expect(normalizeDuration("1y")).toBe("P1Y");
239+
});
240+
241+
test("should normalize hours (12h to PT12H)", () => {
242+
expect(normalizeDuration("12h")).toBe("PT12H");
243+
});
244+
245+
test("should normalize minutes (30m to PT30M)", () => {
246+
expect(normalizeDuration("30m")).toBe("PT30M");
247+
});
248+
249+
test("should normalize seconds (45s to PT45S)", () => {
250+
expect(normalizeDuration("45s")).toBe("PT45S");
251+
});
252+
253+
test("should handle uppercase units", () => {
254+
expect(normalizeDuration("30D")).toBe("P30D");
255+
});
256+
257+
test("should handle lowercase units", () => {
258+
expect(normalizeDuration("2w")).toBe("P2W");
259+
});
260+
261+
test("should handle whitespace between number and unit", () => {
262+
expect(normalizeDuration("30 d")).toBe("P30D");
263+
});
264+
265+
test("should handle decimal values", () => {
266+
expect(normalizeDuration("1.5d")).toBe("P1.5D");
267+
});
268+
});
269+
270+
describe("ISO 8601 formats (should pass through)", () => {
271+
test("should pass through P30D unchanged", () => {
272+
expect(normalizeDuration("P30D")).toBe("P30D");
273+
});
274+
275+
test("should pass through P2W unchanged", () => {
276+
expect(normalizeDuration("P2W")).toBe("P2W");
277+
});
278+
279+
test("should pass through PT1H unchanged", () => {
280+
expect(normalizeDuration("PT1H")).toBe("PT1H");
281+
});
282+
283+
test("should pass through lowercase p30d", () => {
284+
expect(normalizeDuration("p30d")).toBe("p30d");
285+
});
286+
});
287+
288+
describe("Invalid formats (should pass through for parseDuration to handle)", () => {
289+
test("should pass through empty string", () => {
290+
expect(normalizeDuration("")).toBe("");
291+
});
292+
293+
test("should pass through invalid format", () => {
294+
expect(normalizeDuration("30 days")).toBe("30 days");
295+
});
296+
297+
test("should pass through number without unit", () => {
298+
expect(normalizeDuration("30")).toBe("30");
299+
});
300+
});
301+
});
302+
222303
describe("parseDuration", () => {
223304
describe("Valid ISO 8601 durations", () => {
224305
test("should parse days (P30D)", () => {
@@ -264,6 +345,55 @@ describe("parseDuration", () => {
264345
});
265346
});
266347

348+
describe("Human-friendly formats", () => {
349+
test("should parse days (30d)", () => {
350+
const result = parseDuration("30d");
351+
expect(result).toBe(30 * 24 * 60 * 60 * 1000);
352+
});
353+
354+
test("should parse weeks (2w)", () => {
355+
const result = parseDuration("2w");
356+
expect(result).toBe(14 * 24 * 60 * 60 * 1000);
357+
});
358+
359+
test("should parse months (6M)", () => {
360+
const result = parseDuration("6M");
361+
// Moment calculates months, approximately 182 days for 6 months
362+
expect(result).toBeGreaterThan(15500000000);
363+
expect(result).toBeLessThan(16000000000);
364+
});
365+
366+
test("should parse years (1y)", () => {
367+
const result = parseDuration("1y");
368+
expect(result).toBe(365 * 24 * 60 * 60 * 1000);
369+
});
370+
371+
test("should parse hours (12h)", () => {
372+
const result = parseDuration("12h");
373+
expect(result).toBe(12 * 60 * 60 * 1000);
374+
});
375+
376+
test("should parse minutes (30m)", () => {
377+
const result = parseDuration("30m");
378+
expect(result).toBe(30 * 60 * 1000);
379+
});
380+
381+
test("should parse seconds (45s)", () => {
382+
const result = parseDuration("45s");
383+
expect(result).toBe(45 * 1000);
384+
});
385+
386+
test("should handle uppercase human-friendly format", () => {
387+
const result = parseDuration("30D");
388+
expect(result).toBe(30 * 24 * 60 * 60 * 1000);
389+
});
390+
391+
test("should handle whitespace in human-friendly format", () => {
392+
const result = parseDuration("30 d");
393+
expect(result).toBe(30 * 24 * 60 * 60 * 1000);
394+
});
395+
});
396+
267397
describe("Error cases", () => {
268398
test("should throw for empty string", () => {
269399
expect(() => parseDuration("")).toThrow("Duration cannot be empty");

0 commit comments

Comments
 (0)