-
Notifications
You must be signed in to change notification settings - Fork 932
fix(resolve): Prevent submodule deletion #12577
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,4 +1,4 @@ | ||||||||||||||||
| use std::collections::HashMap; | ||||||||||||||||
| use std::collections::{HashMap, HashSet}; | ||||||||||||||||
|
|
||||||||||||||||
| use anyhow::{Context as _, Result, bail}; | ||||||||||||||||
| use bstr::{BString, ByteSlice}; | ||||||||||||||||
|
|
@@ -19,7 +19,13 @@ use gitbutler_operating_modes::{ | |||||||||||||||
| }; | ||||||||||||||||
| use gitbutler_repo::{RepositoryExt as _, SignaturePurpose, signature}; | ||||||||||||||||
| use gitbutler_stack::VirtualBranchesHandle; | ||||||||||||||||
| use gitbutler_workspace::branch_trees::{WorkspaceState, update_uncommitted_changes_with_tree}; | ||||||||||||||||
| use gitbutler_workspace::{ | ||||||||||||||||
| branch_trees::{WorkspaceState, update_uncommitted_changes_with_tree}, | ||||||||||||||||
| submodules::{ | ||||||||||||||||
| configured_submodule_paths, is_submodule_related_path, | ||||||||||||||||
| remove_untracked_excluding_submodule_paths, | ||||||||||||||||
| }, | ||||||||||||||||
| }; | ||||||||||||||||
| use serde::Serialize; | ||||||||||||||||
|
|
||||||||||||||||
| pub mod commands; | ||||||||||||||||
|
|
@@ -149,29 +155,95 @@ fn get_uncommited_changes(ctx: &Context) -> Result<git2::Oid> { | |||||||||||||||
| Ok(uncommited_changes) | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| fn collect_checkout_paths( | ||||||||||||||||
| repo: &git2::Repository, | ||||||||||||||||
| baseline: &git2::Tree, | ||||||||||||||||
| target: &git2::Tree, | ||||||||||||||||
| submodule_paths: &[String], | ||||||||||||||||
| ) -> Result<Vec<String>> { | ||||||||||||||||
| let mut paths = HashSet::new(); | ||||||||||||||||
| let diff = repo.diff_tree_to_tree(Some(baseline), Some(target), None)?; | ||||||||||||||||
|
|
||||||||||||||||
| for delta in diff.deltas() { | ||||||||||||||||
| if let Some(path) = delta.old_file().path() { | ||||||||||||||||
| let path = path.to_string_lossy().to_string(); | ||||||||||||||||
| if !is_submodule_related_path(&path, submodule_paths) { | ||||||||||||||||
| paths.insert(path); | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| if let Some(path) = delta.new_file().path() { | ||||||||||||||||
| let path = path.to_string_lossy().to_string(); | ||||||||||||||||
| if !is_submodule_related_path(&path, submodule_paths) { | ||||||||||||||||
| paths.insert(path); | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| let mut output = paths.into_iter().collect::<Vec<_>>(); | ||||||||||||||||
| output.sort(); | ||||||||||||||||
| Ok(output) | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| fn checkout_edit_branch(ctx: &Context, commit: git2::Commit) -> Result<()> { | ||||||||||||||||
| let repo = &*ctx.git2_repo.get()?; | ||||||||||||||||
| let submodule_paths = configured_submodule_paths(repo); | ||||||||||||||||
| let preserve_submodule_worktrees = !submodule_paths.is_empty(); | ||||||||||||||||
| let result = (|| -> Result<()> { | ||||||||||||||||
| let current_head_tree = repo.head()?.peel_to_tree()?; | ||||||||||||||||
|
|
||||||||||||||||
| // Checkout commits's parent | ||||||||||||||||
| let commit_parent = find_or_create_base_commit(repo, &commit)?; | ||||||||||||||||
| let commit_parent_tree = commit_parent.tree()?; | ||||||||||||||||
| let checkout_head_paths = collect_checkout_paths( | ||||||||||||||||
| repo, | ||||||||||||||||
| ¤t_head_tree, | ||||||||||||||||
| &commit_parent_tree, | ||||||||||||||||
| &submodule_paths, | ||||||||||||||||
| )?; | ||||||||||||||||
|
|
||||||||||||||||
| // Checkout commits's parent | ||||||||||||||||
| let commit_parent = find_or_create_base_commit(repo, &commit)?; | ||||||||||||||||
| repo.reference(EDIT_BRANCH_REF, commit_parent.id(), true, "")?; | ||||||||||||||||
| repo.set_head(EDIT_BRANCH_REF)?; | ||||||||||||||||
| repo.checkout_head(Some(CheckoutBuilder::new().force().remove_untracked(true)))?; | ||||||||||||||||
| repo.reference(EDIT_BRANCH_REF, commit_parent.id(), true, "")?; | ||||||||||||||||
| repo.set_head(EDIT_BRANCH_REF)?; | ||||||||||||||||
| let mut checkout_head = CheckoutBuilder::new(); | ||||||||||||||||
| checkout_head.force(); | ||||||||||||||||
| if preserve_submodule_worktrees { | ||||||||||||||||
| for path in &checkout_head_paths { | ||||||||||||||||
| checkout_head.path(path); | ||||||||||||||||
| } | ||||||||||||||||
| } else { | ||||||||||||||||
| checkout_head.remove_untracked(true); | ||||||||||||||||
| } | ||||||||||||||||
|
||||||||||||||||
| } | |
| } | |
| // When `has_submodules` is true and `checkout_head_paths` is empty, | |
| // all changes are submodule-related. In that case we intentionally | |
| // skip `checkout_head` after updating HEAD above. This preserves the | |
| // existing worktree (including submodules), which may be managed | |
| // independently, while still moving HEAD to `EDIT_BRANCH_REF`. |
Copilot
AI
Feb 25, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This code has an unnecessary wrapper pattern. The closure returns Result<()>, which is immediately bound to result, and then result is returned unchanged. This pattern doesn't add any value and makes the code less readable. The closure body should either be inlined directly, or if error context needs to be added, use .context() instead of this pattern.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,6 +6,7 @@ use gitbutler_edit_mode::commands::{ | |
| }; | ||
| use gitbutler_operating_modes::{EDIT_BRANCH_REF, WORKSPACE_BRANCH_REF}; | ||
| use gitbutler_stack::VirtualBranchesHandle; | ||
| use gitbutler_testsupport::run_git_at_dir; | ||
| use tempfile::TempDir; | ||
|
|
||
| fn command_ctx(folder: &str) -> Result<(Context, TempDir)> { | ||
|
|
@@ -25,7 +26,6 @@ fn command_ctx(folder: &str) -> Result<(Context, TempDir)> { | |
| fn conficted_entries_get_written_when_leaving_edit_mode() -> Result<()> { | ||
| let (mut ctx, _tempdir) = command_ctx("conficted_entries_get_written_when_leaving_edit_mode")?; | ||
| let repo = ctx.git2_repo.get()?; | ||
|
|
||
| let foobar = repo.head()?.peel_to_commit()?.parent(0)?.id(); | ||
|
|
||
| let vb_state = VirtualBranchesHandle::new(ctx.project_data_dir()); | ||
|
|
@@ -87,6 +87,8 @@ fn abort_requires_force_when_changes_were_made() -> Result<()> { | |
| drop(repo); | ||
|
|
||
| std::fs::write(worktree_dir.join("file"), "edited during edit mode\n")?; | ||
| let untracked_path = worktree_dir.join("new-untracked-during-edit-mode.txt"); | ||
| std::fs::write(&untracked_path, "temporary file\n")?; | ||
|
|
||
| let result = abort_and_return_to_workspace(&mut ctx, false); | ||
| assert!(result.is_err()); | ||
|
|
@@ -102,6 +104,155 @@ fn abort_requires_force_when_changes_were_made() -> Result<()> { | |
| ctx.git2_repo.get()?.head()?.name(), | ||
| Some(WORKSPACE_BRANCH_REF) | ||
| ); | ||
| assert!( | ||
| !untracked_path.exists(), | ||
| "forced abort should clean untracked files in non-submodule repos" | ||
| ); | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn save_and_return_to_workspace_preserves_submodule_worktree() -> Result<()> { | ||
| let (mut ctx, _tempdir) = | ||
| command_ctx("save_and_return_to_workspace_preserves_submodule_worktree")?; | ||
| let (foobar, worktree_dir) = { | ||
| let repo = ctx.git2_repo.get()?; | ||
| let foobar = repo.head()?.peel_to_commit()?.parent(0)?.id(); | ||
| (foobar, repo.path().parent().unwrap().to_path_buf()) | ||
| }; | ||
| let vb_state = VirtualBranchesHandle::new(ctx.project_data_dir()); | ||
| let stacks = vb_state.list_stacks_in_workspace()?; | ||
| let stack = stacks.first().unwrap(); | ||
| let submodule_probe = worktree_dir.join("submodules/test-module/probe.txt"); | ||
| assert!( | ||
| submodule_probe.exists(), | ||
| "fixture should start with populated submodule worktree" | ||
| ); | ||
|
|
||
| enter_edit_mode(&mut ctx, foobar, stack.id)?; | ||
| assert!( | ||
| submodule_probe.exists(), | ||
| "submodule file should remain after entering edit mode" | ||
| ); | ||
| save_and_return_to_workspace(&mut ctx)?; | ||
|
|
||
| assert!( | ||
| submodule_probe.exists(), | ||
| "submodule file should remain after leaving edit mode" | ||
| ); | ||
|
|
||
| Ok(()) | ||
| } | ||
|
Comment on lines
+115
to
+146
|
||
|
|
||
| #[test] | ||
| fn abort_preserves_preexisting_dirty_and_diverged_submodule_state() -> Result<()> { | ||
| let (mut ctx, _tempdir) = | ||
| command_ctx("save_and_return_to_workspace_preserves_submodule_worktree")?; | ||
| let (foobar, worktree_dir) = { | ||
| let repo = ctx.git2_repo.get()?; | ||
| let foobar = repo.head()?.peel_to_commit()?.parent(0)?.id(); | ||
| (foobar, repo.path().parent().unwrap().to_path_buf()) | ||
| }; | ||
| let submodule_dir = worktree_dir.join("submodules/test-module"); | ||
|
|
||
| run_git_at_dir(&submodule_dir, &["config", "user.name", "Submodule Author"])?; | ||
| run_git_at_dir( | ||
| &submodule_dir, | ||
| &["config", "user.email", "submodule@example.com"], | ||
| )?; | ||
| std::fs::write(submodule_dir.join("diverged.txt"), "diverged commit\n")?; | ||
| run_git_at_dir(&submodule_dir, &["add", "diverged.txt"])?; | ||
| run_git_at_dir(&submodule_dir, &["commit", "-m", "local diverged commit"])?; | ||
|
|
||
| std::fs::write(submodule_dir.join("dirty.txt"), "dirty worktree change\n")?; | ||
|
|
||
| let baseline_submodule_head = run_git_at_dir(&submodule_dir, &["rev-parse", "HEAD"])?; | ||
| let baseline_submodule_status = run_git_at_dir(&submodule_dir, &["status", "--porcelain"])?; | ||
| let baseline_superproject_submodule_status = run_git_at_dir( | ||
| &worktree_dir, | ||
| &["status", "--porcelain", "submodules/test-module"], | ||
| )?; | ||
|
|
||
| let vb_state = VirtualBranchesHandle::new(ctx.project_data_dir()); | ||
| let stacks = vb_state.list_stacks_in_workspace()?; | ||
| let stack = stacks.first().unwrap(); | ||
|
|
||
| enter_edit_mode(&mut ctx, foobar, stack.id)?; | ||
| abort_and_return_to_workspace(&mut ctx, true)?; | ||
|
|
||
| let final_submodule_head = run_git_at_dir(&submodule_dir, &["rev-parse", "HEAD"])?; | ||
| let final_submodule_status = run_git_at_dir(&submodule_dir, &["status", "--porcelain"])?; | ||
| let final_superproject_submodule_status = run_git_at_dir( | ||
| &worktree_dir, | ||
| &["status", "--porcelain", "submodules/test-module"], | ||
| )?; | ||
|
|
||
| assert_eq!( | ||
| final_submodule_head, baseline_submodule_head, | ||
| "abort should preserve pre-existing submodule commit divergence" | ||
| ); | ||
| assert_eq!( | ||
| final_submodule_status, baseline_submodule_status, | ||
| "abort should preserve pre-existing dirty submodule working tree state" | ||
| ); | ||
| assert_eq!( | ||
| final_superproject_submodule_status, baseline_superproject_submodule_status, | ||
| "abort should restore the same superproject-visible submodule state" | ||
| ); | ||
|
|
||
| Ok(()) | ||
| } | ||
|
|
||
| #[test] | ||
| fn abort_requires_force_warns_about_submodule_and_gitlink_reversion() -> Result<()> { | ||
| let (mut ctx, _tempdir) = | ||
| command_ctx("save_and_return_to_workspace_preserves_submodule_worktree")?; | ||
| let (foobar, worktree_dir) = { | ||
| let repo = ctx.git2_repo.get()?; | ||
| let foobar = repo.head()?.peel_to_commit()?.parent(0)?.id(); | ||
| (foobar, repo.path().parent().unwrap().to_path_buf()) | ||
| }; | ||
| let submodule_dir = worktree_dir.join("submodules/test-module"); | ||
|
|
||
| let vb_state = VirtualBranchesHandle::new(ctx.project_data_dir()); | ||
| let stacks = vb_state.list_stacks_in_workspace()?; | ||
| let stack = stacks.first().unwrap(); | ||
|
|
||
| enter_edit_mode(&mut ctx, foobar, stack.id)?; | ||
|
|
||
| run_git_at_dir(&submodule_dir, &["config", "user.name", "Submodule Author"])?; | ||
| run_git_at_dir( | ||
| &submodule_dir, | ||
| &["config", "user.email", "submodule@example.com"], | ||
| )?; | ||
| std::fs::write( | ||
| submodule_dir.join("during-edit-mode-gitlink-change.txt"), | ||
| "changed during edit mode\n", | ||
| )?; | ||
| run_git_at_dir( | ||
| &submodule_dir, | ||
| &["add", "during-edit-mode-gitlink-change.txt"], | ||
| )?; | ||
| run_git_at_dir( | ||
| &submodule_dir, | ||
| &["commit", "-m", "gitlink change during edit mode"], | ||
| )?; | ||
|
|
||
| // Stage the submodule entry so superproject tree diff includes the gitlink change. | ||
| run_git_at_dir(&worktree_dir, &["add", "submodules/test-module"])?; | ||
|
|
||
| let result = abort_and_return_to_workspace(&mut ctx, false); | ||
| assert!(result.is_err()); | ||
| let err = result.err().unwrap().to_string(); | ||
| assert!( | ||
| err.contains("including submodule or gitlink changes"), | ||
| "expected submodule/gitlink warning in abort error, got: {err}" | ||
| ); | ||
| assert!( | ||
| err.contains("will revert changes made during edit mode"), | ||
| "expected explicit reversion warning in abort error, got: {err}" | ||
| ); | ||
|
|
||
| Ok(()) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Filtering out submodule paths here means the subsequent edit-mode checkouts never apply gitlink entries from
commit_parent -> commit. In repositories where the currently checked-out workspace head has a newer submodule pointer (for example, editing a non-tip commit after a later commit changed the same submodule), the edit branch keeps that newer gitlink andsave_and_return_to_workspacecan silently rewrite the edited commit with the wrong submodule SHA.Useful? React with 👍 / 👎.