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:
ZacharyZhang-NY committed 2026-10-08 21:20:08 -04:00
commit b7fca0146d
817 files changed
+143101

No files matched your search

+227
View File
@@ -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);
}
+430
View File
@@ -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}"
);
}
}
+184
View File
@@ -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");
}
+450
View File
@@ -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"
);
}
+258
View File
@@ -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()));
}
+345
View File
@@ -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"
);
}
+282
View File
@@ -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");
}
+435
View File
@@ -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"), &copy)
.expect("starts")
.follow(|_| {})
.expect("clones");
let list = structure::submodules(&git(), &copy).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(&copy, &update);
let list = structure::submodules(&git(), &copy).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(), &copy).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);
}
+385
View File
@@ -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"
});
}
+322
View File
@@ -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);
}