0082: A workspace is a worktree and a site
Status: accepted
Context
Trying a branch while the site keeps running on another, or letting a coding agent work on one, means a second checkout, and a second checkout only boots with the site's ignored files (.env, vendor, node_modules, a SQLite file), its own address and a database it can change without touching the site's. A repository is untrusted input (0034): making a checkout runs post-checkout, and merging runs four more hooks. Short Leash already writes to Git through one runner (0081) and trusts folders through the project file's trust (0078). The website already has a Workspace feature (terminals, scratchpads, changes and tasks), so this one needs a name of its own.
Decision
-
Address:
<branch>-<site>.test, no routing change. The branch slug is the branch name in the characters a label may hold (sites::label: lower case,[a-z0-9-], no leading, trailing or doubled dashes). When the whole name would pass 63 characters, the slug is cut and ends with the first eight hex digits of the branch name's SHA-256, so long branches that start alike stay apart; a site name so long that no hash fits gets a plain cut, and the taken-name check below catches a clash. A branch with no letters or numbers left, or a site name too long to leave any room, is refused with what to do. A name that is already a site's, an alias or a Short Leash service's address is refused, naming who has it, so two branches with the same slug (fix/login,fix-login) never share an address; the name is checked again just before the branch workspace is recorded. Why not<branch>.<site>:sites::find_by_hostalready sends every subdomain of a site to it (api.shop.testgoes toshop), and a site name can't hold a dot (site_name). Dotted names would need a routing change, and would hide the site's own subdomains whenever a branch matched one (api, a tenant). A dash name gets its own leaf certificate with*.<name>(ca.rs), soapi.fix-login-shop.testworks like any site's subdomain, and host-only cookies stay apart, whereDomain=.shop.testcookies would cross between the site and its branch workspaces. Rejected: dotted names (routing change, shadowing, shared cookies) and--as the separator (labels with--in places 3 and 4 are reserved for IDNA). -
Code:
git worktree, with the ignored files cloned in. The worktree is at<repository root's parent>/.<repository folder name>-workspaces/<branch slug>, with the-workspacessuffix inconfig::defaults. The folder's slug is cut to 63 characters on its own, and one Windows would read as a device (nul,com1) ends with the hash too. Beside the repository keeps it on the same volume, so clones work. The dot keeps it out of parked-folder scans (sites::subfoldersskips dot folders) and outside the site's folder, so editors and watchers don't index it twice. For a site in a subfolder of its repository, the branch workspace's site folder is the worktree plus the same subfolder. The branch is one of three: a local branch, a remote-tracking branch Git already has on exactly one remote (it becomes a local branch tracking it; one on several remotes is refused), or a new branch from a base. The base is the site's current branch, which must have a commit, unless one is chosen, and a chosen one must be a local branch. Short Leash never fetches to find one. A branch checked out in the site or in another worktree,HEAD, a name starting with-, and a namegit check-ref-format --branchrejects or rewrites are refused, and positional arguments follow--end-of-options. An add refuses a target folder that already exists, and checks again in Git's turn that it's still missing and that the branch is still there (or still missing, for a new one), so removal and rollback never touch a folder Short Leash didn't make.- A bare worktree lacks
.env,vendor,node_modules,storageand the SQLite file, so Short Leash copies every pathgit ls-files --others --ignored --exclude-standard --directorylists in the site's repository, never anything under.gitor the workspaces folder, and never over a file the checkout already has. It skips files that would make the branch workspace use the site's database or address whatever its env says, and files that belong to one running copy (workspaces::seed::SKIPandSKIP_SUFFIXES): Laravel's cached config, routes, events, packages and services inbootstrap/cache, Symfony'svar/cache, Vite'spublic/hot,storage/logs, Laravel's framework cache, sessions and compiled views,node_modules/.cache,node_modules/.vite,.next/cache,.DS_Store, files ending in.log, and SQLite's-shmfiles. The skipped paths count from the repository root and from the site's subfolder, so a site inweb/losesweb/bootstrap/cache/config.phptoo. A listed folder that holds a skipped path is walked into, so the rest of it still comes across. A folder that holds none is copied whole only when its destination doesn't exist yet; otherwise Short Leash walks into it entry by entry, with the link check below. - On macOS what can be copied whole goes to
/bin/cp -n -c -R -p --in batches, sources and destination given as absolute paths, so no file name is read as an option (APFS clonefile: copy-on-write, near-instant, and no extra space until a file changes;cpmakes a plain copy where it can't clone).-Rwithout-Lcopies links as links, and-nnever overwrites. Whencpstops part way, that batch is copied again entry by entry. On Windows, with no APFS, Short Leash copies file by file withstd::fs::copy: a plain copy, which Windows 11 24H2 and later makes a block clone on a ReFS volume such as a Dev Drive. Links are made again as links; one Windows won't let Short Leash make is counted and reported, never followed. Walking into a folder never follows a link, on either system. A file or folder Short Leash can't read, or an entry that's a file on one side and a folder on the other, is left out with a note in the job's log; a real error, such as a full disk, fails the add. Progress shows as a file count. - A symbolic link among the ignored paths (one Git lists, or one inside a folder Short Leash walks into) that points into the site's repository by an absolute path, such as the
public/storagelink Laravel'sstorage:linkmakes, is made again pointing at the same place in the branch workspace, so the branch workspace never writes into the site's folder through it. Other links are kept as they are. - Destinations are checked without following links. The branch's tracked files come from the repository, so the checkout can hold a link where the site has an ignored folder (
storagetracked as a link to a folder outside the worktree, say). Before an entry is written, every part of its destination path inside the worktree is checked withsymlink_metadata, never following a link: a link anywhere on the path, or where the copy would go, means the entry is skipped with a note in the job's log, never written through.seed_never_writes_through_a_link_in_the_checkouttests this, including a branch that tracksvendor/xas a link to a folder outside the worktree while the site ignoresvendor/. - Rejected: a full-folder APFS clone without a worktree (no shared branch, so merging back means juggling patches), and a bare worktree (the site wouldn't boot).
- A bare worktree lacks
-
Database, chosen per branch workspace: Shared, Copy or Empty. Short Leash finds the site's database the way
leash dbdoes, from its.env(services::databases::service_for_envandname_for_env): a database URL first (DB_URL, thenDATABASE_URL), thenDB_CONNECTION(mysql,mariadb,pgsqlorsqlite),DB_HOST,DB_PORTandDB_DATABASE, matched to a Short Leash service by port. SQLite isDB_CONNECTION=sqlite, withDB_DATABASE(relative to the site's folder) or Laravel'sdatabase/database.sqlite; the file counts as in the site's folder only when its path stays inside it and no part of that path is a link.- Shared changes nothing for a server database, and for SQLite points
DB_DATABASEat the site's file. - Copy copies the site's database into
services::database_name(<branch workspace name>)(fix_login_shop) on the same service withServer::copy_database. That refuses a name the server already has, backs the site's database up into a new private scratch folder, restores the backup into the new name (the restore makes the database, so nothing is created first), and removes the scratch folder whatever happened, a panic included. It'smysqldump --single-transactionfor MySQL andmariadb-dumpfor MariaDB (the MySQL family,Kind::is_mysql_family), andpg_dump -Fcwithpg_restorefor PostgreSQL. For SQLite it's the file and its-wal, at the same place in the branch workspace, unless seeding already brought them. - Empty makes the new database with
Server::createand leaves it empty. For SQLite it makes an empty file in the branch workspace, replacing the copy seeding brought and removing its-wal,-journaland-shm, so none of the site's data comes back. A SQLite file the add writes is refused, never written through, when a link is on its path in the checkout. - A service that's off is turned on first, as
leash db createdoes. When a database of that name is already on the server, the add is refused rather than taking it over, because removing the branch workspace later would drop it: before the job when the service runs (Server::find), and again by the copy or the create once it's on. - Shared is the only choice, with the reason beside it, for a site whose database Short Leash can't read, one on another computer or a service Short Leash doesn't run, one named by a database URL with a password (even an empty one) or a query setting that looks like a secret in it, a SQLite database in memory, and a SQLite
DB_DATABASEwith an@,://or a URL scheme in it, which isn't taken as a path. A SQLite file outside the site's folder offers Shared and Empty.
- Shared changes nothing for a server database, and for SQLite points
-
Env injection, and the branch workspace's own
.envfor Copy and Empty. The site's.envis never rewritten. The branch workspace'sSiteSettings.envgets:DB_DATABASEfor Copy and Empty (the new database's name, or for SQLite the absolute path of the branch workspace's file: at the same place as the site's when that's in the site's folder, otherwise, for Empty, under the same name at the top of the branch workspace), and for SQLite Shared (the absolute path of the site's file). When the site's database comes from a database URL (DB_URLorDATABASE_URL), which wins overDB_DATABASE, that key is set as well, to the URL with the new database's name as its path, so a framework gets the new database whichever of the two it reads. A URL with a user name and no password (whatleash db urlprints) isn't set here: its user name never goes intoconfig.toml, so it changes only in the branch workspace's own.env(below).- every value in
.envand in the copied settings' env that names the site's domain or one of its subdomains as a host, rewritten to the branch workspace's domain (APP_URL,SANCTUM_STATEFUL_DOMAINS,SESSION_DOMAIN, …). The domain counts where the character before it is the start,/,.,,or a space, and the one after it the end,/,:,,or a space, in any case. - Never a value with userinfo or an
@in it, oneproject::secrets::looks_secretrefuses or whose query has a setting that looks like a secret, one over 4,096 bytes or with a control character, or a key a site can't set, because site settings live inconfig.toml(invariant 9). The one exception to the@rule is a path Short Leash builds from its own folders (a SQLite file's), since a folder name is no userinfo; text from.envnever gets it (decision 3). A rewritten domain value that breaks a rule is left out. A database value that breaks one refuses the add, saying which rule, because leaving it out would leave the branch workspace on the site's database. A site whose database URL has a password gets Shared only, since the URL can't be carried over without it.
Env reaches PHP-FPM requests, the site's server and processes, Short Leash terminals, scratchpads and
leash phprun in the branch workspace's folder (Engine::site_for_dirpicks the branch workspace there: its folder sits beside the site's, not inside it, so it's the only match). Dotenv loaders (Laravel, Symfony, python-dotenv, Ruby's dotenv, Vite) don't overwrite variables already set.For Copy and Empty, the same database and domain values are also written into the branch workspace's own
.env(workspaces::database::dotenv_changesandrewrite_dotenv), so a PHP run from outside Short Leash, such as Homebrew'sphp artisan migrate:freshin an editor's terminal, gets the branch workspace's database rather than wiping the site's. That file is Short Leash's own copy, made by seeding seconds earlier, not the person's: rewriting it carries no secret (the file already holds its values) and changes nothing of theirs, so invariant 9 and the rule above hold, the rule being "never rewrite the site's.env". Only the lines that set those keys change, atomically, with every other line, comment and the order kept, and a key the file didn't set is added at the end. It's opened without following a link, under no linked folder, and one Git tracks (the branch's own file, not a copy) is left alone; then the add's log says why, and its last step names the site's database when a command outside Short Leash would reach it, and the site's address otherwise. Otherwise the last step says "Its .env names its own database and address." Shared rewrites nothing: the branch workspace uses the site's database and.envalready names it. Injection stays as well, for frameworks that don't read.env. A database URL with a user name and no password changes only in that.env, the user name kept (workspaces::database::dotenv_only_env), andDB_DATABASEalone is injected. Since the URL wins overDB_DATABASE, a.envleft as it was would keep the branch workspace on the site's database, so then the add fails before it records anything, undoes what it made, and says to choose Shared or take the user name out of the URL. -
Settings. At creation the branch workspace gets a copy of Short Leash's own settings for the site, its entry in
config.toml(Config.sites[name], or the defaults when it has none), neverSite.settings, which has the site's project files laid over it. So the site'sleash.tomlprocesses and env never become the branch workspace's own settings: its own project files act only after its own trust check (decision 7).workspace_copies_only_leashs_own_settingstests this. The copy leaves outaliasesand hasstopped = falseand every process'skeep_runningandstart_on_visitoff, so queue workers and dev servers don't run twice and the first visit starts nothing until you start them. When its folder is trusted (below), itsleash.tomlprocesses apply withkeep_runningandstart_on_visitoff too, until you start them on its Processes page: an inherited grant, like every first grant, starts nothing (0078), and this keeps leashd's next start from starting them either. Later changes to the site don't carry over. It's listed asSource::Workspace(a link of the same name wins over it, and it wins over a parked folder) and kept inConfig.workspaces: its name, site, branch, base, repository, the repository's Git folder that every worktree shares, the folder name Git chose for it underworktrees/, its folder and subfolder, the database choice, the database and service the add made, whether the add made the branch and the database, and the removal steps finished so far, with no secret in it. A branch workspace can't be renamed or unlinked on its own: "fix-login-shop is a branch workspace of shop, so it can't be unlinked on its own. Remove it instead with leash workspace remove fix-login-shop." Renaming its site moves its branch workspaces along under the new name, keeping their addresses. While a site's folder exists, unlinking it, or unparking the folder that holds it, is refused when it has branch workspaces, naming them and the command: "Remove it first with leash workspace remove fix-login-shop.", or with<name>when there are several. A site whose folder is gone can't be kept: a parked one disappears and a linked one can be unlinked. Its branch workspaces then stay listed with "Its site is gone", keep serving and can be removed (decision 9), and merging one is refused, since a merge runs in the site's folder. A site counts as a branch workspace's site only while it's served from the folder the branch workspace was made from (the recorded repository plus its subfolder): a site that later takes the name in another folder isn't it. -
Git safety. Reads use 0034's runner (
changes::Git::command).worktree add,merge,merge --abort,push,worktree pruneand rollback'sbranch -dgo through 0081's runner:changes::write::run, async, built withWriteOpts::new(git_bin, hooks, cancel), one write at a time, never into submodules, never fetching unless it's a push, and with its errors (HookFailed,NeedsSignIn,Locked,NeedsTrust) turned into messages that say what to do next. No force push, rebase or branch switching in the site's checkout.- Hooks run only when the repository is trusted, through
changes::hooks::check_asking_every_hookandHookChoice, with Changes' rules: trust is the repository root's (project::trust::state, whichEngine::folder_trustwraps),HookPolicy::Skippointscore.hooksPathat a folder that can't exist and empties everyhook.*setting, never--no-verify(which leavespost-checkout,prepare-commit-msgandpost-mergerunning), and an untrusted repository whose own Git config has settings that need trust (changes::hooks::needs_trust) gets no write at all until it's trusted. The hooks counted arepost-checkoutandreference-transactionforworktree add(changes::hooks::ADD_HOOKS);pre-merge-commit,prepare-commit-msg,commit-msg,post-mergeandreference-transactionformerge(MERGE_HOOKS); and a push's (PUSH_HOOKS:pre-pushandreference-transaction) when the merge pushes after it. These lists decide when to ask. The trust digest covers every hook any Short Leash write runs, Changes' included (changes::hooks::write_hooks), and the add's and merge's trust questions list all of those the repository has, so one grant's question shows everything it lets run, and a hook added after the question was shown makes the grant ask again, as does one added after the grant. - Add never asks about hooks: in an untrusted repository it runs
worktree addwithout them and the log says so. With settings that need trust it's refused with Changes' trust dialog (Trust repository), which trusts the repository root (POST /v1/sites/{name}/changes/trust). - Merge asks in an untrusted repository with hooks: Trust and run hooks, Merge without hooks, or cancel, carrying the fingerprint and digest of what it showed, as a commit does. The push after it takes the same choice. On the wire it's Changes'
HookName(ask,trustAndRun,skip, withfingerprintanddigestfortrustAndRun), andleash workspace merge --hooksmaps onto it. Right before the merge, in Git's turn, Short Leash checks that the trust and settings it decided on haven't changed, and checks again in the push's own turn (decision 8). - Merge's abort and prune run without hooks. When an untrusted repository's settings now need trust, removal still finishes, skips the prune and says Git lists the worktree until
git worktree pruneruns in a terminal. - Trust is given only by a person, in the app or at a terminal: no MCP or chat tool trusts or sends
trustAndRun(invariant 15). At a terminal,leash workspace merge --hooks trustuses the one promptleash changes commit|push --trustuses (0078): it lists what the grant covers (the folder, hooks, Git settings, processes, services and env, with the fingerprint) and asksTrust ~/Sites/shop and run its hooks? [y/N];--hooks autoasks with all three choices, andleash workspace addasks to trust a repository whose settings need it. Without a terminal each refuses and says to run it in one or trust the repository in Short Leash's dialog;--yesnever answers a trust question. What leashd and the repository say (a branch name, Git's output) is printed with hidden characters as codes.
- Hooks run only when the repository is trusted, through
-
Trust is inherited only when nothing that acts changed. Once the files Git ignores are copied (
leash.local.tomlis part of what's compared), Short Leash callsEngine::inherit_trust(repository root, worktree root), and, for a site in a subfolder,Engine::inherit_trust(site folder, branch workspace's site folder)as well. Each isproject::trust::inherit(&Paths, from, to)under the trust lock: it grantsto, with its own identity, only whenfromis trusted andto's project files fingerprint the same. The worktree root is a repository root like the site's, so the fingerprint's hooks flag matches, and a grant there lets Changes run hooks in the branch workspace. The inherited grant carries the site's digest of hooks and Git settings (0081), not the branch's: when the branch has a hook of its own or a setting that needs trust that the site didn't, the branch workspace's Git actions ask first. A branch whoseleash.tomlruns another command, starts a process another way, or changes a service or the env asks like any folder. The fingerprint covers what the project files make Short Leash run, not the code those commands run: a process whose command is the same (php artisan queue:work) inherits trust even when the branch changed the script behind it. Trust covers the repository's code, as it does for hooks, whose names it covers and not their contents. -
Merge back runs in the site's own folder. It refuses, changing nothing, when the site is gone or its folder's repository isn't the recorded one (its Git folder, from
git rev-parse --git-common-dir, is compared with the one the add recorded), when the branch or the base is no longer a local branch there, when Short Leash can't read the branch workspace's changes, when the branch workspace has uncommitted tracked changes, when the site's checkout is in the middle of a Git operation (a merge, cherry-pick or revert waiting to be concluded, a cherry-pick or revert of several commits with some still to go, which keeps Git'ssequencerfolder after itsCHERRY_PICK_HEADis gone, a rebase, or agit amsession:workspaces::git::operation_in_progress, each file found withrev-parse --git-path, and checked before the branch and status, since the fix is to finish or abort it there, never for Short Leash to conclude or abort it), when the site isn't on the branch workspace's base branch, when the site has uncommitted tracked changes, when the branch has nothing the base lacks (and when Git can't count its commits, it says so rather than calling it empty), and, when it's to push, when the base has no upstream. The checks run again in Git's turn, right before the merge. It runsgit merge --no-ff --no-edit --end-of-options <branch>. When the merge it started stops part way, on a conflict or a hook that refused the merge commit, it runsgit merge --abortand names the conflicting files, leaving the site's repository as it was; a merge that was already under way is never aborted. If Short Leash quits in the middle of a merge, the next merge's in-progress check names it. Then, if chosen, it pushes the base branch with Changes' push (0081: to its upstream, with an explicit refspec, never forced, never tags). The push takes its own Git turn, and in that turn checks again that the site is still on the base and that the repository's trust and Git settings still allow the hooks the merge decided on, and honors a cancel that came as the turn began: when either changed, nothing is pushed, the merge stays, and the message says to push the base from the site's Changes page. After that, if chosen, it removes the branch workspace as Remove branch workspace does, without forcing, after the same confirmation. -
Removal checks first, and when any check fails, nothing is stopped, trashed or dropped, and the message says which check failed. The checks work without the site's folder or the repository, so a branch workspace whose site is gone can be removed too:
- the site served under its name is this branch workspace (
Source::Workspace, served from the recorded folder), so a site that took the name is never stopped or forgotten, and its recorded subfolder doesn't leave the worktree; - the folder, with links and
..resolved, sits directly in the recorded.<repository folder name>-workspaces/folder and is the linked worktree the add made: its.gitis a plain file whosegitdir:namesworktrees/<admin>in the recorded Git folder,<admin>being the name Git chose and the add recorded, and while the repository is there, Git's own record (worktrees/<admin>/gitdir) points back at the folder. Relative paths (worktree.useRelativePaths) count as written. The main worktree has a.gitfolder, so this proves the folder is a linked worktree without running Git. A recorded folder that's no longer there counts as moved to the Trash; a link at it is refused; - no other site's folder is this folder or inside it;
- the branch workspace has no uncommitted tracked changes, unless the person confirmed removing them. When Git can't read its changes (its repository is gone, say), what would go isn't known, and removing it needs an explicit confirm that names why;
- the database to drop, when there is one, is exactly
services::database_name(<branch workspace name>), the add made it, and it isn't the one any other existing site's.envnames; - that database's service is running and isn't backing up or restoring. When the service is no longer in Short Leash at all, the drop is skipped and the result says so.
Then it stops the branch workspace's server, processes, terminals, scratchpads, calls and share, as unlinking a site does, and goes on in this order, so a failed step leaves the rest as it was:
- the folder moves to the Trash (Recycle Bin on Windows; copy names it with
changes::trash::TRASH_NAME) withchanges::trash::move_to_trash, after the folder's checks run again. It takes folders and fails rather than deleting when the bin can't take one, unless you accept Windows' own warning to delete it permanently; a failed move stops the removal with nothing else removed. The repository's workspaces folder goes too once it's empty; - a Copy or Empty server database is dropped through the same drop as
leash db drop:Engine::drop_databasetakes aReasonand makes its one backup with it, here Before remove (backups::Reason::BeforeRemove), so there's exactly one backup. Only the engine's callers set the reason: the route behindleash db dropand thedrop_databaseMCP tool always pass Before drop, and no client chooses it. A backup that fails keeps the database and stops the removal, to be tried again; git worktree prune, skipped with a note when the repository is gone;- Short Leash forgets the branch workspace's settings, tasks, review comments, pin and trust grants, as unlinking a site does.
Removal can be tried again. Each finished step (trashed, dropped, pruned) is recorded on the branch workspace's entry in
Config.workspacesas it finishes. A retry skips the recorded steps, and the checks don't look again at what a recorded step removed: no folder check once it's trashed, no database check once it's dropped. A drop also counts as done when the database is no longer on the server, its name isservices::database_name(<branch workspace name>)and the entry records that the add made it. A cancel stops a removal between steps, never in the middle of one.removal_can_be_retried_after_a_failed_steptests this.The branch stays. Uncommitted changes, tracked and untracked, are listed in the confirmation, and they reach the Trash with the folder.
- the site served under its name is this branch workspace (
-
No nesting, and only sites served from a repository. A branch workspace is made from a site, never from another branch workspace, and only from a site Short Leash serves from its folder and whose folder is in a Git repository. Refused: a service's address (
Source::Service), a site on a port Short Leash didn't start (0074) or one sent to a port (proxy_to), with "Branch workspaces are for sites Short Leash serves from their folder.", since the port belongs to an app Short Leash doesn't run and the copy would serve the site, and a folder with no repository ("Set up Git in Changes first"). -
Name: "branch workspaces". Copy and docs say "branch workspace", always next to its branch ("a branch workspace for
fix/login"), and document it as a section of the Workspace feature page. Code keepsworkspace: theworkspacesmodule,leash workspace, and the MCP toolslist_workspaces,add_workspace,merge_workspaceandremove_workspace. One name per action: New branch workspace, then "Branch workspace ready atfix-login-shop.test"; Merge intomain, then "Merged intomain"; Remove branch workspace, then "Branch workspace removed". -
A failed add undoes itself. Each step a failed add provably made is undone in reverse, and only after removal's checks pass for what the add made (decision 9), so rollback never trashes or drops anything else. The new database is dropped without a backup, since it held only a copy or nothing, and only when its name is the branch workspace's own, this add made it (or began a restore into it), no other site's
.envnames it, and its service runs and isn't backing up or restoring. The folder goes to the Trash and the worktree is pruned only once the add has read the folder's.gitfile and found theworktrees/<admin>Git made, and the workspaces folder goes too when the add made it and it's empty. A branch the add made is deleted withgit branch -d, which refuses a branch with commits of its own, and only while it still points at the commit the add made it at, read in the same Git turn as the delete. When Git fails after making something, or Short Leash can't prove a folder or branch is the add's, it's left where it is and the log names it with the commands that remove it. The job's steps and log say what was undone. -
Jobs, API, CLI, MCP. Add, merge and remove run as engine jobs with steps and a log, one at a time per branch workspace, kept until dismissed. A merge that removes after it hands its job over: the job under the branch workspace's name becomes the removal, its log continuing the merge's, so clients follow a job by the branch workspace's name. A cancel stops an add and undoes what it made, stops a removal between steps, and stops a merge before it begins or once it's done (a merge under way finishes, or aborts with the base as it was). The routes are
GETandPOST /v1/sites/{name}/workspaces(list, add),GET /v1/workspaces/{name}/removal(what removing it takes), andPOST /v1/workspaces/{name}/remove,/merge,/canceland/dismiss: remove and merge arePOST, because the tool host has onlyGETandPOST.leash workspace add|list|merge|remove(merge and remove say what they'll do and ask first,--yesanswering for a script; remove's confirmation lists the uncommitted changes that go to the Trash, andleash workspace remove --forceis needed only when Short Leash can't read the branch workspace's changes, after a confirmation that prints why) and the MCP tools:list_workspacesisRead,add_workspaceisAct, andmerge_workspaceandremove_workspaceareNeverInChat(they change the site's branch, send data out or move work to the Trash; invariant 15, 0062).
Consequences
- A PHP, Composer script or test runner started outside Short Leash in a branch workspace's folder reads
.env: with Copy or Empty that names the branch workspace's database and URLs (decision 4), with Shared the site's. When Short Leash left.envalone (a link, a tracked file, one it couldn't read), it's the site's database and URLs; running them in a Short Leash terminal or withleash phpgives them the branch workspace's. - On Windows the ignored files are copied, not cloned: a large
node_modulesorvendortakes time and disk space, and links Windows won't let Short Leash make are left out and counted. - The workspaces folder sits beside the repository, so relative paths that leave it, such as a Composer path repository at
../shared-package, don't resolve from a branch workspace. - Settings are copied once and never synced: a PHP version or process changed on the site later stays as it was in its branch workspaces.
- Copying a SQLite database while the site writes to it can catch a half-written page; MySQL-family and PostgreSQL copies are consistent.
- Shared means two checkouts write to one database, so a migration in the branch workspace changes the site's schema. Copy and Empty avoid that.
- A database named by a URL with a password, common in Symfony apps, gets Shared only. One with only a user name can be copied or made empty, but only when Short Leash can rewrite the branch workspace's own
.env. - A removed branch workspace's folder takes space in the Trash until it's emptied, and the Recycle Bin may ask whether to delete a folder too big for it permanently: declining stops the removal.
- In an untrusted repository,
post-checkoutdoesn't run when a branch workspace is made, so a setup it does (installing dependencies, say) has to be run by hand, or the repository trusted first. - An inherited grant lets the repository's hooks run in the branch workspace, the same ones the site's grant covered: a hook the branch adds asks first. When the repository's
core.hooksPathis a tracked folder, those hook files come from the branch, and trust covers their names, not their contents, as 0081 says. In the same way, aleash.tomlprocess whose command didn't change runs the branch's code under the inherited grant. - A daemon restart during an add can leave a half-made worktree with no entry in
config.toml. The next add for that branch is refused because the folder exists, and the message says to remove it and rungit worktree prune. - The new data is a copy of the site's ignored files in the workspaces folder and, for Copy, of its database on the same service, both on this computer. The Privacy policy says so.
Changed while it was built
The first version of this record said otherwise in these places. The decisions above follow the code.
- Removal and rollback check a folder against the repository's Git folder (
git rev-parse --git-common-dir) and theworktrees/<admin>name Git chose, both recorded at the add, and against Git's record pointing back, instead of<repository>/.git/worktrees/<name>, which is wrong for submodules, sites in linked worktrees and relative paths. Merge compares the site's repository with the recorded Git folder too (decisions 8 and 9). - The
@exemption covers only paths Short Leash builds from its own folders. A SQLiteDB_DATABASEfrom.envwith an@,://or a URL scheme gets Shared only (decisions 3 and 4). - A database named by a URL gets both the URL key and
DB_DATABASEwith the new name, not the URL key instead ofDB_DATABASE(decision 4). - Copy doesn't create the database first: the plan's
findand the copy itself refuse a name that's taken, and the restore makes the database (decision 3). - A process's script can differ on the branch and still inherit trust, since trust covers the repository's code (decision 7).
- The add's and merge's trust questions list, and their digest covers, every hook any Short Leash write runs, not only the write's own, so a grant never runs a hook its question didn't show (decision 6).
- Merge refuses while the site's checkout is in the middle of a Git operation, and the push checks the site's branch and trust again in its own Git turn (decision 8).
- A database URL with a user name and no password, which
leash db urlandleash db createprint, was Shared only. It can be copied or made empty now, changing only in the branch workspace's own.env, never inconfig.toml(decisions 3 and 4).