Skip to content

Commit a98e193

Browse files
committed
fix(resolve): preserve submodule worktrees during cleanup
1 parent 4e56b02 commit a98e193

File tree

6 files changed

+359
-82
lines changed

6 files changed

+359
-82
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/gitbutler-edit-mode/src/lib.rs

Lines changed: 30 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@ use gitbutler_repo::{RepositoryExt as _, SignaturePurpose, signature};
2121
use gitbutler_stack::VirtualBranchesHandle;
2222
use gitbutler_workspace::{
2323
branch_trees::{WorkspaceState, update_uncommitted_changes_with_tree},
24-
submodules::has_submodules_configured,
24+
submodules::{
25+
configured_submodule_paths,
26+
has_submodules_configured,
27+
is_submodule_related_path,
28+
remove_untracked_excluding_submodule_paths,
29+
},
2530
};
2631
use serde::Serialize;
2732

@@ -152,62 +157,6 @@ fn get_uncommited_changes(ctx: &Context) -> Result<git2::Oid> {
152157
Ok(uncommited_changes)
153158
}
154159

155-
fn sync_configured_submodules(repo: &git2::Repository) {
156-
let Ok(mut submodules) = repo.submodules() else {
157-
return;
158-
};
159-
160-
for submodule in &mut submodules {
161-
let _ = submodule.update(true, None);
162-
}
163-
}
164-
165-
fn configured_submodule_paths(repo: &git2::Repository) -> Vec<String> {
166-
let mut paths = HashSet::new();
167-
168-
if let Ok(submodules) = repo.submodules() {
169-
for submodule in submodules {
170-
paths.insert(submodule.path().to_string_lossy().to_string());
171-
}
172-
}
173-
174-
// When only .git/modules entries exist, derive logical submodule paths from that layout.
175-
let modules_root = repo.path().join("modules");
176-
if modules_root.exists() {
177-
let mut stack = vec![modules_root.clone()];
178-
while let Some(dir) = stack.pop() {
179-
let Ok(entries) = std::fs::read_dir(&dir) else {
180-
continue;
181-
};
182-
183-
for entry in entries.flatten() {
184-
let path = entry.path();
185-
if !path.is_dir() {
186-
continue;
187-
}
188-
189-
if path.join("HEAD").is_file()
190-
&& let Ok(relative) = path.strip_prefix(&modules_root)
191-
{
192-
paths.insert(relative.to_string_lossy().to_string());
193-
}
194-
195-
stack.push(path);
196-
}
197-
}
198-
}
199-
200-
let mut output = paths.into_iter().collect::<Vec<_>>();
201-
output.sort();
202-
output
203-
}
204-
205-
fn is_submodule_related_path(path: &str, submodule_paths: &[String]) -> bool {
206-
submodule_paths
207-
.iter()
208-
.any(|sm| path == sm || path.strip_prefix(sm).is_some_and(|rest| rest.starts_with('/')))
209-
}
210-
211160
fn collect_checkout_paths(
212161
repo: &git2::Repository,
213162
baseline: &git2::Tree,
@@ -262,15 +211,14 @@ fn checkout_edit_branch(ctx: &Context, commit: git2::Commit) -> Result<()> {
262211
repo.set_head(EDIT_BRANCH_REF)?;
263212
let mut checkout_head = CheckoutBuilder::new();
264213
checkout_head.force();
265-
if !has_submodules {
266-
checkout_head.remove_untracked(true);
267-
} else {
214+
if has_submodules {
268215
for path in &checkout_head_paths {
269216
checkout_head.path(path);
270217
}
271218
}
272219
if !has_submodules || !checkout_head_paths.is_empty() {
273220
repo.checkout_head(Some(&mut checkout_head))?;
221+
remove_untracked_excluding_submodule_paths(repo)?;
274222
}
275223

276224
// Checkout the commit as unstaged changes
@@ -284,21 +232,15 @@ fn checkout_edit_branch(ctx: &Context, commit: git2::Commit) -> Result<()> {
284232
)?;
285233
let mut checkout_index = CheckoutBuilder::new();
286234
checkout_index.force().conflict_style_diff3(true);
287-
if !has_submodules {
288-
checkout_index.remove_untracked(true);
289-
} else {
235+
if has_submodules {
290236
for path in &checkout_index_paths {
291237
checkout_index.path(path);
292238
}
293239
}
294240

295241
if !has_submodules || !checkout_index_paths.is_empty() {
296242
repo.checkout_index(Some(&mut index), Some(&mut checkout_index))?;
297-
}
298-
299-
// Keep configured submodule worktrees populated after moving into edit mode.
300-
if has_submodules {
301-
sync_configured_submodules(repo);
243+
remove_untracked_excluding_submodule_paths(repo)?;
302244
}
303245

304246
Ok(())
@@ -333,15 +275,25 @@ pub(crate) fn abort_and_return_to_workspace(
333275
force: bool,
334276
perm: &mut RepoExclusive,
335277
) -> Result<()> {
336-
if !force && !changes_from_initial(ctx, perm.read_permission())?.is_empty() {
278+
let repo = &*ctx.git2_repo.get()?;
279+
let changes = changes_from_initial(ctx, perm.read_permission())?;
280+
if !force && !changes.is_empty() {
281+
let submodule_paths = configured_submodule_paths(repo);
282+
let has_submodule_or_gitlink_changes = changes.iter().any(|change| {
283+
is_submodule_related_path(&change.path.to_str_lossy(), &submodule_paths)
284+
});
285+
286+
if has_submodule_or_gitlink_changes {
287+
bail!(
288+
"The working tree differs from the original commit, including submodule or gitlink changes. A forced abort is necessary and will revert changes made during edit mode.\nIf you are seeing this message, please report it as a bug. The UI should have prevented this line getting hit."
289+
);
290+
}
291+
337292
bail!(
338293
"The working tree differs from the original commit. A forced abort is necessary.\nIf you are seeing this message, please report it as a bug. The UI should have prevented this line getting hit."
339294
);
340295
}
341296

342-
let repo = &*ctx.git2_repo.get()?;
343-
let has_submodules = has_submodules_configured(repo);
344-
345297
// Checkout gitbutler workspace branch
346298
repo.set_head(WORKSPACE_BRANCH_REF)
347299
.context("Failed to set head reference")?;
@@ -351,10 +303,13 @@ pub(crate) fn abort_and_return_to_workspace(
351303

352304
let mut checkout_tree = CheckoutBuilder::new();
353305
checkout_tree.force();
354-
if !has_submodules {
355-
checkout_tree.remove_untracked(true);
356-
}
357306
repo.checkout_tree(uncommited_changes.as_object(), Some(&mut checkout_tree))?;
307+
remove_untracked_excluding_submodule_paths(repo)?;
308+
309+
// Keep index in sync with HEAD after forceful checkout + cleanup.
310+
let mut index = repo.index()?;
311+
index.read_tree(&repo.head()?.peel_to_tree()?)?;
312+
index.write()?;
358313

359314
Ok(())
360315
}

crates/gitbutler-edit-mode/tests/edit_mode.rs

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,27 @@ use gitbutler_edit_mode::commands::{
66
};
77
use gitbutler_operating_modes::{EDIT_BRANCH_REF, WORKSPACE_BRANCH_REF};
88
use gitbutler_stack::VirtualBranchesHandle;
9+
use std::process::Command;
910
use tempfile::TempDir;
1011

1112
fn command_ctx(folder: &str) -> Result<(Context, TempDir)> {
1213
gitbutler_testsupport::writable::fixture("edit_mode.sh", folder)
1314
}
1415

16+
fn run_git(cwd: &std::path::Path, args: &[&str]) -> Result<String> {
17+
let output = Command::new("git").args(args).current_dir(cwd).output()?;
18+
if !output.status.success() {
19+
anyhow::bail!(
20+
"git command failed in {}: git {}\nstderr: {}",
21+
cwd.display(),
22+
args.join(" "),
23+
String::from_utf8_lossy(&output.stderr)
24+
);
25+
}
26+
27+
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
28+
}
29+
1530
// Fixture:
1631
// * xxx (HEAD -> gitbutler/workspace) GitButler Workspace Commit
1732
// * xxx foobar
@@ -86,6 +101,8 @@ fn abort_requires_force_when_changes_were_made() -> Result<()> {
86101
drop(repo);
87102

88103
std::fs::write(worktree_dir.join("file"), "edited during edit mode\n")?;
104+
let untracked_path = worktree_dir.join("new-untracked-during-edit-mode.txt");
105+
std::fs::write(&untracked_path, "temporary file\n")?;
89106

90107
let result = abort_and_return_to_workspace(&mut ctx, false);
91108
assert!(result.is_err());
@@ -101,6 +118,10 @@ fn abort_requires_force_when_changes_were_made() -> Result<()> {
101118
ctx.git2_repo.get()?.head()?.name(),
102119
Some(WORKSPACE_BRANCH_REF)
103120
);
121+
assert!(
122+
!untracked_path.exists(),
123+
"forced abort should clean untracked files in non-submodule repos"
124+
);
104125

105126
Ok(())
106127
}
@@ -136,3 +157,110 @@ fn save_and_return_to_workspace_preserves_submodule_worktree() -> Result<()> {
136157

137158
Ok(())
138159
}
160+
161+
#[test]
162+
fn abort_preserves_preexisting_dirty_and_diverged_submodule_state() -> Result<()> {
163+
let (mut ctx, _tempdir) = command_ctx("save_and_return_to_workspace_preserves_submodule_worktree")?;
164+
let (foobar, worktree_dir) = {
165+
let repo = ctx.git2_repo.get()?;
166+
let foobar = repo.head()?.peel_to_commit()?.parent(0)?.id();
167+
(foobar, repo.path().parent().unwrap().to_path_buf())
168+
};
169+
let submodule_dir = worktree_dir.join("submodules/test-module");
170+
171+
run_git(
172+
&submodule_dir,
173+
&["config", "user.name", "Submodule Author"],
174+
)?;
175+
run_git(
176+
&submodule_dir,
177+
&["config", "user.email", "submodule@example.com"],
178+
)?;
179+
std::fs::write(submodule_dir.join("diverged.txt"), "diverged commit\n")?;
180+
run_git(&submodule_dir, &["add", "diverged.txt"])?;
181+
run_git(&submodule_dir, &["commit", "-m", "local diverged commit"])?;
182+
183+
std::fs::write(submodule_dir.join("dirty.txt"), "dirty worktree change\n")?;
184+
185+
let baseline_submodule_head = run_git(&submodule_dir, &["rev-parse", "HEAD"])?;
186+
let baseline_submodule_status = run_git(&submodule_dir, &["status", "--porcelain"])?;
187+
let baseline_superproject_submodule_status =
188+
run_git(&worktree_dir, &["status", "--porcelain", "submodules/test-module"])?;
189+
190+
let vb_state = VirtualBranchesHandle::new(ctx.project_data_dir());
191+
let stacks = vb_state.list_stacks_in_workspace()?;
192+
let stack = stacks.first().unwrap();
193+
194+
enter_edit_mode(&mut ctx, foobar, stack.id)?;
195+
abort_and_return_to_workspace(&mut ctx, true)?;
196+
197+
let final_submodule_head = run_git(&submodule_dir, &["rev-parse", "HEAD"])?;
198+
let final_submodule_status = run_git(&submodule_dir, &["status", "--porcelain"])?;
199+
let final_superproject_submodule_status =
200+
run_git(&worktree_dir, &["status", "--porcelain", "submodules/test-module"])?;
201+
202+
assert_eq!(
203+
final_submodule_head, baseline_submodule_head,
204+
"abort should preserve pre-existing submodule commit divergence"
205+
);
206+
assert_eq!(
207+
final_submodule_status, baseline_submodule_status,
208+
"abort should preserve pre-existing dirty submodule working tree state"
209+
);
210+
assert_eq!(
211+
final_superproject_submodule_status, baseline_superproject_submodule_status,
212+
"abort should restore the same superproject-visible submodule state"
213+
);
214+
215+
Ok(())
216+
}
217+
218+
#[test]
219+
fn abort_requires_force_warns_about_submodule_and_gitlink_reversion() -> Result<()> {
220+
let (mut ctx, _tempdir) = command_ctx("save_and_return_to_workspace_preserves_submodule_worktree")?;
221+
let (foobar, worktree_dir) = {
222+
let repo = ctx.git2_repo.get()?;
223+
let foobar = repo.head()?.peel_to_commit()?.parent(0)?.id();
224+
(foobar, repo.path().parent().unwrap().to_path_buf())
225+
};
226+
let submodule_dir = worktree_dir.join("submodules/test-module");
227+
228+
let vb_state = VirtualBranchesHandle::new(ctx.project_data_dir());
229+
let stacks = vb_state.list_stacks_in_workspace()?;
230+
let stack = stacks.first().unwrap();
231+
232+
enter_edit_mode(&mut ctx, foobar, stack.id)?;
233+
234+
run_git(
235+
&submodule_dir,
236+
&["config", "user.name", "Submodule Author"],
237+
)?;
238+
run_git(
239+
&submodule_dir,
240+
&["config", "user.email", "submodule@example.com"],
241+
)?;
242+
std::fs::write(
243+
submodule_dir.join("during-edit-mode-gitlink-change.txt"),
244+
"changed during edit mode\n",
245+
)?;
246+
run_git(&submodule_dir, &["add", "during-edit-mode-gitlink-change.txt"])?;
247+
run_git(&submodule_dir, &["commit", "-m", "gitlink change during edit mode"])?;
248+
249+
// Stage the submodule entry so superproject tree diff includes the gitlink change.
250+
run_git(&worktree_dir, &["add", "submodules/test-module"])?;
251+
252+
let result = abort_and_return_to_workspace(&mut ctx, false);
253+
assert!(result.is_err());
254+
let err = result.err().unwrap().to_string();
255+
assert!(
256+
err.contains("including submodule or gitlink changes"),
257+
"expected submodule/gitlink warning in abort error, got: {err}"
258+
);
259+
assert!(
260+
err.contains("will revert changes made during edit mode"),
261+
"expected explicit reversion warning in abort error, got: {err}"
262+
);
263+
264+
Ok(())
265+
}
266+

crates/gitbutler-workspace/Cargo.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,8 @@ anyhow.workspace = true
2323
gix.workspace = true
2424
git2.workspace = true
2525

26+
[dev-dependencies]
27+
tempfile.workspace = true
28+
2629
[lints]
2730
workspace = true

crates/gitbutler-workspace/src/branch_trees.rs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ use gitbutler_cherry_pick::RepositoryExt;
88
use gitbutler_repo::RepositoryExt as _;
99
use gitbutler_stack::VirtualBranchesHandle;
1010

11-
use crate::{submodules::has_submodules_configured, workspace_base, workspace_base_from_heads};
11+
use crate::{
12+
submodules::remove_untracked_excluding_submodule_paths,
13+
workspace_base,
14+
workspace_base_from_heads,
15+
};
1216

1317
/// A snapshot of the workspace at a point in time.
1418
#[derive(Debug)]
@@ -97,7 +101,6 @@ pub fn update_uncommitted_changes_with_tree(
97101
_perm: &mut RepoExclusive,
98102
) -> Result<()> {
99103
let repo = &*ctx.git2_repo.get()?;
100-
let has_submodules = has_submodules_configured(repo);
101104

102105
if let Some(worktree_id) = old_uncommitted_changes {
103106
let mut new_uncommitted_changes =
@@ -113,13 +116,9 @@ pub fn update_uncommitted_changes_with_tree(
113116

114117
let mut checkout = git2::build::CheckoutBuilder::new();
115118
checkout.force().conflict_style_diff3(true);
116-
// Submodule worktrees can be deleted by aggressive untracked cleanup.
117-
// Keep that cleanup only for repos without configured submodules.
118-
if !has_submodules {
119-
checkout.remove_untracked(true);
120-
}
121119

122120
repo.checkout_index(Some(&mut new_uncommitted_changes), Some(&mut checkout))?;
121+
remove_untracked_excluding_submodule_paths(repo)?;
123122
} else {
124123
let old_tree_id = merge_workspace(repo, old)?.to_gix();
125124
let new_tree_id = merge_workspace(repo, new)?.to_gix();

0 commit comments

Comments
 (0)