cli.sgit.ai / Research / Design 06: sgit worktree (roadmap 2.13, priority 2)

Design 06: sgit worktree (roadmap 2.13, priority 2)

Date2026-10-28 · 03:03 UTCKinddesignResearch topicsgitWritten bythe sgit-ai CLI team (Claude Code sessions), for Dinis CruzSourceteam/humans/dinis_cruz/claude-code-web/10/28/design__06__worktree.md in SGit-AI__CLI at 036e33b. Published here as written; the CLI repository is where the work happened.

Read with design__00__index-and-reconciliation.md: where the two disagree (shared foundations F1–F9, the in-progress rules), the index wins.

Status: design, not built. Date: 28 Oct 2026. Roadmap: roadmap__git-parity.md item 2.13, approach A (agreed with Dinis: shared objects only, Git's alternates; no shared refs). Code read at: 3a1d72a on claude/charming-babbage-5hzcxq. Every file:line below is at that commit.

In one paragraph. sgit worktree add <path> makes a second working copy of the vault in seconds, with no network. The new folder is a real sgit clone: its own .sg_vault/local/ (clone branch, signing key, config, caches, merge state, reflog) and its own small .sg_vault/bare/ (branch index, refs, public keys). Its object store has no objects of its own at first. It reads objects from the main vault's store, then from the network, in that order. New objects are written to the worktree's own store and never to the main one. Merging works as today: push, then branch merge. Phase 2 adds branch merge from another local worktree without a push.


1. Summary and the exact Git behaviour matched

1.1 What Git does (checked on git 2.43.0, installed in this container)

Git pieceWhere it livesWhat it holds
Linked worktree folder<wt>/.git is a file, one linegitdir: /main/.git/worktrees/<name>
Per-worktree admin folder/main/.git/worktrees/<name>/HEAD, ORIG_HEAD, index, logs/, commondir (../.., back to the main .git), gitdir (path of <wt>/.git), locked when locked
Shared statethe main .gitobjects, packs, refs/heads, refs/tags, config, hooks
One-branch rulegit worktree add ../wt2 feat when feat is checked out elsewherefatal: 'feat' is already used by worktree at '…/wt'
Lockgit worktree lock <wt> --reason Rwrites .git/worktrees/<name>/locked containing R; prune and remove skip it
Prunegit worktree prune (also run by gc, gc.worktreePruneExpire, default 3 months)deletes admin folders whose gitdir points to a path that no longer exists, unless locked
Listgit worktree list [--porcelain]path, HEAD, branch, locked <reason>
Git alternates pieceWhereBehaviour
Pointer<repo>/.git/objects/info/alternatesone absolute path per line, e.g. /main/.git/objects (verified: git clone --reference main writes exactly that)
Read pathobject lookupown objects first, then each alternate in order
Write pathnew objectsalways into the repo's own objects/; an alternate is never written
clone --reference Rcloneborrows R's objects; only what R lacks comes over the network
clone --reference R --dissociatecloneborrows during the clone, then copies what it uses and deletes the alternates file (verified: objects/info/ holds only packs afterwards)
The documented dangergit-clone(1), --sharedif the source repository prunes or repacks away an object the borrower needs, the borrower is corrupt

1.2 What sgit matches, and how

Git behavioursgit worktree (this design)Same or different
Second working copy from one store, created in secondssgit worktree add <path>: a clone whose objects are borrowed from the main storeSame effect
Objects sharedread-only borrowing through .sg_vault/local/alternates.json (Git's alternates)Same mechanism
Refs shared (a commit in one worktree is visible in the other at once)Not shared. Each worktree is its own clone with its own refs, as every sgit clone is. A commit reaches the main folder by push + branch merge (phase 1) or by branch merge --worktree <name> (phase 2)Different, on purpose (section 5.9)
One branch per worktreeNot needed. Each worktree has its own clone branch (every sgit clone does), so no two working copies ever move the same local ref. Two worktrees on the same named branch behave like two clones of it: both push, both mergeSafer than Git's rule, no refusal needed
worktree list / remove / prune / lock / unlockthe same five subcommands, same meaningSame
clone --dissociatesgit worktree dissociate <name>: copy every borrowed object the worktree can reach into its own store, then drop the pointerSame
Danger: the source prunes objectssgit has no prune today (roadmap 2.10). Any future prune or a destructive command in the main vault (rekey, move, uninit) must refuse while worktrees are registered (section 6)Same danger, closed by guards

2. Scope and non-goals

In scope, phase 1 (2–3 days):

  1. sgit worktree add <path> [--name N] [--branch <named> | -b <new named>] [--path F ...] [--no-checkout] [--vault-key K].
  2. sgit worktree list [--json], remove <name|path> [--force], prune [--dry-run], lock <name|path> [--reason R], unlock <name|path>, dissociate <name|path>.
  3. The read path: Vault__Object_Store reads through one alternate store, verifying each borrowed object by its content address.
  4. Guards in the main vault: vault uninit, vault rekey, vault move, migrate apply refuse while worktrees are registered (names listed, --force or worktree remove first). Guards in a worktree: vault uninit points to worktree remove; vault backup materialises borrowed objects into the zip; a first push of a never-pushed vault is refused.
  5. The working-copy scan never descends into a nested vault (a sub-folder that holds its own .sg_vault). This matters when a worktree is created inside the main working copy, and it fixes a latent bug for any nested clone.

Phase 2 (1–1.5 days): sgit branch merge --worktree <name|path>: merge another local worktree's clone head without pushing. It imports the commits and objects it needs, verified by content address, plus the other worktree's public key, then runs the existing pull merge step.

Non-goals:

Not doingWhy
Git-style shared refs (commondir)Section 5.9: 81 hand-built paths to reroute and ref locking to add, for a gain phase 2 delivers more cheaply
Writing into the main store from a worktreeSection 5.4
Worktrees of a read-only cloneA read-only clone (clone_mode.json, no write key) has no clone branch to fork. Refused in v1 with a message; open decision D6
Worktrees across machines or network pathsThe pointer is an absolute local path; a network share works if the OS presents it as a path, untested
Sharing the scan cacheIt records paths, mtimes and inodes of one working copy: meaningless in another
Submodulesroadmap 2.15

3. CLI surface

3.1 Placement (Rule 9) — decision D1

Git has git worktree at the top level, and agents type it from habit. A worktree is about working copies, which are daily-use, not about "the vault as a whole". Recommendation: top-level sgit worktree, with TOP_LEVEL_COMMANDS in tests/unit/cli/test_CLI__B07__Namespace_Moves.py:104 gaining 'worktree' in the same commit (a deliberate edit, as Rule 9 asks). The alternative is sgit vault worktree …. The rest of this section assumes top level. sgit help all (sgit_ai/cli/CLI__Main.py:1491) must list it (it already omits several commands; that list is fixed separately).

3.2 Commands

CommandArguments and flagsEffect
sgit worktree add <path>--name N (default: basename of <path>, made safe by Safe_Str__Worktree_Name) · --branch <named> (track this existing named branch; default: the named branch the main clone tracks) · -b <new named> (create a new named branch from the starting commit, as branch new does) · --from <rev> (start commit; default HEAD of the main clone; any revision history accepts) · --path F (repeatable: a scoped worktree, as clone --path) · --no-checkout (selective working copy, as clone --no-checkout) · --vault-key K or - for stdin (only when the main clone is bare, i.e. holds no key) · --jsonCreates the worktree (section 5.6). Prints its name, path, clone branch, named branch, start commit, files written, time
sgit worktree list--json · --porcelainOne line per worktree: name, path, named branch, head (short id), state (ok, missing, locked: <reason>), plus the main vault as the first line, as Git does
sgit worktree remove <name|path>--forceRefuses when the worktree has uncommitted changes, unpushed commits, a merge in progress, or is locked; --force overrides all but locked (Git needs --force --force for a locked one; sgit asks for unlock first, which is clearer). Deletes the worktree folder (working copy and .sg_vault/) and its registry entry
sgit worktree prune--dry-runDrops registry entries whose path no longer holds a worktree of this vault (folder gone, or its alternates.json no longer points here), unless locked
sgit worktree lock <name|path>--reason RMarks the entry locked (a removable disk, a long-running agent): prune and remove skip it
sgit worktree unlock <name|path>Clears the lock
sgit worktree dissociate <name|path>run from anywhereCopies every borrowed object reachable from the worktree's refs into its own store (each verified), then deletes its alternates.json and the registry entry: it becomes an ordinary clone
sgit branch merge --worktree <name|path> (phase 2)--merge lines|files once design 01 landsMerges that worktree's clone head into this clone (section 5.8)

<name|path> resolves first as a registry name, then as a path (absolute or relative to the current directory). Every subcommand except add and phase 2 can run from the main folder or from any of its worktrees. Inside a worktree, the registry used is the main vault's, found through alternates.json.

3.3 Output

$ sgit worktree add ../agent-7 -b agent-7
Worktree agent-7 at /work/agent-7
  vault       y8pwtjlw
  branch      agent-7 (new, from current at 3f9a1c2)
  clone       branch-clone-5e1b… (registered on first push)
  objects     borrowed from /work/vault (0 downloaded)
  files       9,404 written in 2.1 s
$ sgit worktree list
/work/vault       3f9a1c2  current   (main)
/work/agent-7     3f9a1c2  agent-7
/work/agent-8     b07e2d4  current   locked: long run
/mnt/usb/agent-9  -        -         missing (sgit worktree prune)

--porcelain prints Git's shape: blocks of worktree <path>, HEAD <commit id>, branch <named>, locked <reason>, prunable <reason>, separated by a blank line.

3.4 --json shapes

worktree add --json:

{ "name": "agent-7", "path": "/work/agent-7", "main": "/work/vault", "vault_id": "y8pwtjlw",
  "named_branch": "agent-7", "named_branch_new": true, "clone_branch_id": "branch-clone-5e1b…",
  "start_commit": "obj-cas-imm-3f9a1c2…", "files_written": 9404, "objects_downloaded": 0,
  "scope_paths": [], "selective": false, "seconds": 2.1 }

worktree list --json: {"main": {...}, "worktrees": [{"name", "path", "head", "named_branch", "clone_branch_id", "created_at", "locked", "lock_reason", "state"}]}, where state is ok | missing | foreign. foreign means the folder exists but its alternates.json points to another main vault.

3.5 Exit codes

As the rest of the CLI (sys.exit(1) is used 116 times in sgit_ai/cli/*.py, sys.exit(2) for argparse misuse, 130 for Ctrl-C): 0 success, 1 refused or failed (the message says why and names the remedy), 2 bad arguments. remove and prune --dry-run exit 0 when there is nothing to do.


4. How the code works today

AreaCodeWhat matters here
Layout constantssgit_ai/storage/Vault__Storage.py:6-17.sg_vault/, bare/{data,refs,keys,indexes,pending,branches,cache}, local/, vault_key
Path helpersVault__Storage.py:24-31 (sg_vault_dir, bare_dir, local_dir), :83 is_vault, :87 find_vault_rootEvery helper is os.path.join(directory, '.sg_vault', …); no indirection exists
Vault root detectionVault__Storage.find_vault_root :87 and Vault__Context.detect sgit_ai/core/Vault__Context.py:29-56Walk up until a folder holds a .sg_vault/ directory. A worktree with a real .sg_vault/ directory needs no change here
Object storesgit_ai/storage/Vault__Object_Store.py:47-190load :106: loose file → kept packs (packs() :55) → Vault__Object_Missing_Error. exists :118, size_of :124, all_object_ids :157 (loose ∪ packed), loose_object_ids :165, resolve_id :135 (prefix search over all_object_ids), verify_integrity :180
Writesstore :68, store_raw :82, store_at :96; Vault__Verified_Write.save sgit_ai/storage/Vault__Verified_Write.py:72 (every download path)Plain open(path, 'wb'): not atomic. A reader in another process can see a half-written file. Loose reads (load :106) do not re-verify; only kept-pack reads do (Vault__Pack_Store, "verified on every read")
CAS guardstore :68-80An existing id with different bytes raises Vault__Object_Collision_Error; identical bytes are a no-op
Kept packssgit_ai/storage/Vault__Pack_Store.py (db_path :52 = <sg>/local/pack-store.sqlite, packs_dir :55 = <sg>/bare/packs, remove_pack :276)The pack table is per vault_path. A Vault__Object_Store built on the main sg_dir brings the main pack store with it
ConstructionVault__Object_Store( appears 48 times in 30 files (6 in Vault__Packs.py, 4 each in Vault__Pack_Index__Reader.py and Vault__Pack_Index.py, 3 in Vault__Bare.py, …)The alternate must be found inside the object store, lazily, as packs() is. Threading it through 48 constructors is not an option
Clone workflowsgit_ai/workflow/clone/Workflow__Clone.py steps: Derive_Keys, Check_Directory, Download_Index, Download_Branch_Meta, Bulk_Fetch, Walk_Commits, Walk_Trees, Download_Blobs, Verify_Signatures, Create_Clone_Branch, Extract_Working_Copy, Setup_Local_ConfigThe worktree flow reuses the last four as they are and replaces the network steps with local copies
Clone branch creationStep__Clone__Create_Clone_Branch → Vault__Branch_Manager.create_clone_branch sgit_ai/storage/Vault__Branch_Manager.py:49-74New branch-clone-<16 hex>, ref ref-pid-snw-<12 hex>, key pair: public key to bare/keys/key-rnd-imm-… (encrypted under the read key), private .pem to local/ (0600). The index entry is written locally; pending_registration.json registers it on first push
Local configStep__Clone__Setup_Local_Config; Schema__Local_Config (sgit_ai/schemas/Schema__Local_Config.py:9-22)my_branch_id, sparse, pack_store, scope_paths, shallow_boundaries, last_remote_head, … The vault key goes through Vault__Storage.write_private (0600, Vault__Secret_File)
Files under local/config.json, vault_key, <key id>.pem, remotes.json, token, base_url, remote_heads.json (Vault__Sync__Base.py:170), reflog.jsonl, merge_state.json, scan-cache.json, flat-cache/<tree>.json (Vault__Sub_Tree.py:13), pack-store.sqlite, index/generations.json, checkout.json, last-pull.json, pending_registration.json, push_state.json, tracking.json, stash-meta.json, move-history.json, migrations.json, trace.jsonl, lazy-fetch.log, debug/Section 5.6 lists which are copied, rebuilt or left out
Local transportsgit_ai/network/api/Vault__Backend__Local.py (--transport local, read-only folder)clone --transport local <main>/.sg_vault would work but copies every object and makes the folder the remote. Not used
Attachsgit_ai/core/actions/lifecycle/Vault__Attach.pyBinds a key to an existing bare/ (validate first, write nothing on a wrong key). Its key-validation rule is reused
First pushVault__Sync__Push._is_first_push :773 and _upload_bare_to_server :840A first push walks the own bare/ dir and uploads every file. A worktree of a never-pushed vault would upload an incomplete store: refused (section 7)
Push object readsVault__Batch and Vault__Sync__Push read bytes through obj_store.loadBorrowed objects are read and uploaded like own ones
UninitVault__Sync__Lifecycle.uninit :95-129Backup, then shutil.rmtree(sg_dir). On a worktree that removes only the worktree's own .sg_vault
Rekey wipeVault__Sync__Lifecycle.rekey_wipe :52secure_rmtree(sg_dir): destroys the main store that worktrees borrow from
Merge entryVault__Sync.merge_branch sgit_ai/core/Vault__Sync.py:230 → Vault__Sync__Pull.pull(merge_from=…) :80; Step__Pull__Load_Branch_Info :77-85 resolves merge_from to a named branch from the server's indexPhase 2 adds a local source
ScanVault__Sync__Base._scan_local_directory :519, walk at :555-562Skips ignored dirs and links. A sub-folder that is itself a vault root (holds .sg_vault/) is descended into: its .sg_vault/ is skipped (ALWAYS_IGNORED_DIRS, Vault__Ignore.py:6) but its files are scanned and would be committed
Path guardVault__Path_Guard.is_protected sgit_ai/storage/Vault__Path_Guard.py:95Any segment named .sg_vault, .sg_vault_new, .sg_vault_old_*, .git (and spellings) is refused from vault data, file or folder. Vault content therefore cannot plant sub/.sg_vault/local/alternates.json

4.1 Why not Git-style shared refs: the counts (verified)

grep -rn "'\.sg_vault'" sgit_ai --include=*.py, excluding Vault__Storage.py: 52 occurrences in 22 files.

FileCount
sgit_ai/cli/CLI__Token_Store.py9
sgit_ai/cli/CLI__Vault.py7
sgit_ai/plugins/dev/workflow/CLI__Dev__Workflow.py4
sgit_ai/core/actions/status/Vault__Sync__Status.py4
sgit_ai/core/Vault__Ignore.py3
sgit_ai/cli/CLI__Serve.py3
sgit_ai/workflow/Workflow__Runner.py, sgit_ai/core/serve/Vault__Serve.py, sgit_ai/core/actions/publish/Vault__Publish.py, sgit_ai/core/actions/merge/Vault__Merge.py, sgit_ai/core/Vault__Sync__Base.py, sgit_ai/cli/CLI__Main.py2 each
Step__Move__Delete_Source.py, Vault__Path_Guard.py, Dev__Tree__Graph.py, Migration__Runner.py, Vault__Sync__Push.py, Vault__Sync__Move.py, Vault__Restore.py, Vault__Secret_Guard.py, Vault__Context.py, CLI__Create.py1 each

grep -rn "os.path.join([^)]*'bare'", same exclusion: 29 occurrences in 18 files.

FileCount
sgit_ai/workflow/move/steps/Step__Move__Build_Temp_Vault.py9
Vault__Publish.py, Vault__Sync__Base.py, CLI__Serve.py2 each
Step__Move__Write_Sentinel_Commits.py, Step__Move__Push_To_Target.py, Vault__Ref_Manager.py, Vault__Pack_Store.py, Vault__Object_Store.py, Vault__Key_Manager.py, Vault__Serve__Mirror.py, Vault__Pack_Maintain.py, Vault__Attach.py, Vault__Sync__Upload_Objects.py, Vault__Backup.py, Vault__Sync.py, Vault__Context.py, Vault__Bare.py1 each

On top of these, string paths such as 'bare/data/' appear in about 60 more places. Most are wire file ids (the server's path), but Step__Move__Build_Temp_Vault.py:103 and Step__Move__Validate_Local.py:61 use them on disk.

Shared refs would mean: every one of those 81 paths goes through one resolver, bare/refs, bare/indexes and bare/keys become shared between processes, and the ref, index and baseline writes need cross-process locks. Those writes are compare-and-swap on the server today and unlocked on disk (save_branch_index Vault__Branch_Manager.py:76-86 writes in place). That is a 1–2 week change touching most commands, with a concurrency bug class sgit does not have today. Approach A changes the object read path in one class, plus about 10 small guards.


5. Design

5.1 On-disk layout

/work/vault/                        main clone (unchanged layout)
  .sg_vault/bare/{data,refs,keys,indexes,packs,…}
  .sg_vault/local/
      worktrees.json                NEW: registry of this vault's worktrees (Schema__Worktree__Registry)
      …everything as today

/work/agent-7/                      worktree: a real clone
  .sg_vault/bare/
      data/                         own objects only (new commits, trees, blobs; fetched objects the main lacks)
      refs/                         copied at add time, then its own
      keys/                         copied at add time (public keys, encrypted), then its own
      indexes/<branch index id>     copied at add time, then its own
  .sg_vault/local/
      alternates.json               NEW: the pointer to the main store (Schema__Worktree__Alternates)
      config.json  vault_key  <key>.pem  remotes.json  token  base_url  remote_heads.json  …
  <working copy files>

There are no symlinks. The worktree's .sg_vault/ is a real directory, so find_vault_root and Vault__Context.detect work unchanged. A worktree is an ordinary clone plus one file.

5.2 Schemas and safe types (new)

NameFileDefinition
Safe_Str__Worktree_Namesgit_ai/safe_types/Safe_Str__Worktree_Name.pyregex = re.compile(r'[^a-zA-Z0-9._\-]'), max_length = 64, allow_empty = False, trim_whitespace = True. Leading . is refused by Vault__Worktree (no .sg_vault-like names)
Safe_Str__Worktree_Lock_Reasonsgit_ai/safe_types/Safe_Str__Worktree_Lock_Reason.pymax_length = 200, allow_empty = True, trim_whitespace = True, control characters replaced
Enum__Worktree_Statesgit_ai/safe_types/Enum__Worktree_State.pyOK = 'ok', MISSING = 'missing', FOREIGN = 'foreign'
Schema__Worktree__Alternatessgit_ai/schemas/worktree/Schema__Worktree__Alternates.pyschema : Safe_Str__Schema_Version = None ('worktree_alternates_v1'); main_sg_dir : Safe_Str__File_Path = None (absolute path of the main .sg_vault); worktree_name : Safe_Str__Worktree_Name = None; branch_index_id : Safe_Str__Index_Id = None (the vault's branch index file id: the identity check, 5.3); created_at : Safe_UInt__Timestamp
Schema__Worktree__Entrysgit_ai/schemas/worktree/Schema__Worktree__Entry.pyname : Safe_Str__Worktree_Name = None; path : Safe_Str__File_Path = None (absolute); clone_branch_id : Safe_Str__Branch_Id = None; named_branch : Safe_Str__Branch_Name = None; created_at : Safe_UInt__Timestamp; locked : bool = False; lock_reason : Safe_Str__Worktree_Lock_Reason = None (new: one line, 200 chars, printable characters only)
Schema__Worktree__Registrysgit_ai/schemas/worktree/Schema__Worktree__Registry.pyschema : Safe_Str__Schema_Version = None ('worktree_registry_v1'); worktrees : list[Schema__Worktree__Entry]
Schema__Worktree__Add__Statesgit_ai/schemas/workflow/worktree/Schema__Worktree__Add__State.pyThe workflow state: the clone-state fields the reused clone steps read (directory, sg_dir, vault_id, vault_key, read_key_hex, branch_index_file_id, index_id, named_branch_id, named_ref_id, named_commit_id, clone_branch_id, clone_ref_id, clone_public_key_id, scope_paths, shallow_boundaries, sparse, pack_store) plus main_directory, main_sg_dir, worktree_name, start_commit_id, new_named_branch. Alternative, preferred if the steps type-check their input: subclass Schema__Clone__State and add the five fields, so Step__Clone__Create_Clone_Branch, Step__Clone__Verify_Signatures, Step__Clone__Extract_Working_Copy and Step__Clone__Setup_Local_Config run unchanged

Every schema gets the round-trip test (cls.from_json(obj.json()).json() == obj.json()). Both JSON files are written through Vault__Storage.write_private (0600, temp file, fsync, rename): alternates.json names a path to a folder holding a key, and worktrees.json names paths.

5.3 The read path (one class changes)

Vault__Object_Store (sgit_ai/storage/Vault__Object_Store.py) gains:

MemberKindBehaviour
alternate : object = Nonefieldthe main vault's Vault__Object_Store (its own packs() included), or None
alternates_probed : bool = Falsefieldprobed once per instance, as packs_probed is (:51-53)
alternate_store(self)methodFirst call: read <vault_path>/local/alternates.json through Vault__Alternates.load. When it exists and validates, build Vault__Object_Store(vault_path=main_sg_dir, crypto=self.crypto, id_hex_len=self.id_hex_len). An alternate of an alternate is never followed: the main store's own probe is disabled (alternates_probed=True when constructed as an alternate), so a chain or a loop cannot form
load(self, object_id)changedown loose file → own kept packs → alternate.load(object_id) then verified: self.crypto.object_id_matches(object_id, data). A mismatch (a half-written file, a changed disk) counts as missing → Vault__Object_Missing_Error as today, so every fail-soft caller fetches from the network as it already does
exists(self, object_id)changedown loose → own packs → alternate.exists
size_of(self, object_id)changedsame order
all_object_ids(self)changedown ∪ alternate. Callers that list for presence (resolve_id :135, Vault__Revision.py:136, Vault__Incoming_Check.py:88, Step__Clone__Verify_Signatures.py:81, Vault__Inspector.py:330, Vault__Pack_Maintain.py:76) then see borrowed objects as present, which is the Git semantics
own_object_ids(self)newown loose ∪ own packs: for dissociate, backup counting, worktree list stats
is_borrowed(self, object_id)newnot own exists and alternate exists: for fsck reporting and dissociate
verify_integrity :180unchangedgoes through load, so it covers borrowed objects

Vault__Alternates (new, sgit_ai/storage/Vault__Alternates.py, Type_Safe) does all pointer handling:

MethodBehaviour
path(sg_dir) -> str<sg_dir>/local/alternates.json
load(sg_dir) -> Schema__Worktree__Alternates or NoneRead with Vault__Path_Guard().read_regular (O_NOFOLLOW, regular file only, the TM-F27 rule). Parse. Return None on any error: a broken pointer means "no alternate", never a crash, and the network fallback still works
validate(sg_dir, alt) -> str'' when usable, else the reason. Checks, in order: main_sg_dir is absolute; it is a directory and not a link (Vault__Path_Guard.is_link); main_sg_dir != sg_dir (no self-loop); <main_sg_dir>/bare/indexes/<branch_index_id> exists (identity check: the branch index file id is derived from the read key and vault id, so only the same vault has it); <main_sg_dir>/local/alternates.json does not exist (the main is not itself a worktree: no chains)
write(sg_dir, alt)through Vault__Storage.write_private
remove(sg_dir)delete the file (used by dissociate)
main_directory(alt) -> strdirname(main_sg_dir)

Why verify borrowed reads when own loose reads are not verified:

A mismatch is reported once per run as a warning (object <id> in the main store does not match its id; fetched from the server instead).

5.4 The write path: new objects go to the worktree's own store

Decision: own store only. Git never writes into an alternate, and the same reasons hold:

  1. Ownership. The main folder's store belongs to the main clone. Two processes writing into one bare/data/ meet the non-atomic writes above and the CAS guard's read-compare (store :70-75), which would raise a false collision on a half-written file.
  2. Removal is clean. worktree remove deletes the worktree folder and everything it created. Nothing the worktree wrote stays behind in the main store.
  3. Kept packs. A --pack-store main has a SQLite table per clone (local/pack-store.sqlite). A worktree writing loose objects into the main bare/data/ would bypass that table's intake rules.
  4. Cost. The duplicate is only what the worktree itself creates or fetches: its own commits, plus what it pulls that the main has not pulled yet.

A pull in the worktree downloads only objects missing from both stores, because exists covers the alternate. When the main clone has already pulled, the worktree's pull downloads only refs.

5.5 The registry in the main vault

Vault__Worktree_Registry (new, sgit_ai/storage/Vault__Worktree_Registry.py):

MethodBehaviour
path(main_sg_dir)<main_sg_dir>/local/worktrees.json
load(main_sg_dir) -> Schema__Worktree__Registryan empty registry when absent; an unreadable file raises (fail closed: a guard must never read "no worktrees" from a corrupt file, the TM-F23 rule)
save(main_sg_dir, registry)write_private (atomic)
add(main_sg_dir, entry)refuses a duplicate name or path
remove(main_sg_dir, name)
find(main_sg_dir, name_or_path) -> entry or Nonename first, then normalised absolute path
state_of(main_sg_dir, entry) -> Enum__Worktree_StateMISSING when <path>/.sg_vault is absent; FOREIGN when the worktree's alternates.json does not point back to main_sg_dir; else OK
live(main_sg_dir) -> listentries in state OK, plus locked ones in any state: what the guards check

Concurrency: worktree add and remove read-modify-write the registry. Two concurrent add calls could lose an entry. The registry write uses a lock file worktrees.json.lock (os.open(O_CREAT|O_EXCL), removed in finally, stale after 60 s). This is the only cross-process lock in the design.

5.6 worktree add, step by step

New class Vault__Worktree (sgit_ai/core/actions/worktree/Vault__Worktree.py, a Vault__Sync__Base subclass like the other actions), method add(main_directory, path, name=None, branch=None, new_branch=None, from_rev=None, scope_paths=None, no_checkout=False, vault_key=None, on_progress=None) -> dict. It runs Workflow__Worktree__Add (sgit_ai/workflow/worktree/Workflow__Worktree__Add.py, registered with @register_workflow as the clone workflows are):

#StepNew or reusedWhat it does
1Step__Worktree__Check_MainnewResolve the main vault root from the current directory (Vault__Storage.find_vault_root). Refuse when: it is a worktree itself (has alternates.json; the remedy names the main folder; Git also creates worktrees of the main only); it is a read-only clone (clone_mode.json, decision D6); a merge is in progress (Vault__Merge__State.check_not_in_progress); a vault move/rekey is half done (.sg_vault_new or .sg_vault_old_* present). Read the vault key from local/vault_key, or from --vault-key when the main is bare (Vault__Bare.is_bare), validated as Vault__Attach does: the derived branch-index file must exist in bare/indexes, else write nothing. Derive the keys (Vault__Crypto.derive_keys_from_vault_key)
2Step__Worktree__Check_Targetnew<path> must not exist, or be an empty directory. It must not be inside the main's .sg_vault. It may be inside the main working copy only when the main's ignore rules ignore it, or when it is a nested vault the scan skips (5.10): the step warns, and refuses with --no-nested (decision D4). The name must be unique in the registry; the default name is basename(path), with -2, -3 appended on a clash
3Step__Worktree__Resolve_StartnewThe start commit: --from <rev> through Vault__Revision.resolve against the main clone, default the main clone's HEAD (its clone branch head, including unpushed commits, as Git's worktree add starts from HEAD). The named branch: --branch (must exist in the main's local branch index), -b <new> (must not exist), default the main's tracked named branch
4Step__Worktree__Copy_Structurenewcreate_bare_structure(<path>). Copy from the main's bare/: the branch index file, all of bare/refs/ (dozens of files of 69 bytes), all of bare/keys/ (public keys, encrypted). Each copy is a regular-file read through read_regular and a write into the new store; refs and keys are not content-addressed, so they are copied byte for byte. Not copied: bare/data/ (borrowed), bare/packs/ (borrowed through the main store's packs()), bare/cache/ (derived; rebuilt on the worktree's first push if declared), bare/pending/ (change packs are drained by the main clone). Then write local/alternates.json (5.2)
5Step__Worktree__Copy_LocalnewCopy the clone-independent local files: remotes.json, base_url, token (through write_private: the server pin, TM-F15/F22, must hold in the worktree too), remote_heads.json (the baselines of what the main accepted: correct, because every object they name is reachable through the alternate; without them, the first pull in the worktree could not detect a rewind), the format/policy pin if present, flat-cache/<head tree>.json (content-addressed by tree id, so always valid; saves the first flatten). Not copied: scan-cache.json (per working copy), reflog.jsonl (the worktree's history starts here; one entry worktree add from <main> at <commit>), merge_state.json, checkout.json, last-pull.json, push_state.json, pending_registration.json, stash*, pack-store.sqlite, index/generations.json (derived), trace.jsonl, lazy-fetch.log, debug/, move-history.json, migrations.json, the main's .pem (a worktree never signs as the main clone)
6Step__Clone__Create_Clone_Branchreused (sgit_ai/workflow/clone/Step__Clone__Create_Clone_Branch.py)New clone branch, new key pair, .pem 0600 in the worktree's local/, pending_registration.json; its ref is set to the start commit (the step writes named_commit_id; the worktree state sets that field to the start commit)
7Step__Worktree__New_Named_Branchnew, only with -bVault__Branch_Switch.branch_new(<path>, name) (sgit_ai/core/actions/branch/Vault__Branch_Switch.py:167) inside the new worktree, exactly as a user would run sgit branch new there
8Step__Clone__Verify_SignaturesreusedSame rule as every clone (TM-R04/TM-F14): under signatures-required, every commit since the recorded policy start must verify. All objects are local through the alternate, so this costs no network. The main's unpushed commits are signed by the main's clone key, whose public key was copied in step 4, so they verify
9Step__Clone__Extract_Working_CopyreusedFull checkout, or the scoped tree for --path, or nothing beyond the record for --no-checkout. Blobs are read through obj_store.load, therefore borrowed and verified. The scan cache is seeded as every checkout seeds it (_checkout_flat_map, Vault__Sync__Base.py:674)
10Step__Clone__Setup_Local_Configreusedconfig.json (new my_branch_id; scope_paths from --path; shallow_boundaries copied from the main, because history the main never fetched is not borrowable either; sparse as the main's when the main is sparse, or --no-checkout; pack_store=False, since the worktree keeps no packs of its own and borrows the main's; last_remote_head from the main) and vault_key (0600)
11Step__Worktree__RegisternewAdd the Schema__Worktree__Entry to the main's worktrees.json (locked write)

Failure handling. Any failure after step 4 removes <path>/.sg_vault and the files written (the step runner's keep_work=False cleanup plus an explicit rollback in Vault__Worktree.add). Nothing in the main vault is written before step 11.

The vault key: copied, not referenced (decision D2). The worktree is a full clone and must keep working if the main folder moves or is removed. A reference would also make every worktree command open the main's key file. Git shares config the same way, in effect. The copy is written through the single secret writer, as every clone's is.

5.7 remove, prune, lock, unlock, dissociate, list

Method on Vault__WorktreeRules
remove(main_directory, name_or_path, force=False)Resolve the entry. Refuse when locked. Unless force: refuse when Vault__Sync__Status.status(<path>) is not clean (uncommitted, untracked, conflict files), when the clone branch head is not reachable from any named-branch head this clone knows (unpushed commits: the message names how many and says sgit push there first), or when a merge is in progress. Then secure_rmtree(<path>/.sg_vault/local) (key, .pem, token are secrets: zero-filled as rekey_wipe does), shutil.rmtree(<path>), and remove the entry. The worktree's clone branch stays in the vault's branch index if it was ever pushed (sgit has no branch delete; it costs one index entry and one public key; decision D5)
prune(main_directory, dry_run=False)Remove entries in state MISSING or FOREIGN, never locked ones. Prints each. No files are touched (the folder is already gone or belongs elsewhere)
lock(…, reason) / unlock(…)Set or clear locked and lock_reason in the entry
dissociate(main_directory, name_or_path)In the worktree: walk every ref the worktree holds (clone branch, named branches in its index, tags, stash), collect every reachable commit, tree and blob within its scope and shallow boundaries, and for each is_borrowed object load (verified) then store it into the own store. Then Vault__Alternates.remove, and remove the registry entry. Prints counts and bytes. After this, the main vault can be removed without affecting the former worktree
list(main_directory)The main first, then each entry with state_of, head and named branch read from the worktree's own config and refs (read-only; a missing worktree shows -)

5.8 Phase 2: sgit branch merge --worktree <name|path>

Goal: merge another worktree's work into this clone without a push. This is what makes the main folder (or a coordinating agent) able to integrate agents' worktrees the way git merge feature does.

Flow (Workflow__Merge_Local, sgit_ai/workflow/pull/Workflow__Merge_Local.py; reuses the pull workspace):

#StepNew or reusedWhat it does
1Step__Pull__Derive_Keysreusedas pull
2Step__Merge_Local__Resolve_SourcenewResolve <name|path> through the registry (from the main or any worktree). The source must be a worktree of the same vault: its alternates.json validates against the same branch_index_id, or it is the main itself when run from a worktree. Read its clone branch head: its local/config.json my_branch_id → its branch index entry → head_ref_id → its bare/refs/<ref>, decrypted with the read key (Vault__Ref_Manager.read_ref on the source's sg_dir). Refuse when the source has a merge in progress
3Step__Merge_Local__ImportnewWalk commits from the source head until reaching commits this clone has (obj_store.exists). For each missing commit, the trees and blobs it introduces: read from the source's object store (which itself borrows from the main), verify by content address, store into this clone's own store. Import the source's public key file bare/keys/<author_key_id> when this clone lacks it, accepted only if it decrypts to a public key, exactly as Vault__Key_Fetch._is_public_key (sgit_ai/core/actions/verify/Vault__Key_Fetch.py) does
4Step__Merge_Local__Check_Signaturesnew, thinUnder signatures-required: Vault__Signatures.verify_chain(head=source_head, stop_at=<commits this clone already has>), the same check pull applies to incoming commits (Vault__Incoming_Check). A failure refuses before any write
5Step__Pull__MergereusedWith named_commit_id set to the source head. Fast-forward, three-way merge, .conflict files, merge state, the dirty-tree guard (_guard_working_tree): all unchanged. With design 01 landed, --merge lines applies here too

What it does not touch: this clone's named-branch ref and baselines (remote_heads.json). A local merge is not a fetch of the named branch: the rewind guard and the baselines only ever follow the server. The merge commit's message is Merge worktree <name> (<clone branch>) into <this clone branch>.

Where the switch goes: CLI__Branch merge parser gains --worktree NAME (mutually exclusive with fetching from the server). Vault__Sync.merge_branch gains worktree: str = None and calls Vault__Sync__Pull.merge_local(…). Pull__Workspace.merge_from stays for named branches.

5.9 Compatibility

WhoEffect
Vaults and clones without worktreesNothing changes. alternates_probed finds no file and the object store behaves exactly as today. The scan change (5.10) affects only folders containing a nested .sg_vault
The server and the web UINothing changes. A worktree is a clone: one more clone branch and public key in the index after its first push, as for any new clone
Older sgit opening a worktreeIt does not know alternates.json, so it sees a clone with an almost empty bare/data/. Reads miss and fall back to the network (existing fail-soft paths), which is slow but correct. check fsck reports objects missing. The worktree README line printed by add says the worktree needs this sgit version or later. No gate is needed: nothing on the server changes and a worktree is a local arrangement
Older sgit opening the main vaultworktrees.json is ignored; the guards of 6.3 are absent in that build, which is the residual risk named in TM row W3

5.10 Nested vaults in the scan (fix included in phase 1)

_scan_local_directory (Vault__Sync__Base.py:555-562) and the other working-copy walks (:659, :752, Vault__Merge.remove_conflict_files / has_conflicts, Vault__Sync__Lifecycle.uninit's count) must not descend into a sub-folder that holds its own .sg_vault/. The check costs nothing extra: os.walk already lists each folder's sub-directories, so when root != directory and '.sg_vault' in dirs, clear dirs and skip files. Same rule as Git for a nested repository. One shared helper, Vault__Ignore.is_nested_vault_root(root_listing_dirs, root, directory) -> bool, so all walks use the same rule. status reports Nested vault skipped: wt/ once, so the user knows why those files are not tracked.

5.11 File-by-file changes

FileChange
sgit_ai/safe_types/Safe_Str__Worktree_Name.py, Safe_Str__Worktree_Lock_Reason.pynew (5.2)
sgit_ai/safe_types/Enum__Worktree_State.pynew
sgit_ai/schemas/worktree/Schema__Worktree__Alternates.py, Schema__Worktree__Entry.py, Schema__Worktree__Registry.py (+ __init__.py in sgit_ai/schemas/worktree/)new
sgit_ai/schemas/workflow/worktree/Schema__Worktree__Add__State.pynew (or a Schema__Clone__State subclass)
sgit_ai/storage/Vault__Alternates.pynew (5.3)
sgit_ai/storage/Vault__Worktree_Registry.pynew (5.5)
sgit_ai/storage/Vault__Object_Store.pyfields alternate, alternates_probed; alternate_store, own_object_ids, is_borrowed; load, exists, size_of, all_object_ids changed (5.3). Commit 0: atomic writes in store/store_raw/store_at (temp file in the same folder + os.replace)
sgit_ai/storage/Vault__Verified_Write.pysave :72 writes atomically (temp + os.replace)
sgit_ai/core/Vault__Ignore.pyis_nested_vault_root (5.10)
sgit_ai/core/Vault__Sync__Base.pyscan walks skip nested vault roots (:555, :659, :752)
sgit_ai/core/actions/merge/Vault__Merge.pyremove_conflict_files / has_conflicts walks: same skip
sgit_ai/core/actions/worktree/Vault__Worktree.py (+ __init__.py)new: add, list, remove, prune, lock, unlock, dissociate, rollback
sgit_ai/workflow/worktree/Workflow__Worktree__Add.py, Step__Worktree__Check_Main.py, Step__Worktree__Check_Target.py, Step__Worktree__Resolve_Start.py, Step__Worktree__Copy_Structure.py, Step__Worktree__Copy_Local.py, Step__Worktree__New_Named_Branch.py, Step__Worktree__Register.py (+ __init__.py)new
sgit_ai/core/Vault__Sync.pythin delegates worktree_add, worktree_list, …; merge_branch(…, worktree=None) (phase 2)
sgit_ai/cli/CLI__Worktree.pynew CLI__Worktree class: parsers and cmd_* methods, output and --json (3.3, 3.4)
sgit_ai/cli/CLI__Main.pyregister the worktree parser (build_parser), add to help all
sgit_ai/core/actions/lifecycle/Vault__Sync__Lifecycle.pyuninit :95: in a worktree, refuse ("this is worktree N of /main: sgit worktree remove N"); in a main with live worktrees, refuse unless force. rekey_wipe :52 / rekey: refuse with live worktrees
sgit_ai/core/actions/move/Vault__Sync__Move.pymove :22: refuse with live worktrees (a move rewrites every id: worktrees would borrow nothing and point at the old vault); in a worktree: refuse ("move the main vault, or dissociate first")
sgit_ai/migrations/Migration__Runner.pyapply: refuse with live worktrees (migrations rewrite trees)
sgit_ai/core/actions/backup/Vault__Backup.pyin a worktree, the zip's bare/data/ includes every borrowed object reachable from the worktree's refs (read through load), so a backup restores without the main
sgit_ai/core/actions/push/Vault__Sync__Push.py_upload_bare_to_server :840: when alternates.json is present, refuse ("this vault was never pushed; push it from the main folder /main first"). A worktree of a pushed vault never takes this path
sgit_ai/core/actions/fsck/Vault__Sync__Fsck.pyreport borrowed: N objects from /main and, with --verbose, which; a borrowed object that fails verification is reported as corrupt in the main, with the main's path
sgit_ai/workflow/pull/Workflow__Merge_Local.py, Step__Merge_Local__Resolve_Source.py, Step__Merge_Local__Import.py, Step__Merge_Local__Check_Signatures.pynew (phase 2)
sgit_ai/core/actions/pull/Vault__Sync__Pull.pymerge_local(directory, source, on_progress) (phase 2)
sgit_ai/cli/CLI__Branch.py--worktree NAME (phase 2)
tests/unit/cli/test_CLI__B07__Namespace_Moves.py:104'worktree' added to TOP_LEVEL_COMMANDS (if D1 is top level)

6. Security and threat model

6.1 What the worktree trusts

ThingTrustWhy it is safe
Borrowed objectsnoneVerified by content address on every borrowed read (5.3). A changed or half-written file is a miss, never accepted
alternates.jsonThe worktree's own .sg_vault/local/, same trust as vault_keyIt can only be written by a local process with the user's rights. Vault content cannot plant it: Vault__Path_Guard.is_protected (:95) refuses any .sg_vault segment from vault data, file or folder, on every write path (TM-F03)
The main store's identityThe branch_index_id check (5.3)Only the same vault holds that file id. A pointer to another vault fails validation. Even if it passed, foreign objects would fail to decrypt or would not hash to the ids the trees name
Refs and keys copied at addSame as the main clone'sThey are the main clone's own verified state. Everything later is pulled and verified by the worktree itself
The registryMain's local/Only paths and names. An unreadable registry fails closed in every guard

6.2 Cases

CaseBehaviour
The main folder is deleted or movedvalidate fails (no directory, or no index file). The worktree runs as a clone with a sparse store: reads fall back to the network (fail-soft paths). Objects that were only in the main (its unpushed commits that the worktree started from) are lost to the worktree. status and fsck say so. Remedy: dissociate before removing a main folder, and vault uninit in the main refuses while worktrees are live
The pointer is a symlink, or main_sg_dir is a linkRefused by read_regular / is_link
The pointer points to the worktree itself, or to another worktreeRefused (self-loop check; the target holds an alternates.json)
A second user on the machine edits the main storeThe same exposure as editing a clone's own store; verification turns any change into a miss
Key materialThe worktree gets its own clone key pair (a commit from a worktree is attributable to that worktree, author_key_id). The vault key and token are copied through write_private (0600). remove zero-fills them (secure_rmtree of local/)
Secrets in the working copyUnchanged: the secret guard runs in the worktree as in any clone
A worktree inside the main working copyThe nested-vault rule (5.10) keeps the main from committing the worktree's files. Without it, the main's next commit would put the worktree's whole working copy into the vault a second time
Metadata seen by the hostOne more clone branch and public key per worktree, after its first push (TM-R08 already lists "team size (clone branches, keys)"). Nothing else: the host never learns that two clones share a disk

6.3 Threat-model rows to add

These use the next free TM numbers at implementation time (TM-R44 and TM-F38 at the time of writing; other designs may take numbers first).

Proposed rowTextTest
W1 (accepted risk, TM-R)Worktrees borrow objects from the main clone's store (local/alternates.json). Every borrowed object is verified by its content address on read, so a changed or half-written main store yields a miss (and a network fetch), never wrong content. A worktree whose main is removed loses the objects only the main held (its unpushed commits): dissociate first; main uninit/rekey/move/migrate apply refuse while worktrees are registeredtest_Security__Known_Gaps.py (removing the main by hand loses unpushed borrowed objects; the worktree still reads everything pushed)
W2 (fixed, TM-F)A folder inside the working copy that holds its own .sg_vault/ (a worktree or a nested clone) was scanned and its files committed into the outer vault. The scan never descends into a nested vault roottest_Security__Fixed__Hostile_Paths.py::test_nested_vault_is_never_committed
W3 (accepted risk, TM-R)Guards that protect worktrees (uninit, rekey, move, migrate) exist only in builds that know worktrees.json; an older sgit in the main folder can destroy the store worktrees borrow from. Mitigation: update the main folder's sgit with the worktreesanalysis
W4 (fixed, as part of commit 0, TM-F)Object files were written in place, so a reader in another process could see a half-written object; the CAS guard could then report a false collision. Writes are now a temp file plus os.replacetest_Vault__Object_Store atomic-write case

6.4 Path-guard test to add

tests/security/test_Security__Fixed__Hostile_Paths.py:


7. Interactions

7.1 Commands run in a worktree

CommandBehaviour
status, commit, commit --amend, write, history *, diff, checkout, resolve, merge-abort, vault stashUnchanged. All object reads go through the object store, so borrowed objects are present
pushNormal push. Objects the worktree's commits reference are read through the alternate and uploaded when the server lacks them (the push path decides by its own rules, as now). Starting from the main's unpushed HEAD means the first worktree push also pushes the main's unpushed commits, exactly as pushing a Git branch pushes its base. The worktree's clone branch is registered on its first push (pending_registration.json)
pull, fetch, fetch --unshallow, fetch <folder>Downloads only objects missing from both stores, into the worktree's own store. Baselines and the rewind guard work as in any clone (seeded from the main's at add)
branch switch, branch new, branch mergeUnchanged; a switch in the worktree does not affect the main
check fsckBorrowed objects count as present and are verified; the report names how many were borrowed. --repair writes into the worktree's own store
check verifyUnchanged (keys copied at add, missing ones fetched as today)
pack * on a pack vaultpack compact from a worktree works as from any clone (it reads through load). The worktree keeps no packs of its own; --pack-store is not offered on worktree add
vault backupIncludes borrowed reachable objects (5.11)
vault uninitRefused: use sgit worktree remove
vault move, vault rekey, migrate applyRefused in a worktree: run them in the main folder after removing or dissociating worktrees
serve, publishWork (they read through the object store); publish's own walk of bare/ is the main-vault command and is refused in a worktree, as for a partial clone
dev dumpAlready refused on partial clones; also refused in a worktree with an alternate ("dissociate first")

7.2 Commands run in the main folder while worktrees exist

CommandBehaviour
Everything that only adds objects (commit, pull, fetch, push, clone steps)Unaffected. Worktrees see new main objects immediately, because they read the store live
pack compact / pack store swap on a --pack-store mainPacks can be removed from the main's pack store while a worktree process reads through it. The read finds no pack, counts as a miss, and falls back to the network. A worktree started after the swap sees the new table
pack store extractMoves objects from packs to loose files in the main: still borrowable
vault uninit, vault rekey, vault move, migrate applyRefused while live() is non-empty, naming each worktree and the two remedies (sgit worktree remove N, sgit worktree dissociate N); --force exists only on uninit, where the backup is taken first
vault cleanRemoves the main's working copy only; the store stays; unaffected
worktree listShows every worktree's state

7.3 Clone shapes

Main clone isWorktree
FullAny shape: full, --path, --no-checkout
Scoped (--path in the main)Default scope = the main's scope; --path must be within it, otherwise the extra folders are fetched from the network on add (allowed, and said so)
ShallowInherits the boundaries; fetch --unshallow in the worktree fetches into its own store
Sparse / selectiveInherits; blobs the main never fetched are fetched on demand into the worktree's store
Bare (clone --bare, no key)Supported with --vault-key (or stdin). This is the common agent layout in Git (git clone --bare plus one worktree per branch)
Read-onlyRefused in v1 (D6)
Pack vault, loose storeBorrows loose objects
Pack vault, --pack-storeBorrows through the main's pack store (the alternate's own packs())

7.4 Other designs

DesignInteraction
Design 01 (line-level merge)Phase 2's merge goes through the same Step__Pull__Merge, so --merge lines and the per-clone merge_style apply. Each worktree has its own local/config.json, so each can choose
Design 05 (bisect)Bisect state lives in local/ and is per worktree, as Git's refs/bisect is per worktree. An agent can bisect in a worktree while the main keeps working: the main use case
Design 02 (signing with own keys)A user signature comes from the user's key, which is shared across worktrees through Git's or sgit's config; the clone signature is per worktree
Hooks (2.9)local/hooks/ is per clone; worktree add copies the main's hooks (Git shares hooks across worktrees). Copy, so a hook edited in one place does not silently change another: decision D3

8. Performance

Reference numbers, 76,000 files in 58,553 folders, in-memory host, 4 cores (10/26/release-readiness__pack-indexes-branch.md §3): full clone 61 s; clone --no-checkout 28 s, so the network and store part is about 28 s and the checkout about 33 s; clone --path raw/c09 (1,520 files) 2.8 s.

worktree add variantWork doneExpected time (to measure, acceptance item A9)
--no-checkoutCopy index, refs, keys (well under 1 MB), local files, flat cache file for the head; create clone branch; verify signatures from the policy startunder 2 s
--path raw/c09as above plus 1,520 files writtenabout 1 s plus the write
full checkoutas above plus 76,000 files written, 76,000 borrowed reads with SHA-256 each (~1 KB files: tens of ms of hashing in total)about the checkout share of a clone, ~30 s here, dominated by file creation; versus 61 s for a full clone, with zero network. On a real host the gain is larger (a clone's network share was 165 s before the bulk fetch on the DC vault, 80 s after)

Disk:

PartSize
Working copythe same as any checkout (unavoidable)
.sg_vault/bare/the branch index, refs and keys: kilobytes (the DC vault: ~40 refs and ~40 keys)
.sg_vault/local/config, keys, one flat-cache file (on the 76,000-file vault one flat cache is a few MB), scan cache (as any clone)
Objects0 at add; afterwards only the worktree's own commits and what it pulls before the main does

A full clone of the 76,000-file vault holds about 174 MB in .sg_vault; a worktree holds a few MB.

Read-path cost for clones without worktrees: one os.path.isfile on local/alternates.json per object-store instance (probed once), so nothing measurable.


9. Test plan

All tests are real: in-memory API (Vault__API__In_Memory) or Vault__Test_Env vaults, no mocks, no __init__.py under tests/. File names follow test_<Class>.py.

9.1 Unit tests

FileClassCases
tests/unit/safe_types/test_Safe_Str__Worktree_Name.pyTest_Safe_Str__Worktree_Namesanitises a/b → a_b; max 64; empty refused; round trip
tests/unit/schemas/worktree/test_Schema__Worktree__Alternates.py, …Entry.py, …Registry.pyone class eachdefaults; round-trip invariant; unknown-field tolerance
tests/unit/storage/test_Vault__Alternates.pyTest_Vault__Alternatesload absent → None; load broken JSON → None; link file refused; relative main_sg_dir refused; missing dir; self-loop; main that is itself a worktree (chain) refused; identity: an index id of another vault refused; valid → usable
tests/unit/storage/test_Vault__Object_Store__Alternates.pyTest_Vault__Object_Store__Alternatesown first: an object in both stores is read from own; borrowed read returns bytes; borrowed bytes changed on disk → Vault__Object_Missing_Error; half-written file (truncate) → miss; exists, size_of, all_object_ids, own_object_ids, is_borrowed; resolve_id resolves a borrowed short id; writes never touch the main (main's bare/data listing unchanged after store); alternate of the alternate not followed; main with --pack-store: a packed object is borrowed; no alternates file: behaviour identical to today (same calls, same results)
tests/unit/storage/test_Vault__Object_Store.py (extend)commit 0: store/store_raw/store_at leave no temp file, never a partial target (simulate by writing then reading from another handle)
tests/unit/storage/test_Vault__Worktree_Registry.pyTest_Vault__Worktree_Registryadd/find/remove; duplicate name/path refused; corrupt file raises; state_of OK/MISSING/FOREIGN; live includes locked missing; lock file stale after 60 s
tests/unit/core/test_Vault__Ignore__Nested_Vault.pyTest_Vault__Ignore__Nested_Vaultnested root detected; root itself never "nested"
tests/unit/sync/test_Vault__Sync__Nested_Vault_Scan.pyTest_Vault__Sync__Nested_Vault_Scana nested clone inside the working copy: status does not list its files and says it was skipped; commit does not commit them; conflict-file walks skip it
tests/unit/worktree/test_Vault__Worktree__Add.pyTest_Vault__Worktree__Addadd from a full clone: files equal the main's HEAD, own bare/data empty, alternates.json 0600, vault_key 0600, new clone branch id and .pem differ from the main's; -b new creates the named branch; --branch existing; --from HEAD~1; --path scope; --no-checkout; bare main with --vault-key, wrong key writes nothing; refusals: target non-empty, target inside .sg_vault, main is a worktree, read-only main, merge in progress, duplicate name; failure mid-way leaves no .sg_vault at the target and no registry entry; zero API calls during add (count Vault__API__In_Memory calls)
tests/unit/worktree/test_Vault__Worktree__Lifecycle.pyTest_Vault__Worktree__Lifecyclelist shapes (text, porcelain, json); remove refuses dirty, unpushed, locked, merge in progress; --force; removes files and entry; secrets zero-filled; prune drops missing and foreign, keeps locked; lock/unlock; dissociate copies exactly the reachable borrowed set, removes the pointer, and the worktree then works with the main deleted
tests/unit/worktree/test_Worktree__Two_Way.pyTest_Worktree__Two_Wayworktree commit + push, main branch merge sees it; main commit + push, worktree pull downloads 0 objects when the main already pulled; worktree push of a start commit that was unpushed in the main uploads those objects; first push of a never-pushed vault from a worktree refused; signatures-required vault: worktree add verifies, worktree push verifies on the main's pull
tests/unit/worktree/test_Worktree__Guards.pyTest_Worktree__Guardsmain uninit refused with live worktree, allowed with --force (backup taken); rekey refused; move refused; migrate apply refused; in a worktree: uninit, move, rekey, dev dump, publish refused with the remedy; vault backup in a worktree restores into a fresh folder with no main present
tests/unit/worktree/test_Worktree__Fsck.pyTest_Worktree__Fsckborrowed count reported; corrupt borrowed object reported as corrupt in the main; --repair writes to own store
tests/unit/worktree/test_Worktree__Pack_Vault.pyTest_Worktree__Pack_Vaultpack vault, loose main; pack vault, --pack-store main; main compaction swap while a worktree reads → miss then network
tests/unit/worktree/test_Merge_Local.py (phase 2)Test_Merge_Localfast-forward from a worktree; three-way with conflict files; source's public key imported only when it decrypts; under signatures-required a source commit with a bad signature refuses before any write; baselines and named ref untouched; a source of another vault refused; source with merge in progress refused; dirty-tree guard applies
tests/unit/cli/test_CLI__Worktree.pyTest_CLI__Worktreeparsing, exit codes 0/1/2, output lines, --json keys exactly as 3.4
tests/unit/cli/test_CLI__B07__Namespace_Moves.pyexistingTOP_LEVEL_COMMANDS includes worktree (D1)

9.2 Security tests

FileTest
tests/security/test_Security__Fixed__Hostile_Paths.pytest_worktree_pointer_cannot_be_planted_by_vault_content; spellings extended (6.4); test_nested_vault_is_never_committed (W2)
tests/security/test_Security__Known_Gaps.pyW1: main removed by hand → worktree reads pushed objects from the network, unpushed borrowed ones are reported missing; W3 analysis row has no test

9.3 Integration

tests/integration/test_Worktree__Live.py (Python 3.12 venv, the in-memory sgraph server): add, commit, push, main merge, pull, remove: the full loop against the real server code. One run with a pack vault.

9.4 Acceptance checklist (implementation and deployment)

#CheckHow
A1sgit worktree add ../wt on a pushed vault makes zero network requestsAPI call counter in the unit test; on the live dev server, run with the network blocked (SGIT_DEFAULT_BASE_URL to a closed port is not enough, since the server is pinned; run with no route) and the command still succeeds
A2The worktree's files are byte-identical to the main's HEADdiff -r excluding .sg_vault
A3The worktree's bare/data holds no objects after add; the main's bare/data is unchanged after any worktree commandlisting before/after
A4A commit in the worktree, pushed, then sgit branch merge <named> in the main, gives the main the changescripted
A5Phase 2: sgit branch merge --worktree wt in the main gives the same tree as A4 with no pushscripted
A6Removing the main folder by hand: the worktree still statuses, pulls and pushes (network fallback); fsck names what was lostscripted
A7uninit, rekey, move, migrate apply in the main refuse while a worktree exists, and name itscripted
A8A nested worktree inside the main working copy is never committed by the mainscripted
A9Timings on the 76,000-file vault: add --no-checkout < 2 s; full add < 50 % of a full clone on the same machinemeasured, recorded in the CHANGELOG
A10pytest tests/unit -n auto and pytest tests/security green; tests/integration worktree test green on 3.12CI
A11Older sgit (0.26.x) opening a worktree: commands work (slowly, by network), nothing is corruptedmanual, one run
A12sgit help all lists worktree; TOP_LEVEL_COMMANDS updatedtest
A13Threat-model rows W1–W4 added with their tests; CHANGELOG entry; README section "Worktrees"review

10. Implementation steps (ordered commits)

#CommitContentsEstimate
0fix(storage): object files written atomicallytemp + os.replace in Vault__Object_Store.store/store_raw/store_at and Vault__Verified_Write.save; tests; TM row W40.25 day
1feat(storage): object store reads through a verified alternateVault__Alternates, schemas, object store changes, test_Vault__Alternates, test_Vault__Object_Store__Alternates0.5 day
2fix(scan): never descend into a nested vaultis_nested_vault_root, walks, status line, tests, TM row W20.25 day
3feat(worktree): sgit worktree addregistry, workflow and steps, Vault__Worktree.add, CLI__Worktree, CLI registration, TOP_LEVEL_COMMANDS, help all, tests1 day
4feat(worktree): list, remove, prune, lock, unlock, dissociatemethods, CLI, tests0.5 day
5feat(worktree): guards and interactionsuninit/rekey/move/migrate guards, backup materialisation, first-push refusal, fsck reporting, dump/publish refusals, security tests, TM rows W1/W30.5 day
6docs: worktreesREADME section, CHANGELOG, threat model, response note with measured timings (A9)0.25 day
7feat(branch): merge from a local worktree (phase 2)Workflow__Merge_Local and steps, merge_local, --worktree flag, tests, integration test1–1.5 days

Phase 1: about 3.25 days (commits 0–6). Phase 2: 1–1.5 days. Commits 0 and 2 are useful on their own and can land first.


11. Open decisions for Dinis

#DecisionOptionsRecommendation
D1Command placement (Rule 9)top-level sgit worktree · sgit vault worktreeTop level, as Git; agents already type worktree. Edit TOP_LEVEL_COMMANDS in the same commit
D2The vault key in a worktreecopy (0600) · reference the main's fileCopy: a worktree must survive the main moving, and every clone already holds its key this way
D3Hooks (when 2.9 lands)shared with the main · copied at add · noneCopied at add: predictable, no hidden coupling
D4A worktree inside the main working copyallow with a warning · refuseAllow with a warning (Git allows it); the nested-vault scan rule makes it safe
D5Clone branches left in the vault by removed worktreesleave (as today for any deleted clone) · add branch delete for unused clone branches laterLeave for now; note it under roadmap item 2.10 (prune)
D6Worktrees of a read-only clonerefuse · support as read-only worktrees (copy clone_mode.json, no clone branch)Refuse in v1; easy to add if asked
D7Start pointthe main's HEAD including unpushed commits (Git) · the last known named-branch headThe main's HEAD, as Git; --from covers the other case
D8Phase 2 now or laterwith phase 1 · after agents have used phase 1With phase 1 if the integrating agent pattern (one coordinator merging many worktrees) is the main use; otherwise after. It is a self-contained commit either way

This page as markdown · its source, admin/content/research/git-parity-design-worktree.md