Isolated sandbox laboratory for creating patches to optionally merge back to the source.
Patchlab spins up a Podman container with your source files, a git baseline, and an AI coding tool pre-installed. You work inside the container, and when you're done, patchlab extracts a unified diff patch that you can review and apply to your original source.
- Podman installed and running
- Node.js 18+
git clone https://github.com/Training-Datasmith/patchlab.git
cd patchlab
npm install
npm run build
npm linkThis makes the patchlab command available globally via symlink.
npm install -g patchlab# Register a tool provider (once) — see documents/configuration-based-providers.md
# Example: ~/.config/patchlab/tools/my-tool.yaml or ~/.patchlab/tools/my-tool.yaml
# List available providers
patchlab list-tools
# Create a sandbox and launch your configured tool
patchlab create . --tool my-tool
# When the tool exits, patchlab automatically extracts a patch:
# Patch extracted: /tmp/patchlab-7bc5111c.patch
# Apply with: patchlab apply /path/to/project /tmp/patchlab-7bc5111c.patch
# Review the patch
cat /tmp/patchlab-7bc5111c.patch
# Dry-run to verify it applies cleanly
patchlab apply . /tmp/patchlab-7bc5111c.patch --dry-run
# Apply it
patchlab apply . /tmp/patchlab-7bc5111c.patchCreate a sandbox from a source directory. Copies files into a Podman container, initializes a git baseline, installs dependencies, and launches an interactive AI coding tool session.
On exit, the patch is automatically extracted to a temp file.
| Option | Description |
|---|---|
--source <path> |
Additional source directory (repeatable). Sources MAY span multiple git repositories. Each mounts at ${HOME}/workspace/<mount_name>/. See Multiple sources and Working across repositories. |
--mount <name> |
Container-side mount name for the corresponding positional source. Repeatable: the Nth --mount applies to the Nth source (0 = primary, 1 = first --source, etc.). REQUIRED for every source in a multi-repository create. |
--image <image> |
Container image (default: auto-detected or node:22-slim) |
--tool <name> |
AI coding tool to use (required). See Tool Providers. |
--include <globs...> |
Glob patterns to include |
--exclude <globs...> |
Glob patterns to exclude |
--no-install |
Skip automatic npm install |
--force-rebuild |
Force fresh image build, ignoring cached images |
--context <paths...> |
Extra files/directories to inject at ${HOME}/context/ (sibling to workspace/, outside the git-managed tree) |
--include-secrets |
Copy files matched by the default secret-exclude patterns (e.g. .env, keys) into the sandbox. Off by default. |
--allow-socket-mount |
Allow Podman/Docker socket mount without prompting |
--deny-socket-mount |
Deny socket mount without prompting |
--no-interactive |
Skip interactive AI tool launch (for scripts/CI) |
--memory / --cpus / --pids-limit / --blkio-weight |
Per-invocation resource limits (see Resource limits) |
--strict-trust / --allow-untrusted-manifests |
Trust-prompt behavior for per-source tool manifests in non-interactive mode (see Supported Tools) |
A patchlab can mount more than one subpath of a single git repository. The positional argument is the primary source; each additional source is supplied via a repeatable --source <path> flag.
# Single source (unchanged): mounts at ${HOME}/workspace/<source_prefix>/
patchlab create ./src/ui --tool my-tool
# Two sources from the same repository: each mounts under its own prefix
patchlab create ./src/ui --source ./src/server --tool my-toolSources MAY span multiple git repositories. When every source resolves to the same repository_root, mount names default to each source's source_prefix (no --mount flag needed). When sources span two or more distinct repositories, every source MUST carry an explicit --mount <name> flag (see Working across repositories) because source_prefix is per-repository — the same src/ could appear in two repos without disambiguation.
Each mount preserves the host repo's relative path under the container's workspace root: ./src/ui lands at ${HOME}/workspace/src/ui/, and ./src/server lands at ${HOME}/workspace/src/server/. The container's git baseline commits the entire ${HOME}/workspace/ tree, so the patchlab branch records changes at their repo-relative paths — patchlab apply requires no path translation.
Validation rules at create time (all rejected with the offending source paths named):
- Source-prefix uniqueness within a repository — two sources within ONE repository whose
source_prefixmatches (case-insensitive ASCII) are rejected. Cross-repository same-prefix is accepted; the explicit--mountrequirement disambiguates the container path. - Mount-name uniqueness (global) — two sources with the same
mount_nameare rejected regardless of which repository each belongs to. Mount names are global because they share the${HOME}/workspace/namespace. - Multi-repository mount-name explicitness — when sources span two or more repositories, every source MUST be supplied with
--mount <name>. Missing--mountis rejected before any sandbox or branch work begins. - Empty-prefix exclusivity within a repository — a source at a repository root may only appear when it is the only source from THAT repository.
- No nested-prefix overlap within a repository — path-component-aware:
./srcplus./src/uiwithin ONE repository is rejected (nested);./srcplus./src2is accepted (siblings); cross-repository nested-prefix is accepted because each repository has an independent prefix namespace.
A patchlab can span two or more distinct host git repositories. The CLI flag layout: pass every source positionally or via --source, paired with an explicit --mount <name> for each. Mount names appear top-level in the container's ${HOME}/workspace/; the host-side commit per repository lands on each repository's own patchlab/{id} branch.
# Cross-repository patchlab spanning /repo-a/src and /repo-b/lib.
# Both --mount flags are REQUIRED.
patchlab create /repo-a/src --mount frontend \
--source /repo-b/lib --mount backend --tool my-toolThe container sees:
${HOME}/workspace/
├── frontend/ # contents of /repo-a/src
└── backend/ # contents of /repo-b/lib
When the session exits, patchlab fans out the staged diff per repository: each repository's patchlab/{id} branch gets one commit covering the changes under that repository's mount(s). Per-repository outcomes (commit SHA or fallback patch path) are recorded in the session metadata under commit_shas and fallback_patches.
After a successful session, apply to one repository at a time. The --repository <path> flag is REQUIRED for multi-repository patchlabs:
# Apply /repo-a's session commits to the current branch in /repo-a.
cd /repo-a
patchlab apply <patchlab-id> --repository /repo-a
# Then apply /repo-b's session commits in /repo-b.
cd /repo-b
patchlab apply <patchlab-id> --repository /repo-bEach repository's apply is independent — applying to /repo-a does not affect /repo-b. Sessions where the chosen repository's commit_shas entry is null (the session didn't touch that repository's mounts) are silently skipped.
patchlab inspect <id> enumerates every repository the patchlab spans, including each repository's branch state and per-session per-repository commit/fallback display. patchlab patch <id> without --repository on a multi-repository patchlab emits each repository's cumulative diff in turn, separated by # === Patch for <repository_root> === comment headers that git apply ignores.
patchlab destroy <id> deletes the patchlab/{id} branch in EVERY repository the patchlab spans. The destroy may partially succeed: if any repository's branch has unapplied session commits and you decline the per-repository confirmation, that repository's branch is left intact and the archive directory is retained for recovery (re-run with --force or manually delete the branches).
Per-repository trust prompts: when one or more of the spanned repositories has a <repository_root>/.patchlab/tools/*.yaml configured-tool manifest that has never been confirmed (or whose contents have changed since last confirmation), patchlab create prompts for trust once per repository before any branch is created. Prompts run sequentially in source-flag order:
$ patchlab create /repo-a/src --mount a --source /repo-b/src --mount b --tool aider
patchlab: per-source tool manifests detected in /repo-a:
- /repo-a/.patchlab/tools/aider.yaml
launch_command: ['aider']
base_image: docker.io/library/python:3.12-slim
authentication.method: file_copy (1 host file: /repo-a/.aider/config.yaml)
dockerfile.install: (none)
Confirm trust for /repo-a's per-source manifests? [y/N] y
patchlab: per-source tool manifests detected in /repo-b:
- /repo-b/.patchlab/tools/copilot.yaml
launch_command: ['gh', 'copilot', 'cli']
base_image: docker.io/library/node:22-slim
authentication.method: environment_variables (GH_TOKEN)
dockerfile.install: (none)
Confirm trust for /repo-b's per-source manifests? [y/N] y
Sandbox created: <patchlab-id>
Decline any repository's prompt and the entire create aborts (no branches created in any repository). Repositories already confirmed at the same content hash short-circuit without prompting. For full details — cross-repository tool-name collision rules, per-repository file_copy.host containment, and the per-source configuration composition lattice — see Multi-repository patchlabs in the configuration-based-providers documentation.
Resume a patchlab in a fresh sandbox from the branch tip plus a host overlay. Each resume opens a new session on the same patchlab id, restores the prior session's conversation state via the tool provider, and re-injects the merged context bundle.
| Option | Description |
|---|---|
--context <paths...> |
Additional context files to merge with the previous session's context |
--no-install |
Skip automatic dependency install |
--no-interactive |
Skip interactive AI tool launch (for scripts/CI) |
--memory / --cpus / --pids-limit / --blkio-weight |
Resource-limit overrides for this resume (otherwise inherited from the prior session) |
List all active sandboxes with their status.
Show detailed JSON metadata for a sandbox.
Show changed files in a sandbox (+ added, ~ modified, - deleted).
Generate a unified diff patch from sandbox changes. Prints to stdout by default.
| Option | Description |
|---|---|
-o, --output <file> |
Write patch to a file instead of stdout |
Apply a patch file to a target directory.
| Option | Description |
|---|---|
--dry-run |
Validate the patch without modifying files |
Execute a command inside a running sandbox container.
Build a patchlab-compatible container image with tools pre-installed. Auto-detects your project's language (from composer.json, package.json, etc.) and required system packages (including PHP extensions from ext-* entries in require, require-dev, and suggest).
| Option | Description |
|---|---|
--base <image> |
Base image (default: auto-detected) |
--tools <tools...> |
Tools to install (required). Run patchlab list-tools to see available names. |
--tag <tag> |
Image tag |
--exclude-suggested |
Skip PHP extensions listed in composer.json suggest (default: include them) |
List locally available patchlab-compatible images.
List registered tool providers from user-global manifests (~/.config/patchlab/tools/ and ~/.patchlab/tools/). Pass a source path to also include per-source manifests under <repository_root>/.patchlab/tools/; unconfirmed per-source manifests are annotated without firing the trust prompt.
Destroy a sandbox and its container.
Remove stale sandboxes (and, for multi-repository patchlabs, scan each repository's orphan patchlab/{id} branches).
| Option | Description |
|---|---|
--older-than <days> |
Remove sandboxes older than N days (default: 7) |
--no-missing |
Skip sandboxes with missing containers |
--dry-run |
Show what would be removed without removing |
--force |
Skip confirmation prompt |
Every sandbox is created with podman resource-limit flags applied. Defaults are computed at runtime from the host's capacity (assuming one sandbox at a time):
--memory: 75% of total host RAM, rounded down to the nearest 256 MiB, with a 1 GiB floor.--cpus:max(1, host_cpu_count - 1)as a decimal (reserves one core for the host).--pids-limit: fixed1024.--blkio-weight: omitted by default; podman's own neutral default (500) applies.
Override on patchlab create or patchlab resume with explicit flags:
patchlab create ./source --tool my-tool --memory 4g --cpus 2.0 --pids-limit 1024 --blkio-weight 500Values use podman's native formats: memory accepts an integer optionally suffixed with b/k/m/g; cpus accepts a decimal; pids-limit accepts a non-negative integer; blkio-weight accepts an integer in [10, 1000].
Pass 0 to --memory, --cpus, or --pids-limit to opt out of enforcement for that field (the flag is omitted from podman create). Negative values are rejected at parse time. There is no "unlimited" form for --blkio-weight because it's a relative weight, not a cap.
Resolved values persist into the sandbox's per-session metadata. A subsequent bare patchlab resume <id> inherits the create-time choice (including an explicit --memory 0 opt-out); pass new flags on resume to override per-field.
Set persistent defaults without re-typing flags by creating ~/.patchlab/configuration.yaml (user-global) or <source>/.patchlab/configuration.yaml (per-source). The full schema, precedence ladder, and per-source clamping rules are in documents/configuration.md.
On Linux hosts where rootless Podman cannot enforce limits (cgroup v2 controllers not delegated), patchlab emits a one-time stderr warning at first patchlab create and continues. The warning links to documents/configuration.md#cgroup-delegation.
Patchlab reads optional YAML configuration files at two locations:
~/.patchlab/configuration.yaml— user-global, applies to every sandbox you create.<repository_root>/.patchlab/configuration.yaml— per-repository (formerly per-source), applies to sandboxes created from any source under this git repository, including multi-source patchlabs. Per-repository values are clamped so they can only tighten the user-global / default upper bound (no trust prompt needed).
In v1 the schema accepts one top-level key, resource_limits, with the four fields from the Resource limits section. See documents/configuration.md for the full schema, precedence ladder, and failure-mode catalog.
Place a .patchlab.json file in your project root to configure sandbox behavior:
{
"requirements": {
"system_packages": ["postgres-client", "redis-tools"],
"volume_mounts": ["/host/path:/container/path"],
"environment_variables": {
"DATABASE_URL": "postgres://localhost:5432/test"
}
},
"ignore_detected": ["redis-tools"],
"allow_socket_mount": true
}| Field | Description |
|---|---|
requirements.system_packages |
Additional system packages to install in the container |
requirements.volume_mounts |
Host paths to mount into the container |
requirements.environment_variables |
Environment variables to set in the container |
ignore_detected |
Auto-detected requirements to skip |
allow_socket_mount |
Allow Podman/Docker socket mount without prompting |
sources |
Declare a stable set of source directories (see below) |
The sources array lets you declare a stable set of source directories once so that patchlab create (run with no source arguments) builds the sandbox from those sources automatically. This is useful when working from a workspace directory that holds several git repositories as siblings.
{
"sources": [
"patchlab",
"other-library"
]
}Run from the directory containing this file:
patchlab create --tool my-tool # uses sources from .patchlab.json--tool is always required on the command line; .patchlab.json does not store a default tool.
String entries — each string is both a relative path to the source directory (resolved from the .patchlab.json file's directory) and the mount name under workspace/ inside the sandbox. For example, "patchlab" mounts the patchlab/ directory at ~/workspace/patchlab/ in the sandbox. Use a subdirectory path ("patchlab/src") to limit what is included; the git repository root is still auto-discovered from the path, and the mount name is the full string (patchlab/src).
Object entries — for cases where the path and mount name should differ:
{
"sources": [
{ "path": "repos/my-project", "mount": "project" },
{ "path": "repos/shared-lib", "mount": "lib" }
]
}Object entries behave identically to passing --source <path> --mount <mount> on the command line.
Paths are resolved relative to the .patchlab.json file's directory. Absolute paths are accepted as-is.
Discovery — patchlab looks for .patchlab.json in the current working directory first, then in the git repository root of the CWD (when the CWD is inside a git repo and its git root is a different directory). The first file found with a non-empty sources array wins.
CLI preempts file — if any source-related CLI argument is provided (the positional source path, --source, or --mount), the sources field in .patchlab.json is ignored entirely for that invocation. CLI arguments always take precedence.
Multi-repository sources — string entries always carry an explicit mount name (the string itself), so they satisfy the multi-repository mount-name requirement without needing --mount flags. Two strings that would resolve to the same mount name (e.g., "repo-a/src" and "repo-b/src") are rejected with a clear collision error.
Patchlab ships no built-in tool providers. Register providers via YAML manifests under ~/.config/patchlab/tools/ or ~/.patchlab/tools/ (user-global) or <repository>/.patchlab/tools/ (per-source). Run patchlab list-tools to see what is available, then pass --tool <name> to create.
See documents/configuration-based-providers.md for the manifest format.
Patchlab tags each built image with a per-tool state label (biz.ecartz.patchlab.tool.<tool>) that records what authentication was done at build time. The four values are:
absent: the image has no per-tool label for this tool.installed: the tool binary is in the image; no authentication was injected at build time.authenticated: the tool binary AND credentials are baked into the image filesystem (file-copy authentication).ready: the tool binary is in the image, andinject_authenticationran at build time, but the credentials are NOT in the image — they are supplied at container-create time via environment variable.
patchlab list-tools
patchlab create . --tool my-toolPatchlab follows the strict Unix convention for output streams:
- stdout carries the command's pipeable answer — list rows from
patchlab list, the JSON dump frompatchlab inspect, the patch content frompatchlab patch, the file-path lines frompatchlab diff, the image rows frompatchlab images. - stderr carries everything else — progress messages ("Building patchlab image…", "Using cached image: …"), action confirmations ("Sandbox created: abc123", "Image built: …"), warnings, and errors.
Examples:
# Pipe only the structured result — no progress chatter clutters the pipe
patchlab list | awk '{print $1}'
# Capture progress and warnings to a file while keeping the terminal clean for results
patchlab create ./source --tool my-tool 2> diag.logNote for users upgrading from a previous release: action confirmations such as Sandbox created: <id> previously appeared on stdout. They now appear on stderr alongside other progress output. Scripts that captured the structured answer from patchlab list, patchlab inspect, patchlab patch, or patchlab diff are unaffected — those commands continue to emit their canonical answer on stdout. If a future scripting need surfaces a requirement to capture just an ID (or other machine-readable value), individual commands will gain explicit modes (e.g. --quiet, --format json) that route through stdout.
Patchlab has an opt-in verbose-diagnostic channel that is off by default. Activate it with either:
PATCHLAB_VERBOSEenvironment variable — persistent across invocations within a shell session. Recognized "off" values: unset, the empty string,0, case-insensitivefalse, and case-insensitiveoff. Any other non-empty value activates verbose mode. Examples:PATCHLAB_VERBOSE=1,PATCHLAB_VERBOSE=on,PATCHLAB_VERBOSE=yesall turn it on;PATCHLAB_VERBOSE=0,PATCHLAB_VERBOSE=false,PATCHLAB_VERBOSE=offkeep it off.--verboseCLI flag — per-invocation. Activates verbose mode just for the current command. Accepted in any position relative to the subcommand:patchlab --verbose create ./source --tool my-tool,patchlab create --verbose ./source --tool my-tool, andpatchlab create ./source --tool my-tool --verboseare all equivalent.
Precedence: CLI wins. If --verbose is present, verbose mode is on regardless of the env var. If --verbose is absent, the env var is consulted. There is no --no-verbose flag in this release — users with PATCHLAB_VERBOSE=1 exported who want one quiet command can use shell syntax: PATCHLAB_VERBOSE= patchlab create ./source --tool my-tool.
Verbose output goes to stderr (alongside other diagnostic output). Every emitted line is prefixed with the literal patchlab[verbose]: so you can filter:
# Capture only verbose lines to a file
patchlab create --verbose ./source --tool my-tool 2> verbose.log
# Show only verbose lines on the terminal
patchlab create --verbose ./source --tool my-tool 2>&1 >/dev/null | grep '^patchlab\[verbose\]:'
# Strip verbose lines from a combined log
patchlab create --verbose ./source --tool my-tool 2>&1 | grep -v '^patchlab\[verbose\]:'The --verbose flag is reserved at the program level; subcommands SHALL NOT define their own --verbose flag with a different meaning.
- Create: Patchlab copies your source files into a Podman container, initializes a git baseline commit, and optionally installs npm dependencies.
- Work: Your chosen AI coding tool runs inside the container with full access to modify files.
- Extract: On exit, patchlab generates a unified diff against the baseline.
- Apply: You review the patch and apply it to your source directory when ready.
Cached images are reused across sandboxes to speed up subsequent creates. Patchlab auto-detects project language, required system packages, and services from your project files.