1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
//! Git operations for cloning and updating the Whitaker repository.
//!
//! This module provides functions for managing the local Whitaker clone,
//! including initial cloning and subsequent updates. Operations have a
//! configurable timeout to prevent hangs on network issues.
use crate::error::{InstallerError, Result};
use crate::workspace::WHITAKER_REPO_URL;
use camino::Utf8Path;
use std::process::{Command, Output, Stdio};
use std::time::Duration;
use wait_timeout::ChildExt;
/// Default timeout for git operations (5 minutes).
const GIT_TIMEOUT: Duration = Duration::from_secs(300);
/// Clones the Whitaker repository to the specified target directory.
///
/// Creates the parent directories if they do not exist. The operation has
/// a 5-minute timeout to prevent indefinite hangs on network issues.
///
/// # Errors
///
/// Returns `InstallerError::Git` if the clone fails or times out.
pub fn clone_repository(target: &Utf8Path) -> Result<()> {
// Ensure parent directory exists
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)?;
}
let output = run_git_with_timeout(
&["clone", WHITAKER_REPO_URL, target.as_str()],
None,
"clone",
)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(InstallerError::Git {
operation: "clone",
message: stderr.trim().to_owned(),
});
}
Ok(())
}
/// Updates an existing Whitaker repository by pulling the latest changes.
///
/// Runs `git pull` in the specified repository directory. The operation has
/// a 5-minute timeout to prevent indefinite hangs on network issues.
///
/// # Errors
///
/// Returns `InstallerError::Git` if the pull fails or times out.
pub fn update_repository(repo: &Utf8Path) -> Result<()> {
let output = run_git_with_timeout(&["pull"], Some(repo), "pull")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(InstallerError::Git {
operation: "pull",
message: stderr.trim().to_owned(),
});
}
Ok(())
}
/// Runs a git command with a timeout.
///
/// Returns the command output if it completes within the timeout, or an error
/// if the command times out or fails to start.
///
/// Spawns threads to read stdout and stderr concurrently to avoid potential
/// deadlocks if the child process produces large output that fills OS buffers.
fn run_git_with_timeout(
args: &[&str],
working_dir: Option<&Utf8Path>,
operation: &'static str,
) -> Result<Output> {
let mut cmd = Command::new("git");
cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
if let Some(dir) = working_dir {
cmd.current_dir(dir.as_std_path());
}
let mut child = cmd.spawn()?;
// Take ownership of pipes before spawning threads to avoid blocking.
// If either pipe is missing, use empty readers.
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
// Spawn threads to read pipes concurrently whilst the process runs.
let stdout_thread = std::thread::spawn(move || -> std::io::Result<String> {
stdout_pipe
.map(std::io::read_to_string)
.transpose()
.map(|opt| opt.unwrap_or_default())
});
let stderr_thread = std::thread::spawn(move || -> std::io::Result<String> {
stderr_pipe
.map(std::io::read_to_string)
.transpose()
.map(|opt| opt.unwrap_or_default())
});
match child.wait_timeout(GIT_TIMEOUT)? {
Some(status) => {
// Command completed within timeout - collect output from threads
let stdout = stdout_thread
.join()
.map_err(|_| InstallerError::Git {
operation,
message: "failed to read stdout".to_owned(),
})?
.unwrap_or_default();
let stderr = stderr_thread
.join()
.map_err(|_| InstallerError::Git {
operation,
message: "failed to read stderr".to_owned(),
})?
.unwrap_or_default();
Ok(Output {
status,
stdout: stdout.into_bytes(),
stderr: stderr.into_bytes(),
})
}
None => {
// Timeout - kill the process and wait for threads to finish
let _ = child.kill();
let _ = child.wait();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
Err(InstallerError::Git {
operation,
message: format!(
"operation timed out after {} seconds",
GIT_TIMEOUT.as_secs()
),
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn clone_repository_error_includes_operation() {
let err = InstallerError::Git {
operation: "clone",
message: "test error".to_owned(),
};
let msg = err.to_string();
assert!(msg.contains("clone"));
assert!(msg.contains("test error"));
}
#[test]
fn update_repository_error_includes_operation() {
let err = InstallerError::Git {
operation: "pull",
message: "not a git repository".to_owned(),
};
let msg = err.to_string();
assert!(msg.contains("pull"));
assert!(msg.contains("not a git repository"));
}
}