From 9d47c3772ddc04c9ad4472312d0a8b4815dbaed5 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 17 Oct 2026 10:47:19 -0300 Subject: [PATCH 1/5] feat(project): read Project file difference against origin/main (#3060) Adds get_project_file_difference (Tauri + WebSocket BrowserProject route): a bounded, read-only Git count of distinct Project-root paths differing from cached origin/main, untracked non-ignored included; null when unknown. --- src-tauri/src/commands/ac_discovery.rs | 459 +++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src-tauri/src/web/commands.rs | 38 ++ 3 files changed, 498 insertions(+) diff --git a/src-tauri/src/commands/ac_discovery.rs b/src-tauri/src/commands/ac_discovery.rs index 4ffb3bc41..3b9fe270a 100644 --- a/src-tauri/src/commands/ac_discovery.rs +++ b/src-tauri/src/commands/ac_discovery.rs @@ -424,6 +424,188 @@ async fn detect_repo_branch(dir: &str) -> Option { } } +// --- Project file difference (#3060) --- + +/// One total bound for every Git read of a single `get_project_file_difference` request. +const PROJECT_FILE_DIFFERENCE_TIMEOUT: Duration = Duration::from_secs(5); + +/// Inherited Git controls that would point the child at another repository or index. +const PROJECT_GIT_REDIRECT_ENV: [&str; 6] = [ + "GIT_DIR", + "GIT_WORK_TREE", + "GIT_COMMON_DIR", + "GIT_INDEX_FILE", + "GIT_OBJECT_DIRECTORY", + "GIT_ALTERNATE_OBJECT_DIRECTORIES", +]; + +/// Distinct paths of the Project's own repository that differ from the cached +/// `origin/main`, untracked non-ignored files included. `None` = unknown; never +/// a zero substituted for a failed read. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectFileDifference { + pub count: Option, +} + +/// Shared by the Tauri command and the WebSocket `BrowserProject` route. +pub async fn get_project_file_difference_inner(path: &str) -> ProjectFileDifference { + read_project_file_difference( + Path::new(path), + PROJECT_FILE_DIFFERENCE_TIMEOUT, + run_project_git, + ) + .await +} + +#[tauri::command] +pub async fn get_project_file_difference(path: String) -> Result { + Ok(get_project_file_difference_inner(&path).await) +} + +/// Runner and bound are parameters so tests can inject a stalled or malformed Git; +/// production always passes `run_project_git` and `PROJECT_FILE_DIFFERENCE_TIMEOUT`. +async fn read_project_file_difference( + project: &Path, + limit: Duration, + run: R, +) -> ProjectFileDifference +where + R: Fn(PathBuf, Vec) -> Fut, + Fut: std::future::Future, String>>, +{ + let count = + match tokio::time::timeout(limit, count_project_file_difference(project, &run)).await { + Ok(Ok(count)) => Some(count), + Ok(Err(reason)) => { + log::debug!( + "[discovery] project file difference unknown for {}: {reason}", + project.display() + ); + None + } + // Dropping the future drops the in-flight child, which `kill_on_drop` terminates. + Err(_) => { + log::debug!( + "[discovery] project file difference timed out after {limit:?}: {}", + project.display() + ); + None + } + }; + ProjectFileDifference { count } +} + +async fn count_project_file_difference(project: &Path, run: &R) -> Result +where + R: Fn(PathBuf, Vec) -> Fut, + Fut: std::future::Future, String>>, +{ + if existing_ac_root(project).is_none() { + return Err("not an AC project root".to_string()); + } + let project_canonical = + std::fs::canonicalize(project).map_err(|e| format!("canonicalize project: {e}"))?; + let git = |args: &[&str]| run(project.to_path_buf(), project_git_args(args)); + + // A Project nested inside another repository must not report the parent's count. + let toplevel = git(&["rev-parse", "--show-toplevel"]).await?; + let toplevel = std::str::from_utf8(strip_line_terminator(&toplevel)) + .map_err(|_| "toplevel is not UTF-8".to_string())?; + let toplevel_canonical = + std::fs::canonicalize(toplevel).map_err(|e| format!("canonicalize toplevel: {e}"))?; + if toplevel_canonical != project_canonical { + return Err("project is not its repository toplevel".to_string()); + } + + // Pin the OID so both reads compare against the same commit. + let oid = git(&["rev-parse", "--verify", "refs/remotes/origin/main^{commit}"]).await?; + let oid = std::str::from_utf8(strip_line_terminator(&oid)) + .ok() + .filter(|oid| !oid.is_empty() && oid.bytes().all(|b| b.is_ascii_hexdigit())) + .ok_or_else(|| "origin/main did not resolve to an OID".to_string())? + .to_string(); + + let diff = git(&[ + "diff", + "--no-ext-diff", + "--no-textconv", + "--no-renames", + "--name-only", + "-z", + &oid, + "--", + ]) + .await?; + let untracked = git(&["ls-files", "--others", "--exclude-standard", "-z", "--"]).await?; + + let mut paths = std::collections::HashSet::new(); + paths.extend(parse_nul_records(&diff)?); + paths.extend(parse_nul_records(&untracked)?); + u32::try_from(paths.len()).map_err(|_| "path count exceeds u32".to_string()) +} + +fn project_git_args(args: &[&str]) -> Vec { + ["--no-optional-locks", "-c", "core.fsmonitor=false"] + .iter() + .chain(args) + .map(|arg| arg.to_string()) + .collect() +} + +/// Strips one trailing LF or CRLF only; spaces in a path are preserved. +fn strip_line_terminator(bytes: &[u8]) -> &[u8] { + let bytes = bytes.strip_suffix(b"\n").unwrap_or(bytes); + bytes.strip_suffix(b"\r").unwrap_or(bytes) +} + +/// NUL-terminated `-z` records as raw bytes. Non-empty output that is not +/// NUL-terminated, or an empty record, is malformed. +fn parse_nul_records(bytes: &[u8]) -> Result, String> { + let Some(body) = bytes.strip_suffix(b"\0") else { + return if bytes.is_empty() { + Ok(Vec::new()) + } else { + Err("git output is not NUL-terminated".to_string()) + }; + }; + let records: Vec<&[u8]> = body.split(|b| *b == 0).collect(); + if records.iter().any(|record| record.is_empty()) { + return Err("git output has an empty record".to_string()); + } + Ok(records) +} + +async fn run_project_git(cwd: PathBuf, args: Vec) -> Result, String> { + #[cfg(windows)] + const CREATE_NO_WINDOW: u32 = 0x08000000; + + let mut cmd = tokio::process::Command::new("git"); + crate::pty::credentials::scrub_credentials_from_tokio_command(&mut cmd); + crate::config::agent_path::apply_search_path_to_tokio_command(&mut cmd); + for var in PROJECT_GIT_REDIRECT_ENV { + cmd.env_remove(var); + } + cmd.args(&args) + .current_dir(&cwd) + .stdin(std::process::Stdio::null()) + .kill_on_drop(true); + + #[cfg(windows)] + cmd.creation_flags(CREATE_NO_WINDOW); + + // args[3] is the subcommand after the shared prefix; stderr stays out of the result. + let subcommand = args.get(3).map(String::as_str).unwrap_or("?"); + let out = cmd + .output() + .await + .map_err(|e| format!("spawn git {subcommand}: {e}"))?; + if !out.status.success() { + return Err(format!("git {subcommand} exited with {}", out.status)); + } + Ok(out.stdout) +} + // --- Discovery Branch Watcher --- const BRANCH_POLL_INTERVAL: Duration = Duration::from_secs(15); @@ -7961,4 +8143,281 @@ mod snapshot_null_tests { assert!(missing.task.is_none()); assert!(missing.task_title.is_none()); } + + // --- Project file difference (#3060) --- + + /// Runs fixture Git with a local identity and no signing; global config is never written. + fn fixture_git(dir: &Path, args: &[&str]) { + let mut cmd = std::process::Command::new("git"); + for var in PROJECT_GIT_REDIRECT_ENV { + cmd.env_remove(var); + } + let status = cmd + .args([ + "-c", + "user.name=AC Test", + "-c", + "user.email=ac-test@example.invalid", + "-c", + "commit.gpgsign=false", + "-c", + "core.autocrlf=false", + ]) + .args(args) + .current_dir(dir) + .status() + .expect("spawn fixture git"); + assert!(status.success(), "fixture git {args:?} failed"); + } + + fn write_file(path: &Path, contents: &str) { + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).expect("create parent"); + } + std::fs::write(path, contents).expect("write fixture file"); + } + + /// A committed Project (`.ac` included) whose `origin/main` is its base commit. + fn project_file_difference_fixture() -> (tempfile::TempDir, PathBuf) { + let temp = tempfile::tempdir().expect("tempdir"); + let project = temp.path().join("project"); + write_file(&project.join("a.txt"), "a\n"); + write_file(&project.join("b.txt"), "b\n"); + write_file(&project.join(".ac").join("keep.md"), "keep\n"); + write_file(&project.join(".gitignore"), "ignored.txt\n.ac/room-*/\n"); + fixture_git(&project, &["init", "-q", "-b", "main"]); + fixture_git(&project, &["add", "-A"]); + fixture_git(&project, &["commit", "-q", "-m", "base"]); + fixture_git( + &project, + &["update-ref", "refs/remotes/origin/main", "HEAD"], + ); + (temp, project) + } + + async fn project_file_difference_count(project: &Path) -> Option { + get_project_file_difference_inner(project.to_str().expect("utf8 path")) + .await + .count + } + + #[tokio::test] + async fn project_file_difference_clean_is_zero_and_tracked_change_is_one() { + let (_temp, project) = project_file_difference_fixture(); + assert_eq!(project_file_difference_count(&project).await, Some(0)); + + // Positive control: a constant zero or null counter fails here. + write_file(&project.join("a.txt"), "changed\n"); + assert_eq!(project_file_difference_count(&project).await, Some(1)); + } + + #[tokio::test] + async fn project_file_difference_counts_each_kind_of_difference() { + let (_temp, project) = project_file_difference_fixture(); + + // committed-only: HEAD moved past origin/main, worktree equals HEAD + write_file(&project.join("a.txt"), "committed\n"); + fixture_git(&project, &["commit", "-q", "-am", "ahead"]); + assert_eq!(project_file_difference_count(&project).await, Some(1)); + + // staged-only + write_file(&project.join("b.txt"), "staged\n"); + fixture_git(&project, &["add", "b.txt"]); + assert_eq!(project_file_difference_count(&project).await, Some(2)); + + // deletion, then restoring it removes the difference again + std::fs::remove_file(project.join(".gitignore")).expect("delete"); + assert_eq!(project_file_difference_count(&project).await, Some(3)); + write_file(&project.join(".gitignore"), "ignored.txt\n.ac/room-*/\n"); + assert_eq!(project_file_difference_count(&project).await, Some(2)); + + // added to the index + write_file(&project.join("added.txt"), "added\n"); + fixture_git(&project, &["add", "added.txt"]); + assert_eq!(project_file_difference_count(&project).await, Some(3)); + + // untracked + write_file(&project.join("untracked.txt"), "new\n"); + assert_eq!(project_file_difference_count(&project).await, Some(4)); + + // ignored does not count + write_file(&project.join("ignored.txt"), "ignored\n"); + assert_eq!(project_file_difference_count(&project).await, Some(4)); + + // versionable `.ac` content gets no special exclusion: one modified, one new + write_file(&project.join(".ac").join("keep.md"), "changed\n"); + write_file(&project.join(".ac").join("new.md"), "new\n"); + assert_eq!(project_file_difference_count(&project).await, Some(6)); + } + + #[tokio::test] + async fn project_file_difference_unstaged_deleted_and_staged_plus_unstaged_count_once() { + let (_temp, project) = project_file_difference_fixture(); + + write_file(&project.join("a.txt"), "staged\n"); + fixture_git(&project, &["add", "a.txt"]); + write_file(&project.join("a.txt"), "staged then unstaged\n"); + assert_eq!(project_file_difference_count(&project).await, Some(1)); + + std::fs::remove_file(project.join("b.txt")).expect("delete"); + assert_eq!(project_file_difference_count(&project).await, Some(2)); + } + + #[tokio::test] + async fn project_file_difference_is_net_and_rename_is_two_paths() { + let (_temp, project) = project_file_difference_fixture(); + + // committed away and back again: worktree equals origin/main + write_file(&project.join("a.txt"), "temporary\n"); + fixture_git(&project, &["commit", "-q", "-am", "temporary"]); + write_file(&project.join("a.txt"), "a\n"); + assert_eq!(project_file_difference_count(&project).await, Some(0)); + + fixture_git(&project, &["mv", "b.txt", "renamed.txt"]); + assert_eq!(project_file_difference_count(&project).await, Some(2)); + } + + #[tokio::test] + async fn project_file_difference_counts_spaced_and_unicode_names() { + let (_temp, project) = project_file_difference_fixture(); + write_file( + &project.join("spaced name \u{f1} \u{fc} \u{6f22}.txt"), + "x\n", + ); + write_file(&project.join(" leading space.txt"), "x\n"); + assert_eq!(project_file_difference_count(&project).await, Some(2)); + } + + #[cfg(unix)] + #[tokio::test] + async fn project_file_difference_counts_newline_name_once() { + let (_temp, project) = project_file_difference_fixture(); + write_file(&project.join("line\nbreak.txt"), "x\n"); + assert_eq!(project_file_difference_count(&project).await, Some(1)); + } + + #[tokio::test] + async fn project_file_difference_ignores_room_clones_but_counts_project() { + let (_temp, project) = project_file_difference_fixture(); + let clone = project.join(".ac").join("room-01").join("repo-x"); + write_file(&clone.join("inner.txt"), "inner\n"); + fixture_git(&clone, &["init", "-q", "-b", "main"]); + fixture_git(&clone, &["add", "-A"]); + fixture_git(&clone, &["commit", "-q", "-m", "inner"]); + write_file(&clone.join("inner.txt"), "inner changed\n"); + write_file(&clone.join("inner-new.txt"), "new\n"); + assert_eq!(project_file_difference_count(&project).await, Some(0)); + + write_file(&project.join("a.txt"), "changed\n"); + assert_eq!(project_file_difference_count(&project).await, Some(1)); + } + + #[tokio::test] + async fn project_file_difference_is_unknown_without_origin_main() { + let (_temp, project) = project_file_difference_fixture(); + fixture_git(&project, &["update-ref", "-d", "refs/remotes/origin/main"]); + assert_eq!(project_file_difference_count(&project).await, None); + } + + #[tokio::test] + async fn project_file_difference_is_unknown_for_non_project_and_nested_project() { + let (temp, project) = project_file_difference_fixture(); + + // a Project nested inside another repository never reports the parent's count + write_file(&project.join("a.txt"), "changed\n"); + let nested = project.join("nested"); + std::fs::create_dir_all(nested.join(".ac")).expect("nested .ac"); + assert_eq!(project_file_difference_count(&nested).await, None); + + // a repository without `.ac` is not a Project + std::fs::remove_dir_all(project.join(".ac")).expect("remove .ac"); + assert_eq!(project_file_difference_count(&project).await, None); + + // a `.ac` directory with no repository of its own + let bare = temp.path().join("bare"); + std::fs::create_dir_all(bare.join(".ac")).expect("bare .ac"); + assert_eq!(project_file_difference_count(&bare).await, None); + } + + /// Answers the reads like Git would for `project`, with `diff` scripted. + fn scripted_git( + project: PathBuf, + diff: &'static [u8], + ) -> impl Fn(PathBuf, Vec) -> std::future::Ready, String>> { + move |_cwd, args| { + let out = match args.get(3).map(String::as_str) { + Some("rev-parse") if args.iter().any(|a| a == "--show-toplevel") => { + format!("{}\n", project.display()).into_bytes() + } + Some("rev-parse") => b"0123456789abcdef0123456789abcdef01234567\n".to_vec(), + Some("diff") => diff.to_vec(), + Some("ls-files") => Vec::new(), + other => return std::future::ready(Err(format!("unexpected {other:?}"))), + }; + std::future::ready(Ok(out)) + } + } + + #[tokio::test] + async fn project_file_difference_scripted_reads_parse_or_are_unknown() { + let temp = tempfile::tempdir().expect("tempdir"); + let project = temp.path().join("project"); + std::fs::create_dir_all(project.join(".ac")).expect(".ac"); + let limit = Duration::from_secs(5); + let project: &Path = &project; + let read = move |diff: &'static [u8]| { + read_project_file_difference(project, limit, scripted_git(project.to_path_buf(), diff)) + }; + + // Sensitivity control for the scripted runner itself; duplicates count once. + assert_eq!(read(b"a.txt\0b.txt\0a.txt\0").await.count, Some(2)); + assert_eq!(read(b"").await.count, Some(0)); + // not NUL-terminated, and an empty record, are malformed + assert_eq!(read(b"a.txt").await.count, None); + assert_eq!(read(b"a.txt\0\0").await.count, None); + + let failing = read_project_file_difference(&project, limit, |_, _| { + std::future::ready(Err::, String>("spawn failed".to_string())) + }) + .await; + assert_eq!(failing.count, None); + } + + #[test] + fn project_file_difference_parser_keeps_raw_bytes() { + let records = parse_nul_records(b"new\nline\0\xff\xfe\0 spaced \0").expect("parse"); + assert_eq!( + records, + vec![&b"new\nline"[..], &b"\xff\xfe"[..], &b" spaced "[..]] + ); + assert_eq!(strip_line_terminator(b" path \r\n"), b" path "); + assert_eq!(strip_line_terminator(b" path \n"), b" path "); + assert_eq!(strip_line_terminator(b" path "), b" path "); + } + + #[tokio::test] + async fn project_file_difference_timeout_is_unknown_and_bounded() { + let temp = tempfile::tempdir().expect("tempdir"); + let project = temp.path().join("project"); + std::fs::create_dir_all(project.join(".ac")).expect(".ac"); + + // The outer bound fails if the internal timeout is removed. + let result = tokio::time::timeout( + Duration::from_secs(5), + read_project_file_difference(&project, Duration::from_millis(50), |_, _| { + std::future::pending::, String>>() + }), + ) + .await + .expect("internal timeout must bound a stalled git"); + assert_eq!(result.count, None); + } + + #[test] + fn project_file_difference_serializes_count_contract() { + let json_of = |count| serde_json::to_value(ProjectFileDifference { count }).unwrap(); + assert_eq!(json_of(Some(0)), json!({ "count": 0 })); + assert_eq!(json_of(None), json!({ "count": null })); + assert_eq!(json_of(Some(7)), json!({ "count": 7 })); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a512eea3f..4d9a018c1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5637,6 +5637,7 @@ pub fn run( commands::ac_discovery::unarchive_project, commands::ac_discovery::list_archived_projects, commands::ac_discovery::discover_project, + commands::ac_discovery::get_project_file_difference, commands::project_settings::get_project_groups, commands::project_settings::update_project_groups, commands::non_stop::non_stop_report, diff --git a/src-tauri/src/web/commands.rs b/src-tauri/src/web/commands.rs index 7adcc7eec..2a0947adc 100644 --- a/src-tauri/src/web/commands.rs +++ b/src-tauri/src/web/commands.rs @@ -29,6 +29,7 @@ pub struct WsState { enum BrowserProjectCommand { CheckProjectPath, DiscoverProject, + GetProjectFileDifference, GetProjectGroups, UpdateProjectGroups, OpenProject, @@ -93,6 +94,9 @@ fn route_web_command(cmd: &str) -> WebCommandRoute { "discover_project" => { WebCommandRoute::BrowserProject(BrowserProjectCommand::DiscoverProject) } + "get_project_file_difference" => { + WebCommandRoute::BrowserProject(BrowserProjectCommand::GetProjectFileDifference) + } "get_project_groups" => { WebCommandRoute::BrowserProject(BrowserProjectCommand::GetProjectGroups) } @@ -1096,6 +1100,10 @@ async fn dispatch_browser_project_command( serde_json::to_value(result).map_err(|e| e.to_string()) } + BrowserProjectCommand::GetProjectFileDifference => { + project_file_difference_value(args).await + } + BrowserProjectCommand::GetProjectGroups => { let path = require_str(args, "path")?; let result = crate::commands::project_settings::get_project_groups_inner(&path)?; @@ -1195,6 +1203,13 @@ pub fn broadcast_all_r(app: &tauri::AppHandle, event: &str // --- Arg helpers --- +/// Same helper as the Tauri command; a failed read is `{count: null}`, not an error. +async fn project_file_difference_value(args: &Value) -> Result { + let path = require_str(args, "path")?; + let result = crate::commands::ac_discovery::get_project_file_difference_inner(&path).await; + serde_json::to_value(result).map_err(|e| e.to_string()) +} + fn require_str(args: &Value, key: &str) -> Result { args.get(key) .and_then(|v| v.as_str()) @@ -1449,6 +1464,10 @@ mod tests { let expected = [ ("open_project", BrowserProjectCommand::OpenProject), ("discover_project", BrowserProjectCommand::DiscoverProject), + ( + "get_project_file_difference", + BrowserProjectCommand::GetProjectFileDifference, + ), ( "check_project_path", BrowserProjectCommand::CheckProjectPath, @@ -1479,6 +1498,25 @@ mod tests { } } + #[tokio::test] + async fn project_file_difference_web_route_reaches_shared_helper() { + assert_eq!( + route_web_command("get_project_file_difference"), + WebCommandRoute::BrowserProject(BrowserProjectCommand::GetProjectFileDifference) + ); + + // A directory without `.ac` reaches the helper and comes back unknown, not an error. + let temp = tempfile::tempdir().expect("tempdir"); + let path = temp.path().to_str().expect("utf8 path"); + assert_eq!( + project_file_difference_value(&json!({ "path": path })) + .await + .expect("helper result"), + json!({ "count": null }) + ); + assert!(project_file_difference_value(&json!({})).await.is_err()); + } + #[test] fn deferred_and_cli_project_commands_are_not_web_commands() { assert_eq!( From 4e0fe4360ffd851e2311ecabd55a10f66c779837 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 17 Oct 2026 10:59:36 -0300 Subject: [PATCH 2/5] test(project): qualify json! in file difference serialization test (#3060) --- src-tauri/src/commands/ac_discovery.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands/ac_discovery.rs b/src-tauri/src/commands/ac_discovery.rs index 3b9fe270a..0305943cc 100644 --- a/src-tauri/src/commands/ac_discovery.rs +++ b/src-tauri/src/commands/ac_discovery.rs @@ -8416,8 +8416,8 @@ mod snapshot_null_tests { #[test] fn project_file_difference_serializes_count_contract() { let json_of = |count| serde_json::to_value(ProjectFileDifference { count }).unwrap(); - assert_eq!(json_of(Some(0)), json!({ "count": 0 })); - assert_eq!(json_of(None), json!({ "count": null })); - assert_eq!(json_of(Some(7)), json!({ "count": 7 })); + assert_eq!(json_of(Some(0)), serde_json::json!({ "count": 0 })); + assert_eq!(json_of(None), serde_json::json!({ "count": null })); + assert_eq!(json_of(Some(7)), serde_json::json!({ "count": 7 })); } } From e47ddaa66756eaf3074a4924db3f3308b3073d7e Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 17 Oct 2026 12:45:04 -0300 Subject: [PATCH 3/5] fix: remove redundant borrow in project difference test --- src-tauri/src/commands/ac_discovery.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src-tauri/src/commands/ac_discovery.rs b/src-tauri/src/commands/ac_discovery.rs index 0305943cc..9d4251b82 100644 --- a/src-tauri/src/commands/ac_discovery.rs +++ b/src-tauri/src/commands/ac_discovery.rs @@ -8376,7 +8376,7 @@ mod snapshot_null_tests { assert_eq!(read(b"a.txt").await.count, None); assert_eq!(read(b"a.txt\0\0").await.count, None); - let failing = read_project_file_difference(&project, limit, |_, _| { + let failing = read_project_file_difference(project, limit, |_, _| { std::future::ready(Err::, String>("spawn failed".to_string())) }) .await; From a380f79512d982a98a8fd95c085003168a5294c7 Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 17 Oct 2026 15:50:17 -0300 Subject: [PATCH 4/5] fix(3060): reconcile project Git spawn inventory --- src-tauri/tests/spawn_search_path_inventory.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src-tauri/tests/spawn_search_path_inventory.rs b/src-tauri/tests/spawn_search_path_inventory.rs index dea16beec..9729471ab 100644 --- a/src-tauri/tests/spawn_search_path_inventory.rs +++ b/src-tauri/tests/spawn_search_path_inventory.rs @@ -154,9 +154,9 @@ const INVENTORY: &[Row] = &[ ("src-tauri/src/cli/task_ops.rs", 1, 0, &[(TestOnly, 1)]), ( "src-tauri/src/commands/ac_discovery.rs", - 32, - 1, - &[(TestOnly, 31)], + 34, + 2, + &[(TestOnly, 32)], ), ( "src-tauri/src/commands/config.rs", From 9c00028cb46813a9fd3b7ce9cccdd4201be0b9ee Mon Sep 17 00:00:00 2001 From: Mariano Blua Date: Sat, 17 Oct 2026 19:02:32 -0300 Subject: [PATCH 5/5] fix: reconcile screenshot guards and stale menu callback proof (#3060) --- .github/workflows/pr-regression-gates.yml | 12 ++++++------ .../components/ProjectPanel.context-menu.test.tsx | 7 ++++--- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/pr-regression-gates.yml b/.github/workflows/pr-regression-gates.yml index 93938348a..036b89d48 100644 --- a/.github/workflows/pr-regression-gates.yml +++ b/.github/workflows/pr-regression-gates.yml @@ -1043,16 +1043,16 @@ jobs: run: | set -euo pipefail FILTER='screenshot::native::tests::' - # 19 pre-existing + overlay_window_traits_match_target + the two + # 19 pre-existing + overlay_window_traits_match_target + two save_selection tests + the two # Linux-gated tests + the nine IS #2173 monitor-wiring tests # (xcap_logical_frame_divides_bitmap_by_xcap_scale, the five # match_monitor_*, the two build_placement_* and # placement_frame_matches_target). #2173 also deleted # xcap_origin_conversion_is_identity_unless_logical and # placement_size_uses_bitmap_only_when_flagged. LINUX count (Windows - # runs 29, macOS 30); update it in the same commit that adds or + # runs 31, macOS 32); update it in the same commit that adds or # removes a test in that module. - EXPECTED=31 + EXPECTED=33 cargo test --locked --lib "$FILTER" -- --test-threads=1 --nocapture 2>&1 | tee test-1842.log # Three guards, matching the #1577 step, none redundant: @@ -2160,16 +2160,16 @@ jobs: run: | set -euo pipefail FILTER='screenshot::native::tests::' - # 19 pre-existing + overlay_window_traits_match_target + the + # 19 pre-existing + overlay_window_traits_match_target + two save_selection tests + the # macOS-gated test + the nine IS #2173 monitor-wiring tests # (xcap_logical_frame_divides_bitmap_by_xcap_scale, the five # match_monitor_*, the two build_placement_* and # placement_frame_matches_target). #2173 also deleted # xcap_origin_conversion_is_identity_unless_logical and # placement_size_uses_bitmap_only_when_flagged. MACOS count (Windows - # runs 29, Linux 31); update it in the same commit that adds or + # runs 31, Linux 33); update it in the same commit that adds or # removes a test in that module. - EXPECTED=30 + EXPECTED=32 cargo test --locked --lib "$FILTER" -- --test-threads=1 --nocapture 2>&1 | tee test-2079.log # Three guards, matching the #1842 step, none redundant: diff --git a/src/sidebar/components/ProjectPanel.context-menu.test.tsx b/src/sidebar/components/ProjectPanel.context-menu.test.tsx index 8f9465bd5..97658d61e 100644 --- a/src/sidebar/components/ProjectPanel.context-menu.test.tsx +++ b/src/sidebar/components/ProjectPanel.context-menu.test.tsx @@ -2051,8 +2051,8 @@ describe("ProjectPanel replica context menu — session actions (#1673)", () => settled = true; gate.resolve({ telegramBots: [botOne, botTwo] }); await waitFor(() => expect(menuA.querySelectorAll(".settings-color-dot")).toHaveLength(2)); - expect(rafCallbacks).toHaveLength(1); - const staleReclamp = rafCallbacks.shift()!; + expect(rafCallbacks.length).toBeGreaterThan(0); + const staleCallbacks = rafCallbacks.splice(0); dispatchDismiss("escape"); await waitFor(() => expect(replicaMenu()).toBeNull()); @@ -2080,7 +2080,8 @@ describe("ProjectPanel replica context menu — session actions (#1673)", () => toJSON: () => ({}), } as DOMRect); - staleReclamp(0); + expect(left !== "8px" || top !== "8px").toBe(true); + for (const callback of staleCallbacks) callback(0); await Promise.resolve(); expect(replicaMenu()).toBe(replacement); expect(replacement.style.left).toBe(left);