ElyDoc, ElyTable, ElyShow (LibreOfficeKit), ElyPDF (MuPDF), ElyTree (Git), ElyMark (Milkdown and CodeMirror in CEF). ElyMail and ElyAgent follow.
486 lines
16 KiB
Rust
486 lines
16 KiB
Rust
//! Stage and discard, one write at a time (§13.3).
|
|
|
|
use std::{io, sync::Arc};
|
|
|
|
use ely_gpui_component::{
|
|
git::{ChangeAction, ChangeSection},
|
|
primitives::Severity,
|
|
};
|
|
use gpui::{AppContext as _, Context, SharedString};
|
|
|
|
use crate::{
|
|
asks::Ask,
|
|
git::Git,
|
|
ops, recover,
|
|
status::{Entry, State},
|
|
tree::{Tree, text},
|
|
};
|
|
|
|
/// A write to the index or the work tree.
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
pub enum Write {
|
|
Stage(Entry),
|
|
Unstage(Entry),
|
|
Discard(Entry),
|
|
StageAll,
|
|
UnstageAll,
|
|
/// The staged changes, or amending the last commit.
|
|
Commit {
|
|
message: String,
|
|
amend: bool,
|
|
},
|
|
/// A merge result into its work file, between Git's writes.
|
|
Save {
|
|
path: String,
|
|
text: String,
|
|
},
|
|
/// A branch, tag, history or conflict command, as given.
|
|
Git(Vec<String>),
|
|
/// Talks to a remote: progress shows, and it can stop.
|
|
Remote(Vec<String>),
|
|
/// `reset --keep to`, once HEAD is still where shown.
|
|
Move {
|
|
branch: Option<String>,
|
|
head: String,
|
|
to: String,
|
|
},
|
|
/// The shown files, then folders left empty.
|
|
Clean {
|
|
files: Vec<String>,
|
|
dirs: Vec<String>,
|
|
ignored: bool,
|
|
},
|
|
/// `stash <verb> stash@{at}`, once it is still `oid`.
|
|
Stash {
|
|
verb: &'static str,
|
|
at: usize,
|
|
oid: String,
|
|
},
|
|
/// `git config` for this repository, or for the user.
|
|
Config {
|
|
key: String,
|
|
value: String,
|
|
global: bool,
|
|
},
|
|
/// Picked lines; `check` rereads the diff they were cut from.
|
|
Patch {
|
|
patch: String,
|
|
reverse: bool,
|
|
check: Vec<String>,
|
|
seen: Arc<Vec<u8>>,
|
|
},
|
|
}
|
|
|
|
impl Write {
|
|
/// Git's arguments; `born` is false before the first commit.
|
|
pub fn args(&self, born: bool) -> Vec<String> {
|
|
// Unstaging a rename restores its old path too.
|
|
let paths = |entry: &Entry, both: bool| {
|
|
let mut paths = vec!["--".to_string(), entry.path.clone()];
|
|
paths.extend(entry.from.clone().filter(|_| both));
|
|
paths
|
|
};
|
|
let mut args: Vec<String> = match (self, born) {
|
|
(Write::Stage(_), _) => vec!["add".into(), "--all".into()],
|
|
(Write::Unstage(_), true) => vec!["restore".into(), "--staged".into()],
|
|
// `-f`: the index may differ from the file.
|
|
(Write::Unstage(_), false) => vec![
|
|
"rm".into(),
|
|
"--cached".into(),
|
|
"-f".into(),
|
|
"-r".into(),
|
|
"-q".into(),
|
|
],
|
|
(
|
|
Write::Discard(Entry {
|
|
state: State::Untracked,
|
|
..
|
|
}),
|
|
_,
|
|
) => vec!["clean".into(), "-f".into(), "-d".into(), "-q".into()],
|
|
(Write::Discard(_), _) => vec!["restore".into(), "--worktree".into()],
|
|
(Write::StageAll, _) => vec!["add".into(), "--update".into()],
|
|
(Write::UnstageAll, true) => vec!["reset".into(), "-q".into()],
|
|
(Write::UnstageAll, false) => vec![
|
|
"rm".into(),
|
|
"--cached".into(),
|
|
"-f".into(),
|
|
"-r".into(),
|
|
"-q".into(),
|
|
"--".into(),
|
|
".".into(),
|
|
],
|
|
(Write::Git(args) | Write::Remote(args), _) => args.clone(),
|
|
(Write::Move { to, .. }, _) => vec!["reset".into(), "--keep".into(), to.clone()],
|
|
// Files only: Git keeps tracked ones and stops at links.
|
|
(Write::Clean { files, ignored, .. }, _) => {
|
|
let mut args = vec!["clean".into(), "-f".into()];
|
|
args.extend(ignored.then(|| "-x".into()));
|
|
args.push("--".into());
|
|
args.extend(files.iter().cloned());
|
|
args
|
|
}
|
|
(Write::Stash { verb, at, .. }, _) => {
|
|
vec!["stash".into(), (*verb).into(), format!("stash@{{{at}}}")]
|
|
}
|
|
(Write::Save { .. }, _) => Vec::new(),
|
|
(Write::Commit { amend, .. }, _) => {
|
|
// `whitespace`: lines starting `#` stay, whatever the config.
|
|
let mut args = vec![
|
|
"commit".into(),
|
|
"--cleanup=whitespace".into(),
|
|
"-F".into(),
|
|
"-".into(),
|
|
];
|
|
args.extend(amend.then(|| "--amend".into()));
|
|
args
|
|
}
|
|
(Write::Config { key, value, global }, _) => {
|
|
let mut args = vec!["config".into()];
|
|
args.extend(global.then(|| "--global".into()));
|
|
args.extend([key.clone(), value.clone()]);
|
|
args
|
|
}
|
|
(Write::Patch { reverse, .. }, _) => {
|
|
// `nowarn`: the picked bytes go in as they are.
|
|
let mut args = vec![
|
|
"apply".into(),
|
|
"--cached".into(),
|
|
"--whitespace=nowarn".into(),
|
|
];
|
|
args.extend(reverse.then(|| "--reverse".into()));
|
|
args
|
|
}
|
|
};
|
|
match self {
|
|
Write::Unstage(entry) => args.extend(paths(entry, true)),
|
|
Write::Stage(entry) | Write::Discard(entry) => args.extend(paths(entry, false)),
|
|
Write::StageAll
|
|
| Write::UnstageAll
|
|
| Write::Commit { .. }
|
|
| Write::Git(_)
|
|
| Write::Remote(_)
|
|
| Write::Stash { .. }
|
|
| Write::Move { .. }
|
|
| Write::Clean { .. }
|
|
| Write::Save { .. }
|
|
| Write::Config { .. }
|
|
| Write::Patch { .. } => {}
|
|
}
|
|
args
|
|
}
|
|
}
|
|
|
|
fn strs(args: &[String]) -> Vec<&str> {
|
|
args.iter().map(String::as_str).collect()
|
|
}
|
|
|
|
pub(crate) fn run(
|
|
git: &Git,
|
|
root: &std::path::Path,
|
|
write: &Write,
|
|
args: &[String],
|
|
) -> io::Result<()> {
|
|
match write {
|
|
Write::Save { path, text } => {
|
|
let file = root.join(path);
|
|
// A link here now would carry the text elsewhere.
|
|
if std::fs::symlink_metadata(&file).is_ok_and(|meta| meta.file_type().is_symlink()) {
|
|
return Err(io::Error::other(format!(
|
|
"{path} is now a link; nothing was saved"
|
|
)));
|
|
}
|
|
std::fs::write(&file, text)
|
|
}
|
|
// The message goes in on stdin, never through a shell.
|
|
Write::Commit { message, .. } => git
|
|
.run_input(root, &strs(args), message.as_bytes())
|
|
.map(drop),
|
|
Write::Patch {
|
|
patch, check, seen, ..
|
|
} => {
|
|
// A patch from an older diff is refused.
|
|
if git.run(root, &strs(check))? != **seen {
|
|
return Err(io::Error::other(
|
|
"the file changed since it was shown; nothing was staged",
|
|
));
|
|
}
|
|
git.run_input(root, &strs(args), patch.as_bytes()).map(drop)
|
|
}
|
|
Write::Stash { at, oid, .. } => {
|
|
// Another stash since shifts every number.
|
|
let now = git.run_or_one(
|
|
root,
|
|
&["rev-parse", "-q", "--verify", &format!("stash@{{{at}}}")],
|
|
)?;
|
|
if String::from_utf8_lossy(&now).trim() != oid {
|
|
return Err(io::Error::other(
|
|
"the stash list changed since it was shown; nothing was done",
|
|
));
|
|
}
|
|
git.run(root, &strs(args)).map(drop)
|
|
}
|
|
Write::Move { branch, head, .. } => {
|
|
if recover::head_now(git, root)? != (branch.clone(), head.clone()) {
|
|
return Err(io::Error::other(
|
|
"HEAD moved since the preview; nothing was done",
|
|
));
|
|
}
|
|
recover::nothing_staged(git, root)?;
|
|
git.run(root, &strs(args)).map(drop)
|
|
}
|
|
Write::Clean { files, dirs, .. } => {
|
|
for file in files {
|
|
as_shown(root, file, false)?;
|
|
}
|
|
for dir in dirs {
|
|
as_shown(root, dir, true)?;
|
|
}
|
|
if !files.is_empty() {
|
|
git.run(root, &strs(args))?;
|
|
}
|
|
// A folder that gained a file since stays.
|
|
for dir in dirs {
|
|
match std::fs::remove_dir(root.join(dir)) {
|
|
Err(error) if error.kind() == io::ErrorKind::DirectoryNotEmpty => {
|
|
log::info!("tree: {dir} kept; it is not empty");
|
|
}
|
|
Err(error) if error.kind() != io::ErrorKind::NotFound => return Err(error),
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
Write::Remote(_) => git.start(root, &strs(args))?.follow(|_| {}).map(drop),
|
|
_ => git.run(root, &strs(args)).map(drop),
|
|
}
|
|
}
|
|
|
|
/// A shown path: same kind, reached through no link.
|
|
fn as_shown(root: &std::path::Path, path: &str, folder: bool) -> io::Result<()> {
|
|
let changed = || {
|
|
io::Error::other(format!(
|
|
"{path} changed since the preview; nothing was removed"
|
|
))
|
|
};
|
|
let mut at = root.to_path_buf();
|
|
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();
|
|
let last = ix + 1 == parts.len();
|
|
if (!last && !kind.is_dir()) || (last && kind.is_dir() != folder) {
|
|
return Err(changed());
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Git found another process's lock file in place.
|
|
fn locked(said: &str) -> bool {
|
|
said.contains("File exists") && (said.contains(".lock") || said.contains("could not lock"))
|
|
}
|
|
|
|
impl Tree {
|
|
/// A press on a file in the changes list.
|
|
pub fn file_action(
|
|
&mut self,
|
|
path: &SharedString,
|
|
section: ChangeSection,
|
|
action: ChangeAction,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(opened) = self.active() else { return };
|
|
let id = opened.id;
|
|
let Some(Ok(status)) = &opened.status else {
|
|
return;
|
|
};
|
|
let Some(entry) = status
|
|
.entries
|
|
.iter()
|
|
.find(|entry| entry.path == path.as_ref())
|
|
.cloned()
|
|
else {
|
|
return log::warn!("tree: {path} is no longer listed");
|
|
};
|
|
match action {
|
|
ChangeAction::Open if section == ChangeSection::Ignored => {
|
|
self.open_file(&entry.path, cx)
|
|
}
|
|
ChangeAction::Open => self.show(&entry.path, section, cx),
|
|
ChangeAction::Stage => self.write(id, Write::Stage(entry), cx),
|
|
ChangeAction::Unstage => self.write(id, Write::Unstage(entry), cx),
|
|
// Each discard asks; one yes is not a standing one.
|
|
ChangeAction::Discard => {
|
|
self.ask = Some(Ask::Discard { repo: id, entry });
|
|
cx.notify();
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Opens a repository file with its own app.
|
|
pub fn open_file(&self, path: &str, cx: &mut Context<Self>) {
|
|
let Some(opened) = self.active() else { return };
|
|
let file = opened.repo.root.join(path);
|
|
if file.exists() {
|
|
cx.open_with_system(&file);
|
|
} else {
|
|
let words = text(cx, "tree.gone", &[("path", path)]);
|
|
self.toast(words, Severity::Warning, cx);
|
|
}
|
|
}
|
|
|
|
/// Queues `write` behind the repository's other writes.
|
|
pub fn write(&mut self, id: u64, write: Write, cx: &mut Context<Self>) {
|
|
let Some(opened) = self.opened_mut(id) else {
|
|
return;
|
|
};
|
|
opened.writes.push_back(write);
|
|
self.next_write(id, cx);
|
|
}
|
|
|
|
fn next_write(&mut self, id: u64, cx: &mut Context<Self>) {
|
|
let Some(git) = self.git.as_ref().ok().cloned() else {
|
|
return;
|
|
};
|
|
let Some(opened) = self.opened_mut(id).filter(|opened| !opened.writing) else {
|
|
return;
|
|
};
|
|
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 task = cx.background_spawn(async move {
|
|
let done = run(&git, &root, &write, &args);
|
|
let stopped = match (&write, &done) {
|
|
(Write::Git(args), Err(error)) => {
|
|
ops::stopped(&git, &root, args, &error.to_string())
|
|
}
|
|
_ => false,
|
|
};
|
|
(write, done, stopped)
|
|
});
|
|
cx.spawn(async move |this, cx| {
|
|
let (write, done, stopped) = task.await;
|
|
this.update(cx, |tree, cx| tree.written(id, &write, done, stopped, cx))
|
|
.ok();
|
|
})
|
|
.detach();
|
|
}
|
|
|
|
pub(crate) fn written(
|
|
&mut self,
|
|
id: u64,
|
|
write: &Write,
|
|
done: io::Result<()>,
|
|
stopped: bool,
|
|
cx: &mut Context<Self>,
|
|
) {
|
|
let Some(opened) = self.opened_mut(id) else {
|
|
return;
|
|
};
|
|
opened.writing = false;
|
|
if done.is_err() {
|
|
// Later writes counted on this one.
|
|
opened.writes.clear();
|
|
}
|
|
match (write, done) {
|
|
// A held lock waits for a retry; Ely deletes none.
|
|
(_, Err(error)) if locked(&error.to_string()) => {
|
|
self.ask = Some(Ask::Locked {
|
|
repo: id,
|
|
write: write.clone(),
|
|
detail: error.to_string().into(),
|
|
});
|
|
cx.notify();
|
|
}
|
|
(Write::Commit { message, .. }, Ok(())) => self.committed(id, message, cx),
|
|
// Hooks and signing speak at length; show all of it.
|
|
(Write::Commit { .. }, Err(error)) => {
|
|
self.ask = Some(Ask::Failed {
|
|
title: text(cx, "tree.commit.failed", &[]),
|
|
detail: error.to_string().into(),
|
|
});
|
|
cx.notify();
|
|
}
|
|
(Write::Config { .. }, Ok(())) => self.read_me(id, cx),
|
|
(Write::Save { path, text }, Ok(())) => self.merge_saved(id, path, text, cx),
|
|
// A conflict stop is no failure; Git says which.
|
|
(
|
|
Write::Git(_)
|
|
| Write::Remote(_)
|
|
| Write::Stash { .. }
|
|
| Write::Move { .. }
|
|
| Write::Clean { .. },
|
|
Err(error),
|
|
) => self.git_failed(stopped, error.to_string(), cx),
|
|
(_, Ok(())) => {}
|
|
(_, Err(error)) => self.failed("tree.error.write", &error.to_string(), cx),
|
|
}
|
|
self.read(id, cx);
|
|
self.next_write(id, cx);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn entry(path: &str, from: Option<&str>, state: State) -> Entry {
|
|
Entry {
|
|
path: path.into(),
|
|
from: from.map(Into::into),
|
|
state,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn each_write_names_its_paths_after_the_separator() {
|
|
let renamed = entry(
|
|
"new.txt",
|
|
Some("old.txt"),
|
|
State::Tracked {
|
|
staged: 'R',
|
|
unstaged: '.',
|
|
},
|
|
);
|
|
assert_eq!(
|
|
Write::Unstage(renamed.clone()).args(true),
|
|
["restore", "--staged", "--", "new.txt", "old.txt"]
|
|
);
|
|
assert_eq!(
|
|
Write::Discard(renamed).args(true),
|
|
["restore", "--worktree", "--", "new.txt"],
|
|
"the old path is gone"
|
|
);
|
|
let untracked = entry("-n", None, State::Untracked);
|
|
assert_eq!(
|
|
Write::Discard(untracked).args(true),
|
|
["clean", "-f", "-d", "-q", "--", "-n"]
|
|
);
|
|
let changed = entry(
|
|
"a.txt",
|
|
None,
|
|
State::Tracked {
|
|
staged: '.',
|
|
unstaged: 'M',
|
|
},
|
|
);
|
|
assert_eq!(
|
|
Write::Discard(changed.clone()).args(true),
|
|
["restore", "--worktree", "--", "a.txt"]
|
|
);
|
|
assert_eq!(
|
|
Write::Unstage(changed).args(false),
|
|
["rm", "--cached", "-f", "-r", "-q", "--", "a.txt"]
|
|
);
|
|
assert_eq!(Write::UnstageAll.args(true), ["reset", "-q"]);
|
|
}
|
|
}
|