Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
494 changes: 286 additions & 208 deletions Cargo.lock

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,8 @@ members = [
"gix-fsck",
"tests/tools",
"tests/it",
"gix-shallow"
"gix-shallow",
"gix-stash",
]

[workspace.dependencies]
Expand Down
57 changes: 57 additions & 0 deletions gix-stash/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Changelog

All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## 0.0.0 (Unreleased)

The initial release.

### New Features

- `list(refs)` walks the `refs/stash` reflog and returns every stash entry
newest-first. Returns an empty `Outcome` when `refs/stash` is unborn, matching
`git stash list` output on a stash-free repo.

- `push(ctx, head_commit, head_tree, head_branch, options)` captures the current
working tree as a new stash commit at `refs/stash`:
- parent[0] is the commit `HEAD` points at, parent[1] is a commit whose tree
matches the index at stash time, and parent[2] (optional, when
`Options::include_untracked` is set) carries the untracked files.
- The stash commit's own tree reflects the **working-tree** state for tracked
files (not the index) so unstaged modifications are captured.
- After the ref transaction the worktree is reset to `HEAD` via
`gix_worktree_state::checkout`; untracked files captured into parent[2] are
removed from disk.
- Errors: `EmptyRepository`, `NoLocalChanges`, ODB write failures, ref
transaction failures, worktree I/O.

- `pop(ctx, head_tree, options)` applies the latest stash to the working tree
and drops the entry:
- Performs a 3-way merge via `gix_merge::tree` with base = stash parent[0]
tree, ours = current `head_tree`, theirs = stash WIP tree.
- On a clean merge: writes the merged tree to the worktree via
`gix_worktree_state::checkout`, restores parent[2] untracked files when
present, then drops `refs/stash` (deletes the ref when the stack is
exhausted, otherwise advances it to the next reflog entry).
- On conflict: `Outcome::had_conflicts` is set, conflict markers are written
to the worktree, and `refs/stash` is left untouched (matching
`git stash pop` semantics).
- Errors: `NoStash`, merge failures, ODB I/O, ref transaction failures.

### Known limitations

- The default `checkout_options::filters` and `checkout_options::attributes` are
empty. Callers wiring this crate into porcelain (e.g. `gix` at the
`Repository` level) must populate them so smudge/clean filters and
gitattributes run during the worktree write.
- Tracked entries that have been **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 will not restore the deletion.
- The index produced by `pop` after a clean merge does not preserve stat data or
timestamps from the merged tree.
- Operations that aren't `push` / `pop` / `list` (`apply`, `drop`, `show`,
`branch`, autostash integration with rebase-like workflows) are deferred.
56 changes: 56 additions & 0 deletions gix-stash/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
lints.workspace = true

[package]
name = "gix-stash"
version = "0.0.0"
repository = "https://github.com/GitoxideLabs/gitoxide"
license = "MIT OR Apache-2.0"
description = "A crate of the gitoxide project providing `git stash` plumbing (push + pop)"
authors = ["Sebastian Thiel <sebastian.thiel@icloud.com>"]
edition = "2024"
rust-version = "1.85"
include = ["/src/**/*", "/LICENSE-*"]

[lib]
doctest = false

[features]
## Enable support for the SHA-1 hash by forwarding the feature to dependencies.
sha1 = ["gix-hash/sha1", "gix-index/sha1", "gix-object/sha1"]

[dependencies]
gix-hash = { version = "^0.25.0", path = "../gix-hash" }
gix-object = { version = "^0.61.0", path = "../gix-object" }
gix-index = { version = "^0.52.0", path = "../gix-index" }
gix-ref = { version = "^0.64.0", path = "../gix-ref" }
gix-actor = { version = "^0.41.0", path = "../gix-actor" }
gix-date = { version = "^0.15.3", path = "../gix-date" }
gix-features = { version = "^0.48.0", path = "../gix-features", features = ["progress"] }
gix-path = { version = "^0.12.0", path = "../gix-path" }
gix-validate = { version = "^0.11.1", path = "../gix-validate" }
gix-diff = { version = "^0.64.0", path = "../gix-diff", default-features = false, features = ["blob"] }
gix-merge = { version = "^0.17.0", path = "../gix-merge" }
gix-worktree-state = { version = "^0.31.0", path = "../gix-worktree-state" }
gix-lock = { version = "^23.0.0", path = "../gix-lock" }

bstr = { version = "1.12.0", default-features = false, features = ["std"] }
thiserror = "2.0.18"

[dev-dependencies]
gix-testtools = { path = "../tests/tools" }
gix-hash = { path = "../gix-hash", features = ["sha1"] }
gix-odb = { path = "../gix-odb", features = ["sha1"] }
gix-ref = { path = "../gix-ref" }
gix-actor = { path = "../gix-actor" }
gix-date = { path = "../gix-date" }
gix-index = { path = "../gix-index", features = ["sha1"] }
gix-object = { path = "../gix-object" }
gix-worktree = { path = "../gix-worktree" }
gix-diff = { path = "../gix-diff", default-features = false, features = ["blob"] }
gix-merge = { path = "../gix-merge" }
gix-filter = { path = "../gix-filter" }
gix-worktree-state = { path = "../gix-worktree-state" }
bstr = { version = "1.12.0", default-features = false, features = ["std"] }

[package.metadata.docs.rs]
features = ["sha1"]
45 changes: 45 additions & 0 deletions gix-stash/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
//! Plumbing for [`git stash`](https://git-scm.com/docs/git-stash) workflows.
//!
//! This crate implements the `push` and `pop` operations as a starting MVP.
//! Additional operations (`apply`, `drop`, `list`, `show`, `branch`,
//! `autostash`) are tracked in [`crate-status.md`] and may follow.
//!
//! [`crate-status.md`]: https://github.com/GitoxideLabs/gitoxide/blob/main/crate-status.md
//!
//! # Stash representation
//!
//! A stash entry is a merge commit with 2 or 3 parents stored at the single
//! ref `refs/stash`, with the reflog providing the stack of older entries:
//!
//! * `parent[0]` — the commit that `HEAD` pointed at when the stash was made
//! * `parent[1]` — a commit whose tree is the **index** at stash time
//! * `parent[2]` — *(optional, only when `--include-untracked` is used)* — a
//! commit whose tree contains the **untracked** files at stash time
//!
//! The stash commit's own tree is the **working tree** at stash time.
//!
//! # API
//!
//! * [`push()`] — capture working tree (+ index, + optional untracked) and
//! reset to `HEAD`.
//! * [`pop()`] — apply the latest stash to the working tree (3-way merge) and
//! drop it from `refs/stash`.
//! * [`list()`] — walk the `refs/stash` reflog and return every stash entry.
//!
//! All three operate on plumbing handles (index, ODB, ref store, worktree
//! path) rather than a high-level repository — the porcelain layer in `gix`
//! wraps them and provides `Repository::stash_push` / `Repository::stash_pop`
//! / `Repository::stash_list`.

#![deny(missing_docs, rust_2018_idioms)]
#![forbid(unsafe_code)]

pub mod list;
pub mod pop;
pub mod push;

pub use list::{Entry as ListEntry, Outcome as ListOutcome, function::list};
pub use pop::{Context as PopContext, Error as PopError, Outcome as PopOutcome, function::pop};
pub use push::{
Context as PushContext, Error as PushError, Options as PushOptions, Outcome as PushOutcome, function::push,
};
105 changes: 105 additions & 0 deletions gix-stash/src/list/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
//! Implementation of [`stash list`](https://git-scm.com/docs/git-stash#Documentation/git-stash.txt-list).

use bstr::BString;
use gix_hash::ObjectId;

/// A single stash entry as found in the `refs/stash` reflog.
///
/// The newest entry is index `0` (the current value of `refs/stash`); older
/// entries follow in reverse-chronological order, matching what
/// `git stash list` prints (`stash@{0}`, `stash@{1}`, …).
#[derive(Debug, Clone)]
pub struct Entry {
/// Stack position — `0` is the newest.
pub index: usize,

/// The stash commit's object id.
pub commit: ObjectId,

/// The reflog message (e.g. `WIP on main: abc1234 commit subject`).
pub message: BString,

/// Seconds since the Unix epoch, from the reflog committer line.
pub time_seconds: u64,
}

/// Result of [`function::list`].
#[derive(Debug, Clone, Default)]
pub struct Outcome {
/// The stash entries, newest first. Empty when `refs/stash` is unborn
/// (no stashes have ever been created in this repo).
pub entries: Vec<Entry>,
}

/// Errors returned by [`function::list`].
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// The reflog file for `refs/stash` could not be read or seeked.
///
/// This is extracted from [`gix_ref::file::log::Error`] since I/O is
/// the only variant that can occur when a hard-coded, valid ref name is used.
#[error("failed to perform I/O while reading the refs/stash reflog")]
Io(#[from] std::io::Error),
/// An individual reflog line failed to decode.
#[error("failed to decode a reflog line for refs/stash")]
DecodeReflog(#[from] gix_ref::file::log::iter::reverse::Error),
}

pub(crate) mod function {
use gix_ref::FullName;

use super::{Entry, Error, Outcome};

/// Walk the reflog of `refs/stash` and return every stash entry, newest
/// first.
///
/// Returns an empty [`Outcome`] when `refs/stash` is unborn — matching
/// `git stash list` which prints nothing in that case.
///
/// `refs` is the file-based ref store for the repository (typically the
/// `.git/` directory or the common-dir for linked worktrees).
pub fn list(refs: &gix_ref::file::Store) -> Result<Outcome, Error> {
// Parse the well-known ref name once. FullName validates at
// creation time so the expect is safe for this hard-coded literal.
let stash_name: FullName = "refs/stash".try_into().expect("refs/stash is a valid ref name");

// 4 KiB sliding window for the reverse-reflog iterator.
let mut buf = vec![0u8; 4 * 1024];

let iter = match refs
.reflog_iter_rev(stash_name.as_ref(), &mut buf)
.map_err(|e| match e {
gix_ref::file::log::Error::Io(io) => Error::Io(io),
// Cannot happen: we pass a hard-coded valid ref name.
gix_ref::file::log::Error::RefnameValidation(_) => {
unreachable!("refs/stash is always a valid ref name")
}
})? {
// refs/stash has never been written — no stash entries exist.
None => return Ok(Outcome::default()),
Some(iter) => iter,
};

let mut entries: Vec<Entry> = Vec::new();

for line_result in iter {
let line = line_result?;

let commit = line.new_oid;
let message = line.message.clone();
// `gix_actor::Signature` carries a parsed `gix_date::Time` field
// whose `seconds` is `i64`; stash entries cannot predate the epoch.
let time_seconds = line.signature.time.seconds.max(0) as u64;

entries.push(Entry {
// entries are read newest-first, so index 0 = newest.
index: entries.len(),
commit,
message,
time_seconds,
});
}

Ok(Outcome { entries })
}
}
Loading
Loading