use std::path::Path;
use super::{FALLBACK_IDENTITY, Git, GitError, failed};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FolderMerge {
pub tree: String,
pub both_changed: Vec<String>,
}
impl Git {
pub async fn folder_as_commit(
&self,
folder: &Path,
scratch: &Path,
parent: &str,
) -> Result<String, GitError> {
let git = self.with_index(scratch);
let built = async {
git.run(folder, &["read-tree", parent]).await?;
git.run(folder, &["add", "--all"]).await?;
let tree = git.run(folder, &["write-tree"]).await?;
let message = "Your folder while a run waited to be written in";
let args = ["commit-tree", &tree, "-p", parent, "-m", message];
let out = git.output(folder, &args, &FALLBACK_IDENTITY).await?;
if !out.status.success() {
return Err(failed(&args, &out));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
}
.await;
if let Err(err) = std::fs::remove_file(scratch)
&& err.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!("removing {}: {err}", scratch.display());
}
built
}
pub async fn merge_folder(
&self,
dir: &Path,
yours: &str,
runs: &str,
) -> Result<FolderMerge, GitError> {
self.merge_tree(dir, &[], yours, runs).await
}
pub(super) async fn merge_tree(
&self,
dir: &Path,
options: &[&str],
yours: &str,
runs: &str,
) -> Result<FolderMerge, GitError> {
let mut args = vec!["merge-tree", "--write-tree", "--name-only", "-z"];
args.extend(options);
args.extend([yours, runs]);
let out = self.output(dir, &args, &[]).await?;
if !matches!(out.status.code(), Some(0 | 1)) {
return Err(failed(&args, &out));
}
let text = String::from_utf8_lossy(&out.stdout);
let mut parts = text.split('\0');
let tree = parts.next().unwrap_or_default().trim().to_owned();
if tree.is_empty() {
return Err(failed(&args, &out));
}
let mut both_changed: Vec<String> = parts
.take_while(|name| !name.is_empty())
.map(str::to_owned)
.collect();
both_changed.sort();
both_changed.dedup();
Ok(FolderMerge { tree, both_changed })
}
pub async fn tree_with(
&self,
dir: &Path,
scratch: &Path,
tree: &str,
rev: &str,
paths: &[String],
) -> Result<String, GitError> {
let git = self.with_index(scratch);
let built = async {
git.run(dir, &["read-tree", tree]).await?;
let names: Vec<&str> = paths.iter().map(String::as_str).collect();
let entries = self.entries(dir, rev, &names).await?;
for path in paths {
match entries.get(path) {
Some(entry) => {
let info = format!("{},{},{path}", entry.mode, entry.object);
git.run(dir, &["update-index", "--add", "--cacheinfo", &info])
.await?;
}
None => {
git.run(dir, &["update-index", "--force-remove", "--", path])
.await?;
}
}
}
git.run(dir, &["write-tree"]).await
}
.await;
if let Err(err) = std::fs::remove_file(scratch)
&& err.kind() != std::io::ErrorKind::NotFound
{
tracing::warn!("removing {}: {err}", scratch.display());
}
built
}
pub async fn follow_folder(&self, folder: &Path, rev: &str) -> Result<(), GitError> {
self.run(folder, &["reset", "--quiet", "--mixed", rev])
.await
.map(drop)
}
pub async fn diff_of(
&self,
dir: &Path,
from: &str,
to: &str,
paths: &[String],
) -> Result<String, GitError> {
let specs: Vec<String> = paths
.iter()
.map(|path| format!(":(literal){path}"))
.collect();
let mut args = vec!["diff", "--no-ext-diff", "--no-textconv", from, to, "--"];
args.extend(specs.iter().map(String::as_str));
self.run(dir, &args).await
}
pub async fn commit_tree_as_argonauts(
&self,
dir: &Path,
tree: &str,
parent: &str,
message: &str,
) -> Result<String, GitError> {
let args = ["commit-tree", tree, "-p", parent, "-m", message];
let out = self.output(dir, &args, &FALLBACK_IDENTITY).await?;
if !out.status.success() {
return Err(failed(&args, &out));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::process::Command;
use super::*;
async fn folder() -> (tempfile::TempDir, PathBuf, Git, String) {
let root = tempfile::tempdir().unwrap();
let (folder, hidden) = (root.path().join("folder"), root.path().join("history.git"));
std::fs::create_dir_all(folder.join("src")).unwrap();
let made = Command::new("git")
.args(["init", "--quiet", "--bare", "--template="])
.arg(&hidden)
.status()
.unwrap();
assert!(made.success());
std::fs::write(hidden.join("HEAD"), "ref: refs/heads/your-folder\n").unwrap();
std::fs::write(folder.join("src/cli.js"), "one\ntwo\nthree\nfour\nfive\n").unwrap();
std::fs::write(folder.join("notes.md"), "todo\n").unwrap();
let git =
Git::with_env(crate::testutil::git_test_env()).with_hidden(folder.clone(), hidden);
let base = record(&git, &folder).await.unwrap();
(root, folder, git, base)
}
async fn record(git: &Git, folder: &Path) -> Result<String, GitError> {
git.run(folder, &["add", "--all"]).await?;
let args = ["commit", "--quiet", "--allow-empty", "-m", "Your folder"];
let out = git.output(folder, &args, &FALLBACK_IDENTITY).await?;
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
git.resolve(folder, "HEAD").await
}
async fn run_commit(git: &Git, folder: &Path, base: &str, files: &[(&str, &str)]) -> String {
let scratch = folder.parent().unwrap().join("run.index");
let index = git.with_index(&scratch);
index.run(folder, &["read-tree", base]).await.unwrap();
for (path, text) in files {
let blob = folder.parent().unwrap().join("blob");
std::fs::write(&blob, text).unwrap();
let blob = blob.to_string_lossy().into_owned();
let object = git
.run(folder, &["hash-object", "-w", "--", &blob])
.await
.unwrap();
let info = format!("100644,{object},{path}");
index
.run(folder, &["update-index", "--add", "--cacheinfo", &info])
.await
.unwrap();
}
let tree = index.run(folder, &["write-tree"]).await.unwrap();
std::fs::remove_file(&scratch).unwrap();
git.commit_tree_as_argonauts(folder, &tree, base, "the run")
.await
.unwrap()
}
async fn show(git: &Git, folder: &Path, tree: &str, path: &str) -> String {
let spec = format!("{tree}:{path}");
git.output(folder, &["show", &spec], &[])
.await
.map(|out| String::from_utf8_lossy(&out.stdout).into_owned())
.unwrap()
}
#[tokio::test]
async fn edits_in_other_lines_merge_by_themselves() -> Result<(), GitError> {
let (root, folder, git, base) = folder().await;
{
let runs = [("src/cli.js", "one\ntwo\nthree\nfour\nfive\nsix\n")];
let run = run_commit(&git, &folder, &base, &runs).await;
std::fs::write(
folder.join("src/cli.js"),
"zero\none\ntwo\nthree\nfour\nfive\n",
)
.unwrap();
std::fs::write(folder.join("notes.md"), "done\n").unwrap();
let scratch = root.path().join("yours.index");
let yours = git.folder_as_commit(&folder, &scratch, &base).await?;
assert!(!scratch.exists());
assert_eq!(git.resolve(&folder, "HEAD").await?, base);
let merge = git.merge_folder(&folder, &yours, &run).await?;
assert_eq!(merge.both_changed, Vec::<String>::new());
assert_eq!(
show(&git, &folder, &merge.tree, "src/cli.js").await,
"zero\none\ntwo\nthree\nfour\nfive\nsix\n"
);
assert_eq!(show(&git, &folder, &merge.tree, "notes.md").await, "done\n");
Ok(())
}
}
#[tokio::test]
async fn a_file_both_changed_in_the_same_lines_takes_the_version_you_pick()
-> Result<(), GitError> {
let (root, folder, git, base) = folder().await;
{
let runs = [
("src/cli.js", "one\ntwo\nTHE RUN\nfour\nfive\n"),
("src/due.js", "new\n"),
];
let run = run_commit(&git, &folder, &base, &runs).await;
std::fs::write(folder.join("src/cli.js"), "one\ntwo\nyours\nfour\nfive\n").unwrap();
let scratch = root.path().join("yours.index");
let yours = git.folder_as_commit(&folder, &scratch, &base).await?;
let merge = git.merge_folder(&folder, &yours, &run).await?;
assert_eq!(merge.both_changed, ["src/cli.js"]);
let both = &merge.both_changed;
let kept = git
.tree_with(&folder, &scratch, &merge.tree, &yours, both)
.await?;
assert_eq!(
show(&git, &folder, &kept, "src/cli.js").await,
"one\ntwo\nyours\nfour\nfive\n"
);
assert_eq!(show(&git, &folder, &kept, "src/due.js").await, "new\n");
let taken = git
.tree_with(&folder, &scratch, &merge.tree, &run, both)
.await?;
assert_eq!(
show(&git, &folder, &taken, "src/cli.js").await,
"one\ntwo\nTHE RUN\nfour\nfive\n"
);
std::fs::write(folder.join("notes.md"), "mine, after the snapshot\n").unwrap();
let written = git
.commit_tree_as_argonauts(&folder, &taken, &yours, "Run written in")
.await?;
git.write_files(&folder, &scratch, &yours, &written, &[])
.await?;
git.follow_folder(&folder, &written).await?;
assert_eq!(
std::fs::read_to_string(folder.join("src/cli.js")).unwrap(),
"one\ntwo\nTHE RUN\nfour\nfive\n"
);
assert_eq!(
std::fs::read_to_string(folder.join("src/due.js")).unwrap(),
"new\n"
);
assert_eq!(git.resolve(&folder, "HEAD").await?, written);
assert_eq!(
show(&git, &folder, &yours, "src/cli.js").await,
"one\ntwo\nyours\nfour\nfive\n",
"your version stays in the history"
);
assert_eq!(git.tracked_changes(&folder).await?, [" M notes.md"]);
assert!(
git.diff_of(&folder, &yours, &run, both)
.await?
.contains("+THE RUN"),
"the two versions side by side"
);
assert!(!folder.join(".git").exists());
Ok(())
}
}
#[tokio::test]
async fn a_file_the_run_removed_and_you_changed_is_yours_to_pick() -> Result<(), GitError> {
let (root, folder, git, base) = folder().await;
{
let scratch = root.path().join("run.index");
let index = git.with_index(&scratch);
index.run(&folder, &["read-tree", &base]).await?;
index
.run(
&folder,
&["update-index", "--force-remove", "--", "notes.md"],
)
.await?;
let tree = index.run(&folder, &["write-tree"]).await?;
std::fs::remove_file(&scratch).unwrap();
let run = git
.commit_tree_as_argonauts(&folder, &tree, &base, "the run")
.await?;
std::fs::write(folder.join("notes.md"), "todo\nmore\n").unwrap();
let yours = git.folder_as_commit(&folder, &scratch, &base).await?;
let merge = git.merge_folder(&folder, &yours, &run).await?;
assert_eq!(merge.both_changed, ["notes.md"]);
let taken = git
.tree_with(&folder, &scratch, &merge.tree, &run, &merge.both_changed)
.await?;
let held = git
.run(&folder, &["ls-tree", "-r", "--name-only", &taken])
.await?;
assert_eq!(held, "src/cli.js");
Ok(())
}
}
}