#![expect(clippy::expect_used, reason = "Tests should panic on failure")]
mod support;
use std::fs;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
use tempfile::TempDir;
use thoughts_tool::git::sync::GitSync;
fn should_run_integration_tests() -> bool {
std::env::var("THOUGHTS_INTEGRATION_TESTS").ok().as_deref() == Some("1")
}
fn ensure_remote_head_points_to_main(remote: &TempDir) {
let bare_repo = git2::Repository::open(remote.path()).expect("open bare remote");
bare_repo
.set_head("refs/heads/main")
.expect("set bare HEAD to main");
}
#[ignore = "integration test - run with: just test-integration"]
#[tokio::test]
async fn sync_jsonl_smart_merge_on_conflict() {
if !should_run_integration_tests() {
eprintln!("skipping integration test; set THOUGHTS_INTEGRATION_TESTS=1");
return;
}
let remote = TempDir::new().unwrap();
support::git_ok(remote.path(), &["init", "--bare"]);
let local = TempDir::new().unwrap();
support::git_ok(local.path(), &["init"]);
let logs_dir = local.path().join("branch/logs");
fs::create_dir_all(&logs_dir).unwrap();
let jsonl_path = logs_dir.join("tool_logs_base.jsonl");
fs::write(
&jsonl_path,
r#"{"call_id":"base","started_at":"2025-01-01T10:00:00Z","tool":"init"}"#,
)
.unwrap();
fs::write(local.path().join("readme.txt"), "readme").unwrap();
support::git_ok(local.path(), &["add", "."]);
support::git_ok(
local.path(),
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"initial",
],
);
support::git_ok(local.path(), &["branch", "-M", "main"]);
support::git_ok(
local.path(),
&["remote", "add", "origin", remote.path().to_str().unwrap()],
);
support::git_ok(local.path(), &["push", "-u", "origin", "main"]);
ensure_remote_head_points_to_main(&remote);
let other = TempDir::new().unwrap();
support::git_ok(
other.path(),
&["clone", remote.path().to_str().unwrap(), "."],
);
let other_jsonl = other.path().join("branch/logs/tool_logs_base.jsonl");
let new_content = r#"{"call_id":"base","started_at":"2025-01-01T10:00:00Z","tool":"remote_modified"}
{"call_id":"remote_only","started_at":"2025-01-01T11:00:00Z","tool":"remote"}"#;
fs::write(&other_jsonl, new_content).unwrap();
support::git_ok(other.path(), &["add", "."]);
support::git_ok(
other.path(),
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"remote change",
],
);
support::git_ok(other.path(), &["push"]);
let new_local_content = r#"{"call_id":"base","started_at":"2025-01-01T10:00:00Z","tool":"local_override"}
{"call_id":"local_only","started_at":"2025-01-01T12:00:00Z","tool":"local"}"#;
fs::write(&jsonl_path, new_local_content).unwrap();
let sync = GitSync::new(local.path(), None).unwrap();
sync.sync("test").await.unwrap();
let merged = fs::read_to_string(&jsonl_path).unwrap();
assert!(
merged.contains("remote_only"),
"should contain remote_only entry"
);
assert!(
merged.contains("local_only"),
"should contain local_only entry"
);
assert!(
merged.contains("local_override"),
"should contain local_override (local wins)"
);
assert!(
!merged.contains(r#""tool":"remote_modified""#),
"should NOT contain remote_modified (local wins on collision)"
);
let lines: Vec<&str> = merged.lines().collect();
assert!(
lines.len() >= 3,
"should have at least 3 lines, got {}",
lines.len()
);
assert!(lines[0].contains("base"), "first line should be base entry");
assert!(
lines[1].contains("remote_only"),
"second line should be remote_only"
);
assert!(
lines[2].contains("local_only"),
"third line should be local_only"
);
}
#[ignore = "integration test - run with: just test-integration"]
#[tokio::test]
async fn sync_remote_behind_with_local_uncommitted_creates_one_final_commit() {
if !should_run_integration_tests() {
eprintln!("skipping integration test; set THOUGHTS_INTEGRATION_TESTS=1");
return;
}
let remote = TempDir::new().unwrap();
support::git_ok(remote.path(), &["init", "--bare"]);
let local = TempDir::new().unwrap();
support::git_ok(local.path(), &["init"]);
fs::write(local.path().join("base.txt"), "base\n").unwrap();
support::git_ok(local.path(), &["add", "."]);
support::git_ok(
local.path(),
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"initial",
],
);
support::git_ok(local.path(), &["branch", "-M", "main"]);
support::git_ok(
local.path(),
&["remote", "add", "origin", remote.path().to_str().unwrap()],
);
support::git_ok(local.path(), &["push", "-u", "origin", "main"]);
ensure_remote_head_points_to_main(&remote);
let other = TempDir::new().unwrap();
support::git_ok(
other.path(),
&["clone", remote.path().to_str().unwrap(), "."],
);
fs::write(other.path().join("remote.txt"), "remote\n").unwrap();
support::git_ok(other.path(), &["add", "."]);
support::git_ok(
other.path(),
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"remote change",
],
);
let remote_parent = support::git_stdout(other.path(), &["rev-parse", "HEAD"]);
support::git_ok(other.path(), &["push"]);
fs::write(local.path().join("local.txt"), "local\n").unwrap();
let sync = GitSync::new(local.path(), None).unwrap();
sync.sync("test-mount").await.unwrap();
let head_message = support::git_stdout(local.path(), &["log", "-1", "--pretty=%s"]);
assert_eq!(head_message, "Auto-sync thoughts for test-mount");
let parent = support::git_stdout(local.path(), &["rev-parse", "HEAD^"]);
assert_eq!(parent, remote_parent);
let auto_sync_count: i32 = support::git_stdout(
local.path(),
&[
"rev-list",
"--count",
"--grep=^Auto-sync thoughts for test-mount$",
"HEAD",
],
)
.parse()
.unwrap();
assert_eq!(auto_sync_count, 1);
}
#[ignore = "integration test - run with: just test-integration"]
#[tokio::test]
async fn sync_remote_tool_log_only_behind_fast_forwards_without_auto_sync_commit() {
if !should_run_integration_tests() {
eprintln!("skipping integration test; set THOUGHTS_INTEGRATION_TESTS=1");
return;
}
let remote = TempDir::new().unwrap();
support::git_ok(remote.path(), &["init", "--bare"]);
let local = TempDir::new().unwrap();
support::git_ok(local.path(), &["init"]);
let logs_dir = local.path().join("branch/logs");
fs::create_dir_all(&logs_dir).unwrap();
let jsonl_path = logs_dir.join("tool_logs_base.jsonl");
fs::write(
&jsonl_path,
r#"{"call_id":"base","started_at":"2025-01-01T10:00:00Z","tool":"init"}"#,
)
.unwrap();
support::git_ok(local.path(), &["add", "."]);
support::git_ok(
local.path(),
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"initial",
],
);
support::git_ok(local.path(), &["branch", "-M", "main"]);
support::git_ok(
local.path(),
&["remote", "add", "origin", remote.path().to_str().unwrap()],
);
support::git_ok(local.path(), &["push", "-u", "origin", "main"]);
ensure_remote_head_points_to_main(&remote);
let other = TempDir::new().unwrap();
support::git_ok(
other.path(),
&["clone", remote.path().to_str().unwrap(), "."],
);
fs::write(
other.path().join("branch/logs/tool_logs_base.jsonl"),
"{\"call_id\":\"base\",\"started_at\":\"2025-01-01T10:00:00Z\",\"tool\":\"init\"}\n{\"call_id\":\"remote_only\",\"started_at\":\"2025-01-01T11:00:00Z\",\"tool\":\"remote\"}",
)
.unwrap();
support::git_ok(other.path(), &["add", "."]);
support::git_ok(
other.path(),
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"remote tool log",
],
);
let remote_head = support::git_stdout(other.path(), &["rev-parse", "HEAD"]);
support::git_ok(other.path(), &["push"]);
let sync = GitSync::new(local.path(), None).unwrap();
sync.sync("test-mount").await.unwrap();
let local_head = support::git_stdout(local.path(), &["rev-parse", "HEAD"]);
assert_eq!(local_head, remote_head);
let auto_sync_count: i32 = support::git_stdout(
local.path(),
&[
"rev-list",
"--count",
"--grep=^Auto-sync thoughts for test-mount$",
"HEAD",
],
)
.parse()
.unwrap();
assert_eq!(auto_sync_count, 0);
let merged = fs::read_to_string(&jsonl_path).unwrap();
assert!(merged.contains("remote_only"));
}
#[ignore = "integration test - run with: just test-integration"]
#[tokio::test]
async fn sync_retries_once_after_race_like_push_rejection() {
if !should_run_integration_tests() {
eprintln!("skipping integration test; set THOUGHTS_INTEGRATION_TESTS=1");
return;
}
let remote = TempDir::new().unwrap();
support::git_ok(remote.path(), &["init", "--bare"]);
let local = TempDir::new().unwrap();
support::git_ok(local.path(), &["init"]);
fs::write(local.path().join("base.txt"), "base\n").unwrap();
support::git_ok(local.path(), &["add", "."]);
support::git_ok(
local.path(),
&[
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"initial",
],
);
support::git_ok(local.path(), &["branch", "-M", "main"]);
support::git_ok(
local.path(),
&["remote", "add", "origin", remote.path().to_str().unwrap()],
);
support::git_ok(local.path(), &["push", "-u", "origin", "main"]);
ensure_remote_head_points_to_main(&remote);
let hook_path = remote.path().join("hooks/pre-receive");
let marker_path = remote.path().join("hooks/race_once_marker");
fs::write(
&hook_path,
format!(
"#!/bin/sh\nMARKER='{}'\nif [ ! -f \"$MARKER\" ]; then\n touch \"$MARKER\"\n echo 'remote: ! [rejected] HEAD -> main (fetch first)' >&2\n echo 'remote: error: failed to push some refs' >&2\n exit 1\nfi\nexit 0\n",
marker_path.display()
),
)
.unwrap();
#[cfg(unix)]
{
let mut perms = fs::metadata(&hook_path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&hook_path, perms).unwrap();
}
fs::write(local.path().join("retry.txt"), "retry\n").unwrap();
let sync = GitSync::new(local.path(), None).unwrap();
sync.sync("test-mount").await.unwrap();
assert!(
marker_path.exists(),
"first push attempt should have been rejected"
);
let local_head = support::git_stdout(local.path(), &["rev-parse", "HEAD"]);
let remote_head = support::git_stdout(remote.path(), &["rev-parse", "refs/heads/main"]);
assert_eq!(local_head, remote_head);
}