diff --git a/Cargo.lock b/Cargo.lock index fa2d7e7dcc..936cdc6228 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1358,6 +1358,7 @@ dependencies = [ "gix", "gix-testtools", "regex", + "snapbox", "termtree", ] diff --git a/Cargo.toml b/Cargo.toml index 6734a15553..82ae52eda0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -105,6 +105,7 @@ chrono = { version = "0.4.42", default-features = false, features = ["std"] } md5 = "0.8.0" bitflags = "2.9.4" notify = "8.2.0" +snapbox = "0.6.22" [profile.release] # There are no overrides here as we optimise for fast release builds, diff --git a/crates/but-db/Cargo.toml b/crates/but-db/Cargo.toml index 501af2bcc3..e630a0f09c 100644 --- a/crates/but-db/Cargo.toml +++ b/crates/but-db/Cargo.toml @@ -22,7 +22,7 @@ bitflags.workspace = true serde.workspace = true anyhow.workspace = true diesel_migrations = { version = "2.3.0", features = ["sqlite"] } -chrono.workspace = true +chrono = { workspace = true, features = ["serde"] } # other things tokio = { workspace = true, features = ["rt-multi-thread", "parking_lot", "time", "sync", "macros"] } tracing.workspace = true diff --git a/crates/but-graph/src/init/walk.rs b/crates/but-graph/src/init/walk.rs index 12234e586e..6b82beb31b 100644 --- a/crates/but-graph/src/init/walk.rs +++ b/crates/but-graph/src/init/walk.rs @@ -595,11 +595,10 @@ pub fn find( } /// Returns `([(workspace_tip, workspace_ref_name, workspace_info)], target_refs, desired_refs)` for all available workspace, -/// or exactly one workspace if `maybe_ref_name`. -/// already points to a workspace. That way we can discover the workspace containing any starting point, but only if needed. +/// or exactly one workspace if `maybe_ref_name` has workspace metadata (and only then). /// +/// That way we can discover the workspace containing any starting point, but only if needed. /// This means we process all workspaces if we aren't currently and clearly looking at a workspace. -/// /// Also prune all non-standard workspaces early, or those that don't have a tip. #[expect(clippy::type_complexity)] pub fn obtain_workspace_infos( diff --git a/crates/but-testsupport/Cargo.toml b/crates/but-testsupport/Cargo.toml index 01d47dabfd..126f785db5 100644 --- a/crates/but-testsupport/Cargo.toml +++ b/crates/but-testsupport/Cargo.toml @@ -9,6 +9,9 @@ rust-version = "1.89" [lib] doctest = false +[features] +snapbox = ["dep:snapbox"] + [dependencies] gix.workspace = true anyhow.workspace = true @@ -17,5 +20,6 @@ gix-testtools.workspace = true but-graph.workspace = true but-core.workspace = true regex = "1.11.3" +snapbox = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/but-testsupport/src/lib.rs b/crates/but-testsupport/src/lib.rs index 245d46bec8..6273dc05e7 100644 --- a/crates/but-testsupport/src/lib.rs +++ b/crates/but-testsupport/src/lib.rs @@ -1,14 +1,14 @@ //! Utilities for testing. #![deny(missing_docs)] -use std::{collections::HashMap, path::Path}; - use gix::{ Repository, bstr::{BStr, ByteSlice}, config::tree::Key, }; pub use gix_testtools; +use std::io::Write; +use std::{collections::HashMap, path::Path}; mod in_memory_meta; pub use in_memory_meta::{InMemoryRefMetadata, InMemoryRefMetadataHandle, StackState}; @@ -31,13 +31,41 @@ pub fn hunk_header(old: &str, new: &str) -> ((u32, u32), (u32, u32)) { (parse_header(old), parse_header(new)) } -/// While `gix` can't (or can't conveniently) do everything, let's make using `git` easier. +/// While `gix` can't (or can't conveniently) do everything, let's make using `git` easier, +/// by producing a command that is anchored to the `gix` repository. +/// Call [`run()`](CommandExt::run) when done configuring its arguments. pub fn git(repo: &gix::Repository) -> std::process::Command { let mut cmd = std::process::Command::new(gix::path::env::exe_invocation()); cmd.current_dir(repo.workdir().expect("non-bare")); + isolate_env_std_cmd(&mut cmd); cmd } +/// Run the given `script` in bash, with the `cwd` set to the `repo` worktree. +/// Panic if the script fails. +pub fn invoke_bash(script: &str, repo: &gix::Repository) { + let mut cmd = std::process::Command::new("bash"); + cmd.current_dir(repo.workdir().expect("non-bare")); + isolate_env_std_cmd(&mut cmd); + cmd.stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + let mut child = cmd.spawn().expect("bash can be spawned"); + child + .stdin + .as_mut() + .unwrap() + .write_all(script.as_bytes()) + .expect("failed to write to stdin"); + let out = child.wait_with_output().expect("can wait for output"); + assert!( + out.status.success(), + "{cmd:?} failed: {}\n\n{}", + out.stdout.as_bstr(), + out.stderr.as_bstr() + ); +} + /// Open a repository at `path` suitable for testing which means that: /// /// * author and committer are configured, as well as a stable time. @@ -75,7 +103,7 @@ pub fn hex_to_id(hex: &str) -> gix::ObjectId { /// Sets and environment that assures commits are reproducible. /// This needs the `testing` feature enabled in `but-core` as well to work. -/// This changes the process environment, be aware. +/// **This changes the process environment, be aware.** pub fn assure_stable_env() { let env = gix_testtools::Env::new() // TODO(gix): once everything is ported, all these can be configured on `gix::Repository`. @@ -319,3 +347,9 @@ pub fn debug_str(input: &dyn std::fmt::Debug) -> String { mod graph; pub use graph::{graph_tree, graph_workspace}; + +mod prepare_cmd_env; +pub use prepare_cmd_env::isolate_env_std_cmd; + +#[cfg(feature = "snapbox")] +pub use prepare_cmd_env::isolate_snapbox_cmd; diff --git a/crates/but-testsupport/src/prepare_cmd_env.rs b/crates/but-testsupport/src/prepare_cmd_env.rs new file mode 100644 index 0000000000..29e2b12b20 --- /dev/null +++ b/crates/but-testsupport/src/prepare_cmd_env.rs @@ -0,0 +1,94 @@ +use std::borrow::Cow; +use std::ffi::OsStr; + +/// Change the `cmd` environment to be very isolated, particularly when Git is involved. +pub fn isolate_env_std_cmd(cmd: &mut std::process::Command) -> &mut std::process::Command { + for op in updates() { + match op { + EnvOp::Remove(var) => { + cmd.env_remove(var); + } + EnvOp::Add { name, value } => { + cmd.env(name, value); + } + } + } + cmd +} + +/// Change the `cmd` environment to be very isolated, particularly when Git is involved. +#[cfg(feature = "snapbox")] +pub fn isolate_snapbox_cmd(mut cmd: snapbox::cmd::Command) -> snapbox::cmd::Command { + for op in updates() { + cmd = match op { + EnvOp::Remove(var) => cmd.env_remove(var), + EnvOp::Add { name, value } => cmd.env(name, value), + }; + } + cmd +} + +enum EnvOp { + Remove(&'static str), + Add { + name: &'static str, + value: Cow<'static, OsStr>, + }, +} + +fn updates() -> Vec { + // Copied from gix-testtools/lib.rs, in an attempt to isolate everything as good as possible, + #[cfg(windows)] + const NULL_DEVICE: &str = "NUL"; + #[cfg(not(windows))] + const NULL_DEVICE: &str = "/dev/null"; + + // particularly mutation. + let mut msys_for_git_bash_on_windows = std::env::var_os("MSYS").unwrap_or_default(); + msys_for_git_bash_on_windows.push(" winsymlinks:nativestrict"); + [ + EnvOp::Remove("GIT_DIR"), + EnvOp::Remove("GIT_INDEX_FILE"), + EnvOp::Remove("GIT_OBJECT_DIRECTORY"), + EnvOp::Remove("GIT_ALTERNATE_OBJECT_DIRECTORIES"), + EnvOp::Remove("GIT_WORK_TREE"), + EnvOp::Remove("GIT_COMMON_DIR"), + EnvOp::Remove("GIT_ASKPASS"), + EnvOp::Remove("SSH_ASKPASS"), + ] + .into_iter() + .chain( + [ + ("GIT_CONFIG_NOSYSTEM", "1"), + ("GIT_CONFIG_GLOBAL", NULL_DEVICE), + ("GIT_TERMINAL_PROMPT", "false"), + ("GIT_AUTHOR_DATE", "2000-01-01 00:00:00 +0000"), + ("GIT_AUTHOR_EMAIL", "author@example.com"), + ("GIT_AUTHOR_NAME", "author"), + ("GIT_COMMITTER_DATE", "2000-01-02 00:00:00 +0000"), + ("GIT_COMMITTER_EMAIL", "committer@example.com"), + ("GIT_COMMITTER_NAME", "committer"), + ("GIT_CONFIG_COUNT", "4"), + ("GIT_CONFIG_KEY_0", "commit.gpgsign"), + ("GIT_CONFIG_VALUE_0", "false"), + ("GIT_CONFIG_KEY_1", "tag.gpgsign"), + ("GIT_CONFIG_VALUE_1", "false"), + ("GIT_CONFIG_KEY_2", "init.defaultBranch"), + ("GIT_CONFIG_VALUE_2", "main"), + ("GIT_CONFIG_KEY_3", "protocol.file.allow"), + ("GIT_CONFIG_VALUE_3", "always"), + ("CLICOLOR_FORCE", "1"), + ("RUST_BACKTRACE", "0"), + ] + .into_iter() + .map(|(name, value)| EnvOp::Add { + name, + value: Cow::Borrowed(OsStr::new(value)), + }), + ) + .chain(Some(EnvOp::Add { + name: "MSYS", + value: Cow::Owned(msys_for_git_bash_on_windows), + })) + .collect() +} diff --git a/crates/but-workspace/src/commit.rs b/crates/but-workspace/src/commit.rs index 01e7c038eb..3a3a7e8023 100644 --- a/crates/but-workspace/src/commit.rs +++ b/crates/but-workspace/src/commit.rs @@ -365,7 +365,8 @@ pub mod merge { } fn peel_to_tree(commit: gix::Id) -> anyhow::Result { - Ok(commit.object()?.peel_to_tree()?.id) + let commit = but_core::Commit::from_id(commit)?; + Ok(commit.tree_id_or_auto_resolution()?.detach()) } } diff --git a/crates/but-workspace/tests/fixtures/scenario/with-conflict.sh b/crates/but-workspace/tests/fixtures/scenario/with-conflict.sh index 9ab5ce44d3..39b69aedc7 100644 --- a/crates/but-workspace/tests/fixtures/scenario/with-conflict.sh +++ b/crates/but-workspace/tests/fixtures/scenario/with-conflict.sh @@ -3,7 +3,8 @@ set -eu -o pipefail git init -# A repository with a normal and an artificial conflicting commit +echo "A repository with a normal and an artificial conflicting commit" >.git/description + echo content >file && git add . && git commit -m "init" git tag normal diff --git a/crates/but-workspace/tests/workspace/commit.rs b/crates/but-workspace/tests/workspace/commit.rs index 1816ab3cea..2c1d674699 100644 --- a/crates/but-workspace/tests/workspace/commit.rs +++ b/crates/but-workspace/tests/workspace/commit.rs @@ -1,11 +1,13 @@ mod from_new_merge_with_metadata { + use crate::ref_info::with_workspace_commit::utils::{ + named_read_only_in_memory_scenario, named_writable_scenario_with_description_and_graph, + }; use bstr::ByteSlice; - use but_graph::init::Options; + use but_graph::init::{Options, Overlay}; use but_testsupport::{visualize_commit_graph_all, visualize_tree}; use but_workspace::WorkspaceCommit; use gix::prelude::ObjectIdExt; - - use crate::ref_info::with_workspace_commit::utils::named_read_only_in_memory_scenario; + use gix::refs::Target; #[test] fn without_conflict_journey() -> anyhow::Result<()> { @@ -238,6 +240,74 @@ mod from_new_merge_with_metadata { Ok(()) } + #[test] + fn with_conflict_commits() -> anyhow::Result<()> { + let (_tmp, mut graph, repo, mut meta, _description) = + named_writable_scenario_with_description_and_graph("with-conflict", |_| {})?; + insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @r" + * 8450331 (HEAD -> main, tag: conflicted) GitButler WIP Commit + * a047f81 (tag: normal) init + "); + but_testsupport::invoke_bash( + r#" + git branch tip-conflicted + git reset --hard @~1 + git checkout -b unrelated + touch unrelated-file && git add unrelated-file && git commit -m "unrelated" + "#, + &repo, + ); + insta::assert_snapshot!(visualize_commit_graph_all(&repo)?, @r" + * 8450331 (tag: conflicted, tip-conflicted) GitButler WIP Commit + | * 8ab1c4d (HEAD -> unrelated) unrelated + |/ + * a047f81 (tag: normal, main) init + "); + + let stacks = ["tip-conflicted", "unrelated"]; + add_stacks(&mut meta, stacks); + + graph = graph.redo_traversal_with_overlay( + &repo, + &meta, + Overlay::default().with_references_if_new([ + repo.find_reference("unrelated")?.inner, + // The workspace ref is needed so the workspace and its stacks are iterated as well. + // Algorithms which work with simulation also have to be mindful about this. + gix::refs::Reference { + name: "refs/heads/gitbutler/workspace".try_into()?, + target: Target::Object(repo.rev_parse_single("main")?.detach()), + peeled: None, + }, + ]), + )?; + + let out = + WorkspaceCommit::from_new_merge_with_metadata(&to_stacks(stacks), &graph, &repo, None)?; + insta::assert_debug_snapshot!(out, @r#" + Outcome { + workspace_commit_id: Sha1(ed5a3012c6a4798404f5b8586588d0ede0664683), + stacks: [ + Stack { tip: 8450331, name: "tip-conflicted" }, + Stack { tip: 8ab1c4d, name: "unrelated" }, + ], + missing_stacks: [], + conflicting_stacks: [], + } + "#); + + // There it auto-resolves the commit to not merge the actual tree structure. + insta::assert_snapshot!(visualize_tree( + out.workspace_commit_id.attach(&repo).object()?.into_commit().tree_id()? + ), @r#" + 8882acc + ├── file:100644:e69de29 "" + └── unrelated-file:100644:e69de29 "" + "#); + + Ok(()) + } + #[test] fn with_conflict_journey() -> anyhow::Result<()> { let (repo, mut meta) = diff --git a/crates/but/Cargo.toml b/crates/but/Cargo.toml index f0ac3f97d2..67794c2c92 100644 --- a/crates/but/Cargo.toml +++ b/crates/but/Cargo.toml @@ -70,7 +70,7 @@ tracing-subscriber = { version = "0.3", features = [ tracing-forest = { version = "0.2.0" } [dev-dependencies] -but-testsupport.workspace = true -snapbox = { version = "0.6.22", features = ["term-svg", "regex"] } +but-testsupport = { workspace = true, features = ["snapbox"] } +snapbox = { workspace = true, features = ["term-svg", "regex"] } shell-words = "1.1.0" insta.workspace = true \ No newline at end of file diff --git a/crates/but/tests/but/utils.rs b/crates/but/tests/but/utils.rs index 0994e391ef..f6bf3bdb4f 100644 --- a/crates/but/tests/but/utils.rs +++ b/crates/but/tests/but/utils.rs @@ -227,44 +227,7 @@ impl Sandbox { } fn with_updated_env(&self, cmd: snapbox::cmd::Command) -> snapbox::cmd::Command { - // Copied from gix-testtools/lib.rs, in an attempt to isolate everything as good as possible, - #[cfg(windows)] - const NULL_DEVICE: &str = "NUL"; - #[cfg(not(windows))] - const NULL_DEVICE: &str = "/dev/null"; - - // particularly mutation. - let mut msys_for_git_bash_on_windows = env::var_os("MSYS").unwrap_or_default(); - msys_for_git_bash_on_windows.push(" winsymlinks:nativestrict"); - cmd.env_remove("GIT_DIR") - .env_remove("GIT_INDEX_FILE") - .env_remove("GIT_OBJECT_DIRECTORY") - .env_remove("GIT_ALTERNATE_OBJECT_DIRECTORIES") - .env_remove("GIT_WORK_TREE") - .env_remove("GIT_COMMON_DIR") - .env_remove("GIT_ASKPASS") - .env_remove("SSH_ASKPASS") - .env("MSYS", msys_for_git_bash_on_windows) - .env("GIT_CONFIG_NOSYSTEM", "1") - .env("GIT_CONFIG_GLOBAL", NULL_DEVICE) - .env("GIT_TERMINAL_PROMPT", "false") - .env("GIT_AUTHOR_DATE", "2000-01-01 00:00:00 +0000") - .env("GIT_AUTHOR_EMAIL", "author@example.com") - .env("GIT_AUTHOR_NAME", "author") - .env("GIT_COMMITTER_DATE", "2000-01-02 00:00:00 +0000") - .env("GIT_COMMITTER_EMAIL", "committer@example.com") - .env("GIT_COMMITTER_NAME", "committer") - .env("GIT_CONFIG_COUNT", "4") - .env("GIT_CONFIG_KEY_0", "commit.gpgsign") - .env("GIT_CONFIG_VALUE_0", "false") - .env("GIT_CONFIG_KEY_1", "tag.gpgsign") - .env("GIT_CONFIG_VALUE_1", "false") - .env("GIT_CONFIG_KEY_2", "init.defaultBranch") - .env("GIT_CONFIG_VALUE_2", "main") - .env("GIT_CONFIG_KEY_3", "protocol.file.allow") - .env("GIT_CONFIG_VALUE_3", "always") - .env("CLICOLOR_FORCE", "1") - .env("RUST_BACKTRACE", "0") + but_testsupport::isolate_snapbox_cmd(cmd) .env("E2E_TEST_APP_DATA_DIR", self.app_root()) .current_dir(self.projects_root()) }