mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-08 08:52:12 +00:00
Root cause: shelved file paths are relative to the repository they were shelved from, but Compare with Local, Show Diff, navigation and Unshelve joined them to the selected VCS root. In a project folder that is not itself a repository (or holds several), the local file was not found and Compare with Local stayed disabled. Shelves now record their repository (older shelves fall back to the primary repository), imported patches record the current one, and every shelf action resolves paths against it, with Windows separators handled. Stash and Apply Patch already kept their repository. Fixes #875 Co-Authored-By: Claude Opus 5.5 <[email protected]>
665 lines
28 KiB
Rust
665 lines
28 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_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");
|
|
}
|