Ely Office: six offline-first desktop apps in Rust and GPUI
ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
This commit is contained in:
817 files changed
+143101
No files matched your search
@@ -0,0 +1,227 @@
|
||||
//! Commit box, history refresh and settings, windowed (C13.3).
|
||||
|
||||
use gpui::{App, Entity, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::{
|
||||
committed, sh,
|
||||
window::{branch, settle, window},
|
||||
};
|
||||
use crate::tree::Tree;
|
||||
|
||||
pub(crate) fn subject(tree: &Entity<Tree>, cx: &mut VisualTestContext) -> String {
|
||||
cx.update(|_, app| tree.read(app).subject.read(app).text().to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn type_subject(tree: &Entity<Tree>, cx: &mut VisualTestContext, words: &str) {
|
||||
let words = words.to_string();
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.subject
|
||||
.update(cx, |field, cx| field.set_text(words, cx))
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Two repositories, each with its own identity set.
|
||||
pub(crate) fn two(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
|
||||
let (a, b) = (committed(&dir.join("a")), committed(&dir.join("b")));
|
||||
for root in [&a, &b] {
|
||||
for (key, value) in [
|
||||
("user.name", "Ely"),
|
||||
("user.email", "ely@example.com"),
|
||||
("commit.gpgsign", "false"),
|
||||
] {
|
||||
sh(root, &["config", key, value]);
|
||||
}
|
||||
}
|
||||
(a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_repository_keeps_its_own_commit_box() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (a, b) = two(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
for (path, id) in [(a.clone(), 1), (b.clone(), 2)] {
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(path, cx)));
|
||||
settle(cx, |app| tree.read(app).active == Some(id));
|
||||
}
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.activate(Some(1), cx)));
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.set_amend(true, cx)));
|
||||
settle(cx, |app| {
|
||||
tree.read(app).amend && !tree.read(app).amend_loading
|
||||
});
|
||||
assert_eq!(subject(&tree, cx), "first", "a's last message");
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.activate(Some(2), cx)));
|
||||
assert!(
|
||||
!cx.update(|_, app| tree.read(app).amend),
|
||||
"b does not amend"
|
||||
);
|
||||
assert_eq!(subject(&tree, cx), "");
|
||||
type_subject(&tree, cx, "b's draft");
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.activate(Some(1), cx)));
|
||||
assert!(cx.update(|_, app| tree.read(app).amend));
|
||||
assert_eq!(subject(&tree, cx), "first");
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.activate(Some(2), cx)));
|
||||
assert_eq!(subject(&tree, cx), "b's draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unticking_amend_voids_a_read_on_its_way() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (a, _) = two(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(a.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
type_subject(&tree, cx, "mine");
|
||||
// Two clicks on the box, as the control sends them.
|
||||
for _ in 0..2 {
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
let on = !tree.amend;
|
||||
tree.set_amend(on, cx)
|
||||
})
|
||||
});
|
||||
}
|
||||
for _ in 0..20 {
|
||||
settle(cx, |_| true);
|
||||
}
|
||||
assert!(!cx.update(|_, app| tree.read(app).amend));
|
||||
assert_eq!(subject(&tree, cx), "mine");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn typing_during_a_commit_is_kept() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (a, _) = two(dir.path());
|
||||
std::fs::write(a.join("a.txt"), "changed\n").expect("writes");
|
||||
sh(&a, &["add", "a.txt"]);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(a.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
type_subject(&tree, cx, "submitted");
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.commit(cx);
|
||||
tree.subject
|
||||
.update(cx, |field, cx| field.set_text("next draft".to_string(), cx));
|
||||
})
|
||||
});
|
||||
settle(cx, |_| {
|
||||
sh(&a, &["log", "-1", "--format=%s"]).trim() == "submitted"
|
||||
});
|
||||
for _ in 0..5 {
|
||||
settle(cx, |_| true);
|
||||
}
|
||||
assert_eq!(subject(&tree, cx), "next draft");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_the_latest_blame_takes_its_read() {
|
||||
use crate::look::Look;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (a, b) = two(dir.path());
|
||||
std::fs::write(b.join("a.txt"), "other\n").expect("writes");
|
||||
sh(&b, &["commit", "-qam", "b's own"]);
|
||||
let b_head = sh(&b, &["rev-parse", "HEAD"]).trim().to_string();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
for (path, id) in [(a.clone(), 1), (b.clone(), 2)] {
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(path, cx)));
|
||||
settle(cx, |app| tree.read(app).active == Some(id));
|
||||
}
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.activate(Some(1), cx);
|
||||
tree.blame("a.txt", cx);
|
||||
tree.activate(Some(2), cx);
|
||||
tree.blame("a.txt", cx);
|
||||
})
|
||||
});
|
||||
let owner = |app: &App| match &tree.read(app).look {
|
||||
Some(Look::Blame {
|
||||
read: Some(Ok(read)),
|
||||
..
|
||||
}) => Some(read.owners[read.lines[0]].commit.clone()),
|
||||
_ => None,
|
||||
};
|
||||
settle(cx, |app| owner(app).is_some());
|
||||
for _ in 0..10 {
|
||||
settle(cx, |_| true);
|
||||
}
|
||||
assert_eq!(cx.update(|_, app| owner(app)), Some(b_head));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_catches_up_and_shows_ref_errors() {
|
||||
use crate::tree::Side;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (a, _) = two(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(a.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
let rows = |app: &App| {
|
||||
tree.read(app)
|
||||
.active()
|
||||
.map_or(0, |opened| opened.hist.rows.len())
|
||||
};
|
||||
// A refresh asked mid-read is held, then run.
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.set_side(Side::History, cx);
|
||||
tree.refresh_log(1, cx);
|
||||
})
|
||||
});
|
||||
std::fs::write(a.join("a.txt"), "outside\n").expect("writes");
|
||||
sh(&a, &["commit", "-qam", "outside"]);
|
||||
settle(cx, |app| rows(app) == 2);
|
||||
let error = |app: &App| {
|
||||
tree.read(app)
|
||||
.active()
|
||||
.and_then(|opened| opened.hist.error.clone())
|
||||
};
|
||||
std::fs::write(a.join(".git/refs/heads/broken"), "broken-ref\n").expect("writes");
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.refresh_log(1, cx)));
|
||||
settle(cx, |app| {
|
||||
error(app).is_some_and(|error| error.contains("broken"))
|
||||
});
|
||||
std::fs::remove_file(a.join(".git/refs/heads/broken")).expect("removes");
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.refresh_log(1, cx)));
|
||||
settle(cx, |app| error(app).is_none() && rows(app) == 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_follows_outside_settings_and_names_its_fields() {
|
||||
use crate::asks::Ask;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (a, _) = two(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(a.clone(), cx)));
|
||||
let name = |app: &App| match tree.read(app).active().and_then(|opened| opened.me.clone()) {
|
||||
Some(Ok(me)) => me.name,
|
||||
_ => None,
|
||||
};
|
||||
settle(cx, |app| name(app).as_deref() == Some("Ely"));
|
||||
sh(&a, &["config", "user.name", "Changed Identity"]);
|
||||
// Any outside change rereads the settings too.
|
||||
std::fs::write(a.join("poke.txt"), "x\n").expect("writes");
|
||||
settle(cx, |app| name(app).as_deref() == Some("Changed Identity"));
|
||||
cx.update(|window, app| tree.update(app, |tree, cx| tree.ask_identity(window, cx)));
|
||||
let labels = cx.update(|_, app| match &tree.read(app).ask {
|
||||
Some(Ask::Identity { name, email, .. }) => (
|
||||
name.read(app).label_text().to_string(),
|
||||
email.read(app).label_text().to_string(),
|
||||
),
|
||||
_ => (String::new(), String::new()),
|
||||
});
|
||||
let want = cx.update(|_, app| {
|
||||
let text = |key| crate::tree::text(app, key, &[]).to_string();
|
||||
(text("tree.identity.name"), text("tree.identity.email"))
|
||||
});
|
||||
assert_eq!(labels, want);
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
//! Branches, tags, operations under way, conflicts (C13.4).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::{committed, git, sh};
|
||||
use ely_gpui_component::git::{MARKER, RegionKind, conflicts};
|
||||
|
||||
use crate::{
|
||||
branches::{self, Kind},
|
||||
conflict::{self, Stages},
|
||||
ops::{self, Op},
|
||||
work::{self, Write},
|
||||
};
|
||||
|
||||
/// A merge of `side` that conflicts on a.txt.
|
||||
pub(crate) fn conflicted(dir: &Path) -> (std::path::PathBuf, String) {
|
||||
let root = committed(dir);
|
||||
for (key, value) in [
|
||||
("user.name", "Ely"),
|
||||
("user.email", "ely@example.com"),
|
||||
("commit.gpgsign", "false"),
|
||||
] {
|
||||
sh(&root, &["config", key, value]);
|
||||
}
|
||||
let main = sh(&root, &["branch", "--show-current"]).trim().to_string();
|
||||
sh(&root, &["checkout", "-q", "-b", "side"]);
|
||||
std::fs::write(root.join("a.txt"), "one\nside\n").expect("writes");
|
||||
sh(&root, &["commit", "-qam", "side change"]);
|
||||
sh(&root, &["checkout", "-q", &main]);
|
||||
std::fs::write(root.join("a.txt"), "one\nmain\n").expect("writes");
|
||||
sh(&root, &["commit", "-qam", "main change"]);
|
||||
(root, main)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refs_read_branches_remotes_and_both_kinds_of_tag() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let source = committed(&dir.path().join("source"));
|
||||
let into = dir.path().join("copy");
|
||||
git()
|
||||
.clone_repo(source.to_str().expect("utf-8"), &into)
|
||||
.expect("starts")
|
||||
.follow(|_| {})
|
||||
.expect("clones");
|
||||
sh(&into, &["branch", "topic"]);
|
||||
sh(&into, &["tag", "light"]);
|
||||
sh(&into, &["tag", "-a", "heavy", "-m", "Release notes"]);
|
||||
std::fs::write(into.join("a.txt"), "ahead\n").expect("writes");
|
||||
sh(&into, &["commit", "-qam", "ahead"]);
|
||||
let refs = branches::read(&git(), &into).expect("reads");
|
||||
let find = |name: &str| {
|
||||
refs.iter()
|
||||
.find(|r| r.name == name)
|
||||
.unwrap_or_else(|| panic!("{name} in {refs:?}"))
|
||||
};
|
||||
let main = sh(&into, &["branch", "--show-current"]).trim().to_string();
|
||||
assert!(find(&main).current && find(&main).kind == Kind::Local);
|
||||
assert_eq!(find(&main).track, Some((1, 0)), "one ahead of its upstream");
|
||||
assert_eq!(find(&format!("origin/{main}")).kind, Kind::Remote);
|
||||
assert!(
|
||||
!refs
|
||||
.iter()
|
||||
.any(|r| r.name == "origin/HEAD" || r.name == "origin"),
|
||||
"origin/HEAD is no branch"
|
||||
);
|
||||
assert_eq!(find("light").message, None);
|
||||
let heavy = find("heavy");
|
||||
assert_eq!(
|
||||
(heavy.message.as_deref(), heavy.subject.as_str()),
|
||||
(Some("Release notes"), "first")
|
||||
);
|
||||
assert_eq!(
|
||||
heavy.commit,
|
||||
find("light").commit,
|
||||
"an annotated tag names its commit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operations_under_way_name_their_sides() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (root, main) = conflicted(dir.path());
|
||||
let doing = |root: &Path| ops::doing(&git(), root, &root.join(".git")).expect("reads");
|
||||
assert_eq!(doing(&root), None);
|
||||
assert!(git().run(&root, &["merge", "side"]).is_err());
|
||||
let merge = doing(&root).expect("a merge");
|
||||
assert_eq!(merge.op, Op::Merge);
|
||||
assert_eq!(merge.ours, main);
|
||||
assert!(merge.theirs.ends_with("side change"), "{}", merge.theirs);
|
||||
sh(&root, &["merge", "--abort"]);
|
||||
sh(&root, &["checkout", "-q", "side"]);
|
||||
assert!(git().run(&root, &["rebase", &main]).is_err());
|
||||
let rebase = doing(&root).expect("a rebase");
|
||||
assert_eq!(rebase.op, Op::Rebase);
|
||||
assert!(
|
||||
rebase.ours.ends_with("main change") && rebase.theirs.ends_with("side change"),
|
||||
"{rebase:?}"
|
||||
);
|
||||
sh(&root, &["rebase", "--abort"]);
|
||||
let side = sh(&root, &["rev-parse", "side"]).trim().to_string();
|
||||
sh(&root, &["checkout", "-q", &main]);
|
||||
assert!(git().run(&root, &["cherry-pick", &side]).is_err());
|
||||
assert_eq!(doing(&root).expect("a pick").op, Op::CherryPick);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_conflict_reads_its_stages_and_gits_own_blocks() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (root, _) = conflicted(dir.path());
|
||||
assert!(git().run(&root, &["merge", "side"]).is_err());
|
||||
let Stages::Text {
|
||||
sides,
|
||||
work,
|
||||
regions,
|
||||
..
|
||||
} = conflict::read(&git(), &root, "a.txt").expect("reads")
|
||||
else {
|
||||
panic!("a text conflict")
|
||||
};
|
||||
assert_eq!(
|
||||
sides,
|
||||
[
|
||||
Some("one\ntwo\n".into()),
|
||||
Some("one\nmain\n".into()),
|
||||
Some("one\nside\n".into())
|
||||
]
|
||||
);
|
||||
assert!(work.contains("<<<<<<<"));
|
||||
let conflict = regions
|
||||
.iter()
|
||||
.find(|region| region.kind == RegionKind::Conflict)
|
||||
.expect("a block");
|
||||
assert_eq!(
|
||||
(conflict.ours.clone(), conflict.base.clone()),
|
||||
(vec!["main\n".to_string()], vec!["two\n".to_string()]),
|
||||
"base from Git's diff3"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn near_conflicts_stay_one_block_as_git_wrote_them() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
for (key, value) in [
|
||||
("user.name", "Ely"),
|
||||
("user.email", "ely@example.com"),
|
||||
("commit.gpgsign", "false"),
|
||||
] {
|
||||
sh(&root, &["config", key, value]);
|
||||
}
|
||||
std::fs::write(root.join("a.txt"), "first\na\nunchanged\nb\nlast\n").expect("writes");
|
||||
sh(&root, &["commit", "-qam", "base"]);
|
||||
let main = sh(&root, &["branch", "--show-current"]).trim().to_string();
|
||||
sh(&root, &["checkout", "-q", "-b", "side"]);
|
||||
std::fs::write(
|
||||
root.join("a.txt"),
|
||||
"first\nTHEIRS a\nunchanged\nTHEIRS b\nlast\n",
|
||||
)
|
||||
.expect("writes");
|
||||
sh(&root, &["commit", "-qam", "theirs"]);
|
||||
sh(&root, &["checkout", "-q", &main]);
|
||||
std::fs::write(
|
||||
root.join("a.txt"),
|
||||
"first\nOURS a\nunchanged\nOURS b\nlast\n",
|
||||
)
|
||||
.expect("writes");
|
||||
sh(&root, &["commit", "-qam", "ours"]);
|
||||
assert!(git().run(&root, &["merge", "side"]).is_err());
|
||||
let stages = conflict::read(&git(), &root, "a.txt").expect("reads");
|
||||
let merging = conflict::Merging::new(stages).expect("text");
|
||||
let markers = conflicts(&merging.saved, MARKER).len();
|
||||
assert_eq!(
|
||||
merging.takes.len(),
|
||||
markers,
|
||||
"the view's blocks are Git's blocks"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn links_and_bytes_conflict_whole_and_a_save_spares_a_link() {
|
||||
use std::os::unix::fs::symlink;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
for (key, value) in [
|
||||
("user.name", "Ely"),
|
||||
("user.email", "ely@example.com"),
|
||||
("commit.gpgsign", "false"),
|
||||
] {
|
||||
sh(&root, &["config", key, value]);
|
||||
}
|
||||
std::fs::write(root.join("target"), "keep target\n").expect("writes");
|
||||
symlink("base-target", root.join("link")).expect("links");
|
||||
std::fs::write(root.join("blob.bin"), b"\0base\xff").expect("writes");
|
||||
sh(&root, &["add", "-A"]);
|
||||
sh(&root, &["commit", "-qm", "base"]);
|
||||
let main = sh(&root, &["branch", "--show-current"]).trim().to_string();
|
||||
sh(&root, &["checkout", "-q", "-b", "side"]);
|
||||
std::fs::remove_file(root.join("link")).expect("removes");
|
||||
symlink("theirs-target", root.join("link")).expect("links");
|
||||
std::fs::write(root.join("blob.bin"), b"\0theirs\xff").expect("writes");
|
||||
sh(&root, &["commit", "-qam", "theirs"]);
|
||||
sh(&root, &["checkout", "-q", &main]);
|
||||
std::fs::remove_file(root.join("link")).expect("removes");
|
||||
symlink("target", root.join("link")).expect("links");
|
||||
std::fs::write(root.join("blob.bin"), b"\0ours\xff").expect("writes");
|
||||
sh(&root, &["commit", "-qam", "ours"]);
|
||||
assert!(git().run(&root, &["merge", "side"]).is_err());
|
||||
for path in ["link", "blob.bin"] {
|
||||
assert_eq!(
|
||||
conflict::read(&git(), &root, path).expect("reads"),
|
||||
Stages::Whole { present: [true; 3] },
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
let save = Write::Save {
|
||||
path: "link".into(),
|
||||
text: "overwritten".into(),
|
||||
};
|
||||
assert!(
|
||||
work::run(&git(), &root, &save, &save.args(true)).is_err(),
|
||||
"no save through a link"
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("target")).expect("reads"),
|
||||
"keep target\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_branch_and_a_tag_of_one_name_keep_their_own_names() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
sh(&root, &["branch", "topic"]);
|
||||
sh(&root, &["tag", "topic"]);
|
||||
let refs = branches::read(&git(), &root).expect("reads");
|
||||
let named: Vec<(&str, &str)> = refs
|
||||
.iter()
|
||||
.filter(|r| r.name == "topic")
|
||||
.map(|r| (r.name.as_str(), r.full.as_str()))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
named,
|
||||
[("topic", "refs/heads/topic"), ("topic", "refs/tags/topic")]
|
||||
);
|
||||
sh(&root, &["switch", "-q", "topic"]);
|
||||
assert_eq!(sh(&root, &["branch", "--show-current"]).trim(), "topic");
|
||||
}
|
||||
|
||||
/// Two branches that both change `a.txt` from `base` on.
|
||||
fn both_change(
|
||||
dir: &Path,
|
||||
base: &str,
|
||||
ours: &str,
|
||||
theirs: &str,
|
||||
setup: &[(&str, &str)],
|
||||
) -> std::path::PathBuf {
|
||||
let root = committed(dir);
|
||||
for (key, value) in [
|
||||
("user.name", "Ely"),
|
||||
("user.email", "ely@example.com"),
|
||||
("commit.gpgsign", "false"),
|
||||
]
|
||||
.iter()
|
||||
.chain(setup)
|
||||
{
|
||||
sh(&root, &["config", key, value]);
|
||||
}
|
||||
std::fs::write(root.join("a.txt"), base).expect("writes");
|
||||
sh(&root, &["add", "-A"]);
|
||||
sh(&root, &["commit", "-qm", "base"]);
|
||||
let main = sh(&root, &["branch", "--show-current"]).trim().to_string();
|
||||
sh(&root, &["checkout", "-q", "-b", "side"]);
|
||||
std::fs::write(root.join("a.txt"), theirs).expect("writes");
|
||||
sh(&root, &["commit", "-qam", "theirs"]);
|
||||
sh(&root, &["checkout", "-q", &main]);
|
||||
std::fs::write(root.join("a.txt"), ours).expect("writes");
|
||||
sh(&root, &["commit", "-qam", "ours"]);
|
||||
assert!(git().run(&root, &["merge", "side"]).is_err(), "a conflict");
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markers_follow_conflict_marker_size_and_keep_lookalike_lines() {
|
||||
for size in [5usize, 10] {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
std::fs::create_dir_all(dir.path().join("work")).expect("a folder");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(
|
||||
root.join(".gitattributes"),
|
||||
format!("a.txt conflict-marker-size={size}\n"),
|
||||
)
|
||||
.expect("writes");
|
||||
let root = {
|
||||
sh(&root, &["add", ".gitattributes"]);
|
||||
sh(&root, &["commit", "-qm", "attributes"]);
|
||||
root
|
||||
};
|
||||
for (key, value) in [
|
||||
("user.name", "Ely"),
|
||||
("user.email", "ely@example.com"),
|
||||
("commit.gpgsign", "false"),
|
||||
] {
|
||||
sh(&root, &["config", key, value]);
|
||||
}
|
||||
let main = sh(&root, &["branch", "--show-current"]).trim().to_string();
|
||||
sh(&root, &["checkout", "-q", "-b", "side"]);
|
||||
std::fs::write(
|
||||
root.join("a.txt"),
|
||||
"theirs-top\n======= literal\nTHEIRS-END\n",
|
||||
)
|
||||
.expect("writes");
|
||||
sh(&root, &["commit", "-qam", "theirs"]);
|
||||
sh(&root, &["checkout", "-q", &main]);
|
||||
std::fs::write(root.join("a.txt"), "ours\n").expect("writes");
|
||||
sh(&root, &["commit", "-qam", "ours"]);
|
||||
assert!(git().run(&root, &["merge", "side"]).is_err());
|
||||
let merging =
|
||||
conflict::Merging::new(conflict::read(&git(), &root, "a.txt").expect("reads"))
|
||||
.expect("text");
|
||||
assert_eq!(
|
||||
(merging.size(), merging.takes.len()),
|
||||
(size, 1),
|
||||
"width {size}"
|
||||
);
|
||||
let settled = ely_gpui_component::git::resolve(
|
||||
&merging.saved,
|
||||
0,
|
||||
ely_gpui_component::git::Take::Theirs,
|
||||
size,
|
||||
);
|
||||
assert_eq!(
|
||||
settled.as_deref(),
|
||||
Some("theirs-top\n======= literal\nTHEIRS-END\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nul_bytes_that_are_utf8_still_conflict_whole() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = both_change(
|
||||
dir.path(),
|
||||
"one\0base\n",
|
||||
"one\0ours\n",
|
||||
"one\0theirs\n",
|
||||
&[],
|
||||
);
|
||||
assert_eq!(
|
||||
conflict::read(&git(), &root, "a.txt").expect("reads"),
|
||||
Stages::Whole { present: [true; 3] }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_block_keeps_its_own_base() {
|
||||
let same: String = (1..=10).map(|n| format!("same {n}\n")).collect();
|
||||
let base = format!("base-first\n{same}");
|
||||
let ours = format!("OURS first\n{same}ours insert\n");
|
||||
let theirs = format!("THEIRS first\n{same}theirs insert\n");
|
||||
for style in ["merge", "diff3"] {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = both_change(
|
||||
dir.path(),
|
||||
&base,
|
||||
&ours,
|
||||
&theirs,
|
||||
&[("merge.conflictStyle", style)],
|
||||
);
|
||||
let Stages::Text { regions, .. } = conflict::read(&git(), &root, "a.txt").expect("reads")
|
||||
else {
|
||||
panic!("text")
|
||||
};
|
||||
let bases: Vec<Vec<String>> = regions
|
||||
.into_iter()
|
||||
.filter(|r| r.kind == RegionKind::Conflict)
|
||||
.map(|r| r.base)
|
||||
.collect();
|
||||
assert_eq!(
|
||||
bases,
|
||||
[vec!["base-first\n".to_string()], Vec::new()],
|
||||
"{style}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remote_branch_and_a_tag_of_one_name_switch_apart() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
sh(&root, &["update-ref", "refs/remotes/origin/topic", "HEAD"]);
|
||||
sh(&root, &["tag", "origin/topic"]);
|
||||
sh(&root, &["config", "remote.origin.url", "/nowhere"]);
|
||||
sh(
|
||||
&root,
|
||||
&[
|
||||
"config",
|
||||
"remote.origin.fetch",
|
||||
"+refs/heads/*:refs/remotes/origin/*",
|
||||
],
|
||||
);
|
||||
let write = Write::Git(vec![
|
||||
"switch".into(),
|
||||
"--track".into(),
|
||||
"refs/remotes/origin/topic".into(),
|
||||
]);
|
||||
work::run(&git(), &root, &write, &write.args(true)).expect("switches");
|
||||
assert_eq!(sh(&root, &["branch", "--show-current"]).trim(), "topic");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_sizes_git_cannot_read_mean_seven() {
|
||||
for value in ["0", "-1", "abc", "10abc"] {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = both_change(dir.path(), "base\n", "ours\n", "theirs\n", &[]);
|
||||
std::fs::write(
|
||||
root.join(".gitattributes"),
|
||||
format!("a.txt conflict-marker-size={value}\n"),
|
||||
)
|
||||
.expect("writes");
|
||||
let merging =
|
||||
conflict::Merging::new(conflict::read(&git(), &root, "a.txt").expect("reads"))
|
||||
.expect("text");
|
||||
assert_eq!(
|
||||
(merging.size(), merging.takes.len()),
|
||||
(MARKER, 1),
|
||||
"{value}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//! Cleaning untracked files: preview, then only what was shown (C13.6).
|
||||
|
||||
use gpui::TestAppContext;
|
||||
|
||||
use super::{
|
||||
committed, git,
|
||||
recovery::run,
|
||||
sh,
|
||||
window::{branch, settle, window},
|
||||
};
|
||||
use crate::{asks::Ask, recover, work::Write};
|
||||
|
||||
#[test]
|
||||
fn a_clean_removes_what_was_shown_and_nothing_new() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join(".gitignore"), "*.log\n").expect("writes");
|
||||
sh(&root, &["add", ".gitignore"]);
|
||||
sh(&root, &["commit", "-qm", "ignore logs"]);
|
||||
for name in ["tab\there.txt", "say \"hi\".txt", "中文.txt", "build.log"] {
|
||||
std::fs::write(root.join(name), "x").expect("writes");
|
||||
}
|
||||
std::fs::create_dir_all(root.join("out/deep")).expect("makes");
|
||||
std::fs::create_dir_all(root.join("empty")).expect("makes");
|
||||
std::fs::write(root.join("out/deep/f.txt"), "x").expect("writes");
|
||||
std::fs::write(root.join("out/deep/kept.log"), "x").expect("writes");
|
||||
let clean = recover::clean_preview(&git(), &root, false).expect("previews");
|
||||
let mut files = clean.files.clone();
|
||||
files.sort();
|
||||
assert_eq!(
|
||||
files,
|
||||
[
|
||||
"out/deep/f.txt",
|
||||
"say \"hi\".txt",
|
||||
"tab\there.txt",
|
||||
"中文.txt"
|
||||
]
|
||||
);
|
||||
// `out/` holds an ignored file, so Git names its files.
|
||||
assert_eq!(clean.dirs, ["empty/"]);
|
||||
let with = recover::clean_preview(&git(), &root, true).expect("previews");
|
||||
assert!(with.files.contains(&"build.log".to_string()));
|
||||
assert!(with.files.contains(&"out/deep/kept.log".to_string()));
|
||||
// Late files, at the top and inside a shown folder.
|
||||
std::fs::write(root.join("late.txt"), "x").expect("writes");
|
||||
std::fs::write(root.join("out/late.txt"), "x").expect("writes");
|
||||
let write = Write::Clean {
|
||||
files: clean.files,
|
||||
dirs: clean.dirs,
|
||||
ignored: false,
|
||||
};
|
||||
run(&root, write).expect("cleans");
|
||||
assert!(root.join("late.txt").exists() && root.join("out/late.txt").exists());
|
||||
assert!(root.join("build.log").exists() && root.join("out/deep/kept.log").exists());
|
||||
assert!(!root.join("out/deep/f.txt").exists() && !root.join("empty").exists());
|
||||
assert!(!root.join("tab\there.txt").exists() && !root.join("中文.txt").exists());
|
||||
assert!(!root.join("say \"hi\".txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_clean_dialog_previews_then_cleans() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("junk.txt"), "x").expect("writes");
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.ask_clean(cx)));
|
||||
settle(
|
||||
cx,
|
||||
|app| matches!(&tree.read(app).ask, Some(Ask::Clean { preview: Some(Ok(clean)), .. }) if clean.files == ["junk.txt"]),
|
||||
);
|
||||
std::fs::write(root.join("late.txt"), "x").expect("writes");
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
let Some(Ask::Clean {
|
||||
repo,
|
||||
ignored,
|
||||
preview: Some(Ok(clean)),
|
||||
}) = tree.ask.take()
|
||||
else {
|
||||
panic!("a preview");
|
||||
};
|
||||
let write = Write::Clean {
|
||||
files: clean.files,
|
||||
dirs: clean.dirs,
|
||||
ignored,
|
||||
};
|
||||
tree.write(repo, write, cx);
|
||||
})
|
||||
});
|
||||
settle(cx, |_| !root.join("junk.txt").exists());
|
||||
assert!(root.join("late.txt").exists());
|
||||
}
|
||||
|
||||
|
||||
#[test]
|
||||
fn a_shown_file_now_a_folder_stops_the_clean() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::create_dir_all(root.join("out")).expect("makes");
|
||||
std::fs::write(root.join("out/early.txt"), "x").expect("writes");
|
||||
std::fs::write(root.join("top.txt"), "x").expect("writes");
|
||||
let clean = recover::clean_preview(&git(), &root, false).expect("previews");
|
||||
std::fs::remove_file(root.join("out/early.txt")).expect("removes");
|
||||
std::fs::create_dir_all(root.join("out/early.txt")).expect("makes");
|
||||
std::fs::write(root.join("out/early.txt/late.txt"), "x").expect("writes");
|
||||
let write = Write::Clean {
|
||||
files: clean.files,
|
||||
dirs: clean.dirs,
|
||||
ignored: false,
|
||||
};
|
||||
let refused = run(&root, write).expect_err("a folder now");
|
||||
assert!(refused.to_string().contains("changed"), "{refused}");
|
||||
assert!(root.join("out/early.txt/late.txt").exists() && root.join("top.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shown_file_staged_since_is_kept() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("new.txt"), "first\n").expect("writes");
|
||||
let clean = recover::clean_preview(&git(), &root, false).expect("previews");
|
||||
sh(&root, &["add", "new.txt"]);
|
||||
std::fs::write(root.join("new.txt"), "edited\n").expect("writes");
|
||||
let write = Write::Clean {
|
||||
files: clean.files,
|
||||
dirs: clean.dirs,
|
||||
ignored: false,
|
||||
};
|
||||
run(&root, write).expect("runs");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("new.txt")).expect("kept"),
|
||||
"edited\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_folder_turned_link_stops_the_clean() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::create_dir_all(root.join("out")).expect("makes");
|
||||
std::fs::write(root.join("out/early.txt"), "x").expect("writes");
|
||||
let clean = recover::clean_preview(&git(), &root, false).expect("previews");
|
||||
let outside = dir.path().join("outside");
|
||||
std::fs::create_dir_all(&outside).expect("makes");
|
||||
std::fs::write(outside.join("early.txt"), "mine").expect("writes");
|
||||
std::fs::rename(root.join("out"), root.join("saved-out")).expect("moves");
|
||||
std::os::unix::fs::symlink(&outside, root.join("out")).expect("links");
|
||||
let write = Write::Clean {
|
||||
files: clean.files,
|
||||
dirs: clean.dirs,
|
||||
ignored: false,
|
||||
};
|
||||
assert!(run(&root, write).is_err(), "a link in the path");
|
||||
assert!(outside.join("early.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn twin_reflog_events_stay_apart() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
for _ in 0..2 {
|
||||
let reset = std::process::Command::new(&git().path)
|
||||
.args(["reset", "-q", "--keep", "HEAD"])
|
||||
.current_dir(&root)
|
||||
.env("GIT_COMMITTER_DATE", "2030-01-01T00:00:00Z")
|
||||
.status()
|
||||
.expect("runs");
|
||||
assert!(reset.success());
|
||||
}
|
||||
let moves = recover::reflog(&git(), &root).expect("reads");
|
||||
assert_eq!((moves[0].at, &moves[0].said), (moves[1].at, &moves[1].said));
|
||||
assert!(!moves[0].same(&moves[1]), "two events, two identities");
|
||||
let picked = moves[1].clone();
|
||||
sh(&root, &["checkout", "-q", "--detach", "HEAD"]);
|
||||
let again = recover::reflog(&git(), &root).expect("reads");
|
||||
let found: Vec<usize> = (0..again.len())
|
||||
.filter(|ix| again[*ix].same(&picked))
|
||||
.collect();
|
||||
assert_eq!(found, [2], "it moved one down, alone");
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
//! Diffs from Git, cut into hunk and line patches (C13.2).
|
||||
|
||||
use std::{collections::BTreeSet, path::Path, sync::Arc};
|
||||
|
||||
use ely_gpui_component::git::{ChangeSection, LineKind, Stretch};
|
||||
|
||||
use super::{committed, git, sh};
|
||||
use crate::{
|
||||
diff::{self, Body, Space},
|
||||
shown::{self, Found},
|
||||
status::{self, Entry},
|
||||
work::{self, Write},
|
||||
};
|
||||
|
||||
fn entry(root: &Path, path: &str) -> Entry {
|
||||
let read = status::read(&git(), root).expect("reads");
|
||||
read.entries
|
||||
.into_iter()
|
||||
.find(|entry| entry.path == path)
|
||||
.expect("listed")
|
||||
}
|
||||
|
||||
fn read(
|
||||
root: &Path,
|
||||
path: &str,
|
||||
section: ChangeSection,
|
||||
) -> (diff::FileDiff, Vec<u8>, Option<String>) {
|
||||
let born = status::read(&git(), root)
|
||||
.expect("reads")
|
||||
.head
|
||||
.oid
|
||||
.is_some();
|
||||
match shown::read(
|
||||
&git(),
|
||||
root,
|
||||
&entry(root, path),
|
||||
section,
|
||||
Space::Exact,
|
||||
born,
|
||||
)
|
||||
.expect("reads")
|
||||
{
|
||||
Found::Text { diff, seen, old } => (diff, seen, old),
|
||||
_ => panic!("a text diff"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies the chosen `(hunk, line)`s as the app does.
|
||||
fn apply(
|
||||
root: &Path,
|
||||
path: &str,
|
||||
section: ChangeSection,
|
||||
chosen: &[(usize, usize)],
|
||||
) -> std::io::Result<()> {
|
||||
let (diff, seen, _) = read(root, path, section);
|
||||
let reverse = section == ChangeSection::Staged;
|
||||
let chosen: BTreeSet<_> = chosen.iter().copied().collect();
|
||||
let write = Write::Patch {
|
||||
patch: diff::patch(&diff, &chosen, reverse).expect("a patch"),
|
||||
reverse,
|
||||
check: shown::args(&entry(root, path), section, Space::Exact),
|
||||
seen: Arc::new(seen),
|
||||
};
|
||||
work::run(&git(), root, &write, &write.args(true))
|
||||
}
|
||||
|
||||
fn index(root: &Path, path: &str) -> String {
|
||||
sh(root, &["cat-file", "blob", &format!(":{path}")])
|
||||
}
|
||||
|
||||
fn numbered(lines: std::ops::RangeInclusive<usize>) -> String {
|
||||
lines.map(|n| format!("{n}\n")).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_diff_reads_hunks_missing_newlines_and_binaries() {
|
||||
let out = b"diff --git a/a b/a\nindex 1..2 100644\n--- a/a\n+++ b/a\n@@ -1,2 +1,2 @@ fn x\n a\n-b\n\\ No newline at end of file\n+c\n\\ No newline at end of file\n";
|
||||
let read = diff::parse(out).expect("reads");
|
||||
let Body::Text(hunks) = &read.body else {
|
||||
panic!("text")
|
||||
};
|
||||
assert_eq!(
|
||||
(hunks[0].old, hunks[0].new, hunks[0].tail.as_str()),
|
||||
((1, 2), (1, 2), "fn x")
|
||||
);
|
||||
let shape: Vec<_> = hunks[0]
|
||||
.lines
|
||||
.iter()
|
||||
.map(|raw| (raw.kind, raw.text.as_str(), raw.newline))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shape,
|
||||
[
|
||||
(LineKind::Same, "a", true),
|
||||
(LineKind::Removed, "b", false),
|
||||
(LineKind::Added, "c", false)
|
||||
]
|
||||
);
|
||||
assert!(read.partial());
|
||||
let binary = diff::parse(b"diff --git a/p b/p\nindex 1..2\nBinary files a/p and b/p differ\n")
|
||||
.expect("reads");
|
||||
assert_eq!(binary.body, Body::Binary);
|
||||
assert_eq!(
|
||||
diff::parse(b"\xff\xfe").expect("reads").body,
|
||||
Body::Undecodable
|
||||
);
|
||||
assert!(diff::parse(b"@@ -1 +1 @@\n?what\n").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_hunk_stages_and_the_other_stays() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("a.txt"), numbered(1..=20)).expect("writes");
|
||||
sh(&root, &["commit", "-qam", "twenty"]);
|
||||
let word = |n: usize| match n {
|
||||
2 => "two\n".to_string(),
|
||||
18 => "eighteen\n".to_string(),
|
||||
n => format!("{n}\n"),
|
||||
};
|
||||
let changed: String = (1..=20).map(word).collect();
|
||||
std::fs::write(root.join("a.txt"), &changed).expect("writes");
|
||||
let (diff, _, old) = read(&root, "a.txt", ChangeSection::Unstaged);
|
||||
let (stretches, hunks) = shown::stretches(&diff, old.as_deref()).expect("stretches");
|
||||
assert_eq!(hunks, [Some(0), None, Some(1)], "two hunks, a fold between");
|
||||
let Stretch::Folded(between) = &stretches[1] else {
|
||||
panic!("a fold")
|
||||
};
|
||||
assert_eq!((between[0].old, between[0].new), (Some(6), Some(6)));
|
||||
// The second hunk's lines: three context, removed, added, ...
|
||||
apply(&root, "a.txt", ChangeSection::Unstaged, &[(1, 3), (1, 4)]).expect("stages");
|
||||
let only: String = (1..=20)
|
||||
.map(|n| {
|
||||
if n == 18 {
|
||||
"eighteen\n".into()
|
||||
} else {
|
||||
format!("{n}\n")
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(index(&root, "a.txt"), only);
|
||||
let (rest, _, _) = read(&root, "a.txt", ChangeSection::Unstaged);
|
||||
let Body::Text(rest) = rest.body else {
|
||||
panic!("text")
|
||||
};
|
||||
assert_eq!(rest.len(), 1, "the first hunk is still unstaged");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn single_lines_stage_and_unstage_as_picked() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("a.txt"), "one\nthree\nfour\n").expect("writes");
|
||||
// Hunk: one, -two, +three, +four.
|
||||
apply(&root, "a.txt", ChangeSection::Unstaged, &[(0, 3)]).expect("stages one added line");
|
||||
assert_eq!(
|
||||
index(&root, "a.txt"),
|
||||
"one\ntwo\nfour\n",
|
||||
"only four, two kept"
|
||||
);
|
||||
apply(&root, "a.txt", ChangeSection::Unstaged, &[(0, 1)]).expect("stages one removal");
|
||||
assert_eq!(index(&root, "a.txt"), "one\nfour\n");
|
||||
sh(&root, &["add", "a.txt"]);
|
||||
let (staged, _, _) = read(&root, "a.txt", ChangeSection::Staged);
|
||||
let Body::Text(hunks) = &staged.body else {
|
||||
panic!("text")
|
||||
};
|
||||
let three = hunks[0]
|
||||
.lines
|
||||
.iter()
|
||||
.position(|raw| raw.text == "three")
|
||||
.expect("three");
|
||||
apply(&root, "a.txt", ChangeSection::Staged, &[(0, three)]).expect("unstages one line");
|
||||
assert_eq!(index(&root, "a.txt"), "one\nfour\n", "three goes back out");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("a.txt")).expect("reads"),
|
||||
"one\nthree\nfour\n"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_last_line_without_newline_stages() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("a.txt"), "one\nend").expect("writes");
|
||||
sh(&root, &["commit", "-qam", "no newline"]);
|
||||
std::fs::write(root.join("a.txt"), "one\nfin").expect("writes");
|
||||
apply(&root, "a.txt", ChangeSection::Unstaged, &[(0, 1), (0, 2)]).expect("stages");
|
||||
assert_eq!(index(&root, "a.txt"), "one\nfin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_patch_from_an_older_diff_is_refused() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("a.txt"), "one\nTWO\n").expect("writes");
|
||||
let (diff, seen, _) = read(&root, "a.txt", ChangeSection::Unstaged);
|
||||
std::fs::write(root.join("a.txt"), "ONE\nTWO\n").expect("writes");
|
||||
let chosen: BTreeSet<_> = [(0, 1), (0, 2)].into();
|
||||
let write = Write::Patch {
|
||||
patch: diff::patch(&diff, &chosen, false).expect("a patch"),
|
||||
reverse: false,
|
||||
check: shown::args(
|
||||
&entry(&root, "a.txt"),
|
||||
ChangeSection::Unstaged,
|
||||
Space::Exact,
|
||||
),
|
||||
seen: Arc::new(seen),
|
||||
};
|
||||
let refused = work::run(&git(), &root, &write, &write.args(true)).expect_err("stale");
|
||||
assert!(refused.to_string().contains("changed since"), "{refused}");
|
||||
assert_eq!(
|
||||
index(&root, "a.txt"),
|
||||
"one\ntwo\n",
|
||||
"the index is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whole_files_pictures_and_binaries_read_as_such() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("new.txt"), "fresh\n").expect("writes");
|
||||
let (untracked, _, old) = read(&root, "new.txt", ChangeSection::Untracked);
|
||||
assert!(
|
||||
!untracked.partial() && old.is_none(),
|
||||
"a new file stages whole"
|
||||
);
|
||||
sh(&root, &["mv", "a.txt", "b.txt"]);
|
||||
let (renamed, _, _) = read(&root, "b.txt", ChangeSection::Staged);
|
||||
assert!(
|
||||
renamed
|
||||
.head
|
||||
.iter()
|
||||
.any(|line| line.starts_with("rename from a.txt"))
|
||||
&& !renamed.partial()
|
||||
);
|
||||
let png = b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR".to_vec();
|
||||
std::fs::write(root.join("p.png"), &png).expect("writes");
|
||||
std::fs::write(root.join("blob.bin"), b"\0\x01\x02").expect("writes");
|
||||
let born = true;
|
||||
let picture = shown::read(
|
||||
&git(),
|
||||
&root,
|
||||
&entry(&root, "p.png"),
|
||||
ChangeSection::Untracked,
|
||||
Space::Exact,
|
||||
born,
|
||||
)
|
||||
.expect("reads");
|
||||
assert!(matches!(picture, Found::Picture { old: None, new: Some(bytes), .. } if bytes == png));
|
||||
let binary = shown::read(
|
||||
&git(),
|
||||
&root,
|
||||
&entry(&root, "blob.bin"),
|
||||
ChangeSection::Untracked,
|
||||
Space::Exact,
|
||||
born,
|
||||
)
|
||||
.expect("reads");
|
||||
assert!(matches!(
|
||||
binary,
|
||||
Found::Binary {
|
||||
old: None,
|
||||
new: Some(3)
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignoring_whitespace_hides_a_space_only_change() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("a.txt"), "one \ntwo\n").expect("writes");
|
||||
let entry = entry(&root, "a.txt");
|
||||
let found = shown::read(
|
||||
&git(),
|
||||
&root,
|
||||
&entry,
|
||||
ChangeSection::Unstaged,
|
||||
Space::All,
|
||||
true,
|
||||
)
|
||||
.expect("reads");
|
||||
let Found::Text { diff, .. } = found else {
|
||||
panic!("text")
|
||||
};
|
||||
assert_eq!(diff.body, Body::Text(Vec::new()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_kept_last_line_gains_its_newline_when_a_line_follows() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("a.txt"), "one\ntwo").expect("writes");
|
||||
sh(&root, &["commit", "-qam", "no newline"]);
|
||||
std::fs::write(root.join("a.txt"), "one\nTWO\nmore\n").expect("writes");
|
||||
// one, -two, +TWO, +more: only +more.
|
||||
apply(&root, "a.txt", ChangeSection::Unstaged, &[(0, 3)]).expect("stages");
|
||||
assert_eq!(index(&root, "a.txt"), "one\ntwo\nmore\n");
|
||||
sh(&root, &["add", "a.txt"]);
|
||||
let (staged, _, _) = read(&root, "a.txt", ChangeSection::Staged);
|
||||
let Body::Text(hunks) = &staged.body else {
|
||||
panic!("text")
|
||||
};
|
||||
let more = hunks[0]
|
||||
.lines
|
||||
.iter()
|
||||
.position(|raw| raw.text == "more")
|
||||
.expect("more");
|
||||
apply(&root, "a.txt", ChangeSection::Staged, &[(0, more)]).expect("unstages");
|
||||
assert_eq!(index(&root, "a.txt"), "one\nTWO\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picked_bytes_go_in_whatever_apply_whitespace_says() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
sh(&root, &["config", "apply.whitespace", "fix"]);
|
||||
std::fs::write(root.join("a.txt"), "one\nTWO \n").expect("writes");
|
||||
apply(&root, "a.txt", ChangeSection::Unstaged, &[(0, 1), (0, 2)]).expect("stages");
|
||||
assert_eq!(index(&root, "a.txt"), "one\nTWO \n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bytes_that_are_not_utf8_outside_the_diff_show_by_size() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
let mut bytes = b"\xff\n".to_vec();
|
||||
bytes.extend(numbered(2..=30).into_bytes());
|
||||
std::fs::write(root.join("a.txt"), &bytes).expect("writes");
|
||||
sh(&root, &["commit", "-qam", "latin"]);
|
||||
bytes.extend(b"last\n");
|
||||
std::fs::write(root.join("a.txt"), &bytes).expect("writes");
|
||||
let found = shown::read(
|
||||
&git(),
|
||||
&root,
|
||||
&entry(&root, "a.txt"),
|
||||
ChangeSection::Unstaged,
|
||||
Space::Exact,
|
||||
true,
|
||||
)
|
||||
.expect("reads");
|
||||
assert!(matches!(found, Found::Binary { .. }), "not a text view");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blank_context_lines_read_whatever_the_config() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("a.txt"), "one\n\ntwo\n").expect("writes");
|
||||
sh(&root, &["commit", "-qam", "blank"]);
|
||||
sh(&root, &["config", "diff.suppressBlankEmpty", "true"]);
|
||||
std::fs::write(root.join("a.txt"), "one\n\nTWO\n").expect("writes");
|
||||
let (diff, _, _) = read(&root, "a.txt", ChangeSection::Unstaged);
|
||||
assert!(diff.partial());
|
||||
apply(&root, "a.txt", ChangeSection::Unstaged, &[(0, 2), (0, 3)]).expect("stages");
|
||||
assert_eq!(index(&root, "a.txt"), "one\n\nTWO\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_old_file_folds_nothing() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("a.txt"), "").expect("writes");
|
||||
sh(&root, &["commit", "-qam", "empty"]);
|
||||
std::fs::write(root.join("a.txt"), "hello\n").expect("writes");
|
||||
let (diff, _, old) = read(&root, "a.txt", ChangeSection::Unstaged);
|
||||
let (stretches, hunks) = shown::stretches(&diff, old.as_deref()).expect("stretches");
|
||||
assert_eq!(hunks, [Some(0)], "one hunk, no fold");
|
||||
let Stretch::Hunk { lines, .. } = &stretches[0] else {
|
||||
panic!("a hunk")
|
||||
};
|
||||
assert_eq!(lines.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_restored_last_line_keeps_its_newline_when_lines_follow() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("a.txt"), "one\ntwo").expect("writes");
|
||||
sh(&root, &["commit", "-qam", "no newline"]);
|
||||
std::fs::write(root.join("a.txt"), "one\nTWO\nmore\n").expect("writes");
|
||||
sh(&root, &["add", "a.txt"]);
|
||||
let (staged, _, _) = read(&root, "a.txt", ChangeSection::Staged);
|
||||
let Body::Text(hunks) = &staged.body else {
|
||||
panic!("text")
|
||||
};
|
||||
let two = hunks[0]
|
||||
.lines
|
||||
.iter()
|
||||
.position(|raw| raw.text == "two")
|
||||
.expect("two");
|
||||
apply(&root, "a.txt", ChangeSection::Staged, &[(0, two)]).expect("unstages");
|
||||
assert_eq!(index(&root, "a.txt"), "one\ntwo\nTWO\nmore\n");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_file_turned_into_a_link_reads_in_both_sections() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("other.txt"), "other\n").expect("writes");
|
||||
std::fs::remove_file(root.join("a.txt")).expect("removes");
|
||||
std::os::unix::fs::symlink("other.txt", root.join("a.txt")).expect("links");
|
||||
for section in [ChangeSection::Unstaged, ChangeSection::Staged] {
|
||||
if section == ChangeSection::Staged {
|
||||
sh(&root, &["add", "a.txt"]);
|
||||
}
|
||||
let (diff, _, old) = read(&root, "a.txt", section);
|
||||
assert!(!diff.partial(), "{section:?} stages whole");
|
||||
let (stretches, _) = shown::stretches(&diff, old.as_deref()).expect("stretches");
|
||||
// Every row, folds included, with its numbers.
|
||||
let rows: Vec<(String, Option<usize>, Option<usize>)> = stretches
|
||||
.iter()
|
||||
.flat_map(|stretch| match stretch {
|
||||
Stretch::Hunk { lines, .. } | Stretch::Folded(lines) => lines.iter(),
|
||||
})
|
||||
.map(|line| (line.text.to_string(), line.old, line.new))
|
||||
.collect();
|
||||
let expected = [
|
||||
("one".to_string(), Some(1), None),
|
||||
("two".to_string(), Some(2), None),
|
||||
("other.txt".to_string(), None, Some(1)),
|
||||
];
|
||||
assert_eq!(rows, expected, "{section:?}: the text, then the link");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_link_compares_by_its_own_path_whatever_it_points_at() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::remove_file(root.join("a.txt")).expect("removes");
|
||||
std::os::unix::fs::symlink("missing.txt", root.join("a.txt")).expect("links");
|
||||
let (diff, _, _) = read(&root, "a.txt", ChangeSection::Unstaged);
|
||||
assert!(
|
||||
format!("{diff:?}").contains("missing.txt"),
|
||||
"a dangling link reads"
|
||||
);
|
||||
std::fs::write(root.join("latin.txt"), b"\xff\n").expect("writes");
|
||||
std::fs::remove_file(root.join("a.txt")).expect("removes");
|
||||
std::os::unix::fs::symlink("latin.txt", root.join("a.txt")).expect("links");
|
||||
let (diff, _, _) = read(&root, "a.txt", ChangeSection::Unstaged);
|
||||
assert!(
|
||||
format!("{diff:?}").contains("latin.txt"),
|
||||
"a link to bytes is text"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Branches, operations and conflicts, in the window (C13.4).
|
||||
|
||||
use ely_gpui_component::git::{ChangeSection, Take};
|
||||
use gpui::{App, TestAppContext};
|
||||
|
||||
use super::{
|
||||
branching::conflicted,
|
||||
sh,
|
||||
window::{branch, settle, window},
|
||||
};
|
||||
use crate::{asks::Ask, branches::Kind, tree::Side};
|
||||
|
||||
fn conflicts(tree: &crate::tree::Tree) -> usize {
|
||||
match tree.active().and_then(|opened| opened.status.as_ref()) {
|
||||
Some(Ok(status)) => status
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| matches!(e.state, crate::status::State::Conflicted(_)))
|
||||
.count(),
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_merge_conflict_is_settled_saved_resolved_and_continued() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (root, _) = conflicted(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.merge("side", cx)));
|
||||
settle(cx, |app| conflicts(tree.read(app)) == 1);
|
||||
let doing = |app: &App| {
|
||||
matches!(
|
||||
tree.read(app).active().and_then(|o| o.doing.clone()),
|
||||
Some(Ok(Some(_)))
|
||||
)
|
||||
};
|
||||
settle(cx, doing);
|
||||
assert!(
|
||||
cx.update(|_, app| tree.read(app).ask.is_none()),
|
||||
"a stop is not a failure dialog"
|
||||
);
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.show("a.txt", ChangeSection::Conflicted, cx)
|
||||
})
|
||||
});
|
||||
settle(cx, |app| tree.read(app).merging.is_some());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.take(0, Take::Theirs, cx)));
|
||||
let result = cx.update(|_, app| tree.read(app).merge_editor.read(app).text().to_string());
|
||||
assert_eq!(result, "one\nside\n");
|
||||
// Resolving before saving is refused; saving writes the file.
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.mark_resolved(cx)));
|
||||
assert_eq!(
|
||||
sh(&root, &["diff", "--name-only", "--diff-filter=U"]).trim(),
|
||||
"a.txt"
|
||||
);
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.save_merge(cx)));
|
||||
settle(cx, |_| {
|
||||
std::fs::read_to_string(root.join("a.txt")).expect("reads") == "one\nside\n"
|
||||
});
|
||||
settle(cx, |app| {
|
||||
tree.read(app)
|
||||
.merging
|
||||
.as_ref()
|
||||
.is_some_and(|m| m.saved == "one\nside\n")
|
||||
});
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.mark_resolved(cx)));
|
||||
settle(cx, |app| conflicts(tree.read(app)) == 0);
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.op_continue(crate::ops::Op::Merge, cx)));
|
||||
settle(cx, |_| {
|
||||
sh(&root, &["log", "-1", "--format=%p"])
|
||||
.split_whitespace()
|
||||
.count()
|
||||
== 2
|
||||
});
|
||||
settle(cx, |app| {
|
||||
matches!(
|
||||
tree.read(app).active().and_then(|o| o.doing.clone()),
|
||||
Some(Ok(None))
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branches_are_made_renamed_and_deleted_each_with_its_own_yes() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (root, main) = conflicted(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.set_side(Side::Branches, cx)));
|
||||
let names = |app: &App| match tree.read(app).active().and_then(|o| o.refs.clone()) {
|
||||
Some(Ok(refs)) => refs
|
||||
.into_iter()
|
||||
.filter(|r| r.kind == Kind::Local)
|
||||
.map(|r| r.name)
|
||||
.collect::<Vec<_>>(),
|
||||
_ => Vec::new(),
|
||||
};
|
||||
settle(cx, |app| names(app).len() == 2);
|
||||
cx.update(|window, app| tree.update(app, |tree, cx| tree.ask_branch(None, window, cx)));
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
if let Some(Ask::NewBranch { name, .. }) = &tree.ask {
|
||||
name.update(cx, |field, cx| field.set_text("topic".to_string(), cx));
|
||||
}
|
||||
tree.answer(cx);
|
||||
})
|
||||
});
|
||||
settle(cx, |app| branch(tree.read(app)).as_deref() == Some("topic"));
|
||||
cx.update(|window, app| tree.update(app, |tree, cx| tree.ask_rename("topic", window, cx)));
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
if let Some(Ask::Rename { name, .. }) = &tree.ask {
|
||||
name.update(cx, |field, cx| field.set_text("renamed".to_string(), cx));
|
||||
}
|
||||
tree.answer(cx);
|
||||
})
|
||||
});
|
||||
settle(cx, |app| {
|
||||
branch(tree.read(app)).as_deref() == Some("renamed")
|
||||
});
|
||||
sh(&root, &["checkout", "-q", &main]);
|
||||
settle(cx, |app| {
|
||||
branch(tree.read(app)).as_deref() == Some(main.as_str())
|
||||
});
|
||||
// Delete waits for its own yes; nothing runs before it.
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.delete_branch("renamed", false, cx)));
|
||||
assert!(matches!(
|
||||
cx.update(|_, app| tree
|
||||
.read(app)
|
||||
.ask
|
||||
.as_ref()
|
||||
.map(|a| matches!(a, Ask::Confirm { .. }))),
|
||||
Some(true)
|
||||
));
|
||||
assert!(names_now(&root).contains(&"renamed".to_string()));
|
||||
let write = cx.update(|_, app| match &tree.read(app).ask {
|
||||
Some(Ask::Confirm { write, .. }) => write.clone(),
|
||||
_ => panic!("a confirm"),
|
||||
});
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.ask = None;
|
||||
tree.write(1, write, cx)
|
||||
})
|
||||
});
|
||||
settle(cx, |_| !names_now(&root).contains(&"renamed".to_string()));
|
||||
// The next delete asks again.
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.delete_branch("side", false, cx)));
|
||||
assert!(cx.update(|_, app| tree.read(app).ask.is_some()));
|
||||
}
|
||||
|
||||
fn names_now(root: &std::path::Path) -> Vec<String> {
|
||||
sh(root, &["branch", "--format=%(refname:short)"])
|
||||
.lines()
|
||||
.map(String::from)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_failure_that_is_no_stop_shows_gits_words() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (root, _) = conflicted(dir.path());
|
||||
std::fs::write(root.join("a.txt"), "dirty\n").expect("writes");
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
// A dirty file blocks the switch; Git says so.
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.switch("side", Kind::Local, cx)));
|
||||
settle(
|
||||
cx,
|
||||
|app| matches!(&tree.read(app).ask, Some(Ask::Failed { detail, .. }) if detail.contains("a.txt")),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_hook_that_stops_a_continue_says_why() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (root, _) = conflicted(dir.path());
|
||||
assert!(super::git().run(&root, &["merge", "side"]).is_err());
|
||||
std::fs::write(root.join("a.txt"), "one\nsettled\n").expect("writes");
|
||||
sh(&root, &["add", "a.txt"]);
|
||||
let hook = root.join(".git/hooks/pre-commit");
|
||||
std::fs::write(
|
||||
&hook,
|
||||
"#!/bin/sh\necho 'CONFLICT (hook): REVIEW_HOOK_DENIED'\nexit 1\n",
|
||||
)
|
||||
.expect("writes");
|
||||
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.op_continue(crate::ops::Op::Merge, cx)));
|
||||
settle(
|
||||
cx,
|
||||
|app| matches!(&tree.read(app).ask, Some(Ask::Failed { detail, .. }) if detail.contains("REVIEW_HOOK_DENIED")),
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_hook_that_stops_a_side_take_says_why_while_conflicts_remain() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (root, _) = conflicted(dir.path());
|
||||
assert!(super::git().run(&root, &["merge", "side"]).is_err());
|
||||
let hook = root.join(".git/hooks/post-checkout");
|
||||
std::fs::write(
|
||||
&hook,
|
||||
"#!/bin/sh\necho REVIEW_POST_CHECKOUT_DENIED >&2\nexit 1\n",
|
||||
)
|
||||
.expect("writes");
|
||||
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| conflicts(tree.read(app)) == 1);
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.show("a.txt", ChangeSection::Conflicted, cx)
|
||||
})
|
||||
});
|
||||
settle(cx, |app| tree.read(app).merging.is_some());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.take_side(true, cx)));
|
||||
settle(
|
||||
cx,
|
||||
|app| matches!(&tree.read(app).ask, Some(Ask::Failed { detail, .. }) if detail.contains("REVIEW_POST_CHECKOUT_DENIED")),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pick_that_conflicts_stops_without_a_dialog() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (root, _) = conflicted(dir.path());
|
||||
let theirs = sh(&root, &["rev-parse", "side"]).trim().to_string();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.cherry_pick(&theirs, 1, cx)));
|
||||
settle(cx, |app| conflicts(tree.read(app)) == 1);
|
||||
settle(cx, |app| {
|
||||
matches!(
|
||||
tree.read(app).active().and_then(|o| o.doing.clone()),
|
||||
Some(Ok(Some(_)))
|
||||
)
|
||||
});
|
||||
assert!(cx.update(|_, app| tree.read(app).ask.is_none()));
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! History, a commit's detail, blame and committing (C13.3).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use super::{committed, git, sh};
|
||||
use crate::{
|
||||
blame, commit,
|
||||
commits::{self, Find, Query},
|
||||
detail,
|
||||
shown::Found,
|
||||
work::{self, Write},
|
||||
};
|
||||
|
||||
fn commit_all(root: &Path, message: &str) {
|
||||
sh(root, &["add", "-A"]);
|
||||
sh(root, &["commit", "-q", "-m", message]);
|
||||
}
|
||||
|
||||
fn head(root: &Path) -> String {
|
||||
sh(root, &["rev-parse", "HEAD"]).trim().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_reads_branches_tags_merges_and_searches() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
let main = sh(&root, &["branch", "--show-current"]).trim().to_string();
|
||||
sh(&root, &["checkout", "-q", "-b", "side"]);
|
||||
std::fs::write(root.join("side.txt"), "side needle\n").expect("writes");
|
||||
commit_all(&root, "Side work");
|
||||
sh(&root, &["checkout", "-q", &main]);
|
||||
std::fs::write(root.join("main.txt"), "main\n").expect("writes");
|
||||
commit_all(&root, "Main work");
|
||||
sh(
|
||||
&root,
|
||||
&[
|
||||
"-c",
|
||||
"user.name=Bea",
|
||||
"merge",
|
||||
"-q",
|
||||
"--no-ff",
|
||||
"side",
|
||||
"-m",
|
||||
"Merge side",
|
||||
],
|
||||
);
|
||||
sh(&root, &["tag", "v1"]);
|
||||
let all = commits::read(&git(), &root, 0, None).expect("reads");
|
||||
assert_eq!(all.len(), 4);
|
||||
assert_eq!(all[0].subject, "Merge side");
|
||||
assert_eq!(all[0].parents.len(), 2, "a merge has two parents");
|
||||
assert!(
|
||||
all[0].refs.contains(&main) && all[0].refs.contains(&"v1".to_string()),
|
||||
"{:?}",
|
||||
all[0].refs
|
||||
);
|
||||
assert_eq!(
|
||||
commits::read(&git(), &root, 3, None).expect("reads").len(),
|
||||
1,
|
||||
"a later page"
|
||||
);
|
||||
let find = |find, text: &str| {
|
||||
let query = Query {
|
||||
find,
|
||||
text: text.into(),
|
||||
};
|
||||
commits::read(&git(), &root, 0, Some(&query))
|
||||
.expect("searches")
|
||||
.into_iter()
|
||||
.map(|c| c.subject)
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
assert_eq!(
|
||||
find(Find::Message, "side WORK"),
|
||||
["Side work"],
|
||||
"message, any case"
|
||||
);
|
||||
assert_eq!(find(Find::Content, "needle"), ["Side work"], "added text");
|
||||
assert_eq!(find(Find::Author, "nobody"), Vec::<String>::new());
|
||||
let first = all.last().expect("first").id.clone();
|
||||
assert_eq!(find(Find::Hash, &first[..8]), ["first"], "an id prefix");
|
||||
assert!(find(Find::Hash, "zzzz").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_files_history_follows_its_renames() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("a.txt"), "one\nTWO\nthree\n").expect("writes");
|
||||
commit_all(&root, "two");
|
||||
sh(&root, &["mv", "a.txt", "b.txt"]);
|
||||
commit_all(&root, "move");
|
||||
let touches = commits::file(&git(), &root, "b.txt").expect("reads");
|
||||
let shape: Vec<_> = touches
|
||||
.iter()
|
||||
.map(|t| {
|
||||
(
|
||||
t.commit.subject.as_str(),
|
||||
t.path.as_str(),
|
||||
t.from.as_deref(),
|
||||
t.lines,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shape,
|
||||
[
|
||||
("move", "b.txt", Some("a.txt"), Some((0, 0))),
|
||||
("two", "a.txt", None, Some((2, 1))),
|
||||
("first", "a.txt", None, Some((2, 0)))
|
||||
]
|
||||
);
|
||||
let change = |at: usize| {
|
||||
let t = &touches[at];
|
||||
detail::change(
|
||||
&git(),
|
||||
&root,
|
||||
&t.commit.id,
|
||||
&t.commit.parents,
|
||||
&t.path,
|
||||
t.from.clone(),
|
||||
)
|
||||
.expect("reads")
|
||||
};
|
||||
assert!(matches!(change(1), Found::Text { .. }));
|
||||
assert!(
|
||||
matches!(change(2), Found::Text { old: None, .. }),
|
||||
"added in the first commit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_commit_details_its_people_files_and_diffs() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
let first = detail::read(&git(), &root, &head(&root)).expect("reads");
|
||||
assert!(first.parents.is_empty());
|
||||
assert_eq!(
|
||||
first
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| (f.status, f.path.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
[('A', "a.txt")]
|
||||
);
|
||||
assert!(matches!(
|
||||
detail::file(&git(), &root, &first.id, &first.parents, &first.files[0]).expect("reads"),
|
||||
Found::Text { old: None, .. }
|
||||
));
|
||||
std::fs::write(root.join("new.txt"), "new\n").expect("writes");
|
||||
sh(&root, &["mv", "a.txt", "renamed.txt"]);
|
||||
sh(&root, &["add", "-A"]);
|
||||
sh(&root, &["commit", "-q", "-m", "Rename\n\nWith a body."]);
|
||||
let read = detail::read(&git(), &root, &head(&root)).expect("reads");
|
||||
assert_eq!(read.message, "Rename\n\nWith a body.");
|
||||
assert_eq!(
|
||||
(read.author.name.as_str(), read.author.email.as_str()),
|
||||
("Ely", "ely@example.com")
|
||||
);
|
||||
assert_eq!(read.signed, 'N');
|
||||
let files: Vec<_> = read
|
||||
.files
|
||||
.iter()
|
||||
.map(|f| (f.status, f.from.as_deref(), f.path.as_str(), f.lines))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
files,
|
||||
[
|
||||
('A', None, "new.txt", Some((1, 0))),
|
||||
('R', Some("a.txt"), "renamed.txt", Some((0, 0)))
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blame_names_each_lines_commit_and_what_is_not_committed() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("a.txt"), "one\ntwo\nthree\n").expect("writes");
|
||||
commit_all(&root, "third line");
|
||||
std::fs::write(root.join("a.txt"), "one\ntwo\nthree\nfour\n").expect("writes");
|
||||
let read = blame::read(&git(), &root, "a.txt").expect("reads");
|
||||
assert_eq!(read.code, "one\ntwo\nthree\nfour\n");
|
||||
let owner = |line: usize| &read.owners[read.lines[line]];
|
||||
assert_eq!(
|
||||
(owner(0).subject.as_str(), owner(2).subject.as_str()),
|
||||
("first", "third line")
|
||||
);
|
||||
assert_eq!(owner(0).author, "Ely");
|
||||
assert!(owner(3).uncommitted() && !owner(0).uncommitted());
|
||||
assert_eq!(
|
||||
read.lines[0], read.lines[1],
|
||||
"one owner for both first lines"
|
||||
);
|
||||
}
|
||||
|
||||
fn write(root: &Path, write: Write) -> std::io::Result<()> {
|
||||
work::run(&git(), root, &write, &write.args(true))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn commits_go_in_with_their_message_and_amend_replaces_the_last() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
sh(&root, &["config", "user.name", "Ely"]);
|
||||
sh(&root, &["config", "user.email", "ely@example.com"]);
|
||||
sh(&root, &["config", "commit.gpgsign", "false"]);
|
||||
std::fs::write(root.join("a.txt"), "changed\n").expect("writes");
|
||||
sh(&root, &["add", "a.txt"]);
|
||||
let message = commit::message(" Change a ", "Line one.\n# kept, not a comment\n");
|
||||
write(
|
||||
&root,
|
||||
Write::Commit {
|
||||
message,
|
||||
amend: false,
|
||||
},
|
||||
)
|
||||
.expect("commits");
|
||||
assert_eq!(
|
||||
commit::last_message(&git(), &root).expect("reads"),
|
||||
"Change a\n\nLine one.\n# kept, not a comment"
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
Write::Commit {
|
||||
message: "Change a, better".into(),
|
||||
amend: true,
|
||||
},
|
||||
)
|
||||
.expect("amends");
|
||||
let all = commits::read(&git(), &root, 0, None).expect("reads");
|
||||
assert_eq!(
|
||||
all.iter().map(|c| c.subject.as_str()).collect::<Vec<_>>(),
|
||||
["Change a, better", "first"]
|
||||
);
|
||||
assert_eq!(
|
||||
commit::split("Subject\n\nBody\nmore"),
|
||||
("Subject".into(), "Body\nmore".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_failing_hook_says_why() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
sh(&root, &["config", "user.name", "Ely"]);
|
||||
sh(&root, &["config", "user.email", "ely@example.com"]);
|
||||
sh(&root, &["config", "commit.gpgsign", "false"]);
|
||||
let hook = root.join(".git/hooks/pre-commit");
|
||||
std::fs::write(
|
||||
&hook,
|
||||
"#!/bin/sh\necho 'lint found 3 problems' >&2\nexit 1\n",
|
||||
)
|
||||
.expect("writes");
|
||||
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||
std::fs::write(root.join("a.txt"), "changed\n").expect("writes");
|
||||
sh(&root, &["add", "a.txt"]);
|
||||
let failed = write(
|
||||
&root,
|
||||
Write::Commit {
|
||||
message: "x".into(),
|
||||
amend: false,
|
||||
},
|
||||
)
|
||||
.expect_err("the hook stops it");
|
||||
assert!(
|
||||
failed.to_string().contains("lint found 3 problems"),
|
||||
"{failed}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identity_and_signing_come_from_git() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
write(
|
||||
&root,
|
||||
Write::Config {
|
||||
key: "user.name".into(),
|
||||
value: "Ann Lee".into(),
|
||||
global: false,
|
||||
},
|
||||
)
|
||||
.expect("sets");
|
||||
write(
|
||||
&root,
|
||||
Write::Config {
|
||||
key: "user.email".into(),
|
||||
value: "ann@example.com".into(),
|
||||
global: false,
|
||||
},
|
||||
)
|
||||
.expect("sets");
|
||||
sh(&root, &["config", "commit.gpgsign", "true"]);
|
||||
sh(&root, &["config", "gpg.format", "ssh"]);
|
||||
let me = commit::me(&git(), &root).expect("reads");
|
||||
assert_eq!(
|
||||
(me.name.as_deref(), me.email.as_deref(), me.signs.as_deref()),
|
||||
(Some("Ann Lee"), Some("ann@example.com"), Some("ssh"))
|
||||
);
|
||||
sh(&root, &["config", "commit.gpgsign", "false"]);
|
||||
assert_eq!(commit::me(&git(), &root).expect("reads").signs, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hash_lines_stay_whatever_commit_cleanup_says() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
for (key, value) in [
|
||||
("user.name", "Ely"),
|
||||
("user.email", "ely@example.com"),
|
||||
("commit.gpgsign", "false"),
|
||||
("commit.cleanup", "strip"),
|
||||
] {
|
||||
sh(&root, &["config", key, value]);
|
||||
}
|
||||
std::fs::write(root.join("a.txt"), "changed\n").expect("writes");
|
||||
sh(&root, &["add", "a.txt"]);
|
||||
write(
|
||||
&root,
|
||||
Write::Commit {
|
||||
message: "probe\n\n# required body".into(),
|
||||
amend: false,
|
||||
},
|
||||
)
|
||||
.expect("commits");
|
||||
assert_eq!(
|
||||
commit::last_message(&git(), &root).expect("reads"),
|
||||
"probe\n\n# required body"
|
||||
);
|
||||
write(
|
||||
&root,
|
||||
Write::Commit {
|
||||
message: "probe 2\n\n# still here".into(),
|
||||
amend: true,
|
||||
},
|
||||
)
|
||||
.expect("amends");
|
||||
assert_eq!(
|
||||
commit::last_message(&git(), &root).expect("reads"),
|
||||
"probe 2\n\n# still here"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
//! The Repository panel in the window (C13.5).
|
||||
|
||||
use ely_gpui_component::git::StashAction;
|
||||
use gpui::{App, Entity, TestAppContext};
|
||||
|
||||
use super::{
|
||||
committed,
|
||||
places::me,
|
||||
sh,
|
||||
window::{branch, settle, window},
|
||||
};
|
||||
use crate::{
|
||||
asks::Ask,
|
||||
places::Place,
|
||||
stash::Stash,
|
||||
tree::{Side, Tree},
|
||||
};
|
||||
|
||||
fn stashes(tree: &Tree) -> Vec<Stash> {
|
||||
tree.stashes().to_vec()
|
||||
}
|
||||
|
||||
fn running(tree: &Entity<Tree>, app: &App) -> bool {
|
||||
tree.read(app)
|
||||
.active()
|
||||
.is_some_and(|opened| opened.net.is_some())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stash_is_made_shown_and_popped_from_the_window() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
me(&root);
|
||||
std::fs::write(root.join("a.txt"), "one\nkept\n").expect("writes");
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.set_side(Side::Places, cx)));
|
||||
settle(cx, |app| {
|
||||
tree.read(app)
|
||||
.active()
|
||||
.is_some_and(|o| o.places.read.is_some())
|
||||
});
|
||||
cx.update(|window, app| tree.update(app, |tree, cx| tree.ask_stash(window, cx)));
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
if let Some(Ask::Stash { message, .. }) = &tree.ask {
|
||||
message.update(cx, |field, cx| field.set_text("kept for later", cx));
|
||||
}
|
||||
tree.answer(cx);
|
||||
})
|
||||
});
|
||||
settle(cx, |app| stashes(tree.read(app)).len() == 1);
|
||||
let stash = cx.update(|_, app| stashes(tree.read(app))[0].clone());
|
||||
assert_eq!(stash.message, "kept for later");
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("a.txt")).expect("reads"),
|
||||
"one\ntwo\n"
|
||||
);
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.pick_place(Place::Stash(stash.oid.clone()), cx)
|
||||
})
|
||||
});
|
||||
settle(cx, |app| {
|
||||
matches!(
|
||||
tree.read(app).active().and_then(|o| o.places.pick.detail.as_ref()),
|
||||
Some(Ok(detail)) if detail.files.iter().any(|f| f.path == "a.txt")
|
||||
)
|
||||
});
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.stash_act(0, StashAction::Drop, cx)));
|
||||
assert!(
|
||||
cx.update(|_, app| matches!(tree.read(app).ask, Some(Ask::Confirm { .. }))),
|
||||
"a drop asks"
|
||||
);
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.ask = None;
|
||||
tree.stash_act(0, StashAction::Pop, cx)
|
||||
})
|
||||
});
|
||||
settle(cx, |app| stashes(tree.read(app)).is_empty());
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("a.txt")).expect("reads"),
|
||||
"one\nkept\n"
|
||||
);
|
||||
}
|
||||
|
||||
/// A bare remote and a clone of it.
|
||||
fn cloned(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) {
|
||||
let source = committed(&dir.join("source"));
|
||||
let bare = dir.join("remote.git");
|
||||
let (from, to) = (
|
||||
source.to_str().expect("utf-8"),
|
||||
bare.to_str().expect("utf-8"),
|
||||
);
|
||||
sh(dir, &["clone", "-q", "--bare", from, to]);
|
||||
let into = dir.join("copy");
|
||||
sh(dir, &["clone", "-q", to, into.to_str().expect("utf-8")]);
|
||||
me(&into);
|
||||
(bare, into)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fetch_runs_with_progress_and_lands() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (bare, into) = cloned(dir.path());
|
||||
let other = dir.path().join("other");
|
||||
sh(
|
||||
dir.path(),
|
||||
&[
|
||||
"clone",
|
||||
"-q",
|
||||
bare.to_str().expect("utf-8"),
|
||||
other.to_str().expect("utf-8"),
|
||||
],
|
||||
);
|
||||
std::fs::write(other.join("a.txt"), "moved\n").expect("writes");
|
||||
sh(&other, &["commit", "-qam", "moved"]);
|
||||
sh(&other, &["push", "-q"]);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(into.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
let name = cx.update(|_, app| branch(tree.read(app)).expect("a branch"));
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.fetch_all(cx)));
|
||||
let tracked = format!("refs/remotes/origin/{name}");
|
||||
let moved = sh(&other, &["rev-parse", "HEAD"]);
|
||||
settle(cx, |app| {
|
||||
!running(&tree, app) && sh(&into, &["rev-parse", &tracked]) == moved
|
||||
});
|
||||
assert!(cx.update(|_, app| tree.read(app).ask.is_none()));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_stopped_fetch_ends_quietly() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
let silent = std::net::TcpListener::bind("127.0.0.1:0").expect("a port");
|
||||
let url = format!("git://{}/nothing", silent.local_addr().expect("an address"));
|
||||
sh(&root, &["remote", "add", "origin", &url]);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.fetch_one("origin", cx)));
|
||||
settle(cx, |app| running(&tree, app));
|
||||
let _held = silent.accept().expect("Git connects");
|
||||
let id = cx.update(|_, app| tree.read(app).active.expect("open"));
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.stop_remote(id, cx)));
|
||||
settle(cx, |app| !running(&tree, app));
|
||||
assert!(
|
||||
cx.update(|_, app| tree.read(app).ask.is_none()),
|
||||
"a stop is no failure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_push_with_no_upstream_asks_where_then_tracks_it() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (bare, into) = cloned(dir.path());
|
||||
sh(&into, &["switch", "-q", "-c", "topic"]);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(into.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).as_deref() == Some("topic"));
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.push(cx)));
|
||||
settle(
|
||||
cx,
|
||||
|app| matches!(&tree.read(app).ask, Some(Ask::Choose { choices, .. }) if choices == &["origin"]),
|
||||
);
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.ask = None;
|
||||
tree.publish(tree.active.expect("open"), "topic", "origin", cx)
|
||||
})
|
||||
});
|
||||
settle(cx, |app| {
|
||||
!running(&tree, app)
|
||||
&& sh(&into, &["config", "--get", "branch.topic.remote"]).trim() == "origin"
|
||||
});
|
||||
assert_eq!(
|
||||
sh(&bare, &["rev-parse", "topic"]),
|
||||
sh(&into, &["rev-parse", "HEAD"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forced_push_shows_what_it_would_overwrite() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (bare, into) = cloned(dir.path());
|
||||
let other = dir.path().join("other");
|
||||
sh(
|
||||
dir.path(),
|
||||
&[
|
||||
"clone",
|
||||
"-q",
|
||||
bare.to_str().expect("utf-8"),
|
||||
other.to_str().expect("utf-8"),
|
||||
],
|
||||
);
|
||||
std::fs::write(other.join("a.txt"), "theirs\n").expect("writes");
|
||||
sh(&other, &["commit", "-qam", "theirs"]);
|
||||
sh(&other, &["push", "-q"]);
|
||||
sh(&into, &["fetch", "-q"]);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(into.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.ask_force(cx)));
|
||||
settle(
|
||||
cx,
|
||||
|app| matches!(&tree.read(app).ask, Some(Ask::Force { plan, .. }) if plan.lost.len() == 1),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_upstream_is_set_and_unset_from_the_branches() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (_, into) = cloned(dir.path());
|
||||
let main = sh(&into, &["branch", "--show-current"]).trim().to_string();
|
||||
sh(&into, &["branch", "topic"]);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(into.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
let remote = format!("origin/{main}");
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.set_upstream(tree.active.expect("open"), "topic", &remote, cx)
|
||||
})
|
||||
});
|
||||
let merge = || {
|
||||
sh(
|
||||
&into,
|
||||
&["config", "--default", "", "--get", "branch.topic.merge"],
|
||||
)
|
||||
};
|
||||
settle(cx, |_| merge().trim() == format!("refs/heads/{main}"));
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.unset_upstream("topic", cx)));
|
||||
settle(cx, |_| merge().trim().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dialog_answers_for_the_repository_it_opened_on() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let a = committed(&dir.path().join("a"));
|
||||
let b = committed(&dir.path().join("b"));
|
||||
for root in [&a, &b] {
|
||||
me(root);
|
||||
std::fs::write(root.join("a.txt"), "one\nchanged\n").expect("writes");
|
||||
}
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(a.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
let first = cx.update(|_, app| tree.read(app).active.expect("open"));
|
||||
cx.update(|window, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.open_path(b.clone(), cx);
|
||||
tree.ask_stash(window, cx)
|
||||
})
|
||||
});
|
||||
settle(cx, |app| tree.read(app).active != Some(first));
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
if let Some(Ask::Stash { message, .. }) = &tree.ask {
|
||||
message.update(cx, |field, cx| field.set_text("for A", cx));
|
||||
}
|
||||
tree.answer(cx);
|
||||
})
|
||||
});
|
||||
let count = |root: &std::path::Path| {
|
||||
crate::stash::list(&super::git(), root)
|
||||
.expect("reads")
|
||||
.len()
|
||||
};
|
||||
settle(cx, |_| count(&a) == 1);
|
||||
assert_eq!(count(&b), 0, "B keeps its change");
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
//! Stashes, remotes, worktrees, submodules, LFS (C13.5).
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use super::{committed, git, sh};
|
||||
use crate::{
|
||||
detail, remotes, stash,
|
||||
structure::{self, Checkout},
|
||||
work::{self, Write},
|
||||
};
|
||||
|
||||
/// Identity in the repository's own config, for writes.
|
||||
pub(crate) fn me(root: &Path) {
|
||||
for (key, value) in [
|
||||
("user.name", "Ely"),
|
||||
("user.email", "ely@example.com"),
|
||||
("commit.gpgsign", "false"),
|
||||
] {
|
||||
sh(root, &["config", key, value]);
|
||||
}
|
||||
}
|
||||
|
||||
fn run(root: &Path, write: Write) -> std::io::Result<()> {
|
||||
work::run(&git(), root, &write, &write.args(true))
|
||||
}
|
||||
|
||||
fn words(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|word| word.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stashes_list_newest_first_with_untracked_files_apart() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
me(&root);
|
||||
std::fs::write(root.join("a.txt"), "one\nchanged\n").expect("writes");
|
||||
std::fs::write(root.join("new.txt"), "fresh\n").expect("writes");
|
||||
let push = |more: &[&str]| {
|
||||
let mut args = words(&["stash", "push"]);
|
||||
args.extend(words(more));
|
||||
run(&root, Write::Git(args)).expect("stashes");
|
||||
};
|
||||
push(&["--include-untracked", "-m", "first: words"]);
|
||||
std::fs::write(root.join("a.txt"), "one\nagain\n").expect("writes");
|
||||
push(&[]);
|
||||
let list = stash::list(&git(), &root).expect("reads");
|
||||
let branch = sh(&root, &["branch", "--show-current"]).trim().to_string();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(
|
||||
(list[1].branch.as_str(), list[1].message.as_str()),
|
||||
(branch.as_str(), "first: words")
|
||||
);
|
||||
assert_eq!(list[0].branch, branch);
|
||||
assert!(list[0].message.ends_with("first"), "{}", list[0].message);
|
||||
let held = stash::detail(&git(), &root, &list[1].oid).expect("reads");
|
||||
let files: Vec<(&str, bool)> = held
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| (file.path.as_str(), file.commit.is_some()))
|
||||
.collect();
|
||||
assert_eq!(files, [("a.txt", false), ("new.txt", true)]);
|
||||
let untracked = &held.files[1];
|
||||
let holder = untracked.commit.clone().expect("its own commit");
|
||||
let found = detail::file(&git(), &root, &holder, &[], untracked).expect("reads");
|
||||
let crate::shown::View::Text { seen, .. } = crate::shown::view(found).expect("a view") else {
|
||||
panic!("text");
|
||||
};
|
||||
assert!(String::from_utf8_lossy(&seen).contains("+fresh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pop_that_conflicts_keeps_its_stash() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
me(&root);
|
||||
std::fs::write(root.join("a.txt"), "one\nstashed\n").expect("writes");
|
||||
sh(&root, &["stash", "push", "-q"]);
|
||||
std::fs::write(root.join("a.txt"), "one\ncommitted\n").expect("writes");
|
||||
sh(&root, &["commit", "-qam", "meanwhile"]);
|
||||
let oid = stash::list(&git(), &root).expect("reads")[0].oid.clone();
|
||||
let popped = run(
|
||||
&root,
|
||||
Write::Stash {
|
||||
verb: "pop",
|
||||
at: 0,
|
||||
oid: oid.clone(),
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
popped
|
||||
.expect_err("conflicts")
|
||||
.to_string()
|
||||
.contains("CONFLICT")
|
||||
);
|
||||
let left = stash::list(&git(), &root).expect("reads");
|
||||
assert_eq!(
|
||||
left.iter().map(|s| s.oid.as_str()).collect::<Vec<_>>(),
|
||||
[oid.as_str()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stash_command_refuses_a_moved_list() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
me(&root);
|
||||
std::fs::write(root.join("a.txt"), "one\nfirst\n").expect("writes");
|
||||
sh(&root, &["stash", "push", "-q"]);
|
||||
let first = stash::list(&git(), &root).expect("reads")[0].oid.clone();
|
||||
std::fs::write(root.join("a.txt"), "one\nsecond\n").expect("writes");
|
||||
sh(&root, &["stash", "push", "-q"]);
|
||||
let dropped = run(
|
||||
&root,
|
||||
Write::Stash {
|
||||
verb: "drop",
|
||||
at: 0,
|
||||
oid: first,
|
||||
},
|
||||
);
|
||||
assert!(
|
||||
dropped
|
||||
.expect_err("refused")
|
||||
.to_string()
|
||||
.contains("changed")
|
||||
);
|
||||
assert_eq!(stash::list(&git(), &root).expect("reads").len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remotes_list_urls_and_push_urls() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
for args in [
|
||||
words(&["remote", "add", "--", "origin", "/srv/one"]),
|
||||
words(&["remote", "add", "--", "a.b", "/srv/two"]),
|
||||
words(&["remote", "set-url", "--push", "--", "a.b", "/srv/push"]),
|
||||
] {
|
||||
run(&root, Write::Git(args)).expect("runs");
|
||||
}
|
||||
let list = remotes::list(&git(), &root).expect("reads");
|
||||
let shown: Vec<(&str, Vec<&str>, Vec<&str>)> = list
|
||||
.iter()
|
||||
.map(|r| {
|
||||
(
|
||||
r.name.as_str(),
|
||||
r.urls.iter().map(String::as_str).collect(),
|
||||
r.push.iter().map(String::as_str).collect(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
shown,
|
||||
[
|
||||
("a.b", vec!["/srv/two"], vec!["/srv/push"]),
|
||||
("origin", vec!["/srv/one"], vec![]),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// A bare remote and two clones of it.
|
||||
fn shared(dir: &Path) -> (PathBuf, PathBuf, PathBuf) {
|
||||
let source = committed(&dir.join("source"));
|
||||
let bare = dir.join("remote.git");
|
||||
sh(
|
||||
dir,
|
||||
&[
|
||||
"clone",
|
||||
"-q",
|
||||
"--bare",
|
||||
source.to_str().expect("utf-8"),
|
||||
bare.to_str().expect("utf-8"),
|
||||
],
|
||||
);
|
||||
let clone = |name: &str| {
|
||||
let into = dir.join(name);
|
||||
git()
|
||||
.clone_repo(bare.to_str().expect("utf-8"), &into)
|
||||
.expect("starts")
|
||||
.follow(|_| {})
|
||||
.expect("clones");
|
||||
me(&into);
|
||||
into
|
||||
};
|
||||
let (a, b) = (clone("a"), clone("b"));
|
||||
(bare, a, b)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forced_push_names_what_it_overwrites_and_holds_its_lease() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (bare, a, b) = shared(dir.path());
|
||||
std::fs::write(a.join("a.txt"), "theirs\n").expect("writes");
|
||||
sh(&a, &["commit", "-qam", "theirs"]);
|
||||
run(&a, Write::Remote(words(&["push", "--progress"]))).expect("pushes");
|
||||
std::fs::write(b.join("a.txt"), "mine\n").expect("writes");
|
||||
sh(&b, &["commit", "-qam", "mine"]);
|
||||
run(
|
||||
&b,
|
||||
Write::Remote(words(&["fetch", "--progress", "--", "origin"])),
|
||||
)
|
||||
.expect("fetches");
|
||||
let plan = remotes::force(&git(), &b).expect("a plan");
|
||||
let branch = sh(&b, &["branch", "--show-current"]).trim().to_string();
|
||||
assert_eq!(
|
||||
(
|
||||
plan.branch.as_str(),
|
||||
plan.remote.as_str(),
|
||||
plan.target.as_str()
|
||||
),
|
||||
(
|
||||
branch.as_str(),
|
||||
"origin",
|
||||
format!("refs/heads/{branch}").as_str()
|
||||
)
|
||||
);
|
||||
assert_eq!(plan.lost.len(), 1);
|
||||
assert!(plan.lost[0].ends_with(" theirs"), "{:?}", plan.lost);
|
||||
// The remote moves after the plan; the lease refuses.
|
||||
std::fs::write(a.join("a.txt"), "theirs again\n").expect("writes");
|
||||
sh(&a, &["commit", "-qam", "theirs again"]);
|
||||
run(&a, Write::Remote(words(&["push", "--progress"]))).expect("pushes");
|
||||
let refused = run(&b, Write::Remote(plan.args())).expect_err("stale lease");
|
||||
assert!(refused.to_string().contains("stale info"), "{refused}");
|
||||
run(&b, Write::Remote(words(&["fetch", "--progress", "--all"]))).expect("fetches");
|
||||
let plan = remotes::force(&git(), &b).expect("a plan");
|
||||
assert_eq!(plan.lost.len(), 2);
|
||||
run(&b, Write::Remote(plan.args())).expect("forces");
|
||||
let there = sh(&bare, &["rev-parse", &format!("refs/heads/{branch}")]);
|
||||
assert_eq!(there, sh(&b, &["rev-parse", "HEAD"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_detached_head_or_no_upstream_has_no_forced_push() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
let none = remotes::force(&git(), &root).expect_err("no upstream");
|
||||
assert!(none.to_string().contains("no upstream"), "{none}");
|
||||
sh(&root, &["checkout", "-q", "--detach"]);
|
||||
assert!(remotes::force(&git(), &root).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn worktrees_list_branches_and_prunable_folders() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
let other = dir.path().join("other tree");
|
||||
let path = other.to_str().expect("utf-8");
|
||||
run(
|
||||
&root,
|
||||
Write::Git(words(&["worktree", "add", "-b", "side", "--", path])),
|
||||
)
|
||||
.expect("adds");
|
||||
let list = structure::worktrees(&git(), &root).expect("reads");
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].path, std::fs::canonicalize(&root).expect("real"));
|
||||
assert_eq!(
|
||||
(list[1].path.clone(), list[1].branch.as_deref()),
|
||||
(std::fs::canonicalize(&other).expect("real"), Some("side"))
|
||||
);
|
||||
std::fs::remove_dir_all(&other).expect("removes");
|
||||
let list = structure::worktrees(&git(), &root).expect("reads");
|
||||
assert!(list[1].prunable.is_some(), "{list:?}");
|
||||
run(&root, Write::Git(words(&["worktree", "prune"]))).expect("prunes");
|
||||
assert_eq!(structure::worktrees(&git(), &root).expect("reads").len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submodules_say_whether_their_commit_is_checked_out() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let sub = committed(&dir.path().join("s"));
|
||||
let main = committed(&dir.path().join("m"));
|
||||
let file = ["-c", "protocol.file.allow=always"];
|
||||
let add = [
|
||||
&file[..],
|
||||
&[
|
||||
"submodule",
|
||||
"add",
|
||||
"-q",
|
||||
sub.to_str().expect("utf-8"),
|
||||
"lib",
|
||||
],
|
||||
]
|
||||
.concat();
|
||||
sh(&main, &add);
|
||||
sh(&main, &["commit", "-qm", "with lib"]);
|
||||
let recorded = sh(&sub, &["rev-parse", "HEAD"]).trim().to_string();
|
||||
let list = structure::submodules(&git(), &main).expect("reads");
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(
|
||||
(list[0].path.as_str(), &list[0].checkout, list[0].conflicted),
|
||||
("lib", &Checkout::At(recorded.clone()), false)
|
||||
);
|
||||
let copy = dir.path().join("copy");
|
||||
git()
|
||||
.clone_repo(main.to_str().expect("utf-8"), ©)
|
||||
.expect("starts")
|
||||
.follow(|_| {})
|
||||
.expect("clones");
|
||||
let list = structure::submodules(&git(), ©).expect("reads");
|
||||
assert_eq!(
|
||||
(list[0].recorded.as_str(), &list[0].checkout),
|
||||
(recorded.as_str(), &Checkout::Missing)
|
||||
);
|
||||
let update = [
|
||||
&file[..],
|
||||
&["submodule", "update", "--init", "--progress", "--", "lib"],
|
||||
]
|
||||
.concat();
|
||||
sh(©, &update);
|
||||
let list = structure::submodules(&git(), ©).expect("reads");
|
||||
assert_eq!(list[0].checkout, Checkout::At(recorded));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lfs_files_say_whether_the_object_is_here() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
assert!(structure::lfs(&git(), &root).expect("reads").is_empty());
|
||||
sh(&root, &["lfs", "install", "--local"]);
|
||||
sh(&root, &["lfs", "track", "*.bin"]);
|
||||
std::fs::write(root.join("big.bin"), vec![7u8; 3000]).expect("writes");
|
||||
sh(&root, &["add", "."]);
|
||||
sh(&root, &["commit", "-qm", "lfs"]);
|
||||
let here = structure::lfs(&git(), &root).expect("reads");
|
||||
assert_eq!(here.len(), 1);
|
||||
assert_eq!(
|
||||
(
|
||||
here[0].path.as_str(),
|
||||
here[0].size,
|
||||
here[0].checked_out,
|
||||
here[0].downloaded
|
||||
),
|
||||
("big.bin", 3000, true, true)
|
||||
);
|
||||
let copy = dir.path().join("copy");
|
||||
let cloned = std::process::Command::new(&git().path)
|
||||
.args([
|
||||
"clone",
|
||||
"-q",
|
||||
root.to_str().expect("utf-8"),
|
||||
copy.to_str().expect("utf-8"),
|
||||
])
|
||||
.env("GIT_LFS_SKIP_SMUDGE", "1")
|
||||
.status()
|
||||
.expect("runs");
|
||||
assert!(cloned.success());
|
||||
let there = structure::lfs(&git(), ©).expect("reads");
|
||||
assert_eq!((there[0].checked_out, there[0].downloaded), (false, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_forced_push_sends_the_commit_it_showed() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (bare, b, _) = shared(dir.path());
|
||||
let base = sh(&b, &["rev-parse", "HEAD"]).trim().to_string();
|
||||
std::fs::write(b.join("a.txt"), "keep me\n").expect("writes");
|
||||
sh(&b, &["commit", "-qam", "keep-me"]);
|
||||
run(&b, Write::Remote(words(&["push", "--progress"]))).expect("pushes");
|
||||
let plan = remotes::force(&git(), &b).expect("a plan");
|
||||
assert!(plan.lost.is_empty());
|
||||
// The branch moves back before the yes.
|
||||
sh(&b, &["reset", "-q", "--hard", &base]);
|
||||
run(&b, Write::Remote(plan.args())).expect("pushes");
|
||||
let branch = &plan.branch;
|
||||
assert_eq!(
|
||||
sh(&bare, &["rev-parse", &format!("refs/heads/{branch}")]).trim(),
|
||||
plan.source,
|
||||
"keep-me stays"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn every_line_a_hook_says_is_kept() {
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (bare, b, _) = shared(dir.path());
|
||||
let hook = bare.join("hooks/pre-receive");
|
||||
std::fs::write(
|
||||
&hook,
|
||||
"#!/bin/sh\necho POLICY_A required >&2\necho POLICY_B required >&2\nexit 1\n",
|
||||
)
|
||||
.expect("writes");
|
||||
std::fs::set_permissions(&hook, std::fs::Permissions::from_mode(0o755)).expect("chmod");
|
||||
std::fs::write(b.join("a.txt"), "refused\n").expect("writes");
|
||||
sh(&b, &["commit", "-qam", "refused"]);
|
||||
let said = run(&b, Write::Remote(words(&["push", "--progress"]))).expect_err("refused");
|
||||
let said = said.to_string();
|
||||
assert!(
|
||||
said.contains("POLICY_A required") && said.contains("POLICY_B required"),
|
||||
"{said}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remote_named_with_a_dash_is_a_name() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (bare, b, _) = shared(dir.path());
|
||||
let path = bare.to_str().expect("utf-8");
|
||||
run(
|
||||
&b,
|
||||
Write::Git(words(&["remote", "add", "--", "-dash", path])),
|
||||
)
|
||||
.expect("adds");
|
||||
sh(&b, &["switch", "-q", "-c", "topic"]);
|
||||
let push = words(&[
|
||||
"push",
|
||||
"--progress",
|
||||
"--set-upstream",
|
||||
"--",
|
||||
"-dash",
|
||||
"refs/heads/topic:refs/heads/topic",
|
||||
]);
|
||||
run(&b, Write::Remote(push)).expect("pushes");
|
||||
assert_eq!(
|
||||
sh(&b, &["config", "--get", "branch.topic.remote"]).trim(),
|
||||
"-dash"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stash_with_an_empty_message_still_lists() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
me(&root);
|
||||
std::fs::write(root.join("a.txt"), "one\nfirst\n").expect("writes");
|
||||
sh(&root, &["stash", "push", "-q", "-m", "named"]);
|
||||
std::fs::write(root.join("a.txt"), "one\nbare\n").expect("writes");
|
||||
let oid = sh(&root, &["stash", "create"]).trim().to_string();
|
||||
sh(&root, &["stash", "store", "-m", "", &oid]);
|
||||
let list = stash::list(&git(), &root).expect("reads");
|
||||
let shown: Vec<&str> = list.iter().map(|s| s.message.as_str()).collect();
|
||||
assert_eq!(shown, ["", "named"]);
|
||||
assert_eq!(list[0].oid, oid);
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//! Reflog, moving back, cleaning, locks, failures (C13.6).
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use gpui::{App, Entity, TestAppContext};
|
||||
|
||||
use super::{
|
||||
branching::conflicted,
|
||||
committed, git,
|
||||
places::me,
|
||||
sh,
|
||||
window::{branch, settle, window},
|
||||
};
|
||||
use crate::{
|
||||
asks::Ask,
|
||||
ops::Op,
|
||||
recover,
|
||||
tree::Tree,
|
||||
work::{self, Write},
|
||||
};
|
||||
|
||||
pub(super) fn run(root: &Path, write: Write) -> std::io::Result<()> {
|
||||
work::run(&git(), root, &write, &write.args(true))
|
||||
}
|
||||
|
||||
/// Says yes to the dialog's write, as its button would.
|
||||
fn confirm(tree: &Entity<Tree>, app: &mut App) {
|
||||
tree.update(app, |tree, cx| {
|
||||
let Some(Ask::Confirm { repo, write, .. }) = tree.ask.take() else {
|
||||
panic!("a confirm dialog");
|
||||
};
|
||||
tree.write(repo, write, cx);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reflog_lists_where_head_was_newest_first() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
me(&root);
|
||||
std::fs::write(root.join("a.txt"), "two\n").expect("writes");
|
||||
sh(&root, &["commit", "-qam", "second"]);
|
||||
sh(&root, &["checkout", "-q", "--detach", "HEAD~1"]);
|
||||
let moves = recover::reflog(&git(), &root).expect("reads");
|
||||
let names: Vec<&str> = moves.iter().map(|m| m.name.as_str()).collect();
|
||||
assert_eq!(names, ["HEAD@{0}", "HEAD@{1}", "HEAD@{2}"]);
|
||||
assert!(moves[0].said.starts_with("checkout:"), "{}", moves[0].said);
|
||||
assert!(moves[1].said.ends_with("second"), "{}", moves[1].said);
|
||||
}
|
||||
|
||||
fn move_of(plan: &recover::Move) -> Write {
|
||||
Write::Move {
|
||||
branch: plan.branch.clone(),
|
||||
head: plan.head.clone(),
|
||||
to: plan.to.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_back_keeps_changes_and_refuses_a_moved_head() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
me(&root);
|
||||
let first = sh(&root, &["rev-parse", "HEAD"]).trim().to_string();
|
||||
std::fs::write(root.join("b.txt"), "b\n").expect("writes");
|
||||
sh(&root, &["add", "b.txt"]);
|
||||
sh(&root, &["commit", "-qm", "gone soon"]);
|
||||
std::fs::write(root.join("a.txt"), "one\ntwo\nkept\n").expect("writes");
|
||||
let plan = recover::plan_move(&git(), &root, &first).expect("a plan");
|
||||
assert_eq!(plan.left.len(), 1);
|
||||
assert!(plan.left[0].ends_with("gone soon"));
|
||||
sh(&root, &["commit", "-q", "--allow-empty", "-m", "meanwhile"]);
|
||||
let refused = run(&root, move_of(&plan)).expect_err("HEAD moved");
|
||||
assert!(refused.to_string().contains("moved"), "{refused}");
|
||||
sh(&root, &["reset", "-q", "--keep", "HEAD~1"]);
|
||||
run(&root, move_of(&plan)).expect("moves");
|
||||
assert_eq!(sh(&root, &["rev-parse", "HEAD"]).trim(), first);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(root.join("a.txt")).expect("reads"),
|
||||
"one\ntwo\nkept\n"
|
||||
);
|
||||
assert!(!root.join("b.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staged_work_refuses_a_move_rather_than_lose_it() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
me(&root);
|
||||
let first = sh(&root, &["rev-parse", "HEAD"]).trim().to_string();
|
||||
sh(&root, &["commit", "-q", "--allow-empty", "-m", "second"]);
|
||||
std::fs::write(root.join("a.txt"), "staged\n").expect("writes");
|
||||
sh(&root, &["add", "a.txt"]);
|
||||
std::fs::write(root.join("a.txt"), "unstaged\n").expect("writes");
|
||||
assert!(
|
||||
recover::plan_move(&git(), &root, &first).is_err(),
|
||||
"no plan"
|
||||
);
|
||||
// Staged after the plan: the write refuses too.
|
||||
sh(&root, &["reset", "-q"]);
|
||||
let plan = recover::plan_move(&git(), &root, &first).expect("a plan");
|
||||
sh(&root, &["add", "a.txt"]);
|
||||
let refused = run(&root, move_of(&plan)).expect_err("staged");
|
||||
assert!(refused.to_string().contains("staged"), "{refused}");
|
||||
assert_eq!(sh(&root, &["show", ":a.txt"]), "unstaged\n", "index kept");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_move_refuses_another_branch_or_a_detached_head() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
me(&root);
|
||||
let first = sh(&root, &["rev-parse", "HEAD"]).trim().to_string();
|
||||
sh(&root, &["commit", "-q", "--allow-empty", "-m", "second"]);
|
||||
let second = sh(&root, &["rev-parse", "HEAD"]).trim().to_string();
|
||||
sh(&root, &["branch", "other"]);
|
||||
let plan = recover::plan_move(&git(), &root, &first).expect("a plan");
|
||||
sh(&root, &["switch", "-q", "other"]);
|
||||
assert!(
|
||||
run(&root, move_of(&plan)).is_err(),
|
||||
"same commit, other branch"
|
||||
);
|
||||
sh(&root, &["switch", "-q", "--detach", &second]);
|
||||
assert!(run(&root, move_of(&plan)).is_err(), "detached now");
|
||||
for name in ["other", "HEAD"] {
|
||||
assert_eq!(sh(&root, &["rev-parse", name]).trim(), second);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_lock_waits_for_a_retry_and_stays() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("a.txt"), "one\nstaged\n").expect("writes");
|
||||
let lock = root.join(".git/index.lock");
|
||||
std::fs::write(&lock, "").expect("writes");
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
let id = cx.update(|_, app| tree.read(app).active.expect("open"));
|
||||
let entry = super::entry(&root, "a.txt");
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.write(id, Write::Stage(entry), cx)));
|
||||
settle(cx, |app| {
|
||||
matches!(tree.read(app).ask, Some(Ask::Locked { .. }))
|
||||
});
|
||||
assert!(lock.exists(), "Ely deletes no lock");
|
||||
std::fs::remove_file(&lock).expect("the other Git ends");
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
let Some(Ask::Locked { repo, write, .. }) = tree.ask.take() else {
|
||||
panic!("locked");
|
||||
};
|
||||
tree.write(repo, write, cx);
|
||||
})
|
||||
});
|
||||
settle(cx, |_| {
|
||||
sh(&root, &["diff", "--cached", "--name-only"]).trim() == "a.txt"
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_credential_says_so() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
// A server that wants a password nobody gives.
|
||||
let server = std::net::TcpListener::bind("127.0.0.1:0").expect("a port");
|
||||
let url = format!(
|
||||
"http://{}/repo.git",
|
||||
server.local_addr().expect("an address")
|
||||
);
|
||||
std::thread::spawn(move || {
|
||||
for stream in server.incoming().flatten() {
|
||||
use std::io::{Read as _, Write as _};
|
||||
let mut stream = stream;
|
||||
let mut buffer = [0u8; 4096];
|
||||
let _ = stream.read(&mut buffer);
|
||||
let _ = stream.write_all(
|
||||
b"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"x\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
);
|
||||
}
|
||||
});
|
||||
sh(&root, &["remote", "add", "origin", &url]);
|
||||
sh(&root, &["config", "credential.helper", ""]);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.fetch_one("origin", cx)));
|
||||
settle(
|
||||
cx,
|
||||
|app| matches!(&tree.read(app).ask, Some(Ask::Failed { detail, .. }) if detail.contains("terminal prompts disabled")),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interrupted_rebase_aborts_back_to_where_it_was() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (root, _) = conflicted(dir.path());
|
||||
let before = sh(&root, &["rev-parse", "HEAD"]);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.rebase("refs/heads/side", "side", cx)));
|
||||
cx.update(|_, app| confirm(&tree, app));
|
||||
let doing = |app: &App, op: Option<Op>| {
|
||||
matches!(
|
||||
tree.read(app).active().and_then(|o| o.doing.clone()),
|
||||
Some(Ok(now)) if now.as_ref().map(|d| d.op) == op
|
||||
)
|
||||
};
|
||||
settle(cx, |app| doing(app, Some(Op::Rebase)));
|
||||
assert!(
|
||||
cx.update(|_, app| tree.read(app).ask.is_none()),
|
||||
"a stop, no failure"
|
||||
);
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.op_abort(Op::Rebase, cx)));
|
||||
cx.update(|_, app| confirm(&tree, app));
|
||||
settle(cx, |app| doing(app, None));
|
||||
assert_eq!(sh(&root, &["rev-parse", "HEAD"]), before);
|
||||
assert!(sh(&root, &["status", "--porcelain"]).trim().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reflog_entry_shows_and_moves_the_branch_back() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
me(&root);
|
||||
let first = sh(&root, &["rev-parse", "HEAD"]).trim().to_string();
|
||||
sh(&root, &["commit", "-q", "--allow-empty", "-m", "later"]);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.set_side(crate::tree::Side::Places, cx)));
|
||||
settle(cx, |app| {
|
||||
tree.read(app)
|
||||
.active()
|
||||
.and_then(|o| o.places.read.as_ref())
|
||||
.is_some_and(|read| matches!(&read.reflog, Ok(moves) if moves.len() == 2))
|
||||
});
|
||||
let place = cx.update(|_, app| reflog_at(&tree, app, 1));
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.pick_place(place, cx)));
|
||||
settle(cx, |app| {
|
||||
matches!(
|
||||
tree.read(app).active().and_then(|o| o.places.pick.detail.as_ref()),
|
||||
Some(Ok(detail)) if detail.id == first
|
||||
)
|
||||
});
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.ask_move(&first, cx)));
|
||||
settle(
|
||||
cx,
|
||||
|app| matches!(&tree.read(app).ask, Some(Ask::Move { plan, .. }) if plan.left.len() == 1),
|
||||
);
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
let Some(Ask::Move { repo, plan }) = tree.ask.take() else {
|
||||
panic!("a move");
|
||||
};
|
||||
tree.write(repo, move_of(&plan), cx);
|
||||
})
|
||||
});
|
||||
settle(cx, |_| sh(&root, &["rev-parse", "HEAD"]).trim() == first);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_reflog_says_when_head_moved_not_when_committed() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = dir.path().join("work");
|
||||
crate::repo::init(&git(), &root).expect("init");
|
||||
let old = std::process::Command::new(&git().path)
|
||||
.args([
|
||||
"-c",
|
||||
"user.name=Ely",
|
||||
"-c",
|
||||
"user.email=e@x",
|
||||
"commit",
|
||||
"-q",
|
||||
"--allow-empty",
|
||||
"-m",
|
||||
"old",
|
||||
])
|
||||
.current_dir(&root)
|
||||
.env("GIT_COMMITTER_DATE", "2000-01-01T00:00:00Z")
|
||||
.env("GIT_AUTHOR_DATE", "2000-01-01T00:00:00Z")
|
||||
.status()
|
||||
.expect("runs");
|
||||
assert!(old.success());
|
||||
sh(&root, &["checkout", "-q", "-b", "other"]);
|
||||
let moves = recover::reflog(&git(), &root).expect("reads");
|
||||
assert!(
|
||||
moves[0].at > 1_700_000_000,
|
||||
"the checkout's own time: {}",
|
||||
moves[0].at
|
||||
);
|
||||
}
|
||||
|
||||
/// The reflog event at `at`, as picked.
|
||||
fn reflog_at(tree: &Entity<Tree>, app: &App, at: usize) -> crate::places::Place {
|
||||
let read = tree.read(app).active().and_then(|o| o.places.read.as_ref());
|
||||
let Some(crate::places::Read {
|
||||
reflog: Ok(moves), ..
|
||||
}) = read
|
||||
else {
|
||||
panic!("a reflog");
|
||||
};
|
||||
crate::places::Place::Reflog(moves[at].clone())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picked_reflog_event_stays_itself_as_others_arrive() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
sh(&root, &["checkout", "-q", "-b", "other"]);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.set_side(crate::tree::Side::Places, cx)));
|
||||
let count = |app: &App| match tree.read(app).active().and_then(|o| o.places.read.as_ref()) {
|
||||
Some(crate::places::Read {
|
||||
reflog: Ok(moves), ..
|
||||
}) => moves.len(),
|
||||
_ => 0,
|
||||
};
|
||||
settle(cx, |app| count(app) == 2);
|
||||
// The initial commit and the checkout share one commit.
|
||||
let picked = cx.update(|_, app| reflog_at(&tree, app, 1));
|
||||
let newer = cx.update(|_, app| reflog_at(&tree, app, 0));
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.pick_place(picked.clone(), cx)));
|
||||
let crate::places::Place::Reflog(was) = &picked else {
|
||||
panic!("a reflog event");
|
||||
};
|
||||
let crate::places::Place::Reflog(new) = &newer else {
|
||||
panic!("a reflog event");
|
||||
};
|
||||
assert!(!was.same(new), "one commit, two events");
|
||||
sh(&root, &["checkout", "-q", "--detach", "HEAD"]);
|
||||
settle(cx, |app| count(app) == 3);
|
||||
let now = cx.update(|_, app| reflog_at(&tree, app, 2));
|
||||
let crate::places::Place::Reflog(now) = now else {
|
||||
panic!("a reflog event");
|
||||
};
|
||||
assert!(now.same(was), "the event moved to HEAD@{{2}}");
|
||||
assert!(cx.update(|_, app| tree.read(app).picked_place.as_ref() == Some(&picked)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_held_config_lock_waits_for_a_retry_too() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
let lock = root.join(".git/config.lock");
|
||||
std::fs::write(&lock, "").expect("writes");
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
let id = cx.update(|_, app| tree.read(app).active.expect("open"));
|
||||
let write = Write::Config {
|
||||
key: "user.name".into(),
|
||||
value: "Locked Out".into(),
|
||||
global: false,
|
||||
};
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.write(id, write, cx)));
|
||||
settle(cx, |app| {
|
||||
matches!(tree.read(app).ask, Some(Ask::Locked { .. }))
|
||||
});
|
||||
assert!(lock.exists());
|
||||
std::fs::remove_file(&lock).expect("the other Git ends");
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
let Some(Ask::Locked { repo, write, .. }) = tree.ask.take() else {
|
||||
panic!("locked");
|
||||
};
|
||||
tree.write(repo, write, cx);
|
||||
})
|
||||
});
|
||||
settle(cx, |_| {
|
||||
let read = git()
|
||||
.run(&root, &["config", "--get", "user.name"])
|
||||
.expect("reads");
|
||||
String::from_utf8_lossy(&read).trim() == "Locked Out"
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
//! The window: tabs, and rereads after outside Git (TREE-01, TREE-02).
|
||||
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use gpui::{App, Entity, TestAppContext, VisualTestContext};
|
||||
|
||||
use super::{committed, sh};
|
||||
use crate::{status::State, tree::Tree};
|
||||
|
||||
/// Steps, lets the watcher's timer pass, and redraws until `done`.
|
||||
pub(crate) fn settle(cx: &mut VisualTestContext, done: impl Fn(&App) -> bool) {
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
while Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
cx.executor().advance_clock(Duration::from_millis(300));
|
||||
for _ in 0..100 {
|
||||
if !cx.executor().tick() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
cx.update(|window, _| window.refresh());
|
||||
if cx.update(|_, app| done(app)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
panic!("the state never settled");
|
||||
}
|
||||
|
||||
pub(crate) fn window<'a>(
|
||||
cx: &'a mut TestAppContext,
|
||||
data: &std::path::Path,
|
||||
) -> (Entity<Tree>, &'a mut VisualTestContext) {
|
||||
cx.executor().allow_parking();
|
||||
cx.update(ely_gpui_component::init_for_tests);
|
||||
cx.update(|cx| {
|
||||
ely_ui::settings::install_defaults(cx);
|
||||
ely_ui::i18n::install(cx);
|
||||
});
|
||||
let recent = data.join("recent.json");
|
||||
cx.add_window_view(move |window, cx| Tree::new(recent, window, cx))
|
||||
}
|
||||
|
||||
pub(crate) fn untracked(tree: &Tree) -> Vec<String> {
|
||||
let Some(Some(Ok(status))) = tree.active().map(|opened| &opened.status) else {
|
||||
return Vec::new();
|
||||
};
|
||||
status
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|entry| entry.state == State::Untracked)
|
||||
.map(|entry| entry.path.clone())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn branch(tree: &Tree) -> Option<String> {
|
||||
let Some(Some(Ok(status))) = tree.active().map(|opened| &opened.status) else {
|
||||
return None;
|
||||
};
|
||||
status.head.branch.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outside_git_and_edits_show_without_a_press() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
std::fs::write(root.join("new.txt"), "new\n").expect("writes");
|
||||
settle(cx, |app| untracked(tree.read(app)) == ["new.txt"]);
|
||||
sh(&root, &["checkout", "-q", "-b", "elsewhere"]);
|
||||
settle(cx, |app| {
|
||||
branch(tree.read(app)).as_deref() == Some("elsewhere")
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_tab_per_repository_and_reopening_selects_it() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (first, second) = (
|
||||
committed(&dir.path().join("1")),
|
||||
committed(&dir.path().join("2")),
|
||||
);
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
for (path, active) in [
|
||||
(first.clone(), 1),
|
||||
(second.clone(), 2),
|
||||
(first.join("."), 1),
|
||||
] {
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(path, cx)));
|
||||
settle(cx, |app| tree.read(app).active == Some(active));
|
||||
}
|
||||
let tabs = cx.update(|_, app| tree.read(app).repos.len());
|
||||
assert_eq!(tabs, 2, "the same work tree is one tab");
|
||||
let recent = cx.update(|_, app| tree.read(app).recent.clone());
|
||||
assert_eq!(
|
||||
recent[0],
|
||||
std::fs::canonicalize(&first).expect("real"),
|
||||
"newest first"
|
||||
);
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.close_active(cx)));
|
||||
assert_eq!(cx.update(|_, app| tree.read(app).active), Some(2));
|
||||
let missing = dir.path().join("gone");
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.recent.insert(0, missing.clone());
|
||||
tree.open_path(missing.clone(), cx)
|
||||
})
|
||||
});
|
||||
settle(cx, |app| !tree.read(app).recent.contains(&missing));
|
||||
}
|
||||
|
||||
pub(crate) fn oid(tree: &Tree) -> Option<String> {
|
||||
let Some(Some(Ok(status))) = tree.active().map(|opened| &opened.status) else {
|
||||
return None;
|
||||
};
|
||||
status.head.oid.clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_linked_worktree_follows_refs_moved_from_the_main_one() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let main = committed(dir.path());
|
||||
let linked = dir.path().join("linked");
|
||||
sh(
|
||||
&main,
|
||||
&[
|
||||
"worktree",
|
||||
"add",
|
||||
"-q",
|
||||
"-b",
|
||||
"linked",
|
||||
linked.to_str().expect("utf-8"),
|
||||
],
|
||||
);
|
||||
std::fs::write(main.join("b.txt"), "b\n").expect("writes");
|
||||
sh(&main, &["add", "b.txt"]);
|
||||
sh(&main, &["commit", "-q", "-m", "second"]);
|
||||
let second = sh(&main, &["rev-parse", "HEAD"]).trim().to_string();
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(linked.clone(), cx)));
|
||||
settle(cx, |app| {
|
||||
branch(tree.read(app)).as_deref() == Some("linked")
|
||||
});
|
||||
sh(&main, &["update-ref", "refs/heads/linked", &second]);
|
||||
settle(cx, |app| {
|
||||
oid(tree.read(app)).as_deref() == Some(second.as_str())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn picked_lines_stage_from_the_window_and_the_picks_clear() {
|
||||
use ely_gpui_component::git::ChangeSection;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("a.txt"), "one\nthree\nfour\n").expect("writes");
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.show("a.txt", ChangeSection::Unstaged, cx)
|
||||
})
|
||||
});
|
||||
let text = |app: &App| {
|
||||
matches!(
|
||||
tree.read(app).shown.as_ref().and_then(|s| s.view.as_ref()),
|
||||
Some(Ok(crate::shown::View::Text { .. }))
|
||||
)
|
||||
};
|
||||
settle(cx, text);
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.pick_line((0, 1), false, cx);
|
||||
tree.pick_line((0, 3), true, cx);
|
||||
})
|
||||
});
|
||||
let picked = cx.update(|_, app| tree.read(app).shown.as_ref().expect("shown").picked.clone());
|
||||
assert_eq!(
|
||||
picked.into_iter().collect::<Vec<_>>(),
|
||||
[(0, 1), (0, 2), (0, 3)],
|
||||
"Shift takes the range"
|
||||
);
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.pick_line((0, 1), false, cx);
|
||||
tree.pick_line((0, 2), false, cx);
|
||||
tree.stage_picked(cx)
|
||||
})
|
||||
});
|
||||
settle(cx, |_| {
|
||||
sh(&root, &["cat-file", "blob", ":a.txt"]) == "one\ntwo\nfour\n"
|
||||
});
|
||||
settle(cx, |app| {
|
||||
tree.read(app)
|
||||
.shown
|
||||
.as_ref()
|
||||
.is_some_and(|shown| shown.picked.is_empty())
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_read_of_another_file_is_dropped() {
|
||||
use ely_gpui_component::git::ChangeSection;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("b.txt"), "before-b\n").expect("writes");
|
||||
sh(&root, &["add", "b.txt"]);
|
||||
sh(&root, &["commit", "-qm", "b"]);
|
||||
std::fs::write(root.join("a.txt"), "after-a\n").expect("writes");
|
||||
std::fs::write(root.join("b.txt"), "after-b\n").expect("writes");
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.show("a.txt", ChangeSection::Unstaged, cx)
|
||||
})
|
||||
});
|
||||
let first = cx.update(|_, app| tree.read(app).reads);
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.show("b.txt", ChangeSection::Unstaged, cx)
|
||||
})
|
||||
});
|
||||
let shows = |app: &App, word: &str| match tree
|
||||
.read(app)
|
||||
.shown
|
||||
.as_ref()
|
||||
.and_then(|shown| shown.view.as_ref())
|
||||
{
|
||||
Some(Ok(crate::shown::View::Text { diff, .. })) => format!("{diff:?}").contains(word),
|
||||
_ => false,
|
||||
};
|
||||
settle(cx, |app| shows(app, "after-b"));
|
||||
// a.txt's read lands after b.txt is shown.
|
||||
let late = crate::shown::read(
|
||||
&super::git(),
|
||||
&root,
|
||||
&super::entry(&root, "a.txt"),
|
||||
ChangeSection::Unstaged,
|
||||
crate::diff::Space::Exact,
|
||||
true,
|
||||
);
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.shown_read(first, late, cx)));
|
||||
assert!(
|
||||
cx.update(|_, app| shows(app, "after-b")),
|
||||
"b.txt stays shown"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_commit_from_the_box_shows_in_history_with_its_files() {
|
||||
use crate::tree::Side;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
for (key, value) in [
|
||||
("user.name", "Ely"),
|
||||
("user.email", "ely@example.com"),
|
||||
("commit.gpgsign", "false"),
|
||||
] {
|
||||
sh(&root, &["config", key, value]);
|
||||
}
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, dir.path());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.set_side(Side::History, cx)));
|
||||
let rows = |app: &App| {
|
||||
tree.read(app)
|
||||
.active()
|
||||
.map_or(0, |opened| opened.hist.rows.len())
|
||||
};
|
||||
settle(cx, |app| rows(app) == 1);
|
||||
std::fs::write(root.join("a.txt"), "changed\n").expect("writes");
|
||||
sh(&root, &["add", "a.txt"]);
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.subject.update(cx, |field, cx| {
|
||||
field.set_text("From the box".to_string(), cx)
|
||||
});
|
||||
tree.commit(cx)
|
||||
})
|
||||
});
|
||||
// HEAD moved, so history rereads on its own.
|
||||
settle(cx, |app| rows(app) == 2);
|
||||
let newest = cx.update(|_, app| tree.read(app).active().expect("open").hist.rows[0].clone());
|
||||
assert_eq!(newest.subject.as_ref(), "From the box");
|
||||
assert!(
|
||||
cx.update(|_, app| tree.read(app).subject.read(app).text().is_empty()),
|
||||
"the box empties"
|
||||
);
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.pick_commit(&newest.id, cx)));
|
||||
let detail = |app: &App| {
|
||||
matches!(
|
||||
tree.read(app)
|
||||
.active()
|
||||
.and_then(|o| o.hist.pick.detail.as_ref()),
|
||||
Some(Ok(_))
|
||||
)
|
||||
};
|
||||
settle(cx, detail);
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.pick_touched(crate::hist::Whose::History, 0, cx)
|
||||
})
|
||||
});
|
||||
let file = |app: &App| {
|
||||
matches!(
|
||||
tree.read(app)
|
||||
.active()
|
||||
.and_then(|o| o.hist.pick.file.as_ref()),
|
||||
Some((0, Some(Ok(_))))
|
||||
)
|
||||
};
|
||||
settle(cx, file);
|
||||
}
|
||||
Reference in new issue
Block a user