use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash as _, Hasher as _};
use std::io::Write as _;
use std::path::{Component, 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,
];
const ANTIGRAVITY_WORKTREE_ALIAS_DIR: &str = "agentty-antigravity-worktrees";
pub(super) struct AntigravityBackend;
impl AgentBackend for AntigravityBackend {
fn setup(&self, folder: &Path) -> Result<(), AgentBackendError> {
ensure_antigravity_project_state_ignored(folder)?;
ensure_antigravity_workspace_alias(folder)?;
Ok(())
}
fn build_command<'request>(
&'request self,
request: BuildCommandRequest<'request>,
) -> Result<Command, AgentBackendError> {
let BuildCommandRequest {
attachments,
folder,
prompt: _prompt,
request_kind: _request_kind,
model,
reasoning_level: _reasoning_level,
} = request;
let mut command = Command::new("agy");
ensure_antigravity_project_state_ignored(folder)?;
let workspace_folder = ensure_antigravity_workspace_alias(folder)?;
shared_prompt::append_cli_prompt_access_directories(
&mut command,
&workspace_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(workspace_folder)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
Ok(command)
}
}
fn ensure_antigravity_workspace_alias(folder: &Path) -> Result<PathBuf, AgentBackendError> {
if !has_hidden_path_component(folder) {
return Ok(folder.to_path_buf());
}
let alias = antigravity_workspace_alias_path(folder);
if has_hidden_path_component(&alias) {
return Err(AgentBackendError::Setup(format!(
"Antigravity refuses hidden workspace folders and Agentty could not create a \
non-hidden alias for `{}` because the temp alias path `{}` also contains a hidden \
path component.",
folder.display(),
alias.display()
)));
}
ensure_directory_symlink(folder, &alias).map_err(|error| {
AgentBackendError::Setup(format!(
"Antigravity refuses hidden workspace folders and Agentty failed to create a \
non-hidden alias from `{}` to `{}`: {error}",
alias.display(),
folder.display()
))
})?;
Ok(alias)
}
pub(crate) fn cleanup_workspace_alias(folder: &Path) -> Result<(), AgentBackendError> {
if !has_hidden_path_component(folder) {
return Ok(());
}
let alias = antigravity_workspace_alias_path(folder);
let alias_target = match fs::read_link(&alias) {
Ok(alias_target) => alias_target,
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(error) if error.kind() == io::ErrorKind::InvalidInput => return Ok(()),
Err(error) => {
return Err(AgentBackendError::Setup(format!(
"Failed to inspect Antigravity workspace alias `{}`: {error}",
alias.display()
)));
}
};
if alias_target != folder {
return Ok(());
}
fs::remove_file(&alias).map_err(|error| {
AgentBackendError::Setup(format!(
"Failed to remove Antigravity workspace alias `{}`: {error}",
alias.display()
))
})?;
if let Some(parent) = alias.parent()
&& let Err(error) = fs::remove_dir(parent)
&& error.kind() != io::ErrorKind::NotFound
&& error.kind() != io::ErrorKind::DirectoryNotEmpty
{
return Err(AgentBackendError::Setup(format!(
"Failed to prune Antigravity workspace alias directory `{}`: {error}",
parent.display()
)));
}
Ok(())
}
fn has_hidden_path_component(path: &Path) -> bool {
path.components().any(|component| {
matches!(component, Component::Normal(name) if name.to_string_lossy().starts_with('.'))
})
}
fn antigravity_workspace_alias_path(folder: &Path) -> PathBuf {
let mut hasher = DefaultHasher::new();
folder.hash(&mut hasher);
let folder_name = folder
.file_name()
.and_then(|name| name.to_str())
.map(|name| name.trim_start_matches('.'))
.filter(|name| !name.is_empty())
.unwrap_or("worktree");
let alias_name = format!("{folder_name}-{:016x}", hasher.finish());
std::env::temp_dir()
.join(ANTIGRAVITY_WORKTREE_ALIAS_DIR)
.join(alias_name)
}
fn ensure_directory_symlink(target: &Path, link: &Path) -> Result<(), io::Error> {
if let Some(parent) = link.parent() {
fs::create_dir_all(parent)?;
}
match fs::read_link(link) {
Ok(existing_target) if existing_target == target => return Ok(()),
Ok(existing_target) => {
return Err(io::Error::new(
io::ErrorKind::AlreadyExists,
format!(
"alias already points to `{}` instead of `{}`",
existing_target.display(),
target.display()
),
));
}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(error),
}
create_directory_symlink(target, link)
}
#[cfg(unix)]
fn create_directory_symlink(target: &Path, link: &Path) -> Result<(), io::Error> {
std::os::unix::fs::symlink(target, link)
}
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 std::path::PathBuf;
use tempfile::{TempDir, tempdir};
use super::shared_prompt::{
CliPromptAccessRootMode, ProtocolSchemaInstructionMode, build_prompt_stdin_payload,
cli_prompt_access_directories,
};
use super::*;
use crate::channel::AgentRequestKind;
use crate::model::agent::{AgentModel, ReasoningLevel};
use crate::model::turn_prompt::TurnPromptAttachment;
struct AliasCleanup {
link: PathBuf,
}
impl AliasCleanup {
fn new(folder: &Path) -> Self {
Self {
link: antigravity_workspace_alias_path(folder),
}
}
}
impl Drop for AliasCleanup {
fn drop(&mut self) {
let _ = fs::remove_file(&self.link);
}
}
fn session_resume_request_kind(session_output: Option<&str>) -> AgentRequestKind {
AgentRequestKind::SessionResume {
session_output: session_output.map(ToString::to_string),
}
}
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(),
prompt: "Write tests",
request_kind: &session_start_request_kind(),
model: requested_model,
reasoning_level: ReasoningLevel::default(),
},
)
.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_aliases_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 _alias_cleanup = AliasCleanup::new(&session_folder);
let backend = AntigravityBackend;
let requested_model = AgentModel::Gemini31ProPreview.provider_model_str();
let command = AgentBackend::build_command(
&backend,
BuildCommandRequest {
attachments: &[],
folder: &session_folder,
prompt: "Write tests",
request_kind: &session_start_request_kind(),
model: requested_model,
reasoning_level: ReasoningLevel::default(),
},
)
.expect("command should build");
let args = command
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
let alias = antigravity_workspace_alias_path(&session_folder);
assert!(!has_hidden_path_component(&alias));
assert_eq!(
fs::read_link(&alias).expect("alias should exist"),
session_folder
);
assert_eq!(command.get_current_dir(), Some(alias.as_path()));
assert_eq!(args[0], "--add-dir");
assert_eq!(args[1], alias.to_string_lossy());
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_aliases_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 _alias_cleanup = AliasCleanup::new(&session_folder);
let backend = AntigravityBackend;
AgentBackend::setup(&backend, &session_folder).expect("setup should succeed");
let alias = antigravity_workspace_alias_path(&session_folder);
assert!(!has_hidden_path_component(&alias));
assert_eq!(
fs::read_link(alias).expect("alias should exist"),
session_folder
);
}
#[test]
fn test_cleanup_workspace_alias_removes_hidden_session_alias() {
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 alias = antigravity_workspace_alias_path(&session_folder);
cleanup_workspace_alias(&session_folder).expect("cleanup should succeed");
let error = fs::read_link(alias).expect_err("alias should be removed");
assert_eq!(error.kind(), io::ErrorKind::NotFound);
}
#[test]
fn test_cleanup_workspace_alias_preserves_unrelated_symlink() {
let temp_directory = visible_tempdir();
let session_folder = temp_directory
.path()
.join(".agentty")
.join("wt")
.join("00cbfefe");
let other_folder = temp_directory.path().join("other-worktree");
fs::create_dir_all(&session_folder).expect("failed to create hidden session folder");
fs::create_dir_all(&other_folder).expect("failed to create other folder");
let alias = antigravity_workspace_alias_path(&session_folder);
fs::create_dir_all(alias.parent().expect("alias should have parent"))
.expect("failed to create alias parent");
create_directory_symlink(&other_folder, &alias).expect("failed to create unrelated alias");
let _alias_cleanup = AliasCleanup::new(&session_folder);
cleanup_workspace_alias(&session_folder).expect("cleanup should succeed");
assert_eq!(
fs::read_link(alias).expect("alias should remain"),
other_folder
);
}
#[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(),
prompt: "Review [Image #1]",
request_kind: &session_start_request_kind(),
model: requested_model,
reasoning_level: ReasoningLevel::default(),
},
)
.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_session_output_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(),
prompt: "continue work",
request_kind: &request_kind,
model: AgentModel::Gemini31ProPreview.provider_model_str(),
reasoning_level: ReasoningLevel::default(),
},
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"));
}
}