cli.sgit.ai / Research / Design 03: partial commits (commit <paths>, commit -p) and hooks

Design 03: partial commits (commit <paths>, commit -p) and hooks

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__03__partial-commits-and-hooks.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.

Roadmap items: 2.3 (commit some files, not all) and 2.9 (hooks), from roadmap__git-parity.md (same folder). Status: design, not implemented. Written against 3a1d72a on claude/charming-babbage-5hzcxq (code identical to dev). Git reference: git 2.43.0. Every Git behaviour quoted below was run against that binary in a scratch repository on 28 Oct, unless marked "(docs)". Extension rule: nothing changes for a user who does not use these features. sgit commit with no paths still commits the whole working copy. A clone with no hooks folder runs nothing. No vault format change; no min_client gate.


1. Summary and the exact Git behaviour matched

1.1 sgit commit <pathspec>… (whole-file partial commits)

Git's git commit <pathspec> is git commit --only <pathspec>. It takes HEAD's tree, replaces the matched tracked paths with their working-tree content (or removes them if deleted), and commits only that. Everything else stays uncommitted, untouched on disk. Measured on git 2.43:

CaseGit 2.43 resultsgit (this design)
Modified tracked file namedcommitted; the other edits stay dirtysame
Tracked file deleted on disk, namedthe deletion is committedsame
Folder named (d)every changed tracked path under d/same, plus new files under d/ (1.4)
Glob ('*.txt', quoted so the shell leaves it)* also crosses / (fnmatch without FNM_PATHNAME)same rule
Untracked file namederror: pathspec 'new.txt' did not match any file(s) known to git, exit 1committed (1.4)
Name that matches nothingsame error, exit 1pathspec 'zzz' did not match any file known to sgit, exit 1
Named path unchanged"nothing added to commit…", exit 1"Nothing to commit…", exit 0 (sgit's existing exit code, kept)
A merge is in progressfatal: cannot do a partial commit during a merge., exit 128refused with the same words, exit 1
--include / -istages the paths, commits index + pathsnot offered: no index (decision D1)

1.2 sgit commit -p (hunk selection)

Git has no commit -p of its own name for this; git commit -p runs git add -p on a temporary index and commits the result. The interactive keys of git 2.43 add -p: y n q a d j J g / e s p ? (also k K): yes / no / quit / all remaining in file / none remaining in file / leave undecided and go next / previous / go to / search / edit hunk / split hunk / print. sgit matches y n q a d s ? plus j k navigation. e (edit the hunk in an editor) is a non-goal for the first cut (decision D4). The working-tree file is never modified; only the committed blob is the partial content.

1.3 Hooks

Git runs executables named after the event from $GIT_DIR/hooks/ (or core.hooksPath). Measured and from githooks(5):

HookWhenArgumentsstdinExit status--no-verify skips it
pre-commitbefore the commit message is preparednonenonenon-zero aborts the commityes
prepare-commit-msgafter the default message is written to the message file, before the editor$1 message file, $2 source (message with -m, merge, squash, commit with -c/-C/--amend without -m, template, or absent), $3 commit id (only for source commit)nonenon-zero abortsno (measured: it ran with --no-verify)
commit-msgafter the message is final$1 message file (hook may rewrite it)nonenon-zero abortsyes
post-commitafter the commit is madenonenoneignoredno
pre-pushafter the remote refs are known, before anything is sent$1 remote name, $2 URLone line per ref: <local ref> SP <local id> SP <remote ref> SP <remote id> LF, a missing remote ref is forty 0 (measured: refs/heads/master 41b8… refs/heads/master 0000…)non-zero aborts the push, nothing sentyes
post-mergeafter a successful merge (also a fast-forward pull); not after a merge that stopped on conflicts$1 1 if squash else 0noneignoredno
post-checkoutafter a checkout / switch (and clone, worktree add)$1 previous HEAD, $2 new HEAD, $3 1 branch checkout or 0 file checkoutnonebecomes the command's exit status (measured: a hook exiting 3 made git checkout exit 1) but cannot undo itno

Further Git facts the design copies:


2. Scope and non-goals

In scope

  1. sgit commit [-m MSG] [--] <pathspec>…, with folders, globs, deletions, new files, --amend, --json.
  2. sgit commit -p [<pathspec>…] interactive, and a non-interactive hunk selection for agents (--list-hunks, --hunk ID).
  3. Hooks: pre-commit, prepare-commit-msg, commit-msg, post-commit, pre-push, post-merge, post-checkout; --no-verify / -n; a per-clone hooks path (core.hooksPath equivalent).
  4. Prerequisite: sgit push stops refusing a dirty working copy (5.4). Without it, a partial commit cannot be pushed, which defeats the feature.
  5. sgit vault hooks to show the hooks path and what is installed (decision D6 on the name).

Non-goals (first cut)


3. CLI surface

3.1 sgit commit

sgit commit [MESSAGE] [-m MSG] [-d DIR] [--allow-deletions] [--allow-secret-file PATH]…
            [--amend] [-p | --patch] [--hunk ID]… [--list-hunks] [-n | --no-verify] [--json]
            [-- PATHSPEC…]

Disambiguating the positional message. Today sgit commit "msg" takes the first positional as the message (sgit_ai/cli/CLI__Main.py:375). Git takes positionals as pathspecs. The rule, chosen so that every existing invocation keeps its meaning:

InvocationMeaning
sgit commitwhole working copy (today)
sgit commit "fix typo"whole working copy, message "fix typo" (today)
sgit commit -m "fix" a.txt b/paths a.txt, b/ (Git's form: with -m, every positional is a pathspec)
sgit commit "fix" -- a.txtmessage "fix", path a.txt
sgit commit -- a.txtpath a.txt, auto message
sgit commit -m "x" "y"changes meaning: today -m silently overrides the positional y; in this design y is a pathspec. Searched our docs, skills and tests: no use of both. The parser prints note: with -m, "y" is a path when that path matches nothing, so the old habit fails loudly, never silently (decision D2).

argparse implementation: commit_parser.add_argument('args', nargs='*') replaces the message positional; CLI__Vault.cmd_commit splits it: with -m, all of args are pathspecs; without -m, the part before a literal -- must be at most one item (the message) and everything after -- is a pathspec. argparse drops a bare -- from the list, so the split point comes from the raw argv (implementation note at the end of section 10).

Flags added

FlagMeaning
-- PATHSPEC… / positionals with -mcommit only the matched paths (5.1)
-p, --patchinteractive hunk selection over the matched paths (all changed text files when none given); needs a TTY on stdin and stdout, else exit 2 with "use --list-hunks / --hunk for non-interactive selection"
--list-hunksprint the hunks of the matched paths and exit 0; nothing committed. With --json, a machine list (3.3)
--hunk ID (repeatable)non-interactive selection: commit exactly these hunks (ids from --list-hunks); implies -p semantics, no TTY needed
-n, --no-verifyskip pre-commit and commit-msg (Git's scope; prepare-commit-msg and post-commit still run)
--jsonone JSON object on stdout (3.3)

Output (text), unchanged for the whole-copy commit. For a path commit:

Committed 2 file(s) to clone-abc… (2 of 7 changed; 5 left uncommitted).
  Commit: obj-cas-imm-…
  Left uncommitted: notes.md, src/x.py, … (sgit status)

Exit codes

CodeWhen
0committed; or "Nothing to commit" (existing behaviour, kept)
1refused: a pathspec matched nothing, partial commit during a merge, a path outside a scoped clone, secret guard, a hook rejected (pre-commit hook failed (exit 3): nothing was committed), commit-msg emptied the message
2usage: -p without a TTY and without --hunk; --hunk and --list-hunks together; an unknown hunk id

3.2 sgit push, pull, branch switch, branch checkout, checkout, branch merge

One flag is added where Git has one: sgit push -n / --no-verify (skips pre-push). Pull, switch, checkout and merge only gain the post-hooks, which --no-verify does not affect in Git either. sgit write --push gains --no-verify for its push.

3.3 JSON shapes

sgit commit --json (success):

{"status": "committed", "commit_id": "obj-cas-imm-…", "branch_id": "clone-…", "message": "…",
 "files_changed": 2, "paths": ["a.txt", "b/c.md"], "left_uncommitted": ["notes.md"],
 "hunks": {"src/app.py": ["h-3f9a2c41d07b", "h-91aa…"]},
 "hooks": {"pre-commit": 0, "prepare-commit-msg": null, "commit-msg": 0, "post-commit": 0}}

hooks values: exit code, or null when not installed / not run. status is nothing_to_commit with no commit_id when that applies. Refusals print {"status": "refused", "reason": "…", "paths": […]} and exit 1.

sgit commit --list-hunks --json:

{"base_commit": "obj-cas-imm-…",
 "files": [
   {"path": "src/app.py", "binary": false, "status": "modified",
    "base_hash": "…", "work_hash": "…",
    "hunks": [
      {"id": "h-3f9a2c41d07b", "index": 1, "header": "@@ -10,6 +10,8 @@ def main():",
       "old_start": 10, "old_lines": 6, "new_start": 10, "new_lines": 8,
       "added": 2, "removed": 0, "text": " ctx\n+new line\n…",
       "splittable": true}
    ]},
   {"path": "logo.png", "binary": true, "status": "modified", "hunks": [{"id": "h-whole-…", "index": 1, "whole_file": true}]}
 ]}

Hunk ids are content-derived, never positional, so an agent's selection cannot land on the wrong hunk if the file changed between listing and committing: id = "h-" + sha256(path NUL base_hash NUL old_start NUL old_lines NUL removed lines NUL added lines)[:12]. Split sub-hunks get their own ids, listed with --list-hunks --split (each splittable hunk is replaced by its parts). --hunk refuses (exit 2) any id that is not in the freshly computed list, naming it, so a stale id fails loudly. New files list as one whole-file hunk; deletions as one whole-file "delete" hunk.

3.4 sgit vault hooks

sgit vault hooks                 # where hooks are read from, and each known hook: installed / executable / missing
sgit vault hooks --path DIR      # set the hooks path for this clone (core.hooksPath); relative = to the vault root
sgit vault hooks --unset         # back to .sg_vault/local/hooks
sgit vault hooks --json

Rule 9: hooks are per-clone configuration, not a daily verb, so they live under vault, not at the top level; TOP_LEVEL_COMMANDS does not change. (Git: git config core.hooksPath. sgit has no config command; decision D6.)


4. How the code works today

4.1 Commit

WhatWhere
CLI parser: positional message, -m, -d, --allow-deletions, --allow-secret-file, --amendsgit_ai/cli/CLI__Main.py:374-389
CLI handler; "Nothing to commit" exits 0sgit_ai/cli/CLI__Vault.py:794-822
Facadesgit_ai/core/Vault__Sync.py:148-154
Commit flowsgit_ai/core/actions/commit/Vault__Sync__Commit.py:32-203
Load HEAD's flat map (scoped: Vault__Scoped_Tree.flatten → (flat, opaque); full: flatten + flatten_dirs)Vault__Sync__Commit.py:69-78
Full working-copy scan (with scan cache), collecting .conflict files on the wayVault__Sync__Commit.py:80-81
Secret guard on the changed paths onlyVault__Sync__Commit.py:82-86
Scoped clone: any working-copy path outside the scope refuses by nameVault__Sync__Commit.py:88-95
Sparse / selective branch: overlay on-disk files on HEAD's flat map; a checked-out path missing on disk is a deletion; then build_from_flatVault__Sync__Commit.py:97-135
Full branch: Vault__Sub_Tree.build with old_flat_entries + old_tree_ids (the "one-file fast path")Vault__Sync__Commit.py:136-145
Merge state read; has_conflicts uses the scan's listVault__Sync__Commit.py:147-151
--amend plan; "nothing to commit" when the root tree equals HEAD'sVault__Sync__Commit.py:154-160, _amend_plan :205-229
Create the signed commit, write the clone refVault__Sync__Commit.py:180-188
write_file (sgit write): HEAD's flat map + named files → build_from_flat → commit; writes the files, then the refVault__Sync__Commit.py:231-350 (build :325, files then ref :339-344)
write's path guardVault__Sync__Commit.py:352-372

4.2 Trees

WhatWhere
build(directory, file_map, …, old_flat_entries, opaque, old_tree_ids): an entry whose scanned size and hash equal HEAD's is reused without reading the file (unchanged, :58-63); reading is O_NOFOLLOW and never through a link (:65-81)sgit_ai/storage/Vault__Sub_Tree.py:32-92
build_from_flat(flat_map, read_key, opaque): no folder reuse, every folder is rebuilt and re-encrypted (deterministic, so the ids are right, but the cost is the whole vault)Vault__Sub_Tree.py:105-131
Folder reuse: a folder whose file names, sub-folders and files are all unchanged keeps its old tree idVault__Sub_Tree.py:556-642, reuse test :593-604
Blob reuse by content hash; otherwise encrypt (deflated on blob-deflate vaults) and storeVault__Sub_Tree.py:517-529

4.3 Scan and scan cache (sgit's half of Git's index)

WhatWhere
scan-cache.json under .sg_vault/local/: {path: [mtime_ns, ctime_ns, size, ino, dev, hash]}, version 2sgit_ai/core/Vault__Sync__Base.py:32-35, load :487-504, save :506-517
Git's racy rule (2 s)Vault__Sync__Base.py:35, :541-545, :592-599
The scan: ignores, links never followed, unreadable never "deleted", FIFOs keptVault__Sync__Base.py:519-636
Every sgit write seeds the cache with the real hash_checkout_flat_map :674-714, _scan_cache_seed :716-736
Checkout writes a file in place (open(path, 'wb')), so an existing file keeps its mode bitsVault__Sync__Base.py:701-703

4.4 Push, pull, switch, checkout (where hooks would fire)

WhatWhere
Push refuses a dirty working copy: status() then "Working directory has uncommitted changes. Commit your changes before pushing."sgit_ai/core/actions/push/Vault__Sync__Push.py:92-96
Push's implicit pull, then the final headsVault__Sync__Push.py:177-186
Pull refuses during a merge; runs Workflow__Pullsgit_ai/core/actions/pull/Vault__Sync__Pull.py:80-121
Merge outcome (up_to_date, fast_forward, merge, conflict, rewound)sgit_ai/workflow/pull/Step__Pull__Merge.py:54-156
Pull's dirty-tree guard (carries dirty files the merge does not touch, refuses the ones it would)sgit_ai/core/actions/pull/Vault__Pull__Guard.py:28-127
Branch switch (refuses dirty unless --force)sgit_ai/core/actions/branch/Vault__Branch_Switch.py:30, _assert_clean :339
Selective checkout of filessgit_ai/core/actions/checkout/Vault__Sync__Checkout.py:34
branch merge is a pull with merge_fromsgit_ai/core/Vault__Sync.py:230-234

4.5 Paths, the CLI root, secrets in the environment

WhatWhere
The CLI walks directory up to the vault rootsgit_ai/cli/CLI__Main.py:1170-1183, Vault__Storage.find_vault_root sgit_ai/storage/Vault__Storage.py:87-96
Vault content can never name .sg_vault* or .git (any spelling)sgit_ai/storage/Vault__Path_Guard.py:27-28, safe_join :134-148
Environment variables sgit reads: SGIT_DEFAULT_BASE_URL, SGIT_HTTP_NO_KEEPALIVE, SGIT_TRACE, SGIT_PACK_INDEXES(_READ) and SG_SEND_PASSPHRASE (the credential store passphrase: a secret)grep of sgit_ai/
TM-F06: sgit update ran python -m pip with the cwd on sys.path; fixed with python -Ithreat model row TM-F06
Local config schema (per clone, never synced; unknown keys are accepted by from_json)sgit_ai/schemas/Schema__Local_Config.py:9-22
Rule 9 listtests/unit/cli/test_CLI__B07__Namespace_Moves.py:104-109

5. Design

5.1 Pathspec semantics

New class Vault__Pathspec in sgit_ai/core/actions/commit/Vault__Pathspec.py:

class Vault__Pathspec(Type_Safe):
    patterns : list[Safe_Str__Pathspec]        # as typed, normalised to '/' separators
    prefix   : Safe_Str__Pathspec = None        # cwd relative to the vault root ('' at the root)

    def from_args(self, args: list, cwd: str, root: str) -> 'Vault__Pathspec'
    def is_glob(self, pattern: str) -> bool             # contains * ? or [
    def matches(self, path: str) -> bool                # any pattern
    def match(self, head_paths, disk_paths) -> tuple    # (matched: set, unmatched_patterns: list)

New safe type Safe_Str__Pathspec (sgit_ai/safe_types/Safe_Str__Pathspec.py): max_length 4096, regex rejects only control characters [\x00-\x1f\x7f] (paths may be any Unicode; Safe_Str__File_Path would rewrite * ? [ to _, see sgit_ai/safe_types/Safe_Str__File_Path.py:4). trim_whitespace = False (a file name may end in a space).

Rules (each one a test):

InputRule
Relative pathrelative to the cwd, as in Git (cd src && sgit commit -m x app.py means src/app.py). prefix = relpath(cwd, root) when the cwd is inside the vault, else '' (then relative to the root, which is how -d DIR users think).
..allowed while it stays inside the vault (cd src && sgit commit -m x ../README.md); a pattern that resolves outside the root is refused: pathspec '../../x' is outside the vault (exit 1).
Absolute pathaccepted if under the root; converted; else refused as above.
Literal file pathmatches that path (tracked in HEAD, or a new file on disk).
Literal folder (docs, docs/, .)matches every path under it, in HEAD or on disk. . is everything under the cwd.
Glob (* ? [abc] [!abc])fnmatch.fnmatchcase(path, prefix + pattern) over full vault paths; * crosses / (Git's default, measured). Case-sensitive on every OS (Git's default; decision D3 keeps it).
A path matched only on disk and ignored by .gitignorenot matched (Git: an ignored untracked file is not "known"); if a literal pattern names it, refuse with pathspec 'x' is ignored by .gitignore (exit 1). A tracked ignored file matches (sgit's "tracked wins", tests/unit/sync/test_Vault__Ignore__Tracked_Wins.py).
.sg_vault*, .gitnever matched (the scan never lists them). A *.conflict file is matched like any file, as the whole-copy commit takes it; during a merge every path commit is refused anyway. (Corrected at implementation: the scan does list .conflict files.)
A pattern that matches nothing in HEAD ∪ diskerror naming every such pattern, exit 1, nothing written (Git).
A matched path that is unchangedsimply not in the commit; if nothing matched changed: "Nothing to commit" (exit 0, existing).
Renamesthere is no rename in sgit's tree model: a moved file is a delete plus an add. sgit commit -m mv old.txt new.txt names both; naming only new.txt commits the add and leaves old.txt in HEAD (Git does the same: it commits what you name).

match(head_paths, disk_paths) takes the union of HEAD's flat paths and the scan's paths, so a deleted tracked file (in HEAD, not on disk) matches and becomes a deletion.

5.2 Building the commit tree from HEAD plus the selected paths

New method Vault__Sync__Commit._commit_selected(directory, c, old_flat, old_tree_ids, opaque, selected: dict), where selected is {path: bytes | None} (None = delete). It is the same shape as write_file's core, so the two share it:

  1. flat = dict(old_flat). For each path, data in selected: None → flat.pop(path); bytes → encrypt_or_reuse_blob(data, old_flat.get(path), read_key) (Vault__Sub_Tree.py:517), entry {blob_id, size, content_hash, content_type, large} as write_file does (Vault__Sync__Commit.py:310-314).
  2. Root tree: sub_tree.build_from_flat(flat, read_key, opaque=opaque, old_tree_ids=old_tree_ids, file_unchanged=lambda p: p not in selected).
  3. Change to Vault__Sub_Tree.build_from_flat (Vault__Sub_Tree.py:105): add the parameters old_tree_ids: dict = None and file_unchanged: callable = None and pass them, with old_flat_entries=flat, to _build_tree_from_dir_contents (:556), which already implements the reuse. Today build_from_flat rebuilds every folder; with the reuse a pathspec commit (and sgit write, and every merge) builds only the folders on the selected paths' spines. Callers that pass nothing behave exactly as today (both default to None). Correctness: a folder is reused only if its names and every file in it are unchanged and its old tree is in the store (:593-604), and tree ids are content addresses, so a reused id is byte-identical to a rebuilt one. The test test_build_from_flat__reuse_gives_the_full_build_ids builds both ways and compares every id.
  4. Same signing, same create_commit call, same ref write as commit() (Vault__Sync__Commit.py:162-188).

commit() gains the parameters paths: list = None, hunks: list = None, patch: bool = False, no_verify: bool = False, hooks: Vault__Hooks = None (5.5). When paths and hunks are both empty and patch is false, the existing code path runs unchanged (the only edit to it is the hook calls).

Selected-path flow (new private method _commit_paths):

  1. Refusals first, before any read: merge in progress → Vault__Partial_Commit_Error('cannot do a partial commit during a merge') (new error class in sgit_ai/core/Vault__Errors.py).
  2. HEAD's flat map as today (:69-78).
  3. Scan only what the pathspec can reach:
  1. Vault__Pathspec.match(head_paths, disk_paths) → matched paths; unmatched patterns → refuse.
  2. Changed = matched paths whose disk hash differs from HEAD's, plus matched HEAD paths missing on disk (deletions). Sparse / selective clones: a HEAD path not on disk is a deletion only if it is in the checkout set (Vault__Checkout_Set, as Vault__Sync__Commit.py:102-111), unless --allow-deletions.
  3. Scoped clone: any changed path outside the scope → the existing refusal (:88-95), naming only the matched ones.
  4. Secret guard on the changed paths (Vault__Secret_Guard.refuse_files, as :85-86).
  5. Read the content of each changed path with Vault__Path_Guard.read_regular (O_NOFOLLOW).
  6. _commit_selected(...), then the commit as above. Nothing on disk is written; the scan cache needs no update (it describes the working copy, which did not change).
  7. Result dict adds paths (sorted committed paths) and left_uncommitted (changed paths not selected, from the scan when one was done, else omitted).

--amend with paths: base flat map = HEAD's tree (the commit being replaced), parents = HEAD's parents, exactly _amend_plan (:205-229), with the selected paths overlaid. Git does the same (git commit --amend <paths>).

5.3 Hunk selection (-p, --hunk, --list-hunks)

As implemented (28 Oct): the changes come from the shared differ Vault__Line_Diff (Myers with Git's change compaction, design__00 F1), not difflib, so hunk edges are where git diff puts them; hunks are grouped as xdiff does (≤ 2 × context equal lines between two changes share a hunk). Files up to 8 MiB (the line merge's cap). Schemas live in sgit_ai/schemas/commit/; a hunk carries its change blocks as list[Schema__Line_Hunk] and its body as one text string. --hunk accepts ids from both the plain and the --split listing. Prompt keys y n a d s j k q ?; end of input chooses nothing.

New class Vault__Hunks in sgit_ai/core/actions/commit/Vault__Hunks.py (pure: bytes in, bytes and schemas out):

class Vault__Hunks(Type_Safe):
    context : Safe_UInt__Hunk_Context = 3      # lines of context, Git's default (-U3)
    max_bytes : Safe_UInt__File_Size = 10 * 1024 * 1024   # above this a file is whole-file only

    def hunks_of(self, path: str, base: bytes, work: bytes) -> Schema__File_Hunks
    def split(self, hunk: Schema__Hunk) -> list           # [Schema__Hunk], 1 element when not splittable
    def apply(self, base: bytes, work: bytes, chosen: list) -> bytes   # chosen: [Schema__Hunk]
    def hunk_id(self, path: str, base_hash: str, hunk) -> str

New schemas (sgit_ai/schemas/commit/):

SchemaFields
Schema__Hunkhunk_id : Safe_Str__Hunk_Id, index : Safe_UInt, old_start / old_lines / new_start / new_lines : Safe_UInt, removed : list[Safe_Str__Diff_Line], added : list[Safe_Str__Diff_Line], context_before / context_after : list[Safe_Str__Diff_Line], header : Safe_Str__Diff_Header, splittable : bool, whole_file : bool, and internal opcode bounds a_lo / a_hi / b_lo / b_hi : Safe_UInt (line indexes into base and work)
Schema__File_Hunkspath : Safe_Str__Pathspec, status : Enum__Hunk_File_Status (modified, added, deleted), binary : bool, base_hash / work_hash : Safe_Str__Content_Hash, hunks : list[Schema__Hunk]
Schema__Hunk_Listbase_commit : Safe_Str__Commit_Id, files : list[Schema__File_Hunks]

New safe types: Safe_Str__Hunk_Id (^h-[0-9a-f]{12}$), Safe_Str__Diff_Line (any text, no length cap below the file cap; \r kept), Safe_Str__Diff_Header, Safe_UInt__Hunk_Context, Enum__Hunk_File_Status. Every schema passes the round-trip invariant.

Algorithm

  1. Lines: base.splitlines(keepends=True) and work.splitlines(keepends=True) on bytes, so CRLF and a missing final newline survive exactly. Text decoding only for display (errors='replace').
  2. Binary (a NUL in the first 8 KB, the rule of Vault__Diff._is_binary, sgit_ai/core/actions/diff/Vault__Diff.py:534) or larger than max_bytes: one whole_file hunk.
  3. difflib.SequenceMatcher(None, a, b, autojunk=False).get_grouped_opcodes(context) gives Git-shaped hunks (autojunk=False, else long files with repeated lines get worse diffs than Git's). Each group is one hunk; old_start / new_start are 1-based as in a unified header. Header text: @@ -s,l +s,l @@ plus, for text files, the nearest preceding line matching ^[A-Za-z_$] (a cheap stand-in for Git's funcname; display only).
  4. Split (Git's s): a hunk whose group has more than one non-equal opcode separated by equal runs splits at each such run; each part keeps up to context lines on each side. A hunk with a single change block is not splittable (splittable = false), as in Git.
  5. Apply: walk base lines; for each opcode of the full diff, emit the work side if its hunk (or sub-hunk) was chosen, else the base side. Unchosen insert emits nothing, unchosen delete keeps the base lines. Since every chosen block is an opcode of the same SequenceMatcher run, blocks never overlap and the result is well defined. Property tests: choose all → work; choose none → base; any subset → git apply --cached of the same hunks gives the same bytes (Git as oracle, 9.2).
  6. New file: one hunk (all added). Choosing it adds the file; not choosing leaves it out. Deleted file: one hunk; choosing it deletes. Git's add -p behaves the same for intent-to-add / deleted files.
  7. The partial content becomes a blob through _commit_selected (5.2) as {path: bytes}. The working copy is never written. The scan cache is not touched. After the commit sgit status shows the file as modified (the rest of the hunks), which is exactly Git's state after add -p + commit.

Base content. HEAD's blob for the path: obj_store.load(blob_id) + crypto.decrypt. On a sparse, scoped or pack-store clone the blob of a file that is on disk is local (it was written from it); if not, fetch it with the existing blob fetcher (Vault__Sync__Base._make_blob_fetcher, Vault__Sync__Base.py:81) and verify by content address as every fetch does. Read-only clones refuse commit already.

Interactive prompt — new class CLI__Hunk_Prompt (sgit_ai/cli/CLI__Hunk_Prompt.py), using input():

diff --sgit a/src/app.py b/src/app.py
@@ -10,6 +10,8 @@ def main():
 …
(1/3) Commit this hunk [y,n,q,a,d,s,j,k,?]?
KeyAction
y / ntake / skip this hunk
a / dtake / skip this and every later hunk in the file
ssplit (only offered when splittable)
j / kleave undecided, next / previous
qstop; hunks decided so far count, the rest are skipped
?help

Colour only when stdout is a TTY and NO_COLOR is unset. Ctrl-C aborts with nothing committed (exit 130, as CLI__Main.py:1062 does today).

Non-interactive: --list-hunks [--split] [--json] prints; --hunk ID… selects. Both compute the hunks the same way from the same inputs, so the ids agree as long as the file and HEAD did not change; if either changed the id is gone and --hunk exits 2 naming it.

5.4 Prerequisite: push with a dirty working copy

Vault__Sync__Push.push refuses any uncommitted change (Vault__Sync__Push.py:92-96). After a partial commit there are uncommitted changes by definition, so push would always refuse. Git pushes with a dirty tree.

Change: drop the clean refusal. Keep:

The status call (:93) stays only to print a one-line note: note: 5 uncommitted change(s) stay local (not pushed). push --force skips the pull (:178) and so never touches the working copy: unchanged.

Test: test_push__dirty_tree_not_touched_by_incoming_is_pushed and test_push__dirty_file_the_pull_would_change_refuses (two clones on the in-memory API).

5.5 Hooks runner

New package sgit_ai/core/hooks/ with:

class Enum__Hook_Name(Enum):                     # sgit_ai/safe_types/Enum__Hook_Name.py
    PRE_COMMIT = 'pre-commit'; PREPARE_COMMIT_MSG = 'prepare-commit-msg'; COMMIT_MSG = 'commit-msg'
    POST_COMMIT = 'post-commit'; PRE_PUSH = 'pre-push'; POST_MERGE = 'post-merge'; POST_CHECKOUT = 'post-checkout'

class Schema__Hook_Result(Type_Safe):            # sgit_ai/schemas/hooks/Schema__Hook_Result.py
    hook        : Enum__Hook_Name = None
    ran         : bool            = False
    exit_code   : Safe_Int        = 0
    duration_ms : Safe_UInt
    path        : Safe_Str__File_Path = None     # the file run (display only)

class Vault__Hooks(Type_Safe):                   # sgit_ai/core/hooks/Vault__Hooks.py
    disabled : bool = False                      # --no-verify sets the verify hooks off (see VERIFY_HOOKS)
    timeout  : Safe_UInt__Hook_Timeout = 0        # seconds; 0 = none (Git has none)

    def hooks_dir(self, directory: str) -> str
    def find(self, directory: str, hook: Enum__Hook_Name) -> str            # path or ''
    def run(self, directory: str, hook: Enum__Hook_Name, args: list = None,
            stdin: bytes = b'', no_verify: bool = False) -> Schema__Hook_Result
    def environment(self, directory: str, hook: Enum__Hook_Name) -> dict
    def message_file(self, directory: str) -> str                           # .sg_vault/local/COMMIT_EDITMSG

class Vault__Hook_Error(Exception)                # sgit_ai/core/Vault__Errors.py: "<hook> hook failed (exit N): <what was not done>"

VERIFY_HOOKS = {PRE_COMMIT, COMMIT_MSG, PRE_PUSH}: the ones --no-verify skips (Git's scope, measured for prepare-commit-msg).

Discovery (hooks_dir, find):

  1. hooks_path from the local config (new field, below); else <root>/.sg_vault/local/hooks/. A relative hooks_path is relative to the vault root (Git's rule for core.hooksPath).
  2. Refused hooks paths (5.6, decision D5): a hooks_path that resolves inside the working tree is refused at run time unless hooks_path_tracked is also set (sgit vault hooks --path DIR --allow-tracked), because then the hooks are vault content that any teammate's push rewrites.
  3. The hook file is <dir>/<name> exactly (POSIX). It must be a regular file (lstat; a symlink inside the hooks folder is allowed and resolved, since the folder is the user's own, not vault content) and executable (os.access(path, os.X_OK)). Present but not executable: skipped, with one stderr line per command: hint: the pre-commit hook was ignored because it is not executable (chmod +x .sg_vault/local/hooks/pre-commit).
  4. Windows (no exec bit, no shebang exec): look for <name>.exe, <name>.cmd, <name>.bat, <name>.ps1, then <name> with a #! line. .ps1 runs as powershell -NoProfile -ExecutionPolicy Bypass -File <hook> args…; a #! script runs through sh if sh.exe is on PATH (Git for Windows puts it there), else it is skipped with a hint. Decision D7.

Execution (run):

Environment (environment): start from os.environ, then:

VariableValue / action
SGIT_DIRabsolute path of <root>/.sg_vault
SGIT_WORK_TREEabsolute vault root
SGIT_HOOKthe hook name
SGIT_BRANCHthe named branch this clone tracks (plaintext name; it is already plaintext locally)
SGIT_CLONE_BRANCHthe clone branch id
SGIT_VERSIONsgit_ai.__version__
SGIT_VAULT_IDthe vault id (not a secret; it is in every URL)
SG_SEND_PASSPHRASEremoved (the credential-store passphrase)
any variable whose name matches (?i)(^SGIT_.*(TOKEN|KEY|SECRET|PASS))|(^SG_SEND_.*)removed (future-proof rule, tested with a planted variable)
GIT_DIR, GIT_WORK_TREE, GIT_INDEX_FILEremoved if inherited, so a hook that runs git is not pointed at some outer repository by sgit's caller

Never added: the vault key, read key, write key, access token, the server URL with a token. The hook runs as the same user and can read .sg_vault/local/vault_key itself; stripping is about not leaking secrets into processes and logs that did not ask for them (CI logs printing env, child tools that upload their environment), not a security boundary. The design says so in the threat-model row (6).

Messages: message_file is .sg_vault/local/COMMIT_EDITMSG, written with Vault__Storage.write_private (0600) before prepare-commit-msg, read back after commit-msg, left in place afterwards (Git leaves COMMIT_EDITMSG). It holds a plaintext commit message on local disk, the same class of data as the flat and scan caches (TM-R41).

Configuration (local only, never synced): two new fields in Schema__Local_Config (sgit_ai/schemas/Schema__Local_Config.py:22):

    hooks_path         : Safe_Str__File_Path       = None          # core.hooksPath: '' or None = .sg_vault/local/hooks
    hooks_path_tracked : bool                      = False         # the user accepted a hooks path inside the working tree

Older clients read a config with these keys fine (unknown keys are accepted by from_json, verified for Schema__Object_Commit; the same test is added for Schema__Local_Config); an older client that rewrites the config drops them, which only means hooks fall back to the default folder: fail-safe.

hooks_timeout is not a config field in the first cut; SGIT_HOOK_TIMEOUT (seconds) is read by Vault__Hooks for agents that need a bound (decision D8).

5.6 Where each hook fires

HookCall site (new code)Arguments / stdinOn failure
pre-commitVault__Sync__Commit.commit and _commit_paths, before the scan (Vault__Sync__Commit.py:80), after the merge/read-only refusalsnoneVault__Hook_Error, nothing written
prepare-commit-msgafter the tree is built and "nothing to commit" is ruled out (:158-160), before signing$1 message file; $2 message when -m/positional given, merge when concluding a merge (merge_state present), commit + $3 HEAD id for --amend without a message, else absent (sgit writes its auto message into the file)abort, nothing written
commit-msgright after$1 message fileabort; an empty file after the hooks aborts with Aborting commit due to empty commit message. (Git's words)
post-commitafter write_ref (:188) and the merge-state cleanup (:194-197)noneignored (printed as a warning)
pre-pushVault__Sync__Push.push, after the final heads are read (Vault__Sync__Push.py:185-186) and only when something will be sent (clone head ≠ named head, or first push), before any upload$1 remote name (origin or the --remote name), $2 the server base URL (no token, no key); stdin: refs/heads/<named> <clone head> refs/heads/<named> <named head or 40×0>; with --branch-only, refs/clones/<clone id> on both sidesabort, nothing sent
post-mergeVault__Sync__Pull.pull after the workflow returns with status merge or fast_forward (not conflict, up_to_date, rewound without changes); covers branch merge and push's implicit pull0ignored
post-checkoutVault__Branch_Switch.switch (1), sgit branch checkout <commit> (1), Vault__Sync__Checkout.checkout (0), after the files are written$1 previous head id, $2 new head id, $3 flagthe command's exit status becomes non-zero (exit 1) with post-checkout hook exited N; the checkout stays done (Git)

Why pre-commit runs before the scan. In Git the index is the commit; a formatter run by pre-commit changes the working tree, not the index, and a common complaint is that the fix is not in the commit. In sgit the working copy is the commit (no index), so running pre-commit first means a formatter's edits are committed, and a check sees exactly what will be committed. For commit <paths> and -p, the hunks are computed after pre-commit too.

sgit write runs no commit hooks (plumbing). sgit write --push runs pre-push (it is a push), skippable with write --no-verify. History edits (history undo, reset, revert --as-commit): in the first cut only commit (including --amend) runs the commit hooks. Git's git revert does run prepare-commit-msg and commit-msg; adding them to revert --as-commit is decision D9.

5.7 Staging area: analysis for decision D1

Git's index (.git/index) does three jobs:

JobGitsgit today
Change detection cache (stat data → blob id)the index's stat fieldsscan-cache.json: [mtime_ns, ctime_ns, size, ino, dev, hash] per path, Git's racy rule (Vault__Sync__Base.py:32-35). Same role, same key.
The content of the next commitgit add writes blobs and index entriesthe working copy itself; sgit write for agents
Merge stages 1/2/3index stagesmerge_state.json + <path>.conflict files

So the only missing job is the second. Options:

OptionWhatCostRisk
A (recommended)No staging area. commit <paths>, commit -p, write cover selection.in this designnone: no new state
BOpt-in stage: sgit add <paths> / add -p encrypt the content into the object store and record {path: entry or delete} in .sg_vault/local/stage.json; when the stage is non-empty, sgit commit commits HEAD + stage only; sgit commit -a (or an empty stage) keeps today's whole-copy commit; sgit restore --staged; status gets a "staged" section+4–5 dayssgit commit changes meaning the moment anything is staged, which breaks agents that call add by Git habit and then expect commit to take everything; three-way status; add becomes a top-level verb (Rule 9 edit)
CGit-exact: always staged, commit commits only the stageweeksbreaks every existing script and skill

Recommendation A now; B later as an extension if users ask, built on _commit_selected (5.2) so it adds no new tree code.


6. Security and threat model

RiskAnalysisDesign answerNew TM row (number assigned at merge)
Hooks from vault contentA teammate (A4/A5) or a host serving content (A1, cannot: content is authenticated by id) puts a hooks/pre-commit in the vault.Hooks are read only from .sg_vault/local/hooks/ or a user-set path. Vault content can never write under .sg_vault* (Vault__Path_Guard.py:27-28, safe_join :134-148), so the default folder is out of its reach. Nothing in the vault can set hooks_path (local config only).TM-Rxx-hooks-1: "hooks run as the user from a local folder; vault content cannot install or select one" — accepted, with a Known_Gaps-style test that a vault containing .sg_vault/local/hooks/pre-commit at any depth is refused on pull and never run
User points hooks_path at a tracked folder (the Husky pattern)Every teammate with write access then runs code on this user's machine at the next commit. sgit writes checked-out files in place (Vault__Sync__Base.py:701-703), so a file the user made executable stays executable when a pull rewrites its content.Refused unless --allow-tracked was given when setting the path; then sgit vault hooks and the first run after a pull that changed a hook print hook pre-commit changed in the last pull (by <author key id>) with its content hash.TM-Rxx-hooks-2: accepted only with explicit opt-in; names the writer
Secrets in the hook's environmentA hook (or a tool it calls) logs or uploads its environment.Strip SG_SEND_PASSPHRASE, any SGIT_*TOKEN/KEY/SECRET/PASS, any SG_SEND_*; never add a key or token; base URL passed to pre-push without credentials. Not a boundary (same user), stated as such.TM-Rxx-hooks-3: hooks run as the user and can read .sg_vault/local like any process of that user; sgit adds nothing secret to their environment
Shell injection through file names or messagesMessages and paths are attacker-influenced (vault content).No shell: argv list; the message is passed through a file, never argv; paths are not passed at all.covered by test only
cwd-relative executable lookup (TM-F06 class)Running python or sh by name with the vault root as cwd could pick a planted binary on Windows (the cwd is searched first there).The hook is run by absolute path. On Windows sh/powershell are resolved with shutil.which over PATH with the vault root removed from the search, and a vault-root hit is refused.TM-Fxx (preventive, with test)
Partial commit commits something unexpected-p apply bug would commit content the user did not see.apply property tests + Git oracle; --list-hunks shows exactly the text that a --hunk commits; the commit result lists the committed paths.none
Secret guard bypass via pathscommit <paths> must not skip the guard.Guard runs on the changed selected paths, as today on all changed paths.none (test)
Hunk content that is a secretA partial blob is never on disk, so the secret guard (which reads files) must also run on bytes: Vault__Secret_Guard.refuse_bytes (already used by write, Vault__Sync__Commit.py:297-298).refuse_bytes(path, partial_content) for each partial blob.none (test)

--no-verify is a convenience, not a control: anyone who can run sgit commit can delete the hook. The design says so in the docs; teams that need enforcement need it server-side (SGitHub, roadmap section 4).


7. Interactions

WithBehaviour
Scoped clone (clone --path)Pathspec outside the held folders → the existing scoped refusal naming the matched paths; patterns matching only outside → "did not match any file known to sgit" plus (this clone holds only <folders>). Opaque entries are carried by build_from_flat(…, opaque=) exactly as write_file does.
Shallow cloneNo effect (HEAD's tree is always present).
Sparse / selective working copyA HEAD path never checked out is not "deleted" (checkout-set rule, 5.2 step 5); naming it in a pathspec gives 'x' is not checked out (sgit checkout x first) for -p; for a whole-file pathspec it is unchanged, so not committed. After the commit the committed paths are added to the checkout set (as Vault__Sync__Commit.py:189-192).
Pack vault / --pack-storeNo difference: blobs are stored loose locally and pushed in the commit pack as today.
Read-only cloneRefused before anything (existing _check_read_only, CLI__Vault.py:795).
Merge in progresscommit <paths> and -p refused (Git's message). Plain sgit commit concludes the merge as today. Hooks: prepare-commit-msg gets source merge.
.conflict files present, no merge statePlain commit: today's behaviour. Path commit: allowed if no selected path has a .conflict sibling; otherwise refused naming it.
--amend5.2. Works with paths and -p. Refusals of _amend_plan apply (pushed head, merge commit). Hooks run (Git runs them on amend).
Secret guardRuns on the selected changed paths (files) and on every partial blob (bytes). --allow-secret-file works as today.
sgit writeShares _commit_selected; gains the folder reuse through build_from_flat. Behaviour unchanged otherwise.
vault stashStashes the whole working copy as today. A partial commit followed by stash is the Git workflow too.
Push5.4: dirty copies push; the implicit pull's guard protects uncommitted edits.
StatusUnchanged. After -p the file shows modified (remaining hunks).
Web UI and older clientsA partial commit is an ordinary commit; nothing new is stored. No gate.

8. Performance

Measured costs of today's commit on the 76,000-file synthetic vault (release-readiness note, 26 Oct): a 5-file commit plus push 4.5–5.2 s, of which the scan with a warm cache is most of the commit.

OperationExpected costWhy
commit -m x a/b/c.txt (one literal path)flat map from the flat cache (~0.6 s cold on 76k, ms warm) + 1 lstat + 1 read + ~depth trees encryptedno working-copy scan; folder reuse in build_from_flat
commit -m x docs/walk of docs/ onlyscoped scan (under=)
commit -m x '*.md'full scan (as today)a glob can match anywhere
sgit write (any vault)faster than today: was a rebuild of every folder via build_from_flatreuse added (5.2 step 3)
-p on a 10 MB text fileSequenceMatcher with autojunk=False is roughly O(n·m) worst case on pathological input; typical edits are fastfiles over max_bytes (10 MB) are whole-file only; a benchmark test on a 5 MB / 100k-line file with 50 scattered edits must stay under 2 s
Hooksone fork/exec per hook present, ~2–5 ms + the hook's own time; zero when the folder is emptyfind is an lstat per hook name

Perf tests (in tests/unit/perf/ if the folder exists, else alongside, marked slow-safe): a 20,000-file vault, one literal path commit must not call _scan_local_directory (asserted by a counter attribute on a subclass of Vault__Sync__Commit used only in the test, no mocks) and must build at most depth + 1 new trees (count objects in the store before/after).


9. Test plan

All real objects, no mocks; vaults from Vault__Test_Env (tests/_helpers/vault_test_env.py) or two clones on Vault__API__In_Memory; no __init__.py in test folders. Git is available in CI (/usr/bin/git); oracle tests skip with a reason if git is missing.

9.1 Pathspec and partial commits — tests/unit/core/actions/commit/test_Vault__Pathspec.py, test_Commit__Paths.py

ClassCases
Test_Vault__Pathspecliteral file; literal folder with and without /; . at root and in a sub-folder; * crossing /; ?; [ab]; [!ab]; cwd prefix; .. inside; .. escaping refused; absolute inside / outside; Unicode names; trailing-space names; control character refused by Safe_Str__Pathspec; round trip of the class's json
Test_Commit__Paths__Basicstwo of five modified files committed, three still dirty (status lists them); deletion by name; folder pathspec commits new files under it; new file by name; unmatched pattern → exit 1, store and ref unchanged (object count equal); unchanged named path → "Nothing to commit"; -m + positionals = paths; "msg" -- a = message + path; legacy sgit commit "msg" unchanged; -m x y with y matching nothing prints the note
Test_Commit__Paths__Treeresulting tree id equals the tree a whole-copy commit would give when the selection is all changed files (both orders); untouched folders keep their ids (compare flatten_dirs before/after); build_from_flat with reuse gives the same ids as without (random 500-file maps, 50 seeds)
Test_Commit__Paths__Refusalsmerge in progress; scoped clone outside scope; ignored file by literal name; .conflict sibling; secret zip by path (guard); read-only clone
Test_Commit__Paths__Amend--amend a.txt keeps parents, overlays a.txt, leaves b.txt dirty; refused on a pushed head
Test_Commit__Paths__Sparsenever-checked-out path not deleted; checked-out path removed → deleted only when named; checkout set updated
Test_Commit__Paths__Git_Oraclesame scripted edits in a Git repo and an sgit vault, commit <paths> in both, the set of committed paths and resulting file contents per path are equal (except the documented deviations: new untracked files)

9.2 Hunks — tests/unit/core/actions/commit/test_Vault__Hunks.py, test_Commit__Patch.py, tests/unit/cli/test_CLI__Hunk_Prompt.py

ClassCases
Test_Vault__Hunks__Shapesingle edit → 1 hunk; two edits far apart → 2 hunks; two edits 4 lines apart → 1 splittable hunk; split gives 2; headers 1-based; CRLF preserved; no final newline preserved on both sides; binary → whole-file; file > max_bytes → whole-file; empty base (new file); empty work (deleted)
Test_Vault__Hunks__Applyall → work; none → base; every subset of a 6-hunk file (64 cases) equals Git: git apply --cached of the same hunks (generated as a patch per subset) on a repo with base committed, then git show :path
Test_Vault__Hunks__Idsid stable across runs; id changes when base or hunk text changes; split ids distinct from the parent's
Test_Commit__Patch--hunk commits exactly those hunks; the working file is byte-identical before/after; status shows the file modified; stale id → exit 2 naming it; --hunk + --list-hunks → exit 2; -p with no TTY → exit 2 with the hint; partial blob containing a vault key is refused by refuse_bytes
Test_CLI__Hunk_Promptscripted stdin (y, n, s then y n, a, d, q, j k, ?) through a pipe with a pseudo-TTY from pty.openpty() on POSIX (skipped on Windows); Ctrl-C (SIGINT to the child) → exit 130, nothing committed

9.3 Push with a dirty tree — tests/unit/workflow/push/test_Push__Dirty_Tree.py

test_push__dirty_tree_not_touched_by_incoming_is_pushed, test_push__dirty_file_the_pull_would_change_refuses (store, refs and working copy unchanged), test_push__force_never_touches_working_copy, test_push__conflict_files_still_refuse, test_push__prints_uncommitted_note.

9.4 Hooks — tests/unit/core/hooks/test_Vault__Hooks.py, test_Hooks__Commit.py, test_Hooks__Push_Pull_Checkout.py

Hooks are real executable files (#!/bin/sh on POSIX; .cmd on Windows) written by the test into the hooks folder; each writes its argv, cwd, selected env vars and stdin to a file the test reads.

ClassCases
Test_Vault__Hooks__Discoverydefault folder; hooks_path relative / absolute; non-executable ignored with hint; missing folder = nothing run; tracked hooks_path refused without hooks_path_tracked, allowed with it; Windows extension order (skipped on POSIX)
Test_Vault__Hooks__EnvironmentSGIT_DIR, SGIT_WORK_TREE, SGIT_HOOK, SGIT_BRANCH, SGIT_VAULT_ID set; SG_SEND_PASSPHRASE, a planted SGIT_FOO_TOKEN, GIT_DIR removed; no environment value contains the vault key, read key hex or write key (scan every value)
Test_Vault__Hooks__Executioncwd is the vault root when run from a sub-folder; no shell (a file name $(touch pwned) is harmless); ENOEXEC → 126; timeout via SGIT_HOOK_TIMEOUT=1 → 124; stdout redirected to stderr under --json
Test_Hooks__Commitorder pre-commit → prepare-commit-msg → commit-msg → post-commit (each appends to a log); pre-commit exit 1 → nothing committed (object count unchanged, ref unchanged); pre-commit edits a file → the edit is in the commit; prepare-commit-msg sources: message, merge, commit + HEAD id on --amend without -m, absent otherwise; commit-msg rewrites the message → commit has the new one; commit-msg empties it → aborted with Git's words; --no-verify skips pre-commit and commit-msg only; post-commit failure → commit stands, warning; sgit write runs none
Test_Hooks__Push_Pull_Checkoutpre-push argv (origin, URL with no token) and stdin line (named ref, clone head, remote head / 40 zeros on first push); pre-push exit 1 → nothing uploaded (server object count unchanged); push --no-verify skips; write --push runs pre-push; post-merge after fast-forward and after merge, not after conflict or up-to-date; post-checkout on branch switch (1), on sgit checkout <path> (0), its failure makes the command exit 1 with the checkout done
Test_Hooks__Security (in tests/security/test_Security__Fixed__Hooks.py)a vault whose content contains .sg_vault/local/hooks/pre-commit (crafted tree) is refused by the path guard on pull and nothing runs; a tracked hooks path changed by a teammate's pull prints the "changed in the last pull" notice naming the author key; Windows sh lookup ignores a sh.exe in the vault root (skipped on POSIX)

9.5 Schemas and CLI — existing patterns

Round-trip tests for Schema__Hunk, Schema__File_Hunks, Schema__Hunk_List, Schema__Hook_Result, the extended Schema__Local_Config (including reading a config with the new keys through an instance of the old schema: unknown keys accepted). TOP_LEVEL_COMMANDS unchanged (the test stays green without an edit). sgit help all lists nothing new at the top level.

9.6 Acceptance checklist (implementation and deployment)


10. Implementation steps (ordered commits)

#CommitContentsEstimate
1perf(tree): build_from_flat reuses unchanged foldersold_tree_ids / file_unchanged on build_from_flat; write_file passes them; id-equality tests0.5 day
2feat(push): push with a dirty working copy5.4 + 9.3; CHANGELOG0.5 day
3feat(commit): commit <paths>Safe_Str__Pathspec, Vault__Pathspec, _scan_paths / under=, _commit_selected, _commit_paths, Vault__Partial_Commit_Error, CLI parsing (positional rule), --json; tests 9.11.5 days
4feat(commit): -p, --list-hunks, --hunkVault__Hunks, schemas, safe types, CLI__Hunk_Prompt; tests 9.2 incl. Git oracle2 days
5feat(hooks): runner and commit hookssgit_ai/core/hooks/, Enum__Hook_Name, Schema__Hook_Result, config fields, COMMIT_EDITMSG, -n/--no-verify, sgit vault hooks; tests 9.4 commit part1.5 days
6feat(hooks): pre-push, post-merge, post-checkoutcall sites in push, pull, switch, checkout; push --no-verify, write --no-verify; tests1 day
7docs+security: threat model rows, Fixed__Hooks, CHANGELOG, README6, 9.4 security, 9.60.5 day

Total about 7.5 days (2.3 ≈ 4.5 days with the push prerequisite and the tree reuse; 2.9 ≈ 3 days). The roadmap's "1 day / 2 days" for 2.3 did not include the push change, the shared build_from_flat reuse, the agent hunk interface or the Git oracle.

Parser note for step 3: declare commit_parser.add_argument('args', nargs='*'); argparse drops a bare -- from nargs='*' lists, so cmd_commit finds the split point by locating '--' in sys.argv after the commit token (the CLI receives argv through CLI__Main.run; pass the raw list down as args.raw_argv set in run for the commit command only), which keeps the rule testable without patching sys.argv.


11. Open decisions for Dinis

#DecisionOptionsRecommendation
D1Staging areaA none / B opt-in sgit add / C Git-exactA now; B later as an extension if asked (5.7)
D2sgit commit -m x y: y becomes a path (Git) instead of being ignoredGit rule / keep ignoringGit rule, with the loud note when y matches nothing
D3Pathspec scopeliteral + folders + * ? [] / add :!exclude now / full Git magicliteral + folders + wildcards, :! as a small follow-up; case-sensitive everywhere
D4e (edit hunk) in -pinclude / deferdefer: agents use --hunk; humans rarely use e
D5Hooks path inside the working tree (Husky pattern)refuse / allow with opt-in / allowallow only with --allow-tracked, plus the "changed in last pull" notice
D6Where hooks are configuredsgit vault hooks / a new top-level sgit config (Git's core.hooksPath)sgit vault hooks now (no Rule 9 edit); revisit if sgit config arrives for the merge style (design 01)
D7Windows hooks.exe/.cmd/.bat/.ps1 + #! via sh on PATH / POSIX onlysupport Windows as described, tested in the Windows CI job
D8Hook timeoutnone (Git) / config / env onlynone by default, SGIT_HOOK_TIMEOUT for agents
D9Commit hooks for other commit-making commands (revert --as-commit, merge commits from pull, history undo)commit only / also revert --as-commitcommit only in the first cut; pull's merge commit runs none (Git runs pre-merge-commit, a non-goal here)
D10Untracked files under a folder pathspec / by namecommit them (sgit's "working copy is the index") / refuse like Gitcommit them: sgit has no add, so refusing would make new files uncommittable by path

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