mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 00:44:07 +01:00
fix(timeline): reject a missing or malformed --limit with exit 2 (#1723)
gstack-timeline-read parsed --limit with parseInt and fell back to 20, so '--limit 1abc' became 1, 'nope', 0 and -5 silently became the default, and a bare --limit crashed on an unbound $2. Validate the argument as a positive integer before the no-timeline early exit and exit 2 with an example. No flag still means 20. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
f67c478f05
commit
f9796eef8e
2 files changed
+60
-6
No files matched your search
@@ -5,7 +5,8 @@
|
||||
# Session timeline: local-only, never sent anywhere.
|
||||
# Reads ~/.gstack/projects/$SLUG/timeline.jsonl, filters, formats.
|
||||
# Exit 0 silently if no timeline file exists or nothing matches. Missing bun
|
||||
# exits 127 and a failed read exits non-zero with its stderr (#2790).
|
||||
# exits 127 and a failed read exits non-zero with its stderr (#2790). A missing
|
||||
# or non-positive-integer --limit exits 2 before any read (#1723).
|
||||
set -euo pipefail
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
eval "$("$SCRIPT_DIR/gstack-slug" 2>/dev/null)"
|
||||
@@ -20,7 +21,16 @@ BRANCH=""
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--since) SINCE="$2"; shift 2 ;;
|
||||
--limit) LIMIT="$2"; shift 2 ;;
|
||||
--limit)
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "gstack-timeline-read: --limit must be a positive integer (got no value); example: --limit 20" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! "$2" =~ ^[1-9][0-9]*$ ]]; then
|
||||
echo "gstack-timeline-read: --limit must be a positive integer (got '$2'); example: --limit 20" >&2
|
||||
exit 2
|
||||
fi
|
||||
LIMIT="$2"; shift 2 ;;
|
||||
--branch) BRANCH="$2"; shift 2 ;;
|
||||
*) shift ;;
|
||||
esac
|
||||
@@ -41,9 +51,7 @@ cat "$TIMELINE_FILE" | GSTACK_TIMELINE_SINCE="$SINCE" GSTACK_TIMELINE_BRANCH="$B
|
||||
const lines = (await Bun.stdin.text()).trim().split('\n').filter(Boolean);
|
||||
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;
|
||||
const limit = Number(process.env.GSTACK_TIMELINE_LIMIT);
|
||||
|
||||
let sinceMs = 0;
|
||||
if (since) {
|
||||
|
||||
+47
-1
@@ -1,5 +1,5 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
|
||||
import { execFileSync, execSync, ExecSyncOptionsWithStringEncoding } from 'child_process';
|
||||
import { execFileSync, execSync, spawnSync, ExecSyncOptionsWithStringEncoding } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
@@ -56,6 +56,16 @@ function runReadArgs(args: string[] = []): string {
|
||||
}
|
||||
}
|
||||
|
||||
function runReadStatus(args: string[]): { status: number | null; stdout: string; stderr: string } {
|
||||
const result = spawnSync(path.join(BIN, 'gstack-timeline-read'), args, {
|
||||
cwd: ROOT,
|
||||
env: { ...process.env, GSTACK_HOME: tmpDir },
|
||||
encoding: 'utf-8',
|
||||
timeout: 15000,
|
||||
});
|
||||
return { status: result.status, stdout: result.stdout, stderr: result.stderr };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-timeline-'));
|
||||
slugDir = path.join(tmpDir, 'projects');
|
||||
@@ -176,4 +186,40 @@ describe('gstack-timeline-read', () => {
|
||||
expect(unlimitedEvents).toBe(5);
|
||||
expect(limitedEvents).toBe(2);
|
||||
});
|
||||
|
||||
test('no --limit still shows the 20 most recent events', () => {
|
||||
for (let i = 0; i < 25; i++) {
|
||||
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'main', ts: `2026-03-01T10:${String(i).padStart(2, '0')}:00Z` }));
|
||||
}
|
||||
const result = runReadStatus([]);
|
||||
expect(result.status).toBe(0);
|
||||
const events = result.stdout.split('\n').filter(l => l.startsWith('- '));
|
||||
expect(events.length).toBe(20);
|
||||
expect(events[0]).toContain('10:05:00Z');
|
||||
});
|
||||
|
||||
// #1723: a malformed --limit used to be parsed leniently (1abc -> 1) or
|
||||
// silently replaced by the default; a bare --limit crashed on unbound $2.
|
||||
const badLimits: Array<[string, string[], string]> = [
|
||||
['missing value', ['--limit'], 'got no value'],
|
||||
['zero', ['--limit', '0'], "got '0'"],
|
||||
['negative', ['--limit', '-5'], "got '-5'"],
|
||||
['non-numeric', ['--limit', 'abc'], "got 'abc'"],
|
||||
['trailing garbage', ['--limit', '1abc'], "got '1abc'"],
|
||||
];
|
||||
for (const history of ['empty', 'populated'] as const) {
|
||||
for (const [label, args, got] of badLimits) {
|
||||
test(`--limit ${label} exits 2 with an example on ${history} history`, () => {
|
||||
if (history === 'populated') {
|
||||
runLog(JSON.stringify({ skill: 'review', event: 'completed', branch: 'main', ts: '2026-03-28T10:00:00Z' }));
|
||||
}
|
||||
const result = runReadStatus(args);
|
||||
expect(result.status).toBe(2);
|
||||
expect(result.stdout).toBe('');
|
||||
expect(result.stderr).toContain('--limit must be a positive integer');
|
||||
expect(result.stderr).toContain(got);
|
||||
expect(result.stderr).toContain('example: --limit 20');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user