git-shed
A repository-aware link manager for local files kept outside Git.
git-shed prepares a storage directory ("shed") for each Git remote you work
with, and links it into the repository as .shed/<name>.
Notes, investigation SQL, debug scripts, local-only configuration, sample API responses, scratch code, work notes for an AI, test data — things you want next to a repository but not inside it.
~/.git-shed/
├── config.toml
└── sheds/
├── company/
└── backend/
~/src/foo/
└── .shed/
├── company -> ~/.git-shed/sheds/company
└── backend -> ~/.git-shed/sheds/backend
The real data lives outside the clone, so rm -rf on the clone keeps it, every
clone of the same remote sees the same shed, and several repositories can share
one shed. git-shed never looks inside a shed.
Install
uv tool install git-shed
This puts git-shed on PATH, which is all Git needs to dispatch git shed.
Tutorial
Clone a repository and ask for a shed. Nothing matches yet, so sync offers to
create one; the defaults come from the remote:
$ git clone git@github.com:acme/api.git ~/src/api
$ cd ~/src/api
$ git shed sync
No shed matches:
github.com/acme/api
Create a new shed? [Y/n] y
Shed name [api]:
Match pattern [github.com/acme/api]:
Create shed:
name: api
match:
- github.com/acme/api
path: /home/user/.git-shed/sheds/api
Create this shed? (y/n) y
Created shed:
api
Linked:
.shed/api -> /home/user/.git-shed/sheds/api
Write whatever you want under the link. It lives outside the clone, and Git does not see it:
echo '# debugging notes' > .shed/api/notes.md
git status --short # nothing, .shed/ is in .git/info/exclude
Check out a second working tree and run sync there too. It matches the same
remote, so it gets the same shed — the notes are already there:
$ git worktree add ../api-hotfix hotfix
$ cd ../api-hotfix
$ git shed sync
Linked:
.shed/api -> /home/user/.git-shed/sheds/api
$ cat .shed/api/notes.md
# debugging notes
Delete either working tree and ~/.git-shed/sheds/api stays where it is.
Configuration
~/.git-shed/config.toml (or $GIT_SHED_ROOT/config.toml):
[[shed]]
name = "company"
match = ["github.com/acme/*"]
[[shed]]
name = "backend"
match = [
"github.com/acme/api",
"github.com/acme/worker",
]
[[shed]]
name = "personal"
match = ["github.com/me/*"]
Sheds are not exclusive: github.com/acme/api matches both company and
backend, and gets both links.
A shed name starts with an ASCII letter or digit, continues with letters,
digits, ., _ and -, and is at most 64 characters — the safe shape for a
directory name on every platform. Windows device names (CON, NUL, ...) and
a trailing . are rejected.
The data of a shed lives at ~/.git-shed/sheds/<name>, by convention rather
than by configuration. GIT_SHED_ROOT moves the whole root — the
configuration file and the shed data — elsewhere, for the whole tool:
export GIT_SHED_ROOT=/data/git-shed # -> /data/git-shed/config.toml
# /data/git-shed/sheds/company
A leading ~ is expanded, since the variable is often set where a shell does
not expand it (.env files, systemd units, container environments). The value
has to denote an absolute path; a relative one is an error rather than
something resolved against the current directory.
Usage
git shed sync # create and drop links for the current repository
git shed sync --no-interactive
git shed add company 'github.com/acme/*' 'github.com/other/*'
git shed add company 'github.com/more/*' # adds to the shed that exists
git shed add # asks for whatever is missing
git shed link scratch # link a shed here even though it does not match
git shed unlink scratch # drop that link again
git shed remove company # drops the definition, keeps the data
git shed remove company 'github.com/other/*' # drops only these patterns
git shed status
git shed list # matching sheds and their patterns
git shed list --all # every defined shed and its patterns
git shed path company # /home/user/.git-shed/sheds/company
git shed open company # open it in the file manager
git shed list shows each shed with every pattern it is defined with, not only
the one that matched:
$ git shed list
company
github.com/acme/*
backend
github.com/acme/api
github.com/acme/worker
Shed names sit at the left margin and their patterns are indented, so
git shed list | grep -v "^ " still gives you plain names for a script.
git shed add takes a shed name followed by any number of patterns. Naming a
shed that already exists is not an error: its patterns are extended with the
ones you give, and a pattern it already has is reported instead of duplicated.
With no patterns on the command line it asks for one.
git shed remove with just a name drops the whole shed definition (never the
data). With patterns after the name it removes only those patterns and keeps
the shed; naming a pattern the shed does not have is an error.
git shed sync resolves the identities of the repository, creates
~/.git-shed/sheds/<name> and repo/.shed/<name> for every matching shed, drops the
links that no longer match, and adds /.shed/ to .git/info/exclude.
.gitignore is never touched.
It reports what it did:
Linked:
.shed/company -> /home/user/.git-shed/sheds/company
Unlinked:
.shed/old-shed
One-off links
git shed link <shed> links a shed into the current repository even though no
match rule selects it — for the occasional reference that is not worth a
pattern. The name is recorded in .shed/.sheds (one per line, # comments
allowed), which is what keeps sync from dropping the link again:
$ git shed link scratch
Linked:
.shed/scratch -> /home/user/.git-shed/sheds/scratch
The shed does not have to be defined in the configuration: a name whose data directory already exists under the storage root is enough, so a directory created by hand can be linked without writing a match rule for it. A name with neither a definition nor a directory is refused, which keeps a typo from creating one.
git shed unlink scratch removes the entry and the link; the data stays. A
link that comes from the configuration is not unlink's business — drop its
match rule instead.
When nothing matches the repository and the terminal is interactive, sync
offers to create a shed:
$ git shed sync
No shed matches:
github.com/acme/foo
Create a new shed? [Y/n] y
Shed name [foo]: company
Match pattern [github.com/acme/foo]: github.com/acme/*
Create shed:
name: company
match:
- github.com/acme/*
path: /home/user/.git-shed/sheds/company
Create this shed? (y/n) y
Remote identity
Matching uses a canonical identity rather than the raw remote URL, so all of
git@github.com:acme/foo.git
https://github.com/acme/foo.git
ssh://git@github.com/acme/foo
https://github.com/acme/foo
become
github.com/acme/foo
Every remote contributes an identity, and the sheds matching any of them are linked. In a fork checkout both the fork and the upstream repository are matched without configuring anything:
origin git@github.com:me/foo.git -> github.com/me/foo
upstream git@github.com:acme/foo.git -> github.com/acme/foo
To match on specific remotes only, name them — the setting is repeatable:
git config --local shed.remote upstream
git config --local --add shed.remote origin
A shed.remote naming a remote that does not exist is an error. A remote whose
URL cannot be interpreted is skipped with a warning, and the other remotes are
still used:
git shed: warning: ignoring remote 'helper': cannot interpret remote URL: transport::address
A repository with no usable remote is identified by the name of its working
tree directory, so ~/src/notebook gets the identity notebook and is matched
by a pattern of the same name:
[[shed]]
name = "notebook"
match = ["notebook"]
Patterns
Identities are matched segment by segment:
* one segment
** zero or more segments
A * can also stand for part of a segment, so a naming convention is
enough to group repositories:
github.com/acme/foo exactly this repository
github.com/acme/* every repository of acme
github.com/acme-*/api the api repository of acme-jp, acme-us, ...
github.com/acme/repo_* acme repositories named repo_...
*/acme/* acme on any host
gitlab.com/company/** company and everything below its subgroups
Matching is case-insensitive, and several sheds may match the same repository — there is no priority or specificity rule to reason about.
Safety
This tool holds data that is not in Git, so it stays away from anything destructive.
-
syncandremoveonly ever delete links / junctions, never shed data. -
A real file or directory inside
.shed/is left alone, with a warning on stderr:git shed: warning: .shed/notes is not a link, left untouched
-
A changed remote does not move or delete anything;
statusshows the difference. -
Only
.git/info/excludeis modified, never.gitignore.
Platform support
Symbolic links on Linux and macOS, directory junctions on Windows. .shed/
itself is always a plain directory; only its entries are links.
Copying a project directory behaves differently on the two: cp -r keeps the
links, so the copy shares the same shed, while most Windows tools (Explorer,
xcopy, robocopy, Copy-Item) follow a junction and duplicate what is
behind it — use robocopy /XJ to leave junctions out. Running git shed sync
in the copy restores the links either way.
Exit codes
0 when the command completed — a repository matching no shed is not an error.
Non-zero for a broken configuration file, a repository or remote that cannot be
resolved, missing input for add, or a link that cannot be created.
Not in scope
The contents of a shed (layout, name collisions, backup, sync, encryption, secrets), per-shed paths, orphan cleanup, GC, migration and shell completion. It is not a replacement for git-annex or Git LFS.
Development
The test suite runs on Python 3.11 through 3.14. That matrix lives under
[tool.uv-matrix] in pyproject.toml and is driven by
uv-matrix; ruff needs no matrix:
uvx uv-matrix run --max-jobs 4 # the suite on every interpreter
uvx uv-matrix list # what the matrix expands to
uvx uv-matrix run --filter python-version=3.13
uv run --extra dev ruff check src tests
uv run --extra dev ruff format --check src tests
CI runs exactly these commands on Ubuntu. A single interpreter without uv works too:
pip install -e ".[dev]"
python -m pytest
Metadata
Release files for git-shed 0.2.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| git_shed-0.2.0.tar.gz | 30.8 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| git_shed-0.2.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 54.7 kB
Release files / git_shed-0.2.0.tar.gz
| Download URL | git_shed-0.2.0.tar.gz |
|---|---|
| Size | 30.8 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
473aa834e64f711b015b23812c374835fffe033fcea407513086048139e324c5
|
|
BLAKE2b-256 checksum How to use checksums |
e54c52ec173a79596e8f996da08fe9a106a573bcb539df1dbbeb54ab7d9e5a0a
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.2
|
Release files / git_shed-0.2.0-py3-none-any.whl
| Download URL | git_shed-0.2.0-py3-none-any.whl |
|---|---|
| Size | 23.9 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
1cf051ab484e6b85d2485fa7cdef5684f0e213247c0ae7e382d100b3e33589f4
|
|
BLAKE2b-256 checksum How to use checksums |
6ff8f209509d786ca7be77b7267a6347914a421cc21a569e3b2b51e7f7c87cd5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.2
|