Fixes for M-02 to M-14, G-01 to G-14, K-02 to K-04, X-04 to X-14, not yet reviewed
Committed before review so Windows and WSL builds can start. Each fix was tested and mutation-checked here; the codex review of each item is still to come, so tasks/BUGS.md stays unticked. M-02 is mid-review: round 1 findings are fixed (apps/ely-mark/src/tally.rs). K-01 stays deferred. The ElyMark window test mark-outside.sh failed once at its crash-recovery step in the last run; not yet looked into.
This commit is contained in:
75 files changed
+1482
-354
No files matched your search
@@ -128,6 +128,18 @@ pub enum Choice {
|
||||
}
|
||||
|
||||
impl Tree {
|
||||
/// Opens a read's dialog unless another opened since.
|
||||
pub fn offer(&mut self, ask: Ask, cx: &mut Context<Self>) {
|
||||
match (&self.ask, ask) {
|
||||
(None, ask) => self.ask = Some(ask),
|
||||
(Some(_), Ask::Failed { title, detail }) => {
|
||||
self.toast(format!("{title}: {detail}").into(), Severity::Danger, cx)
|
||||
}
|
||||
(Some(_), _) => log::info!("tree: a later dialog stands; read dropped"),
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
pub fn dialog(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let this = cx.weak_entity();
|
||||
let dismiss = {
|
||||
|
||||
@@ -18,7 +18,8 @@ pub struct Ref {
|
||||
/// The commit it names, through an annotated tag.
|
||||
pub commit: String,
|
||||
pub subject: String,
|
||||
pub at: i64,
|
||||
/// When made; a tag on a blob has none.
|
||||
pub at: Option<i64>,
|
||||
/// An annotated tag's own message.
|
||||
pub message: Option<String>,
|
||||
}
|
||||
@@ -103,7 +104,10 @@ pub fn parse(bytes: &[u8]) -> io::Result<Vec<Ref>> {
|
||||
track: (!upstream.is_empty()).then(|| track(tracking)).flatten(),
|
||||
commit: if annotated { peeled } else { object }.to_string(),
|
||||
subject: if annotated { peeled_subject } else { subject }.to_string(),
|
||||
at: at.parse().map_err(|_| bad(record))?,
|
||||
at: match at {
|
||||
"" => None,
|
||||
at => Some(at.parse().map_err(|_| bad(record))?),
|
||||
},
|
||||
message: annotated.then(|| subject.to_string()),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -215,10 +215,12 @@ impl Tree {
|
||||
};
|
||||
let result = self.merge_editor.read(cx).text().to_string();
|
||||
let open = merging.takes.iter().filter(|taken| taken.is_none()).count();
|
||||
if merging.takes.get(at).copied().flatten().is_some() {
|
||||
let words = text(cx, "tree.conflict.taken", &[]);
|
||||
return self.toast(words, Severity::Warning, cx);
|
||||
}
|
||||
// Hand edits that moved the markers end block choices.
|
||||
if merging.takes.get(at).copied().flatten().is_some()
|
||||
|| conflicts(&result, merging.size()).len() != open
|
||||
{
|
||||
if conflicts(&result, merging.size()).len() != open {
|
||||
let words = text(cx, "tree.conflict.hand", &[]);
|
||||
return self.toast(words, Severity::Warning, cx);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ pub fn sides(doing: Option<&Doing>, cx: &App) -> [SharedString; 2] {
|
||||
Op::Rebase => ("tree.ours.rebase", "tree.theirs.rebase"),
|
||||
Op::CherryPick => ("tree.ours.pick", "tree.theirs.pick"),
|
||||
Op::Revert => ("tree.ours.revert", "tree.theirs.revert"),
|
||||
Op::Am => ("tree.ours.am", "tree.theirs.am"),
|
||||
};
|
||||
[
|
||||
text(cx, ours, &[("name", &doing.ours)]),
|
||||
@@ -59,8 +60,14 @@ fn button(
|
||||
impl Tree {
|
||||
/// A stopped operation: continue, skip or abort.
|
||||
pub fn op_alert(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
|
||||
let Some(Some(Ok(Some(doing)))) = self.active().map(|opened| opened.doing.as_ref()) else {
|
||||
return None;
|
||||
let doing = match self.active().map(|opened| opened.doing.as_ref()) {
|
||||
Some(Some(Ok(Some(doing)))) => doing,
|
||||
// A read that failed says so in Git's own words.
|
||||
Some(Some(Err(error))) => {
|
||||
let title = text(cx, "tree.op.unread", &[("error", error)]);
|
||||
return Some(Alert::new("tree-op", Severity::Danger, title).into_any_element());
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
let op = doing.op;
|
||||
let key = match op {
|
||||
@@ -68,6 +75,7 @@ impl Tree {
|
||||
Op::Rebase => "tree.op.rebasing",
|
||||
Op::CherryPick => "tree.op.picking",
|
||||
Op::Revert => "tree.op.reverting",
|
||||
Op::Am => "tree.op.applying",
|
||||
};
|
||||
let title = text(cx, key, &[("ours", &doing.ours), ("theirs", &doing.theirs)]);
|
||||
let mut alert = Alert::new("tree-op", Severity::Warning, title)
|
||||
|
||||
@@ -74,6 +74,13 @@ impl Git {
|
||||
checked(args, output)
|
||||
}
|
||||
|
||||
/// HEAD names a commit; a new repository has none yet.
|
||||
pub fn born(&self, dir: &Path) -> io::Result<bool> {
|
||||
Ok(!self
|
||||
.run_or_one(dir, &["rev-parse", "--verify", "-q", "HEAD"])?
|
||||
.is_empty())
|
||||
}
|
||||
|
||||
/// Exit 1 is an answer: files differ, key unset.
|
||||
pub fn run_or_one(&self, dir: &Path, args: &[&str]) -> io::Result<Vec<u8>> {
|
||||
let output = self.command(dir, args).output()?;
|
||||
|
||||
@@ -95,6 +95,7 @@ fn graph(logged: &[Logged]) -> Vec<GraphRow> {
|
||||
|
||||
impl Tree {
|
||||
pub fn set_side(&mut self, side: crate::tree::Side, cx: &mut Context<Self>) {
|
||||
self.look = None;
|
||||
self.side = side;
|
||||
self.ensure_side(cx);
|
||||
cx.notify();
|
||||
@@ -278,6 +279,7 @@ impl Tree {
|
||||
|
||||
/// Reads a commit, or a stash with its untracked files.
|
||||
pub fn pick_in(&mut self, whose: Whose, commit: &str, cx: &mut Context<Self>) {
|
||||
self.look = None;
|
||||
let Some(git) = self.git.as_ref().ok().cloned() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -34,7 +34,9 @@ pub fn bind(cx: &mut App) {
|
||||
}
|
||||
|
||||
pub fn menus(cx: &App) -> Vec<Menu> {
|
||||
vec![Menu::new(text(cx, "tree.menu.repository", &[])).items([
|
||||
let terminal = crate::tools::TERMINAL
|
||||
.then(|| MenuItem::action(text(cx, "tree.tool.terminal", &[]), OpenTerminal));
|
||||
let items = [
|
||||
MenuItem::action(text(cx, "tree.clone", &[]), CloneRepository),
|
||||
MenuItem::action(text(cx, "tree.close", &[]), CloseRepository),
|
||||
MenuItem::separator(),
|
||||
@@ -43,11 +45,15 @@ pub fn menus(cx: &App) -> Vec<Menu> {
|
||||
MenuItem::action(text(cx, "tree.net.push", &[]), Push),
|
||||
MenuItem::action(text(cx, "tree.force.open", &[]), ForcePush),
|
||||
MenuItem::separator(),
|
||||
MenuItem::action(text(cx, "tree.tool.terminal", &[]), OpenTerminal),
|
||||
MenuItem::action(text(cx, "tree.tool.reveal", &[]), Reveal),
|
||||
]
|
||||
.into_iter()
|
||||
.chain(terminal)
|
||||
.chain([
|
||||
MenuItem::action(text(cx, crate::tools::REVEAL, &[]), Reveal),
|
||||
MenuItem::action(text(cx, "tree.clean.open", &[]), CleanUntracked),
|
||||
MenuItem::action(text(cx, "tree.tool.editor", &[]), ChooseEditor),
|
||||
])]
|
||||
]);
|
||||
vec![Menu::new(text(cx, "tree.menu.repository", &[])).items(items)]
|
||||
}
|
||||
|
||||
impl Tree {
|
||||
|
||||
@@ -66,6 +66,9 @@ impl Tree {
|
||||
let task = cx.background_spawn(async move { ops::doing(&git, &root, &dir) });
|
||||
cx.spawn(async move |this, cx| {
|
||||
let read = task.await.map_err(|error| error.to_string());
|
||||
if let Err(error) = &read {
|
||||
log::error!("tree: what Git is doing did not read: {error}");
|
||||
}
|
||||
this.update(cx, |tree, cx| {
|
||||
if let Some(opened) = tree
|
||||
.opened_mut(id)
|
||||
|
||||
@@ -123,7 +123,7 @@ impl Tree {
|
||||
};
|
||||
net.stopping = true;
|
||||
if let Err(error) = net.stop.now() {
|
||||
self.failed("tree.error.stop", &error.to_string(), cx);
|
||||
self.failed("tree.error.stop_remote", &error.to_string(), cx);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
@@ -183,7 +183,7 @@ impl Tree {
|
||||
if tree.active != Some(repo) {
|
||||
return;
|
||||
}
|
||||
tree.ask = Some(match read {
|
||||
let ask = match read {
|
||||
Ok(list) if list.is_empty() => {
|
||||
let words = text(cx, "tree.push.no_remote", &[]);
|
||||
return tree.toast(words, Severity::Warning, cx);
|
||||
@@ -198,8 +198,8 @@ impl Tree {
|
||||
title: text(cx, "tree.remotes.unread", &[]),
|
||||
detail: error.to_string().into(),
|
||||
},
|
||||
});
|
||||
cx.notify();
|
||||
};
|
||||
tree.offer(ask, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
@@ -234,14 +234,14 @@ impl Tree {
|
||||
if tree.active != Some(repo) {
|
||||
return;
|
||||
}
|
||||
tree.ask = Some(match plan {
|
||||
let ask = match plan {
|
||||
Ok(plan) => Ask::Force { repo, plan },
|
||||
Err(error) => Ask::Failed {
|
||||
title: text(cx, "tree.force.unready", &[]),
|
||||
detail: error.to_string().into(),
|
||||
},
|
||||
});
|
||||
cx.notify();
|
||||
};
|
||||
tree.offer(ask, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
@@ -262,11 +262,10 @@ impl Tree {
|
||||
.on_click(move |_, _, cx| {
|
||||
this.update(cx, |tree, cx| tree.stop_remote(id, cx)).ok();
|
||||
});
|
||||
Some(
|
||||
Alert::new("tree-net", Severity::Info, verb)
|
||||
.body(net.said.clone())
|
||||
.action(stop.into_any_element())
|
||||
.into_any_element(),
|
||||
)
|
||||
let alert = Alert::new("tree-net", Severity::Info, verb).body(net.said.clone());
|
||||
if !crate::running::STOPS {
|
||||
return Some(alert.into_any_element());
|
||||
}
|
||||
Some(alert.action(stop.into_any_element()).into_any_element())
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ pub enum Op {
|
||||
Rebase,
|
||||
CherryPick,
|
||||
Revert,
|
||||
/// `git am`, applying mailed patches.
|
||||
Am,
|
||||
}
|
||||
|
||||
impl Op {
|
||||
@@ -20,6 +22,7 @@ impl Op {
|
||||
Op::Rebase => "rebase",
|
||||
Op::CherryPick => "cherry-pick",
|
||||
Op::Revert => "revert",
|
||||
Op::Am => "am",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +81,18 @@ pub fn doing(git: &Git, root: &Path, git_dir: &Path) -> io::Result<Option<Doing>
|
||||
let read = |name: &str| {
|
||||
std::fs::read_to_string(git_dir.join(name)).map(|text| text.trim().to_string())
|
||||
};
|
||||
let doing = if has("rebase-merge") || has("rebase-apply") {
|
||||
// `git am` keeps rebase-apply too, without an onto.
|
||||
let doing = if has("rebase-apply/applying") {
|
||||
Doing {
|
||||
op: Op::Am,
|
||||
ours: head(git, root)?,
|
||||
theirs: format!(
|
||||
"{}/{}",
|
||||
read("rebase-apply/next")?,
|
||||
read("rebase-apply/last")?
|
||||
),
|
||||
}
|
||||
} else if has("rebase-merge") || has("rebase-apply") {
|
||||
let dir = if has("rebase-merge") {
|
||||
"rebase-merge"
|
||||
} else {
|
||||
|
||||
@@ -45,6 +45,8 @@ impl Tree {
|
||||
/// Shows `path` as listed in `section`.
|
||||
pub fn show(&mut self, path: &str, section: ChangeSection, cx: &mut Context<Self>) {
|
||||
let Some(repo) = self.active else { return };
|
||||
// A new pick leaves any file history or blame.
|
||||
self.look = None;
|
||||
self.shown = Some(Shown {
|
||||
repo,
|
||||
section,
|
||||
|
||||
@@ -94,12 +94,15 @@ impl Tree {
|
||||
}
|
||||
|
||||
pub fn pick_place(&mut self, place: Place, cx: &mut Context<Self>) {
|
||||
self.look = None;
|
||||
match &place {
|
||||
Place::Stash(oid) => self.pick_in(Whose::Stash, oid, cx),
|
||||
Place::Reflog(moved) => self.pick_in(Whose::Reflog, &moved.oid, cx),
|
||||
_ => {}
|
||||
}
|
||||
self.picked_place = Some(place);
|
||||
if let Some(opened) = self.active.and_then(|id| self.opened_mut(id)) {
|
||||
opened.picked_place = Some(place);
|
||||
}
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
|
||||
@@ -125,7 +125,7 @@ impl Tree {
|
||||
// The picked entry itself, while it still names this commit.
|
||||
let moved = match (
|
||||
self.active().and_then(|o| o.places.read.as_ref()),
|
||||
&self.picked_place,
|
||||
self.active().and_then(|o| o.picked_place.as_ref()),
|
||||
) {
|
||||
(
|
||||
Some(Read {
|
||||
@@ -187,7 +187,7 @@ impl Tree {
|
||||
let Some(opened) = self.active() else {
|
||||
return none();
|
||||
};
|
||||
let (Some(read), Some(place)) = (&opened.places.read, &self.picked_place) else {
|
||||
let (Some(read), Some(place)) = (&opened.places.read, &opened.picked_place) else {
|
||||
return none();
|
||||
};
|
||||
let label = |key: &str| text(cx, key, &[]);
|
||||
|
||||
@@ -48,6 +48,9 @@ impl Moved {
|
||||
const KEEP: usize = 200;
|
||||
|
||||
pub fn reflog(git: &Git, root: &Path) -> io::Result<Vec<Moved>> {
|
||||
if !git.born(root)? {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// With a date mode, `%gd` carries when HEAD moved.
|
||||
let format = "--format=%H%x00%gd%x00%gs";
|
||||
let count = format!("-n{KEEP}");
|
||||
|
||||
@@ -113,14 +113,14 @@ impl Tree {
|
||||
if tree.active != Some(repo) {
|
||||
return;
|
||||
}
|
||||
tree.ask = Some(match plan {
|
||||
let ask = match plan {
|
||||
Ok(plan) => Ask::Move { repo, plan },
|
||||
Err(error) => Ask::Failed {
|
||||
title: text(cx, "tree.op.failed", &[]),
|
||||
detail: error.to_string().into(),
|
||||
},
|
||||
});
|
||||
cx.notify();
|
||||
};
|
||||
tree.offer(ask, cx);
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
|
||||
@@ -24,7 +24,7 @@ impl Tree {
|
||||
pub fn refs_pane(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let found = self
|
||||
.active()
|
||||
.and_then(|opened| match (&opened.refs, &self.picked_ref) {
|
||||
.and_then(|opened| match (&opened.refs, &opened.picked_ref) {
|
||||
(Some(Ok(refs)), Some((kind, name))) => refs
|
||||
.iter()
|
||||
.find(|r| r.kind == *kind && r.name == *name)
|
||||
@@ -50,7 +50,10 @@ impl Tree {
|
||||
found.subject
|
||||
),
|
||||
)
|
||||
.text(label("tree.ref.when"), when(found.at));
|
||||
.text(
|
||||
label("tree.ref.when"),
|
||||
found.at.map_or_else(|| "—".into(), when),
|
||||
);
|
||||
if let Some(upstream) = &found.upstream {
|
||||
let track = match found.track {
|
||||
Some((ahead, behind)) => format!("{upstream} ↑{ahead} ↓{behind}"),
|
||||
|
||||
@@ -7,8 +7,6 @@ use std::{
|
||||
|
||||
use crate::{git::Git, running::Running};
|
||||
|
||||
/// How many repositories the recent list keeps.
|
||||
|
||||
/// A work tree and its Git directory.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Repo {
|
||||
|
||||
@@ -154,6 +154,9 @@ impl Stop {
|
||||
}
|
||||
}
|
||||
|
||||
/// Only Unix stops Git; elsewhere no stop shows.
|
||||
pub const STOPS: bool = cfg!(unix);
|
||||
|
||||
/// Its own session: the group is Git and its helpers.
|
||||
#[cfg(unix)]
|
||||
fn terminate(pid: u32) -> io::Result<()> {
|
||||
|
||||
@@ -89,7 +89,7 @@ pub fn places(tree: &Entity<Tree>, cx: &mut Context<Changes>) -> AnyElement {
|
||||
let Some(read) = &opened.places.read else {
|
||||
return div().into_any_element();
|
||||
};
|
||||
let picked = read_tree.picked_place.clone();
|
||||
let picked = opened.picked_place.clone();
|
||||
let label = |key: &str| text(cx, key, &[]);
|
||||
let pick = |place: Place| {
|
||||
let this = tree.downgrade();
|
||||
@@ -124,26 +124,33 @@ pub fn places(tree: &Entity<Tree>, cx: &mut Context<Changes>) -> AnyElement {
|
||||
Box::new(|t, _, cx| t.ask_force(cx)),
|
||||
),
|
||||
]);
|
||||
let local = div().flex().flex_wrap().gap_1().children([
|
||||
let terminal = crate::tools::TERMINAL.then(|| {
|
||||
button(
|
||||
tree,
|
||||
"tree-terminal",
|
||||
label("tree.tool.terminal"),
|
||||
Box::new(|t, _, cx| t.open_terminal(cx)),
|
||||
),
|
||||
button(
|
||||
tree,
|
||||
"tree-reveal",
|
||||
label("tree.tool.reveal"),
|
||||
Box::new(|t, _, cx| t.reveal(cx)),
|
||||
),
|
||||
button(
|
||||
tree,
|
||||
"tree-clean",
|
||||
label("tree.clean.open"),
|
||||
Box::new(|t, _, cx| t.ask_clean(cx)),
|
||||
),
|
||||
]);
|
||||
)
|
||||
});
|
||||
let local = div()
|
||||
.flex()
|
||||
.flex_wrap()
|
||||
.gap_1()
|
||||
.children(terminal)
|
||||
.children([
|
||||
button(
|
||||
tree,
|
||||
"tree-reveal",
|
||||
label(crate::tools::REVEAL),
|
||||
Box::new(|t, _, cx| t.reveal(cx)),
|
||||
),
|
||||
button(
|
||||
tree,
|
||||
"tree-clean",
|
||||
label("tree.clean.open"),
|
||||
Box::new(|t, _, cx| t.ask_clean(cx)),
|
||||
),
|
||||
]);
|
||||
let mut out: Vec<AnyElement> = vec![net.into_any_element(), local.into_any_element()];
|
||||
out.push(heading(
|
||||
label("tree.places.stashes"),
|
||||
|
||||
@@ -28,7 +28,7 @@ fn branch(found: &Ref) -> Branch {
|
||||
ahead,
|
||||
behind,
|
||||
subject: found.subject.clone().into(),
|
||||
when: when(found.at),
|
||||
when: found.at.map_or_else(|| "—".into(), when),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ fn tag(found: &Ref) -> GitTag {
|
||||
name: found.name.clone().into(),
|
||||
message: found.message.clone().map(Into::into),
|
||||
commit: found.commit.clone().into(),
|
||||
when: when(found.at),
|
||||
when: found.at.map_or_else(|| "—".into(), when),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ pub fn branches(tree: &Entity<Tree>, cx: &mut Context<Changes>) -> AnyElement {
|
||||
}
|
||||
Some(Ok(refs)) => refs,
|
||||
};
|
||||
let picked = read.picked_ref.clone();
|
||||
let picked = opened.picked_ref.clone();
|
||||
// A name shows picked only in its own kind's list.
|
||||
let picked_in = move |kind: Kind| {
|
||||
picked
|
||||
@@ -72,7 +72,10 @@ pub fn branches(tree: &Entity<Tree>, cx: &mut Context<Changes>) -> AnyElement {
|
||||
move |name: &gpui::SharedString, _: &mut gpui::Window, cx: &mut gpui::App| {
|
||||
let name = name.to_string();
|
||||
this.update(cx, |tree, cx| {
|
||||
tree.picked_ref = Some((kind, name));
|
||||
tree.look = None;
|
||||
if let Some(opened) = tree.active.and_then(|id| tree.opened_mut(id)) {
|
||||
opened.picked_ref = Some((kind, name));
|
||||
}
|
||||
cx.notify();
|
||||
})
|
||||
.ok();
|
||||
|
||||
@@ -64,6 +64,14 @@ pub fn worktrees(git: &Git, root: &Path) -> io::Result<Vec<Worktree>> {
|
||||
}
|
||||
}
|
||||
found.extend(now);
|
||||
// Real paths, as the open repository's root is.
|
||||
for tree in &mut found {
|
||||
match std::fs::canonicalize(&tree.path) {
|
||||
Ok(real) => tree.path = real,
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
@@ -135,6 +143,10 @@ pub struct LfsFile {
|
||||
|
||||
/// `git lfs ls-files` as JSON; `files` is null with none.
|
||||
pub fn lfs(git: &Git, root: &Path) -> io::Result<Vec<LfsFile>> {
|
||||
// LFS reads HEAD first; before a commit there is nothing.
|
||||
if !git.born(root)? {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let out = git.run(root, &["lfs", "ls-files", "--long", "--json"])?;
|
||||
let read: serde_json::Value = serde_json::from_slice(&out).map_err(io::Error::other)?;
|
||||
let files = match &read["files"] {
|
||||
|
||||
@@ -428,3 +428,21 @@ fn marker_sizes_git_cannot_read_mean_seven() {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tag_on_a_blob_has_no_date_and_spoils_no_list() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
sh(&root, &["tag", "blobtag", "HEAD:a.txt"]);
|
||||
let refs = crate::branches::read(&git(), &root).expect("the list reads");
|
||||
let tag = refs
|
||||
.iter()
|
||||
.find(|r| r.name == "blobtag")
|
||||
.expect("the tag is listed");
|
||||
assert_eq!(tag.at, None);
|
||||
assert!(
|
||||
refs.iter()
|
||||
.filter(|r| r.name != "blobtag")
|
||||
.all(|r| r.at.is_some())
|
||||
);
|
||||
}
|
||||
@@ -182,3 +182,60 @@ fn twin_reflog_events_stay_apart() {
|
||||
.collect();
|
||||
assert_eq!(found, [2], "it moved one down, alone");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_shown_file_deleted_since_says_it_changed() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
std::fs::write(root.join("gone.txt"), "x").expect("writes");
|
||||
let clean = recover::clean_preview(&git(), &root, false).expect("previews");
|
||||
std::fs::remove_file(root.join("gone.txt")).expect("removes");
|
||||
let write = Write::Clean {
|
||||
files: clean.files,
|
||||
dirs: clean.dirs,
|
||||
ignored: false,
|
||||
};
|
||||
let error = run(&root, write).expect_err("refused");
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("gone.txt changed since the preview"),
|
||||
"{error}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pick_in_one_repository_stays_there() {
|
||||
let (one, other) = (
|
||||
tempfile::tempdir().expect("a temp dir"),
|
||||
tempfile::tempdir().expect("a temp dir"),
|
||||
);
|
||||
let (first, second) = (committed(one.path()), committed(other.path()));
|
||||
let mut cx = TestAppContext::single();
|
||||
let (tree, cx) = window(&mut cx, one.path());
|
||||
for root in [&first, &second] {
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.open_path(root.clone(), cx)));
|
||||
}
|
||||
settle(cx, |app| {
|
||||
tree.read(app).repos.len() == 2 && branch(tree.read(app)).is_some()
|
||||
});
|
||||
let ids = cx.update(|_, app| {
|
||||
tree.read(app)
|
||||
.repos
|
||||
.iter()
|
||||
.map(|o| o.id)
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.activate(Some(ids[0]), cx);
|
||||
tree.pick_place(crate::places::Place::Remote("origin".into()), cx);
|
||||
tree.activate(Some(ids[1]), cx);
|
||||
})
|
||||
});
|
||||
assert!(cx.update(|_, app| {
|
||||
tree.read(app)
|
||||
.active()
|
||||
.is_some_and(|o| o.picked_place.is_none())
|
||||
}));
|
||||
}
|
||||
@@ -256,3 +256,67 @@ fn a_pick_that_conflicts_stops_without_a_dialog() {
|
||||
});
|
||||
assert!(cx.update(|_, app| tree.read(app).ask.is_none()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_git_am_stopped_on_a_conflict_says_so() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let (root, _) = conflicted(dir.path());
|
||||
let patch = sh(&root, &["format-patch", "-1", "side", "--stdout"]);
|
||||
std::fs::write(dir.path().join("p.patch"), patch).expect("writes");
|
||||
// `git am` stops on the conflict, as in a terminal.
|
||||
let stopped = super::git().run(
|
||||
&root,
|
||||
&[
|
||||
"am",
|
||||
"--3way",
|
||||
dir.path().join("p.patch").to_str().expect("utf-8"),
|
||||
],
|
||||
);
|
||||
assert!(stopped.is_err(), "the patch conflicts");
|
||||
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)));
|
||||
let op = |app: &App| match tree.read(app).active().and_then(|o| o.doing.clone()) {
|
||||
Some(Ok(Some(doing))) => Some((doing.op, doing.theirs)),
|
||||
_ => None,
|
||||
};
|
||||
settle(cx, |app| op(app).is_some());
|
||||
assert_eq!(
|
||||
cx.update(|_, app| op(app)),
|
||||
Some((crate::ops::Op::Am, "1/1".to_string()))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_settled_conflict_says_so_when_picked_again() {
|
||||
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());
|
||||
let shell = cx.update(|window, app| {
|
||||
let shell = ely_ui::tool_root(
|
||||
ely_platform::AppId::Tree,
|
||||
tree.clone().into(),
|
||||
Vec::new(),
|
||||
window,
|
||||
app,
|
||||
);
|
||||
tree.update(app, |tree, _| tree.root = shell.downgrade());
|
||||
shell
|
||||
});
|
||||
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);
|
||||
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)));
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.take(0, Take::Ours, cx)));
|
||||
let want = cx.update(|_, app| ely_ui::i18n::text(app, "tree.conflict.taken", &[]));
|
||||
let toasts = cx.update(|_, app| shell.read(app).toasts(app));
|
||||
assert!(toasts.iter().any(|(text, _)| *text == want), "{toasts:?}");
|
||||
}
|
||||
@@ -433,3 +433,41 @@ fn a_stash_with_an_empty_message_still_lists() {
|
||||
assert_eq!(shown, ["", "named"]);
|
||||
assert_eq!(list[0].oid, oid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_repository_lists_no_moves_and_no_lfs_files() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = dir.path().join("new");
|
||||
crate::repo::init(&git(), &root).expect("init");
|
||||
assert_eq!(
|
||||
crate::recover::reflog(&git(), &root).expect("no reflog error"),
|
||||
Vec::new()
|
||||
);
|
||||
assert_eq!(
|
||||
crate::structure::lfs(&git(), &root).expect("no LFS error"),
|
||||
Vec::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_worktree_registered_through_a_link_lists_its_real_path() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
let other = dir.path().join("tree");
|
||||
let path = other.to_str().expect("utf-8");
|
||||
run(
|
||||
&root,
|
||||
Write::Git(words(&["worktree", "add", "-b", "side", "--", path])),
|
||||
)
|
||||
.expect("adds");
|
||||
// Other tools may write the path through a link.
|
||||
std::os::unix::fs::symlink(dir.path(), dir.path().join("link")).expect("links");
|
||||
let gitdir = root.join(".git/worktrees/tree/gitdir");
|
||||
std::fs::write(
|
||||
&gitdir,
|
||||
format!("{}\n", dir.path().join("link/tree/.git").display()),
|
||||
)
|
||||
.expect("writes");
|
||||
let list = structure::worktrees(&git(), &root).expect("reads");
|
||||
assert_eq!(list[1].path, std::fs::canonicalize(&other).expect("real"));
|
||||
}
|
||||
@@ -343,7 +343,12 @@ fn a_picked_reflog_event_stays_itself_as_others_arrive() {
|
||||
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)));
|
||||
assert!(cx.update(|_, app| {
|
||||
tree.read(app)
|
||||
.active()
|
||||
.and_then(|o| o.picked_place.as_ref())
|
||||
== Some(&picked)
|
||||
}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -383,3 +388,29 @@ fn a_held_config_lock_waits_for_a_retry_too() {
|
||||
String::from_utf8_lossy(&read).trim() == "Locked Out"
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_read_that_lands_late_leaves_the_users_dialog_open() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = committed(dir.path());
|
||||
let head = sh(&root, &["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(root.clone(), cx)));
|
||||
settle(cx, |app| branch(tree.read(app)).is_some());
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.ask_move(&head, cx);
|
||||
tree.ask = Some(Ask::Failed {
|
||||
title: "mine".into(),
|
||||
detail: "".into(),
|
||||
});
|
||||
})
|
||||
});
|
||||
cx.run_until_parked();
|
||||
let ask = cx.update(|_, app| match &tree.read(app).ask {
|
||||
Some(Ask::Failed { title, .. }) => title.to_string(),
|
||||
_ => "replaced".into(),
|
||||
});
|
||||
assert_eq!(ask, "mine");
|
||||
}
|
||||
@@ -320,3 +320,144 @@ fn a_commit_from_the_box_shows_in_history_with_its_files() {
|
||||
};
|
||||
settle(cx, file);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn any_new_pick_leaves_a_file_history() {
|
||||
use crate::tree::Side;
|
||||
use ely_gpui_component::git::ChangeSection;
|
||||
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());
|
||||
let head = sh(&root, &["rev-parse", "HEAD"]).trim().to_string();
|
||||
let looking = |cx: &mut VisualTestContext| cx.update(|_, app| tree.read(app).look.is_some());
|
||||
let picks: [(&str, Box<dyn Fn(&mut Tree, &mut gpui::Context<Tree>)>); 3] = [
|
||||
(
|
||||
"a tab",
|
||||
Box::new(|tree, cx| tree.set_side(Side::Branches, cx)),
|
||||
),
|
||||
(
|
||||
"a commit",
|
||||
Box::new(move |tree, cx| tree.pick_commit(&head, cx)),
|
||||
),
|
||||
(
|
||||
"a file",
|
||||
Box::new(|tree, cx| tree.show("a.txt", ChangeSection::Unstaged, cx)),
|
||||
),
|
||||
];
|
||||
for (what, pick) in picks {
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.file_log("a.txt", cx)));
|
||||
assert!(looking(cx), "the history opens");
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| pick(tree, cx)));
|
||||
assert!(!looking(cx), "{what} leaves the history");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_result_that_did_not_save_says_so() {
|
||||
use crate::work::Write;
|
||||
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());
|
||||
// The window's toasts live on its root.
|
||||
let shell = cx.update(|window, app| {
|
||||
let shell = ely_ui::tool_root(
|
||||
ely_platform::AppId::Tree,
|
||||
tree.clone().into(),
|
||||
Vec::new(),
|
||||
window,
|
||||
app,
|
||||
);
|
||||
tree.update(app, |tree, _| tree.root = shell.downgrade());
|
||||
shell
|
||||
});
|
||||
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").id);
|
||||
let save = Write::Save {
|
||||
path: "a.txt".into(),
|
||||
text: "merged".into(),
|
||||
};
|
||||
cx.update(|_, app| {
|
||||
tree.update(app, |tree, cx| {
|
||||
tree.written(
|
||||
id,
|
||||
&save,
|
||||
Err(std::io::Error::other("disk full")),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
});
|
||||
let want =
|
||||
cx.update(|_, app| ely_ui::i18n::text(app, "tree.error.save", &[("error", "disk full")]));
|
||||
let toasts = cx.update(|_, app| shell.read(app).toasts(app));
|
||||
assert!(toasts.iter().any(|(text, _)| *text == want), "{toasts:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unstaging_right_after_a_first_commit_deletes_nothing() {
|
||||
use crate::work::Write;
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let root = dir.path().join("new");
|
||||
crate::repo::init(&super::git(), &root).expect("init");
|
||||
std::fs::write(root.join("a.txt"), "one\n").expect("writes");
|
||||
sh(&root, &["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(root.clone(), cx)));
|
||||
settle(cx, |app| {
|
||||
tree.read(app)
|
||||
.active()
|
||||
.is_some_and(|opened| matches!(opened.status, Some(Ok(_))))
|
||||
});
|
||||
let id = cx.update(|_, app| tree.read(app).active().expect("open").id);
|
||||
// Committed outside; the status has not read it yet.
|
||||
sh(&root, &["commit", "-qm", "first"]);
|
||||
cx.update(|_, app| tree.update(app, |tree, cx| tree.write(id, Write::UnstageAll, cx)));
|
||||
settle(cx, |app| {
|
||||
tree.read(app)
|
||||
.active()
|
||||
.is_some_and(|opened| !opened.writing && opened.writes.is_empty())
|
||||
});
|
||||
assert_eq!(
|
||||
sh(&root, &["status", "--porcelain"]),
|
||||
"",
|
||||
"nothing staged for deletion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_repository_menu_offers_only_what_this_platform_builds() {
|
||||
let dir = tempfile::tempdir().expect("a temp dir");
|
||||
let mut cx = TestAppContext::single();
|
||||
let (_tree, cx) = window(&mut cx, dir.path());
|
||||
let names = cx.update(|_, app| {
|
||||
crate::menu::menus(app)[0]
|
||||
.items
|
||||
.iter()
|
||||
.filter_map(|item| match item {
|
||||
gpui::MenuItem::Action { name, .. } => Some(name.to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let mut said = |key: &str| cx.update(|_, app| ely_ui::i18n::text(app, key, &[]).to_string());
|
||||
let (terminal, reveal, clone) = (
|
||||
said("tree.tool.terminal"),
|
||||
said(crate::tools::REVEAL),
|
||||
said("tree.clone"),
|
||||
);
|
||||
assert_eq!(
|
||||
names.contains(&terminal),
|
||||
crate::tools::TERMINAL,
|
||||
"{names:?}"
|
||||
);
|
||||
assert!(
|
||||
names.contains(&reveal) && names.contains(&clone),
|
||||
"{names:?}"
|
||||
);
|
||||
}
|
||||
@@ -97,6 +97,16 @@ fn launch(editor: &Path, file: &Path) -> io::Result<()> {
|
||||
.map_err(|error| io::Error::other(format!("{}: {error}", editor.display())))
|
||||
}
|
||||
|
||||
/// Only macOS opens a terminal; elsewhere it is hidden.
|
||||
pub const TERMINAL: bool = cfg!(target_os = "macos");
|
||||
|
||||
/// The reveal entry, named for this platform's file browser.
|
||||
pub const REVEAL: &str = if cfg!(target_os = "macos") {
|
||||
"tree.tool.reveal"
|
||||
} else {
|
||||
"tree.tool.reveal_folder"
|
||||
};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn terminal_at(dir: &Path) -> io::Result<()> {
|
||||
Command::new("open")
|
||||
|
||||
@@ -54,6 +54,9 @@ pub struct Opened {
|
||||
/// Identity reads started; only the latest lands.
|
||||
pub me_asks: u64,
|
||||
pub places: crate::places::Places,
|
||||
/// The branch or tag picked in the Branches panel.
|
||||
pub picked_ref: Option<(crate::branches::Kind, String)>,
|
||||
pub picked_place: Option<crate::places::Place>,
|
||||
pub net: Option<crate::network::Net>,
|
||||
/// Kept alive; dropping it stops the watch.
|
||||
_watcher: Option<RecommendedWatcher>,
|
||||
@@ -103,9 +106,6 @@ pub struct Tree {
|
||||
pub amend_loading: bool,
|
||||
/// File history and blame opened, numbering each.
|
||||
pub looks: u64,
|
||||
/// The branch or tag picked in the Branches panel.
|
||||
pub picked_ref: Option<(crate::branches::Kind, String)>,
|
||||
pub picked_place: Option<crate::places::Place>,
|
||||
/// The conflicted file being merged by hand.
|
||||
pub merging: Option<crate::conflict::Merging>,
|
||||
pub merge_editor: Entity<ely_gpui_component::editor::CodeEditor>,
|
||||
@@ -220,8 +220,6 @@ impl Tree {
|
||||
amend_ask: 0,
|
||||
amend_loading: false,
|
||||
looks: 0,
|
||||
picked_ref: None,
|
||||
picked_place: None,
|
||||
merging: None,
|
||||
merge_editor,
|
||||
_searching: searching,
|
||||
@@ -364,6 +362,8 @@ impl Tree {
|
||||
me: None,
|
||||
me_asks: 0,
|
||||
places: Default::default(),
|
||||
picked_ref: None,
|
||||
picked_place: None,
|
||||
net: None,
|
||||
_watcher: watcher,
|
||||
});
|
||||
|
||||
@@ -168,16 +168,18 @@ impl Tree {
|
||||
}
|
||||
if let Some(cloning) = &self.cloning {
|
||||
let this = cx.weak_entity();
|
||||
let title = text(cx, "tree.cloning", &[("name", &name(&cloning.into))]);
|
||||
let state = EmptyState::new("tree-cloning", IconName::Download, title)
|
||||
.body(cloning.said.clone());
|
||||
if !crate::running::STOPS {
|
||||
return state.into_any_element();
|
||||
}
|
||||
let stop = Button::new("tree-clone-stop", text(cx, "tree.clone.stop", &[]))
|
||||
.disabled(cloning.stopping)
|
||||
.on_click(move |_, _, cx| {
|
||||
this.update(cx, |tree, cx| tree.stop_clone(cx)).ok();
|
||||
});
|
||||
let title = text(cx, "tree.cloning", &[("name", &name(&cloning.into))]);
|
||||
return EmptyState::new("tree-cloning", IconName::Download, title)
|
||||
.body(cloning.said.clone())
|
||||
.action(stop)
|
||||
.into_any_element();
|
||||
return state.action(stop).into_any_element();
|
||||
}
|
||||
let Some(opened) = self.active() else {
|
||||
return self.start_page(cx);
|
||||
|
||||
@@ -267,7 +267,17 @@ fn as_shown(root: &std::path::Path, path: &str, folder: bool) -> io::Result<()>
|
||||
let parts: Vec<&str> = path.trim_end_matches('/').split('/').collect();
|
||||
for (ix, part) in parts.iter().enumerate() {
|
||||
at.push(part);
|
||||
let kind = std::fs::symlink_metadata(&at)?.file_type();
|
||||
// Gone since the preview is a change; others name paths.
|
||||
let kind = match std::fs::symlink_metadata(&at) {
|
||||
Ok(meta) => meta.file_type(),
|
||||
Err(error) if error.kind() == io::ErrorKind::NotFound => return Err(changed()),
|
||||
Err(error) => {
|
||||
return Err(io::Error::new(
|
||||
error.kind(),
|
||||
format!("{}: {error}", at.display()),
|
||||
));
|
||||
}
|
||||
};
|
||||
let last = ix + 1 == parts.len();
|
||||
if (!last && !kind.is_dir()) || (last && kind.is_dir() != folder) {
|
||||
return Err(changed());
|
||||
@@ -349,15 +359,18 @@ impl Tree {
|
||||
let Some(write) = opened.writes.pop_front() else {
|
||||
return;
|
||||
};
|
||||
let born = matches!(&opened.status, Some(Ok(status)) if status.head.oid.is_some());
|
||||
opened.writing = true;
|
||||
if let Write::Remote(args) = write {
|
||||
return self.start_remote(id, args, cx);
|
||||
}
|
||||
let (root, args) = (opened.repo.root.clone(), write.args(born));
|
||||
log::info!("tree: {:?} in {}", args.first(), root.display());
|
||||
let root = opened.repo.root.clone();
|
||||
let task = cx.background_spawn(async move {
|
||||
let done = run(&git, &root, &write, &args);
|
||||
// Asked now, not cached: a commit may just have landed.
|
||||
let done = git.born(&root).and_then(|born| {
|
||||
let args = write.args(born);
|
||||
log::info!("tree: {:?} in {}", args.first(), root.display());
|
||||
run(&git, &root, &write, &args)
|
||||
});
|
||||
let stopped = match (&write, &done) {
|
||||
(Write::Git(args), Err(error)) => {
|
||||
ops::stopped(&git, &root, args, &error.to_string())
|
||||
@@ -411,6 +424,10 @@ impl Tree {
|
||||
}
|
||||
(Write::Config { .. }, Ok(())) => self.read_me(id, cx),
|
||||
(Write::Save { path, text }, Ok(())) => self.merge_saved(id, path, text, cx),
|
||||
// A file write, not Git: the result did not save.
|
||||
(Write::Save { .. }, Err(error)) => {
|
||||
self.failed("tree.error.save", &error.to_string(), cx)
|
||||
}
|
||||
// A conflict stop is no failure; Git says which.
|
||||
(
|
||||
Write::Git(_)
|
||||
|
||||
Reference in new issue
Block a user