дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-08 19:02:17 +02:00
722 рядки
30 KiB
Rust
722 рядки
30 KiB
Rust
//! Rebase / cherry-pick / revert / reset / tags, the shelf and patches, against
|
|
//! throwaway repositories created with the git CLI.
|
|
|
|
use ide_core::CancellationToken;
|
|
use ide_vcs::*;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
struct Fixture {
|
|
_tmp: tempfile::TempDir,
|
|
dir: PathBuf,
|
|
}
|
|
|
|
fn git_in(dir: &Path, args: &[&str]) -> String {
|
|
let out = Command::new("git")
|
|
.current_dir(dir)
|
|
.args(["-c", "init.defaultBranch=main", "-c", "core.quotepath=off"])
|
|
.args(args)
|
|
.env("GIT_TERMINAL_PROMPT", "0")
|
|
.output()
|
|
.expect("git runs");
|
|
assert!(out.status.success(), "git {args:?} failed: {}", String::from_utf8_lossy(&out.stderr));
|
|
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
|
}
|
|
|
|
impl Fixture {
|
|
fn new() -> Self {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let dir = tmp.path().join("work");
|
|
std::fs::create_dir(&dir).unwrap();
|
|
git_in(&dir, &["init", "-q"]);
|
|
for (k, v) in [
|
|
("user.name", "Test User"),
|
|
("user.email", "[email protected]"),
|
|
("commit.gpgsign", "false"),
|
|
("tag.gpgsign", "false"),
|
|
("core.autocrlf", "false"),
|
|
("core.hooksPath", ".git/hooks"),
|
|
("merge.conflictstyle", "merge"),
|
|
] {
|
|
git_in(&dir, &["config", k, v]);
|
|
}
|
|
Fixture { _tmp: tmp, dir }
|
|
}
|
|
fn git(&self, args: &[&str]) -> String {
|
|
git_in(&self.dir, args)
|
|
}
|
|
fn write(&self, rel: &str, content: &str) {
|
|
let p = self.dir.join(rel);
|
|
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
|
|
std::fs::write(p, content).unwrap();
|
|
}
|
|
fn read(&self, rel: &str) -> String {
|
|
std::fs::read_to_string(self.dir.join(rel)).unwrap()
|
|
}
|
|
fn commit_all(&self, msg: &str) -> String {
|
|
self.git(&["add", "-A"]);
|
|
self.git(&["commit", "-q", "-m", msg]);
|
|
self.git(&["rev-parse", "HEAD"])
|
|
}
|
|
fn repo(&self) -> Repo {
|
|
Repo::discover(&self.dir).unwrap().expect("repository found")
|
|
}
|
|
fn path(&self, rel: &str) -> PathBuf {
|
|
self.dir.join(rel)
|
|
}
|
|
}
|
|
|
|
fn quiet() -> (CancellationToken, impl Fn(String)) {
|
|
(CancellationToken::new(), |_line: String| {})
|
|
}
|
|
|
|
/// main: base → "main" edit of f.txt; topic: base → "topic" edit of f.txt.
|
|
fn diverged() -> Fixture {
|
|
let fx = Fixture::new();
|
|
fx.write("f.txt", "base\n");
|
|
fx.commit_all("base");
|
|
fx.git(&["checkout", "-q", "-b", "topic"]);
|
|
fx.write("f.txt", "topic\n");
|
|
fx.write("t.txt", "t\n");
|
|
fx.commit_all("topic edit");
|
|
fx.git(&["checkout", "-q", "main"]);
|
|
fx.write("f.txt", "main\n");
|
|
fx.commit_all("main edit");
|
|
fx.git(&["checkout", "-q", "topic"]);
|
|
fx
|
|
}
|
|
|
|
#[test]
|
|
fn rebase_conflict_then_continue() {
|
|
let fx = diverged();
|
|
let repo = fx.repo();
|
|
let (cancel, out) = quiet();
|
|
assert!(repo.rebase("main").is_err());
|
|
assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("rebase"));
|
|
// Unresolved conflicts block Continue.
|
|
let err = repo.continue_operation(&cancel, &out).unwrap_err();
|
|
assert!(err.message.contains("f.txt"), "{}", err.message);
|
|
|
|
fx.write("f.txt", "resolved\n");
|
|
repo.stage(&[fx.path("f.txt")]).unwrap();
|
|
repo.continue_operation(&cancel, &out).unwrap();
|
|
assert_eq!(repo.branch_info().unwrap().operation, None);
|
|
assert_eq!(fx.read("f.txt"), "resolved\n");
|
|
assert_eq!(fx.git(&["log", "-1", "--format=%s"]), "topic edit");
|
|
assert_eq!(fx.git(&["rev-parse", "HEAD~1"]), fx.git(&["rev-parse", "main"]));
|
|
}
|
|
|
|
#[test]
|
|
fn merge_versions_put_the_users_work_on_the_left() {
|
|
// Rebase: git's "ours" is main; the user's commit (topic) must be `ours` here.
|
|
let fx = diverged();
|
|
let repo = fx.repo();
|
|
let topic = fx.git(&["rev-parse", "topic"]);
|
|
assert!(repo.rebase("main").is_err());
|
|
let v = repo.merge_versions(&fx.path("f.txt"), encoding_rs::UTF_8).unwrap();
|
|
assert_eq!(v.ours.as_deref(), Some("topic\n"));
|
|
assert_eq!(v.theirs.as_deref(), Some("main\n"));
|
|
assert_eq!(v.base.as_deref(), Some("base\n"));
|
|
assert!(v.sides.reversed);
|
|
assert_eq!(v.sides.operation.as_deref(), Some("rebase"));
|
|
assert_eq!(v.sides.branch.as_deref(), Some("topic"));
|
|
assert_eq!(v.sides.other_branch.as_deref(), Some("main"));
|
|
assert_eq!(v.sides.commit.as_deref(), Some(&topic[..8]));
|
|
// The merged text puts the user's side first, whatever git's stage order.
|
|
assert_eq!(v.merged.as_deref(), Some("<<<<<<< yours\ntopic\n||||||| base\nbase\n=======\nmain\n>>>>>>> theirs\n"));
|
|
let (cancel, out) = quiet();
|
|
repo.abort_operation(&cancel, &out).unwrap();
|
|
|
|
// Merge: not swapped; the merged branch is named.
|
|
assert!(repo.merge("main").is_err());
|
|
let v = repo.merge_versions(&fx.path("f.txt"), encoding_rs::UTF_8).unwrap();
|
|
assert_eq!(v.ours.as_deref(), Some("topic\n"));
|
|
assert_eq!(v.theirs.as_deref(), Some("main\n"));
|
|
assert!(!v.sides.reversed);
|
|
assert_eq!(v.sides.branch.as_deref(), Some("topic"));
|
|
assert_eq!(v.sides.other_branch.as_deref(), Some("main"));
|
|
repo.abort_operation(&cancel, &out).unwrap();
|
|
|
|
// Cherry-pick: not swapped; the picked commit is named.
|
|
let main = fx.git(&["rev-parse", "main"]);
|
|
assert!(repo.cherry_pick(std::slice::from_ref(&main), &cancel, &out).is_err());
|
|
let v = repo.merge_versions(&fx.path("f.txt"), encoding_rs::UTF_8).unwrap();
|
|
assert_eq!(v.ours.as_deref(), Some("topic\n"));
|
|
assert!(!v.sides.reversed);
|
|
assert_eq!(v.sides.operation.as_deref(), Some("cherry-pick"));
|
|
assert_eq!(v.sides.commit.as_deref(), Some(&main[..8]));
|
|
repo.abort_operation(&cancel, &out).unwrap();
|
|
assert_eq!(repo.merge_sides().unwrap(), MergeSides { branch: Some("topic".into()), ..Default::default() });
|
|
}
|
|
|
|
#[test]
|
|
fn rebase_conflict_names_the_remote_branch_without_its_symbolic_aliases() {
|
|
let fx = diverged();
|
|
let main = fx.git(&["rev-parse", "main"]);
|
|
fx.git(&["update-ref", "refs/remotes/origin/main", &main]);
|
|
fx.git(&["symbolic-ref", "refs/remotes/origin/HEAD", "refs/remotes/origin/main"]);
|
|
fx.git(&["symbolic-ref", "refs/remotes/origin/alias", "refs/remotes/origin/main"]);
|
|
fx.git(&["branch", "-D", "main"]);
|
|
let repo = fx.repo();
|
|
assert!(repo.rebase("origin/main").is_err());
|
|
assert_eq!(repo.merge_sides().unwrap().other_branch.as_deref(), Some("origin/main"));
|
|
let (cancel, out) = quiet();
|
|
repo.abort_operation(&cancel, &out).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn rebase_conflict_then_abort_and_skip() {
|
|
let fx = diverged();
|
|
let repo = fx.repo();
|
|
let (cancel, out) = quiet();
|
|
let before = fx.git(&["rev-parse", "HEAD"]);
|
|
assert!(repo.rebase("main").is_err());
|
|
repo.abort_operation(&cancel, &out).unwrap();
|
|
assert_eq!(repo.branch_info().unwrap().operation, None);
|
|
assert_eq!(fx.git(&["rev-parse", "HEAD"]), before);
|
|
assert_eq!(fx.read("f.txt"), "topic\n");
|
|
|
|
assert!(repo.rebase("main").is_err());
|
|
repo.skip_operation(&cancel, &out).unwrap();
|
|
assert_eq!(repo.branch_info().unwrap().operation, None);
|
|
assert_eq!(fx.git(&["rev-parse", "HEAD"]), fx.git(&["rev-parse", "main"]));
|
|
|
|
// Nothing in progress.
|
|
assert!(repo.abort_operation(&cancel, &out).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn merge_conflict_continue_commits() {
|
|
let fx = diverged();
|
|
fx.git(&["checkout", "-q", "main"]);
|
|
let repo = fx.repo();
|
|
let (cancel, out) = quiet();
|
|
assert!(repo.merge("topic").is_err());
|
|
assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("merge"));
|
|
assert!(repo.skip_operation(&cancel, &out).is_err(), "merges cannot be skipped");
|
|
fx.write("f.txt", "both\n");
|
|
repo.stage(&[fx.path("f.txt")]).unwrap();
|
|
repo.continue_operation(&cancel, &out).unwrap();
|
|
assert_eq!(repo.branch_info().unwrap().operation, None);
|
|
assert_eq!(fx.git(&["rev-list", "--count", "--merges", "HEAD"]), "1");
|
|
}
|
|
|
|
#[test]
|
|
fn cherry_pick_and_conflicting_cherry_pick() {
|
|
let fx = diverged();
|
|
let repo = fx.repo();
|
|
let (cancel, out) = quiet();
|
|
fx.git(&["checkout", "-q", "-b", "side", "main~1"]);
|
|
fx.write("s.txt", "side\n");
|
|
let side = fx.commit_all("side work");
|
|
fx.git(&["checkout", "-q", "main"]);
|
|
|
|
repo.cherry_pick(&[side], &cancel, &out).unwrap();
|
|
assert_eq!(fx.read("s.txt"), "side\n");
|
|
assert_eq!(fx.git(&["log", "-1", "--format=%s"]), "side work");
|
|
|
|
let topic = fx.git(&["rev-parse", "topic"]);
|
|
assert!(repo.cherry_pick(std::slice::from_ref(&topic), &cancel, &out).is_err());
|
|
assert_eq!(repo.branch_info().unwrap().operation.as_deref(), Some("cherry-pick"));
|
|
fx.write("f.txt", "picked\n");
|
|
repo.stage(&[fx.path("f.txt")]).unwrap();
|
|
repo.continue_operation(&cancel, &out).unwrap();
|
|
assert_eq!(repo.branch_info().unwrap().operation, None);
|
|
assert_eq!(fx.git(&["log", "-1", "--format=%s"]), "topic edit");
|
|
assert!(repo.cherry_pick(&[], &cancel, &out).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn revert_creates_inverse_commit() {
|
|
let fx = Fixture::new();
|
|
fx.write("a.txt", "1\n");
|
|
fx.commit_all("one");
|
|
fx.write("a.txt", "2\n");
|
|
let two = fx.commit_all("two");
|
|
let repo = fx.repo();
|
|
let (cancel, out) = quiet();
|
|
repo.revert(&[two], &cancel, &out).unwrap();
|
|
assert_eq!(fx.read("a.txt"), "1\n");
|
|
assert_eq!(fx.git(&["log", "-1", "--format=%s"]), "Revert \"two\"");
|
|
assert_eq!(fx.git(&["rev-list", "--count", "HEAD"]), "3");
|
|
}
|
|
|
|
#[test]
|
|
fn reset_modes() {
|
|
let fx = Fixture::new();
|
|
fx.write("a.txt", "1\n");
|
|
let one = fx.commit_all("one");
|
|
fx.write("a.txt", "2\n");
|
|
let two = fx.commit_all("two");
|
|
let repo = fx.repo();
|
|
let (cancel, out) = quiet();
|
|
let status = |repo: &Repo| {
|
|
let st = repo.status().unwrap();
|
|
st.iter().find(|s| s.rel_path == "a.txt").map(|s| (s.staged, s.unstaged))
|
|
};
|
|
|
|
repo.reset(&one, ResetMode::Soft, &cancel, &out).unwrap();
|
|
assert_eq!(fx.git(&["rev-parse", "HEAD"]), one);
|
|
assert_eq!(status(&repo), Some((Some(ChangeKind::Modified), None)));
|
|
|
|
fx.git(&["reset", "-q", "--hard", &two]);
|
|
repo.reset(&one, ResetMode::Mixed, &cancel, &out).unwrap();
|
|
assert_eq!(status(&repo), Some((None, Some(ChangeKind::Modified))));
|
|
assert_eq!(fx.read("a.txt"), "2\n");
|
|
|
|
fx.git(&["reset", "-q", "--hard", &two]);
|
|
fx.write("other.txt", "keep me\n");
|
|
fx.git(&["add", "other.txt"]);
|
|
fx.git(&["commit", "-q", "-m", "three"]);
|
|
fx.write("other.txt", "local edit\n");
|
|
repo.reset(&two, ResetMode::Keep, &cancel, &out).unwrap_err();
|
|
fx.git(&["checkout", "--", "other.txt"]);
|
|
fx.write("a.txt", "local\n");
|
|
// `--keep` keeps local changes of files the reset does not touch.
|
|
repo.reset(&two, ResetMode::Keep, &cancel, &out).unwrap();
|
|
assert_eq!(fx.read("a.txt"), "local\n");
|
|
assert!(!fx.path("other.txt").exists());
|
|
|
|
repo.reset(&one, ResetMode::Hard, &cancel, &out).unwrap();
|
|
assert_eq!(fx.read("a.txt"), "1\n");
|
|
assert!(repo.status().unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn tags_lightweight_and_annotated() {
|
|
let fx = Fixture::new();
|
|
fx.write("a.txt", "1\n");
|
|
let one = fx.commit_all("one");
|
|
fx.write("a.txt", "2\n");
|
|
fx.commit_all("two");
|
|
let repo = fx.repo();
|
|
repo.create_tag("v1.0", &one, None, false).unwrap();
|
|
repo.create_tag("v2.0", "HEAD", Some("Release 2"), false).unwrap();
|
|
assert_eq!(repo.tags().unwrap(), vec!["v1.0".to_string(), "v2.0".to_string()]);
|
|
assert_eq!(fx.git(&["rev-parse", "v1.0^{commit}"]), one);
|
|
assert_eq!(fx.git(&["cat-file", "-t", "v2.0"]), "tag");
|
|
assert!(repo.create_tag("v1.0", "HEAD", None, false).is_err(), "existing tag needs force");
|
|
repo.create_tag("v1.0", "HEAD", None, true).unwrap();
|
|
assert!(repo.create_tag("bad..name", "HEAD", None, false).is_err());
|
|
assert!(repo.create_tag("-x", "HEAD", None, false).is_err());
|
|
assert!(repo.create_tag("ok", "no-such-rev", None, false).is_err());
|
|
let log = repo.log(&LogQuery { branch: Some("HEAD".into()), ..Default::default() }).unwrap();
|
|
assert!(log[0].refs.contains(&"tag: v2.0".to_string()), "{:?}", log[0].refs);
|
|
}
|
|
|
|
#[test]
|
|
fn compare_with_local_lists_differences() {
|
|
let fx = Fixture::new();
|
|
fx.write("a.txt", "1\n");
|
|
fx.write("b.txt", "b\n");
|
|
let one = fx.commit_all("one");
|
|
fx.write("a.txt", "2\n");
|
|
fx.write("c.txt", "c\n");
|
|
fx.commit_all("two");
|
|
fx.write("b.txt", "local\n");
|
|
let files = fx.repo().diff_with_worktree(&one).unwrap();
|
|
let got: Vec<(&str, ChangeKind)> = files.iter().map(|f| (f.path.as_str(), f.kind)).collect();
|
|
assert_eq!(
|
|
got,
|
|
vec![("a.txt", ChangeKind::Modified), ("b.txt", ChangeKind::Modified), ("c.txt", ChangeKind::Added)]
|
|
);
|
|
// Between two revisions the working tree does not count.
|
|
let files = fx.repo().diff_revisions(&one, "HEAD").unwrap();
|
|
let got: Vec<(&str, ChangeKind)> = files.iter().map(|f| (f.path.as_str(), f.kind)).collect();
|
|
assert_eq!(got, vec![("a.txt", ChangeKind::Modified), ("c.txt", ChangeKind::Added)]);
|
|
assert!(fx.repo().diff_revisions("-x", "HEAD").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn shelve_unshelve_round_trip() {
|
|
let fx = Fixture::new();
|
|
fx.write("mod.txt", "one\ntwo\n");
|
|
fx.write("del.txt", "doomed\n");
|
|
fx.write("keep.txt", "keep\n");
|
|
fx.commit_all("base");
|
|
fx.write("mod.txt", "one\nTWO\n");
|
|
std::fs::remove_file(fx.path("del.txt")).unwrap();
|
|
fx.write("new/untracked.txt", "fresh\n");
|
|
fx.write("keep.txt", "not shelved\n");
|
|
fx.git(&["add", "mod.txt"]);
|
|
let repo = fx.repo();
|
|
let shelf = fx._tmp.path().join("shelf");
|
|
|
|
let paths = [fx.path("mod.txt"), fx.path("del.txt"), fx.path("new/untracked.txt")];
|
|
let entry = repo.shelve(&shelf, "My work", &paths).unwrap();
|
|
assert_eq!(entry.name, "My work");
|
|
assert_eq!(entry.branch.as_deref(), Some("main"));
|
|
assert_eq!(entry.files.len(), 3);
|
|
// Shelved changes are gone; the rest stays.
|
|
assert_eq!(fx.read("mod.txt"), "one\ntwo\n");
|
|
assert_eq!(fx.read("del.txt"), "doomed\n");
|
|
assert!(!fx.path("new/untracked.txt").exists());
|
|
assert_eq!(fx.read("keep.txt"), "not shelved\n");
|
|
let st = repo.status().unwrap();
|
|
assert_eq!(st.len(), 1, "{st:#?}");
|
|
|
|
let listed = shelf::list(&shelf).unwrap();
|
|
assert_eq!(listed.len(), 1);
|
|
assert_eq!(listed[0].id, entry.id);
|
|
shelf::rename(&shelf, &entry.id, "Renamed").unwrap();
|
|
assert_eq!(shelf::list(&shelf).unwrap()[0].name, "Renamed");
|
|
assert!(shelf::patch_of(&shelf, &entry.id).unwrap().contains("TWO"));
|
|
|
|
let outcome = repo.unshelve(&shelf, &entry.id, true).unwrap();
|
|
assert!(outcome.conflicts.is_empty());
|
|
assert_eq!(fx.read("mod.txt"), "one\nTWO\n");
|
|
assert!(!fx.path("del.txt").exists());
|
|
assert_eq!(fx.read("new/untracked.txt"), "fresh\n");
|
|
assert!(shelf::list(&shelf).unwrap().is_empty());
|
|
|
|
assert!(repo.shelve(&shelf, "nothing", &[fx.path("del-none.txt")]).is_err());
|
|
assert!(shelf::delete(&shelf, "../work").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn unshelve_with_conflict_leaves_merge_state() {
|
|
let fx = Fixture::new();
|
|
fx.write("f.txt", "a\nb\nc\n");
|
|
fx.commit_all("base");
|
|
fx.write("f.txt", "a\nSHELVED\nc\n");
|
|
let repo = fx.repo();
|
|
let shelf = fx._tmp.path().join("shelf");
|
|
let entry = repo.shelve(&shelf, "conflicting", &[fx.path("f.txt")]).unwrap();
|
|
fx.write("f.txt", "a\nCOMMITTED\nc\n");
|
|
fx.commit_all("diverge");
|
|
|
|
let outcome = repo.unshelve(&shelf, &entry.id, false).unwrap();
|
|
assert_eq!(outcome.conflicts, vec!["f.txt".to_string()]);
|
|
let text = fx.read("f.txt");
|
|
assert!(text.contains("<<<<<<<") && text.contains("SHELVED") && text.contains("COMMITTED"), "{text}");
|
|
let (base, ours, theirs) = repo.conflict_versions(&fx.path("f.txt"), encoding_rs::UTF_8).unwrap();
|
|
assert!(base.is_some() && ours.unwrap().contains("COMMITTED") && theirs.unwrap().contains("SHELVED"));
|
|
// Kept on the shelf when not removing.
|
|
assert_eq!(shelf::list(&shelf).unwrap().len(), 1);
|
|
shelf::delete(&shelf, &entry.id).unwrap();
|
|
assert!(shelf::list(&shelf).unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn create_and_apply_patches() {
|
|
let fx = Fixture::new();
|
|
fx.write("a.txt", "1\n2\n3\n");
|
|
fx.commit_all("base");
|
|
fx.write("a.txt", "1\nTWO\n3\n");
|
|
fx.write("n.txt", "new\n");
|
|
let repo = fx.repo();
|
|
let patch = repo.create_patch(&[fx.path("a.txt"), fx.path("n.txt")], false).unwrap();
|
|
assert!(patch.contains("+TWO") && patch.contains("new file mode"), "{patch}");
|
|
// The real index is untouched.
|
|
assert!(repo.status().unwrap().iter().all(|s| s.staged.is_none()));
|
|
|
|
let reverse = repo.create_patch(&[fx.path("a.txt")], true).unwrap();
|
|
assert!(reverse.contains("-TWO") && reverse.contains("+2"));
|
|
|
|
fx.git(&["checkout", "--", "a.txt"]);
|
|
std::fs::remove_file(fx.path("n.txt")).unwrap();
|
|
let preview = repo.patch_preview(&patch).unwrap();
|
|
let got: Vec<(&str, ChangeKind, u32, u32, bool)> =
|
|
preview.iter().map(|f| (f.path.as_str(), f.kind, f.added, f.removed, f.applies)).collect();
|
|
assert_eq!(got, vec![("a.txt", ChangeKind::Modified, 1, 1, true), ("n.txt", ChangeKind::Added, 1, 0, true)]);
|
|
|
|
let outcome = repo.apply_patch(&patch).unwrap();
|
|
assert!(outcome.conflicts.is_empty());
|
|
assert_eq!(fx.read("a.txt"), "1\nTWO\n3\n");
|
|
assert_eq!(fx.read("n.txt"), "new\n");
|
|
|
|
// Applying again: n.txt exists, a.txt no longer matches.
|
|
let preview = repo.patch_preview(&patch).unwrap();
|
|
assert!(preview.iter().all(|f| !f.applies), "{preview:#?}");
|
|
assert!(repo.apply_patch("").is_err());
|
|
assert!(repo.patch_preview("not a patch\n").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn patch_from_commits_applies_elsewhere() {
|
|
let fx = Fixture::new();
|
|
fx.write("a.txt", "1\n");
|
|
let root = fx.commit_all("root");
|
|
fx.write("a.txt", "1\n2\n");
|
|
let c2 = fx.commit_all("two");
|
|
fx.write("b.txt", "b\n");
|
|
let c3 = fx.commit_all("three");
|
|
let repo = fx.repo();
|
|
let patch = repo.create_commits_patch(&[c2, c3], false).unwrap();
|
|
assert!(patch.contains("+2") && patch.contains("b.txt"));
|
|
let root_patch = repo.create_commits_patch(std::slice::from_ref(&root), false).unwrap();
|
|
assert!(root_patch.contains("new file mode"));
|
|
|
|
fx.git(&["checkout", "-q", "--detach", &root]);
|
|
let outcome = repo.apply_patch(&patch).unwrap();
|
|
assert!(outcome.conflicts.is_empty());
|
|
assert_eq!(fx.read("a.txt"), "1\n2\n");
|
|
assert_eq!(fx.read("b.txt"), "b\n");
|
|
}
|
|
|
|
#[test]
|
|
fn push_with_tags() {
|
|
let fx = Fixture::new();
|
|
let bare = fx._tmp.path().join("origin.git");
|
|
git_in(fx._tmp.path(), &["init", "-q", "--bare", bare.to_str().unwrap()]);
|
|
fx.write("a.txt", "1\n");
|
|
fx.commit_all("one");
|
|
fx.git(&["remote", "add", "origin", bare.to_str().unwrap()]);
|
|
fx.git(&["push", "-q", "-u", "origin", "main"]);
|
|
let repo = fx.repo();
|
|
let (cancel, out) = quiet();
|
|
repo.create_tag("light", "HEAD", None, false).unwrap();
|
|
fx.write("a.txt", "2\n");
|
|
fx.commit_all("two");
|
|
repo.create_tag("v2", "HEAD", Some("annotated"), false).unwrap();
|
|
|
|
repo.push_with_tags(false, false, Some(PushTags::CurrentBranch), &cancel, &out).unwrap();
|
|
let remote_tags = || git_in(&bare, &["tag", "--list"]);
|
|
assert_eq!(remote_tags(), "v2");
|
|
assert_eq!(git_in(&bare, &["rev-parse", "main"]), fx.git(&["rev-parse", "HEAD"]));
|
|
|
|
fx.write("a.txt", "3\n");
|
|
fx.commit_all("three");
|
|
repo.push_with_tags(false, false, Some(PushTags::All), &cancel, &out).unwrap();
|
|
assert_eq!(remote_tags(), "light\nv2");
|
|
assert_eq!(git_in(&bare, &["rev-parse", "main"]), fx.git(&["rev-parse", "HEAD"]));
|
|
}
|
|
|
|
#[test]
|
|
fn push_goes_to_an_upstream_with_another_name() {
|
|
let fx = Fixture::new();
|
|
let bare = fx._tmp.path().join("origin.git");
|
|
git_in(fx._tmp.path(), &["init", "-q", "--bare", bare.to_str().unwrap()]);
|
|
fx.write("a.txt", "1\n");
|
|
fx.commit_all("one");
|
|
fx.git(&["remote", "add", "origin", bare.to_str().unwrap()]);
|
|
fx.git(&["push", "-q", "origin", "main:release/one"]);
|
|
fx.git(&["fetch", "-q", "origin"]);
|
|
fx.git(&["checkout", "-q", "-b", "local-name", "--track", "origin/release/one"]);
|
|
fx.write("a.txt", "2\n");
|
|
let c2 = fx.commit_all("two");
|
|
let repo = fx.repo();
|
|
let (cancel, out) = quiet();
|
|
|
|
repo.push(false, false, &cancel, &out).unwrap();
|
|
assert_eq!(git_in(&bare, &["rev-parse", "release/one"]), c2);
|
|
assert_eq!(git_in(&bare, &["branch", "--list", "local-name"]), "");
|
|
|
|
fx.write("a.txt", "3\n");
|
|
let c3 = fx.commit_all("three");
|
|
repo.push_with_tags(false, false, Some(ide_vcs::PushTags::CurrentBranch), &cancel, &out).unwrap();
|
|
assert_eq!(git_in(&bare, &["rev-parse", "release/one"]), c3);
|
|
}
|
|
|
|
#[test]
|
|
fn push_branch_and_up_to_revision() {
|
|
let fx = Fixture::new();
|
|
let bare = fx._tmp.path().join("origin.git");
|
|
git_in(fx._tmp.path(), &["init", "-q", "--bare", bare.to_str().unwrap()]);
|
|
fx.write("a.txt", "1\n");
|
|
fx.commit_all("one");
|
|
fx.git(&["remote", "add", "origin", bare.to_str().unwrap()]);
|
|
fx.git(&["push", "-q", "-u", "origin", "main"]);
|
|
let c2 = {
|
|
fx.write("a.txt", "2\n");
|
|
fx.commit_all("two")
|
|
};
|
|
fx.write("a.txt", "3\n");
|
|
let c3 = fx.commit_all("three");
|
|
let repo = fx.repo();
|
|
let (cancel, out) = quiet();
|
|
|
|
repo.push_branch("main", Some(&c2), false, &cancel, &out).unwrap();
|
|
assert_eq!(git_in(&bare, &["rev-parse", "main"]), c2);
|
|
repo.push_branch("main", None, false, &cancel, &out).unwrap();
|
|
assert_eq!(git_in(&bare, &["rev-parse", "main"]), c3);
|
|
|
|
// A branch other than HEAD, without upstream: pushed under its name and tracked.
|
|
fx.git(&["branch", "side", &c2]);
|
|
repo.push_branch("side", None, false, &cancel, &out).unwrap();
|
|
assert_eq!(git_in(&bare, &["rev-parse", "side"]), c2);
|
|
assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "side@{upstream}"]), "origin/side");
|
|
assert!(repo.push_branch("nope", None, false, &cancel, &out).is_err());
|
|
|
|
// An edited target: same remote, another branch name; the upstream stays as it was.
|
|
fn opts<'a>(target: Option<&'a str>, remote: Option<&'a str>) -> PushBranchOptions<'a> {
|
|
PushBranchOptions { target, remote, ..Default::default() }
|
|
}
|
|
repo.push_branch_to("main", opts(Some("review/main"), None), &cancel, &out).unwrap();
|
|
assert_eq!(git_in(&bare, &["rev-parse", "review/main"]), c3);
|
|
assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "main@{upstream}"]), "origin/main");
|
|
assert!(repo.push_branch_to("main", opts(Some("bad..name"), None), &cancel, &out).is_err());
|
|
|
|
// A chosen remote: same branch name, pushed to the other remote.
|
|
let bare2 = fx._tmp.path().join("backup.git");
|
|
git_in(fx._tmp.path(), &["init", "-q", "--bare", bare2.to_str().unwrap()]);
|
|
fx.git(&["remote", "add", "backup", bare2.to_str().unwrap()]);
|
|
repo.push_branch_to("main", opts(None, Some("backup")), &cancel, &out).unwrap();
|
|
assert_eq!(git_in(&bare2, &["rev-parse", "main"]), c3);
|
|
assert_eq!(fx.git(&["rev-parse", "--abbrev-ref", "main@{upstream}"]), "origin/main");
|
|
}
|
|
|
|
#[test]
|
|
fn update_branch_fast_forwards_a_branch_not_checked_out() {
|
|
let fx = Fixture::new();
|
|
let bare = fx._tmp.path().join("origin.git");
|
|
git_in(fx._tmp.path(), &["init", "-q", "--bare", bare.to_str().unwrap()]);
|
|
fx.write("a.txt", "1\n");
|
|
fx.commit_all("one");
|
|
fx.git(&["remote", "add", "origin", bare.to_str().unwrap()]);
|
|
fx.git(&["push", "-q", "-u", "origin", "main"]);
|
|
fx.git(&["checkout", "-q", "-b", "work"]);
|
|
// Someone else advances origin/main.
|
|
let other = fx._tmp.path().join("other");
|
|
git_in(fx._tmp.path(), &["clone", "-q", bare.to_str().unwrap(), other.to_str().unwrap()]);
|
|
std::fs::write(other.join("a.txt"), "2\n").unwrap();
|
|
git_in(&other, &["-c", "user.name=O", "-c", "user.email=o@x", "commit", "-q", "-am", "two"]);
|
|
git_in(&other, &["push", "-q", "origin", "main"]);
|
|
let tip = git_in(&other, &["rev-parse", "HEAD"]);
|
|
|
|
let repo = fx.repo();
|
|
let (cancel, out) = quiet();
|
|
repo.update_branch("main", &cancel, &out).unwrap();
|
|
assert_eq!(fx.git(&["rev-parse", "main"]), tip);
|
|
assert_eq!(fx.git(&["rev-parse", "origin/main"]), tip);
|
|
// The checked-out branch and branches without upstream are refused.
|
|
assert!(repo.update_branch("work", &cancel, &out).is_err());
|
|
}
|
|
|
|
/// `"` and tabs are illegal in Windows file names, and only they make Git quote a path.
|
|
const QUOTED: &str = if cfg!(windows) { "quote '.txt" } else { "quote\"\t.txt" };
|
|
|
|
#[test]
|
|
fn shelf_diff_and_scoped_operations_preserve_siblings() {
|
|
let fx = Fixture::new();
|
|
for path in ["a.txt", "b.txt", QUOTED, "gone.txt"] {
|
|
fx.write(path, "base\n");
|
|
}
|
|
fx.commit_all("base");
|
|
for path in ["a.txt", "b.txt", QUOTED] {
|
|
fx.write(path, "shelved\n");
|
|
}
|
|
std::fs::remove_file(fx.path("gone.txt")).unwrap();
|
|
fx.write("new.txt", "new\n");
|
|
std::fs::write(fx.path("binary.dat"), [0, 1, 255]).unwrap();
|
|
let repo = fx.repo();
|
|
let shelf = fx._tmp.path().join("shelf");
|
|
let paths = ["a.txt", "b.txt", QUOTED, "gone.txt", "new.txt", "binary.dat"].map(|p| fx.path(p));
|
|
let entry = repo.shelve(&shelf, "work", &paths).unwrap();
|
|
fx.write("a.txt", "new head\n");
|
|
fx.commit_all("advance head");
|
|
fx.write("a.txt", "local edits\n");
|
|
let status = fx.git(&["status", "--porcelain"]);
|
|
let index = fx.git(&["ls-files", "--stage"]);
|
|
let diffs = shelf::diff_files(&repo, &shelf, &entry.id).unwrap();
|
|
for path in ["a.txt", "b.txt", QUOTED] {
|
|
let f = diffs.iter().find(|f| f.path == path).unwrap();
|
|
assert_eq!(f.before.as_deref(), Some("base\n"), "{path}");
|
|
assert_eq!(f.after.as_deref(), Some("shelved\n"), "{path}");
|
|
}
|
|
assert_eq!(diffs.iter().find(|f| f.path == "new.txt").unwrap().before.as_deref(), Some(""));
|
|
assert_eq!(diffs.iter().find(|f| f.path == "gone.txt").unwrap().after.as_deref(), Some(""));
|
|
assert!(diffs.iter().find(|f| f.path == "binary.dat").unwrap().before.is_none());
|
|
assert_eq!(fx.git(&["status", "--porcelain"]), status);
|
|
assert_eq!(fx.git(&["ls-files", "--stage"]), index);
|
|
assert_eq!(fx.read("a.txt"), "local edits\n");
|
|
let patch = shelf::selected_patch(&shelf, &entry.id, Some("b.txt")).unwrap();
|
|
assert!(patch.contains("b/b.txt"));
|
|
assert!(!patch.contains("b/a.txt"));
|
|
assert!(shelf::selected_patch(&shelf, &entry.id, Some("missing")).is_err());
|
|
shelf::unshelve_path(&repo, &shelf, &entry.id, true, Some("b.txt")).unwrap();
|
|
assert_eq!(fx.read("b.txt"), "shelved\n");
|
|
assert!(!shelf::list(&shelf).unwrap()[0].files.iter().any(|f| f.path == "b.txt"));
|
|
shelf::delete_path(&shelf, &entry.id, Some(QUOTED)).unwrap();
|
|
assert!(shelf::selected_patch(&shelf, &entry.id, Some("a.txt")).is_ok());
|
|
shelf::import_patch(&shelf, "imported", &patch, None).unwrap();
|
|
assert_eq!(shelf::list(&shelf).unwrap().len(), 2);
|
|
assert_eq!(fx.read("a.txt"), "local edits\n");
|
|
}
|
|
|
|
#[test]
|
|
fn shelf_imported_rename_diff_and_last_file_delete() {
|
|
let fx = Fixture::new();
|
|
fx.write("old.txt", "same\n");
|
|
fx.commit_all("base");
|
|
fx.git(&["mv", "old.txt", "new.txt"]);
|
|
let patch = fx.git(&["diff", "--cached", "--binary", "--full-index", "-M"]) + "\n";
|
|
fx.git(&["reset", "--hard", "HEAD"]);
|
|
let repo = fx.repo();
|
|
let shelf = fx._tmp.path().join("shelf");
|
|
shelf::import_patch(&shelf, "rename", &patch, None).unwrap();
|
|
let id = &shelf::list(&shelf).unwrap()[0].id;
|
|
let diffs = shelf::diff_files(&repo, &shelf, id).unwrap();
|
|
assert_eq!(diffs.len(), 1);
|
|
assert_eq!(diffs[0].path, "new.txt");
|
|
// A rename-only imported patch has no embedded blob ids: do not guess from HEAD.
|
|
assert!(diffs[0].before.is_none());
|
|
assert!(diffs[0].patch.contains("rename from old.txt"));
|
|
shelf::delete_path(&shelf, id, Some("new.txt")).unwrap();
|
|
assert!(shelf::list(&shelf).unwrap().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn shelf_new_renames_keep_self_contained_sides_and_plain_imports_keep_scope() {
|
|
let fx = Fixture::new();
|
|
fx.write("old.txt", "renamed content\n");
|
|
fx.commit_all("base");
|
|
fx.git(&["mv", "old.txt", "new.txt"]);
|
|
let repo = fx.repo();
|
|
let shelf = fx._tmp.path().join("shelf");
|
|
let entry = repo.shelve(&shelf, "rename", &[fx.path("old.txt"), fx.path("new.txt")]).unwrap();
|
|
let diffs = shelf::diff_files(&repo, &shelf, &entry.id).unwrap();
|
|
assert_eq!(diffs.len(), 2);
|
|
assert_eq!(diffs.iter().find(|f| f.path == "old.txt").unwrap().before.as_deref(), Some("renamed content\n"));
|
|
assert_eq!(diffs.iter().find(|f| f.path == "new.txt").unwrap().after.as_deref(), Some("renamed content\n"));
|
|
let plain = "--- a/one.txt\n+++ b/one.txt\n@@ -1 +1 @@\n--- removed\n+++ added\n--- a/two.txt\n+++ b/two.txt\n@@ -1 +1 @@\n-old\n+new\n";
|
|
shelf::import_patch(&shelf, "plain", plain, None).unwrap();
|
|
let selected = shelf::selected_patch(&shelf, "plain", Some("one.txt")).unwrap();
|
|
assert!(selected.contains("--- removed\n+++ added"));
|
|
assert!(!selected.contains("two.txt"));
|
|
shelf::delete_path(&shelf, "plain", Some("one.txt")).unwrap();
|
|
assert_eq!(shelf::list(&shelf).unwrap().iter().find(|s| s.id == "plain").unwrap().files[0].path, "two.txt");
|
|
}
|
|
|
|
#[test]
|
|
fn cherry_pick_skips_commits_already_applied() {
|
|
let fx = Fixture::new();
|
|
fx.write("a.txt", "1\n");
|
|
fx.commit_all("base");
|
|
fx.git(&["checkout", "-q", "-b", "side"]);
|
|
fx.write("a.txt", "2\n");
|
|
let same = fx.commit_all("same change");
|
|
fx.write("b.txt", "b\n");
|
|
let other = fx.commit_all("other change");
|
|
fx.git(&["checkout", "-q", "main"]);
|
|
// main already has the first change.
|
|
fx.write("a.txt", "2\n");
|
|
fx.commit_all("same change, made on main");
|
|
let repo = fx.repo();
|
|
let cancel = CancellationToken::new();
|
|
let lines = std::sync::Mutex::new(Vec::new());
|
|
let out = |l: String| lines.lock().unwrap().push(l);
|
|
|
|
repo.cherry_pick_with(&[same.clone(), other], true, &cancel, &out).unwrap();
|
|
assert_eq!(repo.branch_info().unwrap().operation, None, "nothing left in progress");
|
|
assert_eq!(fx.git(&["log", "--format=%s", "-2"]), "other change\nsame change, made on main");
|
|
assert!(fx.git(&["log", "-1", "--format=%B"]).contains("(cherry picked from commit"));
|
|
assert!(lines.lock().unwrap().iter().any(|l| l.starts_with(&same[..8]) && l.contains("skipped")));
|
|
}
|
|
|
|
#[test]
|
|
fn revert_of_a_commit_already_reverted_is_skipped() {
|
|
let fx = Fixture::new();
|
|
fx.write("a.txt", "1\n");
|
|
fx.commit_all("one");
|
|
fx.write("a.txt", "2\n");
|
|
let two = fx.commit_all("two");
|
|
fx.write("a.txt", "1\n");
|
|
fx.commit_all("undo two by hand");
|
|
let repo = fx.repo();
|
|
let (cancel, out) = quiet();
|
|
repo.revert(&[two], &cancel, &out).unwrap();
|
|
assert_eq!(repo.branch_info().unwrap().operation, None);
|
|
assert_eq!(fx.git(&["rev-list", "--count", "HEAD"]), "3", "no empty revert commit");
|
|
}
|