Skip to content

Commit f264a45

Browse files
authored
fix: git apply inside a subdirectory of a git repository (#1675)
1 parent 8ced43a commit f264a45

File tree

1 file changed

+162
-47
lines changed

1 file changed

+162
-47
lines changed

src/source/patch.rs

Lines changed: 162 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
11
//! Functions for applying patches to a work directory.
2+
use super::SourceError;
3+
use crate::system_tools::{SystemTools, Tool};
4+
use itertools::Itertools;
5+
use std::process::{Command, Output};
26
use std::{
37
collections::HashSet,
4-
io::{BufRead, BufReader, Write},
8+
ffi::OsStr,
9+
io::{BufRead, BufReader},
510
path::{Path, PathBuf},
611
process::Stdio,
712
};
813

9-
use super::SourceError;
10-
use crate::system_tools::{SystemTools, Tool};
11-
1214
fn parse_patch_file<P: AsRef<Path>>(patch_file: P) -> std::io::Result<HashSet<PathBuf>> {
1315
let file = fs_err::File::open(patch_file.as_ref())?;
1416
let reader = BufReader::new(file);
@@ -115,6 +117,19 @@ pub(crate) fn apply_patches(
115117
work_dir: &Path,
116118
recipe_dir: &Path,
117119
) -> Result<(), SourceError> {
120+
// Early out to avoid unnecessary work
121+
if patches.is_empty() {
122+
return Ok(());
123+
}
124+
125+
// Ensure that the working directory is a valid git directory.
126+
let git_dir = work_dir.join(".git");
127+
let _dot_git_dir = if !git_dir.exists() {
128+
Some(TempDotGit::setup(work_dir)?)
129+
} else {
130+
None
131+
};
132+
118133
for patch_path_relative in patches {
119134
let patch_file_path = recipe_dir.join(patch_path_relative);
120135

@@ -124,60 +139,137 @@ pub(crate) fn apply_patches(
124139
return Err(SourceError::PatchNotFound(patch_file_path));
125140
}
126141

127-
// Read the patch content into a string. This also normalizes line endings to LF.
128-
let patch_content_for_stdin =
129-
fs_err::read_to_string(&patch_file_path).map_err(SourceError::Io)?;
130-
131142
let strip_level = guess_strip_level(&patch_file_path, work_dir)?;
132143

133-
let mut cmd_builder = system_tools
134-
.call(Tool::Git)
135-
.map_err(SourceError::GitNotFound)?;
136-
137-
cmd_builder
138-
.current_dir(work_dir)
139-
.arg("apply")
140-
.arg(format!("-p{}", strip_level))
141-
.arg("--ignore-space-change")
142-
.arg("--ignore-whitespace")
143-
.arg("--recount")
144-
.stdin(Stdio::piped())
145-
.stdout(Stdio::piped())
146-
.stderr(Stdio::piped());
147-
148-
let mut child_process = cmd_builder.spawn().map_err(SourceError::Io)?;
149-
150-
// Write the patch content to the child process's stdin.
151-
{
152-
if let Some(mut child_stdin) = child_process.stdin.take() {
153-
child_stdin
154-
.write_all(patch_content_for_stdin.as_bytes())
155-
.map_err(SourceError::Io)?;
156-
} else {
157-
return Err(SourceError::Io(std::io::Error::new(
158-
std::io::ErrorKind::Other,
159-
"Failed to obtain stdin handle for git apply",
160-
)));
144+
struct GitApplyAttempt {
145+
command: Command,
146+
output: Output,
147+
}
148+
149+
let mut outputs = Vec::new();
150+
for try_extra_flag in [None, Some("--recount")] {
151+
let mut cmd_builder = system_tools
152+
.call(Tool::Git)
153+
.map_err(SourceError::GitNotFound)?;
154+
cmd_builder
155+
.current_dir(work_dir)
156+
.arg("apply")
157+
.arg(format!("-p{}", strip_level))
158+
.arg("--verbose")
159+
.arg("--ignore-space-change")
160+
.arg("--ignore-whitespace")
161+
.args(try_extra_flag.into_iter())
162+
.arg(patch_file_path.as_os_str())
163+
.stdout(Stdio::piped())
164+
.stderr(Stdio::piped());
165+
166+
tracing::debug!(
167+
"Running: {} {}",
168+
cmd_builder.get_program().to_string_lossy(),
169+
cmd_builder
170+
.get_args()
171+
.map(OsStr::to_string_lossy)
172+
.format(" ")
173+
);
174+
175+
let output = cmd_builder.output().map_err(SourceError::Io)?;
176+
outputs.push(GitApplyAttempt {
177+
command: cmd_builder,
178+
output: output.clone(),
179+
});
180+
181+
if outputs
182+
.last()
183+
.expect("we just added an entry")
184+
.output
185+
.status
186+
.success()
187+
{
188+
break;
161189
}
162190
}
163191

164-
let output = child_process.wait_with_output().map_err(SourceError::Io)?;
192+
// Check if the last output was successful, if not, we report all the errors.
193+
let last_output = outputs.last().expect("we just added at least one entry");
194+
if !last_output.output.status.success() {
195+
return Err(SourceError::PatchFailed(format!(
196+
"{}\n`git apply` failed with a combination of flags.\n\n{}",
197+
patch_path_relative.display(),
198+
outputs
199+
.into_iter()
200+
.map(
201+
|GitApplyAttempt {
202+
output, command, ..
203+
}| {
204+
let stderr = String::from_utf8_lossy(&output.stderr);
205+
format!(
206+
"With the che command:\n\n\t{} {}The output was:\n\n\t{}\n\n",
207+
command.get_program().to_string_lossy(),
208+
command.get_args().map(OsStr::to_string_lossy).format(" "),
209+
stderr.lines().format("\n\t")
210+
)
211+
}
212+
)
213+
.format("\n\n")
214+
)));
215+
}
165216

166-
if !output.status.success() {
167-
eprintln!(
168-
"Failed to apply patch: {}",
169-
patch_file_path.to_string_lossy()
170-
);
171-
eprintln!("Stdout: {}", String::from_utf8_lossy(&output.stdout));
172-
eprintln!("Stderr: {}", String::from_utf8_lossy(&output.stderr));
173-
return Err(SourceError::PatchFailed(
174-
patch_file_path.to_string_lossy().to_string(),
175-
));
217+
// Sometimes git apply will skip the contents of a patch. This usually is *not* what we
218+
// want, so we detect this behavior and return an error.
219+
let stderr = String::from_utf8_lossy(&last_output.output.stderr);
220+
let skipped_patch = stderr
221+
.lines()
222+
.any(|line| line.starts_with("Skipped patch "));
223+
if skipped_patch {
224+
return Err(SourceError::PatchFailed(format!(
225+
"{}\n`git apply` seems to have skipped some of the contents of the patch. The output of the command is:\n\n\t{}\n\nThe command was invoked with:\n\n\t{} {}",
226+
patch_path_relative.display(),
227+
stderr.lines().format("\n\t"),
228+
last_output.command.get_program().to_string_lossy(),
229+
last_output
230+
.command
231+
.get_args()
232+
.map(OsStr::to_string_lossy)
233+
.format(" ")
234+
)));
176235
}
177236
}
178237
Ok(())
179238
}
180239

240+
/// A temporary .git directory that contains the bare minimum files and
241+
/// directories needed for git to function as if the directory that contains
242+
/// the .git directory is a proper git repository.
243+
struct TempDotGit {
244+
path: PathBuf,
245+
}
246+
247+
impl TempDotGit {
248+
/// Creates a temporary .git directory in the specified root directory.
249+
fn setup(root: &Path) -> std::io::Result<Self> {
250+
// Initialize a temporary .git directory
251+
let dot_git = root.join(".git");
252+
fs_err::create_dir(&dot_git)?;
253+
let dot_git = TempDotGit { path: dot_git };
254+
255+
// Add the minimum number of files and directories to the .git directory that are needed for
256+
// git to work
257+
fs_err::create_dir(dot_git.path.join("objects"))?;
258+
fs_err::create_dir(dot_git.path.join("refs"))?;
259+
fs_err::write(dot_git.path.join("HEAD"), "ref: refs/heads/main")?;
260+
261+
Ok(dot_git)
262+
}
263+
}
264+
265+
impl Drop for TempDotGit {
266+
fn drop(&mut self) {
267+
fs_err::remove_dir_all(&self.path).unwrap_or_else(|e| {
268+
eprintln!("Failed to remove temporary .git directory: {}", e);
269+
});
270+
}
271+
}
272+
181273
#[cfg(test)]
182274
mod tests {
183275
use crate::source::copy_dir::CopyDir;
@@ -294,4 +386,27 @@ mod tests {
294386
let cmake_list = fs_err::read_to_string(&cmake_list).unwrap();
295387
assert!(cmake_list.contains("cmake_minimum_required(VERSION 3.12)"));
296388
}
389+
390+
#[test]
391+
fn test_apply_git_patch_in_git_ignored() {
392+
let (tempdir, _) = setup_patch_test_dir();
393+
394+
// Initialize a temporary .git directory at the root of the temporary directory. This makes
395+
// git take the working directory is in a git repository.
396+
let _temp_dot_git = TempDotGit::setup(tempdir.path()).unwrap();
397+
398+
// Apply the patches in the working directory
399+
apply_patches(
400+
&SystemTools::new(),
401+
&[PathBuf::from("0001-increase-minimum-cmake-version.patch")],
402+
&tempdir.path().join("workdir"),
403+
&tempdir.path().join("patches"),
404+
)
405+
.expect("Patch 0001-increase-minimum-cmake-version.patch should apply successfully");
406+
407+
// Read the cmake list file and make sure that it contains `cmake_minimum_required(VERSION 3.12)`
408+
let cmake_list = tempdir.path().join("workdir/CMakeLists.txt");
409+
let cmake_list = fs_err::read_to_string(&cmake_list).unwrap();
410+
assert!(cmake_list.contains("cmake_minimum_required(VERSION 3.12)"));
411+
}
297412
}

0 commit comments

Comments
 (0)