From 1b736efeae35f670cbcfb407a055fc5d940fb81e Mon Sep 17 00:00:00 2001 From: hbrodin <90325907+hbrodin@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:50:38 +0200 Subject: [PATCH] Fix pull mirroring and reject additive Git overlays --- .github/workflows/ci.yml | 4 +- docs/commands.md | 13 +- docs/editor.md | 6 + docs/getting-started.md | 9 +- docs/testing.md | 6 + docs/trust-model.md | 7 + docs/workspaces.md | 38 ++++- src/lib.rs | 40 +++++- src/workspace.rs | 290 ++++++++++++++++++++++++++++++++++++++- tests/integration.sh | 119 ++++++++++++++++ 10 files changed, 513 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fccc47..b6adec0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,8 +33,8 @@ jobs: # coop-proxy's aws-lc-sys crypto backend (issue #411) needs cmake; the # `--workspace` clippy/test below build it. Preinstalled on ubuntu-latest, # installed explicitly so the job doesn't depend on the image contents. - - name: Install cmake - run: sudo apt-get update && sudo apt-get install -y cmake + - name: Install build and workspace test tools + run: sudo apt-get update && sudo apt-get install -y cmake git rsync - name: Format check run: cargo fmt -- --check diff --git a/docs/commands.md b/docs/commands.md index 3a15e66..3e9a7a8 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -623,14 +623,21 @@ coop pull [NAME] [FLAGS] |------|-------------| | `NAME` | Instance name (required if multiple instances exist) | | `--dir ` | Local directory to pull into (defaults to the workspace host path) | -| `--force` | Overwrite local changes without confirmation | -| `--exclude-git` | Skip the `.git/` directory in this transfer | +| `--force` | Skip the local dirty check (does not enable deletion or bypass the Git overlay guard) | +| `--delete` | Mirror the guest, removing non-excluded host-only paths; requires rsync on both ends | +| `--exclude-git` | Preserve all host `.git` entries, including gitfiles and symlinks | ``` -coop pull +coop pull --delete coop pull my-project --dir ./local-copy --force ``` +Additive pulls into an existing Git repository require `--exclude-git`; use +`--delete` to include Git metadata safely with respect to stale refs. Mirror +mode can remove host-only branches even when the working tree is clean. +See [workspace sync](workspaces.md#pulling-guest-to-host) for exclusions, +transport requirements, and worktree limitations. + ### `editor` Open an editor (VS Code or Zed) connected to the guest VM over SSH remote. diff --git a/docs/editor.md b/docs/editor.md index 6dff944..b3be645 100644 --- a/docs/editor.md +++ b/docs/editor.md @@ -138,3 +138,9 @@ VS Code's Remote SSH extension exposes a Ports panel that handles forwarding onc `coop push` and `coop pull` sync files between host and guest. Both work while an editor is connected; there is no need to disconnect. Watch for conflicts. `coop push` overwrites guest files, and `coop pull` overwrites local files. Both commands check for uncommitted git changes and refuse to proceed unless you pass `--force`. + +For an existing host Git repository, choose `coop pull --delete` to mirror the +guest (including removal of host-only files and refs), or `coop pull +--exclude-git` to copy files additively while preserving host Git metadata. +`--force` only bypasses the dirty check; it does not bypass this choice. Avoid +concurrent Git operations during transfers. diff --git a/docs/getting-started.md b/docs/getting-started.md index e16aa1d..81c3ce5 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -336,12 +336,17 @@ Push local changes into a running VM: coop push ``` -Pull guest changes back to the host: +Mirror guest changes back to the host, including deletions: ``` -coop pull +coop pull --delete ``` +This can remove host-only files and Git branches, even with a clean working +tree. To copy files additively while preserving host Git metadata, use +`coop pull --exclude-git`. Ordinary additive pulls refuse to overlay an +existing Git repository; see [workspace sync](workspaces.md#pulling-guest-to-host). + Both commands default to the workspace path recorded by `coop up`. Override with `--dir`: ``` diff --git a/docs/testing.md b/docs/testing.md index 4183658..76aa3a9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -43,6 +43,12 @@ boot session through `post_start` for fresh `up`, `start`, and a stopped-project `up`, checks that model credentials still arrive, and witnesses normal GitHub forwarding on an intervening invocation without the flag. +The `--full` workspace phase exercises mirror pulls: packed refs, guest-deleted +files and branches, incoming ignore rules, Git exclusions, additive-overlay +refusal, and refusal to fall back to tar when mirror mode lacks rsync. Library +workspace tests additionally use local Git and rsync, which must be on `PATH`. +Run this phase on both Firecracker and Lima in copy mode. + When adding new features, consider whether they should be covered here. New commands or guest-visible changes are good candidates for a new test phase. diff --git a/docs/trust-model.md b/docs/trust-model.md index 0167888..b064e36 100644 --- a/docs/trust-model.md +++ b/docs/trust-model.md @@ -61,6 +61,13 @@ user launched it. descriptor. Other paths remain **host** paths: `MountGuard::simple` is a loop mount, not a chroot, so the traversal rule below still applies to the hostname and network-config writes. Those paths are not currently validated. + `pull --delete` explicitly authorizes removal of non-excluded host paths + absent from the guest, including host-only Git refs. It requires rsync and + does not fall back to tar. `--force` bypasses only the dirty check; additive + pulls cannot overwrite existing root Git metadata unless it is excluded. + Root gitfiles and symlinked `.git` entries require `--exclude-git`. These + preflight checks are compatibility guards, not protection against a guest + changing its files during transfer. - **Guest command output read by the host.** e.g. `check_guest_dirty` reads `git status --porcelain` from the guest. Today this only gates control flow / is printed to the user — it is never fed into `sh -c` on the host. Keep it diff --git a/docs/workspaces.md b/docs/workspaces.md index a8f7641..9267958 100644 --- a/docs/workspaces.md +++ b/docs/workspaces.md @@ -105,16 +105,46 @@ Transfer method selection is automatic: ## Pulling: guest to host ```bash -coop pull # uses host_path from workspace.json +coop pull --delete # mirror back to the recorded host path coop pull --dir ./local-copy # pull into a specific directory -coop pull --force # skip local dirty check -coop pull my-instance # target a specific instance +coop pull --delete --force # mirror, skipping the local dirty check +coop pull my-instance --exclude-git # additive copy, preserving host Git metadata coop pull my-instance --dir ./local-copy # combined ``` Before overwriting the local destination, `pull` runs `git status --porcelain` against it. If the directory has a `.git` and any uncommitted changes (tracked or untracked), pull refuses unless you pass `--force`. Unlike push's guest-side check, the local check does not inspect unpushed commits — committing your local work first is enough to satisfy it. -The destination directory is created if absent. Transport selection follows the same rsync-then-tar-pipe order. The tar-pipe fallback verifies SHA-256 checksums end-to-end. +Without `--delete`, pull is additive: destination-only files remain. An additive +pull refuses to overwrite an existing destination `.git` directory, even with +`--force`, because copying Git metadata without deletions can leave stale loose +refs that override newer packed refs. Choose `--delete` to mirror the guest or +`--exclude-git` to preserve host Git metadata. Pulling into a fresh directory +can include Git metadata normally. + +`--delete` removes non-excluded destination paths absent from the guest, +including host-only Git branches and commits' references. A clean working tree +does **not** protect host-only committed work. This is a filesystem mirror, not +a Git fetch or merge. `--force` only skips the dirty check; it never enables +deletion or bypasses the additive Git guard. A failed Git status check also +blocks pull unless `--force` is supplied. + +Mirror mode requires working rsync on both host and guest and checks that +before creating or modifying the destination. It never falls back to tar. +Rsync uses `--delete-after` so incoming `.gitignore` rules are available before +deletions; default exclusions and matching ignore rules protect host paths. +Changing or removing ignore rules can therefore change which paths are deleted. +If a `.gitignore` file itself is removed, rsync can retain its previously +protected files until a subsequent pull. +`--delete --exclude-git` mirrors files while preserving host Git metadata. + +When including Git metadata, a root `.git` file (linked worktree) or symlink +on either side is unsupported; use `--exclude-git` instead. On pull this flag +excludes `.git` entries of every type, including files and symlinks. + +The destination directory is created if absent. Additive transfers use rsync +when available in the guest, otherwise tar-pipe. The tar-pipe fallback verifies +SHA-256 checksums end-to-end. Transfers are not atomic snapshots: avoid concurrent +Git operations in either repository and treat interrupted transfers as incomplete. ## Default exclusions diff --git a/src/lib.rs b/src/lib.rs index 2a3e2be..a0b9851 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -491,10 +491,13 @@ enum Commands { /// Local directory to pull into (defaults to `workspace.json` `host_path`) #[arg(long)] dir: Option, - /// Overwrite local changes without confirmation + /// Skip the local dirty check (does not bypass the Git overlay guard) #[arg(long)] force: bool, - /// Skip the `.git` directory in this transfer + /// Delete non-excluded destination paths absent from the guest (requires rsync) + #[arg(long)] + delete: bool, + /// Skip all `.git` entries in this transfer #[arg(long)] exclude_git: bool, }, @@ -1421,10 +1424,21 @@ pub fn run() -> Result<()> { name, dir, force, + delete, exclude_git, } => { let running = resolve_running(&be, &cfg, name.as_ref())?; - workspace::pull(&running, dir.as_deref(), force, exclude_git) + workspace::pull( + &running, + dir.as_deref(), + force, + exclude_git, + if delete { + workspace::PullMode::Mirror + } else { + workspace::PullMode::Additive + }, + ) } Commands::Exec { name, command } => cmd_exec(&be, &cfg, name.as_ref(), &command), Commands::Editor { @@ -2252,6 +2266,26 @@ token = "test-pat" assert!(!force); } + #[test] + fn pull_delete_is_explicit_and_independent_of_force() { + for (args, expected_delete, expected_force) in [ + (vec!["pull"], false, false), + (vec!["pull", "--force"], false, true), + (vec!["pull", "--delete"], true, false), + ( + vec!["pull", "--delete", "--exclude-git", "--force"], + true, + true, + ), + ] { + let super::Commands::Pull { delete, force, .. } = parse(&args).command else { + panic!("expected Pull variant"); + }; + assert_eq!(delete, expected_delete); + assert_eq!(force, expected_force); + } + } + #[test] fn pull_positional_name_and_dir_flag_parse() { let cli = parse(&["pull", "myvm", "--dir", "./out", "--force"]); diff --git a/src/workspace.rs b/src/workspace.rs index 37a8b41..c0b310c 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -500,7 +500,47 @@ pub fn push( Ok(()) } -/// Pull guest workspace to local directory. Uses rsync if available, falls back to tar-pipe. +/// Whether a pull preserves destination-only paths or mirrors the guest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PullMode { + Additive, + Mirror, +} + +/// Check the destination before any transfer or directory creation. +fn check_pull_destination(dest: &Path, mode: PullMode, exclude_git: bool) -> Result<()> { + if exclude_git { + return Ok(()); + } + let metadata = match fs::symlink_metadata(dest.join(".git")) { + Ok(metadata) => metadata, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err).context("Failed to inspect destination Git metadata"), + }; + if !metadata.is_dir() { + bail!( + "Pulling Git metadata into a worktree or symlinked .git is unsupported. \ + Use --exclude-git or pull into a fresh directory." + ); + } + if mode == PullMode::Additive { + bail!( + "Additive pull cannot safely overwrite existing Git metadata. \ + Use --delete to mirror the guest (including removal of host-only files and refs), \ + or --exclude-git to preserve host Git metadata. --force does not bypass this check." + ); + } + Ok(()) +} + +fn check_guest_git_cmd(guest_path: &GuestPath) -> RemoteCommand { + RemoteCommand::new() + .literal("git_entry=") + .arg(format!("{guest_path}/.git")) + .literal("; if [ -L \"$git_entry\" ] || { [ -e \"$git_entry\" ] && [ ! -d \"$git_entry\" ]; }; then printf '%s\\n' 'Guest .git must be a directory; use --exclude-git for worktrees or symlinks' >&2; exit 1; fi") +} + +/// Pull guest workspace to a local directory. Mirror mode requires rsync. /// /// Takes a `RunningInstance` so the caller's proof of liveness is /// visible in the signature — no surprise SSH failure inside. @@ -509,12 +549,40 @@ pub fn pull( dir: Option<&str>, force: bool, exclude_git: bool, + mode: PullMode, ) -> Result<()> { let inst = running.instance(); let target = running.target(); let state = load_or_default(inst, dir, "pull")?; let dest_dir = resolve_host_dir(dir, &state, "pull")?; + check_pull_destination(&dest_dir, mode, exclude_git)?; + if !exclude_git { + // Gitfiles and symlinks can name metadata outside this workspace. + target + .exec(check_guest_git_cmd(&state.guest_path)) + .context("Failed to check guest Git metadata before pull")?; + } + let use_rsync = if mode == PullMode::Mirror { + let status = Command::new("rsync") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .context("pull --delete requires working rsync on the host and guest")?; + if !status.success() { + bail!("pull --delete requires working rsync on the host and guest"); + } + target + .exec(RemoteCommand::new().literal("rsync --version >/dev/null")) + .context( + "pull --delete requires working rsync on the guest; no files were transferred", + )?; + true + } else { + target.exec_ok(RemoteCommand::new().literal("which rsync")) + }; + if !force && dest_dir.exists() { check_local_dirty(&dest_dir)?; } @@ -528,8 +596,8 @@ pub fn pull( dest_dir.display() ); - if target.exec_ok(RemoteCommand::new().literal("which rsync")) { - rsync_pull(target, &state.guest_path, &dest_dir, exclude_git)?; + if use_rsync { + rsync_pull(target, &state.guest_path, &dest_dir, exclude_git, mode)?; } else { tracing::info!("rsync not available on guest, using tar-pipe"); tar_pipe_pull(target, &state.guest_path, &dest_dir, exclude_git)?; @@ -783,13 +851,27 @@ pub(crate) fn rsync_push( Ok(()) } +fn rsync_pull_args(target: &SshTarget, exclude_git: bool, mode: PullMode) -> Vec { + let mut args = rsync_base_args(target, exclude_git); + if exclude_git { + // Protect gitfiles and symlinks as well as directories, before ignore rules. + args.insert(3, "--exclude=.git".to_string()); + } + if mode == PullMode::Mirror { + // Read newly transferred .gitignore files before deciding what to delete. + args.push("--delete-after".to_string()); + } + args +} + fn rsync_pull( target: &SshTarget, guest_path: &GuestPath, dest: &Path, exclude_git: bool, + mode: PullMode, ) -> Result<()> { - let mut args = rsync_base_args(target, exclude_git); + let mut args = rsync_pull_args(target, exclude_git, mode); args.push(format!("{}:{guest_path}/", target.addr())); args.push(format!("{}/", dest.display())); @@ -842,7 +924,7 @@ fn tar_pull_cmd(guest_path: &GuestPath, exclude_git: bool) -> RemoteCommand { .map(|exc| format!("--exclude={exc}")) .collect(); if exclude_git { - excludes.push(format!("--exclude={GIT_EXCLUDE}")); + excludes.push("--exclude=.git".to_string()); } let exclude_str = excludes.join(" "); RemoteCommand::new() @@ -1025,9 +1107,18 @@ fn check_local_dirty(dest: &Path) -> Result<()> { .arg("-C") .arg(dest) .args(["status", "--porcelain"]) + // A parent Git hook must not redirect this check to another repository. + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_INDEX_FILE") + .env_remove("GIT_COMMON_DIR") .output() .context("Failed to check local git status")?; + if !output.status.success() { + bail!("Failed to check local git status; use --force to skip the dirty check"); + } + let stdout = String::from_utf8_lossy(&output.stdout); if !stdout.trim().is_empty() { bail!( @@ -2098,6 +2189,195 @@ Host coop-other\n\ ); } + #[test] + fn guest_git_check_rejects_gitfiles_and_symlinks_with_quoted_paths() { + let tmp = tempfile::tempdir().unwrap(); + let guest = tmp.path().join("workspace ' ; $(false)"); + fs::create_dir(&guest).unwrap(); + let path = GuestPath::absolute(guest.to_str().unwrap()).unwrap(); + let check = || { + Command::new("sh") + .arg("-c") + .arg(check_guest_git_cmd(&path).into_string()) + .output() + .unwrap() + .status + .success() + }; + assert!(check()); + fs::create_dir(guest.join(".git")).unwrap(); + assert!(check()); + fs::remove_dir(guest.join(".git")).unwrap(); + fs::write(guest.join(".git"), "gitdir: /outside").unwrap(); + assert!(!check()); + fs::remove_file(guest.join(".git")).unwrap(); + std::os::unix::fs::symlink(tmp.path().join("missing"), guest.join(".git")).unwrap(); + assert!(!check()); + } + + #[test] + fn pull_destination_policy() { + let tmp = tempfile::tempdir().unwrap(); + let dest = tmp.path().join("checkout"); + for mode in [PullMode::Additive, PullMode::Mirror] { + check_pull_destination(&dest, mode, false).unwrap(); + } + let non_directory = tmp.path().join("file"); + fs::write(&non_directory, "not a directory").unwrap(); + assert!(check_pull_destination(&non_directory, PullMode::Mirror, false).is_err()); + fs::create_dir_all(dest.join(".git")).unwrap(); + assert!(check_pull_destination(&dest, PullMode::Additive, false).is_err()); + check_pull_destination(&dest, PullMode::Mirror, false).unwrap(); + check_pull_destination(&dest, PullMode::Additive, true).unwrap(); + fs::remove_dir(dest.join(".git")).unwrap(); + fs::write(dest.join(".git"), "gitdir: /outside\n").unwrap(); + for mode in [PullMode::Additive, PullMode::Mirror] { + assert!(check_pull_destination(&dest, mode, false).is_err()); + check_pull_destination(&dest, mode, true).unwrap(); + } + fs::remove_file(dest.join(".git")).unwrap(); + std::os::unix::fs::symlink(tmp.path().join("missing"), dest.join(".git")).unwrap(); + for mode in [PullMode::Additive, PullMode::Mirror] { + assert!(check_pull_destination(&dest, mode, false).is_err()); + check_pull_destination(&dest, mode, true).unwrap(); + } + } + + fn test_git(dir: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args([ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.invalid", + "-c", + "core.hooksPath=/dev/null", + "-c", + "commit.gpgsign=false", + "-C", + ]) + .arg(dir) + .args(args) + .env_clear() + .env("PATH", std::env::var_os("PATH").unwrap_or_default()) + .env("HOME", dir) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .output() + .unwrap(); + assert!(output.status.success(), "git {args:?}: {:?}", output.stderr); + String::from_utf8(output.stdout).unwrap().trim().to_owned() + } + + fn test_local_pull(source: &Path, dest: &Path, exclude_git: bool, mode: PullMode) { + let status = Command::new("rsync") + .args(rsync_pull_args(&fake_ssh_target(), exclude_git, mode)) + .arg(format!("{}/", source.display())) + .arg(format!("{}/", dest.display())) + .status() + .expect("workspace transfer tests require rsync"); + assert!(status.success()); + } + + #[test] + fn mirror_pull_updates_packed_refs_and_removes_deleted_files() { + let tmp = tempfile::tempdir().unwrap(); + let guest = tmp.path().join("guest"); + let host = tmp.path().join("host"); + fs::create_dir(&guest).unwrap(); + test_git(&guest, &["init", "-q", "--initial-branch=work"]); + fs::write(guest.join("deleted.txt"), "old").unwrap(); + test_git(&guest, &["add", "."]); + test_git(&guest, &["commit", "-qm", "A"]); + test_git(&guest, &["branch", "deleted-branch"]); + test_local_pull(&guest, &host, false, PullMode::Additive); + let before = test_git(&host, &["rev-parse", "work"]); + test_git(&guest, &["rm", "deleted.txt"]); + test_git(&guest, &["commit", "-qm", "B"]); + test_git(&guest, &["branch", "-D", "deleted-branch"]); + test_git(&guest, &["pack-refs", "--all"]); + assert!(!guest.join(".git/refs/heads/work").exists()); + test_local_pull(&guest, &host, false, PullMode::Mirror); + let after = test_git(&host, &["rev-parse", "work"]); + assert_ne!(before, after); + assert_eq!(after, test_git(&guest, &["rev-parse", "work"])); + assert!(!host.join("deleted.txt").exists()); + assert_eq!(test_git(&host, &["branch", "--list", "deleted-branch"]), ""); + assert_eq!(test_git(&host, &["status", "--porcelain"]), ""); + } + + #[test] + fn mirror_pull_preserves_exclusions_and_uses_incoming_ignore_files() { + let tmp = tempfile::tempdir().unwrap(); + let guest = tmp.path().join("guest"); + let host = tmp.path().join("host"); + fs::create_dir_all(guest.join("nested")).unwrap(); + fs::create_dir_all(host.join("nested")).unwrap(); + fs::write(guest.join(".gitignore"), "cache/\n.git/\n").unwrap(); + fs::write(guest.join("nested/.gitignore"), "*.local\n").unwrap(); + fs::write(host.join("nested/keep.local"), "keep").unwrap(); + for name in DEFAULT_EXCLUDES.iter().copied().chain(["cache/"]) { + fs::create_dir_all(host.join(name)).unwrap(); + fs::write(host.join(name).join("sentinel"), "keep").unwrap(); + } + fs::write(host.join("remove"), "old").unwrap(); + fs::write(guest.join("incoming"), "new").unwrap(); + fs::create_dir(guest.join(".git")).unwrap(); + fs::write(guest.join(".git/HEAD"), "guest").unwrap(); + test_local_pull(&guest, &host, false, PullMode::Mirror); + assert_eq!(fs::read_to_string(host.join("incoming")).unwrap(), "new"); + assert!(!host.join("remove").exists()); + assert_eq!(fs::read_to_string(host.join(".git/HEAD")).unwrap(), "guest"); + assert!(host.join("nested/keep.local").exists()); + for name in DEFAULT_EXCLUDES.iter().copied().chain(["cache/"]) { + assert!(host.join(name).join("sentinel").exists(), "{name}"); + } + } + + #[test] + fn pull_exclude_git_preserves_directories_gitfiles_and_symlinks() { + let tmp = tempfile::tempdir().unwrap(); + let guest = tmp.path().join("guest"); + let host = tmp.path().join("host"); + fs::create_dir_all(guest.join(".git")).unwrap(); + fs::create_dir_all(host.join(".git")).unwrap(); + fs::write(guest.join(".git/HEAD"), "guest").unwrap(); + fs::write(host.join(".git/HEAD"), "host").unwrap(); + for mode in [PullMode::Additive, PullMode::Mirror] { + fs::write(host.join("host-only"), "keep").unwrap(); + fs::write(guest.join("incoming"), "new").unwrap(); + test_local_pull(&guest, &host, true, mode); + assert_eq!(fs::read_to_string(host.join(".git/HEAD")).unwrap(), "host"); + assert!(host.join("incoming").exists()); + assert_eq!(host.join("host-only").exists(), mode == PullMode::Additive); + } + fs::remove_dir_all(host.join(".git")).unwrap(); + fs::write(host.join(".git"), "gitdir: /outside\n").unwrap(); + test_local_pull(&guest, &host, true, PullMode::Mirror); + assert_eq!( + fs::read_to_string(host.join(".git")).unwrap(), + "gitdir: /outside\n" + ); + fs::remove_file(host.join(".git")).unwrap(); + std::os::unix::fs::symlink("/missing-outside", host.join(".git")).unwrap(); + test_local_pull(&guest, &host, true, PullMode::Mirror); + assert_eq!( + fs::read_link(host.join(".git")).unwrap(), + Path::new("/missing-outside") + ); + } + + #[test] + fn local_dirty_check_rejects_git_failure_and_dirty_worktree() { + let tmp = tempfile::tempdir().unwrap(); + fs::create_dir(tmp.path().join(".git")).unwrap(); + assert!(check_local_dirty(tmp.path()).is_err()); + test_git(tmp.path(), &["init", "-q"]); + check_local_dirty(tmp.path()).unwrap(); + fs::write(tmp.path().join("untracked"), "dirty").unwrap(); + assert!(check_local_dirty(tmp.path()).is_err()); + } + // ── exclude_git policy ──────────────────────────────────── fn fake_ssh_target() -> SshTarget { diff --git a/tests/integration.sh b/tests/integration.sh index b3c9811..2b5f805 100755 --- a/tests/integration.sh +++ b/tests/integration.sh @@ -3847,6 +3847,123 @@ test_git_repo() { # ── Workspace sync tests (--full only) ──────────────────────── +# Runs in the disposable copy-mode VM owned by test_workspace_sync. +test_workspace_pull_mirror() { + local ws_instance="$1" pull_root pull_dir before after + pull_root=$(mktemp -d) + pull_dir="$pull_root/checkout" + if ! guest_exec sh -ec ' + cd /workspace + git config user.name CI + git config user.email ci@test + git add . + git -c commit.gpgsign=false commit -qm "before mirror" + git branch removed-branch + '; then + fail "prepare mirror fixture" "$(guest_stderr)" + rm -rf "$pull_root" + return + fi + if ! coop pull "$ws_instance" --dir "$pull_dir"; then + fail "initial additive pull into fresh directory" "$HARNESS_ERR" + rm -rf "$pull_root" + return + fi + before=$(git -C "$pull_dir" rev-parse HEAD) + if ! guest_exec sh -ec ' + cd /workspace + git rm hello.txt + printf "cache/\n.git/\n" > .gitignore + printf "*.local\n" > subdir/.gitignore + printf "new content\n" > incoming.txt + git add .gitignore subdir/.gitignore incoming.txt + git -c commit.gpgsign=false commit -qm "after mirror" + git branch -D removed-branch + git pack-refs --all + test ! -e .git/refs/heads/main + '; then + fail "pack guest refs and delete tracked file" "$(guest_stderr)" + rm -rf "$pull_root" + return + fi + after=$(guest_exec git -C /workspace rev-parse HEAD) + if coop pull "$ws_instance" --force --dir "$pull_dir"; then + fail "additive Git overlay refuses even with force" "pull unexpectedly succeeded" + elif [[ "$HARNESS_ERR" == *"--delete"* && "$HARNESS_ERR" == *"--exclude-git"* ]] && + [[ "$(git -C "$pull_dir" rev-parse HEAD)" == "$before" && ! -e "$pull_dir/incoming.txt" ]]; then + pass "additive Git overlay refuses before copying" + else + fail "additive Git overlay refuses before copying" "$HARNESS_ERR" + fi + if coop pull "$ws_instance" --exclude-git --dir "$pull_dir" && + [[ "$(git -C "$pull_dir" rev-parse HEAD)" == "$before" && -f "$pull_dir/hello.txt" && -f "$pull_dir/incoming.txt" ]]; then + pass "additive exclude-git preserves refs and destination-only files" + else + fail "additive exclude-git preserves refs and destination-only files" "$HARNESS_ERR" + fi + # Remove the just-received ignore files so mirror must use incoming rules. + rm -f "$pull_dir/.gitignore" "$pull_dir/subdir/.gitignore" + mkdir -p "$pull_dir/cache" "$pull_dir/node_modules" + echo keep > "$pull_dir/cache/sentinel" + echo keep > "$pull_dir/node_modules/sentinel" + echo keep > "$pull_dir/subdir/keep.local" + if coop pull "$ws_instance" --delete --force --dir "$pull_dir" && + [[ "$before" != "$after" && "$(git -C "$pull_dir" rev-parse HEAD)" == "$after" ]] && + [[ ! -e "$pull_dir/hello.txt" && -f "$pull_dir/incoming.txt" ]] && + [[ -z "$(git -C "$pull_dir" branch --list removed-branch)" ]] && + [[ "$(git -C "$pull_dir" status --porcelain)" == "?? node_modules/" ]] && + [[ -f "$pull_dir/cache/sentinel" && -f "$pull_dir/node_modules/sentinel" && -f "$pull_dir/subdir/keep.local" ]]; then + pass "mirror updates packed refs, removes deletions, and preserves exclusions" + else + fail "mirror updates packed refs, removes deletions, and preserves exclusions" "$HARNESS_ERR" + fi + git -C "$pull_dir" branch host-only + echo obsolete > "$pull_dir/obsolete" + if coop pull "$ws_instance" --delete --exclude-git --force --dir "$pull_dir" && + [[ ! -e "$pull_dir/obsolete" ]] && + [[ "$(git -C "$pull_dir" rev-parse host-only)" == "$after" ]]; then + pass "mirror exclude-git preserves host-only refs" + else + fail "mirror exclude-git preserves host-only refs" "$HARNESS_ERR" + fi + + mkdir "$pull_root/bin" + printf '#!/bin/sh\nexit 127\n' > "$pull_root/bin/rsync" + chmod +x "$pull_root/bin/rsync" + if PATH="$pull_root/bin:$PATH" coop pull "$ws_instance" --delete --dir "$pull_root/no-host-rsync"; then + fail "mirror refuses unavailable host rsync" "pull unexpectedly succeeded" + elif [[ "$HARNESS_ERR" == *"requires working rsync"* && ! -e "$pull_root/no-host-rsync" ]]; then + pass "mirror refuses unavailable host rsync before creating destination" + else + fail "mirror refuses unavailable host rsync before creating destination" "$HARNESS_ERR" + fi + + # Hide rsync only inside this disposable VM; restore before further checks. + if guest_exec sudo sh -ec 'p=$(command -v rsync); test "$p" = /usr/bin/rsync; mv "$p" /usr/bin/rsync.coop-test'; then + echo unchanged > "$pull_dir/sentinel" + if coop pull "$ws_instance" --delete --force --dir "$pull_dir"; then + fail "mirror refuses unavailable guest rsync" "pull unexpectedly succeeded" + elif [[ "$HARNESS_ERR" == *"requires working rsync"* && -f "$pull_dir/sentinel" ]] && + [[ "$(git -C "$pull_dir" rev-parse host-only)" == "$after" ]]; then + pass "mirror refuses unavailable guest rsync before modifying destination" + else + fail "mirror refuses unavailable guest rsync before modifying destination" "$HARNESS_ERR" + fi + if coop pull "$ws_instance" --force --exclude-git --dir "$pull_root/tar-copy" && + [[ -f "$pull_root/tar-copy/incoming.txt" && ! -e "$pull_root/tar-copy/.git" ]]; then + pass "additive tar fallback remains available" + else + fail "additive tar fallback remains available" "$HARNESS_ERR" + fi + if ! guest_exec sudo mv /usr/bin/rsync.coop-test /usr/bin/rsync; then + fail "restore guest rsync" "$(guest_stderr)" + fi + else + fail "hide guest rsync for fallback test" "$(guest_stderr)" + fi + rm -rf "$pull_root" +} + test_workspace_sync() { echo "" echo "=== Phase: workspace sync ===" @@ -3955,6 +4072,8 @@ test_workspace_sync() { fail "push exits 0" "exit code: $?" fi + test_workspace_pull_mirror "$ws_instance" + unset GUEST_INSTANCE # Clean up workspace instance