From 8b6d754da8592c9e5864cec5820b8c0f2a78df8d Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sat, 6 Jun 2026 16:21:01 -0400 Subject: [PATCH 1/4] add write-side core operations Refs #21 Co-authored-by: Codex --- src/repo/add.rs | 36 ++++++++++++++++++++++++++++++++ src/repo/commit.rs | 51 +++++++++++++++++++++++++++++++++++++++++++++ src/repo/mod.rs | 40 +++++++++++++++++++++++++++++++++++ src/repo/pull.rs | 52 ++++++++++++++++++++++++++++++++++++++++++++++ src/repo/push.rs | 45 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+) create mode 100644 src/repo/add.rs create mode 100644 src/repo/commit.rs create mode 100644 src/repo/pull.rs create mode 100644 src/repo/push.rs diff --git a/src/repo/add.rs b/src/repo/add.rs new file mode 100644 index 0000000..e4b3020 --- /dev/null +++ b/src/repo/add.rs @@ -0,0 +1,36 @@ +use std::path::Path; + +/// Run `git add ` in `path`. +pub fn add(path: &Path, pattern: &str) -> (bool, String) { + let (ok, stderr, _) = super::run_git(path, ["add", pattern]); + (ok, stderr) +} + +#[cfg(test)] +mod tests { + use crate::repo::fixtures; + + #[test] + fn add_stages_matching_path() { + let repo = fixtures::repo(); + fixtures::write(repo.path(), "tracked.txt", "tracked\n"); + + let (ok, stderr) = super::add(repo.path(), "tracked.txt"); + + assert!(ok, "{stderr}"); + assert_eq!( + fixtures::git(repo.path(), &["diff", "--cached", "--name-only"]), + "tracked.txt" + ); + } + + #[test] + fn add_missing_path_returns_failure_and_stderr() { + let repo = fixtures::repo(); + + let (ok, stderr) = super::add(repo.path(), "missing.txt"); + + assert!(!ok); + assert!(!stderr.is_empty()); + } +} diff --git a/src/repo/commit.rs b/src/repo/commit.rs new file mode 100644 index 0000000..71124ec --- /dev/null +++ b/src/repo/commit.rs @@ -0,0 +1,51 @@ +use std::path::Path; + +/// Run `git commit -m ` in `path`. +/// +/// A clean worktree with "nothing to commit" is treated as success to match +/// git-tend's write-side contract. +pub fn commit(path: &Path, message: &str) -> (bool, String) { + let (ok, stderr, stdout) = super::run_git(path, ["commit", "-m", message]); + if ok { + return (true, stderr); + } + + let output = format!("{stdout}\n{stderr}").to_ascii_lowercase(); + if output.contains("nothing to commit") { + (true, String::new()) + } else { + (false, stderr) + } +} + +#[cfg(test)] +mod tests { + use crate::repo::fixtures; + + #[test] + fn commit_creates_commit_with_message() { + let repo = fixtures::repo(); + fixtures::git(repo.path(), &["config", "user.name", "qa"]); + fixtures::git(repo.path(), &["config", "user.email", "qa@example.com"]); + fixtures::write(repo.path(), "new.txt", "new\n"); + fixtures::git(repo.path(), &["add", "-A"]); + + let (ok, stderr) = super::commit(repo.path(), "new commit"); + + assert!(ok, "{stderr}"); + assert!(stderr.is_empty(), "{stderr}"); + assert_eq!( + fixtures::git(repo.path(), &["log", "-1", "--format=%s"]), + "new commit" + ); + } + + #[test] + fn commit_nothing_to_commit_counts_as_success() { + let repo = fixtures::repo(); + + let (ok, stderr) = super::commit(repo.path(), "noop"); + + assert!(ok, "{stderr}"); + } +} diff --git a/src/repo/mod.rs b/src/repo/mod.rs index c9039b6..ce6e925 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -17,6 +17,10 @@ #[allow(unused_imports)] pub use crate::error::{GitxtendError, Result}; +use std::ffi::OsStr; +use std::path::Path; +use std::process::Command; + // ---- method registrations (one block per implemented method) ------------- // (methods land here as M1 progresses — see docs/ROADMAP.md M1 ordering) @@ -59,6 +63,42 @@ pub use last_commit_date::last_commit_date; mod fetch; pub use fetch::{fetch, fetch_result}; +mod pull; +pub use pull::pull; + +mod push; +pub use push::push; + +mod add; +pub use add::add; + +mod commit; +pub use commit::commit; + +fn run_git(path: &Path, args: I) -> (bool, String, String) +where + I: IntoIterator, + S: AsRef, +{ + let out = Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .env_remove("GIT_DIR") + .env_remove("GIT_WORK_TREE") + .env_remove("GIT_INDEX_FILE") + .output(); + + match out { + Ok(out) => ( + out.status.success(), + String::from_utf8_lossy(&out.stderr).trim().to_string(), + String::from_utf8_lossy(&out.stdout).trim().to_string(), + ), + Err(e) => (false, e.to_string(), String::new()), + } +} + /// Temp-dir git fixtures shared by the per-method parity tests. /// /// Fixtures are built with the real `git` CLI, so each parity test asserts diff --git a/src/repo/pull.rs b/src/repo/pull.rs new file mode 100644 index 0000000..e36baa5 --- /dev/null +++ b/src/repo/pull.rs @@ -0,0 +1,52 @@ +use std::path::Path; + +/// Run `git pull` in `path`; include `--ff-only` when requested. +pub fn pull(path: &Path, ff_only: bool) -> (bool, String) { + let args = if ff_only { + vec!["pull", "--ff-only"] + } else { + vec!["pull"] + }; + let (ok, stderr, _) = super::run_git(path, args); + (ok, stderr) +} + +#[cfg(test)] +mod tests { + use crate::repo::fixtures; + + #[test] + fn pull_ff_only_fast_forwards_from_origin() { + let repo = fixtures::repo(); + let bare = tempfile::tempdir().unwrap(); + fixtures::git(bare.path(), &["init", "--bare", "-q", "-b", "main"]); + let bare_url = bare.path().to_string_lossy().to_string(); + fixtures::git(repo.path(), &["remote", "add", "origin", &bare_url]); + fixtures::git(repo.path(), &["push", "-q", "-u", "origin", "main"]); + + let clone = tempfile::tempdir().unwrap(); + fixtures::git(clone.path(), &["clone", "-q", &bare_url, "."]); + fixtures::write(clone.path(), "remote.txt", "remote\n"); + fixtures::git(clone.path(), &["add", "-A"]); + fixtures::git(clone.path(), &["commit", "-q", "-m", "remote"]); + fixtures::git(clone.path(), &["push", "-q", "origin", "main"]); + + let (ok, stderr) = super::pull(repo.path(), true); + + assert!(ok, "{stderr}"); + assert_eq!( + fixtures::git(repo.path(), &["rev-parse", "HEAD"]), + fixtures::git(clone.path(), &["rev-parse", "HEAD"]) + ); + } + + #[test] + fn pull_bad_repository_returns_failure_and_stderr() { + let dir = tempfile::tempdir().unwrap(); + + let (ok, stderr) = super::pull(dir.path(), true); + + assert!(!ok); + assert!(!stderr.is_empty()); + } +} diff --git a/src/repo/push.rs b/src/repo/push.rs new file mode 100644 index 0000000..427abc6 --- /dev/null +++ b/src/repo/push.rs @@ -0,0 +1,45 @@ +use std::path::Path; + +/// Run `git push ` in `path`. +pub fn push(path: &Path, remote: &str) -> (bool, String) { + let (ok, stderr, _) = super::run_git(path, ["push", remote]); + (ok, stderr) +} + +#[cfg(test)] +mod tests { + use crate::repo::fixtures; + + #[test] + fn push_sends_local_commit_to_origin() { + let repo = fixtures::repo(); + let bare = tempfile::tempdir().unwrap(); + fixtures::git(bare.path(), &["init", "--bare", "-q", "-b", "main"]); + let bare_url = bare.path().to_string_lossy().to_string(); + fixtures::git(repo.path(), &["remote", "add", "origin", &bare_url]); + fixtures::git(repo.path(), &["push", "-q", "-u", "origin", "main"]); + fixtures::git(repo.path(), &["config", "push.default", "upstream"]); + + fixtures::write(repo.path(), "local.txt", "local\n"); + fixtures::git(repo.path(), &["add", "-A"]); + fixtures::git(repo.path(), &["commit", "-q", "-m", "local"]); + + let (ok, stderr) = super::push(repo.path(), "origin"); + + assert!(ok, "{stderr}"); + assert_eq!( + fixtures::git(repo.path(), &["rev-parse", "HEAD"]), + fixtures::git(bare.path(), &["rev-parse", "main"]) + ); + } + + #[test] + fn push_bad_remote_returns_failure_and_stderr() { + let repo = fixtures::repo(); + + let (ok, stderr) = super::push(repo.path(), "does-not-exist"); + + assert!(!ok); + assert!(!stderr.is_empty()); + } +} From f1a66031c1b8f2106ea388a227b658ce9a9c8b07 Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sat, 6 Jun 2026 17:21:04 -0400 Subject: [PATCH 2/4] Address write core review Co-authored-by: Codex --- src/repo/commit.rs | 7 +++++++ src/repo/fetch.rs | 38 ++++++++++---------------------------- src/repo/mod.rs | 1 + 3 files changed, 18 insertions(+), 28 deletions(-) diff --git a/src/repo/commit.rs b/src/repo/commit.rs index 71124ec..b55b6da 100644 --- a/src/repo/commit.rs +++ b/src/repo/commit.rs @@ -43,9 +43,16 @@ mod tests { #[test] fn commit_nothing_to_commit_counts_as_success() { let repo = fixtures::repo(); + let before = fixtures::git(repo.path(), &["rev-parse", "HEAD"]); let (ok, stderr) = super::commit(repo.path(), "noop"); assert!(ok, "{stderr}"); + assert!(stderr.is_empty(), "{stderr}"); + assert_eq!( + fixtures::git(repo.path(), &["rev-parse", "HEAD"]), + before, + "noop commit must not create a new commit" + ); } } diff --git a/src/repo/fetch.rs b/src/repo/fetch.rs index 9857e91..cb72fcb 100644 --- a/src/repo/fetch.rs +++ b/src/repo/fetch.rs @@ -2,40 +2,22 @@ //! //! Implemented as a contained `git fetch` shell-out rather than via gix. Per //! docs/PORTING.md, gix's network fetch is the least-mature path in scope; the -//! shell-out runs the user's own `git`, so it honors their config, credentials, -//! and ssh-agent exactly. `fetch_result` exposes `(ok, stderr)` so the -//! `repo_status` roll-up can report *why* a fetch failed (docs/API.md). +//! shared `run_git` shell-out runs the user's own `git`, so it honors their +//! config, credentials, and ssh-agent exactly. `fetch_result` exposes +//! `(ok, stderr)` so the `repo_status` roll-up can report *why* a fetch failed +//! (docs/API.md). use std::path::Path; -use std::process::Command; /// Run `git fetch` in `path` and return `(ok, stderr)`. `remote = None` fetches /// all remotes (`git fetch --all`); `Some(name)` fetches that one remote. pub fn fetch_result(path: &Path, remote: Option<&str>) -> (bool, String) { - let mut cmd = Command::new("git"); - cmd.arg("-C") - .arg(path) - .arg("fetch") - // Isolate from any ambient git env (e.g. when invoked from a hook) so we - // target `path` rather than the surrounding repository. - .env_remove("GIT_DIR") - .env_remove("GIT_WORK_TREE") - .env_remove("GIT_INDEX_FILE"); - match remote { - Some(r) => { - cmd.arg(r); - } - None => { - cmd.arg("--all"); - } - } - match cmd.output() { - Ok(out) => ( - out.status.success(), - String::from_utf8_lossy(&out.stderr).trim().to_string(), - ), - Err(e) => (false, e.to_string()), - } + let args = match remote { + Some(r) => vec!["fetch", r], + None => vec!["fetch", "--all"], + }; + let (ok, stderr, _) = super::run_git(path, args); + (ok, stderr) } /// Fetch from `remote` (or all remotes when `None`). Returns true on success. diff --git a/src/repo/mod.rs b/src/repo/mod.rs index ce6e925..1ee169f 100644 --- a/src/repo/mod.rs +++ b/src/repo/mod.rs @@ -87,6 +87,7 @@ where .env_remove("GIT_DIR") .env_remove("GIT_WORK_TREE") .env_remove("GIT_INDEX_FILE") + .env("LC_ALL", "C") .output(); match out { From 8b8c773b2ea371b4c958c046c75577f50946de4f Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sat, 6 Jun 2026 17:23:48 -0400 Subject: [PATCH 3/4] Stabilize noop commit test Co-authored-by: Codex --- src/repo/commit.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/repo/commit.rs b/src/repo/commit.rs index b55b6da..ebab7f3 100644 --- a/src/repo/commit.rs +++ b/src/repo/commit.rs @@ -43,6 +43,8 @@ mod tests { #[test] fn commit_nothing_to_commit_counts_as_success() { let repo = fixtures::repo(); + fixtures::git(repo.path(), &["config", "user.name", "qa"]); + fixtures::git(repo.path(), &["config", "user.email", "qa@example.com"]); let before = fixtures::git(repo.path(), &["rev-parse", "HEAD"]); let (ok, stderr) = super::commit(repo.path(), "noop"); From ffcfc85d76be312b6634328e4384e176237d8d5a Mon Sep 17 00:00:00 2001 From: Shawn Hartsock Date: Sat, 6 Jun 2026 17:27:46 -0400 Subject: [PATCH 4/4] Document write-side git config test scope Co-authored-by: Codex --- src/repo/commit.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/repo/commit.rs b/src/repo/commit.rs index ebab7f3..a6e2165 100644 --- a/src/repo/commit.rs +++ b/src/repo/commit.rs @@ -22,6 +22,10 @@ pub fn commit(path: &Path, message: &str) -> (bool, String) { mod tests { use crate::repo::fixtures; + // These tests exercise `run_git`, which intentionally preserves host git + // config for production parity. Set repo-local config when behavior depends + // on identity or other git settings. + #[test] fn commit_creates_commit_with_message() { let repo = fixtures::repo();