|
@@ -25,8 +25,21 @@ import os
|
|
|
from typing import TYPE_CHECKING, Optional, TypedDict
|
|
|
|
|
|
from .file import GitFile
|
|
|
-from .index import commit_tree, iter_fresh_objects
|
|
|
-from .objects import ObjectID
|
|
|
+from .index import (
|
|
|
+ IndexEntry,
|
|
|
+ _tree_to_fs_path,
|
|
|
+ build_file_from_blob,
|
|
|
+ commit_tree,
|
|
|
+ index_entry_from_stat,
|
|
|
+ iter_fresh_objects,
|
|
|
+ iter_tree_contents,
|
|
|
+ symlink,
|
|
|
+ update_working_tree,
|
|
|
+ validate_path,
|
|
|
+ validate_path_element_default,
|
|
|
+ validate_path_element_ntfs,
|
|
|
+)
|
|
|
+from .objects import S_IFGITLINK, Blob, Commit, ObjectID
|
|
|
from .reflog import drop_reflog_entry, read_reflog
|
|
|
from .refs import Ref
|
|
|
|
|
@@ -83,7 +96,147 @@ class Stash:
|
|
|
self._repo.refs[self._ref] = self[0].new_sha
|
|
|
|
|
|
def pop(self, index: int) -> "Entry":
|
|
|
- raise NotImplementedError(self.pop)
|
|
|
+ """Pop a stash entry and apply its changes.
|
|
|
+
|
|
|
+ Args:
|
|
|
+ index: Index of the stash entry to pop (0 is the most recent)
|
|
|
+
|
|
|
+ Returns:
|
|
|
+ The stash entry that was popped
|
|
|
+ """
|
|
|
+ # Get the stash entry before removing it
|
|
|
+ entry = self[index]
|
|
|
+
|
|
|
+ # Get the stash commit
|
|
|
+ stash_commit = self._repo.get_object(entry.new_sha)
|
|
|
+ assert isinstance(stash_commit, Commit)
|
|
|
+
|
|
|
+ # The stash commit has the working tree changes
|
|
|
+ # Its first parent is the commit the stash was based on
|
|
|
+ # Its second parent is the index commit
|
|
|
+ if len(stash_commit.parents) < 1:
|
|
|
+ raise ValueError("Invalid stash entry: no parent commits")
|
|
|
+
|
|
|
+ base_commit_sha = stash_commit.parents[0]
|
|
|
+
|
|
|
+ # Get current HEAD to determine if we can apply cleanly
|
|
|
+ try:
|
|
|
+ current_head = self._repo.refs[b"HEAD"]
|
|
|
+ except KeyError:
|
|
|
+ raise ValueError("Cannot pop stash: no HEAD")
|
|
|
+
|
|
|
+ # Check if we're at the same commit where the stash was created
|
|
|
+ # If not, we need to do a three-way merge
|
|
|
+ if current_head != base_commit_sha:
|
|
|
+ # For now, we'll apply changes directly but this could cause conflicts
|
|
|
+ # A full implementation would do a three-way merge
|
|
|
+ pass
|
|
|
+
|
|
|
+ # Apply the stash changes to the working tree and index
|
|
|
+ # Get config for working directory update
|
|
|
+ config = self._repo.get_config()
|
|
|
+ honor_filemode = config.get_boolean(b"core", b"filemode", os.name != "nt")
|
|
|
+
|
|
|
+ if config.get_boolean(b"core", b"core.protectNTFS", os.name == "nt"):
|
|
|
+ validate_path_element = validate_path_element_ntfs
|
|
|
+ else:
|
|
|
+ validate_path_element = validate_path_element_default
|
|
|
+
|
|
|
+ if config.get_boolean(b"core", b"symlinks", True):
|
|
|
+ symlink_fn = symlink
|
|
|
+ else:
|
|
|
+
|
|
|
+ def symlink_fn(source, target) -> None: # type: ignore
|
|
|
+ mode = "w" + ("b" if isinstance(source, bytes) else "")
|
|
|
+ with open(target, mode) as f:
|
|
|
+ f.write(source)
|
|
|
+
|
|
|
+ # Get blob normalizer for line ending conversion
|
|
|
+ blob_normalizer = self._repo.get_blob_normalizer()
|
|
|
+
|
|
|
+ # Open the index
|
|
|
+ repo_index = self._repo.open_index()
|
|
|
+
|
|
|
+ # Apply working tree changes
|
|
|
+ stash_tree_id = stash_commit.tree
|
|
|
+ repo_path = os.fsencode(self._repo.path)
|
|
|
+
|
|
|
+ # First, if we have index changes (second parent), restore the index state
|
|
|
+ if len(stash_commit.parents) >= 2:
|
|
|
+ index_commit_sha = stash_commit.parents[1]
|
|
|
+ index_commit = self._repo.get_object(index_commit_sha)
|
|
|
+ assert isinstance(index_commit, Commit)
|
|
|
+ index_tree_id = index_commit.tree
|
|
|
+
|
|
|
+ # Update index entries from the stashed index tree
|
|
|
+ for entry in iter_tree_contents(self._repo.object_store, index_tree_id):
|
|
|
+ if not validate_path(entry.path, validate_path_element):
|
|
|
+ continue
|
|
|
+
|
|
|
+ # Add to index with stage 0 (normal)
|
|
|
+ # Get file stats for the entry
|
|
|
+ full_path = _tree_to_fs_path(repo_path, entry.path)
|
|
|
+ try:
|
|
|
+ st = os.lstat(full_path)
|
|
|
+ except FileNotFoundError:
|
|
|
+ # File doesn't exist yet, use dummy stats
|
|
|
+ st = os.stat_result((entry.mode, 0, 0, 0, 0, 0, 0, 0, 0, 0))
|
|
|
+ repo_index[entry.path] = index_entry_from_stat(st, entry.sha)
|
|
|
+
|
|
|
+ # Apply working tree changes from the stash
|
|
|
+ for entry in iter_tree_contents(self._repo.object_store, stash_tree_id):
|
|
|
+ if not validate_path(entry.path, validate_path_element):
|
|
|
+ continue
|
|
|
+
|
|
|
+ full_path = _tree_to_fs_path(repo_path, entry.path)
|
|
|
+
|
|
|
+ # Create parent directories if needed
|
|
|
+ parent_dir = os.path.dirname(full_path)
|
|
|
+ if parent_dir and not os.path.exists(parent_dir):
|
|
|
+ os.makedirs(parent_dir)
|
|
|
+
|
|
|
+ # Write the file
|
|
|
+ if entry.mode == S_IFGITLINK:
|
|
|
+ # Submodule - just create directory
|
|
|
+ if not os.path.isdir(full_path):
|
|
|
+ os.mkdir(full_path)
|
|
|
+ st = os.lstat(full_path)
|
|
|
+ else:
|
|
|
+ obj = self._repo.object_store[entry.sha]
|
|
|
+ assert isinstance(obj, Blob)
|
|
|
+ # Apply blob normalization for checkout if normalizer is provided
|
|
|
+ if blob_normalizer is not None:
|
|
|
+ obj = blob_normalizer.checkout_normalize(obj, entry.path)
|
|
|
+ st = build_file_from_blob(
|
|
|
+ obj,
|
|
|
+ entry.mode,
|
|
|
+ full_path,
|
|
|
+ honor_filemode=honor_filemode,
|
|
|
+ symlink_fn=symlink_fn,
|
|
|
+ )
|
|
|
+
|
|
|
+ # Update index if the file wasn't already staged
|
|
|
+ if entry.path not in repo_index:
|
|
|
+ # Update with file stats from disk
|
|
|
+ repo_index[entry.path] = index_entry_from_stat(st, entry.sha)
|
|
|
+ else:
|
|
|
+ existing_entry = repo_index[entry.path]
|
|
|
+
|
|
|
+ if (
|
|
|
+ isinstance(existing_entry, IndexEntry)
|
|
|
+ and existing_entry.mode == entry.mode
|
|
|
+ and existing_entry.sha == entry.sha
|
|
|
+ ):
|
|
|
+ # Update with file stats from disk
|
|
|
+ repo_index[entry.path] = index_entry_from_stat(st, entry.sha)
|
|
|
+
|
|
|
+ # Write the updated index
|
|
|
+ repo_index.write()
|
|
|
+
|
|
|
+ # Remove the stash entry
|
|
|
+ self.drop(index)
|
|
|
+
|
|
|
+ return entry
|
|
|
|
|
|
def push(
|
|
|
self,
|
|
@@ -107,11 +260,15 @@ class Stash:
|
|
|
|
|
|
index = self._repo.open_index()
|
|
|
index_tree_id = index.commit(self._repo.object_store)
|
|
|
+ # Create a dangling commit for the index state
|
|
|
+ # Note: We pass ref=None which is handled specially in do_commit
|
|
|
+ # to create a commit without updating any reference
|
|
|
index_commit_id = self._repo.do_commit(
|
|
|
tree=index_tree_id,
|
|
|
message=b"Index stash",
|
|
|
merge_heads=[self._repo.head()],
|
|
|
no_verify=True,
|
|
|
+ ref=None, # Don't update any ref
|
|
|
**commit_kwargs,
|
|
|
)
|
|
|
|
|
@@ -146,6 +303,22 @@ class Stash:
|
|
|
**commit_kwargs,
|
|
|
)
|
|
|
|
|
|
+ # Reset working tree and index to HEAD to match git's behavior
|
|
|
+ # Use update_working_tree to reset from stash tree to HEAD tree
|
|
|
+ # Get HEAD tree
|
|
|
+ head_commit = self._repo.get_object(self._repo.head())
|
|
|
+ assert isinstance(head_commit, Commit)
|
|
|
+ head_tree_id = head_commit.tree
|
|
|
+
|
|
|
+ # Update from stash tree to HEAD tree
|
|
|
+ # This will remove files that were in stash but not in HEAD,
|
|
|
+ # and restore files to their HEAD versions
|
|
|
+ update_working_tree(
|
|
|
+ self._repo,
|
|
|
+ old_tree_id=stash_tree_id,
|
|
|
+ new_tree_id=head_tree_id,
|
|
|
+ )
|
|
|
+
|
|
|
return cid
|
|
|
|
|
|
def __getitem__(self, index: int) -> "Entry":
|