diff --git a/tests/integration/graphite/graphite_test_harness.rs b/tests/integration/graphite/graphite_test_harness.rs new file mode 100644 index 0000000000..d274189957 --- /dev/null +++ b/tests/integration/graphite/graphite_test_harness.rs @@ -0,0 +1,717 @@ +//! Shared harness for driving the Graphite (`gt`) CLI inside a `TestRepo`. +//! +//! `gt` shells out to `git` for commits, rebases, and pushes. To make those +//! invocations visible to the git-ai daemon, every `gt` call runs with a shim +//! directory first on `PATH`; the shim logs tracked git invocations and then +//! delegates to real git. After the `gt` process exits, the logged sessions are +//! handed to `TestRepo::sync_daemon_external_completion_sessions` so assertions +//! observe a fully-drained daemon. + +use crate::repos::test_repo::{TestRepo, real_git_executable}; + +use serde::Deserialize; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::OnceLock; +use std::time::{SystemTime, UNIX_EPOCH}; + +const DETERMINISTIC_GIT_NAME: &str = "Graphite Test"; +const DETERMINISTIC_GIT_EMAIL: &str = "graphite-test@example.com"; +const DETERMINISTIC_GIT_DATE: &str = "2000-01-01T00:00:00+00:00"; + +/// Resolve and cache the absolute path to the `gt` CLI binary. +/// On Windows, npm installs `gt` as `gt.cmd` (a batch wrapper), which Rust's +/// `Command::new("gt")` cannot find because it only searches for `.exe` files. +/// By resolving the full path once via `where`/`which`, we can use the absolute +/// path in all subsequent Command invocations. +static GT_BINARY_PATH: OnceLock> = OnceLock::new(); + +pub fn find_gt_binary() -> Option<&'static str> { + GT_BINARY_PATH + .get_or_init(|| { + #[cfg(windows)] + let which_cmd = "where"; + #[cfg(not(windows))] + let which_cmd = "which"; + + let output = Command::new(which_cmd).arg("gt").output().ok()?; + if output.status.success() { + let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); + // `where` on Windows may return multiple lines; take the first. + let first = path.lines().next().unwrap_or(&path).to_string(); + if first.is_empty() { None } else { Some(first) } + } else { + None + } + }) + .as_deref() +} + +/// Guard that skips the test when `gt` is not installed (local dev), +/// or panics when running in CI (where `gt` MUST be available). +macro_rules! require_gt { + () => {{ + if $crate::graphite::graphite_test_harness::find_gt_binary().is_none() { + if std::env::var("CI").is_ok() { + panic!( + "Graphite CLI (`gt`) is required in CI but was not found. \ + Install it with: npm install -g @withgraphite/graphite-cli@stable" + ); + } else { + eprintln!("SKIP: `gt` CLI not found — skipping Graphite test"); + return; + } + } + }}; +} + +pub(crate) use require_gt; + +/// Create a shim directory containing a `git` symlink (or copy on Windows) +/// that points to the test-only git shim binary. The shim logs tracked git +/// invocations for external tools like Graphite, then delegates to real git. +static GT_GIT_SHIM_DIR: OnceLock = OnceLock::new(); + +fn gt_git_shim_dir() -> &'static PathBuf { + GT_GIT_SHIM_DIR.get_or_init(|| { + let shim_binary = PathBuf::from(env!("CARGO_BIN_EXE_git-ai-test-git-shim")); + let shim_dir = + std::env::temp_dir().join(format!("git-ai-gt-git-shim-{}", std::process::id())); + std::fs::create_dir_all(&shim_dir).expect("create shim dir"); + + #[cfg(unix)] + { + let link_path = shim_dir.join("git"); + // Remove stale symlink if it exists + let _ = std::fs::remove_file(&link_path); + std::os::unix::fs::symlink(shim_binary, &link_path).expect("create git symlink"); + } + + #[cfg(windows)] + { + let link_path = shim_dir.join("git.exe"); + let _ = std::fs::remove_file(&link_path); + std::fs::copy(shim_binary, &link_path).expect("copy shim as git.exe"); + } + + shim_dir + }) +} + +/// Build a PATH string that has the shim directory first, +/// followed by the original system PATH. +fn gt_git_path() -> String { + let shim_dir = gt_git_shim_dir(); + let original_path = std::env::var("PATH").unwrap_or_default(); + let sep = if cfg!(windows) { ";" } else { ":" }; + format!("{}{}{}", shim_dir.display(), sep, original_path) +} + +fn gt_git_target() -> String { + real_git_executable().to_string() +} + +fn new_gt_started_log_path() -> PathBuf { + std::env::temp_dir().join(format!( + "git-ai-gt-started-{}-{}.jsonl", + std::process::id(), + git_ai::uuid::generate_v4() + )) +} + +#[derive(Deserialize)] +struct GtStartedLogEntry { + #[serde(default)] + test_sync_session: Option, +} + +fn gt_started_sessions(log_path: &PathBuf) -> Vec { + let Ok(content) = std::fs::read_to_string(log_path) else { + return Vec::new(); + }; + + let mut sessions = Vec::new(); + for (idx, line) in content.lines().enumerate() { + if line.trim().is_empty() { + continue; + } + let entry: GtStartedLogEntry = serde_json::from_str(line).unwrap_or_else(|error| { + panic!( + "failed to parse Graphite shim start log entry {} in {}: {}", + idx + 1, + log_path.display(), + error + ) + }); + if let Some(session) = entry.test_sync_session { + sessions.push(session); + } + } + + sessions +} + +fn apply_deterministic_git_env(command: &mut Command, repo: &TestRepo) { + command.env("HOME", repo.test_home_path()); + command.env( + "GIT_CONFIG_GLOBAL", + repo.test_home_path().join(".gitconfig"), + ); + command.env("XDG_CONFIG_HOME", repo.test_home_path().join(".config")); + + command.env("GIT_AUTHOR_NAME", DETERMINISTIC_GIT_NAME); + command.env("GIT_AUTHOR_EMAIL", DETERMINISTIC_GIT_EMAIL); + command.env("GIT_AUTHOR_DATE", DETERMINISTIC_GIT_DATE); + command.env("GIT_COMMITTER_NAME", DETERMINISTIC_GIT_NAME); + command.env("GIT_COMMITTER_EMAIL", DETERMINISTIC_GIT_EMAIL); + command.env("GIT_COMMITTER_DATE", DETERMINISTIC_GIT_DATE); + command.env("TZ", "UTC"); + command.env("LC_ALL", "C"); + command.env("LANG", "C"); + command.env("GIT_CONFIG_NOSYSTEM", "1"); + command.env("GIT_TERMINAL_PROMPT", "0"); +} + +pub fn assert_head_branch(repo: &TestRepo, expected_branch: &str) { + let current = repo.current_branch(); + assert_eq!( + current, expected_branch, + "expected HEAD branch {expected_branch}, found {current}" + ); +} + +pub fn assert_worktree_clean(repo: &TestRepo) { + let status = repo + .git(&["status", "--porcelain"]) + .expect("git status should succeed"); + assert!( + status.trim().is_empty(), + "expected clean worktree, found:\n{}", + status + ); +} + +/// Execute a `gt` command inside the given TestRepo directory. +/// +/// The key insight: `gt` calls `git` internally for commits, rebases, etc. +/// By prepending a shim directory to PATH, all of `gt`'s git operations emit +/// trace2 metadata to the daemon and can be synchronized by the test harness. +/// +/// Passes `--no-interactive` to avoid prompts. +/// Returns Ok(stdout+stderr) on success, Err(stderr) on failure. +pub fn gt(repo: &TestRepo, args: &[&str]) -> Result { + let gt_path = + find_gt_binary().expect("gt binary not found; require_gt! should have been called"); + + // On Windows, npm installs `gt` as `gt.cmd` (a batch wrapper). Rust's + // Command cannot execute `.cmd` files directly — they must be run through + // `cmd.exe /C`. On Unix, we invoke the binary directly. + #[cfg(windows)] + let mut command = { + let mut c = Command::new("cmd"); + c.args(["/C", gt_path]); + c + }; + #[cfg(not(windows))] + let mut command = Command::new(gt_path); + + command + .current_dir(repo.path()) + .args(args) + .arg("--no-interactive"); + + let started_log_path = new_gt_started_log_path(); + + // Put the test shim first in PATH so `gt` calls it instead of raw git. The + // shim logs tracked git invocations and then delegates to real git. + command.env("PATH", gt_git_path()); + command.env("GIT_AI_TEST_GIT_SHIM_TARGET", gt_git_target()); + command.env( + "GIT_AI_TEST_GIT_SHIM_FALLBACK_TARGET", + real_git_executable(), + ); + command.env("GIT_AI_TEST_SYNC_START_LOG", &started_log_path); + + // Set deterministic git metadata + isolated config/locale across all gt invocations. + apply_deterministic_git_env(&mut command, repo); + + let trace_socket = repo.daemon_trace_socket_path(); + let nesting = std::env::var("GIT_AI_TEST_TRACE2_NESTING").unwrap_or_else(|_| "0".to_string()); + command.env( + "GIT_TRACE2_EVENT", + git_ai::daemon::DaemonConfig::trace2_event_target_for_path(&trace_socket), + ); + command.env("GIT_TRACE2_EVENT_NESTING", nesting); + command.env("GIT_AI_TEST_DB_PATH", repo.test_db_path().to_str().unwrap()); + command.env("GITAI_TEST_DB_PATH", repo.test_db_path().to_str().unwrap()); + + if let Some(patch) = repo.config_patch_json() { + command.env("GIT_AI_TEST_CONFIG_PATCH", patch); + } + + // Isolate Graphite's config and data directories per test to prevent + // parallel test corruption of config files and the nuxes SQLite database + // (race condition in CI). + command.env("XDG_CONFIG_HOME", repo.test_home_path().join(".config")); + command.env( + "XDG_DATA_HOME", + repo.test_home_path().join(".local").join("share"), + ); + // Windows equivalents for Graphite config and data isolation. + // USERPROFILE is read by Node.js os.homedir() on Windows (not HOME). + command.env("USERPROFILE", repo.test_home_path()); + command.env( + "LOCALAPPDATA", + repo.test_home_path().join("AppData").join("Local"), + ); + command.env( + "APPDATA", + repo.test_home_path().join("AppData").join("Roaming"), + ); + + let output = command + .output() + .unwrap_or_else(|e| panic!("Failed to execute gt {:?}: {}", args, e)); + + let sessions = gt_started_sessions(&started_log_path); + repo.sync_daemon_external_completion_sessions(&sessions); + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).to_string(); + + if output.status.success() { + let combined = if stdout.is_empty() { + stderr + } else if stderr.is_empty() { + stdout + } else { + format!("{}{}", stdout, stderr) + }; + Ok(combined) + } else { + let combined_err = format!("{}{}", stderr, stdout); + Err(combined_err) + } +} + +/// Initialize Graphite in a TestRepo (sets trunk to "main"). +pub fn gt_init(repo: &TestRepo) { + gt(repo, &["init", "--trunk", "main"]).expect("gt init should succeed"); +} + +/// Create an initial commit so the repo is not empty (required for most gt operations). +pub fn setup_initial_commit(repo: &TestRepo) { + let mut readme = repo.filename("README.md"); + readme.set_contents(crate::lines!["# Test Repo"]); + repo.stage_all_and_commit("initial commit") + .expect("initial commit should succeed"); +} + +// --------------------------------------------------------------------------- +// Remote-backed harness +// --------------------------------------------------------------------------- + +/// The shared GitHub repository the remote Graphite tests run against. +/// Override with `GRAPHITE_TEST_REPO=owner/name` to point at a different one. +const DEFAULT_GRAPHITE_TEST_REPO: &str = "jumboblip/aug-6"; + +/// Branch namespace for everything these tests push, so orphans from a crashed +/// run are trivially identifiable and sweepable by `cleanup-test-branches.sh`. +const BRANCH_NAMESPACE: &str = "gtai"; + +fn graphite_test_repo() -> String { + std::env::var("GRAPHITE_TEST_REPO").unwrap_or_else(|_| DEFAULT_GRAPHITE_TEST_REPO.to_string()) +} + +static GH_CLI_PRESENT: OnceLock = OnceLock::new(); + +/// Whether the `gh` binary exists. Deliberately does NOT check `gh auth status`: +/// the harness supplies its own `GH_TOKEN`, and the developer's ambient `gh` +/// login is typically a different account than the one owning the test repo. +fn is_gh_binary_present() -> bool { + *GH_CLI_PRESENT.get_or_init(|| { + Command::new("gh") + .arg("--version") + .output() + .is_ok_and(|output| output.status.success()) + }) +} + +/// A `TestRepo` cloned from the shared GitHub test repo, wired for Graphite's +/// remote commands. +/// +/// Every test clones into its own temp directory and namespaces everything it +/// pushes — including its own trunk — under `gtai/--/`, so +/// concurrent runs cannot collide on the remote and the shared repo's real +/// default branch is never written to. +/// +/// A test can add more clones of the same repo with [`GraphiteTestRepo::peer`], +/// which share the namespace. Only the clone that created the namespace tears it +/// down; teardown enumerates the remote directly, so branches pushed by any clone +/// are swept. +pub struct GraphiteTestRepo { + pub repo: TestRepo, + test_repo: String, + branch_prefix: String, + /// Per-test trunk, so tests can advance trunk without racing each other. + trunk: String, + /// GitHub token used for the tokenized `origin` URL and all `gh` calls. + github_token: String, + /// False for peer clones: cleaning up from every clone would double-delete. + owns_cleanup: bool, +} + +impl GraphiteTestRepo { + /// Clone the shared test repo, authenticate Graphite, and branch a per-test + /// trunk off the repo's default branch. + /// + /// Returns `None` when the environment cannot support a remote test. In CI + /// a missing prerequisite is a hard failure instead, matching `require_gt!`. + pub fn new(test_name: &str) -> Option { + let github_token = check_remote_prerequisites()?; + let test_repo = graphite_test_repo(); + let repo = clone_and_authenticate(&test_repo, &github_token); + + let branch_prefix = new_branch_prefix(test_name); + let trunk = format!("{branch_prefix}/trunk"); + + // The clone is already on the repo's default branch, so branching here + // roots the per-test trunk at the real trunk without having to name it. + repo.git(&["checkout", "-b", &trunk]) + .expect("creating the per-test trunk should succeed"); + repo.git(&["push", "-u", "origin", &trunk]) + .expect("pushing the per-test trunk should succeed"); + + let harness = Self { + repo, + test_repo, + branch_prefix, + trunk, + github_token, + owns_cleanup: true, + }; + harness.init_graphite(); + + Some(harness) + } + + /// An additional clone of the same repo, sharing this test's branch + /// namespace and trunk. Used to make changes that reach the first clone only + /// through the remote. + /// + /// The peer owns no cleanup and must not outlive its parent. + pub fn peer(&self) -> Self { + let repo = clone_and_authenticate(&self.test_repo, &self.github_token); + + // The per-test trunk already exists on the remote; checking it out by + // name sets up tracking against `origin/`. + repo.git(&["checkout", &self.trunk]) + .expect("checking out the per-test trunk should succeed"); + + let peer = Self { + repo, + test_repo: self.test_repo.clone(), + branch_prefix: self.branch_prefix.clone(), + trunk: self.trunk.clone(), + github_token: self.github_token.clone(), + owns_cleanup: false, + }; + peer.init_graphite(); + + peer + } + + /// Namespaced branch name, unique to this test run. + pub fn branch(&self, suffix: &str) -> String { + format!("{}/{}", self.branch_prefix, suffix) + } + + /// This test's trunk branch. + pub fn trunk(&self) -> &str { + &self.trunk + } + + /// Namespaced path inside the repo, so files from concurrent runs never + /// collide when their pull requests land on trunk. + pub fn scoped_path(&self, filename: &str) -> String { + format!("{}/{}", self.branch_prefix, filename) + } + + /// Run a `gt` command in this repo. See the free [`gt`] function. + pub fn gt(&self, args: &[&str]) -> Result { + gt(&self.repo, args) + } + + /// Push the current stack and open a pull request for each branch in it. + pub fn submit(&self) -> Result { + // `--no-ai` keeps PR titles deterministic and skips a Graphite round-trip. + self.gt(&["submit", "--no-edit", "--no-ai", "--publish"]) + } + + /// Pull `branch` (and its ancestors) down from the remote, keeping local + /// changes that adopting the remote would discard. + pub fn get(&self, branch: &str) -> Result { + self.gt(&["get", branch]) + } + + /// Pull `branch` down, overwriting the local branch with the remote as the + /// source of truth even when that discards local commits. + pub fn get_force(&self, branch: &str) -> Result { + self.gt(&["get", branch, "--force"]) + } + + /// The open pull request number for a branch, if one exists. + pub fn pr_number_for_branch(&self, branch: &str) -> Option { + let output = self + .gh(&[ + "pr", + "list", + "--repo", + &self.test_repo, + "--head", + branch, + "--json", + "number", + "--jq", + ".[0].number", + ]) + .ok()?; + + let number = output.trim().to_string(); + if number.is_empty() { + None + } else { + Some(number) + } + } + + /// Point Graphite at the per-test trunk. Shared by `new` and `peer`. + fn init_graphite(&self) { + self.gt(&["init", "--trunk", &self.trunk]) + .expect("gt init should succeed"); + } + + /// Run a `gh` command against the shared test repo. + /// + /// `GH_TOKEN` and `GH_CONFIG_DIR` are passed explicitly rather than + /// inherited: `TestRepo` calls `ensure_isolated_process_home()`, which + /// repoints `HOME` process-wide, so an inherited-env `gh` would look for + /// `hosts.yml` in a throwaway home and fail to authenticate. + fn gh(&self, args: &[&str]) -> Result { + let output = Command::new("gh") + .args(args) + .current_dir(self.repo.path()) + .env("GH_TOKEN", &self.github_token) + .env("GH_CONFIG_DIR", self.repo.test_home_path().join("gh")) + .env("GH_PROMPT_DISABLED", "1") + .env("NO_COLOR", "1") + .output() + .map_err(|error| format!("failed to execute gh {args:?}: {error}"))?; + + if !output.status.success() { + return Err(format!( + "gh {args:?} failed:\n{}", + String::from_utf8_lossy(&output.stderr) + )); + } + + Ok(String::from_utf8_lossy(&output.stdout).to_string()) + } + + /// Every branch under this test's namespace that currently exists on the + /// remote. + /// + /// Asks the remote rather than reading local remote-tracking refs, so + /// branches pushed by a peer clone — which this clone never fetched — are + /// still torn down. + fn remote_branches(&self) -> Vec { + let Ok(output) = self.repo.git(&[ + "ls-remote", + "--heads", + "origin", + &format!("refs/heads/{}/*", self.branch_prefix), + ]) else { + return Vec::new(); + }; + + output + .lines() + .filter_map(|line| line.split_once('\t')) + .filter_map(|(_sha, reference)| reference.trim().strip_prefix("refs/heads/")) + .map(str::to_string) + .collect() + } + + /// Open pull requests whose head branch is in this test's namespace. + /// + /// One API call for all of them, then filtered locally. + fn open_pr_numbers(&self) -> Vec { + let Ok(output) = self.gh(&[ + "pr", + "list", + "--repo", + &self.test_repo, + "--state", + "open", + "--limit", + "100", + "--json", + "number,headRefName", + "--jq", + ".[] | \"\\(.number)\\t\\(.headRefName)\"", + ]) else { + return Vec::new(); + }; + + let namespace = format!("{}/", self.branch_prefix); + output + .lines() + .filter_map(|line| line.split_once('\t')) + .filter(|(_number, head)| head.trim().starts_with(&namespace)) + .map(|(number, _head)| number.trim().to_string()) + .collect() + } +} + +impl Drop for GraphiteTestRepo { + fn drop(&mut self) { + if !self.owns_cleanup { + return; + } + + if std::env::var("GIT_AI_TEST_NO_CLEANUP").is_ok() { + eprintln!( + "⚠️ Cleanup disabled — branches under {} left on {}", + self.branch_prefix, self.test_repo + ); + return; + } + + // Close PRs before deleting their branches: GitHub's behavior when a head + // branch disappears out from under an open PR is not something teardown + // should depend on. + for pr_number in self.open_pr_numbers() { + if let Err(error) = self.gh(&["pr", "close", &pr_number, "--repo", &self.test_repo]) { + eprintln!("⚠️ Failed to close PR #{pr_number}: {error}"); + } + } + + let branches = self.remote_branches(); + if branches.is_empty() { + return; + } + + // One push deletes every branch — constant-time teardown rather than a + // spawn per branch. + // + // `--delete` rather than `:refs/heads/` refspecs is deliberate: + // `apply_push_side_effect` (src/daemon.rs) skips its authorship-notes + // push when it sees `--delete`, so teardown does not pile onto the + // shared `refs/notes/ai` ref that these tests already contend for. + let mut args = vec!["push", "origin", "--delete"]; + args.extend(branches.iter().map(String::as_str)); + + if let Err(error) = self.repo.git(&args) { + eprintln!( + "⚠️ Failed to delete remote branches {}: {error}\n Manual cleanup required on {}", + branches.join(", "), + self.test_repo + ); + } + } +} + +/// Clone the test repo into a fresh temp workspace and authenticate Graphite +/// against it. +fn clone_and_authenticate(test_repo: &str, github_token: &str) -> TestRepo { + let workspace = std::env::temp_dir().join(format!( + "git-ai-gt-remote-{}-{}", + std::process::id(), + git_ai::uuid::generate_v4() + )); + clone_test_repo(test_repo, github_token, &workspace); + + let repo = TestRepo::new_at_path(&workspace); + + // Authenticate through the gt() runner so the credential lands in the + // per-test XDG_CONFIG_HOME rather than the developer's real gt config. + let graphite_token = + std::env::var("GRAPHITE_TEST_TOKEN").expect("checked by check_remote_prerequisites"); + gt(&repo, &["auth", "--token", &graphite_token]).expect("gt auth should succeed"); + + repo +} + +/// Verify everything a remote test needs, returning the GitHub token on success. +/// +/// Missing prerequisites skip the test locally but fail it in CI, where they +/// always indicate a misconfigured workflow. +fn check_remote_prerequisites() -> Option { + if find_gt_binary().is_none() { + return skip_or_panic("Graphite CLI (`gt`) not found"); + } + + if !is_gh_binary_present() { + return skip_or_panic("GitHub CLI (`gh`) not found"); + } + + if std::env::var("GRAPHITE_TEST_TOKEN").is_err() { + return skip_or_panic("GRAPHITE_TEST_TOKEN is not set"); + } + + match std::env::var("GRAPHITE_TEST_GH_TOKEN") { + Ok(token) if !token.is_empty() => Some(token), + _ => skip_or_panic("GRAPHITE_TEST_GH_TOKEN is not set"), + } +} + +fn skip_or_panic(reason: &str) -> Option { + if std::env::var("CI").is_ok() { + panic!("Graphite remote test cannot run in CI: {reason}"); + } + eprintln!("SKIP: {reason} — skipping Graphite remote test"); + None +} + +/// Clone the shared test repo with a tokenized `origin` URL. +/// +/// The token has to live in the remote URL: the per-test `HOME` and +/// `GIT_CONFIG_GLOBAL` are throwaway, so there is no credential helper for +/// either `git push` or the pushes `gt submit` makes internally. +fn clone_test_repo(test_repo: &str, token: &str, destination: &Path) { + let url = format!("https://x-access-token:{token}@github.com/{test_repo}.git"); + + let output = Command::new(real_git_executable()) + .args(["clone", &url, destination.to_str().unwrap()]) + // Fail fast on a bad token instead of blocking on a credential prompt, + // and ignore any system gitconfig that might rewrite the remote URL. + .env("GIT_TERMINAL_PROMPT", "0") + .env("GIT_CONFIG_NOSYSTEM", "1") + .output() + .unwrap_or_else(|error| panic!("failed to execute git clone: {error}")); + + assert!( + output.status.success(), + "failed to clone {test_repo}:\n{}", + // Redact the token so a clone failure cannot leak it into test output. + String::from_utf8_lossy(&output.stderr).replace(token, "***"), + ); +} + +fn new_branch_prefix(test_name: &str) -> String { + let sanitized = test_name + .trim_start_matches("test_") + .chars() + .map(|c| if c.is_alphanumeric() { c } else { '-' }) + .collect::(); + + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + format!( + "{BRANCH_NAMESPACE}/{sanitized}-{}-{timestamp}", + std::process::id() + ) +} diff --git a/tests/integration/graphite.rs b/tests/integration/graphite/local_ops.rs similarity index 72% rename from tests/integration/graphite.rs rename to tests/integration/graphite/local_ops.rs index e6d1f3efe3..149629a7b1 100644 --- a/tests/integration/graphite.rs +++ b/tests/integration/graphite/local_ops.rs @@ -1,362 +1,55 @@ -/// Graphite (`gt` CLI) test suite for git-ai attribution preservation. -/// -/// These tests verify that git-ai attribution (line-level blame tracking of AI vs human authorship) -/// is correctly preserved across all local Graphite CLI operations. -/// -/// ## Requirements -/// - The `gt` CLI must be installed and available in PATH -/// - When the `CI` environment variable is set, tests will FAIL if `gt` is not available -/// - When not in CI, tests will be SKIPPED if `gt` is not available -/// -/// ## Graphite's `commit-tree` + `update-ref` plumbing path -/// -/// Graphite's restack/move/absorb/split operations internally use `git commit-tree` + -/// `git update-ref` (low-level plumbing commands) instead of `git rebase`. -/// -/// git-ai receives Graphite's `update-ref` trace2 events, detects the -/// non-fast-forward rewrite, and remaps authorship notes to the new commit SHAs. -/// This covers the core operations: restack, move, modify (with child restacking), -/// and full stack workflows. -/// -/// Remaining known issues (still `#[ignore]`): -/// - `gt absorb` and `gt split --by-file` lose attribution (update-ref hook cannot -/// reconstruct the mapping for these more complex rewrite patterns) -/// - `gt delete --force` and `gt undo` require interactive mode even with `--no-interactive` -/// -/// ## Commands NOT tested (require GitHub authentication / remote): -/// - `gt submit` - Pushes to GitHub, creates/updates PRs -/// - `gt sync` - Syncs branches with remote -/// - `gt get` - Syncs branches from remote -/// - `gt merge` - Merges PRs via Graphite -/// - `gt pr` - Opens PR page in browser -/// - `gt dash` - Opens Graphite dashboard -/// - `gt auth` - Authentication -/// - `gt feedback` - Sends feedback to Graphite team -/// - `gt freeze` / `gt unfreeze` - Primarily useful with remote sync -/// - `gt reorder` - Requires interactive editor -/// - `gt revert` - Experimental, requires specific trunk commit setup -/// - `gt split --by-commit` / `gt split --by-hunk` - Requires interactive input -/// -/// ## Commands NOT tested (require interactive terminal): -/// - `gt undo` - Requires interactive mode even with `--no-interactive` flag -/// -/// ## Commands tested: -/// - `gt init` - Initialize Graphite in a repo -/// - `gt create` - Create new branch with commit -/// - `gt modify` - Amend/new commit with automatic restack -/// - `gt squash` - Squash all commits in branch into one -/// - `gt restack` - Rebase stack to ensure parent lineage -/// - `gt fold` - Fold branch into parent -/// - `gt move` - Move branch to new parent -/// - `gt split --by-file` - Split branch by file (KNOWN_ISSUE: loses attribution) -/// - `gt absorb` - Absorb staged changes into stack (KNOWN_ISSUE: loses attribution) -/// - `gt checkout` / `gt up` / `gt down` / `gt top` / `gt bottom` - Navigation -/// - `gt delete` - Delete branch, restack children (KNOWN_ISSUE: requires interactive mode) -/// - `gt pop` - Delete branch, retain working tree -/// - `gt rename` - Rename branch -/// - `gt track` / `gt untrack` - Metadata tracking +//! Local-only Graphite (`gt`) operations — no remote or GitHub auth required. +//! +//! These tests verify that git-ai attribution (line-level blame tracking of AI vs human +//! authorship) is correctly preserved across Graphite CLI operations that run entirely +//! against the local repository. Remote-backed operations (`gt submit`, `gt sync`, +//! `gt get`, `gt merge`) live in `super::remote_ops`. +//! +//! ## Requirements +//! - The `gt` CLI must be installed and available in PATH +//! - When the `CI` environment variable is set, tests will FAIL if `gt` is not available +//! - When not in CI, tests will be SKIPPED if `gt` is not available +//! +//! ## Graphite's `commit-tree` + `update-ref` plumbing path +//! +//! Graphite's restack/move/absorb/split operations internally use `git commit-tree` + +//! `git update-ref` (low-level plumbing commands) instead of `git rebase`. +//! +//! git-ai receives Graphite's `update-ref` trace2 events, detects the +//! non-fast-forward rewrite, and remaps authorship notes to the new commit SHAs. +//! This covers the core operations: restack, move, modify (with child restacking), +//! and full stack workflows. +//! +//! Remaining known issues (still `#[ignore]`): +//! - `gt absorb` and `gt split --by-file` lose attribution (update-ref hook cannot +//! reconstruct the mapping for these more complex rewrite patterns) +//! - `gt delete --force` and `gt undo` require interactive mode even with `--no-interactive` +//! +//! ## Commands NOT tested here (require interactive terminal): +//! - `gt undo` - Requires interactive mode even with `--no-interactive` flag +//! - `gt reorder` - Requires interactive editor +//! - `gt split --by-commit` / `gt split --by-hunk` - Requires interactive input +//! +//! ## Commands tested: +//! - `gt init` - Initialize Graphite in a repo +//! - `gt create` - Create new branch with commit +//! - `gt modify` - Amend/new commit with automatic restack +//! - `gt squash` - Squash all commits in branch into one +//! - `gt restack` - Rebase stack to ensure parent lineage +//! - `gt fold` - Fold branch into parent +//! - `gt move` - Move branch to new parent +//! - `gt split --by-file` - Split branch by file (KNOWN_ISSUE: loses attribution) +//! - `gt absorb` - Absorb staged changes into stack (KNOWN_ISSUE: loses attribution) +//! - `gt checkout` / `gt up` / `gt down` / `gt top` / `gt bottom` - Navigation +//! - `gt delete` - Delete branch, restack children (KNOWN_ISSUE: requires interactive mode) +//! - `gt pop` - Delete branch, retain working tree +//! - `gt rename` - Rename branch +//! - `gt track` / `gt untrack` - Metadata tracking +use super::graphite_test_harness::{ + assert_head_branch, assert_worktree_clean, gt, gt_init, require_gt, setup_initial_commit, +}; use crate::repos::test_file::ExpectedLineExt; -use crate::repos::test_repo::{TestRepo, real_git_executable}; - -use serde::Deserialize; -use std::path::PathBuf; -use std::process::Command; -use std::sync::OnceLock; - -const DETERMINISTIC_GIT_NAME: &str = "Graphite Test"; -const DETERMINISTIC_GIT_EMAIL: &str = "graphite-test@example.com"; -const DETERMINISTIC_GIT_DATE: &str = "2000-01-01T00:00:00+00:00"; - -// --------------------------------------------------------------------------- -// Helper utilities -// --------------------------------------------------------------------------- - -/// Resolve and cache the absolute path to the `gt` CLI binary. -/// On Windows, npm installs `gt` as `gt.cmd` (a batch wrapper), which Rust's -/// `Command::new("gt")` cannot find because it only searches for `.exe` files. -/// By resolving the full path once via `where`/`which`, we can use the absolute -/// path in all subsequent Command invocations. -static GT_BINARY_PATH: OnceLock> = OnceLock::new(); - -fn find_gt_binary() -> Option<&'static str> { - GT_BINARY_PATH - .get_or_init(|| { - #[cfg(windows)] - let which_cmd = "where"; - #[cfg(not(windows))] - let which_cmd = "which"; - - let output = Command::new(which_cmd).arg("gt").output().ok()?; - if output.status.success() { - let path = String::from_utf8_lossy(&output.stdout).trim().to_string(); - // `where` on Windows may return multiple lines; take the first. - let first = path.lines().next().unwrap_or(&path).to_string(); - if first.is_empty() { None } else { Some(first) } - } else { - None - } - }) - .as_deref() -} - -/// Guard that skips the test when `gt` is not installed (local dev), -/// or panics when running in CI (where `gt` MUST be available). -macro_rules! require_gt { - () => {{ - if find_gt_binary().is_none() { - if std::env::var("CI").is_ok() { - panic!( - "Graphite CLI (`gt`) is required in CI but was not found. \ - Install it with: npm install -g @withgraphite/graphite-cli@stable" - ); - } else { - eprintln!("SKIP: `gt` CLI not found — skipping Graphite test"); - return; - } - } - }}; -} - -/// Create a shim directory containing a `git` symlink (or copy on Windows) -/// that points to the test-only git shim binary. The shim logs tracked git -/// invocations for external tools like Graphite, then delegates to real git. -static GT_GIT_SHIM_DIR: OnceLock = OnceLock::new(); - -fn gt_git_shim_dir() -> &'static PathBuf { - GT_GIT_SHIM_DIR.get_or_init(|| { - let shim_binary = PathBuf::from(env!("CARGO_BIN_EXE_git-ai-test-git-shim")); - let shim_dir = - std::env::temp_dir().join(format!("git-ai-gt-git-shim-{}", std::process::id())); - std::fs::create_dir_all(&shim_dir).expect("create shim dir"); - - #[cfg(unix)] - { - let link_path = shim_dir.join("git"); - // Remove stale symlink if it exists - let _ = std::fs::remove_file(&link_path); - std::os::unix::fs::symlink(shim_binary, &link_path).expect("create git symlink"); - } - - #[cfg(windows)] - { - let link_path = shim_dir.join("git.exe"); - let _ = std::fs::remove_file(&link_path); - std::fs::copy(shim_binary, &link_path).expect("copy shim as git.exe"); - } - - shim_dir - }) -} - -/// Build a PATH string that has the shim directory first, -/// followed by the original system PATH. -fn gt_git_path() -> String { - let shim_dir = gt_git_shim_dir(); - let original_path = std::env::var("PATH").unwrap_or_default(); - let sep = if cfg!(windows) { ";" } else { ":" }; - format!("{}{}{}", shim_dir.display(), sep, original_path) -} - -fn gt_git_target() -> String { - real_git_executable().to_string() -} - -fn new_gt_started_log_path() -> PathBuf { - std::env::temp_dir().join(format!( - "git-ai-gt-started-{}-{}.jsonl", - std::process::id(), - git_ai::uuid::generate_v4() - )) -} - -#[derive(Deserialize)] -struct GtStartedLogEntry { - #[serde(default)] - test_sync_session: Option, -} - -fn gt_started_sessions(log_path: &PathBuf) -> Vec { - let Ok(content) = std::fs::read_to_string(log_path) else { - return Vec::new(); - }; - - let mut sessions = Vec::new(); - for (idx, line) in content.lines().enumerate() { - if line.trim().is_empty() { - continue; - } - let entry: GtStartedLogEntry = serde_json::from_str(line).unwrap_or_else(|error| { - panic!( - "failed to parse Graphite shim start log entry {} in {}: {}", - idx + 1, - log_path.display(), - error - ) - }); - if let Some(session) = entry.test_sync_session { - sessions.push(session); - } - } - - sessions -} - -fn apply_deterministic_git_env(command: &mut Command, repo: &TestRepo) { - command.env("HOME", repo.test_home_path()); - command.env( - "GIT_CONFIG_GLOBAL", - repo.test_home_path().join(".gitconfig"), - ); - command.env("XDG_CONFIG_HOME", repo.test_home_path().join(".config")); - - command.env("GIT_AUTHOR_NAME", DETERMINISTIC_GIT_NAME); - command.env("GIT_AUTHOR_EMAIL", DETERMINISTIC_GIT_EMAIL); - command.env("GIT_AUTHOR_DATE", DETERMINISTIC_GIT_DATE); - command.env("GIT_COMMITTER_NAME", DETERMINISTIC_GIT_NAME); - command.env("GIT_COMMITTER_EMAIL", DETERMINISTIC_GIT_EMAIL); - command.env("GIT_COMMITTER_DATE", DETERMINISTIC_GIT_DATE); - command.env("TZ", "UTC"); - command.env("LC_ALL", "C"); - command.env("LANG", "C"); - command.env("GIT_CONFIG_NOSYSTEM", "1"); - command.env("GIT_TERMINAL_PROMPT", "0"); -} - -fn assert_head_branch(repo: &TestRepo, expected_branch: &str) { - let current = repo.current_branch(); - assert_eq!( - current, expected_branch, - "expected HEAD branch {expected_branch}, found {current}" - ); -} - -fn assert_worktree_clean(repo: &TestRepo) { - let status = repo - .git(&["status", "--porcelain"]) - .expect("git status should succeed"); - assert!( - status.trim().is_empty(), - "expected clean worktree, found:\n{}", - status - ); -} - -/// Execute a `gt` command inside the given TestRepo directory. -/// -/// The key insight: `gt` calls `git` internally for commits, rebases, etc. -/// By prepending a shim directory to PATH, all of `gt`'s git operations emit -/// trace2 metadata to the daemon and can be synchronized by the test harness. -/// -/// Passes `--no-interactive` to avoid prompts. -/// Returns Ok(stdout+stderr) on success, Err(stderr) on failure. -fn gt(repo: &TestRepo, args: &[&str]) -> Result { - let gt_path = - find_gt_binary().expect("gt binary not found; require_gt! should have been called"); - - // On Windows, npm installs `gt` as `gt.cmd` (a batch wrapper). Rust's - // Command cannot execute `.cmd` files directly — they must be run through - // `cmd.exe /C`. On Unix, we invoke the binary directly. - #[cfg(windows)] - let mut command = { - let mut c = Command::new("cmd"); - c.args(["/C", gt_path]); - c - }; - #[cfg(not(windows))] - let mut command = Command::new(gt_path); - - command - .current_dir(repo.path()) - .args(args) - .arg("--no-interactive"); - - let started_log_path = new_gt_started_log_path(); - - // Put the test shim first in PATH so `gt` calls it instead of raw git. The - // shim logs tracked git invocations and then delegates to real git. - command.env("PATH", gt_git_path()); - command.env("GIT_AI_TEST_GIT_SHIM_TARGET", gt_git_target()); - command.env( - "GIT_AI_TEST_GIT_SHIM_FALLBACK_TARGET", - real_git_executable(), - ); - command.env("GIT_AI_TEST_SYNC_START_LOG", &started_log_path); - - // Set deterministic git metadata + isolated config/locale across all gt invocations. - apply_deterministic_git_env(&mut command, repo); - - let trace_socket = repo.daemon_trace_socket_path(); - let nesting = std::env::var("GIT_AI_TEST_TRACE2_NESTING").unwrap_or_else(|_| "0".to_string()); - command.env( - "GIT_TRACE2_EVENT", - git_ai::daemon::DaemonConfig::trace2_event_target_for_path(&trace_socket), - ); - command.env("GIT_TRACE2_EVENT_NESTING", nesting); - command.env("GIT_AI_TEST_DB_PATH", repo.test_db_path().to_str().unwrap()); - command.env("GITAI_TEST_DB_PATH", repo.test_db_path().to_str().unwrap()); - - if let Some(patch) = repo.config_patch_json() { - command.env("GIT_AI_TEST_CONFIG_PATCH", patch); - } - - // Isolate Graphite's config and data directories per test to prevent - // parallel test corruption of config files and the nuxes SQLite database - // (race condition in CI). - command.env("XDG_CONFIG_HOME", repo.test_home_path().join(".config")); - command.env( - "XDG_DATA_HOME", - repo.test_home_path().join(".local").join("share"), - ); - // Windows equivalents for Graphite config and data isolation. - // USERPROFILE is read by Node.js os.homedir() on Windows (not HOME). - command.env("USERPROFILE", repo.test_home_path()); - command.env( - "LOCALAPPDATA", - repo.test_home_path().join("AppData").join("Local"), - ); - command.env( - "APPDATA", - repo.test_home_path().join("AppData").join("Roaming"), - ); - - let output = command - .output() - .unwrap_or_else(|e| panic!("Failed to execute gt {:?}: {}", args, e)); - - let sessions = gt_started_sessions(&started_log_path); - repo.sync_daemon_external_completion_sessions(&sessions); - - let stdout = String::from_utf8_lossy(&output.stdout).to_string(); - let stderr = String::from_utf8_lossy(&output.stderr).to_string(); - - if output.status.success() { - let combined = if stdout.is_empty() { - stderr - } else if stderr.is_empty() { - stdout - } else { - format!("{}{}", stdout, stderr) - }; - Ok(combined) - } else { - let combined_err = format!("{}{}", stderr, stdout); - Err(combined_err) - } -} - -/// Initialize Graphite in a TestRepo (sets trunk to "main"). -fn gt_init(repo: &TestRepo) { - gt(repo, &["init", "--trunk", "main"]).expect("gt init should succeed"); -} - -/// Create an initial commit so the repo is not empty (required for most gt operations). -fn setup_initial_commit(repo: &TestRepo) { - let mut readme = repo.filename("README.md"); - readme.set_contents(crate::lines!["# Test Repo"]); - repo.stage_all_and_commit("initial commit") - .expect("initial commit should succeed"); -} - +use crate::repos::test_repo::TestRepo; // =========================================================================== // Group 1: gt create — Branch creation with attribution // =========================================================================== diff --git a/tests/integration/graphite/mod.rs b/tests/integration/graphite/mod.rs new file mode 100644 index 0000000000..9cc6f6a6d9 --- /dev/null +++ b/tests/integration/graphite/mod.rs @@ -0,0 +1,4 @@ +pub mod graphite_test_harness; +pub mod local_ops; +pub mod remote_ops; +pub mod remote_sync; diff --git a/tests/integration/graphite/remote_ops.rs b/tests/integration/graphite/remote_ops.rs new file mode 100644 index 0000000000..0b25390585 --- /dev/null +++ b/tests/integration/graphite/remote_ops.rs @@ -0,0 +1,58 @@ +//! Remote-backed Graphite (`gt`) operations. +//! +//! These tests drive `gt` against a real GitHub repository (`jumboblip/aug-6` by +//! default, override with `GRAPHITE_TEST_REPO`), covering the commands the +//! local-only suite in `super::local_ops` cannot reach — chiefly `gt submit`, +//! which force-pushes the stack and opens a pull request per branch. +//! +//! They are `#[ignore]`d by default because they push branches and open pull +//! requests on a shared remote. Run them with: +//! +//! ```sh +//! export GRAPHITE_TEST_TOKEN=... # Graphite API token (app.graphite.dev/settings/cli) +//! export GRAPHITE_TEST_GH_TOKEN=... # GitHub PAT with `repo` scope on the test repo +//! ./tests/integration/graphite/scripts/run-graphite-tests.sh +//! ``` +//! +//! Remote-backed tests share one `refs/notes/ai` ref on the test repository, so +//! they all run in the `graphite_remote` serial group — see the "Why these are +//! serialized" note in `super::remote_sync`. +//! +//! Every branch is namespaced under `gtai/--` and torn +//! down when the test ends. Set `GIT_AI_TEST_NO_CLEANUP=1` to leave the pushed +//! branches and opened PRs on the remote for inspection; sweep them afterwards +//! with `scripts/cleanup-test-branches.sh`. + +use super::graphite_test_harness::GraphiteTestRepo; +use crate::repos::test_file::ExpectedLineExt; + +/// `gt submit` pushes the branch and opens a PR for it. It rewrites commits +/// through `commit-tree` + `update-ref` when the stack needs restacking first, +/// so this is the core check that attribution survives a submit. +#[test] +#[ignore] // Remote-backed - run with `cargo test --test integration graphite::remote_ops -- --ignored` +#[serial_test::serial(graphite_remote)] +fn test_gt_submit_opens_pr_and_preserves_attribution() { + let Some(remote) = GraphiteTestRepo::new("test_gt_submit_opens_pr") else { + return; + }; + + let branch = remote.branch("first"); + let mut file = remote.repo.filename(&remote.scoped_path("first.txt")); + file.set_contents(crate::lines!["human line", "ai line".ai()]); + remote + .gt(&["create", &branch, "-am", "first branch"]) + .expect("gt create should succeed"); + + file.assert_committed_lines(crate::lines!["human line".human(), "ai line".ai()]); + + remote.submit().expect("gt submit should succeed"); + + assert!( + remote.pr_number_for_branch(&branch).is_some(), + "expected an open PR for {branch}" + ); + + // Attribution must survive the restack and force-push that submit performs. + file.assert_committed_lines(crate::lines!["human line".human(), "ai line".ai()]); +} diff --git a/tests/integration/graphite/remote_sync.rs b/tests/integration/graphite/remote_sync.rs new file mode 100644 index 0000000000..7956a0f758 --- /dev/null +++ b/tests/integration/graphite/remote_sync.rs @@ -0,0 +1,428 @@ +//! `gt get` reconciliation when a branch has diverged between two clones. +//! +//! Covers every combination of local × remote divergence: +//! +//! | | remote: none | remote: rebased | remote: meaningful | +//! |---------------------|--------------|-----------------|--------------------| +//! | local: none | — (no-op) | ✔ | ✔ | +//! | local: rebased | ✔ | ✔ | ✔ | +//! | local: meaningful | ✔ | ✔ | ✔ | +//! +//! "Rebased" means the commit was replayed onto a moved trunk — same tree, new +//! SHA — which runs through Graphite's `commit-tree` + `update-ref` plumbing. +//! "Meaningful" means the tree actually changed. +//! +//! The remote side of each case is produced by a real second clone +//! ([`GraphiteTestRepo::peer`]) that submits through Graphite, so the branch +//! under test reaches the first clone only via the remote. +//! +//! ## Which side `gt get` keeps +//! +//! If there are meaningful changes both locally and remotely, then we need to +//! tell graphite which changes to "keep". If the `--force` option is provided +//! to `gt get`, the command will overwrite local changes if there are meaningful +//! remote changes. +//! +//! ## Confirmed gap: attribution does not survive adoption from the remote +//! +//! `gt get` fetches with `git fetch`, and git-ai deliberately does NOT import +//! authorship notes on `fetch` — only `clone`, `pull`, and `git-ai fetch-notes` +//! do. See `notes_sync_fetch_does_not_import_authorship_notes` in +//! `tests/notes_sync_regression.rs`. +//! +//! Running this matrix confirmed the consequence. The two tests that read the +//! *remote's* attribution both fail, and they are the only two that ever do — +//! every other case keeps the local branch, whose notes are already local: +//! +//! - `test_gt_get_adopts_meaningful_remote_when_local_unchanged` — no note +//! arrives; every AI line falls back to the committer identity. +//! - `test_gt_get_force_adopts_remote_attribution_over_meaningful_local` — worse: +//! the local note is remapped onto the adopted commit, so it attests the lines +//! this clone already knew and silently omits the line the peer added. The +//! attribution looks complete and is wrong. +//! +//! ## Why these are serialized +//! +//! Branch namespacing isolates what each test pushes, but `refs/notes/ai` is a +//! *single* ref shared by the whole repository. Every clone's daemon pushes +//! authorship notes there after a `git push`, so tests running in parallel race +//! to lock it and fail with `cannot lock ref 'refs/notes/ai': is at X but +//! expected Y` — faster than the fetch-merge-retry loop in +//! `push_authorship_notes` can recover. `#[serial(graphite_remote)]` puts every +//! remote-backed Graphite test in one group so only one pushes notes at a time. +//! +//! Run with `./tests/integration/graphite/scripts/run-graphite-tests.sh`; see +//! `super::remote_ops` for the required environment. + +use super::graphite_test_harness::GraphiteTestRepo; +use crate::repos::test_file::ExpectedLineExt; + +/// How one side of the branch changed before `gt get` reconciles the two. +#[derive(Clone, Copy, PartialEq)] +enum Divergence { + /// Untouched since it was submitted. + None, + /// Replayed onto a moved trunk: same tree, new SHA. + Rebased, + /// The tree changed — an AI line was appended. + Meaningful, +} + +const BRANCH_NAME: &str = "feature"; + +/// The file's content after `Divergence::Meaningful` has been applied to it. +fn meaningful_lines(marker: &str) -> Vec { + crate::lines![ + "human line".human(), + "ai line".ai(), + format!("{marker} ai line").as_str().ai(), + ] +} + +/// Advance this test's trunk by one commit and publish it. +/// +/// Trunk is kept identical between clones on purpose: this matrix is about the +/// *feature* branch diverging. Leaving a trunk commit unpushed instead makes +/// `gt get` refuse with "trunk could not be fast-forwarded" and reconcile +/// nothing, which tests a different — and far less natural — scenario. +fn advance_trunk(clone: &GraphiteTestRepo, marker: &str) { + clone + .gt(&["checkout", clone.trunk()]) + .expect("gt checkout trunk should succeed"); + + // Pick up trunk movement another clone already published, so this commit + // lands on top of it and the push below stays a fast-forward. A no-op unless + // the peer advanced trunk first. + clone + .repo + .git(&["pull", "--ff-only", "origin", clone.trunk()]) + .expect("pulling trunk should succeed"); + + let mut trunk_file = clone.repo.filename(&clone.scoped_path("trunk.txt")); + trunk_file.set_contents(crate::lines![format!("{marker} trunk line").as_str()]); + clone.repo.git(&["add", "-A"]).unwrap(); + clone + .repo + .commit(&format!("{marker} trunk commit")) + .expect("trunk commit should succeed"); + clone + .repo + .git(&["push", "origin", clone.trunk()]) + .expect("pushing the moved trunk should succeed"); +} + +/// Apply `divergence` to the feature branch in `clone`. +/// +/// `marker` distinguishes the local edit from the remote one so the assertion +/// can tell which side `gt get` kept. +fn apply(clone: &GraphiteTestRepo, divergence: Divergence, marker: &str) { + match divergence { + Divergence::None => {} + + Divergence::Rebased => { + // Advance trunk, then restack the feature branch onto it. The commit + // is replayed with an identical tree but a new SHA. + advance_trunk(clone, marker); + clone + .gt(&["checkout", &clone.branch(BRANCH_NAME)]) + .expect("gt checkout feature should succeed"); + clone.gt(&["restack"]).expect("gt restack should succeed"); + } + + Divergence::Meaningful => { + clone + .gt(&["checkout", &clone.branch(BRANCH_NAME)]) + .expect("gt checkout feature should succeed"); + let mut file = clone.repo.filename(&clone.scoped_path("feature.txt")); + file.set_contents(meaningful_lines(marker)); + clone.repo.git(&["add", "-A"]).unwrap(); + clone + .gt(&["modify", "-c", "-m", &format!("{marker} change")]) + .expect("gt modify should succeed"); + } + } +} + +/// Drive one cell of the matrix with the default (non-forced) `gt get`. +fn run_get_case( + test_name: &str, + local: Divergence, + remote: Divergence, +) -> Option<(GraphiteTestRepo, Result)> { + run_case(test_name, local, remote, GraphiteTestRepo::get) +} + +/// Drive one cell of the matrix with `gt get --force`, which takes the remote as +/// the source of truth even when local changes are discarded. +fn run_forced_get_case( + test_name: &str, + local: Divergence, + remote: Divergence, +) -> Option<(GraphiteTestRepo, Result)> { + run_case(test_name, local, remote, GraphiteTestRepo::get_force) +} + +/// Create and submit a branch from clone A, apply `remote` in a peer clone and +/// submit it, apply `local` in A, then reconcile A with `reconcile`. +/// +/// Returns `None` when prerequisites are missing, so callers can `return`. +fn run_case( + test_name: &str, + local: Divergence, + remote: Divergence, + reconcile: fn(&GraphiteTestRepo, &str) -> Result, +) -> Option<(GraphiteTestRepo, Result)> { + let test_repo = GraphiteTestRepo::new(test_name)?; + let feature = test_repo.branch(BRANCH_NAME); + + // Clone A creates the branch and submits it. + let mut file = test_repo + .repo + .filename(&test_repo.scoped_path("feature.txt")); + file.set_contents(crate::lines!["human line", "ai line".ai()]); + test_repo.repo.git(&["add", "-A"]).unwrap(); + test_repo + .gt(&["create", &feature, "-m", "feature branch"]) + .expect("gt create should succeed"); + file.assert_committed_lines(crate::lines!["human line".human(), "ai line".ai()]); + test_repo.submit().expect("gt submit should succeed"); + + // A peer clone picks the branch up and diverges the remote side. + if remote != Divergence::None { + let test_repo_peer = test_repo.peer(); + test_repo_peer + .get(&feature) + .expect("gt get should succeed in the peer"); + apply(&test_repo_peer, remote, "remote"); + test_repo_peer + .submit() + .expect("peer gt submit should succeed"); + // Flush the peer's daemon so the notes it pushed reflect final state. + test_repo_peer.repo.sync_daemon(); + } + + // Diverge the local side. The feature branch is deliberately never pushed — + // that divergence from the remote is what `gt get` has to reconcile. + apply(&test_repo, local, "local"); + + let result = reconcile(&test_repo, &feature); + Some((test_repo, result)) +} + +/// The feature file as clone A sees it after reconciling. +fn assert_feature_lines( + test_repo: &GraphiteTestRepo, + expected: Vec, +) { + test_repo + .repo + .filename(&test_repo.scoped_path("feature.txt")) + .assert_committed_lines(expected); +} + +// ===== Group 1: no local changes ===== + +#[test] +#[ignore] // Remote-backed - run with `cargo test --test integration graphite::remote_sync -- --ignored` +#[serial_test::serial(graphite_remote)] +fn test_gt_get_adopts_rebased_remote_when_local_unchanged() { + let Some((test_repo, result)) = run_get_case( + "test_gt_get_rebased_remote_no_local", + Divergence::None, + Divergence::Rebased, + ) else { + return; + }; + result.expect("gt get should succeed"); + + // The remote only replayed the commit; content and attribution are unchanged. + assert_feature_lines( + &test_repo, + crate::lines!["human line".human(), "ai line".ai()], + ); +} + +/// KNOWN_ISSUE:FETCH_NOTES — a branch adopted from the remote arrives with no +/// authorship at all. +/// +/// `gt get` fetches with `git fetch`, and git-ai does not import notes on +/// `fetch` (only `clone`, `pull`, and `git-ai fetch-notes` do — see +/// `notes_sync_fetch_does_not_import_authorship_notes` in +/// `tests/notes_sync_regression.rs`). Nothing rewrites history in this case +/// either — the local branch is unchanged, so `gt get` just fast-forwards — so +/// the on-demand rescue in `fetch_missing_notes_for_commits` +/// (`src/git/sync_authorship.rs`) never fires. +/// +/// Observed: both AI lines land on the peer's commit with the committer identity +/// instead of the agent. +/// +/// ```text +/// 0d7cbeb7 (Test User ... 1) human line +/// 21c2b212 (Graphite Test ... 2) ai line <- expected mock_ai +/// 21c2b212 (Graphite Test ... 3) remote ai line <- expected mock_ai +/// ``` +#[test] +#[ignore] +#[serial_test::serial(graphite_remote)] +fn test_gt_get_adopts_meaningful_remote_when_local_unchanged() { + let Some((test_repo, result)) = run_get_case( + "test_gt_get_meaningful_remote_no_local", + Divergence::None, + Divergence::Meaningful, + ) else { + return; + }; + result.expect("gt get should succeed"); + + // The remote's added AI line must arrive attributed to AI, not fall back to + // untracked-human because its note never came down. + assert_feature_lines(&test_repo, meaningful_lines("remote")); +} + +// ===== Group 2: rebased local changes ===== + +#[test] +#[ignore] // Remote-backed - run with `cargo test --test integration graphite::remote_sync -- --ignored` +#[serial_test::serial(graphite_remote)] +fn test_gt_get_preserves_attribution_for_rebased_local_and_unchanged_remote() { + let Some((test_repo, result)) = run_get_case( + "test_gt_get_rebased_local_no_remote", + Divergence::Rebased, + Divergence::None, + ) else { + return; + }; + result.expect("gt get should succeed"); + + assert_feature_lines( + &test_repo, + crate::lines!["human line".human(), "ai line".ai()], + ); +} + +#[test] +#[ignore] // Remote-backed - run with `cargo test --test integration graphite::remote_sync -- --ignored` +#[serial_test::serial(graphite_remote)] +fn test_gt_get_preserves_attribution_for_rebased_local_and_rebased_remote() { + let Some((test_repo, result)) = run_get_case( + "test_gt_get_rebased_local_rebased_remote", + Divergence::Rebased, + Divergence::Rebased, + ) else { + return; + }; + result.expect("gt get should succeed"); + + // Both sides replayed the same tree, so whichever side wins the content is + // identical — only the attribution is at risk. + assert_feature_lines( + &test_repo, + crate::lines!["human line".human(), "ai line".ai()], + ); +} + +#[test] +#[ignore] // Remote-backed - run with `cargo test --test integration graphite::remote_sync -- --ignored` +#[serial_test::serial(graphite_remote)] +fn test_gt_get_preserves_attribution_for_rebased_local_and_meaningful_remote() { + let Some((test_repo, result)) = run_get_case( + "test_gt_get_rebased_local_meaningful_remote", + Divergence::Rebased, + Divergence::Meaningful, + ) else { + return; + }; + result.expect("gt get should succeed"); + + // Local only replayed; the remote's content is the one with real changes. + assert_feature_lines(&test_repo, meaningful_lines("remote")); +} + +// ===== Group 3: meaningful local changes ===== + +#[test] +#[ignore] // Remote-backed - run with `cargo test --test integration graphite::remote_sync -- --ignored` +#[serial_test::serial(graphite_remote)] +fn test_gt_get_preserves_attribution_for_meaningful_local_and_unchanged_remote() { + let Some((test_repo, result)) = run_get_case( + "test_gt_get_meaningful_local_no_remote", + Divergence::Meaningful, + Divergence::None, + ) else { + return; + }; + result.expect("gt get should succeed"); + + // Nothing on the remote to adopt, so the local AI line must survive. + assert_feature_lines(&test_repo, meaningful_lines("local")); +} + +#[test] +#[ignore] // Remote-backed - run with `cargo test --test integration graphite::remote_sync -- --ignored` +#[serial_test::serial(graphite_remote)] +fn test_gt_get_preserves_attribution_for_meaningful_local_and_rebased_remote() { + let Some((test_repo, result)) = run_get_case( + "test_gt_get_meaningful_local_rebased_remote", + Divergence::Meaningful, + Divergence::Rebased, + ) else { + return; + }; + result.expect("gt get should succeed"); + + // The remote only replayed, so the local AI line is the real content. + assert_feature_lines(&test_repo, meaningful_lines("local")); +} + +#[test] +#[ignore] // Remote-backed - run with `cargo test --test integration graphite::remote_sync -- --ignored` +#[serial_test::serial(graphite_remote)] +fn test_gt_get_preserves_attribution_for_meaningful_local_and_meaningful_remote() { + let Some((test_repo, result)) = run_get_case( + "test_gt_get_meaningful_local_meaningful_remote", + Divergence::Meaningful, + Divergence::Meaningful, + ) else { + return; + }; + result.expect("gt get should succeed"); + + // Both sides made real changes, so `gt get` keeps the local branch rather + // than discarding work (adopting the remote requires `--force`). What matters + // here is that surviving the reconcile attempt leaves the local AI line still + // attributed to AI rather than degraded to untracked-human. + assert_feature_lines(&test_repo, meaningful_lines("local")); +} + +// ===== Group 4: forced adoption of the remote ===== + +/// KNOWN_ISSUE:FETCH_NOTES — Unlike the unforced case, a note IS present here: +/// the local note is remapped onto the adopted commit, so it attests the lines +/// this clone already knew about and silently says nothing about the line the +/// peer added. The result is not "attribution missing" but *attribution that +/// looks complete and is wrong*. +/// +/// Observed — lines 2 and 3 are the same commit, attributed differently: +/// +/// ```text +/// 89b38ac3 (Test User ... 1) human line +/// 81058709 (mock_ai ... 2) ai line <- correct, local note remapped +/// 81058709 (Graphite Test ... 3) remote ai line <- expected mock_ai +/// ``` +#[test] +#[ignore] +#[serial_test::serial(graphite_remote)] +fn test_gt_get_force_adopts_remote_attribution_over_meaningful_local() { + let Some((test_repo, result)) = run_forced_get_case( + "test_gt_get_force_meaningful_local_meaningful_remote", + Divergence::Meaningful, + Divergence::Meaningful, + ) else { + return; + }; + result.expect("gt get --force should succeed"); + + // `--force` makes the remote the source of truth, discarding the local + // commit — so the remote's AI line must arrive, and must arrive attributed. + assert_feature_lines(&test_repo, meaningful_lines("remote")); +} diff --git a/tests/integration/graphite/scripts/cleanup-test-branches.sh b/tests/integration/graphite/scripts/cleanup-test-branches.sh new file mode 100755 index 0000000000..bc5cbe9923 --- /dev/null +++ b/tests/integration/graphite/scripts/cleanup-test-branches.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash + +# This script cleans up branches and pull requests left on the shared Graphite +# test repository by crashed or --no-cleanup test runs. Every branch the tests +# push is namespaced under 'gtai/', so that prefix is what gets swept. +# +# Usage: +# export GRAPHITE_TEST_GH_TOKEN=... # GitHub PAT with 'repo' scope +# ./cleanup-test-branches.sh # sweep the default repo +# GRAPHITE_TEST_REPO=owner/name ./cleanup-test-branches.sh + +set -euo pipefail + +# Keep in sync with DEFAULT_GRAPHITE_TEST_REPO and BRANCH_NAMESPACE in +# graphite_test_harness.rs. +REPO="${GRAPHITE_TEST_REPO:-jumboblip/aug-6}" +BRANCH_NAMESPACE="gtai" + +echo "🔍 Checking GitHub CLI availability..." +if ! command -v gh &> /dev/null; then + echo "❌ GitHub CLI (gh) is not installed" + echo " Install from: https://cli.github.com/" + exit 1 +fi + +if [ -z "${GRAPHITE_TEST_GH_TOKEN:-}" ]; then + echo "❌ GRAPHITE_TEST_GH_TOKEN is not set" + echo " Needs a GitHub PAT with 'repo' scope on $REPO" + exit 1 +fi + +# Use the same token the tests use, rather than the ambient gh login, which is +# typically a different account than the one that owns the test repository. +export GH_TOKEN="$GRAPHITE_TEST_GH_TOKEN" + +echo "✅ GitHub CLI is available and a token is set" +echo "" +echo "🔍 Searching $REPO for '$BRANCH_NAMESPACE/*' branches..." +echo "" + +BRANCHES=$(gh api --paginate "repos/$REPO/branches" \ + --jq ".[] | select(.name | startswith(\"$BRANCH_NAMESPACE/\")) | .name") + +if [ -z "$BRANCHES" ]; then + echo "✅ No leftover test branches found" + exit 0 +fi + +BRANCH_COUNT=$(echo "$BRANCHES" | wc -l | tr -d ' ') + +echo "Found $BRANCH_COUNT leftover test branches:" +echo "" +while read -r branch; do + echo " - $branch" +done <<< "$BRANCHES" +echo "" + +read -p "⚠️ Close their PRs and delete all $BRANCH_COUNT branches? [y/N] " -n 1 -r +echo "" + +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "❌ Cleanup cancelled" + exit 0 +fi + +echo "" +echo "🗑️ Cleaning up..." +echo "" + +DELETED=0 +FAILED=0 + +while read -r branch; do + echo -n " $branch... " + + # Close the PR first; deleting the branch alone leaves it open. + PR_NUMBER=$(gh pr list --repo "$REPO" --head "$branch" --state open \ + --json number --jq '.[0].number' 2>/dev/null || true) + if [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "null" ]; then + gh pr close "$PR_NUMBER" --repo "$REPO" &> /dev/null || true + fi + + if gh api -X DELETE "repos/$REPO/git/refs/heads/$branch" &> /dev/null; then + echo "✅" + DELETED=$((DELETED + 1)) + else + echo "❌" + FAILED=$((FAILED + 1)) + fi +done <<< "$BRANCHES" + +echo "" +echo "✅ Cleanup complete" +echo " Deleted: $DELETED branches" + +if [ $FAILED -gt 0 ]; then + echo "⚠️ Failed: $FAILED branches" + exit 1 +fi diff --git a/tests/integration/graphite/scripts/run-graphite-tests.sh b/tests/integration/graphite/scripts/run-graphite-tests.sh new file mode 100755 index 0000000000..7e23017a7a --- /dev/null +++ b/tests/integration/graphite/scripts/run-graphite-tests.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash + +# This script runs the remote-backed Graphite integration tests. +# These tests push branches and open pull requests on a shared GitHub +# repository, so they are not part of the default test suite. +# +# Run with: +# ./run-graphite-tests.sh +# +# Or with --no-cleanup to leave the pushed branches and opened PRs in place +# for manual inspection: +# ./run-graphite-tests.sh --no-cleanup +# +# Required environment: +# GRAPHITE_TEST_TOKEN Graphite API token (app.graphite.dev/settings/cli) +# GRAPHITE_TEST_GH_TOKEN GitHub PAT with `repo` scope on the test repository +# +# Optional: +# GRAPHITE_TEST_REPO owner/name of the test repository (default: jumboblip/aug-6) + +set -euo pipefail + +# Parse arguments +NO_CLEANUP=0 +TEST_ARGS=() + +for arg in "$@"; do + if [ "$arg" = "--no-cleanup" ]; then + NO_CLEANUP=1 + else + TEST_ARGS+=("$arg") + fi +done + +echo "🔍 Checking Graphite CLI availability..." +if ! command -v gt &> /dev/null; then + echo "❌ Graphite CLI (gt) is not installed" + echo " Install with: npm install -g @withgraphite/graphite-cli@stable" + exit 1 +fi + +echo "🔍 Checking GitHub CLI availability..." +if ! command -v gh &> /dev/null; then + echo "❌ GitHub CLI (gh) is not installed" + echo " Install from: https://cli.github.com/" + exit 1 +fi + +# Note: `gh auth status` is deliberately NOT checked. The tests supply their own +# GH_TOKEN from GRAPHITE_TEST_GH_TOKEN, and the ambient gh login is typically a +# different account than the one that owns the test repository. + +if [ -z "${GRAPHITE_TEST_TOKEN:-}" ]; then + echo "❌ GRAPHITE_TEST_TOKEN is not set" + echo " Generate one at: https://app.graphite.dev/settings/cli" + exit 1 +fi + +if [ -z "${GRAPHITE_TEST_GH_TOKEN:-}" ]; then + echo "❌ GRAPHITE_TEST_GH_TOKEN is not set" + echo " Needs a GitHub PAT with 'repo' scope on ${GRAPHITE_TEST_REPO:-jumboblip/aug-6}" + exit 1 +fi + +echo "✅ gt and gh are available, both tokens are set" + +if [ $NO_CLEANUP -eq 1 ]; then + echo "⚠️ Cleanup disabled - test branches and PRs will NOT be removed" + export GIT_AI_TEST_NO_CLEANUP=1 +fi + +echo "" +echo "🚀 Running Graphite integration tests against ${GRAPHITE_TEST_REPO:-jumboblip/aug-6}..." +echo "" + +# `graphite::remote` matches both remote_ops and remote_sync. The tests share a +# serial group internally, so they push notes one at a time regardless of +# --test-threads. +cargo test --test integration graphite::remote -- --ignored --nocapture ${TEST_ARGS[@]+"${TEST_ARGS[@]}"}