diff --git a/bin/gstack-timeline-read b/bin/gstack-timeline-read index f11d5b40e3..5c1b6bb6f8 100755 --- a/bin/gstack-timeline-read +++ b/bin/gstack-timeline-read @@ -29,11 +29,13 @@ if [ ! -f "$TIMELINE_FILE" ]; then exit 0 fi -cat "$TIMELINE_FILE" 2>/dev/null | bun -e " +cat "$TIMELINE_FILE" 2>/dev/null | GSTACK_TIMELINE_SINCE="$SINCE" GSTACK_TIMELINE_BRANCH="$BRANCH" GSTACK_TIMELINE_LIMIT="$LIMIT" bun -e " const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean); -const since = '${SINCE}'; -const branch = '${BRANCH}'; -const limit = ${LIMIT}; +const since = process.env.GSTACK_TIMELINE_SINCE || ''; +const branch = process.env.GSTACK_TIMELINE_BRANCH || ''; +const limitRaw = process.env.GSTACK_TIMELINE_LIMIT || '20'; +const parsedLimit = Number.parseInt(limitRaw, 10); +const limit = Number.isSafeInteger(parsedLimit) && parsedLimit > 0 ? parsedLimit : 20; let sinceMs = 0; if (since) { diff --git a/test/timeline.test.ts b/test/timeline.test.ts index 2504ec1f91..6d30a939eb 100644 --- a/test/timeline.test.ts +++ b/test/timeline.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; -import { execSync, ExecSyncOptionsWithStringEncoding } from 'child_process'; +import { execFileSync, execSync, ExecSyncOptionsWithStringEncoding } from 'child_process'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; @@ -42,6 +42,20 @@ function runRead(args: string = ''): string { } } +function runReadArgs(args: string[] = []): string { + const execOpts: ExecSyncOptionsWithStringEncoding = { + cwd: ROOT, + env: { ...process.env, GSTACK_HOME: tmpDir }, + encoding: 'utf-8', + timeout: 15000, + }; + try { + return execFileSync(path.join(BIN, 'gstack-timeline-read'), args, execOpts).trim(); + } catch { + return ''; + } +} + beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-timeline-')); slugDir = path.join(tmpDir, 'projects'); @@ -136,6 +150,17 @@ describe('gstack-timeline-read', () => { expect(output).not.toContain('feature-b'); }); + test('filters branch names containing single quotes', () => { + runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: "feature/o'hare", outcome: 'approved', ts: '2026-03-28T10:00:00Z' })); + runLog(JSON.stringify({ skill: 'ship', event: 'completed', branch: 'feature-other', outcome: 'merged', ts: '2026-03-28T11:00:00Z' })); + + const output = runReadArgs(['--branch', "feature/o'hare"]); + + expect(output).toContain('review'); + expect(output).toContain("feature/o'hare"); + expect(output).not.toContain('feature-other'); + }); + test('limits output with --limit', () => { for (let i = 0; i < 5; i++) { runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'main', outcome: 'approved', ts: `2026-03-2${i}T10:00:00Z` }));