Skip to content

feat: add ccsession gc — disk usage report and cleanup for claude transcripts #113

Description

@sorafujitani

Problem

~/.claude/projects grows silently — long-lived users accumulate gigabytes of JSONL transcripts, and there is no tool to see where the space goes or to reclaim it safely. ccsession already knows how to enumerate every transcript, so it is the natural place for a disk-usage report and a guarded cleanup command.

UX spec

ccsession gc                       # DRY RUN: list deletion candidates + total size, delete nothing
ccsession gc --older-than 90d      # change the age threshold (default 90d)
ccsession gc --min-size 100kb      # additionally require a minimum file size
ccsession gc --rm                  # actually delete, after an interactive y/N confirmation
ccsession gc --rm --yes            # delete without prompting (for scripts)
ccsession gc --archive ~/backup    # write candidates into a tar.gz under ~/backup, then delete them
ccsession gc --du                  # per-project disk usage summary, no candidates, never deletes

Dry-run / candidate output (TSV, aligned with list conventions):

AGE    SIZE    DIR         LABEL                       PATH
182d   4.2MB   ccsession   fix preview highlight ...   /Users/x/.claude/projects/.../abc.jsonl
120d   890KB   myapp       add login endpoint ...      /Users/x/.claude/projects/.../def.jsonl
--
2 sessions, 5.1MB total (dry run — pass --rm to delete, --archive DIR to archive)

--du output:

SIZE     SESSIONS  PROJECT
1.8GB    412       /Users/x/.claude/projects/-Users-x-ghq-...-myapp
340MB    98        /Users/x/.claude/projects/-Users-x-ghq-...-ccsession
--
2.2GB total, 510 sessions

Scope: claude source only

This command operates on the Claude JSONL store only. OpenCode is a single SQLite database and Grok/Codex have different store layouts; deleting inside those is a separate feature. If CCSESSION_SOURCE (or --source) selects anything other than the claude default, gc must fail fast with: gc currently supports the claude source only.

Implementation plan

1. New package internal/gc

  • gc.Options{OlderThan time.Duration, MinSize int64, Mode (DryRun|Delete|Archive), ArchiveDir string, Yes bool, DU bool, Out io.Writer}.
  • Candidate discovery:
    • Root via session.ProjectsDir() (internal/session/scan.go:17) so CLAUDE_CONFIG_DIR is honored.
    • Enumerate with session.CollectJSONLPaths (scan.go:69), then os.Lstat each path: skip anything that is not a regular file (symlinks explicitly excluded), record size and mtime.
    • A path is a candidate iff now-mtime > OlderThan AND size >= MinSize.
    • Labels: parse candidates with session.ParseSessionTail for display only; a file that fails to parse (ErrSessionEmpty, corrupt) is still a candidate — unparseable old files are exactly the junk gc should reclaim. Show (unparseable) as the label.
  • Safety invariants (enforce in code, not just by construction):
    • every deleted path must be inside ProjectsDir() (verify with filepath.Rel — reject .. escapes) and have the .jsonl suffix;
    • never touch a file whose mtime is within the last 7 days, regardless of flags — a hard floor so --older-than 0s cannot nuke live sessions;
    • deletion also removes a matching <id> sidecar only if one exists today — as of now there are none, so: delete exactly the JSONL file, leave directories in place even if empty.
  • Archive mode: create ccsession-gc-<YYYYMMDD-HHMMSS>.tar.gz (timestamp from a clock passed in, see Testing) under ArchiveDir containing candidates with paths relative to ProjectsDir(). Delete originals only after the tar.gz is fully written, fsynced, and closed. On any archive error: delete nothing, remove the partial tar.gz, return the error.
  • --du: group os.Lstat results by the first path segment under ProjectsDir(), sort by total size descending.

2. Flag parsing helpers

  • --older-than accepts 90d, 24h, 30m: parse a trailing d as 24h*n and delegate the rest to time.ParseDuration (Go's parser has no days unit). Put the helper in internal/gc with tests.
  • --min-size accepts 100kb, 2mb, 1gb (case-insensitive, decimal 1000-based or 1024-based — pick 1024 and document it) plus bare bytes. Same file.

3. CLI wiring

  • New case "gc" in the subcommand switch in cmd/ccsession/main.go (main.go:184), flag parsing mirroring cmdList's style.
  • Confirmation prompt for --rm without --yes: print the candidate summary, then read a line from stdin; proceed only on y/yes. If stdin is not a tty, refuse and tell the user to pass --yes.
  • --rm and --archive are mutually exclusive; both imply "not a dry run".
  • Update usage text and README (new "Housekeeping" subsection under Usage).

Edge cases

  • Empty projects dir / no candidates: print nothing to clean and exit 0.
  • Partial failure while deleting (one file EPERM): continue with the rest, report each failure to stderr, exit non-zero at the end.
  • --archive dir does not exist: create it with os.MkdirAll.
  • Candidate file disappears between listing and deletion (session deleted by claude itself): treat os.IsNotExist on remove as success.
  • --du with unreadable subdirectory: report to stderr, continue.

Testing

Table-driven tests in internal/gc using t.TempDir() as a fake projects dir (set CLAUDE_CONFIG_DIR via t.Setenv):

  • candidate selection across age/size boundaries, including the 7-day hard floor and symlink exclusion;
  • duration/size parsers (valid, invalid, units);
  • delete mode removes exactly the candidates and nothing else;
  • archive mode: tar.gz round-trips (extract and compare), originals gone, and the "archive write fails → nothing deleted" invariant (make ArchiveDir read-only);
  • --du aggregation and sort order;
  • non-claude source fails fast.

Inject now (a time.Time field on Options, defaulting to time.Now() in the CLI layer) so age boundaries are deterministic.

Non-goals

  • gc for opencode/grok/codex stores.
  • Interactive multi-select via fzf (--multi) — a possible follow-up once the flag-driven core exists.
  • Any persistent config for thresholds; flags only.

Acceptance criteria

  • ccsession gc never modifies the filesystem without --rm or --archive.
  • --rm prompts by default, --yes skips the prompt, --archive produces a tar.gz that round-trips.
  • The 7-day hard floor holds for every mode and flag combination.
  • ccsession gc --du gives an accurate per-project size/count summary.
  • gofmt -l . clean, go vet ./... clean, golangci-lint run clean, new tests pass with go test ./....

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions