A self-composting junk drawer.
Files you toss into ~/shit decay on a TTL, rot into a compost bin instead of
oblivion, and are destroyed only when you say so.
NAME SIZE AGE DECAY STATE
cascade.html 38K 13w ████████████ rotten — next rot composts it
“no idea where this came from”
invoice-scan.pdf 1.1M 3w ██████████░░ funky, rots in 3d
vpn-configs/ 17B 11d ████░░░░░░░░ fresh, rots in 2w
mystery.zip 391K 1h ░░░░░░░░░░░░ fresh, rots in 4w
keystore.jks 2.1K 29w ∞ kept
5 items, 1.6M total
1 ready to compost — run `shit rot`
One Python file. Zero dependencies. Python 3.8+. MIT.
- The problem
- Install
- Sixty-second tour
- Concepts
- The lifecycle
- Command reference
- Name resolution
- Configuration
- Automation
- Scripting
- Recipes
- How it stores things
- Safety guarantees
- Design decisions
- Limitations and non-goals
- Troubleshooting
- Development
- The git typo thing
You have a directory where files go to be dealt with later. Later never comes.
There are only two ways it ends, and both are bad:
- You never delete it. It becomes archaeology — a stratum of
Screenshot from 2023-11-04.png,final_v2_ACTUAL.zip, and acascade.htmlyou cannot account for. You stop opening it. You rundu -shon it once a year and feel something. - You delete all of it. Three weeks later you need the one file that mattered, and it's gone.
The reliable tell is that a second drawer appears. ~/tmp, then
~/scratch. ~/shit, then ~/newshit. The first one didn't fail because you
lacked discipline. It failed because it had no notion of time — everything
in it is equally and permanently present, so nothing in it can ever be safely
resolved.
shit adds time as a first-class property of the drawer. Forgetting becomes
the default. Losing something requires your consent.
git clone https://github.com/WebWalker3D/shit.git ~/shit
cd ~/shit
./install.shinstall.sh symlinks ./shit into ~/.local/bin (pass a different directory
as $1). It refuses to clobber a non-symlink already sitting at the
destination, and warns if the target directory isn't on your PATH.
linked /home/you/.local/bin/shit -> /home/you/shit/shit
next:
shit adopt enroll whatever is already in the drawer
shit see what's rotting
shit timer install the systemd --user timer (daily decay)
No install step is actually required — the file is standalone and runs from anywhere:
python3 /path/to/shit --helpUninstall: rm ~/.local/bin/shit. Your drawer is a plain directory of
plain files and is unaffected.
- Python 3.8 or newer (no third-party packages, ever)
- POSIX (
fcntl.flockis used for the index lock) gitoptional — only for repo-drawer protection and the typo passthroughsystemd --useroptional — only forshit timer
$ shit toss ~/Downloads/mystery.zip ~/Downloads/invoice-scan.pdf
→ tossed mystery.zip (391K, rots in 30d)
→ tossed invoice-scan.pdf (1.1M, rots in 30d)
$ shit # status: worst-decayed first
$ shit note invoice "vendor sent this, unclear if paid"
invoice-scan.pdf: “vendor sent this, unclear if paid”
$ shit keep invoice # turns out it matters
invoice-scan.pdf will not rot. (shit keep --undo to reverse)
$ shit grab mystery # pull it back into $PWD
← pulled mystery.zip out of the drawer
/home/you/work/mystery.zip
$ shit rot # expired → compost
$ shit flush # destroy compost, deliberatelyIf your drawer already exists and is full of years of debris, start here instead:
$ shit adoptOne directory. Defaults to ~/shit. Override per-invocation with --dir, or
globally with the SHIT_DIR environment variable:
export SHIT_DIR=~/Downloads
shit --dir /srv/scratch lsFiles live in the drawer as themselves — same names, same bytes, not renamed, packed, compressed, or otherwise held hostage. If this tool vanished tomorrow your drawer is still just a directory. The index is metadata about that directory, never a container for it.
Every managed file or directory has a record:
| field | meaning |
|---|---|
slug |
its unique filename inside the drawer |
name |
what it was called before you tossed it |
origin |
the absolute path it came from |
tossed |
when the clock started (unix time) |
ttl_days |
how long it lives |
keep |
exempt from decay |
note |
your annotation |
state |
drawer or compost |
composted |
when it entered the compost bin |
size |
bytes, measured at toss time |
kind |
file, dir, or link |
origin is the quiet MVP. It's what lets shit grab --home put a file back
exactly where it came from, weeks later, after you've forgotten where that was.
fraction = age / ttl. Default TTL is 30 days.
| stage | TTL consumed | meaning |
|---|---|---|
fresh |
< 50% | recently tossed |
ripe |
50–80% | starting to smell |
funky |
80–100% | last call |
rotten |
≥ 100% | the next rot composts it |
compost |
— | in the bin, in its grace period |
kept |
— | exempt, forever |
Two edge cases, both deliberate:
keepforces the fraction to0, so a kept item is never "80% dead".ttl_days = 0means immortal, not instant death. A zero shouldn't be a death sentence.
The bin at .drawer/compost/. Rotten items move here; they do not leave here
except by grab (recovery) or flush (destruction). Items sit for
compost_days — 14 by default — before they become purgeable, which still
only means flush will offer to destroy them.
toss ──▶ drawer ──rot──▶ compost ──flush──▶ gone
▲ │
├──── keep ────────┤
│ │
└──── grab ────────┴──▶ back out to your filesystem
Every command moves an item at most one step to the right.
rotis safe to automate because it cannot reachgone.flushis the only door togone. It is manual, it prints a manifest of what it's about to destroy, and it requires a typedy.
Global options, valid before any subcommand:
shit [--dir PATH] [--version] <command> [args]
Running shit with no arguments is the status view — identical to shit ls.
shit toss PATH [PATH...] [--ttl DAYS] [--note TEXT] [--keep] [--copy]
| flag | effect |
|---|---|
--ttl DAYS |
lifespan for these items, overriding the drawer default |
--note TEXT |
annotation, shown in listings and searchable by dig |
--keep |
tossed already immune to decay |
--copy |
copy instead of move; the original stays put |
$ shit toss mystery.zip invoice-scan.pdf cascade.html vpn-configs keystore.jks
→ tossed mystery.zip (391K, rots in 30d)
→ tossed invoice-scan.pdf (1.1M, rots in 30d)
→ tossed cascade.html (38K, rots in 30d)
→ tossed vpn-configs (17B, rots in 30d)
→ tossed keystore.jks (2.1K, rots in 30d)Give a doomed download a short leash:
$ shit toss --ttl 3 --note "checking a hunch" pve-backup.log
→ tossed pve-backup.log (43K, rots in 3d)Keep a copy of a config before you edit it, without moving the original:
$ shit toss --copy --ttl 90 /etc/pve/corosync.confDirectories are moved whole and sized recursively. Symlinks are moved as links — never followed, never dereferenced.
Missing paths warn and are skipped; the command exits 1 if nothing was
tossed. Tossing something already in the drawer warns and points you at
adopt. Tossing a parent of the drawer is refused outright.
shit ls [--compost] [--all] [--json]
| flag | effect |
|---|---|
--compost |
show the compost bin instead of the drawer |
--all |
show both |
--json |
machine-readable, for scripts |
Sorted worst-first: the things demanding attention float to the top, kept items sink to the bottom.
$ shit
NAME SIZE AGE DECAY STATE
cascade.html 38K 13w ████████████ rotten — next rot composts it
“no idea where this came from”
invoice-scan.pdf 1.1M 3w ██████████░░ funky, rots in 3d
vpn-configs/ 17B 11d ████░░░░░░░░ fresh, rots in 2w
mystery.zip 391K 1h ░░░░░░░░░░░░ fresh, rots in 4w
keystore.jks 2.1K 29w ∞ kept
5 items, 1.6M total
1 ready to compost — run `shit rot`Directories carry a trailing /, symlinks a trailing @. Notes print beneath
their item. The compost view swaps the decay bar for dots and counts down to
purgeability instead:
$ shit ls --compost
NAME SIZE AGE DECAY STATE
cascade.html 38K 13w ............ compost, purgeable in 13d
“no idea where this came from”
1 item, 38K totalAn empty drawer says so rather than printing an empty table:
the drawer is empty. suspiciously clean.
toss something in: shit toss ~/Downloads/whatever.zip
Colour is on for TTYs and off otherwise. NO_COLOR=1 disables it; SHIT_COLOR=1
forces it on when piping.
shit grab NAME [--to DIR] [--home]
| flag | effect |
|---|---|
| (default) | restore into the current working directory |
--to DIR |
restore into a specific directory |
--home |
restore to the exact path it was tossed from |
$ shit grab mystery
← pulled mystery.zip out of the drawer
/home/you/work/mystery.zip
$ shit grab cascade --to /srv/www/staging
← pulled cascade.html out of compost
/srv/www/staging/cascade.html
$ shit grab invoice --home
← pulled invoice-scan.pdf out of the drawer
/home/you/Downloads/invoice-scan.pdfWorks identically on drawer items and composted ones — anything not yet flushed is recoverable. Grabbing removes the item from management entirely; it's yours again.
Restores never clobber. If something already occupies the destination name, the
restored file becomes name~2.ext and the incumbent is untouched.
shit keep NAME [--undo]
$ shit keep keystore
keystore.jks will not rot. (shit keep --undo to reverse)Kept items never decay, never rot, and never enter compost regardless of age.
keep also works on items already in compost — it pulls them back into the
drawer and marks them exempt in one move. That's the "wait, no, I need that"
button.
$ shit keep --undo keystore
keystore.jks is mortal again (rots in 30d)--undo restarts the clock rather than restoring the original toss date, so
un-keeping something you've held for a year doesn't instantly rot it.
shit ttl [NAME] DAYS [--reset]
With a name, sets that item's lifespan. Without one, sets the drawer default for future items:
$ shit ttl cascade 7
cascade.html: ttl 7d, rots in the next rot
$ shit ttl 90
default TTL is now 90 days (existing items keep their own)--reset also restarts the clock, which is the "give this another full
30 days" gesture:
$ shit ttl invoice 30 --reset
invoice-scan.pdf: ttl 30d, rots in 4wshit note NAME [TEXT...]
$ shit note cascade "no idea where this came from"
cascade.html: “no idea where this came from”
$ shit note cascade
cascade.html: note clearedNotes show in listings and are searched by dig. Six weeks from now this is
the difference between "delete it" and "oh, right".
shit rot [--dry-run] [--hard] [--quiet]
| flag | effect |
|---|---|
--dry-run |
report what would happen, change nothing |
--hard |
also purge compost past its grace period |
--quiet |
suppress the nudge line (for timers) |
This is the command the timer runs. It moves rotten drawer items into compost. By default it cannot delete anything.
$ shit rot --dry-run
[dry run] ↓ composted 1:
cascade.html (38K, 13w old)
$ shit rot
↓ composted 1:
cascade.html (38K, 13w old)Nothing to do is a first-class outcome, not an error:
$ shit rot
nothing had rotted. the drawer is at peace.When compost has finished its grace period, rot tells you and stops:
$ shit rot
nothing had rotted. the drawer is at peace.
3 compost items finished rotting. `shit flush` destroys them; `shit grab` still saves them.--hard opts into permanent deletion for one run, and only for compost past its
grace period. Items inside the grace window are always spared.
rot also self-heals: index rows whose files have disappeared are dropped.
shit flush [--all] [-y|--yes]
The only command in the tool that destroys data.
| flag | effect |
|---|---|
| (default) | destroy compost past its grace period |
--all |
destroy everything in compost, grace period or not |
-y |
skip the confirmation prompt |
$ shit flush
about to permanently destroy 3 item(s), 12.4M:
old-vzdump.tar (11.9M)
cascade.html (38K)
stale-notes.md (2.1K)
this cannot be undone. proceed? [y/N] y
✗ flushed 3 item(s), 12.4M reclaimedIt prints the manifest before asking, largest first, capped at 20 lines with
an "and N more" tail. It never touches the live drawer. It refuses to run
non-interactively without -y:
$ shit flush < /dev/null
shit: refusing to do that non-interactively without -yshit dig PATTERN [--content]
Case-insensitive. Searches names, notes, and original paths. --content
additionally greps inside files.
$ shit dig invoice
invoice-scan.pdf [funky] in drawer · matched name
was: /home/you/Downloads/invoice-scan.pdf
“vendor sent this, unclear if paid”
$ shit dig remote --content
vpn-configs [fresh] in drawer · matched content
was: /home/you/work/vpn-configsMatching by origin means you can find things by where they came from — shit dig Downloads lists everything you ever tossed from your downloads folder.
A miss exits 1 and, if you didn't pass --content, suggests it:
$ shit dig openvpn
nothing matching 'openvpn' (try --content)Content search is deliberately cheap: binaries are skipped (NUL byte in the first 8 KB), files over 4 MB are skipped, and it stops after 500 files inside a directory. It's for finding your config file, not for indexing a corpus.
shit adopt [--ttl DAYS] [--now] [--prune] [--dry-run]
| flag | effect |
|---|---|
| (default) | enroll strays, backdating each to its mtime |
--now |
start every clock at now instead |
--ttl DAYS |
lifespan for adopted items |
--prune |
drop index rows whose files no longer exist |
--dry-run |
report only |
This is the command you run first, on a drawer that already exists.
$ shit adopt
adopted 47:
Screenshot-2023-11-04.png (2.1M, 2.4y old, rotten)
final_v2_ACTUAL.zip (410M, 1.8y old, rotten)
cascade.html (38K, 93d old, rotten)
pve-backup.log (43K, 12d old, fresh)Because items are backdated to their mtime, the ones that have sat there for years are already rotten and say so on the first run. See Design decisions for why this matters.
If the drawer is also a git repo, adopt consults git ls-files and
git check-ignore and skips everything git tracks or ignores:
$ shit adopt --dry-run
nothing to adopt. everything here is already accounted for.
(skipped 6 path(s) git tracks or ignores — this drawer is also a repo)
$ touch stray.tmp && shit adopt --dry-run
[dry run] adopted 1:
stray.tmp (0B, 0s old, fresh)
(skipped 6 path(s) git tracks or ignores — this drawer is also a repo)Repo content is the repo's; ignored build output is the build's. Neither is junk the drawer is responsible for. A genuine stray in the same directory still gets adopted.
adopt also sweeps the compost bin. rot moves files before it writes the
index, so a pass killed midway can leave a file in compost that the index never
learned about — invisible to ls, uncounted by stats, and beyond the reach of
both flush and grab. Adoption reattaches those, backdating the grace clock
to the file's mtime so nothing gets a fresh reprieve it hasn't earned:
$ shit adopt
↑ recovered 2 stranded in compost:
ghost.bin (49K, ready to purge)
ghost.txt (15B, purgeable in 13d)
these were invisible until now — `shit grab` still saves them.Slugs are one namespace across the drawer and the compost bin, so a stray whose
name is already spoken for is renamed on disk (clash~2.txt) rather than
displacing the existing row.
shit stats [--json]
$ shit stats
/home/you/shit
default ttl 30d · compost grace 14d · auto-purge off
in the drawer 4 1.5M
fresh 2
funky 1
kept 1
in the compost 0 0B
oldest resident: keystore.jks (29w)
heaviest: invoice-scan.pdf (1.1M)shit config [KEY [VALUE]]
| key | default | meaning |
|---|---|---|
ttl_days |
30 |
default lifespan for newly tossed items |
compost_days |
14 |
grace period before compost may be purged |
auto_purge |
False |
let rot permanently delete ripe compost |
$ shit config
auto_purge False
compost_days 14
ttl_days 30
$ shit config compost_days
14
$ shit config compost_days 30
compost_days = 30.0Booleans accept 1/true/yes/on and anything else is false. Unknown keys and
negative numbers are rejected. Settings are per-drawer, stored in that drawer's
index — two drawers can have completely different policies.
shit timer [--print]
Writes and enables a systemd --user timer that runs shit rot --quiet daily.
$ shit timer
wrote /home/you/.config/systemd/user/shit-rot.{service,timer}
✓ timer enabled — the drawer now rots daily
systemctl --user list-timers shit-rot.timer--print emits the units without installing anything, so you can read them
first or adapt them:
$ shit timer --print
# ~/.config/systemd/user/shit-rot.service
[Unit]
Description=Compost the junk drawer (shit)
[Service]
Type=oneshot
Environment=SHIT_DIR=/home/you/shit
Environment=NO_COLOR=1
ExecStart=/home/you/shit/shit rot --quiet
# ~/.config/systemd/user/shit-rot.timer
[Unit]
Description=Let the junk drawer rot (shit)
[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=1h
[Install]
WantedBy=timers.targetPersistent=true means a missed run fires at next login — decay survives your
laptop being closed for a week.
To make the timer survive logout, enable lingering:
sudo loginctl enable-linger $USER.
Every command that takes a NAME accepts a fragment. Resolution runs in order
and stops at the first tier with exactly one match:
- exact slug
- exact original name
- case-insensitive slug
- slug prefix
- slug substring
- original-name substring
$ shit grab mys # → mystery.zip
$ shit grab 2026 # → annual-report-2026.pdfIf a tier matches more than one item, the command stops and tells you rather than guessing:
$ shit grab notes
shit: 'notes' matches 2 things: notes-a.txt, notes-b.txtAn exact match always beats an ambiguous partial: with both log.txt and
log.txt.old present, shit grab log.txt is unambiguous.
Settings live per-drawer in .drawer/index.json and are managed by
shit config. Environment variables:
| variable | effect |
|---|---|
SHIT_DIR |
drawer path (default ~/shit) |
NO_COLOR |
disable colour (any value) |
SHIT_COLOR |
0 disables, 1 forces on even when piping |
--dir beats SHIT_DIR beats ~/shit.
shit timer. See above.
17 4 * * * SHIT_DIR=$HOME/shit NO_COLOR=1 $HOME/.local/bin/shit rot --quietRot on login, at most once a day, silently:
# ~/.bashrc
shit rot --quiet >/dev/null 2>&1 &Concurrency is safe: the index is written under an flock, so a timer firing
while you're mid-toss cannot corrupt anything.
ls --json and stats --json are stable machine-readable output.
$ shit ls --json
[
{
"slug": "cascade.html",
"name": "cascade.html",
"origin": "/home/you/newshit/cascade.html",
"state": "drawer",
"stage": "rotten",
"keep": false,
"size": 38972,
"kind": "file",
"note": "no idea where this came from",
"tossed": 1746400000.0,
"ttl_days": 30,
"age_seconds": 8035200.0,
"expires_at": 1748992000.0
}
]expires_at is null for kept items. Rows are sorted oldest-first.
$ shit stats --json
{
"root": "/home/you/shit",
"config": {"ttl_days": 30, "compost_days": 14, "auto_purge": false},
"counts": {"drawer": 4, "compost": 0, "fresh": 2, "funky": 1},
"bytes": {"drawer": 1572864, "compost": 0}
}Useful one-liners:
# total bytes in the drawer
shit stats --json | python3 -c 'import json,sys; print(json.load(sys.stdin)["bytes"]["drawer"])'
# everything rotten, one per line
shit ls --json | python3 -c 'import json,sys; [print(i["slug"]) for i in json.load(sys.stdin) if i["stage"]=="rotten"]'
# alert if the drawer exceeds 5 GB — drop in a monitoring check
shit stats --json | python3 -c 'import json,sys; sys.exit(json.load(sys.stdin)["bytes"]["drawer"] > 5<<30)'Exit codes: 0 success, 1 failure or "nothing found", 130 interrupted.
export SHIT_DIR=~/Downloads # put this in your shell profile
shit adopt # enroll the existing pile, backdated
shit config ttl_days 14
shit config compost_days 14
shit timerDownloads now have a 14-day life and a further 14-day recovery window. You get a month of grace and a directory that stops growing.
alias trash='shit toss --ttl 30'trash foo.log is a delete you can undo for 44 days.
shit toss --copy --ttl 90 --note "before the MTU change" /etc/pve/corosync.confThe original stays in place; you have a dated copy that cleans itself up.
alias sc='shit --dir .scratch'
sc toss debug-output.log
sc lsAdd .scratch/ to .gitignore and every project gets a self-composting
scratch space with its own policy.
Instead of rm -rf ~/old-stuff, toss it. If nothing breaks in 30 days, it
composts. If something breaks on day 12, shit grab fixes it in seconds.
shit dig 10.10.10 --contentEvery tossed file mentioning that subnet, whatever it's called.
~/shit/ the drawer — your files, at top level, as themselves
├── mystery.zip
├── invoice-scan.pdf
├── vpn-configs/
└── .drawer/
├── index.json config + one record per item
├── lock flock target
├── compost/ rotted items, still recoverable
│ └── cascade.html
└── .gitignore contains "*" — the metadata self-ignores
index.json:
{
"version": 1,
"config": {
"auto_purge": false,
"compost_days": 14,
"ttl_days": 30
},
"items": {
"cascade.html": {
"name": "cascade.html",
"origin": "/home/you/newshit/cascade.html",
"tossed": 1746400000.0,
"ttl_days": 30,
"keep": false,
"note": "no idea where this came from",
"state": "compost",
"composted": 1754400000.0,
"size": 38972,
"kind": "file"
}
}
}It's readable, hand-editable, and diffable. If you delete it, shit adopt
rebuilds it from what's on disk — both the drawer and the compost bin, so a
lost index costs you notes and origins, never the ability to reach a file. If
you delete the tool, your files are untouched.
The .drawer/.gitignore containing * means a drawer that's also a repo never
accidentally commits its own metadata.
Each of these is a load-bearing promise with a test behind it.
| guarantee | mechanism |
|---|---|
rot never deletes |
it only ever shutil.moves into compost |
| nothing is destroyed without consent | flush requires typed y or -y, and refuses non-interactively without -y |
| deletion can't escape the drawer | every destructive path is containment-checked against the drawer root first |
| traversal can't escape either | paths are anchored — parents resolved, final component not — so ../.. is caught |
| a symlink in the drawer is in the drawer | the final component is deliberately not resolved |
| purging a link never harms its target | symlinks are unlinked, never followed |
| restores never clobber | collisions become name~2.ext; .tar.gz stays readable |
| ambiguity never becomes a guess | resolution reports all matches and exits |
| the index can't be half-written | temp file + fsync + os.replace, under flock |
| a corrupt index fails loudly | it refuses to run and tells you to rebuild, rather than showing an empty drawer you might then flush |
| the tool never eats its own repo | adopt honours git ls-files and git check-ignore |
| unattended deletion is opt-in | auto_purge defaults off; --hard is per-run |
The design principle behind all of it: the automated half is incapable of
destruction, and the destructive half is never automated. A junk drawer that
silently eats things is just rm with extra steps.
Why two stages instead of a straight TTL delete? Because you're wrong sometimes, and the cost is asymmetric. Keeping a file 14 extra days costs disk. Deleting the wrong file costs an afternoon or a job. The compost stage means the automated pass can run daily, forever, without you ever having to trust it.
Why does adopt backdate to mtime?
Every retention tool starts its clock at install. That's wrong for a drawer
that's already five years deep — it grants a full fresh TTL to a file from 2023
and makes the accumulated problem invisible for another 30 days.
Backdating means the first thing the tool ever prints is the truth: a triage
list sorted by how badly you've been ignoring each item. And because those items
are already rotten, they land in compost on the very next rot — giving you a
14-day window in which the entire historical drawer is reversibly out of the
way. You get a clean drawer immediately, and every file is still one shit grab
away. The first run tells you the truth, and the truth is recoverable.
Why store files as themselves?
So the tool is never a hostage-taker. Any tool that repacks your data makes
itself load-bearing. ls, grep, find, and your file manager all still work
on the drawer. Uninstalling costs nothing.
Why fuzzy names? Because the whole premise is that you don't remember what's in there. Requiring exact filenames would defeat the point. Refusing to guess when ambiguous is what keeps it safe.
Why is keep a rescue from compost too?
Because that's the actual moment of realisation. You see something in the
compost list and think "wait, I need that" — one command should both save it and
mark it permanent.
Why zero dependencies?
It manages a directory. Anything you'd need to pip install to do that is a
liability, and a tool meant to run unattended for years shouldn't have a
dependency tree that can break.
Stated plainly, because an overview that only lists strengths isn't one:
- Single directory, single machine. No sync, no remote drawers, no daemon.
rotdoesn't free disk. Composted files still occupy the filesystem untilflush. That's the trade for recoverability — budget for it.- Sizes are measured at toss time and not recomputed. A directory that grows
after being tossed will under-report in
lsandstats. - No dedup, no compression, no encryption. It's a discipline tool, not a storage tool.
grabis not undoable by the tool — it hands the file back to your filesystem and forgets it. That's intentional; recovery shouldn't create another thing to manage.- Not a backup. It has no second copy of anything. Everything in it is scheduled to eventually not exist.
- Not a trash can. Trash is for things you've decided about. This is for things you haven't.
- POSIX only.
fcntl.flockhas no Windows equivalent here.
"the drawer is empty" but there are files in it.
They aren't enrolled yet. Run shit adopt.
adopt skips everything.
The drawer is a git repo and those paths are tracked or ignored. That's the
protection working. shit adopt --dry-run names the count it skipped.
Index says an item exists but the file is gone (you deleted it manually).
shit adopt --prune drops orphaned rows. rot also cleans these up.
Compost is using disk that stats doesn't account for.
The mirror of the above: a file in .drawer/compost that the index never
learned about, usually from a rot that was killed partway through. Run
shit adopt — it reattaches them and reports what it found. Until then they're
unreachable, not lost.
"index is unreadable".
Move .drawer/index.json aside and run shit adopt — it rebuilds from disk.
You'll lose notes and origins, not files.
The timer isn't firing.
systemctl --user list-timers shit-rot.timer and
journalctl --user -u shit-rot.service. If you're not logged in graphically,
you likely need sudo loginctl enable-linger $USER.
Colour codes in a pipe or log.
Set NO_COLOR=1. The generated systemd unit already does.
Something rotted that shouldn't have.
It's in compost. shit grab NAME or shit keep NAME. Nothing is gone until
you've typed y at a flush.
python3 tests/test_shit.py # 78 tests
python3 tests/test_shit.py -v # verbose
python3 tests/test_shit.py Rot # one classStdlib unittest, no fixtures beyond a tempdir, no network, no mocks. Each test
builds a real drawer in /tmp, does real filesystem operations, and asserts on
real state. Time is manipulated by rewriting tossed/composted in the index,
so decay is tested without sleeping.
Coverage is organised by concern: TossAndGrab, Resolution, Decay, Rot,
KeepAndTtl, Flush, Adopt, Search, Reporting, Config, Persistence,
Safety, Formatting, Cli.
Everything lives in one file. There's no build step, no packaging, no lockfile.
Edit shit, run the tests.
shit is the most common typo for git. Inside a git repo, an unrecognised
verb that git does recognise is passed straight through:
$ shit status
(you meant git. proceeding anyway.)
On branch main
nothing to commit, working tree clean
$ shit log --oneline
(you meant git. proceeding anyway.)
441a0b2 adopt: never manage paths git tracks or ignoresDrawer verbs always win, so shit ls lists the drawer and never runs
git ls. Outside a repo, an unknown verb is just an error. The passthrough
preserves git's exit code.
MIT. See LICENSE.