Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 24 additions & 18 deletions src/git/repository/working_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1144,13 +1144,21 @@ impl<'a> WorkingTree<'a> {
Ok(has_initialized_submodules_from_status(&output))
}

/// Create a safety backup of current working tree state without affecting the working tree.
/// Back up the index, as a squash is about to commit it, to `refs/wt-backup/<branch>`.
///
/// This creates a backup commit containing all changes (staged, unstaged, and untracked files)
/// and stores it in a custom ref (`refs/wt-backup/<branch>`). This creates a reflog entry
/// for recovery without polluting the stash list. The working tree remains unchanged.
/// Writes the index as a commit whose parent is `HEAD`, so one commit holds
/// both the branch tip being rewritten and the changes the squash sweeps in,
/// and records it in the ref's reflog. Unstaged and untracked files are not
/// captured; the squash leaves them in place. The index, the working tree and
/// the stash list are untouched.
///
/// Users can find safety backups with: `git reflog show refs/wt-backup/<branch>`
/// Plumbing throughout, so no config hides part of the index: `git stash
/// create` honors `submodule.<name>.ignore=all` and sees nothing in a staged
/// gitlink bump. The backup commit is never signed.
///
/// To recover, `git reflog show refs/wt-backup/<branch>` lists the backups;
/// `git read-tree <sha>` restores that index, `git checkout <sha> -- .` also
/// restores the files, and `<sha>^` is the branch tip before the squash.
///
/// Returns the short SHA of the backup commit.
///
Expand All @@ -1165,20 +1173,21 @@ impl<'a> WorkingTree<'a> {
/// # Ok::<(), anyhow::Error>(())
/// ```
pub fn create_safety_backup(&self, message: &str) -> anyhow::Result<String> {
// Create a backup commit using git stash create (without storing it in the stash list)
let tree = self.run_command(&["write-tree"])?;
let backup_sha = self
.run_command(&["stash", "create", "--include-untracked"])?
.run_command(&[
"commit-tree",
"--no-gpg-sign",
tree.trim(),
"-p",
"HEAD",
"-m",
message,
])
.context("Failed to create backup commit")?
.trim()
.to_string();

// Validate that we got a SHA back
if backup_sha.is_empty() {
return Err(GitError::Other {
message: "git stash create returned empty SHA - no changes to backup".into(),
}
.into());
}

// Get current branch name to use in the ref name
let stdout = self.run_command(&["rev-parse", "--symbolic-full-name", "HEAD"])?;
let branch = stdout
Expand Down Expand Up @@ -1728,11 +1737,8 @@ mod tests {

for branch in ["a/b", "a-b"] {
test.run_git(&["switch", "-c", branch]);
// Modify the tracked file so `git stash create` picks up changes.
std::fs::write(test.root_path().join("file.txt"), branch).unwrap();
wt.create_safety_backup(&format!("{branch} (squash)"))
.unwrap();
test.run_git(&["checkout", "--", "file.txt"]);
test.run_git(&["switch", "main"]);
}

Expand Down
89 changes: 89 additions & 0 deletions tests/integration_tests/merge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2639,6 +2639,95 @@ fn test_merge_squash_with_working_tree_creates_backup(mut repo_with_main_worktre
);
}

/// A staged submodule bump that `submodule.<name>.ignore = all` hides from
/// porcelain is still squashed in and backed up, whatever the branch's commits
/// add up to: the backup snapshots the index with plumbing, which that config
/// cannot hide. `None` restores the file's content from main, so the last case's
/// commits cancel out and the bump is all that remains.
#[rstest]
#[case::two_commits(&[("a.txt", Some("a")), ("b.txt", Some("b"))], "a.txt\nb.txt\nsub")]
#[case::one_commit(&[("a.txt", Some("a"))], "a.txt\nsub")]
#[case::commits_cancel_out(&[("file.txt", Some("changed")), ("file.txt", None)], "sub")]
fn test_step_squash_backs_up_submodule_bump_hidden_by_ignore(
mut repo: TestRepo,
#[case] commits: &[(&str, Option<&str>)],
#[case] squashed_paths: &str,
) {
use worktrunk::git::PlumbingDiff;

// A gitlink `sub` on main; uninitialized, so no clone is needed.
fs::write(
repo.root_path().join(".gitmodules"),
"[submodule \"sub\"]\n\tpath = sub\n\turl = ./sub\n",
)
.unwrap();
let old_pointer = repo.git_output(&["rev-parse", "HEAD"]);
repo.run_git(&["add", ".gitmodules"]);
repo.run_git(&[
"update-index",
"--add",
"--cacheinfo",
&format!("160000,{old_pointer},sub"),
]);
repo.run_git(&["commit", "-m", "add submodule"]);
repo.run_git(&["config", "submodule.sub.ignore", "all"]);

let feature_wt = repo.add_worktree("feature");
for (i, (file, content)) in commits.iter().enumerate() {
match content {
Some(content) => fs::write(feature_wt.join(file), content).unwrap(),
None => repo.run_git_in(&feature_wt, &["checkout", "main", "--", file]),
}
repo.run_git_in(&feature_wt, &["add", file]);
repo.run_git_in(&feature_wt, &["commit", "-m", &format!("commit {i}")]);
}
let pre_squash_tip = repo.git_output(&["rev-parse", "feature"]);
let new_pointer = repo.git_output(&["rev-parse", "main"]);
repo.run_git_in(
&feature_wt,
&[
"update-index",
"--cacheinfo",
&format!("160000,{new_pointer},sub"),
],
);

let output = repo
.wt_command()
.args(["step", "squash", "--no-hooks"])
.current_dir(&feature_wt)
.env(
"WORKTRUNK_COMMIT__GENERATION__COMMAND",
"cat >/dev/null && echo 'feat: combined'",
)
.output()
.unwrap();
assert!(
output.status.success(),
"step squash failed: {}",
String::from_utf8_lossy(&output.stderr)
);

assert_eq!(
repo.git_output(&["rev-parse", "feature^"]),
repo.git_output(&["rev-parse", "main"])
);
assert_eq!(repo.git_output(&["rev-parse", "feature:sub"]), new_pointer);
assert_eq!(
repo.git_output(&PlumbingDiff::Tree.args(&["--name-only", "-r", "main", "feature"])),
squashed_paths
);
// The backup holds the squashed tree on top of the pre-squash tip.
assert_eq!(
repo.git_output(&["rev-parse", "refs/wt-backup/feature^{tree}"]),
repo.git_output(&["rev-parse", "feature^{tree}"])
);
assert_eq!(
repo.git_output(&["rev-parse", "refs/wt-backup/feature^"]),
pre_squash_tip
);
}

#[rstest]
fn test_merge_when_default_branch_missing_worktree(repo: TestRepo) {
// Move primary off default branch so no worktree holds it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ exit_code: 0

----- stderr -----
◎ Squashing 2 commits & working tree changes into a single commit (2 files, +2)...
↳ Backup created @ d5452aa
↳ Backup created @ 650fba7
◎ Generating squash commit message...
  fix: update file 1 content
✓ Squashed @ [HASH]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ exit_code: 0

----- stderr -----
◎ Squashing 1 commit & working tree changes into a single commit (1 file, +1)...
↳ Backup created @ ba39431
↳ Backup created @ 6cb8292
◎ Generating squash commit message...
↳ Using fallback commit message. For LLM setup guide, run wt config --help
  Squash commits from feature
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ exit_code: 0

----- stderr -----
◎ Squashing 1 commit & working tree changes into a single commit (1 file, +1)...
↳ Backup created @ bcc96d5
↳ Backup created @ 96b35d4
◎ Generating squash commit message...
  fix: improve auth validation logic
✓ Squashed @ [HASH]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ exit_code: 0

----- stderr -----
◎ Squashing 2 commits & working tree changes into a single commit (2 files, +2)...
↳ Backup created @ d5452aa
↳ Backup created @ 650fba7
◎ Generating squash commit message...
  fix: update files
✓ Squashed @ [HASH]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ exit_code: 0
  untracked1.txt
  untracked2.txt
◎ Squashing 1 commit & working tree changes into a single commit (3 files, +3)...
↳ Backup created @ a40b656
↳ Backup created @ ce9d1c1
◎ Generating squash commit message...
  fix: commit changes
✓ Squashed @ [HASH]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ exit_code: 0
----- stderr -----
○ Skipping pre-commit hooks (--no-hooks)
◎ Squashing 2 commits & tracked changes into a single commit (3 files, +3)...
↳ Backup created @ 91a335e
↳ Backup created @ 6f7dfd4
◎ Generating squash commit message...
  squash: combined commits
✓ Squashed @ [HASH]
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ exit_code: 0

----- stderr -----
◎ Squashing 2 commits & tracked changes into a single commit (2 files, +2)...
↳ Backup created @ d8f4370
↳ Backup created @ 3f99f8e
◎ Generating squash commit message...
  squash: combined commits
✓ Squashed @ [HASH]
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ exit_code: 0
▲ Auto-staging 1 untracked path:
  dirty.txt
◎ Squashing 1 commit & working tree changes into a single commit (2 files, +2)...
↳ Backup created @ c866be4
↳ Backup created @ 874aee4
◎ Generating squash commit message...
↳ Using fallback commit message. For LLM setup guide, run wt config --help
  Squash commits from feature
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ exit_code: 0
▲ Auto-staging 1 untracked path:
  dirty.txt
◎ Squashing 1 commit & working tree changes into a single commit (2 files, +2)...
↳ Backup created @ c866be4
↳ Backup created @ 874aee4
◎ Generating squash commit message...
↳ Using fallback commit message. For LLM setup guide, run wt config --help
  Squash commits from feature
Expand Down
Loading