Skip to content

feat(gix-stash): new crate — push / pop / list MVP#2603

Open
mxaddict (mxaddict) wants to merge 11 commits into
GitoxideLabs:mainfrom
mxaddict:feat/gix-stash
Open

feat(gix-stash): new crate — push / pop / list MVP#2603
mxaddict (mxaddict) wants to merge 11 commits into
GitoxideLabs:mainfrom
mxaddict:feat/gix-stash

Conversation

@mxaddict
Copy link
Copy Markdown

@mxaddict mxaddict (mxaddict) commented May 17, 2026

Adds a new gix-stash plumbing crate implementing the three core git stash
operations: push, pop, list. Wires it through the top-level gix crate
as the gated stash feature, mirroring the blame / worktree-stream
pattern.

I have an end-to-end consumer running this on real dotfile repos
(see "End-to-end validation" below), so this is not speculative API
shopping — the surface here is shaped by an actual integration.

What it adds

Op Signature Behaviour
list(refs) &gix_ref::file::Store -> Result<Outcome> Walks the refs/stash reflog newest-first. Returns empty Outcome (not an error) when the ref is unborn — matches git stash list on a stash-free repo.
push(ctx, head_commit, head_tree, head_branch, options) takes a Context<Objects> with refs/odb/index/worktree/committer/checkout-options Captures the working tree (not just the index) into a 2- or 3-parent stash commit. After the ref transaction, resets the worktree to HEAD via gix_worktree_state::checkout. Optionally captures + removes untracked files into parent[2].
pop(ctx, head_tree, options) same Context shape + head_tree: ObjectId 3-way merge via gix_merge::tree (base = stash parent[0] tree, ours = current HEAD tree, theirs = stash WIP tree). On clean merge: writes via checkout, restores parent[2] untracked files, drops the ref. On conflict: sets had_conflicts = true, writes conflict markers, leaves refs/stash intact — matching git stash pop semantics.

Implementation notes

  • Stash representation matches what real git stash writes: a 2- or 3-parent
    merge commit at refs/stash, with the reflog providing the stack
    (`stash@{0}` is current ref, `stash@{1}` is reflog[-2], etc).
  • The crate's deps mirror what's actually needed for stash semantics:
    gix-index, gix-object, gix-ref, gix-actor, gix-dir, gix-diff
    (with blob), gix-merge, gix-worktree-state, plus the usual
    gix-hash / gix-date / gix-trace. Same shape as gix-blame.
  • push captures the working-tree content for tracked files (reading
    each file off disk + writing a fresh blob), not just the index — this is
    important because the headline use case for git stash is "save my
    unstaged edits".
  • Context types are generic over `Objects: Find + FindHeader + Write + Send
    • Cloneso the same handle (e.g.gix::Repository::objects`) satisfies the
      whole bound stack.

Test coverage

14 integration tests, all in gix-stash/tests/stash/, using
gix_testtools::scripted_fixture_* with bash fixture scripts that build
deterministic repos via real git (then we operate on them with the gix
plumbing). Coverage:

  • list: empty repo, populated stack newest-first, timestamp sanity
  • push: captures unstaged WT mod, captures staged change in parent[1],
    NoLocalChanges on clean WT, include_untracked on/off, WT actually
    resets to HEAD post-push
  • pop: clean apply round-trip, multi-stash advances ref (doesn't drop),
    NoStash when unborn, parent[2] untracked restoration, conflict path
    leaves refs/stash intact

The tests caught a real bug during dev: an early version of push had a
NoLocalChanges guard checking index.entries().is_empty() instead of
diffing trees vs HEAD — which meant any non-empty index would "succeed"
into producing a stash whose tree was identical to HEAD. Tests forced a
fix that compares both WIP and index trees against head_tree.

End-to-end validation

This isn't tested in isolation — it's already wired into a real
dotfiles manager: kryptic-sh/krypt#44.
Krypt's `update` command auto-stashes a dirty working tree before pulling
the dotfiles repo, then auto-pops afterwards.

Smoke test against a real repo:

```
$ git status
M tracked.txt

$ krypt update
INFO auto-stashed working tree before krypt update stash=1f3baab… repo=…
INFO auto-stash restored cleanly after krypt update stash=1f3baab… repo=…
stash: auto-stashed (restored after pull)
pull: up to date
link: wrote: 0

$ git status
M tracked.txt # exactly the same modification

$ git stash list
# empty — stash was popped + dropped cleanly

$ cat tracked.txt
modified-by-smoke # the user's edit survived the roundtrip
```

Krypt also exposes a --no-stash opt-out that errors immediately on dirty
WT — both code paths exercised.

Known limitations (carried forward as TODO(gix-stash): in code)

These are MVP cuts, not bugs:

  • Default checkout_options::filters/attributes are empty. Callers
    wiring this into porcelain (the gix Repository layer, or downstream
    consumers like krypt) need to populate them so smudge/clean filters and
    gitattributes run during the worktree write. The plumbing is correct;
    filter setup is the porcelain's responsibility per the pattern
    established by gix-worktree-state.
  • Tracked entries deleted from the worktree are stored with their index
    OID rather than recorded as a deletion in the WIP tree. A pop of such a
    stash won't restore the deletion.
  • The post-pop index doesn't preserve stat data / timestamps from the
    merged tree.

Not in this PR

Deferred for follow-up PRs (some likely reviewer requests):

  • apply (pop without drop), drop, show, branch operations
  • Autostash integration for rebase-like workflows
  • Porcelain wrappers on gix::Repository (`stash_push` / `stash_pop` /
    `stash_list`) — currently only plumbing is exposed
  • cargo-smart-release metadata polish for the new crate

Happy to roll any of these into this PR if preferred — they're scoped out
mostly for review-size reasons.

AI assistance disclosure

Per CONTRIBUTING.md:

  • This PR was developed with Claude (Anthropic's coding assistant) acting
    as the implementing agent on behalf of mxaddict (@mxaddict).
  • The recon / scoping, the plumbing inventory, the implementation, and the
    test suite were AI-generated under human review at each step. Two bugs
    surfaced and were fixed during review: (1) initial push was building
    the WIP tree from the index instead of the working tree, masking
    unstaged modifications — caught during code review and fixed; (2) the
    NoLocalChanges guard was checking index-empty rather than tree-equal,
    caught by the integration tests.
  • This PR description was AI-drafted with human review.
  • Commits carry Co-authored-by: Claude <noreply@anthropic.com> trailers per
    gitoxide convention. Authorship stays with mxaddict (@mxaddict); Claude is credited as
    co-author on every commit in the series.

Closes (functional half of): kryptic-sh/krypt#44

mxaddict (mxaddict) and others added 8 commits May 17, 2026 23:51
New plumbing crate placeholder for git-stash workflows. push() / pop()
currently return Error::NotImplemented; full implementation follows in
subsequent commits.

Crate layout mirrors gix-blame: lib.rs re-exports, push/ and pop/
modules each with their own Options/Outcome/Error types. The plumbing
takes lower-level handles (index, ODB, ref store, worktree path) rather
than gix::Repository so it stays in the plumbing layer; gix porcelain
will wrap it as Repository::stash_push / stash_pop.

Wired into the workspace and exposed through gix as the gated
`stash` feature, matching the blame/worktree-stream pattern.

Reference: crate-status.md gix-stash checklist.
Co-authored-by: Claude <noreply@anthropic.com>
Third MVP op — walks refs/stash reflog and returns entries newest-first
(matching git stash list output order). Entry carries index, commit oid,
reflog message, and Unix timestamp; returns Vec<Entry> wrapped in
Outcome.  Empty Outcome when refs/stash is unborn.

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Claude <noreply@anthropic.com>
Push: build WIP tree from actual working-tree file content (not index
OIDs) so unstaged modifications are captured; reset WT to HEAD after
stash commit via gix_worktree_state::checkout; remove untracked files
from disk when include_untracked is set.

Pop: perform 3-way merge (base=stash parent[0] tree, ours=current HEAD
tree, theirs=stash WIP tree) via gix_merge::tree; write merged result
to WT; restore untracked files from parent[2] on clean merge; leave
refs/stash intact on conflict.

Context structs are now generic over Objects (Find + FindHeader +
Write + Send + Clone) matching gix-blame/gix-merge conventions.
Remove stash_tree and base_commit from pop::Outcome; pop now does the
merge itself. Add gix-validate and gix-path to direct deps.

Co-authored-by: Claude <noreply@anthropic.com>
Cover all three operations with bash fixture scripts and Rust tests.
Fix NoLocalChanges guard in push to compare WIP/index trees against
HEAD rather than checking for an empty index.

- list: 3 tests (newest-first ordering, empty repo, positive timestamps)
- push: 6 tests (unstaged capture, staged capture, clean-WT bug→fix,
  include_untracked flag, exclude_untracked flag, empty-repo doc)
- pop: 5 tests (clean apply + ref drop, multi-stash stack, no-stash
  error, untracked restore, conflict keeps ref intact)

Co-authored-by: Claude <noreply@anthropic.com>
Manual initial entry per gitoxide convention — cargo-smart-release will
auto-augment future releases with commit-statistics sections.  Documents
the MVP scope (list / push / pop), behaviour, errors, and known
limitations carried over from the implementation TODOs.

Co-authored-by: Claude <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0a4696b822

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "Codex (@codex) review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "Codex (@codex) address that feedback".

Comment thread gix-stash/src/pop/mod.rs
source: e,
})?;
}
std::fs::write(&entry_path, blob.data).map_err(|e| super::Error::RestoreUntracked {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Avoid dropping stash after overwriting existing untracked path

When restoring parent[2] during pop, this write unconditionally truncates any existing file at the target path. If the user created the same untracked filename after stashing, pop will silently overwrite their current file and then continue toward dropping refs/stash, which can irreversibly lose data instead of surfacing a restore conflict and keeping the stash entry.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 3dd66fd. Pre-flight scan via collect_restore_targets_recursive checks try_exists() on every parent[2] entry before any write. If any target already exists, set had_conflicts=true and bail — refs/stash is preserved, nothing is written. New test pop_returns_conflict_when_untracked_target_exists covers it.

Comment thread gix-stash/src/push/mod.rs Outdated
Comment on lines +576 to +582
let content = std::fs::read(&abs_path).map_err(|e| Error::ReadFile {
path: abs_path.clone(),
source: e,
})?;
let blob_oid = odb
.write_buf(gix_object::Kind::Blob, &content)
.map_err(Error::WriteBlob)?;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Store symlink target instead of linked file contents

For untracked symlinks, this reads bytes via std::fs::read, which follows the symlink and stores the target file's content, not the symlink target path. Later pop interprets that blob as a link target, so restored symlinks point to garbage paths (or fail), breaking stash round-trips for untracked symlink entries.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e3b420c. collect_untracked now branches on symlink_metadata().file_type().is_symlink() — for symlinks, reads target via std::fs::read_link, converts to bytes via gix_path::into_bstr, and stores with EntryKind::Link. The tracked-files path in write_wip_tree already did this correctly. New test push_captures_untracked_symlink_target covers it.

Comment thread gix-stash/src/push/mod.rs Outdated
Comment on lines +211 to +212
if !has_wt_changes && !has_index_changes && !options.include_untracked {
return Err(Error::NoLocalChanges);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Return NoLocalChanges when -u finds nothing to save

This guard skips NoLocalChanges whenever include_untracked is true, even if the untracked snapshot later turns out empty. On a clean repository, push with include_untracked=true will still create no-op stash/index commits, polluting the stash stack instead of behaving like a no-op with no new stash entry.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e3b420c. Guard reordered: build WIP, index, and pending-untracked trees first; then compare each against head_tree (or the empty-tree OID for untracked). Returns NoLocalChanges only when all three are vacuous, so -u on a clean repo no longer pollutes the stack. New test push_returns_no_local_changes_with_include_untracked_when_nothing_to_save covers it.

Comment thread gix-stash/src/push/mod.rs Outdated
Comment on lines +330 to +334
let mut head_index =
gix_index::State::from_tree(&head_tree, objects, gix_validate::path::component::Options::default())?;
let should_interrupt = std::sync::atomic::AtomicBool::new(false);
gix_worktree_state::checkout(
&mut head_index,
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor keep_index when resetting the worktree

The implementation always checks out head_tree after writing the stash, and Options::keep_index is never consulted. That means staged changes are removed from the working tree even when callers request keep_index, violating the option’s documented contract and breaking staged/unstaged split workflows.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in e3b420c. reset_tree = if options.keep_index { index_tree_oid } else { head_tree } — reuses the index tree already written for parent[1], no extra ODB writes. New test push_with_keep_index_preserves_staged_changes_in_wt verifies staged content remains on disk after push.

mxaddict (mxaddict) and others added 2 commits May 18, 2026 00:20
Before writing any blob from parent[2] during pop, scan all target
paths for pre-existing files.  If any would be clobbered, mark
had_conflicts=true, leave refs/stash intact, and skip all parent[2]
writes — preventing silent data loss when the user created a file at
the same path after stashing.

Co-authored-by: Claude <noreply@anthropic.com>
In collect_untracked, symlinks were read with std::fs::read which
follows the link and reads the pointed-at file's content.  Use
std::fs::read_link instead, convert the OsStr target to bytes via
gix_path::into_bstr, and store the result as an EntryKind::Link blob
so the stash faithfully records the link target string, not the
content of the target file.

Also re-evaluate NoLocalChanges after building all three trees: build
the untracked tree eagerly when include_untracked=true and include it
in the no-changes check so a clean repo with include_untracked=true
returns Err(NoLocalChanges) instead of writing an empty stash commit.

Honour the keep_index option in the post-stash worktree checkout: when
keep_index=true reset the working tree to the index tree (staged
changes remain on disk) rather than unconditionally resetting to HEAD.

Co-authored-by: Claude <noreply@anthropic.com>
@mxaddict
Copy link
Copy Markdown
Author

Thanks chatgpt-codex-connector — all 4 review comments addressed in 3dd66fd + e3b420c. Added 4 new tests covering each fix; suite now 18/18.

Comment Fix Commit
pop:365 — silent overwrite of pre-existing untracked target Pre-flight try_exists scan in restore_tree_to_worktree; on any conflict, set had_conflicts=true and bail before writing — refs/stash is preserved, atomic fail. 3dd66fd
push:582 — symlink contents stored instead of target Branch on symlink_metadata.is_symlink() in collect_untracked; use read_link + gix_path::into_bstr, store as EntryKind::Link. Tracked-symlink path in write_wip_tree was already correct. e3b420c
push:212 — NoLocalChanges skipped when include_untracked=true Reordered guard: build all 3 trees first, then check WIP/index/untracked against HEAD / empty tree. Returns NoLocalChanges only if all three are vacuous. e3b420c
push:334 — keep_index ignored One-line branch: reset_tree = if options.keep_index { index_tree_oid } else { head_tree }. Reuses the index tree already written for parent[1]. e3b420c

The two original P1+P2 fixes got bundled into one commit because they all live in push/mod.rs and touch overlapping surfaces — happy to split if preferred.

@chatgpt-codex-connector
Copy link
Copy Markdown
Contributor

To use Codex here, create a Codex account and connect to github.

…mlink restore by cfg

cargo machete flagged gix-dir, gix-trace, smallvec as unused; remove
them from the manifest (they were copy-paste from the gix-blame
template but never imported).

cargo doc rejected the intra-doc links [push] / [pop] / [list] as
ambiguous because each name is both a function (re-exported in lib.rs)
and a module.  Disambiguate with [push()] / [pop()] / [list()] to
target the function re-exports.

cargo test on windows-latest / windows-11-arm / macos failed at the
build step because std::os::unix::fs::symlink doesn't exist on Windows.
cfg-gate the symlink call: unix uses std::os::unix::fs::symlink,
windows uses std::os::windows::fs::symlink_file, other targets get an
Unsupported io::Error.  The pre-flight try_exists scan already
short-circuits before any writes when a target conflict is detected,
so a partial restore on Windows symlink failure only happens when the
file system itself rejects the call (e.g. insufficient privileges).

Co-authored-by: Claude <noreply@anthropic.com>
@mxaddict
Copy link
Copy Markdown
Author

CI failures from the previous run addressed in b6c5aad:

Job Cause Fix
lint (cargo machete) Unused deps in gix-stash/Cargo.toml flagged: gix-dir, gix-trace, smallvec (copy-paste leftovers from the gix-blame template) Removed from manifest
test (cargo doc -D warnings) Intra-doc links [push] / [pop] / [list] were ambiguous — each name is both a function re-export in lib.rs and a module Disambiguated to [push()] / [pop()] / [list()] to target the function re-exports
test-fast (windows-*), test-fixtures-windows, test-fast (macos-latest) Build failure: std::os::unix::fs::symlink doesn't exist on Windows cfg-gated the symlink call — unix uses std::os::unix::fs::symlink, windows uses std::os::windows::fs::symlink_file, other targets get an Unsupported io::Error. Pre-flight try_exists scan in collect_restore_targets_recursive still short-circuits before any writes when a target conflict is detected, so a partial restore on Windows-symlink-failure only happens when the file system itself rejects the call (e.g. insufficient privileges)

Local validation before push:

  • cargo build -p gix-stash --features sha1
  • cargo check -p gix-stash --features sha1 --target x86_64-pc-windows-gnu ✓ (cross-compile to verify the cfg gate)
  • cargo clippy -p gix-stash --features sha1 -- -D warnings
  • RUSTDOCFLAGS="-D warnings" cargo doc -p gix-stash --no-deps --features sha1
  • cargo test -p gix-stash --features sha1 ✓ 18 passed

@mxaddict
Copy link
Copy Markdown
Author

Heads-up: the 3 remaining test failures (gix-pack::pack::data::output::count_and_entries::empty_pack_is_allowed, multi_index::access::general, data::output::count_and_entries::traversals) are unrelated to this PR — they reproduce on concurrent unrelated branches (packed-refs-hashmap-in-fetch-update, skip-validation-in-binary-search) with the same fatal: could not find pack from git repack inside make_pack_gen_repo.sh fixtures.

All gix-stash jobs are green; the failure looks like a runner-image git regression rather than anything in this branch.

@mxaddict
Copy link
Copy Markdown
Author

Happy to open a separate PR to investigate the gix-pack fixture failures if that would help — just let me know.

mxaddict (mxaddict) added a commit to kryptic-sh/krypt that referenced this pull request May 18, 2026
… lands

cargo publish rejects a manifest whose declared gix features (`stash`,
`merge`) do not exist on the version published to crates.io. The
mxaddict/gitoxide fork adds those features; stock gix 0.83.0 does not.
Until GitoxideLabs/gitoxide#2603 lands and we repoint the gix dep back
to crates.io, krypt-core and krypt-cli cannot be published.

krypt-platform + krypt-pkg continue to publish (no gix dep). The
binary still ships via AUR / brew / scoop / GH Releases (built from the
fork in the build job).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant