diff --git a/cmux-tui/crates/cmux-tui/src/link/mod.rs b/cmux-tui/crates/cmux-tui/src/link/mod.rs index 5c0e2765939c..0ad1bc24080d 100644 --- a/cmux-tui/crates/cmux-tui/src/link/mod.rs +++ b/cmux-tui/crates/cmux-tui/src/link/mod.rs @@ -11,7 +11,7 @@ mod dial; mod dial_cli; mod enrollment; mod ensure; -// `cmux link seed plan`; `cmux link seed push|clone` is [`seed_cli`]. +// `cmux link seed plan`; `cmux link seed push|clone|pull` is [`seed_cli`]. #[cfg_attr(not(test), allow(dead_code))] mod seed; mod seed_cli; @@ -121,7 +121,7 @@ pub(crate) fn run(args: &[String]) -> i32 { return sync_cli::run(&args[1..]); } if args.first().map(String::as_str) == Some("seed") { - if matches!(args.get(1).map(String::as_str), Some("push" | "clone")) { + if matches!(args.get(1).map(String::as_str), Some("push" | "clone" | "pull")) { return seed_cli::run(args.get(1..).unwrap_or_default()); } return seed::run(&args[1..]); diff --git a/cmux-tui/crates/cmux-tui/src/link/seed/plan.rs b/cmux-tui/crates/cmux-tui/src/link/seed/plan.rs index 3e9649a27682..66c515a9a7e3 100644 --- a/cmux-tui/crates/cmux-tui/src/link/seed/plan.rs +++ b/cmux-tui/crates/cmux-tui/src/link/seed/plan.rs @@ -58,8 +58,13 @@ pub struct SeedParams { /// An untracked file left out of the work commit, and why in plain words. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct Skipped { + /// For display: a name that is not UTF-8 is shown lossily. pub path: String, pub why: String, + /// git's raw name, which is what the plan leaves out (a lossy spelling + /// of a name that is not UTF-8 would match nothing). + #[serde(skip)] + pub raw: Vec, } /// A submodule and the commit checked out in it (the recorded one when it @@ -421,6 +426,33 @@ const SEED_NAME: &str = "cmux seed"; const SEED_EMAIL: &str = "seed@cmux.invalid"; const SEED_DATE: &str = "946684800 +0000"; +/// The working copy at `folder` as a seed would send it now: HEAD and its +/// branch, and the INDEX and WORK commits [`plan`] would make (written to +/// the repository's object store, unreferenced). `cmux link seed pull` +/// compares them with the seed's. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Current { + pub head_ref: Option, + pub head: String, + pub index: String, + pub work: String, +} + +pub(crate) fn current(folder: &Path) -> Result { + let repo = Repo::open(folder)?; + let scratch = Scratch::new()?; + let made = commits(&repo, &scratch)?; + Ok(Current { head_ref: repo.head_ref, head: repo.head, index: made.index, work: made.work }) +} + +/// The INDEX and WORK commits of a seed, and what WORK left out. +struct Commits { + index: String, + work: String, + skipped: Vec, + sparse: Option, +} + fn build( repo: &Repo, out_bundle: &Path, @@ -429,6 +461,8 @@ fn build( ) -> Result { let submodules = submodules(repo)?; let scratch = Scratch::new()?; + let Commits { index: index_commit, work: work_commit, skipped, sparse } = + commits(repo, &scratch)?; let store = scratch.0.join("store.git"); git::output(git::at(&scratch.0).args(["init", "-q", "--bare", "--template="]).arg(&store)) .map_err(|error| failed("making the scratch repository", error))?; @@ -442,53 +476,6 @@ fn build( std::fs::copy(shallow, store.join("shallow")) .map_err(|error| failed("copying the shallow list", error.to_string()))?; } - let index = scratch.0.join("index"); - let real_index = repo.git_path("index")?; - if real_index.is_file() { - std::fs::copy(real_index, &index) - .map_err(|error| failed("copying the index", error.to_string()))?; - } else { - run(repo.scratch_git(&index).args(["read-tree", repo.head.as_str()]), "reading HEAD")?; - } - - let index_tree = repo.write_tree(&index)?; - let index_commit = commit(repo, &index_tree, &repo.head, "cmux seed index")?; - - let skipped = untracked_left_out(repo)?; - let sparse = sparse_patterns(repo)?; - // In a sparse checkout `--sparse` also takes untracked files outside - // the patterns; entries git keeps out of the working tree stay as they - // are either way. - let sparse_flag: &[&str] = if sparse.is_some() { &["--sparse"] } else { &[] }; - run(repo.scratch_git(&index).args(["add", "-A"]).args(sparse_flag), "adding the working tree")?; - if !skipped.is_empty() { - let list = scratch.0.join("left-out"); - let mut paths = Vec::new(); - for item in &skipped { - paths.extend_from_slice(item.path.trim_end_matches('/').as_bytes()); - paths.push(0); - } - write(&list, paths)?; - run( - repo.scratch_git(&index) - .env("GIT_LITERAL_PATHSPECS", "1") - .args([ - "rm", - "--cached", - "-r", - "-f", - "-q", - "--ignore-unmatch", - "--pathspec-file-nul", - ]) - .args(sparse_flag) - .arg(format!("--pathspec-from-file={}", list.display())), - "leaving out secrets", - )?; - } - let work_tree = repo.write_tree(&index)?; - let work_commit = commit(repo, &work_tree, &index_commit, "cmux seed work")?; - let stash = repo.commit("refs/stash"); let lfs = uses_lfs(repo, &skipped); @@ -561,6 +548,60 @@ fn build( }) } +/// The INDEX commit (the staged tree on HEAD) and the WORK commit (the +/// working tree with untracked files that are not ignored, less +/// [`untracked_left_out`], on INDEX), built through a copy of the index in +/// `scratch`. +fn commits(repo: &Repo, scratch: &Scratch) -> Result { + let index = scratch.0.join("index"); + let real_index = repo.git_path("index")?; + if real_index.is_file() { + std::fs::copy(real_index, &index) + .map_err(|error| failed("copying the index", error.to_string()))?; + } else { + run(repo.scratch_git(&index).args(["read-tree", repo.head.as_str()]), "reading HEAD")?; + } + + let index_tree = repo.write_tree(&index)?; + let index_commit = commit(repo, &index_tree, &repo.head, "cmux seed index")?; + + let skipped = untracked_left_out(repo)?; + let sparse = sparse_patterns(repo)?; + // In a sparse checkout `--sparse` also takes untracked files outside + // the patterns; entries git keeps out of the working tree stay as they + // are either way. + let sparse_flag: &[&str] = if sparse.is_some() { &["--sparse"] } else { &[] }; + run(repo.scratch_git(&index).args(["add", "-A"]).args(sparse_flag), "adding the working tree")?; + if !skipped.is_empty() { + let list = scratch.0.join("left-out"); + let mut paths = Vec::new(); + for item in &skipped { + paths.extend_from_slice(item.raw.strip_suffix(b"/").unwrap_or(&item.raw)); + paths.push(0); + } + write(&list, paths)?; + run( + repo.scratch_git(&index) + .env("GIT_LITERAL_PATHSPECS", "1") + .args([ + "rm", + "--cached", + "-r", + "-f", + "-q", + "--ignore-unmatch", + "--pathspec-file-nul", + ]) + .args(sparse_flag) + .arg(format!("--pathspec-from-file={}", list.display())), + "leaving out secrets", + )?; + } + let work_tree = repo.write_tree(&index)?; + let work = commit(repo, &work_tree, &index_commit, "cmux seed work")?; + Ok(Commits { index: index_commit, work, skipped, sparse }) +} + impl Repo { /// git in the working copy with the scratch index. fn scratch_git(&self, index: &Path) -> Command { @@ -621,7 +662,7 @@ fn untracked_left_out(repo: &Repo) -> Result, Fallback> { secrets::reason(&repo.top, raw) }; if let Some(why) = why { - skipped.push(Skipped { path, why: why.to_string() }); + skipped.push(Skipped { path, why: why.to_string(), raw: raw.to_vec() }); } } Ok(skipped) diff --git a/cmux-tui/crates/cmux-tui/src/link/seed/tests.rs b/cmux-tui/crates/cmux-tui/src/link/seed/tests.rs index c9eeb07a5562..e910a71cc05f 100644 --- a/cmux-tui/crates/cmux-tui/src/link/seed/tests.rs +++ b/cmux-tui/crates/cmux-tui/src/link/seed/tests.rs @@ -231,6 +231,20 @@ fn the_same_tree_gives_the_same_commits() { assert_eq!(std::fs::read_to_string(machine.join("notes.txt")).unwrap(), "untracked\n"); } +/// A secret whose name is not UTF-8 is left out by its raw name: its +/// contents never reach WORK. +#[test] +fn a_secret_with_a_non_utf8_name_is_left_out() { + use std::os::unix::ffi::OsStrExt as _; + let f = Fixture::new(); + std::fs::write(f.work.join(std::ffi::OsStr::from_bytes(b".env-\xff")), "SECRET=1\n").unwrap(); + write(&f.work.join("README.md"), "changed\n"); + let planned = plan(&f.work, &f.path("one.bundle")).unwrap(); + assert_eq!(planned.skipped.len(), 1, "{:?}", planned.skipped); + let names = git(&f.work, &["ls-tree", "-r", "--name-only", &planned.params.work]); + assert!(!names.contains(".env"), "{names}"); +} + #[test] fn detached_head_has_no_head_ref() { let f = Fixture::new(); diff --git a/cmux-tui/crates/cmux-tui/src/link/seed_cli.rs b/cmux-tui/crates/cmux-tui/src/link/seed_cli.rs index ef81cad330aa..748c22877dbc 100644 --- a/cmux-tui/crates/cmux-tui/src/link/seed_cli.rs +++ b/cmux-tui/crates/cmux-tui/src/link/seed_cli.rs @@ -28,6 +28,10 @@ //! `cmux link seed clone --socket PATH --repo OWNER/NAME [--ref REF] REMOTE` //! clones a GitHub repository on the machine with no folder of this Mac. //! +//! `cmux link seed pull --socket PATH LOCAL REMOTE` brings the seeded +//! folder's changes back as a diff, merged against the [`record`] +//! ([`pull`]). +//! //! Output: progress on stderr as JSON lines, `{"progress":{"phase":"upload", //! "bytes_done","bytes_total"}}` while the bundle goes, then //! `{"progress":{"phase":clone|fetch|bundle|checkout|submodules|lfs, @@ -43,6 +47,7 @@ //! is `{"ok":false,"error","error_code","install_url"?}`, exit 1; bad usage //! exits 64. +mod pull; mod record; use std::path::{Path, PathBuf}; @@ -54,10 +59,13 @@ use serde_json::{Value, json}; use super::seed::plan::{self, Fallback, FallbackCode, Planned, PreviousSeed}; use super::{machine_gate, sync_cli}; use record::{Kept, Record}; +use sync_cli::SeedBase; const USAGE: &str = "usage: cmux link seed push --socket PATH LOCAL_FOLDER REMOTE_FOLDER \ [--since-last] - cmux link seed clone --socket PATH --repo OWNER/NAME [--ref REF] REMOTE_FOLDER"; + cmux link seed clone --socket PATH --repo OWNER/NAME [--ref REF] REMOTE_FOLDER + cmux link seed pull --socket PATH LOCAL_FOLDER REMOTE_FOLDER \ +[--base-head SHA --base-index SHA --base-work SHA]"; /// Time between two `git.seed.status` requests. const STATUS_EVERY: Duration = Duration::from_millis(500); @@ -172,16 +180,17 @@ pub(super) fn run(args: &[String]) -> i32 { return 64; }; let answer = match command { + Command::Pull { socket, local, remote, base } => pull::pull(&socket, &local, &remote, base), Command::Push { socket, local, remote, since_last } => { let pushed = if since_last { push_since_last(&socket, &local, &remote) } else { push(&socket, &local, &remote) }; - pushed.map(|pushed| pushed.json()).map_err(|error| SeedError::of(&error)) + pushed.map(|pushed| pushed.json()).map_err(|error| SeedError::of(&error).line()) } Command::Clone { socket, repo, git_ref, remote } => { - clone(&socket, &repo, git_ref.as_deref(), &remote) + clone(&socket, &repo, git_ref.as_deref(), &remote).map_err(|error| error.line()) } }; match answer { @@ -189,14 +198,15 @@ pub(super) fn run(args: &[String]) -> i32 { println!("{line}"); 0 } - Err(error) => { - println!("{}", error.line()); + Err(line) => { + println!("{line}"); 1 } } } enum Command { + Pull { socket: PathBuf, local: PathBuf, remote: String, base: Option }, Push { socket: PathBuf, local: PathBuf, remote: String, since_last: bool }, Clone { socket: PathBuf, repo: String, git_ref: Option, remote: String }, } @@ -204,6 +214,7 @@ enum Command { fn parse(args: &[String]) -> Option { let (action, rest) = args.split_first()?; let (mut socket, mut repo, mut git_ref, mut since_last) = (None, None, None, false); + let (mut head, mut index, mut work) = (None, None, None); let mut positional = Vec::new(); let mut rest = rest.iter(); while let Some(arg) = rest.next() { @@ -212,6 +223,9 @@ fn parse(args: &[String]) -> Option { "--repo" => repo = Some(rest.next()?.clone()), "--ref" => git_ref = Some(rest.next()?.clone()), "--since-last" => since_last = true, + "--base-head" => head = Some(rest.next()?.clone()), + "--base-index" => index = Some(rest.next()?.clone()), + "--base-work" => work = Some(rest.next()?.clone()), flag if flag.starts_with('-') => return None, value => positional.push(value), } @@ -220,8 +234,25 @@ fn parse(args: &[String]) -> Option { (remote.starts_with('/') && remote.len() > 1) .then(|| remote.trim_end_matches('/').to_string()) }; + let base = match (head, index, work) { + (None, None, None) => None, + (Some(head), Some(index), Some(work)) + if [&head, &index, &work].iter().all(|sha| sync_cli::is_sha(sha)) => + { + Some(SeedBase { head, index, work }) + } + _ => return None, + }; + let plain = repo.is_none() && git_ref.is_none(); match (action.as_str(), positional.as_slice()) { - ("push", [local, remote]) if repo.is_none() && git_ref.is_none() => Some(Command::Push { + ("pull", [local, remote]) if plain && !since_last => Some(Command::Pull { + socket: socket?, + local: PathBuf::from(local), + remote: absolute(remote)?, + base, + }), + _ if base.is_some() => None, + ("push", [local, remote]) if plain => Some(Command::Push { socket: socket?, local: PathBuf::from(local), remote: absolute(remote)?, @@ -644,6 +675,35 @@ mod tests { list.iter().map(|arg| (*arg).to_string()).collect() } + const SHA: &str = "0123456789abcdef0123456789abcdef01234567"; + + #[test] + fn parses_pull_with_its_base_from_the_record_or_all_three_flags() { + let Some(Command::Pull { local, remote, base, .. }) = + parse(&args(&["pull", "--socket", "/s", "/a", "/home/w/a/"])) + else { + panic!("pull"); + }; + assert_eq!((local.to_str(), remote.as_str()), (Some("/a"), "/home/w/a")); + assert!(base.is_none(), "the record's"); + let Some(Command::Pull { base: Some(base), .. }) = parse(&args(&[ + "pull", + "--socket", + "/s", + "/a", + "/b", + "--base-head", + SHA, + "--base-index", + SHA, + "--base-work", + SHA, + ])) else { + panic!("pull with a base"); + }; + assert_eq!(base.work, SHA); + } + #[test] fn parses_push_and_clone() { let Some(Command::Push { local, remote, since_last, .. }) = @@ -674,6 +734,37 @@ mod tests { &["clone", "--socket", "/s", "/b"], &["plan", "--socket", "/s", "/a", "/b"], &["push", "--socket", "/s", "/a", "/b", "--x"], + &[ + "push", + "--socket", + "/s", + "/a", + "/b", + "--base-head", + SHA, + "--base-index", + SHA, + "--base-work", + SHA, + ], + &["pull", "--socket", "/s", "/a"], + &["pull", "/a", "/b"], + &["pull", "--socket", "/s", "/a", "b"], + &["pull", "--socket", "/s", "/a", "/b", "--since-last"], + &["pull", "--socket", "/s", "/a", "/b", "--base-head", SHA], + &[ + "pull", + "--socket", + "/s", + "/a", + "/b", + "--base-head", + "HEAD", + "--base-index", + SHA, + "--base-work", + SHA, + ], ] { assert!(parse(&args(bad)).is_none(), "{bad:?}"); } diff --git a/cmux-tui/crates/cmux-tui/src/link/seed_cli/pull.rs b/cmux-tui/crates/cmux-tui/src/link/seed_cli/pull.rs new file mode 100644 index 000000000000..0297e152b918 --- /dev/null +++ b/cmux-tui/crates/cmux-tui/src/link/seed_cli/pull.rs @@ -0,0 +1,163 @@ +//! `cmux link seed pull --socket PATH LOCAL REMOTE [--base-head SHA +//! --base-index SHA --base-work SHA]`: brings a seeded Cloud folder's changes +//! back to this Mac as a diff, never a folder copy (leo-workspace +//! `design/cloud.md`, "Getting the project onto the machine", rule 6; the +//! engine is [`sync_cli::seed_pull`]). +//! +//! The base it merges against is this Mac's [`record`] of the folder: the +//! seed the last `cmux link seed push` sent, or what the last pull brought +//! back. The three `--base-*` flags, all or none, override it. Every pull +//! that brings the folder up to the machine's state keeps the machine's +//! HEAD, INDEX and WORK as the new record, so the next pull and the next +//! `cmux link seed push --since-last` build on it. +//! +//! Output: one JSON line on stdout, the engine's `{"ok":true,"unchanged", +//! "written","deleted","bytes","bundle_bytes","fast_forwarded","staged", +//! "head","base":{"head","index","work"},"left_out"}`, exit 0. A conflict is +//! `{"ok":false,"error","error_code":"sync_conflict","conflicts":[paths]}` +//! with nothing changed here; any other failure `{"ok":false,"error", +//! "error_code"}`: `seed.no_previous` when there is no base to merge +//! against, `sync_no_base` when this repository lacks the base's commits, +//! `machine_outdated` for a machine without `fs-v1` and +//! `git-seed-export-v1`. Both exit 1. + +use std::path::Path; + +use serde_json::{Value, json}; + +use super::record::{self, Kept, Record}; +use super::{SeedError, machine_gate, sync_cli}; +use sync_cli::{SeedBase, SeedPulled}; + +/// What the machine must serve. +const NEEDED: [&str; 2] = + [cmux_tui_core::fs_ops::FS_CAPABILITY, cmux_tui_core::git_seed::GIT_SEED_EXPORT_CAPABILITY]; + +/// The pull (module docs): its answer, or the failure line. +pub(super) fn pull( + socket: &Path, + local: &Path, + remote: &str, + flags: Option, +) -> Result { + let Some(identity) = machine_gate::identify(socket) else { + let why = format!("the machine at {} did not answer", socket.display()); + return Err(SeedError::new("sync_failed", why).line()); + }; + if let Some(outdated) = machine_gate::outdated_identity(&identity, &NEEDED) { + return Err(outdated.line()); + } + let machine = identity.get("session_id").and_then(Value::as_str).map(str::to_owned); + let local = std::fs::canonicalize(local).map_err(|error| { + SeedError::new("sync_failed", format!("cannot open {}: {error}", local.display())).line() + })?; + let kept = record::load(&local, remote); + let base = match flags { + Some(base) => base, + None => { + let kept = kept + .as_ref() + .map_err(|error| SeedError::new("seed.no_previous", format!("{error:#}")).line())?; + base_of(kept.as_ref(), machine.as_deref()).map_err(|error| error.line())? + } + }; + match sync_cli::seed_pull(socket, &local, remote, &base) { + Ok(SeedPulled::Done(summary)) => { + let url = kept.ok().flatten().and_then(|record| record.url); + keep(&local, remote, machine, url, &summary); + Ok(summary) + } + Ok(SeedPulled::Conflict(conflicts, reasons)) => { + let mut error = format!( + "{} changed both here and on the machine; nothing was brought back", + conflicts.join(", ") + ); + if !reasons.is_empty() { + error = format!("{error} ({})", reasons.join("; ")); + } + Err(json!({ "ok": false, "error": error, "error_code": "sync_conflict", + "conflicts": conflicts })) + } + Err(error) => Err(json!({ "ok": false, "error": format!("{error:#}"), + "error_code": sync_cli::seed_pull_failure_code(&error) })), + } +} + +/// The base a record gives a pull from the machine `machine`. +fn base_of(kept: Option<&Record>, machine: Option<&str>) -> Result { + let no_previous = |why: &str| SeedError::new("seed.no_previous", why); + let Some(kept) = kept else { + return Err(no_previous( + "this Mac never seeded this folder to that machine folder (or pass --base-head, \ + --base-index and --base-work)", + )); + }; + if let (Some(was), Some(now)) = (kept.machine.as_deref(), machine) + && was != now + { + return Err(no_previous("the last seed of this folder went to another machine")); + } + if kept.mode == Kept::Copy { + return Err(no_previous( + "the last push of this folder was a copy, not a seed; bring it back with cmux link \ + sync pull", + )); + } + match (&kept.head, &kept.index, &kept.work) { + (Some(head), Some(index), Some(work)) => { + Ok(SeedBase { head: head.clone(), index: index.clone(), work: work.clone() }) + } + _ => Err(no_previous("the last seed's record is incomplete")), + } +} + +/// Keeps the machine's state, the answer's `base`, as this folder's record. +fn keep(local: &Path, remote: &str, machine: Option, url: Option, summary: &Value) { + let sha = |key: &str| summary["base"][key].as_str().map(str::to_owned); + let (Some(head), Some(index), Some(work)) = (sha("head"), sha("index"), sha("work")) else { + return; + }; + let record = Record { + mode: Kept::Seed, + machine, + url, + head: Some(head), + index: Some(index), + work: Some(work), + }; + // A record that could not be kept only makes the next pull merge from + // the older base, where what came back already matches both sides. + let _ = record::save(local, remote, &record); +} + +#[cfg(test)] +mod tests { + use super::{Kept, Record, base_of}; + + fn seed(machine: Option<&str>) -> Record { + Record { + mode: Kept::Seed, + machine: machine.map(str::to_owned), + url: None, + head: Some("h".into()), + index: Some("i".into()), + work: Some("w".into()), + } + } + + #[test] + fn the_record_is_the_base_only_for_a_seed_to_the_same_machine() { + let base = base_of(Some(&seed(Some("m"))), Some("m")).unwrap(); + assert_eq!((base.head.as_str(), base.index.as_str(), base.work.as_str()), ("h", "i", "w")); + assert!(base_of(Some(&seed(None)), Some("m")).is_ok(), "an unknown machine"); + for (kept, why) in [ + (None, "no record"), + (Some(seed(Some("other"))), "another machine"), + (Some(Record { mode: Kept::Copy, ..seed(Some("m")) }), "a copy"), + (Some(Record { work: None, ..seed(Some("m")) }), "incomplete"), + ] { + let error = base_of(kept.as_ref(), Some("m")).unwrap_err(); + assert_eq!(error.code, "seed.no_previous", "{why}"); + } + } +} diff --git a/cmux-tui/crates/cmux-tui/src/link/sync_cli.rs b/cmux-tui/crates/cmux-tui/src/link/sync_cli.rs index 1f22529f6705..c9974fcadc5a 100644 --- a/cmux-tui/crates/cmux-tui/src/link/sync_cli.rs +++ b/cmux-tui/crates/cmux-tui/src/link/sync_cli.rs @@ -42,6 +42,12 @@ mod names; mod place; mod pull; mod remote; +mod seed_pull; + +/// `cmux link seed pull`'s engine ([`super::seed_cli`] is its CLI). +pub(super) use seed_pull::{ + SeedBase, SeedPulled, failure_code as seed_pull_failure_code, is_sha, seed_pull, +}; mod skips; use std::collections::{BTreeMap, BTreeSet}; diff --git a/cmux-tui/crates/cmux-tui/src/link/sync_cli/git_back.rs b/cmux-tui/crates/cmux-tui/src/link/sync_cli/git_back.rs index c2be1f2beb6e..fc43f8435342 100644 --- a/cmux-tui/crates/cmux-tui/src/link/sync_cli/git_back.rs +++ b/cmux-tui/crates/cmux-tui/src/link/sync_cli/git_back.rs @@ -346,7 +346,7 @@ fn object_id(text: &str) -> anyhow::Result { /// Makes the quarantine: an empty bare repository of this command's own, /// whose object store borrows this repository's. -fn quarantined(quarantine: &Path, common_dir: &Path) -> anyhow::Result<()> { +pub(super) fn quarantined(quarantine: &Path, common_dir: &Path) -> anyhow::Result<()> { let format = output( Command::new("git").arg("-C").arg(common_dir).args(["rev-parse", "--show-object-format"]), )? @@ -469,14 +469,14 @@ fn set_ref(quarantine: &Path, name: &str, id: &str) -> anyhow::Result<()> { } /// git in the working copy at `folder`, with the person's own settings. -fn local(folder: &Path) -> Command { +pub(super) fn local(folder: &Path) -> Command { let mut command = Command::new("git"); command.arg("-C").arg(folder); command } /// git in the quarantine, with no global or system settings. -fn quarantine_git(quarantine: &Path) -> Command { +pub(super) fn quarantine_git(quarantine: &Path) -> Command { let mut command = clean(Command::new("git")); command.arg("--git-dir").arg(quarantine); command @@ -495,7 +495,7 @@ fn clean(mut command: Command) -> Command { } /// The command's stdout, or an error with its stderr. -fn checked(command: &mut Command) -> anyhow::Result> { +pub(super) fn checked(command: &mut Command) -> anyhow::Result> { let output = command.output().context("cannot run git")?; if !output.status.success() { bail!("git: {}", String::from_utf8_lossy(&output.stderr).trim()); diff --git a/cmux-tui/crates/cmux-tui/src/link/sync_cli/pull.rs b/cmux-tui/crates/cmux-tui/src/link/sync_cli/pull.rs index 01d10a6aa630..4b2920f3480a 100644 --- a/cmux-tui/crates/cmux-tui/src/link/sync_cli/pull.rs +++ b/cmux-tui/crates/cmux-tui/src/link/sync_cli/pull.rs @@ -491,7 +491,7 @@ fn local_names(local: &Path, dir: &str) -> Vec { /// The working-tree folders whose files this working copy's git runs as /// hooks: `core.hooksPath` when it is inside the folder, and the folder /// above it for husky's `.husky/_` (whose hooks call `.husky/`). -fn hook_folders(local: &Path) -> anyhow::Result> { +pub(super) fn hook_folders(local: &Path) -> anyhow::Result> { let Some(path) = git_back::hooks_path(local)? else { return Ok(Vec::new()) }; let home = std::env::var_os("HOME").map(PathBuf::from); let path = match (path.strip_prefix("~/"), home) { diff --git a/cmux-tui/crates/cmux-tui/src/link/sync_cli/seed_pull.rs b/cmux-tui/crates/cmux-tui/src/link/sync_cli/seed_pull.rs new file mode 100644 index 000000000000..d5465ab920ff --- /dev/null +++ b/cmux-tui/crates/cmux-tui/src/link/sync_cli/seed_pull.rs @@ -0,0 +1,800 @@ +//! The engine of `cmux link seed pull` ([`super::super::seed_cli`], which +//! parses it and keeps the base in its record): brings a seeded Cloud +//! folder's changes back to this Mac as a diff, never a folder copy +//! (leo-workspace `design/cloud.md`, "Getting the project onto the machine", +//! rule 6). +//! +//! The base is the seed this Mac last sent or pulled (its HEAD, INDEX and +//! WORK commits; `cmux link seed push` and every pull keep them). The machine answers +//! `git.seed.export` with one bundle of what changed since (`git-seed-export-v1`), +//! read here through `fs-v1` and streamed to disk. It is fetched into a +//! quarantine repository whose object store borrows this one's, every object +//! checked (`transfer.fsckObjects`), as `cmux link sync pull`'s git leg does +//! ([`super::git_back`]). Then three ways, against the base: +//! +//! - HEAD: the machine's commits come back only as a fast-forward of the +//! branch this Mac is on, and only while this Mac's HEAD is still the +//! seed's. A switched branch, a rewritten history or both sides moving is +//! a conflict (`.git/HEAD`). +//! - Staged: the machine's INDEX tree is staged here when this Mac's staged +//! tree is still the seed's; both changed differently is a conflict +//! (`.git/index`). +//! - Working tree, per file, between the seed's WORK tree, the machine's +//! and this Mac's working tree now (as a seed would send it): the +//! machine's version (or its delete) where this Mac's is unchanged, this +//! Mac's where the machine's is unchanged, a conflict where both changed +//! differently. No path that reaches a repository or a hook comes back +//! ([`super::names::carries_repository_or_hook`]), nor a symlink or a +//! submodule the machine changed. +//! +//! Ignored files on the machine (`node_modules`, build output), untracked +//! secrets and nested repositories never come back: the export leaves them +//! out of its WORK. +//! +//! On any conflict nothing here changes ([`SeedPulled::Conflict`]). Otherwise files move into place +//! through [`super::place::Transaction`], then the branch moves by +//! compare-and-swap and the index follows; any failure puts back what +//! changed. The answer's `base` (the machine's HEAD, INDEX and WORK) is the +//! next pull's and the next delta push's base. A machine that changed +//! nothing answers `unchanged` and nothing is downloaded. + +use std::collections::BTreeMap; +use std::io::Write as _; +use std::os::unix::fs::{DirBuilderExt as _, OpenOptionsExt as _, PermissionsExt as _}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::time::{Duration, Instant}; + +use anyhow::{Context as _, bail}; +use serde_json::{Value, json}; +use sha2::{Digest as _, Sha256}; + +use super::git_back::{self, GitBase, checked, local, quarantine_git}; +use super::local::{GitDirs, permissions_of}; +use super::place::{self, Dir, Entry, Expected, Transaction}; +use super::remote::Remote; +use super::{names, pull}; + +/// Most bytes of the machine's bundle (big untracked files travel in it). +const MAX_BUNDLE_BYTES: u64 = 64 << 30; +/// How long the export may run. +const EXPORT_DEADLINE: Duration = Duration::from_secs(30 * 60); +const EXPORT_POLL: Duration = Duration::from_millis(200); + +/// The seed a pull merges against: its HEAD, INDEX and WORK commits. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SeedBase { + pub(crate) head: String, + pub(crate) index: String, + pub(crate) work: String, +} + +/// What a pull did, or the paths that stopped it. +pub(crate) enum SeedPulled { + Done(Value), + /// The conflicting paths, and why for each one that has a reason. + Conflict(Vec, Vec), +} + +/// The machine refused or failed the export: its code and message. +#[derive(Debug)] +pub(crate) struct ExportFailed { + pub code: String, + pub message: String, +} + +impl std::fmt::Display for ExportFailed { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "{}: {}", self.code, self.message) + } +} + +impl std::error::Error for ExportFailed {} + +/// The base's commits are not in this repository. +#[derive(Debug)] +pub(crate) struct NoSeedBase; + +impl std::fmt::Display for NoSeedBase { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .write_str("this repository does not have the seed's commits; nothing was brought back") + } +} + +impl std::error::Error for NoSeedBase {} + +/// The machine's state, as its export answered it. +struct Exported { + head_ref: Option, + head: String, + index: String, + work: String, + unchanged: bool, + bundle_bytes: u64, + left_out: Value, +} + +/// One tree entry: mode and object id. +type TreeEntry = (String, String); +/// Each changed path with its entry on either side (None: absent). +type TreeDiff = BTreeMap, Option)>; + +/// What happens to one working-tree path. +enum Step { + Write { relative: String, staged: String, expected: Option }, + Delete { relative: String, expected: Expected }, +} + +/// The error code of a failed [`seed_pull`]. +pub(crate) fn failure_code(error: &anyhow::Error) -> String { + if let Some(failed) = error.downcast_ref::() { + return failed.code.clone(); + } + if error.downcast_ref::().is_some() { + return "sync_no_base".into(); + } + pull::failure_code(error) +} + +/// Whether `text` is a full object id (SHA-1 or SHA-256), lowercase. +pub(crate) fn is_sha(text: &str) -> bool { + matches!(text.len(), 40 | 64) + && text.bytes().all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} + +/// Pulls the machine's changes to the seeded folder `remote_root` since +/// `base` into the working copy at `local`. +pub(crate) fn seed_pull( + socket: &Path, + local_folder: &Path, + remote_root: &str, + base: &SeedBase, +) -> anyhow::Result { + let folder = std::fs::canonicalize(local_folder) + .with_context(|| format!("cannot open {}", local_folder.display()))?; + let dirs = GitDirs::of(&folder)?.context("the folder is not a git working copy")?; + for sha in [&base.head, &base.index, &base.work] { + let present = + checked(local(&folder).args(["cat-file", "-e"]).arg(format!("{sha}^{{commit}}"))); + if present.is_err() { + return Err(NoSeedBase.into()); + } + } + let mut remote = Remote::connect(socket)?; + let (parent, _) = super::remote::split(remote_root)?; + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_nanos()); + let remote_bundle = format!( + "{}/.cmux-seed-back-{}-{nanos}.bundle", + parent.trim_end_matches('/'), + std::process::id() + ); + let exported = export(&mut remote, remote_root, base, &remote_bundle)?; + let new_base = json!({ "head": exported.head, "index": exported.index, "work": exported.work }); + if exported.unchanged { + return Ok(SeedPulled::Done(json!({ + "ok": true, "unchanged": true, "written": 0, "deleted": 0, "bytes": 0, + "bundle_bytes": 0, "fast_forwarded": false, "staged": false, + "head": exported.head, "base": new_base, "left_out": exported.left_out, + }))); + } + let staging = Staging::make(&dirs.own)?; + let bundle = staging.path.join("back.bundle"); + let downloaded = download(&mut remote, &remote_bundle, &bundle); + let _ = remote.delete(&[remote_bundle]); + downloaded?; + let quarantine = staging.path.join("quarantine.git"); + git_back::quarantined(&quarantine, &dirs.common)?; + receive(&quarantine, &bundle, &exported)?; + + // The branch and the staged changes are read on both sides of the + // snapshot the merge decides from, so the decision and the check in + // [`move_git`] see one state. + let here_git = git_back::base_of(&folder)?; + let here = super::super::seed::plan::current(&folder) + .map_err(|fallback| anyhow::anyhow!("cannot read this folder: {}", fallback.why))?; + if git_back::base_of(&folder)? != here_git { + bail!( + "this Mac's branch or staged changes changed during the pull; nothing was brought back" + ); + } + let hooks = pull::hook_folders(&folder)?; + let q = Quarantine(&quarantine); + let mut conflicts = Vec::new(); + let mut reasons = Vec::new(); + let mut conflict = |path: &str, why: Option| { + conflicts.push(path.to_string()); + if let Some(why) = why { + reasons.push(format!("{path}: {why}")); + } + }; + + // HEAD. + let mut fast_forward = None; + let machine_head = (exported.head_ref.as_deref(), exported.head.as_str()); + let here_head = (here.head_ref.as_deref(), here.head.as_str()); + let head_settled = + machine_head.1 == base.head && machine_head.0 == here_head.0 || machine_head == here_head; + if head_settled { + // Nothing moved, or both sides moved to the same commit. + } else if here_head.1 != base.head { + conflict(".git/HEAD", Some("both this Mac and the machine moved the branch".into())); + } else if machine_head.0 != here_head.0 { + let name = machine_head.0.unwrap_or("a detached HEAD"); + conflict(".git/HEAD", Some(format!("the machine switched to {name}"))); + } else if q.is_ancestor(&base.head, &exported.head) { + if let Some(path) = q.carried(&base.head, &exported.head, &hooks)? { + conflict( + ".git/HEAD", + Some(format!("the machine's commits add a hook or a repository: {path}")), + ); + } + fast_forward = Some(exported.head.clone()); + } else { + conflict(".git/HEAD", Some("the machine rewrote the branch's history".into())); + } + + // Staged. + let mut staged = None; + let tree = |commit: &str| q.text(&["rev-parse", &format!("{commit}^{{tree}}")]); + let (base_index, machine_index, here_index) = + (tree(&base.index)?, tree(&exported.index)?, tree(&here.index)?); + if machine_index != base_index && machine_index != here_index { + if here_index != base_index { + let why = "both this Mac and the machine changed what is staged"; + conflict(".git/index", Some(why.into())); + } else if let Some(path) = q.carried(&base.index, &exported.index, &hooks)? { + let why = format!("the machine staged a hook or a repository: {path}"); + conflict(".git/index", Some(why)); + } else { + staged = Some(machine_index); + } + } + + // The working tree. + let theirs = q.diff(&base.work, &exported.work)?; + let ours = q.diff(&base.work, &here.work)?; + let root = Dir::open(&folder)?; + let mut planned: Vec<(String, Option, Option)> = Vec::new(); + for (relative, (base_entry, machine)) in theirs { + let here_entry = ours.get(&relative).map_or(base_entry.clone(), |(_, here)| here.clone()); + if here_entry == machine { + continue; + } + if here_entry != base_entry { + conflict(&relative, None); + continue; + } + if !names::safe_relative(&relative) || names::carries_repository_or_hook(&relative, &hooks) + { + conflict(&relative, Some("it reaches a repository or a hook".into())); + continue; + } + let regular = |entry: &Option| { + entry.as_ref().is_none_or(|(mode, _)| matches!(mode.as_str(), "100644" | "100755")) + }; + if !regular(&machine) || !regular(&here_entry) { + conflict(&relative, Some("a symlink or submodule changed; it stays as it is".into())); + continue; + } + planned.push((relative, here_entry, machine)); + } + if !conflicts.is_empty() { + conflicts.sort(); + conflicts.dedup(); + return Ok(SeedPulled::Conflict(conflicts, reasons)); + } + + // Stage the machine's files and read what each replaces here. + let mut steps = Vec::new(); + let mut bytes = 0u64; + for (relative, here_entry, machine) in planned { + let expected = match &here_entry { + Some((_, blob)) => match disk_file(&root, &folder, &staging, &relative, blob)? { + Some(expected) => Some(expected), + None => { + let why = format!("{relative}: it changed here during the pull"); + return Ok(SeedPulled::Conflict(vec![relative], vec![why])); + } + }, + None => None, + }; + match machine { + Some((mode, blob)) => { + let staged_name = staging.stage(&quarantine, &blob, &mode)?; + bytes += std::fs::metadata(staging.path.join(&staged_name))?.len(); + steps.push(Step::Write { relative, staged: staged_name, expected }); + } + None => { + if let Some(expected) = expected { + steps.push(Step::Delete { relative, expected }); + } + } + } + } + // Only objects so far: this repository gets the machine's commits. + q.fetch_into(&folder, &exported)?; + // Deletes first, so a file may take the place of a folder it emptied. + steps.sort_by_key(|step| matches!(step, Step::Write { .. })); + let mut transaction = Transaction::new(&folder, &staging.path)?; + let (mut written, mut deleted) = (0u64, 0u64); + for step in steps { + let applied = match &step { + Step::Write { relative, staged, expected } => { + transaction.write(relative, staged, expected.as_ref()).map(|()| written += 1) + } + Step::Delete { relative, expected } => { + transaction.delete(relative, expected).map(|()| deleted += 1) + } + }; + if let Err(error) = applied { + return undone(transaction, &staging, error); + } + } + if let Err(error) = move_git(&folder, &here_git, fast_forward.as_deref(), staged.as_deref()) { + return undone(transaction, &staging, error); + } + transaction.finish(); + Ok(SeedPulled::Done(json!({ + "ok": true, + "unchanged": false, + "written": written, + "deleted": deleted, + "bytes": bytes, + "bundle_bytes": exported.bundle_bytes, + "fast_forwarded": fast_forward.is_some(), + "staged": staged.is_some(), + "head": exported.head, + "base": new_base, + "left_out": exported.left_out, + }))) +} + +/// Starts the export and waits (bounded) for it to end. +fn export( + remote: &mut Remote, + remote_root: &str, + base: &SeedBase, + bundle: &str, +) -> anyhow::Result { + let refused = |error: anyhow::Error| match super::remote::refusal_code(&error) { + Some(code) => anyhow::Error::from(ExportFailed { + code: code.to_string(), + message: format!("{error:#}"), + }), + None => error, + }; + let started = remote + .call( + "git.seed.export", + json!({ "folder": remote_root, "since_work": base.work, "since_head": base.head, "bundle": bundle }), + ) + .map_err(refused)?; + let job = started["job"].as_str().context("the export answered no job")?.to_string(); + let deadline = Instant::now() + EXPORT_DEADLINE; + let status = loop { + let status = remote.call("git.seed.status", json!({ "job": job })).map_err(refused)?; + if status["state"] != "running" { + break status; + } + if Instant::now() > deadline { + bail!("the machine's export did not finish"); + } + std::thread::sleep(EXPORT_POLL); + }; + if status["state"] != "done" { + return Err(ExportFailed { + code: status["error_code"].as_str().unwrap_or("seed.export_failed").to_string(), + message: status["error"].as_str().unwrap_or("the machine's export failed").to_string(), + } + .into()); + } + let sha = |name: &str| -> anyhow::Result { + let value = status[name].as_str().unwrap_or_default(); + if !is_sha(value) { + bail!("the machine's export answered no {name} commit"); + } + Ok(value.to_string()) + }; + let head_ref = status["head_ref"].as_str().map(str::to_string); + if let Some(name) = &head_ref + && name.strip_prefix("refs/heads/").is_none_or(str::is_empty) + { + bail!("the machine's HEAD names {name}, not a branch"); + } + Ok(Exported { + head_ref, + head: sha("head")?, + index: sha("index")?, + work: sha("work")?, + unchanged: status["unchanged"] == json!(true), + bundle_bytes: status["bundle_bytes"].as_u64().unwrap_or(0), + left_out: status["left_out"].clone(), + }) +} + +/// Streams the machine's bundle to `to` (a new private file). +fn download(remote: &mut Remote, from: &str, to: &Path) -> anyhow::Result<()> { + let mut out = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(to)?; + remote + .read_into(from, &mut out, MAX_BUNDLE_BYTES)? + .context("the machine's bundle went away")?; + out.sync_all()?; + Ok(()) +} + +/// Verifies the bundle and fetches it into the quarantine, every object +/// checked; the commits the export named must be there, a ref the bundle +/// carried must name the same commit, and they must chain as a seed's do +/// (WORK on INDEX on HEAD). +fn receive(quarantine: &Path, bundle: &Path, exported: &Exported) -> anyhow::Result<()> { + let invalid = "the machine's bundle is not valid"; + checked(quarantine_git(quarantine).args(["bundle", "verify", "-q"]).arg(bundle)) + .context(invalid)?; + checked( + quarantine_git(quarantine) + .args(["-c", "protocol.file.allow=always"]) + .args(["-c", "transfer.fsckObjects=true", "-c", "fetch.fsckObjects=true"]) + .args(["fetch", "--quiet", "--no-tags", "--no-write-fetch-head"]) + .args(["--no-recurse-submodules", "--no-auto-gc"]) + .arg(bundle) + .arg("+refs/cmux-back/*:refs/cmux-back/*"), + ) + .context(invalid)?; + let q = Quarantine(quarantine); + let heads = String::from_utf8_lossy(&checked( + quarantine_git(quarantine).args(["bundle", "list-heads"]).arg(bundle), + )?) + .into_owned(); + for (name, sha) in + [("head", &exported.head), ("index", &exported.index), ("work", &exported.work)] + { + if q.text(&["rev-parse", "--verify", "--quiet", &format!("{sha}^{{commit}}")]) + .ok() + .as_deref() + != Some(sha.as_str()) + { + bail!("the machine's bundle lacks its {name} commit"); + } + let reference = format!("refs/cmux-back/{name}"); + let carried = heads.lines().find_map(|line| { + line.split_once(' ').filter(|(_, listed)| *listed == reference).map(|(id, _)| id) + }); + if carried.is_some_and(|carried| carried != sha) { + bail!("the machine's bundle names another {name} commit"); + } + } + let parent = + |commit: &str| q.text(&["rev-parse", "--verify", "--quiet", &format!("{commit}^1")]); + if parent(&exported.work)? != exported.index || parent(&exported.index)? != exported.head { + bail!("the machine's export is not a seed's commits"); + } + Ok(()) +} + +/// The quarantine, read with no global or system settings. +struct Quarantine<'a>(&'a Path); + +impl Quarantine<'_> { + fn text(&self, args: &[&str]) -> anyhow::Result { + Ok(String::from_utf8_lossy(&checked(quarantine_git(self.0).args(args))?).trim().to_string()) + } + + fn is_ancestor(&self, old: &str, new: &str) -> bool { + quarantine_git(self.0) + .args(["merge-base", "--is-ancestor", old, new]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) + } + + /// A path `new` adds or changes over `old` that carries a repository or + /// a hook here. + fn carried(&self, old: &str, new: &str, hooks: &[String]) -> anyhow::Result> { + let paths = checked(quarantine_git(self.0).args([ + "diff-tree", + "-r", + "-z", + "--no-renames", + "--name-only", + old, + new, + ]))?; + Ok(paths + .split(|byte| *byte == 0) + .map(|path| String::from_utf8_lossy(path).into_owned()) + .find(|path| !path.is_empty() && names::carries_repository_or_hook(path, hooks))) + } + + /// The paths that differ between the trees of `old` and `new`, with + /// each side's entry (None: absent). + fn diff(&self, old: &str, new: &str) -> anyhow::Result { + let listing = checked(quarantine_git(self.0).args([ + "diff-tree", + "-r", + "-z", + "--no-renames", + old, + new, + ]))?; + let mut changed = BTreeMap::new(); + let mut fields = listing.split(|byte| *byte == 0).filter(|field| !field.is_empty()); + while let (Some(meta), Some(path)) = (fields.next(), fields.next()) { + // `:100644 100644 M` + let meta = String::from_utf8_lossy(meta); + let parts: Vec<&str> = meta.trim_start_matches(':').split(' ').collect(); + let [old_mode, new_mode, old_id, new_id, _status] = parts.as_slice() else { + bail!("git diff-tree gave an unexpected line"); + }; + let Ok(path) = String::from_utf8(path.to_vec()) else { + bail!("the machine's tree names a path that is not UTF-8"); + }; + let entry = |mode: &str, id: &str| { + (mode != "000000").then(|| (mode.to_string(), id.to_string())) + }; + changed.insert(path, (entry(old_mode, old_id), entry(new_mode, new_id))); + } + Ok(changed) + } + + /// Fetches the machine's HEAD, INDEX and WORK commits into the working + /// copy at `folder`, every object checked: they are the next base. No + /// ref changes there. + fn fetch_into(&self, folder: &Path, exported: &Exported) -> anyhow::Result<()> { + let mut wanted = Vec::new(); + for (name, sha) in + [("head", &exported.head), ("index", &exported.index), ("work", &exported.work)] + { + let reference = format!("refs/cmux-pull/{name}"); + checked(quarantine_git(self.0).args(["update-ref", &reference, sha]))?; + wanted.push(reference); + } + checked( + local(folder) + .args(["-c", "transfer.fsckObjects=true", "-c", "fetch.fsckObjects=true"]) + .args(["fetch", "--quiet", "--no-tags", "--no-write-fetch-head"]) + .args(["--no-recurse-submodules", "--no-auto-gc"]) + .arg(self.0) + .args(&wanted), + ) + .context("cannot fetch the machine's commits")?; + Ok(()) + } +} + +/// This Mac's regular file at `relative` as a transaction checks it; None +/// when it is not a regular file reached without a symlink. +fn disk_file( + root: &Dir, + folder: &Path, + staging: &Staging, + relative: &str, + blob: &str, +) -> anyhow::Result> { + let Ok(Some((dir, name))) = place::parent_of(root, relative) else { return Ok(None) }; + let Entry::File { mode } = dir.entry(&name)? else { return Ok(None) }; + let mut file = dir.open_file(&name)?; + // One read: the bytes hashed for the transaction are the bytes checked + // against the blob the merge planned from (as `git add` would store + // them, through the path's filters). A file saved since the snapshot, + // or one git was told to assume unchanged, is not that blob. + let (copy, sha256) = staging.copy(&mut file)?; + let stored = + checked(local(folder).args(["hash-object", "--path"]).arg(relative).arg("--").arg(©))?; + let _ = std::fs::remove_file(©); + if String::from_utf8_lossy(&stored).trim() != blob { + return Ok(None); + } + Ok(Some(Expected { sha256, permissions: permissions_of(mode) })) +} + +/// Moves HEAD's branch (compare-and-swap) and then the index, once this +/// Mac's branch and staged changes are checked to be still `planned`. The +/// branch moves back if the index can't follow. The index is git's own +/// `index.lock` from that check until the new index is in place, so nothing +/// staged in between is lost. +fn move_git( + folder: &Path, + planned: &GitBase, + head: Option<&str>, + staged: Option<&str>, +) -> anyhow::Result<()> { + if head.is_none() && staged.is_none() { + return Ok(()); + } + let lock = staged.map(|_| IndexLock::take(folder)).transpose()?; + if git_back::base_of(folder)? != *planned { + bail!("this Mac's branch or staged changes changed during the pull"); + } + let move_ref = |new: &str, old: &str| { + let mut command = local(folder); + command.args(["update-ref", "-m", "cmux seed pull: bring back from the machine"]); + match &planned.head_ref { + Some(name) => command.arg(name), + None => command.args(["--no-deref", "HEAD"]), + }; + checked(command.args([new, old])).map(drop) + }; + let old = planned.head.clone().unwrap_or_default(); + if let Some(head) = head { + move_ref(head, &old).context("cannot move the branch")?; + } + if let (Some(tree), Some(lock)) = (staged, &lock) + && let Err(error) = lock.replace(folder, tree) + { + if let Some(head) = head { + let _ = move_ref(&old, head); + } + return Err(error.context("cannot stage the machine's changes")); + } + Ok(()) +} + +/// git's `index.lock` of a working copy, held: no git command stages while +/// it is there. Dropped, it goes. +struct IndexLock { + index: PathBuf, + lock: PathBuf, +} + +impl IndexLock { + fn take(folder: &Path) -> anyhow::Result { + let path = checked(local(folder).args([ + "rev-parse", + "--path-format=absolute", + "--git-path", + "index", + ]))?; + let index = PathBuf::from(String::from_utf8_lossy(&path).trim_end_matches('\n')); + let lock = index.with_extension("lock"); + std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o644) + .custom_flags(libc::O_NOFOLLOW) + .open(&lock) + .with_context(|| format!("another git process is using {}", lock.display()))?; + Ok(Self { index, lock }) + } + + /// Writes the index of `tree` to a new file beside the index and renames + /// it over the index while the lock is held. + fn replace(&self, folder: &Path, tree: &str) -> anyhow::Result<()> { + let fresh = self.index.with_extension(format!("cmux-seed-pull-{}", std::process::id())); + let _ = std::fs::remove_file(&fresh); + let written = + checked(local(folder).env("GIT_INDEX_FILE", &fresh).arg("read-tree").arg(tree)) + .and_then(|_| { + std::fs::rename(&fresh, &self.index) + .with_context(|| format!("cannot write {}", self.index.display())) + }); + if written.is_err() { + let _ = std::fs::remove_file(&fresh); + } + written + } +} + +impl Drop for IndexLock { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.lock); + } +} + +/// Puts back what `transaction` changed after `error`. A file that changed +/// here during the pull is a conflict. +fn undone( + transaction: Transaction, + staging: &Staging, + error: anyhow::Error, +) -> anyhow::Result { + if let Err(left) = transaction.roll_back() { + staging.keep.set(true); + return Err(error.context(format!( + "cmux could not put back {}; what it moved aside is in {}", + left.join(", "), + staging.path.display() + ))); + } + if let Some(changed) = error.downcast_ref::() { + return Ok(SeedPulled::Conflict(vec![changed.0.clone()], Vec::new())); + } + Err(error) +} + +/// This pull's own folder in the repository's git folder, removed when the +/// pull ends unless it holds a file a failed pull could not put back. +struct Staging { + path: PathBuf, + files: std::cell::Cell, + keep: std::cell::Cell, +} + +impl Staging { + fn make(git_dir: &Path) -> anyhow::Result { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_nanos()); + let path = git_dir.join(format!(".cmux-seed-pull-{}-{nanos}", std::process::id())); + std::fs::DirBuilder::new() + .mode(0o700) + .create(&path) + .with_context(|| format!("cannot make {}", path.display()))?; + Ok(Self { path, files: std::cell::Cell::new(0), keep: std::cell::Cell::new(false) }) + } + + /// Copies `file` to a new file here as it reads it; the copy and the + /// SHA-256 of what was read. + fn copy(&self, file: &mut std::fs::File) -> anyhow::Result<(PathBuf, String)> { + let number = self.files.get() + 1; + self.files.set(number); + let path = self.path.join(format!("here-{number}")); + let mut out = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(&path)?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0u8; 1 << 16]; + loop { + let read = std::io::Read::read(file, &mut buffer)?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + out.write_all(&buffer[..read])?; + } + let sha256 = hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect(); + Ok((path, sha256)) + } + + /// Writes the blob `id` of the quarantine to a new file here with the + /// tree's `mode` (0644 or 0755); its name here. + fn stage(&self, quarantine: &Path, id: &str, mode: &str) -> anyhow::Result { + let number = self.files.get() + 1; + self.files.set(number); + let name = format!("file-{number}"); + let mut out = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .custom_flags(libc::O_NOFOLLOW) + .open(self.path.join(&name))?; + let mut child = quarantine_git(quarantine) + .args(["cat-file", "blob", id]) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .context("cannot run git")?; + let mut stdout = child.stdout.take().context("no stdout")?; + let copied = std::io::copy(&mut stdout, &mut out); + let status = child.wait()?; + copied?; + if !status.success() { + bail!("the machine's blob {id} is missing"); + } + let bits = if mode == "100755" { 0o755 } else { 0o644 }; + out.set_permissions(std::fs::Permissions::from_mode(bits))?; + out.flush()?; + out.sync_all()?; + Ok(name) + } +} + +impl Drop for Staging { + fn drop(&mut self) { + if self.keep.get() || std::thread::panicking() { + return; + } + let _ = std::fs::remove_dir_all(&self.path); + } +} diff --git a/cmux-tui/crates/cmux-tui/tests/seed_pull_journey.rs b/cmux-tui/crates/cmux-tui/tests/seed_pull_journey.rs new file mode 100644 index 000000000000..78ae24bbfb3e --- /dev/null +++ b/cmux-tui/crates/cmux-tui/tests/seed_pull_journey.rs @@ -0,0 +1,400 @@ +//! Seed pull, end to end on one Linux box with real processes: a project +//! seeded to a daemon shaped like a personal Cloud VM (`git-seed-v1`, its +//! GitHub remote a `file://` bare repository through the debug-only +//! `CMUX_TEST_GIT_SEED_GITHUB` seam) changes there, and `cmux link seed +//! pull` brings the changes back as a diff (`git-seed-export-v1` and +//! `fs-v1`), never a folder copy, merged against this Mac's seed record or +//! the `--base-*` flags. +//! +//! Linux only: a Cloud host is a Linux machine (`cloud_fs::is_cloud_host`). +#![cfg(target_os = "linux")] + +mod journey_support; + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::Engine as _; +use journey_support::*; +use serde_json::{Value, json}; + +/// A Mac daemon, a Cloud VM daemon and the "GitHub" they share. +struct World { + root: PathBuf, + mac: Machine, + vm: Machine, + _cleanup: Cleanup, +} + +/// The seed a pull merges against. +#[derive(Clone)] +struct Base { + head: String, + index: String, + work: String, +} + +impl World { + fn new(label: &str) -> Self { + let stamp = + SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos() % 1_000_000_000; + let root = + PathBuf::from("/tmp").join(format!("csp-{label}-{}-{stamp}", std::process::id())); + fs::create_dir_all(root.join("remotes/o")).unwrap(); + let root = fs::canonicalize(&root).unwrap(); + let cleanup = Cleanup(root.clone()); + let mac = Machine::start(&root, "mac", Vec::new()); + let vm = Machine::start( + &root, + "vm", + vec![ + ("CMUX_TEST_CLOUD_ETC", cloud_etc(&root).to_string_lossy().into_owned()), + ("CMUX_VM_ID", format!("vm-seed-pull-{label}")), + ("CMUX_CODEROUTER_URL", "http://127.0.0.1:9".to_owned()), + ("CMUX_TEST_GIT_SEED_GITHUB", root.join("remotes").to_string_lossy().into_owned()), + ], + ); + Self { root, mac, vm, _cleanup: cleanup } + } + + /// A working copy on the Mac whose `origin` is + /// `https://github.com/o/NAME.git` (read from the bare repository under + /// `remotes/`), its first commit pushed. `.githooks` is its hooks folder. + fn project(&self, name: &str) -> PathBuf { + let repo = self.mac.home().join(name); + fs::create_dir_all(repo.join("d")).unwrap(); + fs::create_dir_all(repo.join(".githooks")).unwrap(); + git(&repo, &["init", "-q", "-b", "main"]); + for (path, text) in [ + ("a", "a\n"), + ("b", "b\n"), + ("d/c", "c\n"), + ("notes", "notes\n"), + (".gitignore", "node_modules/\n"), + (".githooks/pre-commit", "#!/bin/sh\nexit 0\n"), + ] { + fs::write(repo.join(path), text).unwrap(); + } + git(&repo, &["add", "-A"]); + git(&repo, &["commit", "-q", "-m", "first"]); + let bare = self.root.join(format!("remotes/o/{name}.git")); + git(&self.root, &["clone", "-q", "--bare", repo.to_str().unwrap(), bare.to_str().unwrap()]); + git(&bare, &["config", "uploadpack.allowFilter", "true"]); + git(&bare, &["config", "uploadpack.allowAnySHA1InWant", "true"]); + let url = format!("https://github.com/o/{name}.git"); + git(&repo, &["remote", "add", "origin", &url]); + let rewrite = format!("url.file://{}/.insteadOf", self.root.join("remotes").display()); + git(&repo, &["config", &rewrite, "https://github.com/"]); + git(&repo, &["fetch", "-q", "origin"]); + git(&repo, &["branch", "-q", "-u", "origin/main"]); + git(&repo, &["config", "core.hooksPath", ".githooks"]); + repo + } + + /// The VM's folder for `name`. + fn folder(&self, name: &str) -> PathBuf { + self.vm.home().join("work").join(name) + } + + /// Seeds `repo` to the VM as `cmux link seed plan` and `git.seed.start` + /// do; the seed's commits. + fn seed(&self, repo: &Path, name: &str) -> Base { + let bundle = self.root.join(format!("{name}.bundle")); + let (ok, plan) = run_json( + Command::new(bin()) + .args(["link", "seed", "plan", "--local"]) + .arg(repo) + .arg("--bundle") + .arg(&bundle), + ); + assert!(ok, "seed plan: {plan}"); + let upload = self.vm.home().join(format!("work/{name}.seed.bundle")); + let bytes = fs::read(&bundle).unwrap(); + let written = self.vm.rpc(json!({ + "id": 1, "cmd": "fs.write", "path": upload, "mode": "create", + "bytes_base64": base64::engine::general_purpose::STANDARD.encode(bytes), + })); + assert_eq!(written["ok"], true, "{written}"); + let mut params = plan["params"].clone(); + params["id"] = json!(2); + params["cmd"] = json!("git.seed.start"); + params["folder"] = json!(self.folder(name)); + params["bundle"] = json!(upload); + let started = self.vm.rpc(params); + assert_eq!(started["ok"], true, "{started}"); + let job = started["data"]["job"].as_str().unwrap().to_owned(); + let status = wait_until(120, "the seed", || { + let status = self.vm.rpc(json!({"id": 3, "cmd": "git.seed.status", "job": job})); + (status["data"]["state"] != "running").then_some(status) + }); + assert_eq!(status["data"]["state"], "done", "{status}"); + let sha = |key: &str| plan["params"][key].as_str().unwrap().to_owned(); + Base { head: sha("head"), index: sha("index"), work: sha("work") } + } + + /// `cmux link seed pull` on the Mac, against `base` or (None) the + /// Mac's record. + fn pull(&self, repo: &Path, name: &str, base: Option<&Base>) -> (bool, Value) { + let mut command = self.mac.command(); + command.args(["link", "seed", "pull", "--socket"]); + command.arg(&self.vm.socket).arg(repo).arg(self.folder(name)); + if let Some(base) = base { + command.args(["--base-head", &base.head, "--base-index", &base.index]); + command.args(["--base-work", &base.work]); + } + run_json(&mut command) + } + + /// `cmux link seed push` on the Mac (it keeps the record). + fn push(&self, repo: &Path, name: &str, since_last: bool) -> (bool, Value) { + let mut command = self.mac.command(); + command.args(["link", "seed", "push", "--socket"]); + command.arg(&self.vm.socket).arg(repo).arg(self.folder(name)); + if since_last { + command.arg("--since-last"); + } + run_json(&mut command) + } + + /// Bundles the export left on the VM. + fn leftover_bundles(&self) -> Vec { + fs::read_dir(self.vm.home().join("work")) + .unwrap() + .filter_map(Result::ok) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .filter(|name| name.ends_with(".bundle")) + .collect() + } +} + +fn write(path: &Path, text: &str) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, text).unwrap(); +} + +fn read(path: &Path) -> String { + fs::read_to_string(path).unwrap() +} + +/// Everything a pull could change on the Mac: status, HEAD, the index (its +/// entries and bytes) and every file's bytes outside `.git`. +fn everything(repo: &Path) -> (String, String, String, Vec, BTreeMap>) { + fn walk(top: &Path, dir: &Path, files: &mut BTreeMap>) { + for entry in fs::read_dir(dir).unwrap().filter_map(Result::ok) { + let path = entry.path(); + if entry.file_name() == ".git" { + continue; + } + if entry.file_type().unwrap().is_dir() { + walk(top, &path, files); + } else { + let relative = path.strip_prefix(top).unwrap().to_string_lossy().into_owned(); + files.insert(relative, fs::read(&path).unwrap()); + } + } + } + let mut files = BTreeMap::new(); + walk(repo, repo, &mut files); + ( + git(repo, &["status", "--porcelain", "--untracked-files=all"]), + git(repo, &["rev-parse", "HEAD"]), + git(repo, &["ls-files", "-s"]), + fs::read(repo.join(".git/index")).unwrap(), + files, + ) +} + +fn conflicted(answer: &Value) -> Vec<&str> { + answer["conflicts"].as_array().unwrap().iter().filter_map(Value::as_str).collect() +} + +#[test] +fn a_pull_brings_back_commits_edits_adds_deletes_and_staged_changes_not_ignored_files() { + let world = World::new("all"); + let repo = world.project("app"); + // No seed yet: no record to merge against. + let (ok, none) = world.pull(&repo, "app", None); + assert!(!ok && none["error_code"] == "seed.no_previous", "{none}"); + // `cmux link seed push` seeds it and keeps the record each pull reads. + let (ok, pushed) = world.push(&repo, "app", false); + assert!(ok && pushed["mode"] == "seed", "{pushed}"); + let vm = world.folder("app"); + // Nothing changed yet: nothing is downloaded. + let (ok, same) = world.pull(&repo, "app", None); + assert!(ok && same["unchanged"] == json!(true), "{same}"); + assert_eq!(same["bundle_bytes"], 0); + assert!(world.leftover_bundles().is_empty(), "{:?}", world.leftover_bundles()); + + write(&vm.join("b"), "b\ncommitted on the vm\n"); + git(&vm, &["commit", "-q", "-am", "on the vm"]); + write(&vm.join("a"), "a\nedited on the vm\n"); + write(&vm.join("made.txt"), "made on the vm\n"); + fs::remove_file(vm.join("d/c")).unwrap(); + write(&vm.join("notes"), "notes\nstaged on the vm\n"); + git(&vm, &["add", "notes"]); + write(&vm.join("node_modules/dep/index.js"), "ignored\n"); + write(&vm.join(".env"), "SECRET=1\n"); + + let (ok, pulled) = world.pull(&repo, "app", None); + assert!(ok, "seed pull: {pulled}"); + assert_eq!(pulled["fast_forwarded"], true, "{pulled}"); + assert_eq!(pulled["staged"], true, "{pulled}"); + assert_eq!(git(&repo, &["rev-parse", "HEAD"]), git(&vm, &["rev-parse", "HEAD"])); + assert_eq!(git(&repo, &["symbolic-ref", "HEAD"]).trim(), "refs/heads/main"); + assert_eq!(git(&repo, &["write-tree"]), git(&vm, &["write-tree"]), "the staged tree"); + assert_eq!(read(&repo.join("a")), "a\nedited on the vm\n"); + assert_eq!(read(&repo.join("b")), "b\ncommitted on the vm\n"); + assert_eq!(read(&repo.join("made.txt")), "made on the vm\n"); + assert!(!repo.join("d/c").exists(), "the delete"); + assert!(!repo.join("node_modules").exists(), "ignored files stay on the VM"); + assert!(!repo.join(".env").exists(), "secrets stay on the VM"); + let status = |dir: &Path| { + git(dir, &["status", "--porcelain", "--untracked-files=all"]) + .lines() + .filter(|line| !line.ends_with(".env")) + .map(str::to_owned) + .collect::>() + }; + assert_eq!(status(&repo), status(&vm), "git status differs from the VM's"); + assert!(world.leftover_bundles().is_empty(), "{:?}", world.leftover_bundles()); + + // The record moved to the pull's base: a second pull finds nothing new, + let (ok, again) = world.pull(&repo, "app", None); + assert!(ok && again["unchanged"] == json!(true), "{again}"); + assert_eq!(again["base"], pulled["base"], "{again}"); + // and the next delta push builds on it. + write(&repo.join("a"), "a\nedited on the vm\nthen on the mac\n"); + let (ok, delta) = world.push(&repo, "app", true); + assert!(ok && delta["mode"] == "delta", "{delta}"); + assert_eq!(read(&vm.join("a")), "a\nedited on the vm\nthen on the mac\n"); + let (ok, after) = world.pull(&repo, "app", None); + assert!(ok && after["unchanged"] == json!(true), "{after}"); +} + +#[test] +fn a_pull_keeps_both_sides_and_refuses_what_changed_on_both() { + let world = World::new("both"); + let repo = world.project("app"); + let base = world.seed(&repo, "app"); + let vm = world.folder("app"); + + // Different files: both kept. + write(&repo.join("a"), "a\nedited on the mac\n"); + write(&vm.join("b"), "b\nedited on the vm\n"); + let (ok, pulled) = world.pull(&repo, "app", Some(&base)); + assert!(ok, "{pulled}"); + assert_eq!(read(&repo.join("a")), "a\nedited on the mac\n"); + assert_eq!(read(&repo.join("b")), "b\nedited on the vm\n"); + let base = Base { + head: pulled["base"]["head"].as_str().unwrap().to_owned(), + index: pulled["base"]["index"].as_str().unwrap().to_owned(), + work: pulled["base"]["work"].as_str().unwrap().to_owned(), + }; + + // The same file, differently: nothing changes here. + write(&repo.join("notes"), "notes\nfrom the mac\n"); + write(&vm.join("notes"), "notes\nfrom the vm\n"); + write(&vm.join("other"), "would come back\n"); + let before = everything(&repo); + let (ok, refused) = world.pull(&repo, "app", Some(&base)); + assert!(!ok, "{refused}"); + assert_eq!(refused["error_code"], "sync_conflict", "{refused}"); + assert_eq!(conflicted(&refused), vec!["notes"], "{refused}"); + assert_eq!(everything(&repo), before, "a conflict changed something here"); + assert!(world.leftover_bundles().is_empty()); + + // A file git was told to assume unchanged, edited here: the snapshot + // reads it as the seed's, but the bytes on disk are not; a conflict, + // nothing changes. + write(&repo.join("notes"), "notes\nfrom the vm\n"); + write(&vm.join("notes"), "notes\nfrom the vm\n"); + write(&vm.join("other"), "would come back\n"); + let (ok, settled) = world.pull(&repo, "app", Some(&base)); + assert!(ok, "{settled}"); + let base = Base { + head: settled["base"]["head"].as_str().unwrap().to_owned(), + index: settled["base"]["index"].as_str().unwrap().to_owned(), + work: settled["base"]["work"].as_str().unwrap().to_owned(), + }; + git(&repo, &["update-index", "--assume-unchanged", "a"]); + write(&repo.join("a"), "a\nedited on the mac, hidden from git\n"); + write(&vm.join("a"), "a\nedited on the vm\n"); + let before = everything(&repo); + let (ok, refused) = world.pull(&repo, "app", Some(&base)); + assert!(!ok, "{refused}"); + assert_eq!(refused["error_code"], "sync_conflict", "{refused}"); + assert_eq!(conflicted(&refused), vec!["a"], "{refused}"); + assert_eq!(everything(&repo), before, "the hidden edit was overwritten"); + git(&repo, &["update-index", "--no-assume-unchanged", "a"]); + write(&repo.join("a"), "a\nedited on the vm\n"); + + // Another git process holds the index while the VM staged something: + // nothing changes, and its lock stays. + write(&vm.join("b"), "b\nedited on the vm\nstaged there\n"); + git(&vm, &["add", "b"]); + write(&repo.join(".git/index.lock"), ""); + let before = everything(&repo); + let (ok, refused) = world.pull(&repo, "app", Some(&base)); + assert!(!ok, "{refused}"); + assert_eq!(everything(&repo), before, "a held index changed something here"); + assert!(repo.join(".git/index.lock").exists(), "another process's lock was removed"); + fs::remove_file(repo.join(".git/index.lock")).unwrap(); + git(&vm, &["reset", "-q", "b"]); + write(&vm.join("b"), "b\nedited on the vm\n"); + + // The VM rewrote history: a conflict, nothing changes. + write(&repo.join("notes"), "notes\nfrom the vm\n"); + git(&vm, &["commit", "-q", "--amend", "-am", "rewritten on the vm"]); + let before = everything(&repo); + let (ok, refused) = world.pull(&repo, "app", Some(&base)); + assert!(!ok, "{refused}"); + assert_eq!(refused["error_code"], "sync_conflict", "{refused}"); + assert!(conflicted(&refused).contains(&".git/HEAD"), "{refused}"); + assert!(refused["error"].as_str().unwrap().contains("rewrote"), "{refused}"); + assert_eq!(everything(&repo), before, "a conflict changed something here"); +} + +#[test] +fn a_pull_never_brings_back_hooks_or_repositories() { + let world = World::new("hooks"); + let repo = world.project("app"); + let base = world.seed(&repo, "app"); + let vm = world.folder("app"); + + // A hook in the VM's own `.git` and a nested repository: neither is part + // of the working tree a pull brings back. + write(&vm.join(".git/hooks/post-checkout"), "#!/bin/sh\ntouch /tmp/pwned\n"); + let inner = vm.join("vendor/inner"); + write(&inner.join("x.txt"), "inner\n"); + git(&inner, &["init", "-q"]); + git(&inner, &["add", "-A"]); + git(&inner, &["commit", "-q", "-m", "inner"]); + write(&vm.join("a"), "a\nfrom the vm\n"); + let (ok, pulled) = world.pull(&repo, "app", Some(&base)); + assert!(ok, "{pulled}"); + assert_eq!(read(&repo.join("a")), "a\nfrom the vm\n"); + assert!(!repo.join("vendor").exists(), "a nested repository came back"); + assert!(!repo.join(".git/hooks/post-checkout").exists(), "a hook came back"); + let left: Vec<&str> = + pulled["left_out"].as_array().unwrap().iter().filter_map(Value::as_str).collect(); + assert!(left.contains(&"vendor/inner/"), "{pulled}"); + let base = Base { + head: pulled["base"]["head"].as_str().unwrap().to_owned(), + index: pulled["base"]["index"].as_str().unwrap().to_owned(), + work: pulled["base"]["work"].as_str().unwrap().to_owned(), + }; + + // A file in this Mac's hooks folder (`core.hooksPath`): refused. + write(&vm.join(".githooks/pre-commit"), "#!/bin/sh\ntouch /tmp/pwned\n"); + let before = everything(&repo); + let (ok, refused) = world.pull(&repo, "app", Some(&base)); + assert!(!ok, "{refused}"); + assert_eq!(refused["error_code"], "sync_conflict", "{refused}"); + assert_eq!(conflicted(&refused), vec![".githooks/pre-commit"], "{refused}"); + assert_eq!(everything(&repo), before, "a refused hook changed something here"); +}