diff --git a/src/daemon.rs b/src/daemon.rs index 6adb325..ce86bb7 100644 --- a/src/daemon.rs +++ b/src/daemon.rs @@ -15,7 +15,10 @@ use crate::git::repo_state::{ resolve_stash_target_oid_for_worktree, resolve_worktree_head_reflog_old_oid_for_new_head, worktree_root_for_path, }; -use crate::git::repository::{Repository, discover_repository_in_path_no_git_exec, exec_git}; +use crate::git::repository::{ + Repository, discover_repository_in_path_no_git_exec, exec_git, + is_no_repository_discovered_error, +}; use crate::git::rewrite_log::{ CherryPickAbortEvent, CherryPickCompleteEvent, MergeSquashEvent, RebaseAbortEvent, RebaseCompleteEvent, ResetEvent, ResetKind, RewriteLogEvent, StashEvent, StashOperation, @@ -4996,8 +4999,16 @@ impl ActorDaemonCoordinator { carryover_capture_stage_error(stage, command, input.exit_code, error) }; - let repo = discover_repository_in_path_no_git_exec(input.worktree) - .map_err(|error| with_stage("repo_discovery", error))?; + // The traced command may have run in a directory that is not a git + // repository — for example a temporary scratch directory. There is no + // carryover state to capture there, so treat "no repository found" as a + // benign no-snapshot case rather than a reported error. Real discovery + // faults (e.g. an unreadable git config) still surface. + let repo = match discover_repository_in_path_no_git_exec(input.worktree) { + Ok(repo) => repo, + Err(error) if is_no_repository_discovered_error(&error) => return Ok(None), + Err(error) => return Err(with_stage("repo_discovery", error)), + }; let stable_heads = stable_carryover_heads_for_command(&repo, &input, &parsed) .map_err(|error| with_stage("stable_heads", error))?; @@ -9460,6 +9471,31 @@ mod tests { ); } + #[tokio::test] + async fn carryover_capture_in_non_repository_worktree_returns_no_snapshot() { + // A git command traced in a temporary, non-repository directory has no + // carryover state to capture. Repo discovery finds nothing, which must + // be a benign no-snapshot result rather than a reported error — the + // daemon otherwise files its own failure into error tracking. + // ActorDaemonCoordinator::new() spawns Tokio tasks, so this runs inside + // a runtime. + let coordinator = ActorDaemonCoordinator::new(); + let tmp = tempfile::tempdir().unwrap(); + let argv = vec!["git".to_string(), "commit".to_string()]; + let result = coordinator.capture_carryover_snapshot_for_command(CarryoverCaptureInput { + root_sid: "sid-123", + worktree: tmp.path(), + primary_command: Some("commit"), + argv: &argv, + exit_code: 0, + finished_at_ns: 0, + post_repo: None, + ref_changes: &[], + }); + + assert!(matches!(result, Ok(None)), "{result:?}"); + } + #[test] fn ai_replay_checkpoint_request_preserves_active_bash_agent_identity() { let agent_id = AgentId { diff --git a/src/git/repository.rs b/src/git/repository.rs index 76c7be7..fa1633b 100644 --- a/src/git/repository.rs +++ b/src/git/repository.rs @@ -2346,11 +2346,30 @@ fn discover_repository_paths_no_git_exec( } Err(AutterError::Generic(format!( - "No git repository found for path without exec: {}", + "{NO_REPOSITORY_DISCOVERED_PREFIX}: {}", path.display() ))) } +/// Message prefix for the "no git repository at or above this path" outcome of +/// [`discover_repository_in_path_no_git_exec`]. Callers that treat a +/// non-repository path as benign match on it through +/// [`is_no_repository_discovered_error`]. +const NO_REPOSITORY_DISCOVERED_PREFIX: &str = "No git repository found for path without exec"; + +/// Returns `true` when `error` is the benign "no repository at this path" +/// outcome of [`discover_repository_in_path_no_git_exec`], as opposed to a real +/// discovery fault such as an unreadable git config. A caller for which a +/// non-repository path is not an error — for example a git command traced in a +/// temporary scratch directory — uses this to skip quietly instead of +/// reporting. +pub fn is_no_repository_discovered_error(error: &AutterError) -> bool { + matches!( + error, + AutterError::Generic(message) if message.starts_with(NO_REPOSITORY_DISCOVERED_PREFIX) + ) +} + fn git_config_file_for_repo_paths( git_dir: &Path, git_common_dir: &Path, @@ -3154,6 +3173,27 @@ mod tests { assert_eq!(repo.merge_base(a.clone(), a.clone()).unwrap(), Some(a)); } + #[test] + fn discovery_in_non_repository_path_is_recognised_as_benign() { + // A directory that is not a git repository — a temporary scratch dir + // here — yields the "no repository found" outcome, which callers treat + // as benign rather than a fault. + let tmp = tempfile::tempdir().unwrap(); + let error = discover_repository_in_path_no_git_exec(tmp.path()).unwrap_err(); + assert!(is_no_repository_discovered_error(&error), "{error}"); + } + + #[test] + fn other_errors_are_not_recognised_as_no_repository() { + // A real fault must not be mistaken for the benign no-repository case. + assert!(!is_no_repository_discovered_error(&AutterError::Generic( + "Git directory has no parent: /tmp/x".to_string() + ))); + assert!(!is_no_repository_discovered_error(&AutterError::IoError( + std::io::Error::new(std::io::ErrorKind::NotFound, "gone") + ))); + } + #[test] fn author_config_overlays_full_identity() { let git_identity = GitAuthorIdentity {