Skip to content

Commit 23370ee

Browse files
hartsockclaude
andauthored
feat(repo): fetch via contained git shell-out (M1 R13) (#17)
WHAT: src/repo/fetch.rs — `fetch_result(path, remote) -> (bool, stderr)` and `fetch(path, remote) -> bool`, a contained `git fetch` shell-out. Per PORTING.md, gix network fetch is the least-mature path; shelling out to the user's `git` honors their config / credentials / ssh-agent. `fetch_result` exposes stderr so the repo_status roll-up can build "Fetch failed: {stderr}" (API.md / #11). Registered + PyO3 wrapper. Tested vs a local bare remote (fetch advances origin/main) and a bad-remote failure. WHY: Last read-side primitive of M1, and the (ok, stderr) seam S1 needs. NOTE: pilot-authored. The newt-agent worker prose-scrubbed twice here (the long pre-written shell-out recipe pushed it past its whole-file-emit envelope — see newt-agent docs/notes/2026-05-31-newt-coder-driving-sweet-spots.md). 12 of the 13 read methods were worker-coded; this network method is the exception. Co-authored-by: Shawn Hartsock <hartsock@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d32e575 commit 23370ee

3 files changed

Lines changed: 93 additions & 2 deletions

File tree

src/python.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,12 @@ fn status_counts(path: String) -> PyResult<(usize, usize)> {
122122
}
123123

124124
#[pyfunction]
125-
fn fetch(_path: String, _remote: Option<String>) -> PyResult<bool> {
126-
todo!("repo::fetch (gix fetch, contained shell-out fallback)")
125+
#[pyo3(signature = (path, remote=None))]
126+
fn fetch(path: String, remote: Option<String>) -> PyResult<bool> {
127+
Ok(crate::repo::fetch(
128+
std::path::Path::new(&path),
129+
remote.as_deref(),
130+
))
127131
}
128132

129133
#[pyfunction]

src/repo/fetch.rs

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
//! `fetch` — the one network call in the v1 read-side scope.
2+
//!
3+
//! Implemented as a contained `git fetch` shell-out rather than via gix. Per
4+
//! docs/PORTING.md, gix's network fetch is the least-mature path in scope; the
5+
//! shell-out runs the user's own `git`, so it honors their config, credentials,
6+
//! and ssh-agent exactly. `fetch_result` exposes `(ok, stderr)` so the
7+
//! `repo_status` roll-up can report *why* a fetch failed (docs/API.md).
8+
9+
use std::path::Path;
10+
use std::process::Command;
11+
12+
/// Run `git fetch` in `path` and return `(ok, stderr)`. `remote = None` fetches
13+
/// all remotes (`git fetch --all`); `Some(name)` fetches that one remote.
14+
pub fn fetch_result(path: &Path, remote: Option<&str>) -> (bool, String) {
15+
let mut cmd = Command::new("git");
16+
cmd.arg("-C")
17+
.arg(path)
18+
.arg("fetch")
19+
// Isolate from any ambient git env (e.g. when invoked from a hook) so we
20+
// target `path` rather than the surrounding repository.
21+
.env_remove("GIT_DIR")
22+
.env_remove("GIT_WORK_TREE")
23+
.env_remove("GIT_INDEX_FILE");
24+
match remote {
25+
Some(r) => {
26+
cmd.arg(r);
27+
}
28+
None => {
29+
cmd.arg("--all");
30+
}
31+
}
32+
match cmd.output() {
33+
Ok(out) => (
34+
out.status.success(),
35+
String::from_utf8_lossy(&out.stderr).trim().to_string(),
36+
),
37+
Err(e) => (false, e.to_string()),
38+
}
39+
}
40+
41+
/// Fetch from `remote` (or all remotes when `None`). Returns true on success.
42+
pub fn fetch(path: &Path, remote: Option<&str>) -> bool {
43+
fetch_result(path, remote).0
44+
}
45+
46+
#[cfg(test)]
47+
mod tests {
48+
use super::*;
49+
use crate::repo::fixtures;
50+
51+
#[test]
52+
fn fetch_updates_tracking_ref() {
53+
// repo A with a local bare remote
54+
let a = fixtures::repo();
55+
let bare = tempfile::tempdir().unwrap();
56+
fixtures::git(bare.path(), &["init", "--bare", "-q", "-b", "main"]);
57+
let bare_url = bare.path().to_string_lossy().to_string();
58+
fixtures::git(a.path(), &["remote", "add", "origin", &bare_url]);
59+
fixtures::git(a.path(), &["push", "-q", "-u", "origin", "main"]);
60+
61+
// advance the bare remote via a second clone
62+
let c = tempfile::tempdir().unwrap();
63+
fixtures::git(c.path(), &["clone", "-q", &bare_url, "."]);
64+
fixtures::write(c.path(), "new.txt", "x");
65+
fixtures::git(c.path(), &["add", "-A"]);
66+
fixtures::git(c.path(), &["commit", "-q", "-m", "remote commit"]);
67+
fixtures::git(c.path(), &["push", "-q", "origin", "main"]);
68+
69+
// back in A: fetch should succeed and advance origin/main to C's HEAD
70+
assert!(fetch(a.path(), None));
71+
assert_eq!(
72+
fixtures::git(a.path(), &["rev-parse", "origin/main"]),
73+
fixtures::git(c.path(), &["rev-parse", "HEAD"])
74+
);
75+
}
76+
77+
#[test]
78+
fn fetch_bad_remote_fails() {
79+
let a = fixtures::repo();
80+
let (ok, stderr) = fetch_result(a.path(), Some("does-not-exist"));
81+
assert!(!ok);
82+
assert!(!stderr.is_empty());
83+
}
84+
}

src/repo/mod.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ pub use remote_urls::remote_urls;
5656
mod last_commit_date;
5757
pub use last_commit_date::last_commit_date;
5858

59+
mod fetch;
60+
pub use fetch::{fetch, fetch_result};
61+
5962
/// Temp-dir git fixtures shared by the per-method parity tests.
6063
///
6164
/// Fixtures are built with the real `git` CLI, so each parity test asserts

0 commit comments

Comments
 (0)