diff --git a/crates/gitbutler-branch-actions/src/branch_manager/branch_creation.rs b/crates/gitbutler-branch-actions/src/branch_manager/branch_creation.rs index cfa1cc8c7a..fd61d4d68c 100644 --- a/crates/gitbutler-branch-actions/src/branch_manager/branch_creation.rs +++ b/crates/gitbutler-branch-actions/src/branch_manager/branch_creation.rs @@ -13,6 +13,7 @@ use gitbutler_error::error::Marker; use gitbutler_oplog::SnapshotExt; use gitbutler_oxidize::GixRepositoryExt; use gitbutler_project::access::WorktreeWritePermission; +use gitbutler_project::AUTO_TRACK_LIMIT_BYTES; use gitbutler_reference::{Refname, RemoteRefname}; use gitbutler_repo::logging::{LogUntil, RepositoryExt as _}; use gitbutler_repo::{ @@ -305,7 +306,7 @@ impl BranchManager<'_> { // We don't support having two branches applied that conflict with each other { - let uncommited_changes_tree_id = repo.create_wd_tree()?.id(); + let uncommited_changes_tree_id = repo.create_wd_tree(AUTO_TRACK_LIMIT_BYTES)?.id(); let gix_repo = self.ctx.gix_repository_for_merging_non_persisting()?; let merges_cleanly = gix_repo .merges_cleanly_compat( diff --git a/crates/gitbutler-branch-actions/src/virtual.rs b/crates/gitbutler-branch-actions/src/virtual.rs index 62b3762c2f..5f1503642f 100644 --- a/crates/gitbutler-branch-actions/src/virtual.rs +++ b/crates/gitbutler-branch-actions/src/virtual.rs @@ -26,6 +26,7 @@ use gitbutler_oxidize::{ git2_signature_to_gix_signature, git2_to_gix_object_id, gix_to_git2_oid, GixRepositoryExt, }; use gitbutler_project::access::WorktreeWritePermission; +use gitbutler_project::AUTO_TRACK_LIMIT_BYTES; use gitbutler_reference::{normalize_branch_name, Refname, RemoteRefname}; use gitbutler_repo::{ logging::{LogUntil, RepositoryExt as _}, @@ -1089,7 +1090,7 @@ pub fn is_remote_branch_mergeable( let base_tree = find_base_tree(ctx.repo(), &branch_commit, &target_commit)?; - let wd_tree = ctx.repo().create_wd_tree()?; + let wd_tree = ctx.repo().create_wd_tree(AUTO_TRACK_LIMIT_BYTES)?; let branch_tree = branch_commit.tree().context("failed to find branch tree")?; let gix_repo_in_memory = ctx.gix_repository_for_merging()?.with_object_memory(); diff --git a/crates/gitbutler-edit-mode/src/lib.rs b/crates/gitbutler-edit-mode/src/lib.rs index 72eee0caaa..2101c7db82 100644 --- a/crates/gitbutler-edit-mode/src/lib.rs +++ b/crates/gitbutler-edit-mode/src/lib.rs @@ -20,6 +20,7 @@ use gitbutler_operating_modes::{ }; use gitbutler_oxidize::{git2_to_gix_object_id, gix_to_git2_index, GixRepositoryExt}; use gitbutler_project::access::{WorktreeReadPermission, WorktreeWritePermission}; +use gitbutler_project::AUTO_TRACK_LIMIT_BYTES; use gitbutler_reference::{ReferenceName, Refname}; use gitbutler_repo::{rebase::cherry_rebase, RepositoryExt}; use gitbutler_repo::{signature, SignaturePurpose}; @@ -234,7 +235,7 @@ pub(crate) fn save_and_return_to_workspace( let parents = commit.parents().collect::>(); // Recommit commit - let tree = repository.create_wd_tree()?; + let tree = repository.create_wd_tree(AUTO_TRACK_LIMIT_BYTES)?; let (_, committer) = repository.signatures()?; let commit_headers = commit diff --git a/crates/gitbutler-oplog/src/oplog.rs b/crates/gitbutler-oplog/src/oplog.rs index c908979e11..d44cd75e76 100644 --- a/crates/gitbutler-oplog/src/oplog.rs +++ b/crates/gitbutler-oplog/src/oplog.rs @@ -20,7 +20,7 @@ use gitbutler_oxidize::{ }; use gitbutler_project::{ access::{WorktreeReadPermission, WorktreeWritePermission}, - Project, + Project, AUTO_TRACK_LIMIT_BYTES, }; use gitbutler_repo::RepositoryExt; use gitbutler_repo::SignaturePurpose; @@ -30,8 +30,6 @@ use gix::object::tree::diff::Change; use gix::prelude::ObjectIdExt; use tracing::instrument; -const SNAPSHOT_FILE_LIMIT_BYTES: u64 = 32 * 1024 * 1024; - /// The Oplog allows for crating snapshots of the current state of the project as well as restoring to a previous snapshot. /// Snapshots include the state of the working directory as well as all additional GitButler state (e.g. virtual branches, conflict state). /// The data is stored as git trees in the following shape: @@ -312,7 +310,7 @@ impl OplogExt for Project { let old_wd_tree_id = tree_from_applied_vbranches(&gix_repo, commit.parent(0)?.id())?; let old_wd_tree = repo.find_tree(old_wd_tree_id)?; - repo.ignore_large_files_in_diffs(SNAPSHOT_FILE_LIMIT_BYTES)?; + repo.ignore_large_files_in_diffs(AUTO_TRACK_LIMIT_BYTES)?; let mut diff_opts = git2::DiffOptions::new(); diff_opts @@ -602,7 +600,7 @@ fn restore_snapshot( let workdir_tree_id = tree_from_applied_vbranches(&gix_repo, snapshot_commit_id)?; let workdir_tree = repo.find_tree(workdir_tree_id)?; - repo.ignore_large_files_in_diffs(SNAPSHOT_FILE_LIMIT_BYTES)?; + repo.ignore_large_files_in_diffs(AUTO_TRACK_LIMIT_BYTES)?; // Define the checkout builder let mut checkout_builder = git2::build::CheckoutBuilder::new(); @@ -739,7 +737,7 @@ fn lines_since_snapshot(project: &Project, repo: &git2::Repository) -> Result Result; fn checkout_tree_builder<'a>(&'a self, tree: &'a git2::Tree<'a>) -> CheckoutTreeBuidler<'a>; fn maybe_find_branch_by_refname(&self, name: &Refname) -> Result>; - /// Based on the index, add all data similar to `git add .` and create a tree from it, which is returned. - fn create_wd_tree(&self) -> Result; + /// Add all untracked and modified files in the worktree to + /// the object database, and create a tree from it. + /// + /// Use `untracked_limit_in_bytes` to control the maximum file size for untracked files + /// before we stop tracking them automatically. Set it to 0 to disable the limit. + /// + /// It should also be noted that this will fail if run on an empty branch + /// or if the HEAD branch has no commits. + fn create_wd_tree(&self, untracked_limit_in_bytes: u64) -> Result; /// Returns the `gitbutler/workspace` branch if the head currently points to it, or fail otherwise. /// Use it before any modification to the repository, or extra defensively each time the @@ -105,15 +113,8 @@ impl RepositoryExt for git2::Repository { Ok(branch) } - /// Add all untracked and modified files in the worktree to - /// the object database, and create a tree from it. - /// - /// Note that right now, it doesn't skip big files. - /// - /// It should also be noted that this will fail if run on an empty branch - /// or if the HEAD branch has no commits. - #[instrument(level = tracing::Level::DEBUG, skip(self), err(Debug))] - fn create_wd_tree(&self) -> Result { + #[instrument(level = tracing::Level::DEBUG, skip(self, untracked_limit_in_bytes), err(Debug))] + fn create_wd_tree(&self, untracked_limit_in_bytes: u64) -> Result { use bstr::ByteSlice; use gix::dir::walk::EmissionMode; use gix::status; @@ -133,6 +134,57 @@ impl RepositoryExt for git2::Repository { )?; let (mut pipeline, index) = repo.filter_pipeline(None)?; let workdir = repo.work_dir().context("Need non-bare repository")?; + let mut added_worktree_file = |rela_path: &BStr, + head_tree_editor: &mut gix::object::tree::Editor<'_>| + -> anyhow::Result { + let rela_path_as_path = gix::path::from_bstr(rela_path); + let path = workdir.join(&rela_path_as_path); + let Ok(md) = std::fs::symlink_metadata(&path) else { + return Ok(false); + }; + if md.len() > untracked_limit_in_bytes { + return Ok(false); + } + let (id, kind) = if md.is_symlink() { + let target = std::fs::read_link(&path).with_context(|| { + format!( + "Failed to read link at '{}' for adding to the object database", + path.display() + ) + })?; + let id = repo.write_blob(gix::path::into_bstr(target).as_bytes())?; + (id, gix::object::tree::EntryKind::Link) + } else if md.is_file() { + let file = std::fs::File::open(&path).with_context(|| { + format!( + "Could not open file at '{}' for adding it to the object database", + path.display() + ) + })?; + let file_for_git = + pipeline.convert_to_git(file, rela_path_as_path.as_ref(), &index)?; + let id = match file_for_git { + ToGitOutcome::Unchanged(mut file) => repo.write_blob_stream(&mut file)?, + ToGitOutcome::Buffer(buf) => repo.write_blob(buf)?, + ToGitOutcome::Process(mut read) => repo.write_blob_stream(&mut read)?, + }; + + let kind = if gix::fs::is_executable(&md) { + gix::object::tree::EntryKind::BlobExecutable + } else { + gix::object::tree::EntryKind::Blob + }; + (id, kind) + } else { + // This is probably a type-change to something we can't track. Instead of keeping + // what's in `HEAD^{tree}` we remove the entry. + head_tree_editor.remove(rela_path)?; + return Ok(true); + }; + + head_tree_editor.upsert(rela_path, kind, id)?; + Ok(true) + }; let mut head_tree_editor = repo.edit_tree(repo.head_tree_id()?)?; let status_changes = repo .status(gix::progress::Discard)? @@ -154,6 +206,8 @@ impl RepositoryExt for git2::Repository { .into_iter(None)?; let mut worktreepaths_changed = HashSet::new(); + // We have to apply untracked items last, but don't have ordering here so impose it ourselves. + let mut untracked_items = Vec::new(); for change in status_changes { let change = change?; match change { @@ -193,7 +247,7 @@ impl RepositoryExt for git2::Repository { )?; } } - status::Item::IndexWorktree(gix::status::index_worktree::Item::Modification { + status::Item::IndexWorktree(index_worktree::Item::Modification { rela_path, status: EntryStatus::Change(Change::Removed), .. @@ -203,73 +257,29 @@ impl RepositoryExt for git2::Repository { } // modified or untracked files are unconditionally added as blob. // Note that this implementation will re-read the whole blob even on type-change - status::Item::IndexWorktree( - gix::status::index_worktree::Item::Modification { - rela_path, - status: - EntryStatus::Change(Change::Type | Change::Modification { .. }) - | EntryStatus::IntentToAdd, - .. - } - | gix::status::index_worktree::Item::DirectoryContents { - entry: - gix::dir::Entry { - rela_path, - status: gix::dir::entry::Status::Untracked, - .. - }, - .. - }, - ) => { - let rela_path_as_path = gix::path::from_bstr(&rela_path); - let path = workdir.join(&rela_path_as_path); - let Ok(md) = std::fs::symlink_metadata(&path) else { - continue; - }; - let (id, kind) = if md.is_symlink() { - let target = std::fs::read_link(&path).with_context(|| { - format!( - "Failed to read link at '{}' for adding to the object database", - path.display() - ) - })?; - let id = repo.write_blob(gix::path::into_bstr(target).as_bytes())?; - (id, gix::object::tree::EntryKind::Link) - } else if md.is_file() { - let file = std::fs::File::open(&path).with_context(|| { - format!( - "Could not open file at '{}' for adding it to the object database", - path.display() - ) - })?; - let file_for_git = - pipeline.convert_to_git(file, rela_path_as_path.as_ref(), &index)?; - let id = match file_for_git { - ToGitOutcome::Unchanged(mut file) => { - repo.write_blob_stream(&mut file)? - } - ToGitOutcome::Buffer(buf) => repo.write_blob(buf)?, - ToGitOutcome::Process(mut read) => repo.write_blob_stream(&mut read)?, - }; - - let kind = if gix::fs::is_executable(&md) { - gix::object::tree::EntryKind::BlobExecutable - } else { - gix::object::tree::EntryKind::Blob - }; - (id, kind) - } else { - // This is probably a type-change to something we can't track. Instead of keeping - // what's in `HEAD^{tree}` we remove the entry. - head_tree_editor.remove(rela_path.as_bstr())?; + status::Item::IndexWorktree(index_worktree::Item::Modification { + rela_path, + status: + EntryStatus::Change(Change::Type | Change::Modification { .. }) + | EntryStatus::IntentToAdd, + .. + }) => { + if added_worktree_file(rela_path.as_ref(), &mut head_tree_editor)? { worktreepaths_changed.insert(rela_path); - continue; - }; - - head_tree_editor.upsert(rela_path.as_bstr(), kind, id)?; - worktreepaths_changed.insert(rela_path); + } } - status::Item::IndexWorktree(gix::status::index_worktree::Item::Modification { + status::Item::IndexWorktree(index_worktree::Item::DirectoryContents { + entry: + gix::dir::Entry { + rela_path, + status: gix::dir::entry::Status::Untracked, + .. + }, + .. + }) => { + untracked_items.push(rela_path); + } + status::Item::IndexWorktree(index_worktree::Item::Modification { rela_path, status: EntryStatus::Change(Change::SubmoduleModification(change)), .. @@ -283,18 +293,16 @@ impl RepositoryExt for git2::Repository { worktreepaths_changed.insert(rela_path); } } - status::Item::IndexWorktree(gix::status::index_worktree::Item::Rewrite { - .. - }) + status::Item::IndexWorktree(index_worktree::Item::Rewrite { .. }) | status::Item::TreeIndex(gix::diff::index::Change::Rewrite { .. }) => { unreachable!("disabled") } status::Item::IndexWorktree( - gix::status::index_worktree::Item::Modification { + index_worktree::Item::Modification { status: EntryStatus::Conflict(_) | EntryStatus::NeedsUpdate(_), .. } - | gix::status::index_worktree::Item::DirectoryContents { + | index_worktree::Item::DirectoryContents { entry: gix::dir::Entry { status: @@ -309,6 +317,10 @@ impl RepositoryExt for git2::Repository { } } + for rela_path in untracked_items { + added_worktree_file(rela_path.as_ref(), &mut head_tree_editor)?; + } + let tree_oid = gix_to_git2_oid(head_tree_editor.write()?); Ok(self.find_tree(tree_oid)?) } diff --git a/crates/gitbutler-repo/tests/create_wd_tree.rs b/crates/gitbutler-repo/tests/create_wd_tree.rs index 40cb2bc746..18c4dd9d8d 100644 --- a/crates/gitbutler-repo/tests/create_wd_tree.rs +++ b/crates/gitbutler-repo/tests/create_wd_tree.rs @@ -8,6 +8,8 @@ use gitbutler_testsupport::gix_testtools::scripted_fixture_read_only; use gitbutler_testsupport::testing_repository::TestingRepository; use gitbutler_testsupport::visualize_git2_tree; +const MAX_SIZE: u64 = 20; + /// These tests exercise the truth table that we use to update the HEAD /// tree to match the worktree. /// @@ -40,7 +42,7 @@ mod head_upsert_truthtable { std::fs::remove_file(test.tempdir.path().join("file1.txt"))?; - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; assert_eq!(tree.len(), 0, "Tree should end up empty"); Ok(()) @@ -59,7 +61,7 @@ mod head_upsert_truthtable { std::fs::remove_file(test.tempdir.path().join("file1.txt"))?; - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; assert_eq!(tree.len(), 0, "Tree should end up empty"); Ok(()) @@ -72,7 +74,7 @@ mod head_upsert_truthtable { std::fs::remove_file(test.tempdir.path().join("file1.txt"))?; - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; assert_eq!(tree.len(), 0, "Tree should end up empty"); Ok(()) @@ -87,7 +89,7 @@ mod head_upsert_truthtable { index.remove_all(["*"], None)?; index.write()?; - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; // We should ignore whatever happens to the index - the current worktree state matters. insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" @@ -108,7 +110,7 @@ mod head_upsert_truthtable { std::fs::write(test.tempdir.path().join("file1.txt"), "content2")?; - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; // Tree should match whatever is written on disk insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" @@ -129,7 +131,7 @@ mod head_upsert_truthtable { index.add_path(Path::new("file1.txt"))?; index.write()?; - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" f87e9ef @@ -145,7 +147,7 @@ mod head_upsert_truthtable { std::fs::write(test.tempdir.path().join("file1.txt"), "content2")?; - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" f87e9ef @@ -167,7 +169,7 @@ mod head_upsert_truthtable { std::fs::write(test.tempdir.path().join("file1.txt"), "content2")?; - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" f87e9ef @@ -192,7 +194,7 @@ mod head_upsert_truthtable { // this change won't be seen. std::fs::write(file_path, "content3")?; - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" d377861 @@ -213,7 +215,7 @@ mod head_upsert_truthtable { index.add_path(Path::new("file1.txt"))?; index.write()?; - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" f87e9ef @@ -230,7 +232,7 @@ fn lists_uncommited_changes() -> anyhow::Result<()> { std::fs::write(test.tempdir.path().join("file1.txt"), "content1")?; std::fs::write(test.tempdir.path().join("file2.txt"), "content2")?; - let tree = test.repository.create_wd_tree()?; + let tree = test.repository.create_wd_tree(MAX_SIZE)?; insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" 1ae8c21 @@ -253,7 +255,7 @@ fn does_not_include_staged_but_deleted_files() -> anyhow::Result<()> { index.write()?; std::fs::remove_file(test.tempdir.path().join("file3.txt"))?; - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" 1ae8c21 @@ -284,7 +286,7 @@ fn should_be_empty_after_checking_out_empty_tree() -> anyhow::Result<()> { assert!(!test.tempdir.path().join("file1.txt").exists()); assert!(!test.tempdir.path().join("file2.txt").exists()); - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; // `create_wd_tree` uses the head commit as the base, and then performs // modifications to the tree to match the working tree. @@ -309,7 +311,7 @@ fn should_track_deleted_files() -> anyhow::Result<()> { assert!(!test.tempdir.path().join("file1.txt").exists()); assert!(test.tempdir.path().join("file2.txt").exists()); - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" 295a2e4 @@ -330,7 +332,7 @@ fn should_not_change_index() -> anyhow::Result<()> { let index_tree = test.repository.find_tree(index_tree)?; assert_eq!(index_tree.len(), 0); - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; let mut index = test.repository.index()?; let index_tree = index.write_tree()?; @@ -358,7 +360,7 @@ fn tree_behavior() -> anyhow::Result<()> { std::fs::create_dir(test.tempdir.path().join("dir3"))?; std::fs::write(test.tempdir.path().join("dir3/file1.txt"), "new2")?; - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" c8aa4f7 @@ -383,7 +385,7 @@ fn executable_blobs() -> anyhow::Result<()> { file.set_permissions(Permissions::from_mode(0o755))?; file.write_all(b"content1")?; - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; // The executable bit is also present in the tree. insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" @@ -400,7 +402,7 @@ fn links() -> anyhow::Result<()> { std::os::unix::fs::symlink("target", test.tempdir.path().join("link1.txt"))?; - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; // Links are also present in the tree. insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" @@ -422,7 +424,7 @@ fn tracked_file_becomes_directory_in_worktree() -> anyhow::Result<()> { std::fs::create_dir(&worktree_path)?; std::fs::write(worktree_path.join("file"), "content in directory")?; - let tree: git2::Tree = test.repository.create_wd_tree().unwrap(); + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" 8b80519 └── soon-directory:df6d699 @@ -441,7 +443,7 @@ fn tracked_directory_becomes_file_in_worktree() -> anyhow::Result<()> { std::fs::remove_dir_all(&worktree_path)?; std::fs::write(worktree_path, "content")?; - let tree: git2::Tree = test.repository.create_wd_tree().unwrap(); + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" 637be29 └── soon-file:100644:6b584e8 "content" @@ -460,7 +462,7 @@ fn non_files_are_ignored() -> anyhow::Result<()> { .status()? .success()); - let tree: git2::Tree = test.repository.create_wd_tree().unwrap(); + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; assert_eq!( tree.len(), 0, @@ -481,7 +483,7 @@ fn tracked_file_swapped_with_non_file() -> anyhow::Result<()> { .status()? .success()); - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; assert_eq!( tree.len(), 0, @@ -500,7 +502,7 @@ fn ignored_files() -> anyhow::Result<()> { let ignored_path = test.tempdir.path().join("I-am.ignored"); std::fs::write(&ignored_path, "")?; - let tree: git2::Tree = test.repository.create_wd_tree()?; + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; // ignored files aren't picked up. insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" 38b94c0 @@ -510,11 +512,27 @@ fn ignored_files() -> anyhow::Result<()> { Ok(()) } +#[test] +fn can_autotrack_empty_files() -> anyhow::Result<()> { + let test = TestingRepository::open_with_initial_commit(&[("soon-empty", "content")]); + + let ignored_path = test.tempdir.path().join("soon-empty"); + std::fs::write(&ignored_path, "")?; + + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; + // ignored files aren't picked up. + insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" + 4fe2781 + └── soon-empty:100644:e69de29 "" + "#); + Ok(()) +} + #[test] fn intent_to_add_is_picked_up_just_like_untracked() -> anyhow::Result<()> { let repo = repo("intent-to-add")?; - let tree: git2::Tree = repo.create_wd_tree()?; + let tree: git2::Tree = repo.create_wd_tree(MAX_SIZE)?; // We pick up what's in the worktree, independently of the intent-to-add flag. insta::assert_snapshot!(visualize_git2_tree(tree.id(), &repo), @r#" d6a22f9 @@ -527,7 +545,7 @@ fn intent_to_add_is_picked_up_just_like_untracked() -> anyhow::Result<()> { fn submodule_in_index_is_picked_up() -> anyhow::Result<()> { let repo = repo("with-submodule-in-index")?; - let tree: git2::Tree = repo.create_wd_tree()?; + let tree: git2::Tree = repo.create_wd_tree(MAX_SIZE)?; // Everything that is not contending with the worktree that is already in the index // is picked up, even if it involves submodules. insta::assert_snapshot!(visualize_git2_tree(tree.id(), &repo), @r#" @@ -542,7 +560,7 @@ fn submodule_in_index_is_picked_up() -> anyhow::Result<()> { fn submodule_change() -> anyhow::Result<()> { let repo = repo("with-submodule-new-commit")?; - let tree: git2::Tree = repo.create_wd_tree()?; + let tree: git2::Tree = repo.create_wd_tree(MAX_SIZE)?; // Changes to submodule heads are also picked up. insta::assert_snapshot!(visualize_git2_tree(tree.id(), &repo), @r#" @@ -553,9 +571,50 @@ fn submodule_change() -> anyhow::Result<()> { Ok(()) } +#[test] +fn big_files_are_ignored_based_on_threshold_in_working_tree() -> anyhow::Result<()> { + let test = + TestingRepository::open_with_initial_commit(&[("soon-too-big", "still small enough")]); + + let big_file_path = test.tempdir.path().join("soon-too-big"); + std::fs::write(&big_file_path, "a massive file above the threshold")?; + + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; + + // It does not pickup the big worktree change + insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" + 26ea3c5 + └── soon-too-big:100644:7d72316 "still small enough" + "#); + Ok(()) +} + +#[test] +fn big_files_are_fine_when_in_the_index() -> anyhow::Result<()> { + let test = + TestingRepository::open_with_initial_commit(&[("soon-too-big", "still small enough")]); + + std::fs::write( + &test.tempdir.path().join("soon-too-big"), + "a massive file above the threshold", + )?; + let mut index = test.repository.index()?; + index.add_path("soon-too-big".as_ref())?; + index.write()?; + + let tree: git2::Tree = test.repository.create_wd_tree(MAX_SIZE)?; + + // It does not pickup the big worktree change + insta::assert_snapshot!(visualize_git2_tree(tree.id(), &test.repository), @r#" + bbd82c6 + └── soon-too-big:100644:1799e5a "a massive file above the threshold" + "#); + Ok(()) +} + fn repo(name: &str) -> anyhow::Result { let worktree_dir = scripted_fixture_read_only("make_create_wd_tree_repos.sh") - .map_err(|err| anyhow::Error::from_boxed(err))? + .map_err(anyhow::Error::from_boxed)? .join(name); Ok(git2::Repository::open(worktree_dir)?) } diff --git a/crates/gitbutler-workspace/src/branch_trees.rs b/crates/gitbutler-workspace/src/branch_trees.rs index aabe46b850..64f8756287 100644 --- a/crates/gitbutler-workspace/src/branch_trees.rs +++ b/crates/gitbutler-workspace/src/branch_trees.rs @@ -4,6 +4,7 @@ use gitbutler_command_context::CommandContext; use gitbutler_commit::commit_ext::CommitExt as _; use gitbutler_oxidize::{git2_to_gix_object_id, gix_to_git2_oid, GixRepositoryExt}; use gitbutler_project::access::WorktreeWritePermission; +use gitbutler_project::AUTO_TRACK_LIMIT_BYTES; use gitbutler_repo::rebase::cherry_rebase_group; use gitbutler_repo::RepositoryExt as _; use gitbutler_stack::{Stack, VirtualBranchesHandle}; @@ -23,7 +24,7 @@ pub fn checkout_branch_trees<'a>( if stacks.is_empty() { // If there are no applied branches, then return the current uncommtied state - return repository.create_wd_tree(); + return repository.create_wd_tree(AUTO_TRACK_LIMIT_BYTES); }; if stacks.len() == 1 { diff --git a/crates/gitbutler-workspace/tests/mod.rs b/crates/gitbutler-workspace/tests/mod.rs index ddc69695ec..d421f91da1 100644 --- a/crates/gitbutler-workspace/tests/mod.rs +++ b/crates/gitbutler-workspace/tests/mod.rs @@ -6,6 +6,7 @@ mod checkout_branch_trees { use gitbutler_branch::BranchCreateRequest; use gitbutler_branch_actions as branch_actions; use gitbutler_command_context::CommandContext; + use gitbutler_project::AUTO_TRACK_LIMIT_BYTES; use gitbutler_repo::RepositoryExt as _; use gitbutler_testsupport::{paths, testing_repository::assert_tree_matches, TestProject}; use gitbutler_workspace::checkout_branch_trees; @@ -40,7 +41,10 @@ mod checkout_branch_trees { branch_actions::create_commit(&project, branch_2, "commit two", None, false).unwrap(); - let tree = test_project.local_repository.create_wd_tree().unwrap(); + let tree = test_project + .local_repository + .create_wd_tree(AUTO_TRACK_LIMIT_BYTES) + .unwrap(); // Assert original state assert_tree_matches( @@ -70,7 +74,10 @@ mod checkout_branch_trees { // Assert tree is indeed empty { - let tree: git2::Tree = test_project.local_repository.create_wd_tree().unwrap(); + let tree: git2::Tree = test_project + .local_repository + .create_wd_tree(AUTO_TRACK_LIMIT_BYTES) + .unwrap(); // Tree should be empty assert_eq!( @@ -85,7 +92,10 @@ mod checkout_branch_trees { checkout_branch_trees(&ctx, guard.write_permission()).unwrap(); - let tree = test_project.local_repository.create_wd_tree().unwrap(); + let tree = test_project + .local_repository + .create_wd_tree(AUTO_TRACK_LIMIT_BYTES) + .unwrap(); // Should be back to original state assert_tree_matches(