Metadata-Version: 2.4
Name: fastws-cli
Version: 0.0.11
Summary: Fast workspace tools for multi-repo management
Author: Jeremy Howard
License: Apache-2.0
Project-URL: Homepage, https://github.com/AnswerDotAI/fastws
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: fastcore>=2.2.6
Requires-Dist: fastgit>=0.1.2
Requires-Dist: ghapi>=2.0.6
Requires-Dist: packaging
Requires-Dist: tomli; python_version < "3.11"
Provides-Extra: dev
Requires-Dist: fastship; extra == "dev"
Requires-Dist: build; extra == "dev"
Requires-Dist: twine; extra == "dev"
Requires-Dist: pytest; extra == "dev"
Requires-Dist: pytest-asyncio; extra == "dev"
Dynamic: license-file

# fastws

Fast workspace tools for multi-repo management.

## Install

```bash
pip install fastws-cli
```

## Setup

Create a `repos.txt` file listing your repos (one per line):

```
AnswerDotAI/fastcore
AnswerDotAI/fastgit
AnswerDotAI/fastship
AnswerDotAI/fastws
```

A line may add a checkout location after the repo, for repos that live outside
the workspace root (`~` ok). Every command then reads and writes them there, and
`ws-sync` editable-installs each Python project found inside (the repo root if it
has a `pyproject.toml`, else its immediate subdirectories), so one out-of-tree
repo can hold several small packages alongside non-Python content:

```
AnswerDotAI/fastcore
jph00/private ~/private
```

## Commands

### `ws-clone`

Clone all repos from your repos file:

```bash
ws-clone
ws-clone --repos-file myrepos.txt
ws-clone --workers 8
```

### `ws-pull`

Pull updates in parallel, first asking GitHub (one batched GraphQL call) which repos' origins have actually moved, so unchanged repos are skipped silently. Repos that can't be checked (no `GITHUB_TOKEN`, detached HEAD, non-GitHub remote) are pulled the old way:

```bash
ws-pull
```

### `ws-status`

Show uncommitted changes and unpushed commits:

```bash
ws-status
ws-status --branches
```

### `ws-branches`

Check if all repos are on the expected branch:

```bash
ws-branches
ws-branches --expected develop
```

### `ws-build`

Build an sdist of each workspace project (including `repos.txt` checkouts outside the root) into a dists directory, `.dists` by default. A project is rebuilt only when a file in it is newer than its existing sdist, and older versions of a rebuilt package are pruned, so the directory always holds one current sdist per project. A failing project gets a warning without stopping the others, but the command then exits 1.

Progress goes to stderr; on success the dists path prints to stdout, so `$(ws-build)` in a script both refreshes the pool and yields its location. The result suits any resolver that takes a candidate pool, e.g. `uv pip install --find-links` or a docker build context for images that must test unreleased workspace packages:

```bash
ws-build
ws-build --force  # rebuild everything
ws-build --out /tmp/dists
```

### `ws-sync`

Sync the workspace metadata, pull local repos, and install updates. Like `ws-pull`, only repos whose GitHub origin has moved are pulled, so a typical sync is quiet and fast.
By default it uses the active venv parent as the workspace root, so you do not need to `cd` first.
Any git checkout at the root that isn't in `repos.txt` yet is added to it, whether or not it's a Python project; `_`-prefixed directories are private and left alone.
It respects `tool.uv.workspace.members` and `exclude` when scanning local projects, and if any
member directory isn't a Python project yet (no `pyproject.toml`, e.g. a fresh empty clone), it
warns and skips the `uv sync` step instead of letting uv fail on the whole workspace.

The workspace `exclude` list is auto-managed: a top-level directory that isn't a valid Python
project gets excluded automatically, and is un-excluded once it gains a real `pyproject.toml`.
A `repos.txt` checkout is auto-excluded only when it's a Cargo-only Rust crate; one with neither
`pyproject.toml` nor `Cargo.toml` is treated as a pending member awaiting scaffolding and triggers
the warning above instead. Globs, entries for missing directories, and entries for checkouts that
are still not valid Python projects are kept, and `exclude = [...]` under `[tool.fastws]` in the
workspace `pyproject.toml` declares intent the scan can't infer (e.g. keeping a real project out of
the workspace). Hand-written `[tool.uv.sources]` entries (path, git, ...) are preserved when syncing
adds new members.

Each sync also regenerates the `[patch]` entries in the workspace's `.cargo/config.toml`: one
`[patch.crates-io]` entry per local crate (nested cargo workspace members included), plus an entry
under the matching URL for each member git dep that names a local crate. This is the cargo analog
of editable installs, so every build under the workspace root uses the local checkouts. Entries
pointing outside the root, and all other config sections, are left alone. Do not commit a
`Cargo.lock` generated under these patches: it records source-less local entries that no other
machine can resolve.

At most once per day (tracked by a stamp file inside the workspace's `.git`, so git never
sees it), the sync also floats dependencies: `uv sync -U` instead of plain `uv sync`, plus
a parallel `cargo update` in every member with a `Cargo.toml`, printing what moved. Pass
`--upgrade` to force that pass regardless of when it last ran.

Before every uv sync, `ws-sync` writes `.git/fastws-cargo-key` for each member crate. The key hashes `Cargo.lock` contents and the workspace Cargo patch configuration. For Git dependencies redirected to local paths by `[patch."<url>"]`, it also hashes each patched crate's `Cargo.toml`, `build.rs`, and `src` tree, recursively. The file is rewritten only when that content changes, so projects can use `{ file = ".git/fastws-cargo-key" }` in `tool.uv.cache-keys` without rebuilding after a timestamp-only `Cargo.lock` write.

```bash
ws-sync
ws-sync --workspace ~/aai-ws
ws-sync --upgrade
```

### `ws-add`

Add a repo to `repos.txt`, then run `ws-sync`. Given `owner/repo`, it clones; given the
name of an existing local folder (e.g. one just scaffolded with `nbdev-new` or `ship-new`),
it resolves `owner/repo` from the folder's `origin` remote instead, telling you exactly
what's missing if the folder has no git repo, no GitHub origin, or no `pyproject.toml`.
Given a path outside the workspace root, the repo stays where it is and its location is
recorded in `repos.txt` (no root `pyproject.toml` needed: its packages are discovered on sync):

```bash
ws-add AnswerDotAI/fastws
ws-add answerdotai/fastws
ws-add fastws  # existing local folder, resolved via its origin remote
ws-add ~/private  # a path outside the workspace: stays where it is, recorded in repos.txt with its location
```

### `ws-remove`

Remove a repo: delete its clone, and drop it from `repos.txt` and the workspace
`pyproject.toml`, then run `uv sync`. It refuses if the directory has uncommitted
changes, unpushed commits, no `origin` remote, or isn't a clean git checkout, and
always prompts for confirmation before deleting anything:

```bash
ws-remove AnswerDotAI/fastws
ws-remove fastws  # bare folder name also works if the directory exists
```

### `ws-releases`

Report repos with commits since their newest GitHub release, so nothing reviewed sits unshipped:

```bash
ws-releases                 # at the workspace root: sweep every repo in repos.txt
ws-releases solveit         # only solveit's transitive workspace dependencies
ws-releases --nodeps        # inside a repo: just that repo
ws-releases --skip 'wip'    # extra start-of-message regex for commits that need no release
```

Run from inside a workspace checkout, the sweep narrows to that repo and its transitive workspace dependencies; `--nodeps` narrows it to the repo alone (an error when no project is in play).

Each pending repo lists its unreleased commit summaries; repos with no releases yet get one quiet `no releases:` line, and fully-released repos appear in `up to date:`. The newest release is picked by version number (publish timestamps can be out of order), and repos whose default branch isn't `main` are handled automatically.

Commits whose message matches a start-anchored regex from the skip set need no release and aren't reported. The built-in set covers version bumps and housekeeping (`bump`, `nbdev regen`, `.gitignore`, `docs`, `CI`, ...: `DEFAULT_SKIP` in `fastws.releases`); `[tool.fastws]` in the workspace root `pyproject.toml` adds to it, and names repos that should never be swept (apps that deploy rather than release):

```toml
[tool.fastws]
release_skip = ["docs only"]
release_exclude = ["solveit", "md_site"]
```

From Python (the primary interface - the CLI is a thin wrapper over `fastws.releases`):

```python
from fastws import check_releases, check_release
await check_releases()            # ReleaseReport: the repr is the report
await check_releases('solveit')   # dependency-closure mode
await check_release('mdhtml')     # one repo: list of unreleased commit summaries (None = no releases)
```
