diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 2549ba9a1..98b24cd90 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -4,6 +4,7 @@ on:
push:
branches:
- master
+ - screen-recording-2026-07-12 # [DO NOT MERGE] temporary docs preview
workflow_dispatch:
permissions:
@@ -30,6 +31,7 @@ jobs:
- README.*
docs:
- 'docs/**'
+ - 'docs/_static/demos/asciinema/*.gif'
- 'examples/**'
python_files:
- 'src/vcspull/**'
@@ -37,7 +39,8 @@ jobs:
- uv.lock
- name: Should publish
- if: github.event_name == 'workflow_dispatch' || steps.changes.outputs.docs == 'true' || steps.changes.outputs.root_docs == 'true' || steps.changes.outputs.python_files == 'true'
+ # [DO NOT MERGE] force publish on the preview branch regardless of changed paths
+ if: github.ref == 'refs/heads/screen-recording-2026-07-12' || github.event_name == 'workflow_dispatch' || steps.changes.outputs.docs == 'true' || steps.changes.outputs.root_docs == 'true' || steps.changes.outputs.python_files == 'true'
run: echo "PUBLISH=$(echo true)" >> $GITHUB_ENV
- name: Install uv
diff --git a/README.md b/README.md
index bc21d3f7a..bc56fb877 100644
--- a/README.md
+++ b/README.md
@@ -21,6 +21,8 @@ See the [documentation](https://vcspull.git-pull.com/), [configuration](https://
[myrepos]: http://myrepos.branchable.com/
[mu-repo]: http://fabioz.github.io/mu-repo/
+
+
# How to
## Install
@@ -109,6 +111,8 @@ $ vcspull add ~/projects/libs/my-lib
yourself instead of having vcspull merge them automatically.
- Follow with `vcspull sync my-lib` to clone or update the working tree after registration.
+
+
### Discover local checkouts and add en masse
Have a directory tree full of cloned Git repositories? Scan and append them to
@@ -123,6 +127,8 @@ The scan shows each repository before import unless you opt into `--yes`. Add
than the default `~/.vcspull.yaml`. Duplicate workspace roots are merged by
default; include `--no-merge` to keep them separate while you review the log.
+
+
### Import from remote services
Pull repository lists from GitHub, GitLab, Codeberg, Gitea, Forgejo, or AWS
@@ -158,6 +164,8 @@ $ vcspull list --json | jq '.[].name'
`--json` emits a single JSON array, while `--ndjson` streams newline-delimited
objects that are easy to consume from shell pipelines.
+
+
Search across repositories with an rg-like query syntax:
```console
@@ -166,6 +174,8 @@ $ vcspull search name:django url:github
$ vcspull search --fixed-strings 'git+https://github.com/org/repo.git'
```
+
+
### Check repository status
Get a quick health check for all configured workspaces:
@@ -176,6 +186,8 @@ $ vcspull status --detailed
$ vcspull status --ndjson | jq --slurp 'map(select(.reason == "summary"))'
```
+
+
The status command respects `--workspace/-w` filters and the global
`--color {auto,always,never}` flag. JSON and NDJSON output mirrors the list
command for automation workflows.
@@ -285,8 +297,6 @@ $ vcspull sync "$HOME/code/*"
[libvcs]: https://github.com/vcs-python/libvcs
-
-
# Donations
Your donations fund development of new features, testing and support.
diff --git a/docs/AGENTS.md b/docs/AGENTS.md
index 1f187d270..562218946 100644
--- a/docs/AGENTS.md
+++ b/docs/AGENTS.md
@@ -86,6 +86,39 @@ Two mechanical conventions, separate from voice:
`.. automodule::`. Introduce them in prose; never paraphrase their
content into sentences that will drift.
+## Demo recordings
+
+The animated demos under `docs/_static/demos/asciinema/*.gif` — embedded
+in the CLI pages, the homepage, and the README — come from the
+`/screen-record` skill: asciinema casts rendered to GIF with `agg`, with
+VHS tapes producing the MP4/WebM alternates. `setup-sandbox.sh`, committed
+beside them, rebuilds the config and repositories every recording drives
+against, so re-render from it rather than hand-editing a frame.
+
+One post-step the skill does not do: **crop each shipped GIF to its
+content.** `agg` and VHS render the full terminal width, but a command's
+output fills only part of it, leaving a wide horizontal band of dead space
+that makes the demo read small once embedded. ffmpeg `cropdetect`,
+ImageMagick `TrimBounds`, and `cropdetect=mode=mvedges` all fail on a
+non-black, full-frame terminal capture — the background sits above the
+black threshold, so every frame reads as content and nothing is trimmed.
+The approach that holds is an ImageMagick union-trim that pads with the
+terminal's *own* background:
+
+```console
+$ magick demo.gif -coalesce -fuzz 4% -trim +repage \
+ -bordercolor "$(magick demo.gif[0] -format '%[pixel:p{0,0}]' info:)" \
+ -border 10 \
+ -layers Optimize \
+ demo-cropped.gif
+```
+
+`-fuzz 4% -trim` crops to the content bounding box. Sampling the pad colour
+from the GIF's own corner pixel — the terminal background — makes the 10px
+margin bit-identical to the terminal, so it reads as breathing room rather
+than a frame. Each crop adapts to its command: wide output stays wide,
+short output tightens, and `-layers Optimize` keeps the file small.
+
## Cross-references
Point the advanced reader at the deep-dive rather than inlining it,
diff --git a/docs/_ext/aspect_ratio.py b/docs/_ext/aspect_ratio.py
new file mode 100644
index 000000000..a6fd68800
--- /dev/null
+++ b/docs/_ext/aspect_ratio.py
@@ -0,0 +1,65 @@
+"""Reserve demo image space via aspect-ratio so pages don't shift on load (CLS).
+
+Sphinx's HTML writer renders image ``:width:`` as an inline pixel style, which
+overrides the theme's ``height: auto`` and distorts responsive images. Instead
+this stamps each demo ``
`` with ``width:px; max-width:100%;
+aspect-ratio:W/H`` (computed from the source GIF): the gif shows at its real
+size, shrinks on a narrow screen, never scales up, and the browser reserves the
+correct box before it loads (no layout shift).
+"""
+
+from __future__ import annotations
+
+import re
+import typing as t
+from pathlib import Path
+
+from sphinx.util.images import get_image_size
+
+if t.TYPE_CHECKING:
+ from docutils import nodes
+ from sphinx.application import Sphinx
+
+_IMG = re.compile(r'
]*?\bsrc="[^"]+?/([^"/]+?\.gif)"[^>]*?/?>')
+_STYLE = re.compile(r'style="[^"]*"')
+_sizes: dict[str, tuple[int, int]] = {}
+
+
+def _index_sizes(app: Sphinx) -> None:
+ """Map each source GIF's basename to its intrinsic ``(width, height)``."""
+ for path in Path(app.srcdir).rglob("*.gif"):
+ size = get_image_size(path)
+ if size is not None:
+ _sizes[path.name] = size
+
+
+def _inject(
+ app: Sphinx,
+ pagename: str,
+ templatename: str,
+ context: dict[str, t.Any],
+ doctree: nodes.document | None,
+) -> None:
+ """Rewrite demo ``
`` tags to carry ``width:100%; aspect-ratio:W/H``."""
+ body = context.get("body")
+ if not body or "_images/" not in body:
+ return
+
+ def repl(match: re.Match[str]) -> str:
+ tag, name = match.group(0), match.group(1)
+ size = _sizes.get(name)
+ if size is None or "aspect-ratio" in tag:
+ return tag
+ style = f"width:{size[0]}px;max-width:100%;aspect-ratio:{size[0]}/{size[1]}"
+ if 'style="' in tag:
+ return _STYLE.sub(f'style="{style}"', tag)
+ return tag[:-1].rstrip("/") + f' style="{style}" />'
+
+ context["body"] = _IMG.sub(repl, body)
+
+
+def setup(app: Sphinx) -> dict[str, t.Any]:
+ """Register the aspect-ratio injector."""
+ app.connect("builder-inited", _index_sizes)
+ app.connect("html-page-context", _inject)
+ return {"parallel_read_safe": True, "parallel_write_safe": True}
diff --git a/docs/_static/demos/asciinema/vcspull-add.cast b/docs/_static/demos/asciinema/vcspull-add.cast
new file mode 100644
index 000000000..b1f26ab8c
--- /dev/null
+++ b/docs/_static/demos/asciinema/vcspull-add.cast
@@ -0,0 +1,43 @@
+{"version":3,"term":{"cols":120,"rows":9},"timestamp":1783863920,"idle_time_limit":1.0,"command":"vcspull add ~/code/rust/ripgrep -y; sleep 2","title":"vcspull add","env":{"SHELL":"/bin/zsh"}}
+[0.15,"o","\u001b[34m$\u001b[0m "]
+[0.05,"o","v"]
+[0.05,"o","c"]
+[0.05,"o","s"]
+[0.05,"o","p"]
+[0.05,"o","u"]
+[0.05,"o","l"]
+[0.05,"o","l"]
+[0.05,"o"," "]
+[0.05,"o","a"]
+[0.05,"o","d"]
+[0.05,"o","d"]
+[0.05,"o"," "]
+[0.05,"o","~"]
+[0.05,"o","/"]
+[0.05,"o","c"]
+[0.05,"o","o"]
+[0.05,"o","d"]
+[0.05,"o","e"]
+[0.05,"o","/"]
+[0.05,"o","r"]
+[0.05,"o","u"]
+[0.05,"o","s"]
+[0.05,"o","t"]
+[0.05,"o","/"]
+[0.05,"o","r"]
+[0.05,"o","i"]
+[0.05,"o","p"]
+[0.05,"o","g"]
+[0.05,"o","r"]
+[0.05,"o","e"]
+[0.05,"o","p"]
+[0.05,"o"," "]
+[0.05,"o","-"]
+[0.05,"o","y"]
+[0.20,"o","\r\n"]
+[0.121,"o","\u001b[32mFound new repository to import:\u001b[0m\r\n \u001b[32m+\u001b[0m \u001b[36mripgrep\u001b[0m (\u001b[33mhttps://github.com/BurntSushi/ripgrep.git\u001b[0m)\r\n \u001b[34m•\u001b[0m workspace: \u001b[35m~/code/rust/\u001b[0m"]
+[0.000,"o","\r\n"]
+[0.000,"o"," \u001b[34m↳\u001b[0m path: \u001b[34m~/code/rust/ripgrep\u001b[0m\r\n"]
+[0.001,"o","\u001b[36m?\u001b[0m Import this repository? [y/N]: \u001b[32my (auto-confirm)\u001b[0m\r\n"]
+[0.003,"o","\u001b[32m✓\u001b[0m Successfully added \u001b[36m'ripgrep'\u001b[0m (\u001b[33mgit+https://github.com/BurntSushi/ripgrep.git\u001b[0m) to \u001b[34m~/.vcspull.yaml\u001b[0m under '\u001b[35m~/code/rust/\u001b[0m'.\r\n"]
+[2.027,"x","0"]
diff --git a/docs/_static/demos/asciinema/vcspull-add.gif b/docs/_static/demos/asciinema/vcspull-add.gif
new file mode 100644
index 000000000..85916d0f1
Binary files /dev/null and b/docs/_static/demos/asciinema/vcspull-add.gif differ
diff --git a/docs/_static/demos/asciinema/vcspull-discover.cast b/docs/_static/demos/asciinema/vcspull-discover.cast
new file mode 100644
index 000000000..cbbb561cc
--- /dev/null
+++ b/docs/_static/demos/asciinema/vcspull-discover.cast
@@ -0,0 +1,54 @@
+{"version":3,"term":{"cols":120,"rows":14},"timestamp":1783863911,"idle_time_limit":1.0,"command":"vcspull discover ~/code --recursive --dry-run; sleep 2","title":"vcspull discover","env":{"SHELL":"/bin/zsh"}}
+[0.15,"o","\u001b[34m$\u001b[0m "]
+[0.05,"o","v"]
+[0.05,"o","c"]
+[0.05,"o","s"]
+[0.05,"o","p"]
+[0.05,"o","u"]
+[0.05,"o","l"]
+[0.05,"o","l"]
+[0.05,"o"," "]
+[0.05,"o","d"]
+[0.05,"o","i"]
+[0.05,"o","s"]
+[0.05,"o","c"]
+[0.05,"o","o"]
+[0.05,"o","v"]
+[0.05,"o","e"]
+[0.05,"o","r"]
+[0.05,"o"," "]
+[0.05,"o","~"]
+[0.05,"o","/"]
+[0.05,"o","c"]
+[0.05,"o","o"]
+[0.05,"o","d"]
+[0.05,"o","e"]
+[0.05,"o"," "]
+[0.05,"o","-"]
+[0.05,"o","-"]
+[0.05,"o","r"]
+[0.05,"o","e"]
+[0.05,"o","c"]
+[0.05,"o","u"]
+[0.05,"o","r"]
+[0.05,"o","s"]
+[0.05,"o","i"]
+[0.05,"o","v"]
+[0.05,"o","e"]
+[0.05,"o"," "]
+[0.05,"o","-"]
+[0.05,"o","-"]
+[0.05,"o","d"]
+[0.05,"o","r"]
+[0.05,"o","y"]
+[0.05,"o","-"]
+[0.05,"o","r"]
+[0.05,"o","u"]
+[0.05,"o","n"]
+[0.20,"o","\r\n"]
+[0.137,"o","\r\n\u001b[32mFound 4 new repositories to preview:\u001b[0m\r\n \u001b[32m+\u001b[0m \u001b[36mripgrep\u001b[0m (\u001b[33mhttps://github.com/BurntSushi/ripgrep.git\u001b[0m)\r\n \u001b[32m+\u001b[0m \u001b[36mflask\u001b[0m (\u001b[33mhttps://github.com/pallets/flask.git\u001b[0m)\r\n \u001b[34m•\u001b[0m shallow: \u001b[33mtrue\u001b[0m\r\n"]
+[0.000,"o"," \u001b[32m+\u001b[0m \u001b[36mclick\u001b[0m (\u001b[33mhttps://github.com/pallets/click.git\u001b[0m)\r\n"]
+[0.000,"o"," \u001b[34m•\u001b[0m shallow: \u001b[33mtrue\u001b[0m\r\n \u001b[32m+\u001b[0m \u001b[36mhttpx\u001b[0m (\u001b[33mhttps://github.com/encode/httpx.git\u001b[0m)\r\n"]
+[0.000,"o"," \u001b[34m•\u001b[0m shallow: \u001b[33mtrue\u001b[0m\r\n"]
+[0.000,"o","\r\n\u001b[33m→\u001b[0m Dry run complete. No changes made to \u001b[34m~/.vcspull.yaml\u001b[0m.\r\n"]
+[2.026,"x","0"]
diff --git a/docs/_static/demos/asciinema/vcspull-discover.gif b/docs/_static/demos/asciinema/vcspull-discover.gif
new file mode 100644
index 000000000..f359337a1
Binary files /dev/null and b/docs/_static/demos/asciinema/vcspull-discover.gif differ
diff --git a/docs/_static/demos/asciinema/vcspull-fmt.cast b/docs/_static/demos/asciinema/vcspull-fmt.cast
new file mode 100644
index 000000000..79b98f1c8
--- /dev/null
+++ b/docs/_static/demos/asciinema/vcspull-fmt.cast
@@ -0,0 +1,16 @@
+{"version":3,"term":{"cols":120,"rows":9},"timestamp":1783863913,"idle_time_limit":1.0,"command":"vcspull fmt; sleep 2","title":"vcspull fmt","env":{"SHELL":"/bin/zsh"}}
+[0.15,"o","\u001b[34m$\u001b[0m "]
+[0.05,"o","v"]
+[0.05,"o","c"]
+[0.05,"o","s"]
+[0.05,"o","p"]
+[0.05,"o","u"]
+[0.05,"o","l"]
+[0.05,"o","l"]
+[0.05,"o"," "]
+[0.05,"o","f"]
+[0.05,"o","m"]
+[0.05,"o","t"]
+[0.20,"o","\r\n"]
+[0.116,"o","\u001b[36mi\u001b[0m Found \u001b[33m9\u001b[0m formatting issues in \u001b[34m~/.vcspull.yaml\u001b[0m\r\n \u001b[34m•\u001b[0m 5 repositories from compact to verbose format\r\n \u001b[34m•\u001b[0m 3 repositories from 'url' to 'repo' key\r\n \u001b[34m•\u001b[0m Directories will be sorted alphabetically\r\n\r\n\u001b[33m→\u001b[0m Run with \u001b[36m--write\u001b[0m to apply these formatting changes.\r\n"]
+[2.027,"x","0"]
diff --git a/docs/_static/demos/asciinema/vcspull-fmt.gif b/docs/_static/demos/asciinema/vcspull-fmt.gif
new file mode 100644
index 000000000..34d44903d
Binary files /dev/null and b/docs/_static/demos/asciinema/vcspull-fmt.gif differ
diff --git a/docs/_static/demos/asciinema/vcspull-import.cast b/docs/_static/demos/asciinema/vcspull-import.cast
new file mode 100644
index 000000000..ee615d92a
--- /dev/null
+++ b/docs/_static/demos/asciinema/vcspull-import.cast
@@ -0,0 +1,95 @@
+{"version":3,"term":{"cols":120,"rows":20},"timestamp":1783863922,"idle_time_limit":1.0,"command":"vcspull import github pallets --mode org -w ~/code/python --limit 5 --https --dry-run; sleep 2","title":"vcspull import","env":{"SHELL":"/bin/zsh"}}
+[0.15,"o","\u001b[34m$\u001b[0m "]
+[0.05,"o","v"]
+[0.05,"o","c"]
+[0.05,"o","s"]
+[0.05,"o","p"]
+[0.05,"o","u"]
+[0.05,"o","l"]
+[0.05,"o","l"]
+[0.05,"o"," "]
+[0.05,"o","i"]
+[0.05,"o","m"]
+[0.05,"o","p"]
+[0.05,"o","o"]
+[0.05,"o","r"]
+[0.05,"o","t"]
+[0.05,"o"," "]
+[0.05,"o","g"]
+[0.05,"o","i"]
+[0.05,"o","t"]
+[0.05,"o","h"]
+[0.05,"o","u"]
+[0.05,"o","b"]
+[0.05,"o"," "]
+[0.05,"o","p"]
+[0.05,"o","a"]
+[0.05,"o","l"]
+[0.05,"o","l"]
+[0.05,"o","e"]
+[0.05,"o","t"]
+[0.05,"o","s"]
+[0.05,"o"," "]
+[0.05,"o","-"]
+[0.05,"o","-"]
+[0.05,"o","m"]
+[0.05,"o","o"]
+[0.05,"o","d"]
+[0.05,"o","e"]
+[0.05,"o"," "]
+[0.05,"o","o"]
+[0.05,"o","r"]
+[0.05,"o","g"]
+[0.05,"o"," "]
+[0.05,"o","-"]
+[0.05,"o","w"]
+[0.05,"o"," "]
+[0.05,"o","~"]
+[0.05,"o","/"]
+[0.05,"o","c"]
+[0.05,"o","o"]
+[0.05,"o","d"]
+[0.05,"o","e"]
+[0.05,"o","/"]
+[0.05,"o","p"]
+[0.05,"o","y"]
+[0.05,"o","t"]
+[0.05,"o","h"]
+[0.05,"o","o"]
+[0.05,"o","n"]
+[0.05,"o"," "]
+[0.05,"o","-"]
+[0.05,"o","-"]
+[0.05,"o","l"]
+[0.05,"o","i"]
+[0.05,"o","m"]
+[0.05,"o","i"]
+[0.05,"o","t"]
+[0.05,"o"," "]
+[0.05,"o","5"]
+[0.05,"o"," "]
+[0.05,"o","-"]
+[0.05,"o","-"]
+[0.05,"o","h"]
+[0.05,"o","t"]
+[0.05,"o","t"]
+[0.05,"o","p"]
+[0.05,"o","s"]
+[0.05,"o"," "]
+[0.05,"o","-"]
+[0.05,"o","-"]
+[0.05,"o","d"]
+[0.05,"o","r"]
+[0.05,"o","y"]
+[0.05,"o","-"]
+[0.05,"o","r"]
+[0.05,"o","u"]
+[0.05,"o","n"]
+[0.20,"o","\r\n"]
+[0.119,"o","\u001b[36m→\u001b[0m Fetching repositories from \u001b[35mGitHub\u001b[0m...\r\n"]
+[0.626,"o","Showing 5 repositories; more may be available (use --limit 0 to fetch all)\r\n\r\n\u001b[32m✓\u001b[0m Found \u001b[36m5\u001b[0m repositories\r\n \u001b[32m+\u001b[0m \u001b[36mjinja\u001b[0m\u001b[34m [Python]\u001b[0m\u001b[34m ★11684\u001b[0m\r\n \u001b[32m+\u001b[0m \u001b[36mflask\u001b[0m\u001b[34m [Python]\u001b[0m\u001b[34m ★71941\u001b[0m\r\n \u001b[32m+\u001b[0m \u001b[36mclick\u001b[0m\u001b[34m [Python]\u001b[0m\u001b[34m ★17578\u001b[0m\r\n \u001b[32m+\u001b[0m \u001b[36mitsdangerous\u001b[0m\u001b[34m [Python]\u001b[0m\u001b[34m ★3125\u001b[0m\r\n \u001b[32m+\u001b[0m \u001b[36mwerkzeug\u001b[0m\u001b[34m [Python]\u001b[0m\u001b[34m ★6876\u001b[0m\r\n"]
+[0.002,"o","[DRY-RUN] Would add: jinja → git+https://github.com/pallets/jinja.git\r\n[DRY-RUN] Would add: itsdangerous → git+https://github.com/pallets/itsdangerous.git\r\n[DRY-RUN] Would add: werkzeug → git+https://github.com/pallets/werkzeug.git\r\n"]
+[0.000,"o","[DRY-RUN] Would add \u001b[36m3\u001b[0m repositories\r\n[DRY-RUN] Would tag \u001b[36m2\u001b[0m repositories with import provenance\r\n[DRY-RUN] \u001b[36m2\u001b[0m repositories unchanged\r\n"]
+[0.000,"o","\r\n"]
+[0.000,"o","\u001b[33m→\u001b[0m Dry run complete. Would write to \u001b[34m~/.vcspull.yaml\u001b[0m\r\n"]
+[2.027,"x","0"]
diff --git a/docs/_static/demos/asciinema/vcspull-import.gif b/docs/_static/demos/asciinema/vcspull-import.gif
new file mode 100644
index 000000000..aabc24e97
Binary files /dev/null and b/docs/_static/demos/asciinema/vcspull-import.gif differ
diff --git a/docs/_static/demos/asciinema/vcspull-list-tree.cast b/docs/_static/demos/asciinema/vcspull-list-tree.cast
new file mode 100644
index 000000000..232dc6325
--- /dev/null
+++ b/docs/_static/demos/asciinema/vcspull-list-tree.cast
@@ -0,0 +1,25 @@
+{"version":3,"term":{"cols":120,"rows":17},"timestamp":1783863904,"idle_time_limit":1.0,"command":"vcspull list --tree; sleep 2","title":"vcspull list-tree","env":{"SHELL":"/bin/zsh"}}
+[0.15,"o","\u001b[34m$\u001b[0m "]
+[0.05,"o","v"]
+[0.05,"o","c"]
+[0.05,"o","s"]
+[0.05,"o","p"]
+[0.05,"o","u"]
+[0.05,"o","l"]
+[0.05,"o","l"]
+[0.05,"o"," "]
+[0.05,"o","l"]
+[0.05,"o","i"]
+[0.05,"o","s"]
+[0.05,"o","t"]
+[0.05,"o"," "]
+[0.05,"o","-"]
+[0.05,"o","-"]
+[0.05,"o","t"]
+[0.05,"o","r"]
+[0.05,"o","e"]
+[0.05,"o","e"]
+[0.20,"o","\r\n"]
+[0.125,"o","\r\n\u001b[35m~/code/c/\u001b[0m\r\n \u001b[34m•\u001b[0m \u001b[36mjq\u001b[0m \u001b[34m→\u001b[0m ~/code/c/jq\r\n \u001b[34m•\u001b[0m \u001b[36mtmux\u001b[0m \u001b[34m→\u001b[0m ~/code/c/tmux\r\n\r\n\u001b[35m~/code/go/\u001b[0m\r\n \u001b[34m•\u001b[0m \u001b[36mbubbletea\u001b[0m \u001b[34m→\u001b[0m ~/code/go/bubbletea\r\n \u001b[34m•\u001b[0m \u001b[36mfzf\u001b[0m \u001b[34m→\u001b[0m ~/code/go/fzf\r\n \u001b[34m•\u001b[0m \u001b[36mgin\u001b[0m \u001b[34m→\u001b[0m ~/code/go/gin\r\n\r\n\u001b[35m~/code/python/\u001b[0m\r\n \u001b[34m•\u001b[0m \u001b[36mclick\u001b[0m \u001b[34m→\u001b[0m ~/code/python/click\r\n \u001b[34m•\u001b[0m \u001b[36mflask\u001b[0m \u001b[34m→\u001b[0m ~/code/python/flask\r\n"]
+[0.000,"o"," \u001b[34m•\u001b[0m \u001b[36mhttpx\u001b[0m \u001b[34m→\u001b[0m ~/code/python/httpx\r\n"]
+[2.022,"x","0"]
diff --git a/docs/_static/demos/asciinema/vcspull-list-tree.gif b/docs/_static/demos/asciinema/vcspull-list-tree.gif
new file mode 100644
index 000000000..3f9c10a1b
Binary files /dev/null and b/docs/_static/demos/asciinema/vcspull-list-tree.gif differ
diff --git a/docs/_static/demos/asciinema/vcspull-list.cast b/docs/_static/demos/asciinema/vcspull-list.cast
new file mode 100644
index 000000000..acea91433
--- /dev/null
+++ b/docs/_static/demos/asciinema/vcspull-list.cast
@@ -0,0 +1,18 @@
+{"version":3,"term":{"cols":120,"rows":11},"timestamp":1783863901,"idle_time_limit":1.0,"command":"vcspull list; sleep 2","title":"vcspull list","env":{"SHELL":"/bin/zsh"}}
+[0.15,"o","\u001b[34m$\u001b[0m "]
+[0.05,"o","v"]
+[0.05,"o","c"]
+[0.05,"o","s"]
+[0.05,"o","p"]
+[0.05,"o","u"]
+[0.05,"o","l"]
+[0.05,"o","l"]
+[0.05,"o"," "]
+[0.05,"o","l"]
+[0.05,"o","i"]
+[0.05,"o","s"]
+[0.05,"o","t"]
+[0.20,"o","\r\n"]
+[0.129,"o","\u001b[34m•\u001b[0m \u001b[36mclick\u001b[0m \u001b[34m→\u001b[0m ~/code/python/click\r\n"]
+[0.001,"o","\u001b[34m•\u001b[0m \u001b[36mflask\u001b[0m \u001b[34m→\u001b[0m ~/code/python/flask\r\n\u001b[34m•\u001b[0m \u001b[36mhttpx\u001b[0m \u001b[34m→\u001b[0m ~/code/python/httpx\r\n\u001b[34m•\u001b[0m \u001b[36mbubbletea\u001b[0m \u001b[34m→\u001b[0m ~/code/go/bubbletea\r\n\u001b[34m•\u001b[0m \u001b[36mfzf\u001b[0m \u001b[34m→\u001b[0m ~/code/go/fzf\r\n\u001b[34m•\u001b[0m \u001b[36mgin\u001b[0m \u001b[34m→\u001b[0m ~/code/go/gin\r\n\u001b[34m•\u001b[0m \u001b[36mjq\u001b[0m \u001b[34m→\u001b[0m ~/code/c/jq\r\n\u001b[34m•\u001b[0m \u001b[36mtmux\u001b[0m \u001b[34m→\u001b[0m ~/code/c/tmux\r\n"]
+[2.024,"x","0"]
diff --git a/docs/_static/demos/asciinema/vcspull-list.gif b/docs/_static/demos/asciinema/vcspull-list.gif
new file mode 100644
index 000000000..9a0d0c202
Binary files /dev/null and b/docs/_static/demos/asciinema/vcspull-list.gif differ
diff --git a/docs/_static/demos/asciinema/vcspull-migrate.cast b/docs/_static/demos/asciinema/vcspull-migrate.cast
new file mode 100644
index 000000000..40cf39f22
--- /dev/null
+++ b/docs/_static/demos/asciinema/vcspull-migrate.cast
@@ -0,0 +1,38 @@
+{"version":3,"term":{"cols":120,"rows":8},"timestamp":1783863915,"idle_time_limit":1.0,"command":"vcspull migrate -f ~/legacy.yaml; sleep 2","title":"vcspull migrate","env":{"SHELL":"/bin/zsh"}}
+[0.15,"o","\u001b[34m$\u001b[0m "]
+[0.05,"o","v"]
+[0.05,"o","c"]
+[0.05,"o","s"]
+[0.05,"o","p"]
+[0.05,"o","u"]
+[0.05,"o","l"]
+[0.05,"o","l"]
+[0.05,"o"," "]
+[0.05,"o","m"]
+[0.05,"o","i"]
+[0.05,"o","g"]
+[0.05,"o","r"]
+[0.05,"o","a"]
+[0.05,"o","t"]
+[0.05,"o","e"]
+[0.05,"o"," "]
+[0.05,"o","-"]
+[0.05,"o","f"]
+[0.05,"o"," "]
+[0.05,"o","~"]
+[0.05,"o","/"]
+[0.05,"o","l"]
+[0.05,"o","e"]
+[0.05,"o","g"]
+[0.05,"o","a"]
+[0.05,"o","c"]
+[0.05,"o","y"]
+[0.05,"o","."]
+[0.05,"o","y"]
+[0.05,"o","a"]
+[0.05,"o","m"]
+[0.05,"o","l"]
+[0.20,"o","\r\n"]
+[0.121,"o","\u001b[36mi\u001b[0m Migrating \u001b[33m2\u001b[0m entries in \u001b[34m~/legacy.yaml\u001b[0m\r\n \u001b[34m•\u001b[0m \u001b[36mclick\u001b[0m: moved rev/shallow/depth under options:\r\n \u001b[34m•\u001b[0m \u001b[36mflask\u001b[0m: moved rev/shallow/depth under options:\r\n\r\n"]
+[0.000,"o","\u001b[33m→\u001b[0m Run with \u001b[36m--write\u001b[0m to apply these changes.\r\n"]
+[2.023,"x","0"]
diff --git a/docs/_static/demos/asciinema/vcspull-migrate.gif b/docs/_static/demos/asciinema/vcspull-migrate.gif
new file mode 100644
index 000000000..6f6bd7a28
Binary files /dev/null and b/docs/_static/demos/asciinema/vcspull-migrate.gif differ
diff --git a/docs/_static/demos/asciinema/vcspull-search.cast b/docs/_static/demos/asciinema/vcspull-search.cast
new file mode 100644
index 000000000..1392563e4
--- /dev/null
+++ b/docs/_static/demos/asciinema/vcspull-search.cast
@@ -0,0 +1,30 @@
+{"version":3,"term":{"cols":120,"rows":9},"timestamp":1783863908,"idle_time_limit":1.0,"command":"vcspull search python; sleep 2","title":"vcspull search","env":{"SHELL":"/bin/zsh"}}
+[0.15,"o","\u001b[34m$\u001b[0m "]
+[0.05,"o","v"]
+[0.05,"o","c"]
+[0.05,"o","s"]
+[0.05,"o","p"]
+[0.05,"o","u"]
+[0.05,"o","l"]
+[0.05,"o","l"]
+[0.05,"o"," "]
+[0.05,"o","s"]
+[0.05,"o","e"]
+[0.05,"o","a"]
+[0.05,"o","r"]
+[0.05,"o","c"]
+[0.05,"o","h"]
+[0.05,"o"," "]
+[0.05,"o","p"]
+[0.05,"o","y"]
+[0.05,"o","t"]
+[0.05,"o","h"]
+[0.05,"o","o"]
+[0.05,"o","n"]
+[0.20,"o","\r\n"]
+[0.132,"o","\u001b[34m•\u001b[0m \u001b[36mclick\u001b[0m \u001b[34m→\u001b[0m ~/code/\u001b[35mpython\u001b[0m/click\r\n \u001b[34mworkspace:\u001b[0m ~/code/\u001b[35mpython\u001b[0m"]
+[0.000,"o","\r\n"]
+[0.000,"o","\u001b[34m•\u001b[0m \u001b[36mflask\u001b[0m \u001b[34m→\u001b[0m ~/code/\u001b[35mpython\u001b[0m/flask\r\n \u001b[34mworkspace:\u001b[0m ~/code/\u001b[35mpython\u001b[0m\r\n"]
+[0.000,"o","\u001b[34m•\u001b[0m \u001b[36mhttpx\u001b[0m \u001b[34m→\u001b[0m ~/code/\u001b[35mpython\u001b[0m/httpx\r\n"]
+[0.000,"o"," \u001b[34mworkspace:\u001b[0m ~/code/\u001b[35mpython\u001b[0m\r\n"]
+[2.028,"x","0"]
diff --git a/docs/_static/demos/asciinema/vcspull-search.gif b/docs/_static/demos/asciinema/vcspull-search.gif
new file mode 100644
index 000000000..ea569d738
Binary files /dev/null and b/docs/_static/demos/asciinema/vcspull-search.gif differ
diff --git a/docs/_static/demos/asciinema/vcspull-status.cast b/docs/_static/demos/asciinema/vcspull-status.cast
new file mode 100644
index 000000000..8d85e7dfb
--- /dev/null
+++ b/docs/_static/demos/asciinema/vcspull-status.cast
@@ -0,0 +1,26 @@
+{"version":3,"term":{"cols":120,"rows":14},"timestamp":1783863906,"idle_time_limit":1.0,"command":"vcspull status; sleep 2","title":"vcspull status","env":{"SHELL":"/bin/zsh"}}
+[0.15,"o","\u001b[34m$\u001b[0m "]
+[0.05,"o","v"]
+[0.05,"o","c"]
+[0.05,"o","s"]
+[0.05,"o","p"]
+[0.05,"o","u"]
+[0.05,"o","l"]
+[0.05,"o","l"]
+[0.05,"o"," "]
+[0.05,"o","s"]
+[0.05,"o","t"]
+[0.05,"o","a"]
+[0.05,"o","t"]
+[0.05,"o","u"]
+[0.05,"o","s"]
+[0.20,"o","\r\n"]
+[0.125,"o","\rProgress: 1/8 \u001b[32m✓:0\u001b[0m \u001b[31m✗:1\u001b[0m"]
+[0.001,"o","\rProgress: 2/8 \u001b[32m✓:0\u001b[0m \u001b[31m✗:2\u001b[0m\rProgress: 3/8 \u001b[32m✓:0\u001b[0m \u001b[31m✗:3\u001b[0m\rProgress: 4/8 \u001b[32m✓:0\u001b[0m \u001b[31m✗:4\u001b[0m"]
+[0.000,"o","\rProgress: 5/8 \u001b[32m✓:0\u001b[0m \u001b[31m✗:5\u001b[0m"]
+[0.003,"o","\rProgress: 6/8 \u001b[32m✓:1\u001b[0m \u001b[31m✗:5\u001b[0m"]
+[0.001,"o","\rProgress: 7/8 \u001b[32m✓:2\u001b[0m \u001b[31m✗:5\u001b[0m"]
+[0.006,"o","\rProgress: 8/8 \u001b[32m✓:3\u001b[0m \u001b[31m✗:5\u001b[0m"]
+[0.001,"o","\r\n"]
+[0.000,"o","\u001b[31m✗\u001b[0m \u001b[36mbubbletea\u001b[0m: \u001b[31mmissing\u001b[0m\r\n\u001b[31m✗\u001b[0m \u001b[36mgin\u001b[0m: \u001b[31mmissing\u001b[0m\r\n\u001b[31m✗\u001b[0m \u001b[36mjq\u001b[0m: \u001b[31mmissing\u001b[0m\r\n\u001b[31m✗\u001b[0m \u001b[36mtmux\u001b[0m: \u001b[31mmissing\u001b[0m\r\n\u001b[31m✗\u001b[0m \u001b[36mfzf\u001b[0m: \u001b[31mmissing\u001b[0m\r\n\u001b[32m✓\u001b[0m \u001b[36mclick\u001b[0m: \u001b[32mup to date\u001b[0m\r\n\u001b[32m✓\u001b[0m \u001b[36mflask\u001b[0m: \u001b[33mdirty\u001b[0m\r\n\u001b[32m✓\u001b[0m \u001b[36mhttpx\u001b[0m: \u001b[32mup to date\u001b[0m\r\n\r\n\u001b[36mSummary:\u001b[0m 8 repositories, \u001b[32m3\u001b[0m exist, \u001b[31m5\u001b[0m missing\r\n"]
+[2.023,"x","0"]
diff --git a/docs/_static/demos/asciinema/vcspull-status.gif b/docs/_static/demos/asciinema/vcspull-status.gif
new file mode 100644
index 000000000..be770481e
Binary files /dev/null and b/docs/_static/demos/asciinema/vcspull-status.gif differ
diff --git a/docs/_static/demos/asciinema/vcspull-sync.cast b/docs/_static/demos/asciinema/vcspull-sync.cast
new file mode 100644
index 000000000..383e9993b
--- /dev/null
+++ b/docs/_static/demos/asciinema/vcspull-sync.cast
@@ -0,0 +1,83 @@
+{"version":3,"term":{"cols":120,"rows":9},"timestamp":1783863925,"idle_time_limit":1.0,"command":"vcspull sync -w ~/code/python --all; sleep 2","title":"vcspull sync","env":{"SHELL":"/bin/zsh"}}
+[0.15,"o","\u001b[34m$\u001b[0m "]
+[0.05,"o","v"]
+[0.05,"o","c"]
+[0.05,"o","s"]
+[0.05,"o","p"]
+[0.05,"o","u"]
+[0.05,"o","l"]
+[0.05,"o","l"]
+[0.05,"o"," "]
+[0.05,"o","s"]
+[0.05,"o","y"]
+[0.05,"o","n"]
+[0.05,"o","c"]
+[0.05,"o"," "]
+[0.05,"o","-"]
+[0.05,"o","w"]
+[0.05,"o"," "]
+[0.05,"o","~"]
+[0.05,"o","/"]
+[0.05,"o","c"]
+[0.05,"o","o"]
+[0.05,"o","d"]
+[0.05,"o","e"]
+[0.05,"o","/"]
+[0.05,"o","p"]
+[0.05,"o","y"]
+[0.05,"o","t"]
+[0.05,"o","h"]
+[0.05,"o","o"]
+[0.05,"o","n"]
+[0.05,"o"," "]
+[0.05,"o","-"]
+[0.05,"o","-"]
+[0.05,"o","a"]
+[0.05,"o","l"]
+[0.05,"o","l"]
+[0.20,"o","\r\n"]
+[0.122,"o","\u001b[?25l"]
+[0.000,"o","\u001b[?2026h\r\u001b[2K\u001b[36m⠋\u001b[0m Syncing \u001b[36mclick\u001b[0m ... 0.0s\u001b[?2026l"]
+[0.103,"o","\u001b[?2026h\r\u001b[2K\u001b[36m⠙\u001b[0m Syncing \u001b[36mclick\u001b[0m ... 0.1s\u001b[?2026l"]
+[0.101,"o","\u001b[?2026h\r\u001b[2K\u001b[36m⠹\u001b[0m Syncing \u001b[36mclick\u001b[0m ... 0.2s\u001b[?2026l"]
+[0.100,"o","\u001b[?2026h\r\u001b[2K\u001b[36m⠸\u001b[0m Syncing \u001b[36mclick\u001b[0m ... 0.3s\u001b[?2026l"]
+[0.101,"o","\u001b[?2026h\r\u001b[2K\u001b[36m⠼\u001b[0m Syncing \u001b[36mclick\u001b[0m ... 0.4s\u001b[?2026l"]
+[0.100,"o","\u001b[?2026h\r\u001b[2Kremote: Compressing objects: 99% (171/172)\r\n\u001b[2Kremote: Compressing objects: 100% (172/172)\r\n\u001b[2Kremo\r\n\u001b[2K\u001b[36m⠴\u001b[0m Syncing \u001b[36mclick\u001b[0m ... 0.5s\u001b[?2026l"]
+[0.100,"o","\u001b[?2026h\u001b[3A\r\u001b[2KReceiving objects: 46% (86/186)\r\n\u001b[2KReceiving objects: 47% (88/186)\r\n\u001b[2KReceiving objects: 48% (90/18\r\n\u001b[2K\u001b[36m⠦\u001b[0m Syncing \u001b[36mclick\u001b[0m ... 0.6s\u001b[?2026l"]
+[0.101,"o","\u001b[?2026h\u001b[3A\r\u001b[2KResolving deltas: 76% (10/13)\r\n\u001b[2KResolving deltas: 84% (11/13)\r\n\u001b[2KResolving deltas: 92% (12/1\r\n\u001b[2K\u001b[36m⠧\u001b[0m Syncing \u001b[36mclick\u001b[0m ... 0.7s\u001b[?2026l"]
+[0.100,"o","\u001b[?2026h\u001b[3A\r\u001b[2K3)\r\n\u001b[2KResolving deltas: 100% (13/13)\r\n\u001b[2KResolving deltas: 100% (13/13), done.\r\n\u001b[2K\u001b[36m⠇\u001b[0m Syncing \u001b[36mclick\u001b[0m ... 0.8s\u001b[?2026l"]
+[0.100,"o","\u001b[?2026h\u001b[3A\r\u001b[2K3)\r\n\u001b[2KResolving deltas: 100% (13/13)\r\n\u001b[2KResolving deltas: 100% (13/13), done.\r\n\u001b[2K\u001b[36m⠏\u001b[0m Syncing \u001b[36mclick\u001b[0m ... 0.9s\u001b[?2026l"]
+[0.101,"o","\u001b[?2026h\u001b[3A\r\u001b[2K3)\r\n\u001b[2KResolving deltas: 100% (13/13)\r\n\u001b[2KResolving deltas: 100% (13/13), done.\r\n\u001b[2K\u001b[36m⠋\u001b[0m Syncing \u001b[36mclick\u001b[0m ... 1.0s\u001b[?2026l"]
+[0.100,"o","\u001b[?2026h\u001b[3A\r\u001b[2K3)\r\n\u001b[2KResolving deltas: 100% (13/13)\r\n\u001b[2KResolving deltas: 100% (13/13), done.\r\n\u001b[2K\u001b[36m⠙\u001b[0m Syncing \u001b[36mclick\u001b[0m ... 1.1s\u001b[?2026l"]
+[0.096,"o","\u001b[?2026h\u001b[3A\r\u001b[3M\u001b[2K\u001b[32m✓\u001b[0m Synced \u001b[36mclick\u001b[0m \u001b[34m→\u001b[0m ~/code/python/click\r\n\u001b[?2026l"]
+[0.004,"o","\u001b[?2026h\r\u001b[2K\u001b[36m⠹\u001b[0m Syncing \u001b[36mflask\u001b[0m ... 0.0s\u001b[?2026l"]
+[0.100,"o","\u001b[?2026h\r\u001b[2K\u001b[36m⠸\u001b[0m Syncing \u001b[36mflask\u001b[0m ... 0.1s\u001b[?2026l"]
+[0.101,"o","\u001b[?2026h\r\u001b[2K\u001b[36m⠼\u001b[0m Syncing \u001b[36mflask\u001b[0m ... 0.2s\u001b[?2026l"]
+[0.102,"o","\u001b[?2026h\r\u001b[2K\u001b[36m⠴\u001b[0m Syncing \u001b[36mflask\u001b[0m ... 0.3s\u001b[?2026l"]
+[0.102,"o","\u001b[?2026h\r\u001b[2K\u001b[36m⠦\u001b[0m Syncing \u001b[36mflask\u001b[0m ... 0.4s\u001b[?2026l"]
+[0.102,"o","\u001b[?2026h\r\u001b[2KReceiving objects: 2% (6/283)\r\n\u001b[2KReceiving objects: 3% (9/283)\r\n\u001b[2KReceiving objects: 4% (12/283)\r\n\u001b[2K\u001b[36m⠧\u001b[0m Syncing \u001b[36mflask\u001b[0m ... 0.5s\u001b[?2026l"]
+[0.101,"o","\u001b[?2026h\u001b[3A\r\u001b[2KReceiving objects: 6% (17/283)\r\n\u001b[2KReceiving objects: 7% (20/283)\r\n\u001b[2KReceiving objects: 8% (23/2\r\n\u001b[2K\u001b[36m⠇\u001b[0m Syncing \u001b[36mflask\u001b[0m ... 0.6s\u001b[?2026l"]
+[0.100,"o","\u001b[?2026h\u001b[3A\r\u001b[2KReceiving objects: 97% (275/283)\r\n\u001b[2KReceiving objects: 98% (278/283)\r\n\u001b[2KReceiving objects: 99% (281/283)\r\n\u001b[2K\u001b[36m⠏\u001b[0m Syncing \u001b[36mflask\u001b[0m ... 0.7s\u001b[?2026l"]
+[0.101,"o","\u001b[?2026h\u001b[3A\r\u001b[2KResolving deltas: 100% (5/5)\r\n\u001b[2KResolving deltas: 100% (5/5)\r\n\u001b[2K, done.\r\n\u001b[2K\u001b[36m⠋\u001b[0m Syncing \u001b[36mflask\u001b[0m ... 0.8s\u001b[?2026l"]
+[0.100,"o","\u001b[?2026h\u001b[3A\r\u001b[2KResolving deltas: 100% (5/5)\r\n\u001b[2KResolving deltas: 100% (5/5)\r\n\u001b[2K, done.\r\n\u001b[2K\u001b[36m⠙\u001b[0m Syncing \u001b[36mflask\u001b[0m ... 0.9s\u001b[?2026l"]
+[0.100,"o","\u001b[?2026h\u001b[3A\r\u001b[2KResolving deltas: 100% (5/5)\r\n\u001b[2KResolving deltas: 100% (5/5)\r\n\u001b[2K, done.\r\n\u001b[2K\u001b[36m⠹\u001b[0m Syncing \u001b[36mflask\u001b[0m ... 1.0s\u001b[?2026l"]
+[0.100,"o","\u001b[?2026h\u001b[3A\r\u001b[2KResolving deltas: 100% (5/5)\r\n\u001b[2KResolving deltas: 100% (5/5)\r\n\u001b[2K, done.\r\n\u001b[2K\u001b[36m⠸\u001b[0m Syncing \u001b[36mflask\u001b[0m ... 1.1s\u001b[?2026l"]
+[0.101,"o","\u001b[?2026h\u001b[3A\r\u001b[2KResolving deltas: 100% (5/5)\r\n\u001b[2K, done.\r\n\u001b[2KAlready on 'main'\r\n\u001b[2K\u001b[36m⠼\u001b[0m Syncing \u001b[36mflask\u001b[0m ... 1.2s\u001b[?2026l"]
+[0.010,"o","\u001b[?2026h\u001b[3A\r\u001b[3M\u001b[2K\u001b[32m✓\u001b[0m Synced \u001b[36mflask\u001b[0m \u001b[34m→\u001b[0m ~/code/python/flask\r\n\u001b[?2026l"]
+[0.090,"o","\u001b[?2026h\r\u001b[2K\u001b[36m⠴\u001b[0m Syncing \u001b[36mhttpx\u001b[0m ... 0.1s\u001b[?2026l"]
+[0.101,"o","\u001b[?2026h\r\u001b[2K\u001b[36m⠦\u001b[0m Syncing \u001b[36mhttpx\u001b[0m ... 0.2s\u001b[?2026l"]
+[0.102,"o","\u001b[?2026h\r\u001b[2K\u001b[36m⠧\u001b[0m Syncing \u001b[36mhttpx\u001b[0m ... 0.3s\u001b[?2026l"]
+[0.101,"o","\u001b[?2026h\r\u001b[2K\u001b[36m⠇\u001b[0m Syncing \u001b[36mhttpx\u001b[0m ... 0.4s\u001b[?2026l"]
+[0.100,"o","\u001b[?2026h\r\u001b[2KReceiving objects: 17% (24/140)\r\n\u001b[2KReceiving objects: 18% (26/140)\r\n\u001b[2KRe\r\n\u001b[2K\u001b[36m⠏\u001b[0m Syncing \u001b[36mhttpx\u001b[0m ... 0.5s\u001b[?2026l"]
+[0.100,"o","\u001b[?2026h\u001b[3A\r\u001b[2KReceiving objects: 24% (34/140)\r\n\u001b[2KReceiving objects: 25% (35/140)\r\n\u001b[2KReceiving objects: 26% (37\r\n\u001b[2K\u001b[36m⠋\u001b[0m Syncing \u001b[36mhttpx\u001b[0m ... 0.6s\u001b[?2026l"]
+[0.101,"o","\u001b[?2026h\u001b[3A\r\u001b[2KReceiving objects: 28% (40/140)\r\n\u001b[2KReceiving objects: 29% (41/140)\r\n\u001b[2KReceiving objects: 30%\r\n\u001b[2K\u001b[36m⠙\u001b[0m Syncing \u001b[36mhttpx\u001b[0m ... 0.7s\u001b[?2026l"]
+[0.100,"o","\u001b[?2026h\u001b[3A\r\u001b[2KResolving deltas: 0% (0/2)\r\n\u001b[2KResolving deltas: 50% (1/2)\r\n\u001b[2KResolvi\r\n\u001b[2K\u001b[36m⠹\u001b[0m Syncing \u001b[36mhttpx\u001b[0m ... 0.8s\u001b[?2026l"]
+[0.101,"o","\u001b[?2026h\u001b[3A\r\u001b[2KResolvi\r\n\u001b[2Kng deltas: 100% (2/2)\r\n\u001b[2KResolving deltas: 100% (2/2), done.\r\n\u001b[2K\u001b[36m⠸\u001b[0m Syncing \u001b[36mhttpx\u001b[0m ... 0.9s\u001b[?2026l"]
+[0.100,"o","\u001b[?2026h\u001b[3A\r\u001b[2KResolvi\r\n\u001b[2Kng deltas: 100% (2/2)\r\n\u001b[2KResolving deltas: 100% (2/2), done.\r\n\u001b[2K\u001b[36m⠼\u001b[0m Syncing \u001b[36mhttpx\u001b[0m ... 1.0s\u001b[?2026l"]
+[0.101,"o","\u001b[?2026h\u001b[3A\r\u001b[2KResolvi\r\n\u001b[2Kng deltas: 100% (2/2)\r\n\u001b[2KResolving deltas: 100% (2/2), done.\r\n\u001b[2K\u001b[36m⠴\u001b[0m Syncing \u001b[36mhttpx\u001b[0m ... 1.1s\u001b[?2026l"]
+[0.100,"o","\u001b[?2026h\u001b[3A\r\u001b[2KResolvi\r\n\u001b[2Kng deltas: 100% (2/2)\r\n\u001b[2KResolving deltas: 100% (2/2), done.\r\n\u001b[2K\u001b[36m⠦\u001b[0m Syncing \u001b[36mhttpx\u001b[0m ... 1.2s\u001b[?2026l"]
+[0.100,"o","\u001b[?2026h\u001b[3A\r\u001b[2Kng deltas: 100% (2/2)\r\n\u001b[2KResolving deltas: 100% (2/2), done.\r\n\u001b[2KAlready on 'master'\r\n\u001b[2K\u001b[36m⠧\u001b[0m Syncing \u001b[36mhttpx\u001b[0m ... 1.3s\u001b[?2026l"]
+[0.009,"o","\u001b[?2026h\u001b[3A\r\u001b[3M\u001b[2K\u001b[32m✓\u001b[0m Synced \u001b[36mhttpx\u001b[0m \u001b[34m→\u001b[0m ~/code/python/httpx\r\n\u001b[?2026l"]
+[0.000,"o","\r\u001b[1M\u001b[?25h"]
+[0.000,"o","\r\n\u001b[36mSummary:\u001b[0m \u001b[36m3\u001b[0m repos, \u001b[32m3\u001b[0m synced, \u001b[31m0\u001b[0m failed\r\n"]
+[2.025,"x","0"]
diff --git a/docs/_static/demos/asciinema/vcspull-sync.gif b/docs/_static/demos/asciinema/vcspull-sync.gif
new file mode 100644
index 000000000..d14e9814a
Binary files /dev/null and b/docs/_static/demos/asciinema/vcspull-sync.gif differ
diff --git a/docs/_static/demos/asciinema/vcspull-worktree.cast b/docs/_static/demos/asciinema/vcspull-worktree.cast
new file mode 100644
index 000000000..17ddb9dac
--- /dev/null
+++ b/docs/_static/demos/asciinema/vcspull-worktree.cast
@@ -0,0 +1,27 @@
+{"version":3,"term":{"cols":120,"rows":7},"timestamp":1783863918,"idle_time_limit":1.0,"command":"vcspull worktree list; sleep 2","title":"vcspull worktree","env":{"SHELL":"/bin/zsh"}}
+[0.15,"o","\u001b[34m$\u001b[0m "]
+[0.05,"o","v"]
+[0.05,"o","c"]
+[0.05,"o","s"]
+[0.05,"o","p"]
+[0.05,"o","u"]
+[0.05,"o","l"]
+[0.05,"o","l"]
+[0.05,"o"," "]
+[0.05,"o","w"]
+[0.05,"o","o"]
+[0.05,"o","r"]
+[0.05,"o","k"]
+[0.05,"o","t"]
+[0.05,"o","r"]
+[0.05,"o","e"]
+[0.05,"o","e"]
+[0.05,"o"," "]
+[0.05,"o","l"]
+[0.05,"o","i"]
+[0.05,"o","s"]
+[0.05,"o","t"]
+[0.20,"o","\r\n"]
+[0.127,"o","\r\n\u001b[35mflask\u001b[0m (~/code/python/flask)\r\n \u001b[32m+\u001b[0m \u001b[36mtag:3.0.0\u001b[0m \u001b[34m~/code/flask-3.x\u001b[0m (\u001b[32mwill create tag worktree\u001b[0m)\r\n"]
+[0.000,"o"," \u001b[32m+\u001b[0m \u001b[36mbranch:main\u001b[0m \u001b[34m~/code/flask-dev\u001b[0m (\u001b[32mwill create branch worktree\u001b[0m)\r\n"]
+[2.028,"x","0"]
diff --git a/docs/_static/demos/asciinema/vcspull-worktree.gif b/docs/_static/demos/asciinema/vcspull-worktree.gif
new file mode 100644
index 000000000..755b3ad61
Binary files /dev/null and b/docs/_static/demos/asciinema/vcspull-worktree.gif differ
diff --git a/docs/_static/demos/setup-sandbox.sh b/docs/_static/demos/setup-sandbox.sh
new file mode 100755
index 000000000..1b2829670
--- /dev/null
+++ b/docs/_static/demos/setup-sandbox.sh
@@ -0,0 +1,107 @@
+#!/usr/bin/env bash
+# Build the sandbox the demo tapes record against.
+#
+# Everything lives under /tmp/vcspull-demo, which the tapes point HOME and
+# XDG_CONFIG_HOME at. That keeps the recordings off the real home directory and
+# makes every path on screen render as ~/code/... instead of a developer's
+# actual layout.
+#
+# Idempotent: wipes and rebuilds. Re-run before re-rendering any tape.
+set -euo pipefail
+
+DEMO_DIR=/tmp/vcspull-demo
+
+# Resolve the vcspull under test. Override with VCSPULL_BIN to record against a
+# working tree rather than an installed release.
+REAL_VCSPULL="${VCSPULL_BIN:-$(command -v vcspull || true)}"
+if [ -z "$REAL_VCSPULL" ]; then
+ echo "error: vcspull not found on PATH; set VCSPULL_BIN to its location" >&2
+ exit 1
+fi
+
+rm -rf "$DEMO_DIR"
+mkdir -p "$DEMO_DIR/.config/vcspull" "$DEMO_DIR/bin"
+
+# The tapes put $DEMO_DIR/bin first on PATH, so `vcspull` resolves here without
+# any absolute developer path appearing in a committed tape.
+printf '#!/usr/bin/env bash\nexec %q "$@"\n' "$REAL_VCSPULL" > "$DEMO_DIR/bin/vcspull"
+chmod +x "$DEMO_DIR/bin/vcspull"
+
+# ---------------------------------------------------------------- main config
+# Eight widely-recognized public repositories, grouped by language. The three
+# Python entries pin depth: 1 so the sync demo clones shallowly and finishes
+# inside a watchable window.
+cat > "$DEMO_DIR/.vcspull.yaml" <<'YAML'
+~/code/python/:
+ click:
+ url: git+https://github.com/pallets/click.git
+ options:
+ depth: 1
+ flask:
+ url: git+https://github.com/pallets/flask.git
+ options:
+ depth: 1
+ worktrees:
+ - dir: ../flask-3.x
+ tag: "3.0.0"
+ - dir: ../flask-dev
+ branch: main
+ httpx:
+ url: git+https://github.com/encode/httpx.git
+ options:
+ depth: 1
+~/code/go/:
+ bubbletea: git+https://github.com/charmbracelet/bubbletea.git
+ fzf: git+https://github.com/junegunn/fzf.git
+ gin: git+https://github.com/gin-gonic/gin.git
+~/code/c/:
+ jq: git+https://github.com/jqlang/jq.git
+ tmux: git+https://github.com/tmux/tmux.git
+YAML
+
+# -------------------------------------------------------------- legacy config
+# Pre-v1.62 shape, with rev/shallow/depth at the entry root. Feeds `migrate`.
+cat > "$DEMO_DIR/legacy.yaml" <<'YAML'
+~/code/python/:
+ click:
+ url: git+https://github.com/pallets/click.git
+ shallow: true
+ flask:
+ url: git+https://github.com/pallets/flask.git
+ depth: 1
+ rev: "3.0.0"
+YAML
+
+# -------------------------------------------------------------- cloned repos
+# Three of the eight are present on disk; the rest stay missing so `status`
+# shows a mix rather than a uniform column of checkmarks.
+mkdir -p "$DEMO_DIR/code/python" "$DEMO_DIR/code/rust"
+for r in "pallets/click" "pallets/flask" "encode/httpx"; do
+ git clone --quiet --depth 1 "https://github.com/$r.git" \
+ "$DEMO_DIR/code/python/${r#*/}" 2>/dev/null
+done
+
+# `worktree list` plans a tag worktree. A --depth 1 clone carries no tags, so
+# without this fetch the tag fails to resolve and libvcs prints the absolute
+# sandbox path inside the error -- putting /tmp/vcspull-demo on screen.
+git -C "$DEMO_DIR/code/python/flask" fetch --quiet --depth 1 origin tag 3.0.0
+
+# One dirty tree, so `status` and `sync` have a non-clean state to report.
+echo "scratch" >> "$DEMO_DIR/code/python/flask/README.md"
+
+# On disk but absent from the config, which gives `add` and `discover`
+# something real to find.
+git init --quiet "$DEMO_DIR/code/rust/ripgrep"
+git -C "$DEMO_DIR/code/rust/ripgrep" remote add origin \
+ https://github.com/BurntSushi/ripgrep.git
+git -C "$DEMO_DIR/code/rust/ripgrep" \
+ -c user.email=demo@example.com -c user.name=demo \
+ commit --quiet --allow-empty -m "init"
+
+# ------------------------------------------------------------------- restore
+# Mutative demos (add, sync, fmt, migrate) reset from these between takes.
+cp "$DEMO_DIR/.vcspull.yaml" "$DEMO_DIR/.vcspull.yaml.bak"
+cp "$DEMO_DIR/legacy.yaml" "$DEMO_DIR/legacy.yaml.bak"
+tar -C "$DEMO_DIR" -czf "$DEMO_DIR/code.tar.gz" code
+
+echo "sandbox ready: $DEMO_DIR ($(du -sh "$DEMO_DIR/code" | cut -f1))"
diff --git a/docs/_static/demos/vhs/vcspull-add.gif b/docs/_static/demos/vhs/vcspull-add.gif
new file mode 100644
index 000000000..08449aad2
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-add.gif differ
diff --git a/docs/_static/demos/vhs/vcspull-add.mp4 b/docs/_static/demos/vhs/vcspull-add.mp4
new file mode 100644
index 000000000..9e80243b5
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-add.mp4 differ
diff --git a/docs/_static/demos/vhs/vcspull-add.tape b/docs/_static/demos/vhs/vcspull-add.tape
new file mode 100644
index 000000000..14a176d7c
--- /dev/null
+++ b/docs/_static/demos/vhs/vcspull-add.tape
@@ -0,0 +1,32 @@
+Output docs/_static/demos/vhs/vcspull-add.gif
+Output docs/_static/demos/vhs/vcspull-add.mp4
+Output docs/_static/demos/vhs/vcspull-add.webm
+
+Set Shell "bash"
+Set FontSize 18
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 50ms
+Set Padding 20
+Set Width 1400
+Set Height 212
+
+# Wait syncs to the prompt returning, so the demo advances when the command
+# actually finishes instead of after a guessed Sleep.
+Set WaitPattern /[$#>]\s*$/
+Set WaitTimeout 90s
+
+# The sandbox from docs/_static/demos/setup-sandbox.sh. Redirecting HOME keeps real paths
+# off screen: everything renders as ~/code/... instead of a developer's layout.
+Env HOME "/tmp/vcspull-demo"
+Env XDG_CONFIG_HOME "/tmp/vcspull-demo/.config"
+Env PATH "/tmp/vcspull-demo/bin:/usr/local/bin:/usr/bin:/bin"
+
+Hide
+Type "cd ~ && clear"
+Enter
+Show
+
+Type "vcspull add ~/code/rust/ripgrep -y"
+Enter
+Wait
+Sleep 2s
diff --git a/docs/_static/demos/vhs/vcspull-add.webm b/docs/_static/demos/vhs/vcspull-add.webm
new file mode 100644
index 000000000..7dc07fe92
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-add.webm differ
diff --git a/docs/_static/demos/vhs/vcspull-discover.gif b/docs/_static/demos/vhs/vcspull-discover.gif
new file mode 100644
index 000000000..10cc009e0
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-discover.gif differ
diff --git a/docs/_static/demos/vhs/vcspull-discover.mp4 b/docs/_static/demos/vhs/vcspull-discover.mp4
new file mode 100644
index 000000000..0d7edba1b
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-discover.mp4 differ
diff --git a/docs/_static/demos/vhs/vcspull-discover.tape b/docs/_static/demos/vhs/vcspull-discover.tape
new file mode 100644
index 000000000..34c3a7ff6
--- /dev/null
+++ b/docs/_static/demos/vhs/vcspull-discover.tape
@@ -0,0 +1,32 @@
+Output docs/_static/demos/vhs/vcspull-discover.gif
+Output docs/_static/demos/vhs/vcspull-discover.mp4
+Output docs/_static/demos/vhs/vcspull-discover.webm
+
+Set Shell "bash"
+Set FontSize 18
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 50ms
+Set Padding 20
+Set Width 1400
+Set Height 324
+
+# Wait syncs to the prompt returning, so the demo advances when the command
+# actually finishes instead of after a guessed Sleep.
+Set WaitPattern /[$#>]\s*$/
+Set WaitTimeout 90s
+
+# The sandbox from docs/_static/demos/setup-sandbox.sh. Redirecting HOME keeps real paths
+# off screen: everything renders as ~/code/... instead of a developer's layout.
+Env HOME "/tmp/vcspull-demo"
+Env XDG_CONFIG_HOME "/tmp/vcspull-demo/.config"
+Env PATH "/tmp/vcspull-demo/bin:/usr/local/bin:/usr/bin:/bin"
+
+Hide
+Type "cd ~ && clear"
+Enter
+Show
+
+Type "vcspull discover ~/code --recursive --dry-run"
+Enter
+Wait
+Sleep 2s
diff --git a/docs/_static/demos/vhs/vcspull-discover.webm b/docs/_static/demos/vhs/vcspull-discover.webm
new file mode 100644
index 000000000..4dd24df83
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-discover.webm differ
diff --git a/docs/_static/demos/vhs/vcspull-fmt.gif b/docs/_static/demos/vhs/vcspull-fmt.gif
new file mode 100644
index 000000000..d9a7018c4
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-fmt.gif differ
diff --git a/docs/_static/demos/vhs/vcspull-fmt.mp4 b/docs/_static/demos/vhs/vcspull-fmt.mp4
new file mode 100644
index 000000000..b7f2aeec7
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-fmt.mp4 differ
diff --git a/docs/_static/demos/vhs/vcspull-fmt.tape b/docs/_static/demos/vhs/vcspull-fmt.tape
new file mode 100644
index 000000000..2a42fc743
--- /dev/null
+++ b/docs/_static/demos/vhs/vcspull-fmt.tape
@@ -0,0 +1,32 @@
+Output docs/_static/demos/vhs/vcspull-fmt.gif
+Output docs/_static/demos/vhs/vcspull-fmt.mp4
+Output docs/_static/demos/vhs/vcspull-fmt.webm
+
+Set Shell "bash"
+Set FontSize 18
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 50ms
+Set Padding 20
+Set Width 1400
+Set Height 184
+
+# Wait syncs to the prompt returning, so the demo advances when the command
+# actually finishes instead of after a guessed Sleep.
+Set WaitPattern /[$#>]\s*$/
+Set WaitTimeout 90s
+
+# The sandbox from docs/_static/demos/setup-sandbox.sh. Redirecting HOME keeps real paths
+# off screen: everything renders as ~/code/... instead of a developer's layout.
+Env HOME "/tmp/vcspull-demo"
+Env XDG_CONFIG_HOME "/tmp/vcspull-demo/.config"
+Env PATH "/tmp/vcspull-demo/bin:/usr/local/bin:/usr/bin:/bin"
+
+Hide
+Type "cd ~ && clear"
+Enter
+Show
+
+Type "vcspull fmt"
+Enter
+Wait
+Sleep 2s
diff --git a/docs/_static/demos/vhs/vcspull-fmt.webm b/docs/_static/demos/vhs/vcspull-fmt.webm
new file mode 100644
index 000000000..836820a77
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-fmt.webm differ
diff --git a/docs/_static/demos/vhs/vcspull-import.gif b/docs/_static/demos/vhs/vcspull-import.gif
new file mode 100644
index 000000000..f8bd382fd
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-import.gif differ
diff --git a/docs/_static/demos/vhs/vcspull-import.mp4 b/docs/_static/demos/vhs/vcspull-import.mp4
new file mode 100644
index 000000000..14f405857
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-import.mp4 differ
diff --git a/docs/_static/demos/vhs/vcspull-import.tape b/docs/_static/demos/vhs/vcspull-import.tape
new file mode 100644
index 000000000..2606702e2
--- /dev/null
+++ b/docs/_static/demos/vhs/vcspull-import.tape
@@ -0,0 +1,32 @@
+Output docs/_static/demos/vhs/vcspull-import.gif
+Output docs/_static/demos/vhs/vcspull-import.mp4
+Output docs/_static/demos/vhs/vcspull-import.webm
+
+Set Shell "bash"
+Set FontSize 18
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 50ms
+Set Padding 20
+Set Width 1400
+Set Height 450
+
+# Wait syncs to the prompt returning, so the demo advances when the command
+# actually finishes instead of after a guessed Sleep.
+Set WaitPattern /[$#>]\s*$/
+Set WaitTimeout 90s
+
+# The sandbox from docs/_static/demos/setup-sandbox.sh. Redirecting HOME keeps real paths
+# off screen: everything renders as ~/code/... instead of a developer's layout.
+Env HOME "/tmp/vcspull-demo"
+Env XDG_CONFIG_HOME "/tmp/vcspull-demo/.config"
+Env PATH "/tmp/vcspull-demo/bin:/usr/local/bin:/usr/bin:/bin"
+
+Hide
+Type "cd ~ && clear"
+Enter
+Show
+
+Type "vcspull import github pallets --mode org -w ~/code/python --limit 5 --https --dry-run"
+Enter
+Wait
+Sleep 2s
diff --git a/docs/_static/demos/vhs/vcspull-import.webm b/docs/_static/demos/vhs/vcspull-import.webm
new file mode 100644
index 000000000..1aaef5272
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-import.webm differ
diff --git a/docs/_static/demos/vhs/vcspull-list-tree.gif b/docs/_static/demos/vhs/vcspull-list-tree.gif
new file mode 100644
index 000000000..372584ecc
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-list-tree.gif differ
diff --git a/docs/_static/demos/vhs/vcspull-list-tree.mp4 b/docs/_static/demos/vhs/vcspull-list-tree.mp4
new file mode 100644
index 000000000..93d072311
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-list-tree.mp4 differ
diff --git a/docs/_static/demos/vhs/vcspull-list-tree.tape b/docs/_static/demos/vhs/vcspull-list-tree.tape
new file mode 100644
index 000000000..216edacf5
--- /dev/null
+++ b/docs/_static/demos/vhs/vcspull-list-tree.tape
@@ -0,0 +1,32 @@
+Output docs/_static/demos/vhs/vcspull-list-tree.gif
+Output docs/_static/demos/vhs/vcspull-list-tree.mp4
+Output docs/_static/demos/vhs/vcspull-list-tree.webm
+
+Set Shell "bash"
+Set FontSize 18
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 50ms
+Set Padding 20
+Set Width 1400
+Set Height 380
+
+# Wait syncs to the prompt returning, so the demo advances when the command
+# actually finishes instead of after a guessed Sleep.
+Set WaitPattern /[$#>]\s*$/
+Set WaitTimeout 90s
+
+# The sandbox from docs/_static/demos/setup-sandbox.sh. Redirecting HOME keeps real paths
+# off screen: everything renders as ~/code/... instead of a developer's layout.
+Env HOME "/tmp/vcspull-demo"
+Env XDG_CONFIG_HOME "/tmp/vcspull-demo/.config"
+Env PATH "/tmp/vcspull-demo/bin:/usr/local/bin:/usr/bin:/bin"
+
+Hide
+Type "cd ~ && clear"
+Enter
+Show
+
+Type "vcspull list --tree"
+Enter
+Wait
+Sleep 2s
diff --git a/docs/_static/demos/vhs/vcspull-list-tree.webm b/docs/_static/demos/vhs/vcspull-list-tree.webm
new file mode 100644
index 000000000..4ebea01d0
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-list-tree.webm differ
diff --git a/docs/_static/demos/vhs/vcspull-list.gif b/docs/_static/demos/vhs/vcspull-list.gif
new file mode 100644
index 000000000..80abce747
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-list.gif differ
diff --git a/docs/_static/demos/vhs/vcspull-list.mp4 b/docs/_static/demos/vhs/vcspull-list.mp4
new file mode 100644
index 000000000..45001793f
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-list.mp4 differ
diff --git a/docs/_static/demos/vhs/vcspull-list.tape b/docs/_static/demos/vhs/vcspull-list.tape
new file mode 100644
index 000000000..8e5f05db8
--- /dev/null
+++ b/docs/_static/demos/vhs/vcspull-list.tape
@@ -0,0 +1,32 @@
+Output docs/_static/demos/vhs/vcspull-list.gif
+Output docs/_static/demos/vhs/vcspull-list.mp4
+Output docs/_static/demos/vhs/vcspull-list.webm
+
+Set Shell "bash"
+Set FontSize 18
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 50ms
+Set Padding 20
+Set Width 1400
+Set Height 240
+
+# Wait syncs to the prompt returning, so the demo advances when the command
+# actually finishes instead of after a guessed Sleep.
+Set WaitPattern /[$#>]\s*$/
+Set WaitTimeout 90s
+
+# The sandbox from docs/_static/demos/setup-sandbox.sh. Redirecting HOME keeps real paths
+# off screen: everything renders as ~/code/... instead of a developer's layout.
+Env HOME "/tmp/vcspull-demo"
+Env XDG_CONFIG_HOME "/tmp/vcspull-demo/.config"
+Env PATH "/tmp/vcspull-demo/bin:/usr/local/bin:/usr/bin:/bin"
+
+Hide
+Type "cd ~ && clear"
+Enter
+Show
+
+Type "vcspull list"
+Enter
+Wait
+Sleep 2s
diff --git a/docs/_static/demos/vhs/vcspull-list.webm b/docs/_static/demos/vhs/vcspull-list.webm
new file mode 100644
index 000000000..b47029b8f
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-list.webm differ
diff --git a/docs/_static/demos/vhs/vcspull-migrate.gif b/docs/_static/demos/vhs/vcspull-migrate.gif
new file mode 100644
index 000000000..2311d2402
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-migrate.gif differ
diff --git a/docs/_static/demos/vhs/vcspull-migrate.mp4 b/docs/_static/demos/vhs/vcspull-migrate.mp4
new file mode 100644
index 000000000..d52ceb9ea
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-migrate.mp4 differ
diff --git a/docs/_static/demos/vhs/vcspull-migrate.tape b/docs/_static/demos/vhs/vcspull-migrate.tape
new file mode 100644
index 000000000..1949c7efb
--- /dev/null
+++ b/docs/_static/demos/vhs/vcspull-migrate.tape
@@ -0,0 +1,32 @@
+Output docs/_static/demos/vhs/vcspull-migrate.gif
+Output docs/_static/demos/vhs/vcspull-migrate.mp4
+Output docs/_static/demos/vhs/vcspull-migrate.webm
+
+Set Shell "bash"
+Set FontSize 18
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 50ms
+Set Padding 20
+Set Width 1400
+Set Height 156
+
+# Wait syncs to the prompt returning, so the demo advances when the command
+# actually finishes instead of after a guessed Sleep.
+Set WaitPattern /[$#>]\s*$/
+Set WaitTimeout 90s
+
+# The sandbox from docs/_static/demos/setup-sandbox.sh. Redirecting HOME keeps real paths
+# off screen: everything renders as ~/code/... instead of a developer's layout.
+Env HOME "/tmp/vcspull-demo"
+Env XDG_CONFIG_HOME "/tmp/vcspull-demo/.config"
+Env PATH "/tmp/vcspull-demo/bin:/usr/local/bin:/usr/bin:/bin"
+
+Hide
+Type "cd ~ && clear"
+Enter
+Show
+
+Type "vcspull migrate -f ~/legacy.yaml"
+Enter
+Wait
+Sleep 2s
diff --git a/docs/_static/demos/vhs/vcspull-migrate.webm b/docs/_static/demos/vhs/vcspull-migrate.webm
new file mode 100644
index 000000000..5b6976008
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-migrate.webm differ
diff --git a/docs/_static/demos/vhs/vcspull-search.gif b/docs/_static/demos/vhs/vcspull-search.gif
new file mode 100644
index 000000000..520b983f2
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-search.gif differ
diff --git a/docs/_static/demos/vhs/vcspull-search.mp4 b/docs/_static/demos/vhs/vcspull-search.mp4
new file mode 100644
index 000000000..a0e60643c
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-search.mp4 differ
diff --git a/docs/_static/demos/vhs/vcspull-search.tape b/docs/_static/demos/vhs/vcspull-search.tape
new file mode 100644
index 000000000..a17f535f6
--- /dev/null
+++ b/docs/_static/demos/vhs/vcspull-search.tape
@@ -0,0 +1,32 @@
+Output docs/_static/demos/vhs/vcspull-search.gif
+Output docs/_static/demos/vhs/vcspull-search.mp4
+Output docs/_static/demos/vhs/vcspull-search.webm
+
+Set Shell "bash"
+Set FontSize 18
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 50ms
+Set Padding 20
+Set Width 1400
+Set Height 184
+
+# Wait syncs to the prompt returning, so the demo advances when the command
+# actually finishes instead of after a guessed Sleep.
+Set WaitPattern /[$#>]\s*$/
+Set WaitTimeout 90s
+
+# The sandbox from docs/_static/demos/setup-sandbox.sh. Redirecting HOME keeps real paths
+# off screen: everything renders as ~/code/... instead of a developer's layout.
+Env HOME "/tmp/vcspull-demo"
+Env XDG_CONFIG_HOME "/tmp/vcspull-demo/.config"
+Env PATH "/tmp/vcspull-demo/bin:/usr/local/bin:/usr/bin:/bin"
+
+Hide
+Type "cd ~ && clear"
+Enter
+Show
+
+Type "vcspull search python"
+Enter
+Wait
+Sleep 2s
diff --git a/docs/_static/demos/vhs/vcspull-search.webm b/docs/_static/demos/vhs/vcspull-search.webm
new file mode 100644
index 000000000..0a047e549
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-search.webm differ
diff --git a/docs/_static/demos/vhs/vcspull-status.gif b/docs/_static/demos/vhs/vcspull-status.gif
new file mode 100644
index 000000000..872819532
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-status.gif differ
diff --git a/docs/_static/demos/vhs/vcspull-status.mp4 b/docs/_static/demos/vhs/vcspull-status.mp4
new file mode 100644
index 000000000..74da78b99
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-status.mp4 differ
diff --git a/docs/_static/demos/vhs/vcspull-status.tape b/docs/_static/demos/vhs/vcspull-status.tape
new file mode 100644
index 000000000..3776d5476
--- /dev/null
+++ b/docs/_static/demos/vhs/vcspull-status.tape
@@ -0,0 +1,32 @@
+Output docs/_static/demos/vhs/vcspull-status.gif
+Output docs/_static/demos/vhs/vcspull-status.mp4
+Output docs/_static/demos/vhs/vcspull-status.webm
+
+Set Shell "bash"
+Set FontSize 18
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 50ms
+Set Padding 20
+Set Width 1400
+Set Height 324
+
+# Wait syncs to the prompt returning, so the demo advances when the command
+# actually finishes instead of after a guessed Sleep.
+Set WaitPattern /[$#>]\s*$/
+Set WaitTimeout 90s
+
+# The sandbox from docs/_static/demos/setup-sandbox.sh. Redirecting HOME keeps real paths
+# off screen: everything renders as ~/code/... instead of a developer's layout.
+Env HOME "/tmp/vcspull-demo"
+Env XDG_CONFIG_HOME "/tmp/vcspull-demo/.config"
+Env PATH "/tmp/vcspull-demo/bin:/usr/local/bin:/usr/bin:/bin"
+
+Hide
+Type "cd ~ && clear"
+Enter
+Show
+
+Type "vcspull status"
+Enter
+Wait
+Sleep 2s
diff --git a/docs/_static/demos/vhs/vcspull-status.webm b/docs/_static/demos/vhs/vcspull-status.webm
new file mode 100644
index 000000000..20b4f0975
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-status.webm differ
diff --git a/docs/_static/demos/vhs/vcspull-sync.gif b/docs/_static/demos/vhs/vcspull-sync.gif
new file mode 100644
index 000000000..6dbebd0cd
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-sync.gif differ
diff --git a/docs/_static/demos/vhs/vcspull-sync.mp4 b/docs/_static/demos/vhs/vcspull-sync.mp4
new file mode 100644
index 000000000..97200e820
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-sync.mp4 differ
diff --git a/docs/_static/demos/vhs/vcspull-sync.tape b/docs/_static/demos/vhs/vcspull-sync.tape
new file mode 100644
index 000000000..7bc3435a0
--- /dev/null
+++ b/docs/_static/demos/vhs/vcspull-sync.tape
@@ -0,0 +1,32 @@
+Output docs/_static/demos/vhs/vcspull-sync.gif
+Output docs/_static/demos/vhs/vcspull-sync.mp4
+Output docs/_static/demos/vhs/vcspull-sync.webm
+
+Set Shell "bash"
+Set FontSize 18
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 50ms
+Set Padding 20
+Set Width 1400
+Set Height 212
+
+# Wait syncs to the prompt returning, so the demo advances when the command
+# actually finishes instead of after a guessed Sleep.
+Set WaitPattern /[$#>]\s*$/
+Set WaitTimeout 90s
+
+# The sandbox from docs/_static/demos/setup-sandbox.sh. Redirecting HOME keeps real paths
+# off screen: everything renders as ~/code/... instead of a developer's layout.
+Env HOME "/tmp/vcspull-demo"
+Env XDG_CONFIG_HOME "/tmp/vcspull-demo/.config"
+Env PATH "/tmp/vcspull-demo/bin:/usr/local/bin:/usr/bin:/bin"
+
+Hide
+Type "cd ~ && rm -rf ~/code && clear"
+Enter
+Show
+
+Type "vcspull sync -w ~/code/python --all"
+Enter
+Wait
+Sleep 2s
diff --git a/docs/_static/demos/vhs/vcspull-sync.webm b/docs/_static/demos/vhs/vcspull-sync.webm
new file mode 100644
index 000000000..8e5799b86
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-sync.webm differ
diff --git a/docs/_static/demos/vhs/vcspull-worktree.gif b/docs/_static/demos/vhs/vcspull-worktree.gif
new file mode 100644
index 000000000..59298f577
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-worktree.gif differ
diff --git a/docs/_static/demos/vhs/vcspull-worktree.mp4 b/docs/_static/demos/vhs/vcspull-worktree.mp4
new file mode 100644
index 000000000..3a15264dc
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-worktree.mp4 differ
diff --git a/docs/_static/demos/vhs/vcspull-worktree.tape b/docs/_static/demos/vhs/vcspull-worktree.tape
new file mode 100644
index 000000000..f0ab5be98
--- /dev/null
+++ b/docs/_static/demos/vhs/vcspull-worktree.tape
@@ -0,0 +1,32 @@
+Output docs/_static/demos/vhs/vcspull-worktree.gif
+Output docs/_static/demos/vhs/vcspull-worktree.mp4
+Output docs/_static/demos/vhs/vcspull-worktree.webm
+
+Set Shell "bash"
+Set FontSize 18
+Set Theme "Catppuccin Mocha"
+Set TypingSpeed 50ms
+Set Padding 20
+Set Width 1400
+Set Height 128
+
+# Wait syncs to the prompt returning, so the demo advances when the command
+# actually finishes instead of after a guessed Sleep.
+Set WaitPattern /[$#>]\s*$/
+Set WaitTimeout 90s
+
+# The sandbox from docs/_static/demos/setup-sandbox.sh. Redirecting HOME keeps real paths
+# off screen: everything renders as ~/code/... instead of a developer's layout.
+Env HOME "/tmp/vcspull-demo"
+Env XDG_CONFIG_HOME "/tmp/vcspull-demo/.config"
+Env PATH "/tmp/vcspull-demo/bin:/usr/local/bin:/usr/bin:/bin"
+
+Hide
+Type "cd ~ && clear"
+Enter
+Show
+
+Type "vcspull worktree list"
+Enter
+Wait
+Sleep 2s
diff --git a/docs/_static/demos/vhs/vcspull-worktree.webm b/docs/_static/demos/vhs/vcspull-worktree.webm
new file mode 100644
index 000000000..9302519b7
Binary files /dev/null and b/docs/_static/demos/vhs/vcspull-worktree.webm differ
diff --git a/docs/_static/vcspull-demo.gif b/docs/_static/vcspull-demo.gif
deleted file mode 100644
index f8f2efea9..000000000
Binary files a/docs/_static/vcspull-demo.gif and /dev/null differ
diff --git a/docs/cli/add.md b/docs/cli/add.md
index 0c7777457..b77cc6a3b 100644
--- a/docs/cli/add.md
+++ b/docs/cli/add.md
@@ -15,6 +15,12 @@ For bulk import from remote services ([GitHub](https://github.com),
[GitLab](https://gitlab.com), etc.), see {ref}`cli-import`.
```
+```{image} ../_static/demos/asciinema/vcspull-add.gif
+:alt: vcspull add registering a repository from its checkout
+:width: 100%
+:loading: lazy
+```
+
## Command
```{eval-rst}
diff --git a/docs/cli/discover.md b/docs/cli/discover.md
index f4ce319aa..63a9a3a1b 100644
--- a/docs/cli/discover.md
+++ b/docs/cli/discover.md
@@ -7,6 +7,12 @@ The `vcspull discover` command scans directories for existing
{ref}`configuration `. This is ideal for importing existing
workspaces or migrating from other tools.
+```{image} ../_static/demos/asciinema/vcspull-discover.gif
+:alt: vcspull discover scanning a directory tree for repositories
+:width: 100%
+:loading: lazy
+```
+
## Command
```{eval-rst}
diff --git a/docs/cli/fmt.md b/docs/cli/fmt.md
index 9c46a2ebc..96dc93416 100644
--- a/docs/cli/fmt.md
+++ b/docs/cli/fmt.md
@@ -11,6 +11,12 @@ single section so repositories are never dropped. Prefer to review duplicates
without rewriting them? Pass `--no-merge` to leave the original sections in
place while still showing a warning.
+```{image} ../_static/demos/asciinema/vcspull-fmt.gif
+:alt: vcspull fmt previewing a normalized configuration
+:width: 100%
+:loading: lazy
+```
+
## Command
```{eval-rst}
diff --git a/docs/cli/import/index.md b/docs/cli/import/index.md
index b0de6de32..70fdbe361 100644
--- a/docs/cli/import/index.md
+++ b/docs/cli/import/index.md
@@ -13,6 +13,12 @@ Supported services: **[GitHub](https://github.com)**,
**[Gitea](https://about.gitea.com/)**, **[Forgejo](https://forgejo.org)**, and
**[AWS CodeCommit](https://aws.amazon.com/codecommit/)**.
+```{image} ../../_static/demos/asciinema/vcspull-import.gif
+:alt: vcspull import fetching repositories from a remote service
+:width: 100%
+:loading: lazy
+```
+
## Command
```{eval-rst}
diff --git a/docs/cli/list.md b/docs/cli/list.md
index 74c8ebbbe..67a899f64 100644
--- a/docs/cli/list.md
+++ b/docs/cli/list.md
@@ -7,6 +7,12 @@ The `vcspull list` command displays configured repositories from your vcspull
verify your configuration,
filter repositories by patterns, and export structured data for automation.
+```{image} ../_static/demos/asciinema/vcspull-list.gif
+:alt: vcspull list showing configured repositories
+:width: 100%
+:loading: lazy
+```
+
## Command
```{eval-rst}
@@ -62,6 +68,12 @@ $ vcspull list --tree
• flask → ~/code/flask
```
+```{image} ../_static/demos/asciinema/vcspull-list-tree.gif
+:alt: vcspull list grouping repositories by workspace
+:width: 100%
+:loading: lazy
+```
+
## JSON output
Export repository information as JSON for automation and tooling:
diff --git a/docs/cli/migrate.md b/docs/cli/migrate.md
index bfdb3bd32..0e3a3b481 100644
--- a/docs/cli/migrate.md
+++ b/docs/cli/migrate.md
@@ -12,6 +12,12 @@ These keys shipped at the entry root in vcspull v1.61.0. They are still read,
but {ref}`cli-sync` warns when it encounters them. Migrating clears the warning
and keeps configs on the supported shape.
+```{image} ../_static/demos/asciinema/vcspull-migrate.gif
+:alt: vcspull migrate previewing configuration schema changes
+:width: 100%
+:loading: lazy
+```
+
## Command
```{eval-rst}
diff --git a/docs/cli/search.md b/docs/cli/search.md
index 9a53c43d9..b34560995 100644
--- a/docs/cli/search.md
+++ b/docs/cli/search.md
@@ -7,6 +7,12 @@ The `vcspull search` command looks up repositories across your vcspull
[rg](https://github.com/BurntSushi/ripgrep)-like query syntax. Queries are regex by default, can
scope to specific fields, and can emit structured JSON for automation.
+```{image} ../_static/demos/asciinema/vcspull-search.gif
+:alt: vcspull search narrowing a set of repositories to matches
+:width: 100%
+:loading: lazy
+```
+
## Command
```{eval-rst}
diff --git a/docs/cli/status.md b/docs/cli/status.md
index 2034bd080..379bbc90a 100644
--- a/docs/cli/status.md
+++ b/docs/cli/status.md
@@ -7,6 +7,12 @@ showing which repositories exist on disk, which are missing, and their
[Git](https://git-scm.com/) status. This introspection command helps verify
your local workspace matches your {ref}`configuration `.
+```{image} ../_static/demos/asciinema/vcspull-status.gif
+:alt: vcspull status reporting clean, dirty, and missing repositories
+:width: 100%
+:loading: lazy
+```
+
## Command
```{eval-rst}
diff --git a/docs/cli/sync.md b/docs/cli/sync.md
index 2e3b7837f..868999a15 100644
--- a/docs/cli/sync.md
+++ b/docs/cli/sync.md
@@ -8,6 +8,12 @@ The `vcspull sync` command clones and updates repositories defined in your
vcspull {ref}`configuration `. It's the primary command for keeping your local workspace
synchronized with remote repositories.
+```{image} ../_static/demos/asciinema/vcspull-sync.gif
+:alt: vcspull sync cloning a workspace of repositories
+:width: 100%
+:loading: lazy
+```
+
## Command
```{eval-rst}
diff --git a/docs/cli/worktree/index.md b/docs/cli/worktree/index.md
index 002d6d7df..2250254cc 100644
--- a/docs/cli/worktree/index.md
+++ b/docs/cli/worktree/index.md
@@ -9,6 +9,12 @@ create, update, and prune them.
[git worktrees]: https://git-scm.com/docs/git-worktree
+```{image} ../../_static/demos/asciinema/vcspull-worktree.gif
+:alt: vcspull worktree reporting configured worktree actions
+:width: 100%
+:loading: lazy
+```
+
## Command
```{eval-rst}
diff --git a/docs/conf.py b/docs/conf.py
index b84e5050f..0882ccc5c 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -38,6 +38,7 @@
extra_extensions=[
"sphinx_autodoc_api_style",
"sphinx_autodoc_argparse.exemplar",
+ "aspect_ratio",
],
intersphinx_mapping={
"py": ("https://docs.python.org/", None),
@@ -48,8 +49,10 @@
html_extra_path=["manifest.json"],
rediraffe_redirects="redirects.txt",
# AGENTS.md is agent guidance, not a site page; keep Sphinx from
- # treating it as an orphan document.
- exclude_patterns=["_build", "AGENTS.md", "CLAUDE.md"],
+ # treating it as an orphan document. "demos/**" keeps the recorded
+ # demo sources in the repo but out of the published _static copy —
+ # the referenced GIFs still reach the build via _images/.
+ exclude_patterns=["_build", "AGENTS.md", "CLAUDE.md", "demos/**"],
)
_gp_setup = conf.pop("setup")
diff --git a/docs/index.md b/docs/index.md
index 9eb9ba2af..08985b6ed 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -81,7 +81,8 @@ See {ref}`quickstart` for all installation methods and first steps.
$ vcspull sync --all
```
-```{image} _static/vcspull-demo.gif
+```{image} _static/demos/asciinema/vcspull-sync.gif
+:alt: vcspull sync cloning a workspace of repositories
:width: 100%
:loading: lazy
```