use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::{fs, io};
use super::backend::{AgentBackend, AgentBackendError, BuildCommandRequest};
use super::prompt::{self as shared_prompt, CliPromptAccessRootMode};
const ANTIGRAVITY_PRINT_TIMEOUT: &str = "1h";
const ANTIGRAVITY_PROJECT_STATE_PATTERN: &str = ".antigravitycli/";
const ANTIGRAVITY_PROJECT_CACHE_PATTERN: &str = "cache/projects.json";
const ANTIGRAVITY_PROJECT_STATE_PATTERNS: &[&str] = &[
ANTIGRAVITY_PROJECT_STATE_PATTERN,
ANTIGRAVITY_PROJECT_CACHE_PATTERN,
];
pub(super) struct AntigravityBackend;
impl AgentBackend for AntigravityBackend {
fn setup(&self, folder: &Path) -> Result<(), AgentBackendError> {
ensure_antigravity_project_state_ignored(folder)?;
Ok(())
}
fn build_command<'request>(
&'request self,
request: BuildCommandRequest<'request>,
) -> Result<Command, AgentBackendError> {
let BuildCommandRequest {
attachments,
folder,
main_checkout_root: _main_checkout_root,
model,
prompt: _prompt,
request_kind: _request_kind,
replay_transcript: _replay_transcript,
reasoning_level: _reasoning_level,
} = request;
let mut command = Command::new("agy");
ensure_antigravity_project_state_ignored(folder)?;
shared_prompt::append_cli_prompt_access_directories(
&mut command,
folder,
attachments,
CliPromptAccessRootMode::WorkspaceThenAttachments,
);
command
.arg("--sandbox")
.arg("--dangerously-skip-permissions")
.arg("--print")
.arg("--print-timeout")
.arg(ANTIGRAVITY_PRINT_TIMEOUT)
.arg("--model")
.arg(model)
.current_dir(folder)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
Ok(command)
}
}
fn ensure_antigravity_project_state_ignored(folder: &Path) -> Result<(), AgentBackendError> {
let Some(exclude_path) = git_info_exclude_path(folder)? else {
return Ok(());
};
for pattern in ANTIGRAVITY_PROJECT_STATE_PATTERNS {
append_git_exclude_pattern(&exclude_path, pattern)?;
}
Ok(())
}
fn git_info_exclude_path(folder: &Path) -> Result<Option<PathBuf>, AgentBackendError> {
let git_path = folder.join(".git");
let metadata = match fs::metadata(&git_path) {
Ok(metadata) => metadata,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(AgentBackendError::Setup(format!(
"Failed to inspect Antigravity git metadata path `{}`: {error}",
git_path.display()
)));
}
};
let git_dir = if metadata.is_dir() {
git_path
} else {
let git_file = fs::read_to_string(&git_path).map_err(|error| {
AgentBackendError::Setup(format!(
"Failed to read Antigravity gitdir file `{}`: {error}",
git_path.display()
))
})?;
let Some(git_dir) = parse_gitdir_file(folder, &git_file) else {
return Ok(None);
};
git_dir
};
Ok(Some(git_common_info_exclude_path(&git_dir)?))
}
fn git_common_info_exclude_path(git_dir: &Path) -> Result<PathBuf, AgentBackendError> {
let common_dir_file = git_dir.join("commondir");
let common_dir = match fs::read_to_string(&common_dir_file) {
Ok(common_dir) => parse_common_dir_file(git_dir, &common_dir),
Err(error) if error.kind() == io::ErrorKind::NotFound => git_dir.to_path_buf(),
Err(error) => {
return Err(AgentBackendError::Setup(format!(
"Failed to read Antigravity git common-dir file `{}`: {error}",
common_dir_file.display()
)));
}
};
Ok(common_dir.join("info").join("exclude"))
}
fn parse_common_dir_file(git_dir: &Path, common_dir_file: &str) -> PathBuf {
let Some(common_dir) = common_dir_file.lines().next().map(str::trim) else {
return git_dir.to_path_buf();
};
if common_dir.is_empty() {
return git_dir.to_path_buf();
}
let common_dir = PathBuf::from(common_dir);
if common_dir.is_absolute() {
return common_dir;
}
git_dir.join(common_dir)
}
fn parse_gitdir_file(folder: &Path, git_file: &str) -> Option<PathBuf> {
let git_dir = git_file.strip_prefix("gitdir:")?.trim();
if git_dir.is_empty() {
return None;
}
let git_dir = PathBuf::from(git_dir);
if git_dir.is_absolute() {
return Some(git_dir);
}
Some(folder.join(git_dir))
}
fn append_git_exclude_pattern(exclude_path: &Path, pattern: &str) -> Result<(), AgentBackendError> {
let existing = match fs::read_to_string(exclude_path) {
Ok(existing) => existing,
Err(error) if error.kind() == io::ErrorKind::NotFound => String::new(),
Err(error) => {
return Err(AgentBackendError::Setup(format!(
"Failed to read Antigravity git exclude `{}`: {error}",
exclude_path.display()
)));
}
};
if existing.lines().any(|line| line.trim() == pattern) {
return Ok(());
}
if let Some(parent) = exclude_path.parent() {
fs::create_dir_all(parent).map_err(|error| {
AgentBackendError::Setup(format!(
"Failed to create Antigravity git exclude directory `{}`: {error}",
parent.display()
))
})?;
}
let mut exclude_file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(exclude_path)
.map_err(|error| {
AgentBackendError::Setup(format!(
"Failed to open Antigravity git exclude `{}`: {error}",
exclude_path.display()
))
})?;
if !existing.is_empty() && !existing.ends_with('\n') {
writeln!(exclude_file).map_err(|error| {
AgentBackendError::Setup(format!(
"Failed to update Antigravity git exclude `{}`: {error}",
exclude_path.display()
))
})?;
}
writeln!(
exclude_file,
"# Agentty: ignore Antigravity CLI workspace project state\n{pattern}"
)
.map_err(|error| {
AgentBackendError::Setup(format!(
"Failed to update Antigravity git exclude `{}`: {error}",
exclude_path.display()
))
})
}
#[cfg(test)]
mod tests {
use ag_protocol::{ProtocolSchemaInstructionMode, TurnPromptAttachment};
use tempfile::{TempDir, tempdir};
use super::shared_prompt::{
CliPromptAccessRootMode, build_prompt_stdin_payload, cli_prompt_access_directories,
};
use super::*;
use crate::channel::AgentRequestKind;
use crate::model::agent::{AgentModel, ReasoningLevel};
fn session_resume_request_kind(_replay_transcript: Option<&str>) -> AgentRequestKind {
AgentRequestKind::SessionResume
}
fn session_start_request_kind() -> AgentRequestKind {
AgentRequestKind::SessionStart
}
fn visible_tempdir() -> TempDir {
tempfile::Builder::new()
.prefix("agentty-antigravity-test-")
.tempdir()
.expect("failed to create visible temp dir")
}
fn create_standard_git_directory(folder: &Path) {
fs::create_dir_all(folder.join(".git")).expect("failed to create git metadata directory");
}
fn read_standard_git_exclude(folder: &Path) -> String {
fs::read_to_string(folder.join(".git").join("info").join("exclude"))
.expect("failed to read git exclude")
}
fn assert_antigravity_project_state_patterns_ignored(exclude: &str) {
for pattern in ANTIGRAVITY_PROJECT_STATE_PATTERNS {
assert!(
exclude.lines().any(|line| line.trim() == *pattern),
"exclude should contain pattern `{pattern}`"
);
}
}
#[test]
fn test_antigravity_build_command_uses_print_mode_with_sandbox() {
let temp_directory = visible_tempdir();
create_standard_git_directory(temp_directory.path());
let backend = AntigravityBackend;
let requested_model = AgentModel::Gemini31ProPreview.provider_model_str();
let command = AgentBackend::build_command(
&backend,
BuildCommandRequest {
attachments: &[],
folder: temp_directory.path(),
main_checkout_root: None,
replay_transcript: None,
model: requested_model,
prompt: "Write tests",
reasoning_level: ReasoningLevel::default(),
request_kind: &session_start_request_kind(),
},
)
.expect("command should build");
let args = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
let session_folder = temp_directory.path().to_string_lossy().into_owned();
assert_eq!(
args,
vec![
"--add-dir".to_string(),
session_folder,
"--sandbox".to_string(),
"--dangerously-skip-permissions".to_string(),
"--print".to_string(),
"--print-timeout".to_string(),
ANTIGRAVITY_PRINT_TIMEOUT.to_string(),
"--model".to_string(),
requested_model.to_string(),
]
);
assert_eq!(command.get_current_dir(), Some(temp_directory.path()));
assert_antigravity_project_state_patterns_ignored(&read_standard_git_exclude(
temp_directory.path(),
));
}
#[test]
fn test_antigravity_build_command_uses_hidden_session_folder_directly() {
let temp_directory = visible_tempdir();
let session_folder = temp_directory
.path()
.join(".agentty")
.join("wt")
.join("00cbfefe");
fs::create_dir_all(&session_folder).expect("failed to create hidden session folder");
create_standard_git_directory(&session_folder);
let backend = AntigravityBackend;
let requested_model = AgentModel::Gemini31ProPreview.provider_model_str();
let command = AgentBackend::build_command(
&backend,
BuildCommandRequest {
attachments: &[],
folder: &session_folder,
main_checkout_root: None,
replay_transcript: None,
model: requested_model,
prompt: "Write tests",
reasoning_level: ReasoningLevel::default(),
request_kind: &session_start_request_kind(),
},
)
.expect("command should build");
let args = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
let session_folder_display = session_folder.to_string_lossy().into_owned();
assert_eq!(command.get_current_dir(), Some(session_folder.as_path()));
assert_eq!(args[0], "--add-dir");
assert_eq!(args[1], session_folder_display);
assert_antigravity_project_state_patterns_ignored(&read_standard_git_exclude(
&session_folder,
));
}
#[test]
fn test_antigravity_setup_ignores_project_state_for_standard_git_directory() {
let temp_directory = visible_tempdir();
create_standard_git_directory(temp_directory.path());
let backend = AntigravityBackend;
AgentBackend::setup(&backend, temp_directory.path()).expect("setup should succeed");
let exclude = read_standard_git_exclude(temp_directory.path());
assert!(exclude.contains("# Agentty: ignore Antigravity CLI workspace project state"));
assert_antigravity_project_state_patterns_ignored(&exclude);
}
#[test]
fn test_antigravity_setup_accepts_hidden_session_folder() {
let temp_directory = visible_tempdir();
let session_folder = temp_directory
.path()
.join(".agentty")
.join("wt")
.join("00cbfefe");
fs::create_dir_all(&session_folder).expect("failed to create hidden session folder");
create_standard_git_directory(&session_folder);
let backend = AntigravityBackend;
AgentBackend::setup(&backend, &session_folder).expect("setup should succeed");
let exclude = read_standard_git_exclude(&session_folder);
assert_antigravity_project_state_patterns_ignored(&exclude);
}
#[test]
fn test_antigravity_setup_ignores_project_state_for_linked_worktree_gitdir() {
let temp_directory = visible_tempdir();
let common_git_dir = temp_directory.path().join("main").join(".git");
let worktree = temp_directory.path().join("worktree");
let worktree_git_dir = common_git_dir.join("worktrees").join("feature");
fs::create_dir_all(&worktree).expect("failed to create worktree directory");
fs::create_dir_all(&worktree_git_dir).expect("failed to create gitdir directory");
fs::write(
worktree.join(".git"),
format!("gitdir: {}\n", worktree_git_dir.display()),
)
.expect("failed to write linked worktree gitdir file");
fs::write(worktree_git_dir.join("commondir"), "../..\n")
.expect("failed to write linked worktree commondir file");
let backend = AntigravityBackend;
AgentBackend::setup(&backend, &worktree).expect("setup should succeed");
let exclude = fs::read_to_string(common_git_dir.join("info").join("exclude"))
.expect("failed to read linked worktree git exclude");
assert_antigravity_project_state_patterns_ignored(&exclude);
}
#[test]
fn test_antigravity_setup_ignores_project_state_idempotently() {
let temp_directory = visible_tempdir();
create_standard_git_directory(temp_directory.path());
let backend = AntigravityBackend;
AgentBackend::setup(&backend, temp_directory.path()).expect("first setup should succeed");
AgentBackend::setup(&backend, temp_directory.path()).expect("second setup should succeed");
let exclude = read_standard_git_exclude(temp_directory.path());
for pattern in ANTIGRAVITY_PROJECT_STATE_PATTERNS {
let pattern_count = exclude
.lines()
.filter(|line| line.trim() == *pattern)
.count();
assert_eq!(pattern_count, 1, "pattern `{pattern}` should appear once");
}
}
#[test]
fn test_antigravity_build_command_adds_workspace_directories() {
let temp_directory = visible_tempdir();
let attachment_directory = temp_directory.path().join("images");
let attachment = TurnPromptAttachment {
placeholder: "[Image #1]".to_string(),
local_image_path: attachment_directory.join("one.png"),
};
let backend = AntigravityBackend;
let requested_model = "gemini-3.5-flash";
let command = AgentBackend::build_command(
&backend,
BuildCommandRequest {
attachments: &[attachment],
folder: temp_directory.path(),
main_checkout_root: None,
replay_transcript: None,
model: requested_model,
prompt: "Review [Image #1]",
reasoning_level: ReasoningLevel::default(),
request_kind: &session_start_request_kind(),
},
)
.expect("command should build");
let args = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
let expected_workspace_args = vec![
"--add-dir".to_string(),
temp_directory.path().to_string_lossy().into_owned(),
"--add-dir".to_string(),
attachment_directory.to_string_lossy().into_owned(),
];
let expected_model_args = ["--model".to_string(), requested_model.to_string()];
assert_eq!(args[..4], expected_workspace_args);
assert!(
args.windows(2)
.any(|pair| pair[0] == expected_model_args[0] && pair[1] == expected_model_args[1]),
"command should include requested model"
);
}
#[test]
fn test_workspace_access_directories_keep_session_folder_first() {
let temp_directory = tempdir().expect("failed to create temp dir");
let session_folder = temp_directory.path().join("z-session");
let attachment_directory = temp_directory.path().join("a-images");
let attachment = TurnPromptAttachment {
placeholder: "[Image #1]".to_string(),
local_image_path: attachment_directory.join("one.png"),
};
let workspace_directories = cli_prompt_access_directories(
&session_folder,
&[attachment],
CliPromptAccessRootMode::WorkspaceThenAttachments,
);
assert_eq!(
workspace_directories,
vec![session_folder, attachment_directory]
);
}
#[test]
fn test_antigravity_stdin_payload_replays_transcript_on_resume() {
let temp_directory = tempdir().expect("failed to create temp dir");
let request_kind = session_resume_request_kind(Some("previous answer"));
let payload = build_prompt_stdin_payload(
BuildCommandRequest {
attachments: &[],
folder: temp_directory.path(),
main_checkout_root: None,
replay_transcript: Some("previous answer"),
model: AgentModel::Gemini31ProPreview.provider_model_str(),
prompt: "continue work",
reasoning_level: ReasoningLevel::default(),
request_kind: &request_kind,
},
ProtocolSchemaInstructionMode::PromptSchema,
"Antigravity",
)
.expect("stdin payload should build");
let prompt = String::from_utf8(payload).expect("prompt should be utf8");
assert!(prompt.contains("Structured response protocol:"));
assert!(prompt.contains("previous answer"));
assert!(prompt.contains("continue work"));
}
}