From 0312a9bf376ff41ed9eac86aea662b9704705e5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 23 Jun 2026 16:23:00 -0400 Subject: [PATCH 01/19] add git-lfs family dependencies --- asyncgit/Cargo.toml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml index 4d32071b72..40b1416c55 100644 --- a/asyncgit/Cargo.toml +++ b/asyncgit/Cargo.toml @@ -17,6 +17,11 @@ crossbeam-channel = "0.5" dirs = "6.0" easy-cast = "0.5" fuzzy-matcher = "0.3" +<<<<<<< HEAD +git-lfs = "0.7.0" +git-lfs-filter = "0.7.0" +git-lfs-pointer = "0.7.0" +git-lfs-store = "0.7.0" git2 = { version = "0.21", features = ["https", "ssh", "unstable-sha256"] } git2-hooks = { path = "../git2-hooks", version = "0.7" } gix = { version = "0.84.0", default-features = false, features = ["mailmap", "max-performance", "revision", "sha1", "sha256", "status"] } @@ -29,6 +34,7 @@ rayon = "1.11" rayon-core = "1.13" scopetime = { path = "../scopetime", version = "0.1" } serde = { version = "1.0", features = ["derive"] } +<<<<<<< HEAD tempfile = "3" thiserror = "2.0" unicode-truncate = "2.0" From 31857a88c3ec5b4fc44cc290bf8859cbcf9d2145 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 23 Jun 2026 16:23:03 -0400 Subject: [PATCH 02/19] add LFS error variants --- asyncgit/src/error.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/asyncgit/src/error.rs b/asyncgit/src/error.rs index e89e4a56f8..c2abdfc4d8 100644 --- a/asyncgit/src/error.rs +++ b/asyncgit/src/error.rs @@ -180,6 +180,18 @@ pub enum Error { /// #[error("reword error: config commit.gpgsign=true detected.\ngpg signing is not supported for rewording commits with staged changes\ntry unstaging or stashing your changes")] SignRewordLastCommitStaged, + + /// + #[error("LFS clean error: {0}")] + LfsClean(#[from] git_lfs_filter::CleanError), + + /// + #[error("LFS smudge error: {0}")] + LfsSmudge(#[from] git_lfs_filter::SmudgeError), + + /// + #[error("tempfile persist error: {0}")] + Persist(#[from] tempfile::PersistError), } /// From dcbd443a9a945561fd0e818160a3740f50fa5b23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 23 Jun 2026 16:23:03 -0400 Subject: [PATCH 03/19] add native LFS smudge/clean module --- asyncgit/src/sync/lfs.rs | 539 +++++++++++++++++++++++++++++++++++++++ asyncgit/src/sync/mod.rs | 1 + 2 files changed, 540 insertions(+) create mode 100644 asyncgit/src/sync/lfs.rs diff --git a/asyncgit/src/sync/lfs.rs b/asyncgit/src/sync/lfs.rs new file mode 100644 index 0000000000..7a0a828b33 --- /dev/null +++ b/asyncgit/src/sync/lfs.rs @@ -0,0 +1,539 @@ +use std::{fs::File, io::Read, path::Path}; + +use git2::Repository; +use git_lfs_filter::{clean, smudge_object_to}; +use git_lfs_pointer::Pointer; +use git_lfs_store::Store; + +use crate::error::{Error, Result}; + +/// Whether a specific path is subject to the LFS `filter` attribute. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LfsFilter { + /// The `filter` attribute is absent or set to something other than `lfs`. + Passthrough, + /// The `filter` attribute IS `lfs` + Lfs, +} + +impl LfsFilter { + /// Returns `true` if this path should be handled by the LFS filter. + pub const fn is_lfs(self) -> bool { + matches!(self, Self::Lfs) + } +} + +/// The outcome of smudging a single working-tree file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SmudgeOutcome { + /// The file was not an LFS pointer (or is not LFS-tracked); unchanged. + Skipped, + /// The pointer's object was not in the local store; left as a pointer. + ObjectMissing, + /// The file was smudged successfully; pointer replaced with real content. + Restored, +} + +/// Return the local LFS object store for `repo` (`.git/lfs`). +pub fn lfs_store(repo: &Repository) -> Store { + Store::new(repo.path().join("lfs")) +} + +/// Resolve the LFS filter attribute for `path` (relative to the repo root). +/// +/// This delegates to libgit2's attribute lookup and returns an [`LfsFilter`] +/// variant so downstream code can pattern-match instead of inspecting strings +/// or booleans. +pub fn lfs_filter_for(repo: &Repository, path: &Path) -> LfsFilter { + match repo.get_attr(path, "filter", git2::AttrCheckFlags::empty()) + { + Ok(Some("lfs")) => LfsFilter::Lfs, + _ => LfsFilter::Passthrough, + } +} + +/// Apply the LFS *clean* filter to `file_path` +/// +/// The real bytes are written to `.git/lfs/objects/`; keeping the index entry holding +/// just the small pointer text. +pub fn large_file_storage_clean_and_stage( + repository: &Repository, + index: &mut git2::Index, + file_path: &Path, +) -> Result<()> { + let working_directory = + repository.workdir().ok_or(Error::NoWorkDir)?; + let absolute_path = working_directory.join(file_path); + + let mut file = File::open(&absolute_path)?; + let store = lfs_store(repository); + + // Extract the path string once to avoid repeating the unwrap logic later + let path_string = file_path.to_str().unwrap_or(""); + let mut pointer_bytes = Vec::new(); + + // Produce the canonical pointer text into `pointer_bytes`. + clean(&store, &mut file, &mut pointer_bytes, path_string, &[])?; + + let mode = file_mode_for(&absolute_path)?; + + let entry = + create_index_entry(mode, pointer_bytes.len(), path_string)?; + + index.add_frombuffer(&entry, &pointer_bytes)?; + + Ok(()) +} + +fn create_index_entry( + mode: git2::FileMode, + size: usize, + path: &str, +) -> Result { + Ok(git2::IndexEntry { + ctime: git2::IndexTime::new(0, 0), + mtime: git2::IndexTime::new(0, 0), + dev: 0, + ino: 0, + mode: u32::from(mode), + uid: 0, + gid: 0, + file_size: u32::try_from(size).map_err(|_| { + Error::Generic("pointer too large".into()) + })?, + id: git2::Oid::zero(), + flags: 0, + flags_extended: 0, + path: path.as_bytes().to_vec(), + }) +} + +/// Determine the git file mode for an on-disk path. +fn file_mode_for(path: &Path) -> Result { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = path.metadata()?.permissions().mode(); + Ok(if perms & 0o111 != 0 { + git2::FileMode::BlobExecutable + } else { + git2::FileMode::Blob + }) + } + #[cfg(not(unix))] + { + let _ = path; + Ok(git2::FileMode::Blob) + } +} + +pub fn needs_lfs_cleaning( + repository: &Repository, + index: &git2::Index, + file_path: &Path, +) -> bool { + if !lfs_filter_for(repository, file_path).is_lfs() { + return false; + } + + let Some(index_entry) = index.get_path(file_path, 0) else { + return false; + }; + + // Use the ODB to read just the object header. This avoids loading + // potentially huge blobs entirely into memory just to check their size. + let Ok(odb) = repository.odb() else { + return false; + }; + let Ok((size, _)) = odb.read_header(index_entry.id) else { + return false; + }; + + if size >= git_lfs_pointer::MAX_POINTER_SIZE { + // It's too big to be a pointer, so it must be raw data that needs cleaning + return true; + } + + let Ok(file_blob) = repository.find_blob(index_entry.id) else { + return false; + }; + + let content = file_blob.content(); + + // 3. Check if it's already a valid pointer + let is_valid_pointer = + git_lfs_pointer::Pointer::parse(content).is_ok(); + + // If it's NOT a valid pointer, it's raw data that needs cleaning + !is_valid_pointer +} + +/// Walk `tree` and smudge every LFS pointer file in the working tree. +/// +/// Returns `Ok(())` even when individual files cannot be smudged, so a partial LFS store only warns. +pub fn large_file_storage_smudge_tree( + repository: &Repository, + target_tree: &git2::Tree, + previous_tree: Option<&git2::Tree>, +) -> Result<()> { + let working_directory = + repository.workdir().ok_or(Error::NoWorkDir)?; + let store = lfs_store(repository); + + let Some(previous) = previous_tree else { + smudge_tree_entries( + repository, + target_tree, + working_directory, + &store, + Path::new(""), + )?; + return Ok(()); + }; + + // Only smudge files that changed between previous and target tree. + let difference = repository.diff_tree_to_tree( + Some(previous), + Some(target_tree), + None, + )?; + + let changed_file_paths = difference + .deltas() + .filter(|delta| { + matches!( + delta.status(), + git2::Delta::Added + | git2::Delta::Modified + | git2::Delta::Copied + | git2::Delta::Renamed + ) + }) + .filter_map(|delta| delta.new_file().path()); + + // Process only the filtered paths + for file_path in changed_file_paths { + // Staging will clean content into the local LFS store + // Checkout will smudge pointer files into their OG content + if lfs_filter_for(repository, file_path).is_lfs() { + let full_path = working_directory.join(file_path); + let outcome = + smudge_file(&store, full_path.as_path(), file_path); + + log_smudge_outcome(file_path, outcome); + } + } + + Ok(()) +} + +fn log_smudge_outcome( + entry_path: &Path, + outcome: Result, +) { + match outcome { + Ok(SmudgeOutcome::Restored) => { + log::debug!("lfs smudge: restored {entry_path:?}"); + } + Ok(SmudgeOutcome::ObjectMissing) => { + log::info!( + "lfs smudge: object not in local store for \ + {entry_path:?}, left as pointer" + ); + } + Ok(SmudgeOutcome::Skipped) => {} + Err(e) => { + log::warn!( + "lfs smudge: failed to smudge {entry_path:?}: {e}" + ); + } + } +} + +fn smudge_tree_entries( + repository: &Repository, + tree: &git2::Tree, + working_directory: &Path, + store: &Store, + prefix: &Path, +) -> Result<()> { + for entry in tree { + let name = entry.name().unwrap_or(""); + let entry_path = prefix.join(name); + + match entry.kind() { + Some(git2::ObjectType::Tree) => { + let subtree = + entry.to_object(repository)?.peel_to_tree()?; + + smudge_tree_entries( + repository, + &subtree, + working_directory, + store, + entry_path.as_path(), + )?; + } + // Use a match guard to gracefully filter out non-LFS blobs immediately + Some(git2::ObjectType::Blob) + if lfs_filter_for( + repository, + entry_path.as_path(), + ) + .is_lfs() => + { + let full_path = working_directory.join(&entry_path); + let outcome = smudge_file( + store, + full_path.as_path(), + entry_path.as_path(), + ); + + log_smudge_outcome(entry_path.as_path(), outcome); + } + _ => {} + } + } + Ok(()) +} + +/// Smudge a single file: replace its pointer content with real bytes. +fn smudge_file( + store: &Store, + file_path: &Path, + logical_path: &Path, +) -> Result { + let Some(pointer) = parse_lfs_pointer_from_file(file_path)? + else { + return Ok(SmudgeOutcome::Skipped); + }; + + if !store.contains_with_size(pointer.oid, pointer.size) { + return Ok(SmudgeOutcome::ObjectMissing); + } + + replace_file_with_smudged_content( + store, + &pointer, + file_path, + logical_path, + )?; + + Ok(SmudgeOutcome::Restored) +} + +fn parse_lfs_pointer_from_file( + file_path: &Path, +) -> Result> { + let mut head = vec![0u8; git_lfs_pointer::MAX_POINTER_SIZE]; + let mut file = File::open(file_path)?; + + let bytes_read = fill_buffer(&mut file, &mut head)?; + head.truncate(bytes_read); + + if bytes_read >= git_lfs_pointer::MAX_POINTER_SIZE { + // File is larger than any possible pointer — not LFS. + return Ok(None); + } + + Ok(Pointer::parse(&head).ok()) +} + +fn replace_file_with_smudged_content( + store: &Store, + pointer: &Pointer, + file_path: &Path, + logical_path: &Path, +) -> Result<()> { + let parent = file_path.parent().unwrap_or_else(|| Path::new(".")); + let mut temp_file = tempfile::NamedTempFile::new_in(parent)?; + + let path_hint = logical_path.to_str().unwrap_or(""); + smudge_object_to( + store, + pointer, + temp_file.as_file_mut(), + path_hint, + &[], + None, + )?; + + temp_file.persist(file_path)?; + + Ok(()) +} + +/// Fill `buf` from `reader`, stopping at EOF or when the buffer is full. +/// Returns the number of bytes actually read. +fn fill_buffer( + reader: &mut impl Read, + buf: &mut [u8], +) -> Result { + let mut filled = 0; + while filled < buf.len() { + match reader.read(&mut buf[filled..])? { + 0 => break, + n => filled += n, + } + } + Ok(filled) +} + +#[cfg(test)] +mod tests { + use super::*; + use git_lfs_filter::clean; + use git_lfs_store::Store; + use tempfile::TempDir; + + fn tmp_store() -> (TempDir, Store) { + let td = TempDir::new().unwrap(); + let store = Store::new(td.path().join("lfs")); + (td, store) + } + + #[test] + fn lfs_filter_passthrough_is_not_lfs() { + assert!(!LfsFilter::Passthrough.is_lfs()); + } + + #[test] + fn lfs_filter_lfs_is_lfs() { + assert!(LfsFilter::Lfs.is_lfs()); + } + + /// Cleaning non-pointer content stores the bytes and produces a pointer. + #[test] + fn clean_content_stores_and_produces_pointer() { + let (_td, store) = tmp_store(); + + let content = b"large binary payload"; + let mut out = Vec::new(); + let outcome = + clean(&store, &mut &content[..], &mut out, "", &[]) + .unwrap(); + + // The pointer text should be in `out`. + let pointer_text = String::from_utf8(out).unwrap(); + assert!( + pointer_text + .starts_with("version https://git-lfs.github.com"), + "unexpected pointer text: {pointer_text:?}" + ); + assert!( + outcome.pointer().size == content.len() as u64, + "size mismatch" + ); + // The object must be stored locally. + assert!(store.contains(outcome.pointer().oid)); + } + + /// Cleaning an already-canonical pointer is a passthrough — no new store + /// entry is created. + #[test] + fn clean_existing_pointer_is_passthrough() { + let (_td, store) = tmp_store(); + + // First, create a valid pointer via a clean pass. + let content = b"some content"; + let mut pointer_bytes = Vec::new(); + clean(&store, &mut &content[..], &mut pointer_bytes, "", &[]) + .unwrap(); + + // Clean the pointer itself — must pass through unchanged. + let mut out2 = Vec::new(); + let outcome2 = clean( + &store, + &mut pointer_bytes.as_slice(), + &mut out2, + "", + &[], + ) + .unwrap(); + assert!( + outcome2.was_passthrough(), + "expected Passthrough, got {:?}", + outcome2 + ); + assert_eq!(out2, pointer_bytes); + } + + /// A written pointer file that has its object in the local store is + /// replaced with the real content. + #[test] + fn smudge_file_restores_content() { + let (_td, store) = tmp_store(); + + let content = b"restored content here"; + + // Put the content in the store and obtain the pointer. + let mut pointer_bytes = Vec::new(); + clean(&store, &mut &content[..], &mut pointer_bytes, "", &[]) + .unwrap(); + + // Write the pointer to a temp file (simulating a git2 checkout). + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("file.bin"); + std::fs::write(&file_path, &pointer_bytes).unwrap(); + + let outcome = + smudge_file(&store, &file_path, Path::new("file.bin")) + .unwrap(); + + assert_eq!( + outcome, + SmudgeOutcome::Restored, + "expected Restored" + ); + + let on_disk = std::fs::read(&file_path).unwrap(); + assert_eq!(on_disk, content); + } + + /// When the object is not in the store, `smudge_file` returns + /// `ObjectMissing` and leaves the pointer file untouched. + #[test] + fn smudge_file_missing_object_leaves_pointer() { + let (_td, store) = tmp_store(); + + // Build a valid pointer that references an OID we never store. + let content = b"this content is never stored"; + let mut pointer_bytes = Vec::new(); + clean(&store, &mut &content[..], &mut pointer_bytes, "", &[]) + .unwrap(); + let oid = git_lfs_pointer::Pointer::parse(&pointer_bytes) + .unwrap() + .oid; + // Remove the just-stored object to simulate a missing download. + std::fs::remove_file(store.object_path(oid)).unwrap(); + + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("missing.bin"); + std::fs::write(&file_path, &pointer_bytes).unwrap(); + + let outcome = + smudge_file(&store, &file_path, Path::new("missing.bin")) + .unwrap(); + + assert_eq!(outcome, SmudgeOutcome::ObjectMissing); + // Pointer file must still be there and unchanged. + assert_eq!(std::fs::read(&file_path).unwrap(), pointer_bytes); + } + + /// Non-pointer data is skipped entirely. + #[test] + fn smudge_file_non_pointer_is_skipped() { + let (_td, store) = tmp_store(); + + let dir = TempDir::new().unwrap(); + let file_path = dir.path().join("normal.txt"); + std::fs::write(&file_path, b"just a plain text file") + .unwrap(); + + let outcome = + smudge_file(&store, &file_path, Path::new("normal.txt")) + .unwrap(); + + assert_eq!(outcome, SmudgeOutcome::Skipped); + } +} diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 2cd358d065..3c90491307 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -17,6 +17,7 @@ pub mod diff; mod hooks; mod hunks; mod ignore; +pub(crate) mod lfs; mod logwalker; mod merge; mod patches; From cb79bb2a8596a5286c37f9472ee878dc9ca68efb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 23 Jun 2026 16:23:06 -0400 Subject: [PATCH 04/19] support LFS smudge on checkout --- asyncgit/src/sync/branch/mod.rs | 87 ++++++++++++++++++++++++--------- 1 file changed, 64 insertions(+), 23 deletions(-) diff --git a/asyncgit/src/sync/branch/mod.rs b/asyncgit/src/sync/branch/mod.rs index 0af7b4b10d..a55c720792 100644 --- a/asyncgit/src/sync/branch/mod.rs +++ b/asyncgit/src/sync/branch/mod.rs @@ -322,24 +322,33 @@ pub fn checkout_branch( let repo = repo(repo_path)?; let branch = repo.find_branch(branch_name, BranchType::Local)?; - let branch_ref = branch.into_reference(); + let target_tree = branch_ref.peel_to_tree()?; - let target_treeish = branch_ref.peel_to_tree()?; - let target_treeish_object = target_treeish.as_object(); + let previous_tree = + repo.head().and_then(|h| h.peel_to_tree()).ok(); // modify state to match branch's state repo.checkout_tree( - target_treeish_object, + target_tree.as_object(), Some(&mut git2::build::CheckoutBuilder::new()), )?; - let branch_ref = branch_ref.name().map_err(|_| { + let branch_ref_name = branch_ref.name().map_err(|_| { Error::Generic(String::from("branch ref not found")) }); // modify HEAD to point to given branch - repo.set_head(branch_ref?)?; + repo.set_head(branch_ref_name?)?; + + // Smudge LFS pointer files with real content from the local LFS store. + if let Err(e) = super::lfs::large_file_storage_smudge_tree( + &repo, + &target_tree, + previous_tree.as_ref(), + ) { + log::warn!("checkout_branch: LFS smudge failed: {e}"); + } Ok(()) } @@ -357,21 +366,37 @@ pub fn checkout_commit( git2::StatusOptions::new().include_ignored(false), ))?; - if statuses.is_empty() { - repo.set_head_detached(commit_hash.into())?; + if !statuses.is_empty() { + return Err(Error::UncommittedChanges); + } - if let Err(e) = repo.checkout_head(Some( - git2::build::CheckoutBuilder::new().force(), - )) { - repo.set_head( - bytes2string(cur_ref.name_bytes())?.as_str(), - )?; - return Err(Error::Git(e)); - } - Ok(()) - } else { - Err(Error::UncommittedChanges) + let commit = repo.find_commit(commit_hash.into())?; + let tree = commit.tree()?; + let previous_tree = repo + .head() + .and_then(|head_reference| head_reference.peel_to_tree()) + .ok(); + + // Modify state to match commit's state + if let Err(error) = repo.checkout_tree( + tree.as_object(), + Some(&mut git2::build::CheckoutBuilder::new()), + ) { + // Fallback to current HEAD on error + repo.set_head(bytes2string(cur_ref.name_bytes())?.as_str())?; + return Err(Error::Git(error)); } + + // Smudge Large File Storage pointer files with real content from the local Large File Storage store. + if let Err(error) = super::lfs::large_file_storage_smudge_tree( + &repo, + &tree, + previous_tree.as_ref(), + ) { + log::warn!("checkout_commit: Large File Storage smudge failed: {error}"); + } + + Ok(()) } /// @@ -399,21 +424,37 @@ pub fn checkout_remote_branch( ); let commit = repo.find_commit(branch.top_commit.into())?; + let tree = commit.tree()?; let mut new_branch = repo.branch(&name, &commit, false)?; new_branch.set_upstream(Some(&branch.name))?; + let previous_tree = + repo.head().and_then(|h| h.peel_to_tree()).ok(); + repo.set_head( bytes2string(new_branch.into_reference().name_bytes())? .as_str(), )?; - if let Err(e) = repo.checkout_head(Some( - git2::build::CheckoutBuilder::new().force(), - )) { - // This is safe because cur_ref was just found + // modify state to match branch's state + if let Err(e) = repo.checkout_tree( + tree.as_object(), + Some(&mut git2::build::CheckoutBuilder::new()), + ) { + // fallback to current HEAD on error repo.set_head(bytes2string(cur_ref.name_bytes())?.as_str())?; return Err(Error::Git(e)); } + + // Smudge LFS pointer files with real content from the local LFS store. + if let Err(e) = super::lfs::large_file_storage_smudge_tree( + &repo, + &tree, + previous_tree.as_ref(), + ) { + log::warn!("checkout_remote_branch: LFS smudge failed: {e}"); + } + Ok(()) } From 657a340d4f710a5043134eebbbfa0dac281ce22e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 23 Jun 2026 16:23:29 -0400 Subject: [PATCH 05/19] add is_lfs field to StatusItem --- asyncgit/src/sync/commit_files.rs | 13 ++++---- asyncgit/src/sync/status.rs | 48 ++++++++++++++++++++++++++---- src/components/status_tree.rs | 1 + src/components/utils/filetree.rs | 1 + src/components/utils/statustree.rs | 1 + 5 files changed, 53 insertions(+), 11 deletions(-) diff --git a/asyncgit/src/sync/commit_files.rs b/asyncgit/src/sync/commit_files.rs index c03d7c13cf..0c9f1b4c0c 100644 --- a/asyncgit/src/sync/commit_files.rs +++ b/asyncgit/src/sync/commit_files.rs @@ -71,14 +71,17 @@ pub fn get_commit_files( .deltas() .map(|delta| { let status = StatusItemType::from(delta.status()); + let path = delta + .new_file() + .path() + .map(|p| p.to_str().unwrap_or("").to_string()) + .unwrap_or_default(); + let is_lfs = super::lfs::lfs_filter_for(&repo, std::path::Path::new(&path)).is_lfs(); StatusItem { - path: delta - .new_file() - .path() - .map(|p| p.to_str().unwrap_or("").to_string()) - .unwrap_or_default(), + path, status, + is_lfs, } }) .collect::>(); diff --git a/asyncgit/src/sync/status.rs b/asyncgit/src/sync/status.rs index 8afdf2dd09..efe12a0503 100644 --- a/asyncgit/src/sync/status.rs +++ b/asyncgit/src/sync/status.rs @@ -101,6 +101,8 @@ pub struct StatusItem { pub path: String, /// pub status: StatusItemType, + /// + pub is_lfs: bool, } /// @@ -177,11 +179,11 @@ pub fn get_status( let repo: gix::Repository = gix_repo(repo_path)?; + let git2_repo = crate::sync::repository::repo(repo_path)?; + let show_untracked = if let Some(config) = show_untracked { config } else { - let git2_repo = crate::sync::repository::repo(repo_path)?; - // Calling `untracked_files_config_repo` ensures compatibility with `gitui` <= 0.27. // `untracked_files_config_repo` defaults to `All` while both `libgit2` and `gix` default to // `Normal`. According to [show-untracked-files], `normal` is the default value that `git` @@ -212,8 +214,9 @@ pub fn get_status( if let Some(status) = status { let path = item.rela_path().to_string(); + let is_lfs = super::lfs::lfs_filter_for(&git2_repo, Path::new(&path)).is_lfs(); - res.push(StatusItem { path, status }); + res.push(StatusItem { path, status, is_lfs }); } } } @@ -240,8 +243,9 @@ pub fn get_status( -> Result { let path = change_ref.fields().0.to_string(); let status = change_ref.into(); + let is_lfs = super::lfs::lfs_filter_for(&git2_repo, Path::new(&path)).is_lfs(); - res.push(StatusItem { path, status }); + res.push(StatusItem { path, status, is_lfs }); Ok(gix::diff::index::Action::Continue(())) }; @@ -272,7 +276,8 @@ pub fn get_status( }; if let Some(status) = status { - res.push(StatusItem { path, status }); + let is_lfs = super::lfs::lfs_filter_for(&git2_repo, Path::new(&path)).is_lfs(); + res.push(StatusItem { path, status, is_lfs }); } } } @@ -364,8 +369,39 @@ mod tests { status, vec![StatusItem { path: "foo".into(), - status: StatusItemType::New + status: StatusItemType::New, + is_lfs: false }] ); } + + #[test] + fn test_get_status_lfs() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: &RepoPath = + &root.as_os_str().to_str().unwrap().into(); + + // Configure .gitattributes to track *.dat with LFS filter. + std::fs::write( + root.join(".gitattributes"), + "*.dat filter=lfs\n", + ) + .unwrap(); + + let file_path = Path::new("large.dat"); + File::create(root.join(file_path)) + .unwrap() + .write_all(b"large binary data") + .unwrap(); + + let status = + get_status(repo_path, StatusType::WorkingDir, Some(ShowUntrackedFilesConfig::All)) + .unwrap(); + + // We expect .gitattributes and large.dat to be in status + let lfs_item = status.iter().find(|s| s.path == "large.dat").unwrap(); + assert!(lfs_item.is_lfs); + assert_eq!(lfs_item.status, StatusItemType::New); + } } diff --git a/src/components/status_tree.rs b/src/components/status_tree.rs index ac4fc9f6a8..919a7e8fe6 100644 --- a/src/components/status_tree.rs +++ b/src/components/status_tree.rs @@ -597,6 +597,7 @@ mod tests { .map(|a| StatusItem { path: String::from(*a), status: StatusItemType::Modified, + is_lfs: false, }) .collect::>() } diff --git a/src/components/utils/filetree.rs b/src/components/utils/filetree.rs index 2a0b37a7b3..6277f7c499 100644 --- a/src/components/utils/filetree.rs +++ b/src/components/utils/filetree.rs @@ -268,6 +268,7 @@ mod tests { .map(|a| StatusItem { path: String::from(*a), status: StatusItemType::Modified, + is_lfs: false, }) .collect::>() } diff --git a/src/components/utils/statustree.rs b/src/components/utils/statustree.rs index 8eb6cdd10b..3d6a0f6182 100644 --- a/src/components/utils/statustree.rs +++ b/src/components/utils/statustree.rs @@ -467,6 +467,7 @@ mod tests { .map(|a| StatusItem { path: String::from(*a), status: StatusItemType::Modified, + is_lfs: false, }) .collect::>() } From a3da56092dc1af3b926aa747be41c26ede9a553e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 23 Jun 2026 16:23:32 -0400 Subject: [PATCH 06/19] support LFS clean filter on staging --- asyncgit/src/sync/utils.rs | 73 ++++++++++++++++++++++++++++++++++---- 1 file changed, 66 insertions(+), 7 deletions(-) diff --git a/asyncgit/src/sync/utils.rs b/asyncgit/src/sync/utils.rs index 148e29baa9..075ffa21b8 100644 --- a/asyncgit/src/sync/utils.rs +++ b/asyncgit/src/sync/utils.rs @@ -100,12 +100,17 @@ pub fn stage_add_file( scope_time!("stage_add_file"); let repo = repo(repo_path)?; - let mut index = repo.index()?; - index.add_path(path)?; - index.write()?; + if super::lfs::lfs_filter_for(&repo, path).is_lfs() { + super::lfs::large_file_storage_clean_and_stage( + &repo, &mut index, path, + )?; + } else { + index.add_path(path)?; + } + index.write()?; Ok(()) } @@ -118,7 +123,6 @@ pub fn stage_add_all( scope_time!("stage_add_all"); let repo = repo(repo_path)?; - let mut index = repo.index()?; let stage_untracked = if let Some(config) = stage_untracked { @@ -127,14 +131,32 @@ pub fn stage_add_all( untracked_files_config_repo(&repo)? }; + let mut staged_files = Vec::new(); + let mut callback = + |file_path: &Path, _ignored_specification: &[u8]| -> i32 { + staged_files.push(file_path.to_path_buf()); + 0 + }; + + let search_patterns = vec![pattern]; + + // 1. Stage the files if stage_untracked.include_untracked() { index.add_all( - vec![pattern], + search_patterns, IndexAddOption::DEFAULT, - None, + Some(&mut callback), )?; } else { - index.update_all(vec![pattern], None)?; + index.update_all(search_patterns, Some(&mut callback))?; + } + + for file_path in &staged_files { + // Can't use filter because of the mutable borrow below + if super::lfs::needs_lfs_cleaning(&repo, &index, file_path) { + let _ = super::lfs::large_file_storage_clean_and_stage(&repo, &mut index, file_path) + .inspect_err(|e| log::warn!("Large file storage clean failed for {file_path:?}: {e}")); + } } index.write()?; @@ -142,6 +164,8 @@ pub fn stage_add_all( Ok(()) } + + /// Undo last commit in repo pub fn undo_last_commit(repo_path: &RepoPath) -> Result<()> { let repo = repo(repo_path)?; @@ -505,4 +529,39 @@ mod tests { Ok(()) } + + #[test] + fn test_stage_add_all_lfs() { + let (_td, repo) = repo_init().unwrap(); + let root = repo.path().parent().unwrap(); + let repo_path: &RepoPath = + &root.as_os_str().to_str().unwrap().into(); + + // Configure .gitattributes to track *.dat with LFS filter. + std::fs::write( + root.join(".gitattributes"), + "*.dat filter=lfs\n", + ) + .unwrap(); + + let file_path = Path::new("data.dat"); + let raw_content = b"large binary data to clean"; + std::fs::write(root.join(file_path), raw_content).unwrap(); + + stage_add_all( + repo_path, + "data.dat", + Some(ShowUntrackedFilesConfig::All), + ) + .unwrap(); + + let index = repo.index().unwrap(); + let entry = index.get_path(file_path, 0).unwrap(); + let pointer_blob = repo.find_blob(entry.id).unwrap(); + let pointer_text = + String::from_utf8(pointer_blob.content().to_vec()) + .unwrap(); + assert!(pointer_text + .starts_with("version https://git-lfs.github.com")); + } } From d0ee386baf5cf1758f8d8cd2707b10c1fbe0dbb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 23 Jun 2026 16:23:32 -0400 Subject: [PATCH 07/19] show LFS badge in status tree --- src/components/status_tree.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/status_tree.rs b/src/components/status_tree.rs index 919a7e8fe6..ece84007d4 100644 --- a/src/components/status_tree.rs +++ b/src/components/status_tree.rs @@ -177,16 +177,18 @@ impl StatusTreeComponent { .and_then(std::ffi::OsStr::to_str) .expect("invalid path."); + let lfs_suffix = if status_item.is_lfs { " [LFS]" } else { "" }; + let display_name = format!("{file}{lfs_suffix}"); let txt = if selected { format!( "{} {}{:w$}", status_char, indent_str, - file, + display_name, w = width as usize ) } else { - format!("{status_char} {indent_str}{file}") + format!("{status_char} {indent_str}{display_name}") }; Some(Span::styled( From 63d64c96aa6c2bf74b63fd928cb800f82fc80f40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 23 Jun 2026 16:23:35 -0400 Subject: [PATCH 08/19] update Cargo.lock --- Cargo.lock | 1078 +++++++++++++++++++++++++++++++++++++++---- asyncgit/Cargo.toml | 2 - 2 files changed, 996 insertions(+), 84 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 332a5e4fef..c5d43dcddd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -121,6 +121,10 @@ dependencies = [ "easy-cast", "env_logger", "fuzzy-matcher", + "git-lfs", + "git-lfs-filter", + "git-lfs-pointer", + "git-lfs-store", "git2", "git2-hooks", "gix", @@ -148,6 +152,12 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + [[package]] name = "autocfg" version = "1.4.0" @@ -175,6 +185,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bincode" version = "1.3.3" @@ -319,10 +335,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.7" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a012a0df96dd6d06ba9a1b29d6402d1a5d77c6befd2566afdc26e10603dc93d7" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ + "find-msvc-tools", "jobserver", "libc", "shlex", @@ -340,6 +357,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -358,6 +386,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" dependencies = [ "clap_builder", + "clap_derive", ] [[package]] @@ -370,6 +399,19 @@ dependencies = [ "anstyle", "clap_lex", "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.117", ] [[package]] @@ -426,6 +468,35 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -904,6 +975,12 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + [[package]] name = "finl_unicode" version = "1.4.0" @@ -962,6 +1039,30 @@ dependencies = [ "libc", ] +[[package]] +name = "futures" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +dependencies = [ + "futures-core", + "futures-sink", +] + [[package]] name = "futures-core" version = "0.3.31" @@ -979,6 +1080,29 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-io" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" + +[[package]] +name = "futures-macro" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" + [[package]] name = "futures-task" version = "0.3.31" @@ -991,8 +1115,13 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ + "futures-channel", "futures-core", + "futures-io", + "futures-macro", + "futures-sink", "futures-task", + "memchr", "pin-project-lite", "pin-utils", "slab", @@ -1024,8 +1153,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1047,10 +1178,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -1069,6 +1203,132 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "git-lfs" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4342e27a497f5949883477b58ec88ff86fba131c3e2fa5d18687c877747f5335" +dependencies = [ + "clap", + "git-lfs-api", + "git-lfs-creds", + "git-lfs-filter", + "git-lfs-git", + "git-lfs-pointer", + "git-lfs-store", + "git-lfs-transfer", + "globset", + "reqwest", + "rustls", + "rustls-pemfile", + "rustls-pki-types", + "serde", + "serde_json", + "sha1", + "sha2 0.10.8", + "tempfile", + "thiserror 2.0.18", + "tokio", + "url", +] + +[[package]] +name = "git-lfs-api" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "314499d5abafefab707c565430615c25ce64e39d790fa74514b75020a6a4ee07" +dependencies = [ + "git-lfs-creds", + "reqwest", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "url", +] + +[[package]] +name = "git-lfs-creds" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc59105dff43881e806517c93557f0e3a8efe06ae66c4c205366ff1eacaf1cf8" +dependencies = [ + "git-lfs-git", + "serde", + "serde_json", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "git-lfs-filter" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec1bdc7c820bbf2257c63381c8e9d4c58aaabd9c88ebfd521569896d763678a8" +dependencies = [ + "git-lfs-git", + "git-lfs-pointer", + "git-lfs-store", + "git-lfs-transfer", + "sha2 0.10.8", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "git-lfs-git" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "292ac7edc451a672bcef7c598e5ec976da4cfb865ff0fbd3597cd5ab82daf4c5" +dependencies = [ + "bstr", + "git-lfs-pointer", + "gix-attributes 0.32.0", + "gix-glob 0.25.0", + "thiserror 2.0.18", +] + +[[package]] +name = "git-lfs-pointer" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b58f6ae42f4f07af74c59efdb48eaba7e8331d463e935015f2d605888fa3613" +dependencies = [ + "thiserror 2.0.18", +] + +[[package]] +name = "git-lfs-store" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "039d4941949098eef9faf841aefc8d19034c8f00e7b7ed2ca0afb86633410218" +dependencies = [ + "git-lfs-pointer", + "libc", + "sha2 0.10.8", + "tempfile", + "thiserror 2.0.18", +] + +[[package]] +name = "git-lfs-transfer" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e268b779a5904b04ea186f0b3e743af0197bdbc27a81fbb5cea4880620e192e" +dependencies = [ + "futures", + "git-lfs-api", + "git-lfs-pointer", + "git-lfs-store", + "reqwest", + "serde", + "sha2 0.10.8", + "thiserror 2.0.18", + "tokio", + "tokio-util", +] + [[package]] name = "git-version" version = "0.3.9" @@ -1110,7 +1370,7 @@ version = "0.7.0" dependencies = [ "git2", "git2-testing", - "gix-path", + "gix-path 0.12.2", "log", "pretty_assertions", "shellexpand", @@ -1135,7 +1395,7 @@ dependencies = [ "anyhow", "asyncgit", "backtrace", - "base64", + "base64 0.22.1", "bitflags 2.10.0", "bugreport", "bwrap", @@ -1186,7 +1446,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae54ae0ebd1a5a3c3f8d95dd3b5ca6e63f4fed9bfd585e13801a97d7bde8f9ce" dependencies = [ "gix-actor", - "gix-attributes", + "gix-attributes 0.33.2", "gix-command", "gix-commitgraph", "gix-config", @@ -1195,10 +1455,10 @@ dependencies = [ "gix-dir", "gix-discover", "gix-error", - "gix-features", + "gix-features 0.48.1", "gix-filter", "gix-fs", - "gix-glob", + "gix-glob 0.26.1", "gix-hash", "gix-hashtable", "gix-ignore", @@ -1208,7 +1468,7 @@ dependencies = [ "gix-object", "gix-odb", "gix-pack", - "gix-path", + "gix-path 0.12.2", "gix-pathspec", "gix-protocol", "gix-ref", @@ -1243,6 +1503,23 @@ dependencies = [ "gix-error", ] +[[package]] +name = "gix-attributes" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ac00bd435a36fcc518640dad4eca4045e1a2f0b33f74a2bbff58245f8e3744d" +dependencies = [ + "bstr", + "gix-glob 0.25.0", + "gix-path 0.11.3", + "gix-quote", + "gix-trace", + "kstring", + "smallvec", + "thiserror 2.0.18", + "unicode-bom", +] + [[package]] name = "gix-attributes" version = "0.33.2" @@ -1250,8 +1527,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39b40888d0ed415c0744a6cdc61eebf0304c9d26ab726725b718443c322e5ba4" dependencies = [ "bstr", - "gix-glob", - "gix-path", + "gix-glob 0.26.1", + "gix-path 0.12.2", "gix-quote", "gix-trace", "kstring", @@ -1285,7 +1562,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "00706d4fef135ef4b01680d5218c6ee40cda8baf697b864296cbc887d19118f6" dependencies = [ "bstr", - "gix-path", + "gix-path 0.12.2", "gix-quote", "gix-trace", "shell-words", @@ -1313,9 +1590,9 @@ checksum = "4f2372d4b49ca28431e7d150cab9d25edc1890f0184bd57eb0e917c7799e63de" dependencies = [ "bstr", "gix-config-value", - "gix-features", - "gix-glob", - "gix-path", + "gix-features 0.48.1", + "gix-glob 0.26.1", + "gix-path 0.12.2", "gix-ref", "gix-sec", "smallvec", @@ -1331,7 +1608,7 @@ checksum = "ed42168329552f6c2e5df09665c104199d45d84bedb53683738a49b57fe1baab" dependencies = [ "bitflags 2.10.0", "bstr", - "gix-path", + "gix-path 0.12.2", "libc", "thiserror 2.0.18", ] @@ -1355,7 +1632,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b6d9528f32d94cef2edf39a1ac01fe5a0fc44ddbb18d9e44099936047c3302b" dependencies = [ "bstr", - "gix-attributes", + "gix-attributes 0.33.2", "gix-command", "gix-filter", "gix-fs", @@ -1363,7 +1640,7 @@ dependencies = [ "gix-imara-diff", "gix-index", "gix-object", - "gix-path", + "gix-path 0.12.2", "gix-pathspec", "gix-tempfile", "gix-trace", @@ -1384,7 +1661,7 @@ dependencies = [ "gix-ignore", "gix-index", "gix-object", - "gix-path", + "gix-path 0.12.2", "gix-pathspec", "gix-trace", "gix-utils", @@ -1401,7 +1678,7 @@ dependencies = [ "bstr", "dunce", "gix-fs", - "gix-path", + "gix-path 0.12.2", "gix-ref", "gix-sec", "thiserror 2.0.18", @@ -1416,6 +1693,16 @@ dependencies = [ "bstr", ] +[[package]] +name = "gix-features" +version = "0.47.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0f40b8c98b4b592a7e9c83fe79eacbfcdae8485aa6148ff15e52a4b6e9fe30" +dependencies = [ + "gix-trace", + "libc", +] + [[package]] name = "gix-features" version = "0.48.1" @@ -1425,7 +1712,7 @@ dependencies = [ "bytes", "crc32fast", "crossbeam-channel", - "gix-path", + "gix-path 0.12.2", "gix-trace", "gix-utils", "libc", @@ -1445,12 +1732,12 @@ checksum = "ecf74b7d16f6694ce4a3049074c41be0c7987105743674f1671807bd6dce09fa" dependencies = [ "bstr", "encoding_rs", - "gix-attributes", + "gix-attributes 0.33.2", "gix-command", "gix-hash", "gix-object", "gix-packetline", - "gix-path", + "gix-path 0.12.2", "gix-quote", "gix-trace", "gix-utils", @@ -1466,12 +1753,24 @@ checksum = "6cdff46db8798e47e2f727d84b9379aac5add3dd3d9d0b07bb4d7d5d640771fe" dependencies = [ "bstr", "fastrand", - "gix-features", - "gix-path", + "gix-features 0.48.1", + "gix-path 0.12.2", "gix-utils", "thiserror 2.0.18", ] +[[package]] +name = "gix-glob" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76beed0974ea539df93e44d10a6c7df0a9554f6e184dd730c33e65dc0e089614" +dependencies = [ + "bitflags 2.10.0", + "bstr", + "gix-features 0.47.0", + "gix-path 0.11.3", +] + [[package]] name = "gix-glob" version = "0.26.1" @@ -1480,8 +1779,8 @@ checksum = "d1fcb8ef5b16bcf874abe9b68d8abb3c0493c876d367ab824151f30a0f3f3756" dependencies = [ "bitflags 2.10.0", "bstr", - "gix-features", - "gix-path", + "gix-features 0.48.1", + "gix-path 0.12.2", ] [[package]] @@ -1491,7 +1790,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cb0926d3819c837750b4e03c7754901e73f68b8c9b690753a6372a1bed4eedce" dependencies = [ "faster-hex", - "gix-features", + "gix-features 0.48.1", "sha1-checked", "sha2 0.11.0", "thiserror 2.0.18", @@ -1515,8 +1814,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d491bab9bf2c9f341dc754f425c31d5d3f63aca615312167b82e1deeaca97d8d" dependencies = [ "bstr", - "gix-glob", - "gix-path", + "gix-glob 0.26.1", + "gix-path 0.12.2", "gix-trace", "unicode-bom", ] @@ -1528,7 +1827,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b305d85504de270ad3525d726a6b69cc59ee7b2269b014387651107ab9f0755b" dependencies = [ "bstr", - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -1542,7 +1841,7 @@ dependencies = [ "filetime", "fnv", "gix-bitmap", - "gix-features", + "gix-features 0.48.1", "gix-fs", "gix-hash", "gix-lock", @@ -1591,7 +1890,7 @@ dependencies = [ "bstr", "gix-actor", "gix-date", - "gix-features", + "gix-features 0.48.1", "gix-hash", "gix-hashtable", "gix-utils", @@ -1608,13 +1907,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d004c32858b1556f2d7874405edb3c97dc78fc09beaa87d57bb077ee2858a7d" dependencies = [ "arc-swap", - "gix-features", + "gix-features 0.48.1", "gix-fs", "gix-hash", "gix-hashtable", "gix-object", "gix-pack", - "gix-path", + "gix-path 0.12.2", "gix-quote", "memmap2", "parking_lot", @@ -1631,11 +1930,11 @@ dependencies = [ "clru", "gix-chunk", "gix-error", - "gix-features", + "gix-features 0.48.1", "gix-hash", "gix-hashtable", "gix-object", - "gix-path", + "gix-path 0.12.2", "memmap2", "smallvec", "thiserror 2.0.18", @@ -1654,6 +1953,18 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "gix-path" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fd1fe596dc393b538e1d5492c5585971a9311475b3255f7b889023df208476" +dependencies = [ + "bstr", + "gix-trace", + "gix-validate", + "thiserror 2.0.18", +] + [[package]] name = "gix-path" version = "0.12.2" @@ -1674,10 +1985,10 @@ checksum = "3050783b41ee11511e1e8fb35623df81806194f4030395f14f48ea37c2798c9f" dependencies = [ "bitflags 2.10.0", "bstr", - "gix-attributes", + "gix-attributes 0.33.2", "gix-config-value", - "gix-glob", - "gix-path", + "gix-glob 0.26.1", + "gix-path 0.12.2", "thiserror 2.0.18", ] @@ -1689,7 +2000,7 @@ checksum = "51dea3acb390707ab868f1f9584f18449eb95d869deffae96768e47d303595ee" dependencies = [ "bstr", "gix-date", - "gix-features", + "gix-features 0.48.1", "gix-hash", "gix-ref", "gix-shallow", @@ -1718,12 +2029,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c04f64c37eb7e6feb73c7060f8dc6f381cc5de5d53249bfd450bc48a86b2e8b" dependencies = [ "gix-actor", - "gix-features", + "gix-features 0.48.1", "gix-fs", "gix-hash", "gix-lock", "gix-object", - "gix-path", + "gix-path 0.12.2", "gix-tempfile", "gix-utils", "gix-validate", @@ -1739,7 +2050,7 @@ checksum = "b216ae06ec74b5f24ad0142026a997fb0a935b7410eaf9c1616fc3f0e6c5a6d3" dependencies = [ "bstr", "gix-error", - "gix-glob", + "gix-glob 0.26.1", "gix-hash", "gix-revision", "gix-validate", @@ -1789,7 +2100,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8519976e4c7e486270740a5400369f37940779b80bd1377d94cfa1125d01b3" dependencies = [ "bitflags 2.10.0", - "gix-path", + "gix-path 0.12.2", "libc", "windows-sys 0.61.2", ] @@ -1817,13 +2128,13 @@ dependencies = [ "filetime", "gix-diff", "gix-dir", - "gix-features", + "gix-features 0.48.1", "gix-filter", "gix-fs", "gix-hash", "gix-index", "gix-object", - "gix-path", + "gix-path 0.12.2", "gix-pathspec", "gix-worktree", "portable-atomic", @@ -1838,7 +2149,7 @@ checksum = "3059890ef054066c22a94bfc6a3eaba0d806aedcd630a0bc9e5783fd88884781" dependencies = [ "bstr", "gix-config", - "gix-path", + "gix-path 0.12.2", "gix-pathspec", "gix-refspec", "gix-url", @@ -1872,7 +2183,7 @@ checksum = "186874f7ad1fb2f9a2f2aa9c2dabc7f9dd087bef74c1a0eee2b4a9cf0248fcb3" dependencies = [ "bstr", "gix-command", - "gix-features", + "gix-features 0.48.1", "gix-packetline", "gix-quote", "gix-sec", @@ -1904,7 +2215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "57d68e70e96da0e5f9c871f1566349e0fd0e1a20bb483c7f54af1dd0b85b4b29" dependencies = [ "bstr", - "gix-path", + "gix-path 0.12.2", "percent-encoding", "thiserror 2.0.18", ] @@ -1937,14 +2248,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cef414ed275e8407cd5d53d301e83be19700b0dd3f859d2434417b58f454a2d1" dependencies = [ "bstr", - "gix-attributes", + "gix-attributes 0.33.2", "gix-fs", - "gix-glob", + "gix-glob 0.26.1", "gix-hash", "gix-ignore", "gix-index", "gix-object", - "gix-path", + "gix-path 0.12.2", "gix-validate", ] @@ -1954,18 +2265,50 @@ version = "0.33.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d25e9ed30100c63f7590bc581c225e53f731a53e06aa79a245739c07f7dcc557" dependencies = [ - "gix-attributes", + "gix-attributes 0.33.2", "gix-error", - "gix-features", + "gix-features 0.48.1", "gix-filter", "gix-fs", "gix-hash", "gix-object", - "gix-path", + "gix-path 0.12.2", "gix-traverse", "parking_lot", ] +[[package]] +name = "globset" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e47d37d2ae4464254884b60ab7071be2b876a9c35b696bd018ddcc76847309cd" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "h2" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hash32" version = "0.3.1" @@ -2035,43 +2378,143 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] -name = "hybrid-array" -version = "0.4.13" +name = "http" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ - "typenum", + "bytes", + "itoa", ] [[package]] -name = "iana-time-zone" -version = "0.1.61" +name = "http-body" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "wasm-bindgen", - "windows-core 0.52.0", + "bytes", + "http", ] [[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" +name = "http-body-util" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ - "cc", + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", ] [[package]] -name = "icu_collections" -version = "1.5.0" +name = "httparse" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" -dependencies = [ +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hybrid-array" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64 0.23.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "235e081f3925a06703c2d0117ea8b91f042756fd6e7a6e5d901e8ca1a996b220" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core 0.52.0", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ "displaydoc", "yoke", "zerofrom", @@ -2284,6 +2727,12 @@ dependencies = [ name = "invalidstring" version = "0.1.3" +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -2536,6 +2985,12 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "lru-slab" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" + [[package]] name = "mac_address" version = "1.1.8" @@ -2587,6 +3042,12 @@ dependencies = [ "autocfg", ] +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2908,7 +3369,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared", - "rand", + "rand 0.8.5", ] [[package]] @@ -2957,7 +3418,7 @@ version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ - "base64", + "base64 0.22.1", "indexmap", "quick-xml", "serde", @@ -3023,6 +3484,22 @@ dependencies = [ "parking_lot", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "quick-xml" version = "0.41.0" @@ -3032,6 +3509,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" +dependencies = [ + "bytes", + "getrandom 0.4.2", + "lru-slab", + "rand 0.10.3", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.38" @@ -3059,7 +3592,18 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" dependencies = [ - "rand_core", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", ] [[package]] @@ -3068,6 +3612,21 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "ratatui" version = "0.30.0" @@ -3239,6 +3798,66 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.15", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "ron" version = "0.12.0" @@ -3259,6 +3878,12 @@ version = "0.1.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f" +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3294,6 +3919,51 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.19" @@ -3391,6 +4061,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serial_test" version = "3.3.1" @@ -3483,9 +4165,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -3554,6 +4236,16 @@ version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -3613,6 +4305,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "1.0.109" @@ -3635,6 +4333,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.1" @@ -3694,6 +4401,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix 1.1.4", + "windows-sys 0.61.2", +] + [[package]] name = "terminfo" version = "0.9.0" @@ -3733,7 +4450,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4676b37242ccbd1aabf56edb093a4827dc49086c0ffd764a5705899e0f35f8f7" dependencies = [ "anyhow", - "base64", + "base64 0.22.1", "bitflags 2.10.0", "fancy-regex 0.11.0", "filedescriptor", @@ -3877,6 +4594,126 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.10.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "two-face" version = "0.4.5" @@ -3971,6 +4808,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.4" @@ -4044,6 +4887,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "want" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" @@ -4081,6 +4933,20 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.114" @@ -4135,6 +5001,19 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "wasmparser" version = "0.244.0" @@ -4147,6 +5026,35 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "wezterm-bidi" version = "0.2.3" @@ -4659,6 +5567,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + [[package]] name = "zerovec" version = "0.10.4" diff --git a/asyncgit/Cargo.toml b/asyncgit/Cargo.toml index 40b1416c55..80121f91ed 100644 --- a/asyncgit/Cargo.toml +++ b/asyncgit/Cargo.toml @@ -17,7 +17,6 @@ crossbeam-channel = "0.5" dirs = "6.0" easy-cast = "0.5" fuzzy-matcher = "0.3" -<<<<<<< HEAD git-lfs = "0.7.0" git-lfs-filter = "0.7.0" git-lfs-pointer = "0.7.0" @@ -34,7 +33,6 @@ rayon = "1.11" rayon-core = "1.13" scopetime = { path = "../scopetime", version = "0.1" } serde = { version = "1.0", features = ["derive"] } -<<<<<<< HEAD tempfile = "3" thiserror = "2.0" unicode-truncate = "2.0" From 3bac20a230d3a1bfd7c3e6694d9ebc78dbe5faf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 23 Jun 2026 18:14:11 -0400 Subject: [PATCH 09/19] lfs snapshot --- src/gitui.rs | 62 +++++++++++++++++++ ...i__gitui__tests__app_status_lfs_badge.snap | 16 +++++ 2 files changed, 78 insertions(+) create mode 100644 src/snapshots/gitui__gitui__tests__app_status_lfs_badge.snap diff --git a/src/gitui.rs b/src/gitui.rs index 03d73b11c1..fe15927c1a 100644 --- a/src/gitui.rs +++ b/src/gitui.rs @@ -287,4 +287,66 @@ mod tests { terminal.backend() ); } + + #[test] + fn gitui_lfs_badge() { + apply_common_filters!(); + + let (temp_dir, repo) = repo_init_suffix(Some("-insta")); + let path: RepoPath = temp_dir.path().to_str().unwrap().into(); + + // Configure LFS + std::fs::write( + temp_dir.path().join(".gitattributes"), + "*.dat filter=lfs\n", + ) + .unwrap(); + std::fs::write( + temp_dir.path().join("large_file.dat"), + b"large binary data", + ) + .unwrap(); + + let mut index = repo.index().unwrap(); + index + .add_path(std::path::Path::new(".gitattributes")) + .unwrap(); + index + .add_path(std::path::Path::new("large_file.dat")) + .unwrap(); + index.write().unwrap(); + + let cliargs = CliArgs { + theme: PathBuf::from("theme.ron"), + select_file: None, + repo_path: path, + notify_watcher: false, + key_bindings_path: None, + key_symbols_path: None, + }; + + let theme = Theme::init(&PathBuf::new()); + let key_config = KeyConfig::default(); + + let mut gitui = + Gitui::new(cliargs, theme, &key_config, Updater::Ticker) + .unwrap(); + + let mut terminal = + Terminal::new(TestBackend::new(90, 12)).unwrap(); + + gitui.draw(&mut terminal).unwrap(); + + gitui.wait_for_async_git_notification( + AsyncGitNotification::Status, + ); + + let event = + AsyncNotification::Git(AsyncGitNotification::Status); + gitui.update_async(event); + + gitui.draw(&mut terminal).unwrap(); + + assert_snapshot!("app_status_lfs_badge", terminal.backend()); + } } diff --git a/src/snapshots/gitui__gitui__tests__app_status_lfs_badge.snap b/src/snapshots/gitui__gitui__tests__app_status_lfs_badge.snap new file mode 100644 index 0000000000..0dfa256c0d --- /dev/null +++ b/src/snapshots/gitui__gitui__tests__app_status_lfs_badge.snap @@ -0,0 +1,16 @@ +--- +source: src/gitui.rs +expression: terminal.backend() +--- +" Status [1] | Log [2] | Files [3] | Stashing [4] | Stashes [5][TEMP_FILE] " +" ──────────────────────────────────────────────────────────────────────────────────────── " +"┌Unstaged Changes───────────────────────────┐┌Diff: ─────────────────────────────────────┐" +"│ ││ │" +"│ ││ │" +"│ ││ │" +"└───────────────────────────────────{master}┘│ │" +"┌Staged Changes─────────────────────────────┐│ │" +"│+ .gitattributes ││ │" +"│+ large_file.dat [LFS] ││ │" +"└───────────────────────────────────────────┘└───────────────────────────────────────────┘" +"Branches [b] Push [p] Fetch [⇧F] Pull [f] Undo Commit [⇧U] Submodules [⇧S] more [.]" From 44e631c0975fdafb078bf3d91397de149dd5dd53 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 23 Jun 2026 19:18:03 -0400 Subject: [PATCH 10/19] Using the proper masking --- asyncgit/src/sync/lfs.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/asyncgit/src/sync/lfs.rs b/asyncgit/src/sync/lfs.rs index 7a0a828b33..cfb099f838 100644 --- a/asyncgit/src/sync/lfs.rs +++ b/asyncgit/src/sync/lfs.rs @@ -90,6 +90,10 @@ fn create_index_entry( size: usize, path: &str, ) -> Result { + /// The lower 12 bits of the `flags` field in a git index entry store the + /// path length (capped at this mask value). (https://git-scm.com/docs/index-format) + const GIT_INDEX_ENTRY_NAMEMASK: u16 = 0x0FFF; + Ok(git2::IndexEntry { ctime: git2::IndexTime::new(0, 0), mtime: git2::IndexTime::new(0, 0), @@ -101,8 +105,8 @@ fn create_index_entry( file_size: u32::try_from(size).map_err(|_| { Error::Generic("pointer too large".into()) })?, - id: git2::Oid::zero(), - flags: 0, + id: git2::Oid::ZERO_SHA1, + flags: (path.len() as u16) & GIT_INDEX_ENTRY_NAMEMASK, flags_extended: 0, path: path.as_bytes().to_vec(), }) From fdafbcec889029e73d4a3226a095c6e94e21a004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 23 Jun 2026 19:18:17 -0400 Subject: [PATCH 11/19] fixes for tests --- asyncgit/src/sync/branch/mod.rs | 2 ++ asyncgit/src/sync/commit_files.rs | 6 +++- asyncgit/src/sync/status.rs | 52 ++++++++++++++++++++++++------- asyncgit/src/sync/utils.rs | 6 ++-- src/components/status_tree.rs | 26 +++++++++------- 5 files changed, 65 insertions(+), 27 deletions(-) diff --git a/asyncgit/src/sync/branch/mod.rs b/asyncgit/src/sync/branch/mod.rs index a55c720792..aa003954b9 100644 --- a/asyncgit/src/sync/branch/mod.rs +++ b/asyncgit/src/sync/branch/mod.rs @@ -387,6 +387,8 @@ pub fn checkout_commit( return Err(Error::Git(error)); } + repo.set_head_detached(commit.id())?; + // Smudge Large File Storage pointer files with real content from the local Large File Storage store. if let Err(error) = super::lfs::large_file_storage_smudge_tree( &repo, diff --git a/asyncgit/src/sync/commit_files.rs b/asyncgit/src/sync/commit_files.rs index 0c9f1b4c0c..5100029147 100644 --- a/asyncgit/src/sync/commit_files.rs +++ b/asyncgit/src/sync/commit_files.rs @@ -76,7 +76,11 @@ pub fn get_commit_files( .path() .map(|p| p.to_str().unwrap_or("").to_string()) .unwrap_or_default(); - let is_lfs = super::lfs::lfs_filter_for(&repo, std::path::Path::new(&path)).is_lfs(); + let is_lfs = super::lfs::lfs_filter_for( + &repo, + std::path::Path::new(&path), + ) + .is_lfs(); StatusItem { path, diff --git a/asyncgit/src/sync/status.rs b/asyncgit/src/sync/status.rs index efe12a0503..56d71690ae 100644 --- a/asyncgit/src/sync/status.rs +++ b/asyncgit/src/sync/status.rs @@ -214,9 +214,17 @@ pub fn get_status( if let Some(status) = status { let path = item.rela_path().to_string(); - let is_lfs = super::lfs::lfs_filter_for(&git2_repo, Path::new(&path)).is_lfs(); - - res.push(StatusItem { path, status, is_lfs }); + let is_lfs = super::lfs::lfs_filter_for( + &git2_repo, + Path::new(&path), + ) + .is_lfs(); + + res.push(StatusItem { + path, + status, + is_lfs, + }); } } } @@ -243,9 +251,17 @@ pub fn get_status( -> Result { let path = change_ref.fields().0.to_string(); let status = change_ref.into(); - let is_lfs = super::lfs::lfs_filter_for(&git2_repo, Path::new(&path)).is_lfs(); - - res.push(StatusItem { path, status, is_lfs }); + let is_lfs = super::lfs::lfs_filter_for( + &git2_repo, + Path::new(&path), + ) + .is_lfs(); + + res.push(StatusItem { + path, + status, + is_lfs, + }); Ok(gix::diff::index::Action::Continue(())) }; @@ -276,8 +292,16 @@ pub fn get_status( }; if let Some(status) = status { - let is_lfs = super::lfs::lfs_filter_for(&git2_repo, Path::new(&path)).is_lfs(); - res.push(StatusItem { path, status, is_lfs }); + let is_lfs = super::lfs::lfs_filter_for( + &git2_repo, + Path::new(&path), + ) + .is_lfs(); + res.push(StatusItem { + path, + status, + is_lfs, + }); } } } @@ -395,12 +419,16 @@ mod tests { .write_all(b"large binary data") .unwrap(); - let status = - get_status(repo_path, StatusType::WorkingDir, Some(ShowUntrackedFilesConfig::All)) - .unwrap(); + let status = get_status( + repo_path, + StatusType::WorkingDir, + Some(ShowUntrackedFilesConfig::All), + ) + .unwrap(); // We expect .gitattributes and large.dat to be in status - let lfs_item = status.iter().find(|s| s.path == "large.dat").unwrap(); + let lfs_item = + status.iter().find(|s| s.path == "large.dat").unwrap(); assert!(lfs_item.is_lfs); assert_eq!(lfs_item.status, StatusItemType::New); } diff --git a/asyncgit/src/sync/utils.rs b/asyncgit/src/sync/utils.rs index 075ffa21b8..567642fb88 100644 --- a/asyncgit/src/sync/utils.rs +++ b/asyncgit/src/sync/utils.rs @@ -164,8 +164,6 @@ pub fn stage_add_all( Ok(()) } - - /// Undo last commit in repo pub fn undo_last_commit(repo_path: &RepoPath) -> Result<()> { let repo = repo(repo_path)?; @@ -555,6 +553,10 @@ mod tests { ) .unwrap(); + // Re-open the repo to get a fresh index; stage_add_all uses + // its own Repository handle, so the original `repo`'s cached + // index is stale. + let repo = Repository::open(root).unwrap(); let index = repo.index().unwrap(); let entry = index.get_path(file_path, 0).unwrap(); let pointer_blob = repo.find_blob(entry.id).unwrap(); diff --git a/src/components/status_tree.rs b/src/components/status_tree.rs index ece84007d4..22b90d1958 100644 --- a/src/components/status_tree.rs +++ b/src/components/status_tree.rs @@ -177,19 +177,21 @@ impl StatusTreeComponent { .and_then(std::ffi::OsStr::to_str) .expect("invalid path."); - let lfs_suffix = if status_item.is_lfs { " [LFS]" } else { "" }; + let lfs_suffix = + if status_item.is_lfs { " [LFS]" } else { "" }; let display_name = format!("{file}{lfs_suffix}"); - let txt = if selected { - format!( - "{} {}{:w$}", - status_char, - indent_str, - display_name, - w = width as usize - ) - } else { - format!("{status_char} {indent_str}{display_name}") - }; + let txt = + if selected { + format!( + "{} {}{:w$}", + status_char, + indent_str, + display_name, + w = width as usize + ) + } else { + format!("{status_char} {indent_str}{display_name}") + }; Some(Span::styled( Cow::from(txt), From 19ab970684e6ae57591fa8d2e66a1602c81fea6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Tue, 23 Jun 2026 19:19:54 -0400 Subject: [PATCH 12/19] clippy --- asyncgit/src/sync/lfs.rs | 22 ++++++++++++++-------- asyncgit/src/sync/status.rs | 1 + 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/asyncgit/src/sync/lfs.rs b/asyncgit/src/sync/lfs.rs index cfb099f838..3a8f4fc45f 100644 --- a/asyncgit/src/sync/lfs.rs +++ b/asyncgit/src/sync/lfs.rs @@ -75,7 +75,7 @@ pub fn large_file_storage_clean_and_stage( // Produce the canonical pointer text into `pointer_bytes`. clean(&store, &mut file, &mut pointer_bytes, path_string, &[])?; - let mode = file_mode_for(&absolute_path)?; + let mode = file_mode_for(&absolute_path); let entry = create_index_entry(mode, pointer_bytes.len(), path_string)?; @@ -91,7 +91,7 @@ fn create_index_entry( path: &str, ) -> Result { /// The lower 12 bits of the `flags` field in a git index entry store the - /// path length (capped at this mask value). (https://git-scm.com/docs/index-format) + /// path length (capped at this mask value). () const GIT_INDEX_ENTRY_NAMEMASK: u16 = 0x0FFF; Ok(git2::IndexEntry { @@ -106,28 +106,34 @@ fn create_index_entry( Error::Generic("pointer too large".into()) })?, id: git2::Oid::ZERO_SHA1, - flags: (path.len() as u16) & GIT_INDEX_ENTRY_NAMEMASK, + flags: u16::try_from(path.len()) + .unwrap_or(GIT_INDEX_ENTRY_NAMEMASK) + & GIT_INDEX_ENTRY_NAMEMASK, flags_extended: 0, path: path.as_bytes().to_vec(), }) } /// Determine the git file mode for an on-disk path. -fn file_mode_for(path: &Path) -> Result { +#[allow(clippy::missing_const_for_fn)] // not const everywhere +fn file_mode_for(path: &Path) -> git2::FileMode { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let perms = path.metadata()?.permissions().mode(); - Ok(if perms & 0o111 != 0 { + let perms = match path.metadata() { + Ok(meta) => meta.permissions().mode(), + Err(_) => return git2::FileMode::Blob, + }; + if perms & 0o111 != 0 { git2::FileMode::BlobExecutable } else { git2::FileMode::Blob - }) + } } #[cfg(not(unix))] { let _ = path; - Ok(git2::FileMode::Blob) + git2::FileMode::Blob } } diff --git a/asyncgit/src/sync/status.rs b/asyncgit/src/sync/status.rs index 56d71690ae..f1ef279595 100644 --- a/asyncgit/src/sync/status.rs +++ b/asyncgit/src/sync/status.rs @@ -170,6 +170,7 @@ impl From for gix::status::UntrackedFiles { } /// guarantees sorting +#[allow(clippy::too_many_lines)] pub fn get_status( repo_path: &RepoPath, status_type: StatusType, From 90b2a35a210e84a00ba82ad5fb514d8923a52997 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 25 Jun 2026 00:57:14 -0400 Subject: [PATCH 13/19] fixing silent data corruption --- asyncgit/src/sync/utils.rs | 46 ++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/asyncgit/src/sync/utils.rs b/asyncgit/src/sync/utils.rs index 567642fb88..3fc714a77c 100644 --- a/asyncgit/src/sync/utils.rs +++ b/asyncgit/src/sync/utils.rs @@ -131,16 +131,34 @@ pub fn stage_add_all( untracked_files_config_repo(&repo)? }; - let mut staged_files = Vec::new(); - let mut callback = - |file_path: &Path, _ignored_specification: &[u8]| -> i32 { - staged_files.push(file_path.to_path_buf()); - 0 - }; + // LFS-tracked files are diverted out of `add_all`/`update_all` here so + // their raw content is never written to the ODB. Adding them directly + // would write the full blob to `.git/objects` only for the LFS pointer to + // replace it. + let mut lfs_files = Vec::new(); + let mut callback = |file_path: &Path, + _ignored_specification: &[u8]| + -> i32 { + // Only divert existing working-tree files. + let is_existing_file = repo + .workdir() + .is_some_and(|workdir| workdir.join(file_path).is_file()); + + if is_existing_file + && super::lfs::lfs_filter_for(&repo, file_path).is_lfs() + { + lfs_files.push(file_path.to_path_buf()); + + // Skip; staged later. + return 1; + } + + 0 + }; let search_patterns = vec![pattern]; - // 1. Stage the files + // Stage the non-LFS files (LFS files are collected by the callback). if stage_untracked.include_untracked() { index.add_all( search_patterns, @@ -151,11 +169,15 @@ pub fn stage_add_all( index.update_all(search_patterns, Some(&mut callback))?; } - for file_path in &staged_files { - // Can't use filter because of the mutable borrow below - if super::lfs::needs_lfs_cleaning(&repo, &index, file_path) { - let _ = super::lfs::large_file_storage_clean_and_stage(&repo, &mut index, file_path) - .inspect_err(|e| log::warn!("Large file storage clean failed for {file_path:?}: {e}")); + // The callback's borrows of `repo` and `lfs_files` end here , so the collected paths can now be staged. + for file_path in &lfs_files { + if let Err(e) = super::lfs::large_file_storage_clean_and_stage( + &repo, &mut index, file_path, + ) { + log::warn!( + "Large file storage clean failed for {file_path:?}: \ + {e}; leaving previous index entry unchanged" + ); } } From 59e68d7402af3413b93855dcdddcdc853ec308a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 25 Jun 2026 00:57:30 -0400 Subject: [PATCH 14/19] hard resetting --- asyncgit/src/sync/reset.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/asyncgit/src/sync/reset.rs b/asyncgit/src/sync/reset.rs index 4142255954..eda1915e6f 100644 --- a/asyncgit/src/sync/reset.rs +++ b/asyncgit/src/sync/reset.rs @@ -52,6 +52,15 @@ pub fn reset_repo( repo.reset(c.as_object(), kind, None)?; + // Only a hard reset rewrites the working tree from the target commit and + // leaves LFS files as pointer text; soft/mixed resets leave it untouched. + if matches!(kind, ResetType::Hard) { + super::lfs::large_file_storage_smudge_head_logged( + &repo, + "reset_repo", + ); + } + Ok(()) } From 65d6f9001ef167ec76f0944ca1bf4872b4641cfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 25 Jun 2026 00:58:12 -0400 Subject: [PATCH 15/19] git_lfs smudge support --- asyncgit/src/sync/lfs.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/asyncgit/src/sync/lfs.rs b/asyncgit/src/sync/lfs.rs index 3a8f4fc45f..1f0071a86b 100644 --- a/asyncgit/src/sync/lfs.rs +++ b/asyncgit/src/sync/lfs.rs @@ -39,6 +39,13 @@ pub fn lfs_store(repo: &Repository) -> Store { Store::new(repo.path().join("lfs")) } +/// Whether smudging should be skipped, honoring the `GIT_LFS_SKIP_SMUDGE` +/// environment variable, usually seen in CI +fn lfs_skip_smudge() -> bool { + std::env::var_os("GIT_LFS_SKIP_SMUDGE") + .is_some_and(|value| !value.is_empty() && value != "0") +} + /// Resolve the LFS filter attribute for `path` (relative to the repo root). /// /// This delegates to libgit2's attribute lookup and returns an [`LfsFilter`] From 720bd2a6e928dbb429c590f78195b53427d96080 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 25 Jun 2026 00:59:16 -0400 Subject: [PATCH 16/19] new helper functions --- asyncgit/src/sync/lfs.rs | 57 +++++++++++++++------------------------- 1 file changed, 21 insertions(+), 36 deletions(-) diff --git a/asyncgit/src/sync/lfs.rs b/asyncgit/src/sync/lfs.rs index 1f0071a86b..33e9ded6c0 100644 --- a/asyncgit/src/sync/lfs.rs +++ b/asyncgit/src/sync/lfs.rs @@ -144,45 +144,23 @@ fn file_mode_for(path: &Path) -> git2::FileMode { } } -pub fn needs_lfs_cleaning( +/// Smudge every LFS pointer file in the working tree against the current +/// `HEAD` tree. +pub fn large_file_storage_smudge_head( repository: &Repository, - index: &git2::Index, - file_path: &Path, -) -> bool { - if !lfs_filter_for(repository, file_path).is_lfs() { - return false; - } - - let Some(index_entry) = index.get_path(file_path, 0) else { - return false; - }; - - // Use the ODB to read just the object header. This avoids loading - // potentially huge blobs entirely into memory just to check their size. - let Ok(odb) = repository.odb() else { - return false; - }; - let Ok((size, _)) = odb.read_header(index_entry.id) else { - return false; - }; +) -> Result<()> { + let head_tree = repository.head()?.peel_to_tree()?; + large_file_storage_smudge_tree(repository, &head_tree, None) +} - if size >= git_lfs_pointer::MAX_POINTER_SIZE { - // It's too big to be a pointer, so it must be raw data that needs cleaning - return true; +/// Smudge LFS pointer files in the working tree against `HEAD`, logging a +/// warning tagged with `operation` on failure instead of propagating it. +pub fn record_smudge(repository: &Repository, operation: &str) { + // Operations that rewrite the working tree from git history (reset, stash + // apply/pop, rebase, revert) leave LFS files as pointer text, so cover that here. + if let Err(e) = large_file_storage_smudge_head(repository) { + log::warn!("{operation}: LFS smudge failed: {e}"); } - - let Ok(file_blob) = repository.find_blob(index_entry.id) else { - return false; - }; - - let content = file_blob.content(); - - // 3. Check if it's already a valid pointer - let is_valid_pointer = - git_lfs_pointer::Pointer::parse(content).is_ok(); - - // If it's NOT a valid pointer, it's raw data that needs cleaning - !is_valid_pointer } /// Walk `tree` and smudge every LFS pointer file in the working tree. @@ -193,6 +171,13 @@ pub fn large_file_storage_smudge_tree( target_tree: &git2::Tree, previous_tree: Option<&git2::Tree>, ) -> Result<()> { + if lfs_skip_smudge() { + log::debug!( + "lfs smudge: skipped (GIT_LFS_SKIP_SMUDGE is set)" + ); + return Ok(()); + } + let working_directory = repository.workdir().ok_or(Error::NoWorkDir)?; let store = lfs_store(repository); From 69153d262bdb8a1ec4ca6918779e342ba440b47d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 25 Jun 2026 01:11:32 -0400 Subject: [PATCH 17/19] reasonable refactor to make the new lfs code an easier fit --- asyncgit/src/sync/utils.rs | 154 +++++++++++++++++++++++-------------- 1 file changed, 95 insertions(+), 59 deletions(-) diff --git a/asyncgit/src/sync/utils.rs b/asyncgit/src/sync/utils.rs index 3fc714a77c..fc5dcf9882 100644 --- a/asyncgit/src/sync/utils.rs +++ b/asyncgit/src/sync/utils.rs @@ -7,7 +7,7 @@ use crate::{ error::{Error, Result}, sync::config::untracked_files_config_repo, }; -use git2::{IndexAddOption, Repository, RepositoryOpenFlags}; +use git2::{Index, IndexAddOption, Repository, RepositoryOpenFlags}; use scopetime::scope_time; use std::{ fs::File, @@ -102,10 +102,8 @@ pub fn stage_add_file( let repo = repo(repo_path)?; let mut index = repo.index()?; - if super::lfs::lfs_filter_for(&repo, path).is_lfs() { - super::lfs::large_file_storage_clean_and_stage( - &repo, &mut index, path, - )?; + if super::lfs::filter_for(&repo, path).is_lfs() { + super::lfs::clean_and_stage(&repo, &mut index, path)?; } else { index.add_path(path)?; } @@ -115,75 +113,113 @@ pub fn stage_add_file( } /// like `stage_add_file` but uses a pattern to match/glob multiple files/folders -pub fn stage_add_all( - repo_path: &RepoPath, - pattern: &str, - stage_untracked: Option, +pub fn stage_add_all_files( + repository_path: &RepoPath, + search_pattern: &str, + optional_untracked_configuration: Option< + ShowUntrackedFilesConfig, + >, ) -> Result<()> { - scope_time!("stage_add_all"); + scope_time!("stage_add_all_files"); - let repo = repo(repo_path)?; - let mut index = repo.index()?; + let repository = repo(repository_path)?; + let mut git_index = repository.index()?; - let stage_untracked = if let Some(config) = stage_untracked { - config - } else { - untracked_files_config_repo(&repo)? - }; + let untracked_configuration = optional_untracked_configuration + .map_or_else( + || untracked_files_config_repo(&repository), + Ok, + )?; - // LFS-tracked files are diverted out of `add_all`/`update_all` here so - // their raw content is never written to the ODB. Adding them directly - // would write the full blob to `.git/objects` only for the LFS pointer to - // replace it. - let mut lfs_files = Vec::new(); - let mut callback = |file_path: &Path, - _ignored_specification: &[u8]| - -> i32 { - // Only divert existing working-tree files. - let is_existing_file = repo - .workdir() - .is_some_and(|workdir| workdir.join(file_path).is_file()); - - if is_existing_file - && super::lfs::lfs_filter_for(&repo, file_path).is_lfs() - { - lfs_files.push(file_path.to_path_buf()); - - // Skip; staged later. - return 1; - } + let large_storage_files = + stage_standard_files_and_collect_large_files( + &repository, + &mut git_index, + search_pattern, + untracked_configuration.include_untracked(), + )?; - 0 - }; + stage_large_storage_files( + &repository, + &mut git_index, + &large_storage_files, + ); + + git_index.write()?; + + Ok(()) +} + +/// Stages normal files directly into the git index and diverts large storage files for special handling. +fn stage_standard_files_and_collect_large_files( + repository: &Repository, + git_index: &mut Index, + search_pattern: &str, + include_untracked_files: bool, +) -> Result> { + let mut large_storage_files = Vec::new(); + + let mut divert_large_files_callback = + |file_path: &Path, _: &[u8]| -> i32 { + let is_large_storage_file = + is_existing_large_storage_file(repository, file_path); + + if is_large_storage_file { + large_storage_files.push(file_path.to_path_buf()); + } + + // Cast boolean + i32::from(is_large_storage_file) + }; - let search_patterns = vec![pattern]; + // Just to satisfy the bounds + let search_patterns = [search_pattern]; - // Stage the non-LFS files (LFS files are collected by the callback). - if stage_untracked.include_untracked() { - index.add_all( + if include_untracked_files { + git_index.add_all( search_patterns, IndexAddOption::DEFAULT, - Some(&mut callback), + Some(&mut divert_large_files_callback), )?; } else { - index.update_all(search_patterns, Some(&mut callback))?; + git_index.update_all( + search_patterns, + Some(&mut divert_large_files_callback), + )?; } - // The callback's borrows of `repo` and `lfs_files` end here , so the collected paths can now be staged. - for file_path in &lfs_files { - if let Err(e) = super::lfs::large_file_storage_clean_and_stage( - &repo, &mut index, file_path, + Ok(large_storage_files) +} + +/// Determines if a path points to an existing file in the working directory that is tracked via Large File Storage. +fn is_existing_large_storage_file( + repository: &Repository, + file_path: &Path, +) -> bool { + let is_existing_file = repository + .workdir() + .is_some_and(|directory| directory.join(file_path).is_file()); + + is_existing_file + && super::lfs::filter_for(repository, file_path).is_lfs() +} + +/// Cleans and stages collected Large File Storage files, logging warnings on failure. +fn stage_large_storage_files( + repository: &Repository, + git_index: &mut Index, + large_storage_files: &[PathBuf], +) { + for file_path in large_storage_files { + if let Err(error) = super::lfs::clean_and_stage( + repository, git_index, file_path, ) { log::warn!( "Large file storage clean failed for {file_path:?}: \ - {e}; leaving previous index entry unchanged" + {error}; leaving previous index entry unchanged" ); } } - - index.write()?; - - Ok(()) } /// Undo last commit in repo @@ -382,7 +418,7 @@ mod tests { assert_eq!(status_count(StatusType::WorkingDir), 3); - stage_add_all(repo_path, "a/d", None).unwrap(); + stage_add_all_files(repo_path, "a/d", None).unwrap(); assert_eq!(status_count(StatusType::WorkingDir), 1); assert_eq!(status_count(StatusType::Stage), 2); @@ -450,7 +486,7 @@ mod tests { assert_eq!(get_statuses(repo_path), (0, 0)); - stage_add_all(repo_path, "*", None).unwrap(); + stage_add_all_files(repo_path, "*", None).unwrap(); assert_eq!(get_statuses(repo_path), (0, 0)); @@ -521,7 +557,7 @@ mod tests { assert_eq!(status_count(StatusType::WorkingDir), 1); //expect to fail - assert!(stage_add_all(repo_path, "sub", None).is_err()); + assert!(stage_add_all_files(repo_path, "sub", None).is_err()); Ok(()) } @@ -568,7 +604,7 @@ mod tests { let raw_content = b"large binary data to clean"; std::fs::write(root.join(file_path), raw_content).unwrap(); - stage_add_all( + stage_add_all_files( repo_path, "data.dat", Some(ShowUntrackedFilesConfig::All), From 290f81f26e28ecbae59a70ebdcc94aa502b06b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 25 Jun 2026 01:15:08 -0400 Subject: [PATCH 18/19] proper smudge recording --- asyncgit/src/sync/lfs.rs | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/asyncgit/src/sync/lfs.rs b/asyncgit/src/sync/lfs.rs index 33e9ded6c0..5097347cde 100644 --- a/asyncgit/src/sync/lfs.rs +++ b/asyncgit/src/sync/lfs.rs @@ -144,25 +144,6 @@ fn file_mode_for(path: &Path) -> git2::FileMode { } } -/// Smudge every LFS pointer file in the working tree against the current -/// `HEAD` tree. -pub fn large_file_storage_smudge_head( - repository: &Repository, -) -> Result<()> { - let head_tree = repository.head()?.peel_to_tree()?; - large_file_storage_smudge_tree(repository, &head_tree, None) -} - -/// Smudge LFS pointer files in the working tree against `HEAD`, logging a -/// warning tagged with `operation` on failure instead of propagating it. -pub fn record_smudge(repository: &Repository, operation: &str) { - // Operations that rewrite the working tree from git history (reset, stash - // apply/pop, rebase, revert) leave LFS files as pointer text, so cover that here. - if let Err(e) = large_file_storage_smudge_head(repository) { - log::warn!("{operation}: LFS smudge failed: {e}"); - } -} - /// Walk `tree` and smudge every LFS pointer file in the working tree. /// /// Returns `Ok(())` even when individual files cannot be smudged, so a partial LFS store only warns. From 79110eeae1aa2f9ac866ba295f18ef40eca05812 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=B7=F0=9D=92=89=F0=9D=92=8A=F0=9D=92=8D?= =?UTF-8?q?=F0=9D=92=90=F0=9D=92=84=F0=9D=92=82=F0=9D=92=8D=F0=9D=92=9A?= =?UTF-8?q?=F0=9D=92=94=F0=9D=92=95?= Date: Thu, 25 Jun 2026 01:15:15 -0400 Subject: [PATCH 19/19] renaming felt things weren't fully qualified --- asyncgit/src/sync/branch/mod.rs | 18 ++++++---------- asyncgit/src/sync/commit_files.rs | 2 +- asyncgit/src/sync/commit_revert.rs | 2 ++ asyncgit/src/sync/lfs.rs | 34 ++++++++++++++++++++++-------- asyncgit/src/sync/mod.rs | 2 +- asyncgit/src/sync/rebase.rs | 6 ++++++ asyncgit/src/sync/reset.rs | 12 +++++------ asyncgit/src/sync/stash.rs | 4 ++++ asyncgit/src/sync/status.rs | 6 +++--- src/components/changes.rs | 4 ++-- 10 files changed, 57 insertions(+), 33 deletions(-) diff --git a/asyncgit/src/sync/branch/mod.rs b/asyncgit/src/sync/branch/mod.rs index aa003954b9..dd887ebd85 100644 --- a/asyncgit/src/sync/branch/mod.rs +++ b/asyncgit/src/sync/branch/mod.rs @@ -342,7 +342,7 @@ pub fn checkout_branch( repo.set_head(branch_ref_name?)?; // Smudge LFS pointer files with real content from the local LFS store. - if let Err(e) = super::lfs::large_file_storage_smudge_tree( + if let Err(e) = super::lfs::smudge_tree( &repo, &target_tree, previous_tree.as_ref(), @@ -390,11 +390,9 @@ pub fn checkout_commit( repo.set_head_detached(commit.id())?; // Smudge Large File Storage pointer files with real content from the local Large File Storage store. - if let Err(error) = super::lfs::large_file_storage_smudge_tree( - &repo, - &tree, - previous_tree.as_ref(), - ) { + if let Err(error) = + super::lfs::smudge_tree(&repo, &tree, previous_tree.as_ref()) + { log::warn!("checkout_commit: Large File Storage smudge failed: {error}"); } @@ -449,11 +447,9 @@ pub fn checkout_remote_branch( } // Smudge LFS pointer files with real content from the local LFS store. - if let Err(e) = super::lfs::large_file_storage_smudge_tree( - &repo, - &tree, - previous_tree.as_ref(), - ) { + if let Err(e) = + super::lfs::smudge_tree(&repo, &tree, previous_tree.as_ref()) + { log::warn!("checkout_remote_branch: LFS smudge failed: {e}"); } diff --git a/asyncgit/src/sync/commit_files.rs b/asyncgit/src/sync/commit_files.rs index 5100029147..27ce8b220f 100644 --- a/asyncgit/src/sync/commit_files.rs +++ b/asyncgit/src/sync/commit_files.rs @@ -76,7 +76,7 @@ pub fn get_commit_files( .path() .map(|p| p.to_str().unwrap_or("").to_string()) .unwrap_or_default(); - let is_lfs = super::lfs::lfs_filter_for( + let is_lfs = super::lfs::filter_for( &repo, std::path::Path::new(&path), ) diff --git a/asyncgit/src/sync/commit_revert.rs b/asyncgit/src/sync/commit_revert.rs index 2d66a2a1d4..90e52b56ce 100644 --- a/asyncgit/src/sync/commit_revert.rs +++ b/asyncgit/src/sync/commit_revert.rs @@ -20,6 +20,8 @@ pub fn revert_commit( repo.revert(&commit, None)?; + crate::sync::lfs::record_smudge(&repo, "revert_commit"); + Ok(()) } diff --git a/asyncgit/src/sync/lfs.rs b/asyncgit/src/sync/lfs.rs index 5097347cde..4894c98097 100644 --- a/asyncgit/src/sync/lfs.rs +++ b/asyncgit/src/sync/lfs.rs @@ -51,7 +51,7 @@ fn lfs_skip_smudge() -> bool { /// This delegates to libgit2's attribute lookup and returns an [`LfsFilter`] /// variant so downstream code can pattern-match instead of inspecting strings /// or booleans. -pub fn lfs_filter_for(repo: &Repository, path: &Path) -> LfsFilter { +pub fn filter_for(repo: &Repository, path: &Path) -> LfsFilter { match repo.get_attr(path, "filter", git2::AttrCheckFlags::empty()) { Ok(Some("lfs")) => LfsFilter::Lfs, @@ -63,7 +63,7 @@ pub fn lfs_filter_for(repo: &Repository, path: &Path) -> LfsFilter { /// /// The real bytes are written to `.git/lfs/objects/`; keeping the index entry holding /// just the small pointer text. -pub fn large_file_storage_clean_and_stage( +pub fn clean_and_stage( repository: &Repository, index: &mut git2::Index, file_path: &Path, @@ -147,7 +147,7 @@ fn file_mode_for(path: &Path) -> git2::FileMode { /// Walk `tree` and smudge every LFS pointer file in the working tree. /// /// Returns `Ok(())` even when individual files cannot be smudged, so a partial LFS store only warns. -pub fn large_file_storage_smudge_tree( +pub fn smudge_tree( repository: &Repository, target_tree: &git2::Tree, previous_tree: Option<&git2::Tree>, @@ -198,7 +198,7 @@ pub fn large_file_storage_smudge_tree( for file_path in changed_file_paths { // Staging will clean content into the local LFS store // Checkout will smudge pointer files into their OG content - if lfs_filter_for(repository, file_path).is_lfs() { + if filter_for(repository, file_path).is_lfs() { let full_path = working_directory.join(file_path); let outcome = smudge_file(&store, full_path.as_path(), file_path); @@ -210,6 +210,25 @@ pub fn large_file_storage_smudge_tree( Ok(()) } +/// Smudge LFS pointer files in the working tree against `HEAD`, logging a +/// warning tagged with `operation` on failure instead of propagating it. +pub fn record_smudge(repository: &Repository, operation: &str) { + // Operations that rewrite the working tree from git history (reset, stash + // apply/pop, rebase, revert) leave LFS files as pointer text, so cover that here. + let head_tree = + match repository.head().and_then(|h| h.peel_to_tree()) { + Ok(tree) => tree, + Err(e) => { + log::warn!("{operation}: LFS smudge failed to resolve HEAD: {e}"); + return; + } + }; + + if let Err(e) = smudge_tree(repository, &head_tree, None) { + log::warn!("{operation}: LFS smudge failed: {e}"); + } +} + fn log_smudge_outcome( entry_path: &Path, outcome: Result, @@ -259,11 +278,8 @@ fn smudge_tree_entries( } // Use a match guard to gracefully filter out non-LFS blobs immediately Some(git2::ObjectType::Blob) - if lfs_filter_for( - repository, - entry_path.as_path(), - ) - .is_lfs() => + if filter_for(repository, entry_path.as_path()) + .is_lfs() => { let full_path = working_directory.join(&entry_path); let outcome = smudge_file( diff --git a/asyncgit/src/sync/mod.rs b/asyncgit/src/sync/mod.rs index 3c90491307..a6eb998122 100644 --- a/asyncgit/src/sync/mod.rs +++ b/asyncgit/src/sync/mod.rs @@ -107,7 +107,7 @@ pub use tags::{ pub use tree::{tree_file_content, tree_files, TreeFile}; pub use utils::{ get_head, get_head_tuple, repo_dir, repo_open_error, - stage_add_all, stage_add_file, stage_addremoved, Head, + stage_add_all_files, stage_add_file, stage_addremoved, Head, }; pub use git2::ResetType; diff --git a/asyncgit/src/sync/rebase.rs b/asyncgit/src/sync/rebase.rs index eaf05acdc8..f0846b91cc 100644 --- a/asyncgit/src/sync/rebase.rs +++ b/asyncgit/src/sync/rebase.rs @@ -63,6 +63,8 @@ pub fn conflict_free_rebase( rebase.finish(Some(&signature))?; + crate::sync::lfs::record_smudge(repo, "rebase"); + last_commit.ok_or_else(|| { Error::Generic(String::from("no commit rebased")) }) @@ -103,6 +105,8 @@ pub fn rebase( rebase.finish(Some(&signature))?; + crate::sync::lfs::record_smudge(repo, "rebase"); + Ok(RebaseState::Finished) } @@ -140,6 +144,8 @@ pub fn continue_rebase( rebase.finish(Some(&signature))?; + crate::sync::lfs::record_smudge(repo, "continue_rebase"); + Ok(RebaseState::Finished) } diff --git a/asyncgit/src/sync/reset.rs b/asyncgit/src/sync/reset.rs index eda1915e6f..571bf506d7 100644 --- a/asyncgit/src/sync/reset.rs +++ b/asyncgit/src/sync/reset.rs @@ -35,6 +35,9 @@ pub fn reset_workdir(repo_path: &RepoPath, path: &str) -> Result<()> { .path(path); repo.checkout_index(None, Some(&mut checkout_opts))?; + + super::lfs::record_smudge(&repo, "reset_workdir"); + Ok(()) } @@ -55,10 +58,7 @@ pub fn reset_repo( // Only a hard reset rewrites the working tree from the target commit and // leaves LFS files as pointer text; soft/mixed resets leave it untouched. if matches!(kind, ResetType::Hard) { - super::lfs::large_file_storage_smudge_head_logged( - &repo, - "reset_repo", - ); + super::lfs::record_smudge(&repo, "reset_repo"); } Ok(()) @@ -74,7 +74,7 @@ mod tests { tests::{ debug_cmd_print, get_statuses, repo_init, repo_init_empty, }, - utils::{stage_add_all, stage_add_file}, + utils::{stage_add_all_files, stage_add_file}, RepoPath, }; use std::{ @@ -197,7 +197,7 @@ mod tests { .write_all(b"file3")?; } - stage_add_all(repo_path, "*", None).unwrap(); + stage_add_all_files(repo_path, "*", None).unwrap(); commit(repo_path, "msg").unwrap(); { diff --git a/asyncgit/src/sync/stash.rs b/asyncgit/src/sync/stash.rs index 8a65f92feb..dfb98b6f0e 100644 --- a/asyncgit/src/sync/stash.rs +++ b/asyncgit/src/sync/stash.rs @@ -52,6 +52,8 @@ pub fn stash_pop( repo.stash_pop(index, None)?; + super::lfs::record_smudge(&repo, "stash_pop"); + Ok(()) } @@ -74,6 +76,8 @@ pub fn stash_apply( opt.checkout_options(checkout); repo.stash_apply(index, Some(&mut opt))?; + super::lfs::record_smudge(&repo, "stash_apply"); + Ok(()) } diff --git a/asyncgit/src/sync/status.rs b/asyncgit/src/sync/status.rs index f1ef279595..961a3bf6e2 100644 --- a/asyncgit/src/sync/status.rs +++ b/asyncgit/src/sync/status.rs @@ -215,7 +215,7 @@ pub fn get_status( if let Some(status) = status { let path = item.rela_path().to_string(); - let is_lfs = super::lfs::lfs_filter_for( + let is_lfs = super::lfs::filter_for( &git2_repo, Path::new(&path), ) @@ -252,7 +252,7 @@ pub fn get_status( -> Result { let path = change_ref.fields().0.to_string(); let status = change_ref.into(); - let is_lfs = super::lfs::lfs_filter_for( + let is_lfs = super::lfs::filter_for( &git2_repo, Path::new(&path), ) @@ -293,7 +293,7 @@ pub fn get_status( }; if let Some(status) = status { - let is_lfs = super::lfs::lfs_filter_for( + let is_lfs = super::lfs::filter_for( &git2_repo, Path::new(&path), ) diff --git a/src/components/changes.rs b/src/components/changes.rs index 74f11e581a..eecb7caae0 100644 --- a/src/components/changes.rs +++ b/src/components/changes.rs @@ -98,7 +98,7 @@ impl ChangesComponent { self.options.borrow().status_show_untracked(); //TODO: check if we can handle the one file case with it as well - sync::stage_add_all( + sync::stage_add_all_files( &self.repo.borrow(), tree_item.info.full_path.as_str(), config, @@ -131,7 +131,7 @@ impl ChangesComponent { fn index_add_all(&self) -> Result<()> { let config = self.options.borrow().status_show_untracked(); - sync::stage_add_all(&self.repo.borrow(), "*", config)?; + sync::stage_add_all_files(&self.repo.borrow(), "*", config)?; self.queue.push(InternalEvent::Update(NeedsUpdate::ALL));