Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions src/repo/add.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
use std::path::Path;

/// Run `git add <pattern>` 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());
}
}
51 changes: 51 additions & 0 deletions src/repo/commit.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
use std::path::Path;

/// Run `git commit -m <message>` 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}");
}
}
40 changes: 40 additions & 0 deletions src/repo/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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<I, S>(path: &Path, args: I) -> (bool, String, String)
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
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
Expand Down
52 changes: 52 additions & 0 deletions src/repo/pull.rs
Original file line number Diff line number Diff line change
@@ -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());
}
}
45 changes: 45 additions & 0 deletions src/repo/push.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
use std::path::Path;

/// Run `git push <remote>` 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());
}
}
Loading