Skip to content

feat: add 'gra ls --strays' to find and clean what gra does not manage - #34

Merged
martinus merged 1 commit into
mainfrom
feat/ls-strays
Sep 14, 2026
Merged

martinus merged 1 commit into
mainfrom
feat/ls-strays

Conversation

@martinus

Copy link
Copy Markdown
Owner

The gra root is flat, so anything can end up sitting in it: a plain git clone, a build directory, a file saved in the wrong terminal. No gra command has ever looked at those, which is why they go unnoticed. On the root this was written against: 31 directories and 3 files gra never created, holding 21G.

gra ls --strays

Root: /home/me/gra
Strays: 3 directories, 1 file  1.2G

NAME             SIZE   MODIFIED       STATUS
abseil-build/    1.1G   6 months ago   -
notes.md         8K     2 days ago     -
scratch/         95M    3 days ago     ● dirty ↑2
vendored/        4.2M   1 year ago     ✓ clean

A stray is any entry directly under the root that is not a repository — gra calls a directory a repository when it holds a .bare. Names carry the ls -F markers: / a directory, @ a symlink whose SIZE is the link's own, not the tree it points at.

STATUS is the column that decides whether something is safe to delete. - means Git has never heard of it. Anything else is a checkout, dirty when something is uncommitted and ↑2 when two commits have not been pushed — local refs only, like the rest of gra ls. A checkout with no remote at all reads as ✓ clean and may still hold the only copy of its commits; the README says so.

gra ls --strays --rm

Opens the same table in fzf, where Tab marks as many entries as you want. What was picked is listed back with what it costs, and one question carries it out:

Deleting 2 directories, freeing 1.1G:
  /home/me/gra/abseil-build  -
  /home/me/gra/scratch  ● dirty ↑2

delete for good? [y/N]

There is no flag that deletes every stray at once. A flat root is exactly where the only copy of something ends up, so the picking stays manual. A file that cannot go — a read-only build artifact — is reported and the rest of the selection still goes, with a non-zero exit naming what stayed.

Notes

  • --fetch and --strays are mutually exclusive; --rm without --strays fails rather than guessing.
  • fzf_table gained multi= and now returns list[str], so pick_branch takes [0].
  • Both flags added to bash completion.
  • Version 8.5.0 → 8.6.0.

Verification

python3 -m pytest tests/ -q149 passed (11 new tests).

16 semantic mutations, all killed. Each new test was verified by reversing the behaviour it covers and confirming the expected failure:

Mutation Observed failure
root_strays keeps repositories assert 'oans' not in ...
stray_status never reads Git aclean/ no longer ends ✓ clean
remove_strays deletes all strays bkeep/file.txt gone
fzf_table drops --multi '--multi' not in [fzf args]
remove_strays ignores the answer exit 0 instead of 1
delete_stray follows a symlink rmtree raised on the link
ls_command drops the --rm guard exit 0 instead of 1
print_strays silent on empty root 'No strays found.' missing
human_size rounds whole '1M' == '1.0M'
relative_age drops plural '2 hour ago' == '2 hours ago'
stray_name marks nothing / marks a link as a dir 'link@' missing
stray_summary lumps links with files '1 directory, 1 link, 1 file' missing
delete_stray re-raises OSError batch aborted
remove_strays silent about failures exit 0 instead of 1
remove_strays counts failures as deleted 'deleted 1 stray' vs deleted 2
stray_total / stray_size lose the unknown size 'over 2.0M' and ? missing

Not run: the fzf picker with a real fzf (every --rm test drives the existing mock, as before); Python 3.10 locally, though CI covers it and the new code uses nothing past 3.10.

Known costs: du -sk over the whole root is the price of the SIZE column — under a second warm on 21G of strays, but a cold cache will block on disk. shutil.rmtree can delete part of a tree before failing; the entry is then reported as failed but is already partly gone.

🤖 Generated with Claude Code

The gra root is flat, so anything can end up sitting in it: a plain
'git clone', a build directory, a file saved in the wrong terminal. No
gra command has ever looked at those, which is exactly why they go
unnoticed - a root here had 31 directories and 3 files that gra never
created, holding 21G between them.

'gra ls --strays' is the one command that sees them. A stray is any
entry directly under the root without a '.bare', listed with its size,
its age, and a STATUS column: '-' when Git has never heard of it, and
otherwise 'dirty' or '↑2' for a checkout that may hold the only copy
of something. Names carry the ls -F markers, so a symlink's size reads
as its own rather than the tree it points at.

'--strays --rm' opens the same table in fzf, where Tab marks as many
entries as you want. What was picked is listed back with what it
costs, and one question carries it out. There is no flag that deletes
every stray at once, because a flat root is exactly where the only
copy of something ends up. A file that cannot go is reported and the
rest of the selection still goes.

fzf_table grew a multi flag and now returns the chosen keys, which is
why the branch picker takes the first one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@martinus
martinus merged commit 142f2df into main Sep 14, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant