///| Git stash implementation
///|
pub struct StashEntry {
id : @bit.ObjectId
message : String
}
///|
pub(all) struct StashPatchHunk {
path : String
display_lines : Array[String]
}
///|
priv struct StashPatchInternalHunk {
prompt : StashPatchHunk
old_start : Int
lines : Array[String]
}
///|
priv struct StashPatchFile {
path : String
old_content : Bytes?
new_content : Bytes?
old_mode : Int?
new_mode : Int?
hunks : Array[StashPatchInternalHunk]
}
///|
priv struct StashPatchContent {
content : Bytes?
mode : Int
}
///|
/// List all stash entries.
pub fn stash_list(
fs : &@bit.RepoFileSystem,
git_dir : String,
) -> Array[StashEntry] raise @bit.GitError {
let stash_ref_path = git_dir + "/refs/stash"
if !fs.is_file(stash_ref_path) {
return []
}
// Read reflog for stash
let reflog_path = git_dir + "/logs/refs/stash"
if !fs.is_file(reflog_path) {
// Just return the current stash
let content = @utf8.decode_lossy(fs.read_file(stash_ref_path)[:])
let hex = content.trim_end(chars="\n\r ").to_owned()
let id = @bit.ObjectId::from_hex(hex)
return [{ id, message: "WIP on HEAD" }]
}
let log_content = @utf8.decode_lossy(fs.read_file(reflog_path)[:])
let entries : Array[StashEntry] = []
for line_view in log_content.split("\n") {
let line = line_view.to_owned()
if line.length() == 0 {
continue
}
// Format: \t
let tab_idx = line.find("\t")
match tab_idx {
None => continue
Some(idx) => {
let prefix = String::unsafe_substring(line, start=0, end=idx)
let message = String::unsafe_substring(
line,
start=idx + 1,
end=line.length(),
)
let parts = prefix.split(" ").map(v => v.to_owned()).collect()
if parts.length() >= 2 {
let new_sha = parts[1]
let id = @bit.ObjectId::from_hex(new_sha) catch { _ => continue }
entries.push({ id, message })
}
}
}
}
// Reverse to show newest first
entries.rev_in_place()
entries
}
///|
/// Push current changes to stash.
pub async fn stash_push(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
root : String,
message : String,
author : String,
timestamp : Int64,
) -> @bit.ObjectId? raise @bit.GitError {
let git_dir = join_path(root, ".git")
// Check if there are changes
let st = status(rfs, root)
if st.staged_added.length() == 0 &&
st.staged_modified.length() == 0 &&
st.staged_deleted.length() == 0 &&
st.unstaged_modified.length() == 0 &&
st.unstaged_deleted.length() == 0 {
return None
}
// Get current HEAD
let head = resolve_head_commit(rfs, git_dir)
guard head is Some(head_id) else {
raise @bit.GitError::InvalidObject("HEAD not found")
}
// Create tree from current index
let index_entries = read_index_entries(rfs, git_dir)
let index_tree = write_tree_from_index(fs, rfs, git_dir, index_entries)
// Create tree from worktree (also writes blobs to object store)
let worktree_entries = build_worktree_index(
fs, rfs, git_dir, root, index_entries,
)
let worktree_tree = write_tree_from_index(fs, rfs, git_dir, worktree_entries)
let stash_id = stash_store_entry(
fs, rfs, git_dir, head_id, index_tree, worktree_tree, message, author, timestamp,
)
// Reset worktree to HEAD
let db = ObjectDb::load(rfs, git_dir)
let head_files = collect_tree_files_from_commit(db, rfs, head_id)
write_worktree_from_files(db, fs, rfs, root, git_dir, head_files)
// Reset index to HEAD
let head_entries = tree_files_to_index(db, rfs, head_files)
write_index_entries(fs, git_dir, head_entries)
Some(stash_id)
}
///|
pub async fn stash_push_patch(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
root : String,
message : String,
author : String,
timestamp : Int64,
select_hunk : async (StashPatchHunk, Int, Int) -> Bool,
) -> @bit.ObjectId? raise @bit.GitError {
let git_dir = join_path(root, ".git")
let head = resolve_head_commit(rfs, git_dir)
guard head is Some(head_id) else {
raise @bit.GitError::InvalidObject("HEAD not found")
}
let db = ObjectDb::load_lazy(rfs, git_dir)
let head_files = collect_tree_files_from_commit(db, rfs, head_id)
let index_entries = read_index_entries(rfs, git_dir)
let patch_files = stash_collect_patch_files(
rfs, root, db, head_files, index_entries,
)
let total = patch_files
.iter()
.fold(init=0, fn(acc, file) { acc + file.hunks.length() })
if total == 0 {
return None
}
let selected_by_path : Map[String, Array[Bool]] = Map([])
let mut selected_count = 0
let mut hunk_index = 0
for file in patch_files {
let choices : Array[Bool] = []
for hunk in file.hunks {
let selected = select_hunk(hunk.prompt, hunk_index, total) catch {
_ => false
}
choices.push(selected)
if selected {
selected_count += 1
}
hunk_index += 1
}
selected_by_path[file.path] = choices
}
if selected_count == 0 {
return None
}
let selected_overrides : Map[String, StashPatchContent] = Map([])
let remaining_overrides : Map[String, StashPatchContent] = Map([])
for file in patch_files {
let selected = selected_by_path[file.path]
let selected_content = stash_patch_apply_selection(
file.old_content,
file.new_content,
file.hunks,
selected,
)
let remaining_choices = selected.map(fn(v) { !v })
let remaining_content = stash_patch_apply_selection(
file.old_content,
file.new_content,
file.hunks,
remaining_choices,
)
let selected_mode = stash_patch_result_mode(
file.old_mode,
file.new_mode,
selected,
)
let remaining_mode = stash_patch_result_mode(
file.old_mode,
file.new_mode,
remaining_choices,
)
selected_overrides[file.path] = {
content: selected_content,
mode: selected_mode,
}
remaining_overrides[file.path] = {
content: remaining_content,
mode: remaining_mode,
}
}
let selected_entries = stash_build_patch_tree_entries(
fs, rfs, git_dir, db, head_files, selected_overrides,
)
let worktree_tree = write_tree_from_index(fs, rfs, git_dir, selected_entries)
let index_tree = write_tree_from_index(fs, rfs, git_dir, index_entries)
let stash_id = stash_store_entry(
fs, rfs, git_dir, head_id, index_tree, worktree_tree, message, author, timestamp,
)
stash_write_patch_remaining_worktree(fs, rfs, root, remaining_overrides)
Some(stash_id)
}
///|
fn stash_store_entry(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
git_dir : String,
head_id : @bit.ObjectId,
index_tree : @bit.ObjectId,
worktree_tree : @bit.ObjectId,
message : String,
author : String,
timestamp : Int64,
) -> @bit.ObjectId raise @bit.GitError {
let index_commit = @bit.Commit::new(
index_tree,
[head_id],
author,
timestamp,
"+0000",
author,
timestamp,
"+0000",
"index on HEAD",
)
let (index_commit_id, index_commit_data) = @bit.create_commit(index_commit)
write_object_bytes(fs, git_dir, index_commit_id, index_commit_data)
let stash_msg = if message.length() > 0 { message } else { "WIP on HEAD" }
let stash_commit = @bit.Commit::new(
worktree_tree,
[head_id, index_commit_id],
author,
timestamp,
"+0000",
author,
timestamp,
"+0000",
stash_msg,
)
let (stash_id, stash_data) = @bit.create_commit(stash_commit)
write_object_bytes(fs, git_dir, stash_id, stash_data)
let stash_ref_path = git_dir + "/refs/stash"
let old_id = if rfs.is_file(stash_ref_path) {
let content = @utf8.decode_lossy(rfs.read_file(stash_ref_path)[:])
let hex = content.trim_end(chars="\n\r ").to_owned()
@bit.ObjectId::from_hex(hex) catch {
_ => @bit.ObjectId::zero()
}
} else {
@bit.ObjectId::zero()
}
fs.write_string(stash_ref_path, stash_id.to_hex() + "\n")
let reflog_dir = git_dir + "/logs/refs"
fs.mkdir_p(reflog_dir)
let reflog_path = reflog_dir + "/stash"
let reflog_entry = "\{old_id.to_hex()} \{stash_id.to_hex()} \{author} \{timestamp} +0000\t\{stash_msg}\n"
let existing = if rfs.is_file(reflog_path) {
@utf8.decode_lossy(rfs.read_file(reflog_path)[:])
} else {
""
}
fs.write_string(reflog_path, existing + reflog_entry)
stash_id
}
///|
fn build_worktree_index(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
git_dir : String,
root : String,
index_entries : Array[IndexEntry],
) -> Array[IndexEntry] raise @bit.GitError {
let result : Array[IndexEntry] = []
for e in index_entries {
let abs = join_path(root, e.path)
if rfs.is_file(abs) {
let content = rfs.read_file(abs)
// Write blob to object store
let id = write_loose_object(fs, git_dir, @bit.ObjectType::Blob, content)
result.push({
path: e.path,
id,
mode: e.mode,
size: content.length(),
mtime_sec: 0,
mtime_nsec: 0,
intent_to_add: false,
dev: 0,
ino: 0,
uid: 0,
gid: 0,
})
}
// Skip deleted files
}
result
}
///|
fn stash_collect_patch_files(
rfs : &@bit.RepoFileSystem,
root : String,
db : ObjectDb,
head_files : Map[String, TreeFileEntry],
index_entries : Array[IndexEntry],
) -> Array[StashPatchFile] raise @bit.GitError {
let path_set : Map[String, Bool] = Map([])
for path in head_files.keys() {
path_set[path] = true
}
for entry in index_entries {
path_set[entry.path] = true
}
let paths = path_set.keys().to_array()
paths.sort()
let files : Array[StashPatchFile] = []
for path in paths {
let old_entry = head_files.get(path)
let old_mode = match old_entry {
Some(info) => Some(info.mode)
None => None
}
let index_mode = stash_index_mode(index_entries, path)
if stash_is_gitlink_mode(old_mode) || stash_is_gitlink_mode(index_mode) {
continue
}
let old_content = match old_entry {
Some(info) => stash_read_tree_blob(db, rfs, info)
None => None
}
let abs = join_path(root, path)
let new_content = if rfs.is_file(abs) {
Some(rfs.read_file(abs))
} else {
None
}
let new_mode = match new_content {
Some(_) => Some(index_mode.unwrap_or(old_mode.unwrap_or(0o100644)))
None => None
}
if stash_same_content(old_content, new_content) &&
stash_same_mode(old_mode, new_mode) {
continue
}
let hunks = stash_build_patch_hunks(path, old_content, new_content)
if hunks.length() == 0 {
continue
}
files.push({ path, old_content, new_content, old_mode, new_mode, hunks })
}
files
}
///|
fn stash_index_mode(entries : Array[IndexEntry], path : String) -> Int? {
for entry in entries {
if entry.path == path {
return Some(entry.mode)
}
}
None
}
///|
fn stash_is_gitlink_mode(mode : Int?) -> Bool {
match mode {
Some(m) => is_gitlink_mode_int(m)
None => false
}
}
///|
fn stash_read_tree_blob(
db : ObjectDb,
rfs : &@bit.RepoFileSystem,
info : TreeFileEntry,
) -> Bytes? raise @bit.GitError {
if is_gitlink_mode_int(info.mode) {
return None
}
let obj = db.get(rfs, info.id)
match obj {
Some(o) if o.obj_type == @bit.ObjectType::Blob => Some(o.data)
_ => None
}
}
///|
fn stash_same_mode(left : Int?, right : Int?) -> Bool {
match (left, right) {
(None, None) => true
(Some(a), Some(b)) => a == b
_ => false
}
}
///|
fn stash_same_content(left : Bytes?, right : Bytes?) -> Bool {
match (left, right) {
(None, None) => true
(Some(a), Some(b)) => stash_bytes_equal(a, b)
_ => false
}
}
///|
fn stash_bytes_equal(left : Bytes, right : Bytes) -> Bool {
if left.length() != right.length() {
return false
}
for i in 0.. Array[StashPatchInternalHunk] {
let lines : Array[String] = []
@diff_core.unified_hunks(lines, old_content, new_content)
let hunks : Array[StashPatchInternalHunk] = []
let display_header = stash_patch_display_header(
path, old_content, new_content,
)
let mut current_header = ""
let mut current_old_start = 0
let mut current_lines : Array[String] = []
for line in lines {
if line.has_prefix("@@ ") {
if current_header.length() > 0 {
stash_push_internal_hunk(
hunks, path, display_header, current_header, current_old_start, current_lines,
)
current_lines = []
}
current_header = line
match stash_parse_hunk_header(line) {
Some((os, _, _, _)) => current_old_start = os
None => current_old_start = 0
}
} else if current_header.length() > 0 {
current_lines.push(line)
}
}
if current_header.length() > 0 {
stash_push_internal_hunk(
hunks, path, display_header, current_header, current_old_start, current_lines,
)
}
hunks
}
///|
fn stash_patch_display_header(
path : String,
old_content : Bytes?,
new_content : Bytes?,
) -> Array[String] {
let lines = ["diff --git a/\{path} b/\{path}"]
match old_content {
Some(_) => lines.push("--- a/\{path}")
None => lines.push("--- /dev/null")
}
match new_content {
Some(_) => lines.push("+++ b/\{path}")
None => lines.push("+++ /dev/null")
}
lines
}
///|
fn stash_push_internal_hunk(
hunks : Array[StashPatchInternalHunk],
path : String,
display_header : Array[String],
header : String,
old_start : Int,
lines : Array[String],
) -> Unit {
let display_lines = display_header.copy()
display_lines.push(header)
for line in lines {
display_lines.push(line)
}
hunks.push({ prompt: { path, display_lines }, old_start, lines })
}
///|
fn stash_parse_hunk_header(line : String) -> (Int, Int, Int, Int)? {
if !line.has_prefix("@@ ") {
return None
}
let content = String::unsafe_substring(line, start=3, end=line.length())
match content.find(" @@") {
None => None
Some(end) => {
let range_part = String::unsafe_substring(content, start=0, end~)
let parts : Array[String] = range_part
.split(" ")
.map(v => v.to_owned())
.collect()
if parts.length() < 2 {
return None
}
let old_part = parts[0]
let new_part = parts[1]
if old_part.length() == 0 || new_part.length() == 0 {
return None
}
let (old_start, old_count) = stash_parse_hunk_range(
String::unsafe_substring(old_part, start=1, end=old_part.length()),
)
let (new_start, new_count) = stash_parse_hunk_range(
String::unsafe_substring(new_part, start=1, end=new_part.length()),
)
Some((old_start, old_count, new_start, new_count))
}
}
}
///|
fn stash_parse_hunk_range(s : String) -> (Int, Int) {
match s.find(",") {
Some(idx) => {
let start_str = String::unsafe_substring(s, start=0, end=idx)
let count_str = String::unsafe_substring(s, start=idx + 1, end=s.length())
let start = @string.parse_int(start_str) catch { _ => 1 }
let count = @string.parse_int(count_str) catch { _ => 1 }
(start, count)
}
None => {
let start = @string.parse_int(s) catch { _ => 1 }
(start, 1)
}
}
}
///|
fn stash_patch_apply_selection(
old_content : Bytes?,
new_content : Bytes?,
hunks : Array[StashPatchInternalHunk],
selected : Array[Bool],
) -> Bytes? {
let mut selected_count = 0
for v in selected {
if v {
selected_count += 1
}
}
if selected_count == 0 {
return old_content
}
if new_content is None && selected_count == hunks.length() {
return None
}
let old_lines = match old_content {
Some(data) => @diff_core.split_lines(data)
None => []
}
let result_lines : Array[String] = []
let mut line_idx = 0
for i in 0..= selected.length() || !selected[i] {
continue
}
let hunk = hunks[i]
let old_start_index = if hunk.old_start > 0 {
hunk.old_start - 1
} else {
0
}
while line_idx < old_start_index && line_idx < old_lines.length() {
result_lines.push(old_lines[line_idx])
line_idx += 1
}
for line in hunk.lines {
if line.has_prefix("\\") {
continue
} else if line.has_prefix("+") {
result_lines.push(
String::unsafe_substring(line, start=1, end=line.length()),
)
} else if line.has_prefix("-") {
if line_idx < old_lines.length() {
line_idx += 1
}
} else if line.has_prefix(" ") {
if line_idx < old_lines.length() {
result_lines.push(old_lines[line_idx])
} else {
result_lines.push(
String::unsafe_substring(line, start=1, end=line.length()),
)
}
line_idx += 1
}
}
}
while line_idx < old_lines.length() {
result_lines.push(old_lines[line_idx])
line_idx += 1
}
let trailing_newline = match new_content {
Some(data) => data.length() > 0 && data[data.length() - 1] == b'\n'
None =>
match old_content {
Some(data) => data.length() > 0 && data[data.length() - 1] == b'\n'
None => true
}
}
Some(stash_join_lines(result_lines, trailing_newline))
}
///|
fn stash_join_lines(lines : Array[String], trailing_newline : Bool) -> Bytes {
let text = if lines.length() == 0 {
""
} else {
let joined = lines.join("\n")
if trailing_newline {
joined + "\n"
} else {
joined
}
}
@utf8.encode(text)
}
///|
fn stash_patch_result_mode(
old_mode : Int?,
new_mode : Int?,
selected : Array[Bool],
) -> Int {
let mut selected_count = 0
for v in selected {
if v {
selected_count += 1
}
}
if selected_count > 0 {
new_mode.unwrap_or(old_mode.unwrap_or(0o100644))
} else {
old_mode.unwrap_or(new_mode.unwrap_or(0o100644))
}
}
///|
fn stash_build_patch_tree_entries(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
git_dir : String,
db : ObjectDb,
head_files : Map[String, TreeFileEntry],
overrides : Map[String, StashPatchContent],
) -> Array[IndexEntry] raise @bit.GitError {
let path_set : Map[String, Bool] = Map([])
for path in head_files.keys() {
path_set[path] = true
}
for path in overrides.keys() {
path_set[path] = true
}
let paths = path_set.keys().to_array()
paths.sort()
let entries : Array[IndexEntry] = []
for path in paths {
match overrides.get(path) {
Some(patch_content) =>
match patch_content.content {
Some(content) => {
let id = write_loose_object(
fs,
git_dir,
@bit.ObjectType::Blob,
content,
)
entries.push(
IndexEntry::new(path, id, patch_content.mode, content.length()),
)
}
None => ()
}
None =>
match head_files.get(path) {
Some(info) =>
entries.push(stash_index_entry_from_tree(db, rfs, path, info))
None => ()
}
}
}
entries
}
///|
fn stash_index_entry_from_tree(
db : ObjectDb,
rfs : &@bit.RepoFileSystem,
path : String,
info : TreeFileEntry,
) -> IndexEntry raise @bit.GitError {
if is_gitlink_mode_int(info.mode) {
return IndexEntry::new(path, info.id, info.mode, 0)
}
let obj = db.get(rfs, info.id)
let size = match obj {
Some(o) if o.obj_type == @bit.ObjectType::Blob => o.data.length()
_ => 0
}
IndexEntry::new(path, info.id, info.mode, size)
}
///|
fn stash_write_patch_remaining_worktree(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
root : String,
overrides : Map[String, StashPatchContent],
) -> Unit raise @bit.GitError {
for path, content in overrides {
let abs = join_path(root, path)
match content.content {
Some(bytes) => {
match abs.rev_find("/") {
Some(idx) => {
let dir = String::unsafe_substring(abs, start=0, end=idx)
fs.mkdir_p(dir)
}
None => ()
}
fs.write_file(abs, bytes)
}
None => if rfs.is_file(abs) { fs.remove_file(abs) }
}
}
}
///|
/// Apply stash entry (optionally dropping it).
pub fn stash_apply(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
root : String,
index : Int,
drop : Bool,
) -> Unit raise @bit.GitError {
let git_dir = join_path(root, ".git")
let entries = stash_list(rfs, git_dir)
if index >= entries.length() {
raise @bit.GitError::InvalidObject("No stash entry at index \{index}")
}
let entry = entries[index]
// Get stash commit
let db = ObjectDb::load(rfs, git_dir)
let obj = db.get(rfs, entry.id)
guard obj is Some(stash_obj) else {
raise @bit.GitError::InvalidObject("Stash object not found")
}
if stash_obj.obj_type != @bit.ObjectType::Commit {
raise @bit.GitError::InvalidObject("Invalid stash entry")
}
let commit_info = @bit.parse_commit(stash_obj.data)
// Apply worktree state from stash
let stash_files = collect_tree_files(db, rfs, commit_info.tree)
write_worktree_from_files(
db,
fs,
rfs,
root,
git_dir,
stash_files,
remove_missing=false,
)
// Also update index from first parent's second parent (index commit) if exists
if commit_info.parents.length() >= 2 {
let index_commit_id = commit_info.parents[1]
let index_obj = db.get(rfs, index_commit_id)
match index_obj {
Some(idx_obj) if idx_obj.obj_type == @bit.ObjectType::Commit => {
let idx_info = @bit.parse_commit(idx_obj.data)
let idx_files = collect_tree_files(db, rfs, idx_info.tree)
let idx_entries = tree_files_to_index(db, rfs, idx_files)
write_index_entries(fs, git_dir, idx_entries)
}
_ => ()
}
}
if drop {
stash_drop_at(fs, rfs, git_dir, index)
}
}
///|
/// Drop stash entry at index.
pub fn stash_drop(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
root : String,
index : Int,
) -> Unit raise @bit.GitError {
let git_dir = join_path(root, ".git")
stash_drop_at(fs, rfs, git_dir, index)
}
///|
fn stash_drop_at(
fs : &@bit.FileSystem,
rfs : &@bit.RepoFileSystem,
git_dir : String,
index : Int,
) -> Unit raise @bit.GitError {
let entries = stash_list(rfs, git_dir)
if index >= entries.length() {
raise @bit.GitError::InvalidObject("No stash entry at index \{index}")
}
// Rebuild reflog without the entry
let reflog_path = git_dir + "/logs/refs/stash"
if !rfs.is_file(reflog_path) {
// Only one entry, just remove refs/stash
let stash_ref = git_dir + "/refs/stash"
fs.remove_file(stash_ref)
return ()
}
let log_content = @utf8.decode_lossy(rfs.read_file(reflog_path)[:])
let lines : Array[String] = []
for line_view in log_content.split("\n") {
let line = line_view.to_owned()
if line.length() > 0 {
lines.push(line)
}
}
// Reverse to get newest first, then remove at index
lines.rev_in_place()
if index < lines.length() {
let _ = lines.remove(index)
}
if lines.length() == 0 {
// No more entries
fs.remove_file(reflog_path)
fs.remove_file(git_dir + "/refs/stash")
} else {
// Reverse back and write
lines.rev_in_place()
let new_content = lines
.iter()
.fold(init="", (acc, line) => acc + line + "\n")
fs.write_string(reflog_path, new_content)
// Update refs/stash to newest entry (last line has newest)
let newest = lines[lines.length() - 1]
let parts = newest.split(" ").map(v => v.to_owned()).collect()
if parts.length() >= 2 {
let new_id = parts[1]
fs.write_string(git_dir + "/refs/stash", new_id + "\n")
}
}
}
///|
/// Collect sorted union of paths from two tree file maps.
pub fn stash_collect_paths(
parent_files : Map[String, TreeFileEntry],
stash_files : Map[String, TreeFileEntry],
) -> Array[String] {
let all_paths : Map[String, Bool] = Map([])
for key, _ in parent_files {
all_paths[key] = true
}
for key, _ in stash_files {
all_paths[key] = true
}
let paths : Array[String] = []
for key, _ in all_paths {
paths.push(key)
}
paths.sort()
paths
}
///|
/// Convert Int mode to String representation.
pub fn stash_mode_str(mode : Int) -> String {
@string_utils.mode_to_string(mode)
}