From bb27b51d56887e5c642d3d75649ba8e519e960b6 Mon Sep 17 00:00:00 2001 From: Lauri Gates Date: Sun, 14 Jun 2026 17:16:28 +0300 Subject: [PATCH] fix(check): honor --base scope when workspace is a repo subdirectory (#2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(check): honor --base scope when workspace is a repo subdirectory git diff --name-only reports repo-root-relative paths, but anchor files are workspace-root-relative. When surf.toml lives in a subdirectory of the git repo, the two never intersect, so check --base silently scoped the gate to zero claims and exited 0 despite real drift — the exact diff-scoped mode action.yml recommends for PR gating. Pass --relative so git emits workspace-root-relative paths (a no-op when the workspace is the repo root). Adds a regression test that fails without the fix. https://claude.ai/code/session_01T3Z35bgg6cvWzBDfyh6pQ8 * style: rustfmt subdir-base regression test * docs(hub): update changed_files claim for workspace-relative scoping The --base fix changes changed_files to emit workspace-root-relative paths (git diff --relative). Surface's own dogfood gate flagged the stale hash + prose, which still described repo-relative paths. Update the claim text and re-stamp the hash. (cherry picked from commit b20de8bd57f4901716308ae4473eaeeda67e878b) --- hubs/cli-git.md | 10 ++++---- surf-cli/src/check.rs | 54 +++++++++++++++++++++++++++++++++++++++++++ surf-cli/src/git.rs | 11 ++++++--- 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/hubs/cli-git.md b/hubs/cli-git.md index 4985b2f..86839f0 100644 --- a/hubs/cli-git.md +++ b/hubs/cli-git.md @@ -2,11 +2,13 @@ summary: Best-effort git queries for scoping and rename-following — advisory only, the gate never depends on them. anchors: - claim: > - changed_files returns the repo-relative paths changed between the merge base of base..HEAD - and the working tree, used to diff-scope the check. A missing merge base (shallow clone) - falls back to diffing the ref directly; if git can't answer at all it returns None. + changed_files returns the workspace-root-relative paths changed between the merge base of + base..HEAD and the working tree (via git diff --relative), used to diff-scope the check — + so the set intersects workspace-relative anchors even when the workspace is a repo + subdirectory. A missing merge base (shallow clone) falls back to diffing the ref directly; + if git can't answer at all it returns None. at: surf-cli/src/git.rs > changed_files - hash: 9f422d548239 + hash: 454e65cc8aa3 - claim: > show returns the contents of a file at a git ref (git show :), used to recover the previous source for advisory old_code/magnitude. None when unavailable — the verdict is diff --git a/surf-cli/src/check.rs b/surf-cli/src/check.rs index 8ca0bdb..99cb46d 100644 --- a/surf-cli/src/check.rs +++ b/surf-cli/src/check.rs @@ -733,6 +733,60 @@ mod tests { assert!(unmatched.is_empty(), "glob matched an anchored file"); } + #[test] + fn base_scope_works_when_workspace_is_a_repo_subdir() { + // The workspace (surf.toml) sits in `proj/`, a subdirectory of the git repo. A real + // logic drift in an anchored file must still fail `check --base`. Regression for the + // silent bypass where `git diff` emitted repo-root-relative paths (`proj/src/m.rs`) + // that never matched the workspace-relative anchor (`src/m.rs`), scoping the gate to + // zero claims and exiting clean. + let tmp = tempfile::tempdir().unwrap(); + let repo = tmp.path(); + let proj = repo.join("proj"); + + let v1 = "pub fn add(a: i64, b: i64) -> i64 { a + b }\n"; + let h = stored_hash(v1, "src/m.rs > add"); + write(&proj, "surf.toml", ""); + write(&proj, "src/m.rs", v1); + write( + &proj, + "hubs/a.md", + &format!("---\nsummary: x\nanchors:\n - claim: add sums\n at: src/m.rs > add\n hash: {h}\n---\n"), + ); + + // Initialize the repo at the parent, not the workspace. + git(repo, &["init", "-q"]); + git( + repo, + &["-c", "user.email=t@t", "-c", "user.name=t", "add", "."], + ); + git( + repo, + &[ + "-c", + "user.email=t@t", + "-c", + "user.name=t", + "commit", + "-q", + "-m", + "v1", + ], + ); + + // Diverge the anchored span in the working tree. + write( + &proj, + "src/m.rs", + "pub fn add(a: i64, b: i64) -> i64 { a - b }\n", + ); + + let ws = ws_at(proj.clone()); + let scoped = check_workspace(&ws, Some("HEAD"), &[]).unwrap().0; + assert_eq!(scoped.len(), 1, "subdir --base must still catch the drift"); + assert_eq!(scoped[0].kind, DivergenceKind::Changed); + } + #[test] fn no_flags_checks_everything() { let tmp = tempfile::tempdir().unwrap(); diff --git a/surf-cli/src/git.rs b/surf-cli/src/git.rs index 7f5c4c0..9ec73cf 100644 --- a/surf-cli/src/git.rs +++ b/surf-cli/src/git.rs @@ -8,8 +8,13 @@ use std::path::Path; use std::process::Command; /// Files changed between the merge base of `base`..HEAD and the working tree. Paths are -/// repo-root-relative; they match `Anchor.file` (workspace-root-relative) when the workspace -/// root is the repo root, the normal case. `None` if git can't answer. +/// emitted relative to `root` (the workspace root) via `--relative`, so they match +/// `Anchor.file` (also workspace-root-relative) even when the workspace is a *subdirectory* +/// of the git repo — without `--relative`, `git diff` reports repo-root-relative paths +/// (e.g. `proj/src/x.rs`) that never intersect a workspace-relative anchor (`src/x.rs`), +/// silently scoping the `--base` gate to zero claims and passing real drift (exit 0). +/// `--relative` also drops changes outside the workspace, which can never be anchored anyway. +/// `None` if git can't answer. pub fn changed_files(root: &Path, base: &str) -> Option> { let merge_base = Command::new("git") .current_dir(root) @@ -23,7 +28,7 @@ pub fn changed_files(root: &Path, base: &str) -> Option> { let output = Command::new("git") .current_dir(root) - .args(["diff", "--name-only", &merge_base]) + .args(["diff", "--name-only", "--relative", &merge_base]) .output() .ok()?; output.status.success().then(|| {