Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

4 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

shit

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.


Table of contents


The problem

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:

  1. You never delete it. It becomes archaeology — a stratum of Screenshot from 2023-11-04.png, final_v2_ACTUAL.zip, and a cascade.html you cannot account for. You stop opening it. You run du -sh on it once a year and feel something.
  2. 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.


Install

git clone https://github.com/WebWalker3D/shit.git ~/shit
cd ~/shit
./install.sh

install.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 --help

Uninstall: rm ~/.local/bin/shit. Your drawer is a plain directory of plain files and is unaffected.

Requirements

  • Python 3.8 or newer (no third-party packages, ever)
  • POSIX (fcntl.flock is used for the index lock)
  • git optional — only for repo-drawer protection and the typo passthrough
  • systemd --user optional — only for shit timer

Sixty-second tour

$ 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, deliberately

If your drawer already exists and is full of years of debris, start here instead:

$ shit adopt

Concepts

The drawer

One 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 ls

Files 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.

An item

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.

Decay stages

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:

  • keep forces the fraction to 0, so a kept item is never "80% dead".
  • ttl_days = 0 means immortal, not instant death. A zero shouldn't be a death sentence.

Compost

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.


The lifecycle

   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.

  • rot is safe to automate because it cannot reach gone.
  • flush is the only door to gone. It is manual, it prints a manifest of what it's about to destroy, and it requires a typed y.

Command reference

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.


toss — put things in

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.conf

Directories 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.


ls — see what's decaying

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 total

An 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.


grab — take something back

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.pdf

Works 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.


keep — stop the clock

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.


ttl — change lifespans

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 4w

note — annotate

shit 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 cleared

Notes show in listings and are searched by dig. Six weeks from now this is the difference between "delete it" and "oh, right".


rot — the decay pass

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.


flush — permanent deletion

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 reclaimed

It 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 -y

dig — search

shit 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-configs

Matching 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.


adopt — enroll what's already there

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.


stats — census

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)

config — settings

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.0

Booleans 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.


timer — automation

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.target

Persistent=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.


Name resolution

Every command that takes a NAME accepts a fragment. Resolution runs in order and stops at the first tier with exactly one match:

  1. exact slug
  2. exact original name
  3. case-insensitive slug
  4. slug prefix
  5. slug substring
  6. original-name substring
$ shit grab mys          # → mystery.zip
$ shit grab 2026         # → annual-report-2026.pdf

If 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.txt

An exact match always beats an ambiguous partial: with both log.txt and log.txt.old present, shit grab log.txt is unambiguous.


Configuration

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.


Automation

systemd (recommended)

shit timer. See above.

cron

17 4 * * * SHIT_DIR=$HOME/shit NO_COLOR=1 $HOME/.local/bin/shit rot --quiet

Shell profile

Rot 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.


Scripting

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.


Recipes

Make ~/Downloads self-cleaning

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 timer

Downloads 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.

A safe rm you can walk back

alias trash='shit toss --ttl 30'

trash foo.log is a delete you can undo for 44 days.

Snapshot a config before editing it

shit toss --copy --ttl 90 --note "before the MTU change" /etc/pve/corosync.conf

The original stays in place; you have a dated copy that cleans itself up.

Per-project scratch drawers

alias sc='shit --dir .scratch'
sc toss debug-output.log
sc ls

Add .scratch/ to .gitignore and every project gets a self-composting scratch space with its own policy.

Quarantine before a big delete

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.

Find that config again

shit dig 10.10.10 --content

Every tossed file mentioning that subnet, whatever it's called.


How it stores things

~/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.


Safety guarantees

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.


Design decisions

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.


Limitations and non-goals

Stated plainly, because an overview that only lists strengths isn't one:

  • Single directory, single machine. No sync, no remote drawers, no daemon.
  • rot doesn't free disk. Composted files still occupy the filesystem until flush. 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 ls and stats.
  • No dedup, no compression, no encryption. It's a discipline tool, not a storage tool.
  • grab is 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.flock has no Windows equivalent here.

Troubleshooting

"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.


Development

python3 tests/test_shit.py            # 78 tests
python3 tests/test_shit.py -v         # verbose
python3 tests/test_shit.py Rot        # one class

Stdlib 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.


The git typo thing

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 ignores

Drawer 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.


License

MIT. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages