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