Skip to content

Commit e0622f1

Browse files
authored
internal: Improve VCS implementation and handling. (#2569)
* Polish. * Fixes. * Fix diff. * Add warnings. * Polish. * Update hooks. * Fix tests. * Fix tests.
1 parent f814a84 commit e0622f1

19 files changed

Lines changed: 721 additions & 239 deletions

File tree

CHANGELOG.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,44 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
#### 🚀 Updates
6+
7+
- **Processes**
8+
- Improved our "stream and capture output" child process handling to operate on bytes instead of
9+
lines, which should resolve some edge cases with output not being written to the console, or
10+
being written out of order.
11+
- **VCS**
12+
- Hardened all executed Git commands: revisions are validated against argument injection,
13+
credential prompts now fail immediately instead of hanging, and the fsmonitor daemon is now
14+
disabled to avoid it blocking process pipes.
15+
- Reworked merge base resolution to be more accurate and performant. The most recent divergence
16+
point is now preferred when local and remote branches are out of sync, and a warning is now
17+
logged when a merge base could not be resolved, as diffs may be inaccurate without one.
18+
- Reworked how renames are handled. When diffing between revisions, the old path is now reported
19+
as "deleted" and the new path as "added", instead of both being "modified". For statuses, the
20+
old path is now reported as "deleted", instead of being omitted entirely. Additionally, type
21+
changes and unmerged files are now reported, instead of being omitted.
22+
23+
#### 🐞 Fixes
24+
25+
- Fixed an issue where an explicit head revision was ignored when diffing between revisions, and the
26+
current working tree was compared against instead.
27+
- Fixed an issue where diffing against the previous revision would fail in repositories with a
28+
single commit.
29+
- Fixed an issue where file names with spaces or special characters were excluded from file tree
30+
results.
31+
- Fixed an issue where Git submodules added between 2 revisions were not included when diffing.
32+
- Fixed an issue where Git hooks could not be set up from the primary working tree when other
33+
worktrees exist.
34+
- Fixed an issue where moon would take over a hooks directory managed by another tool (husky,
35+
lefthook, etc) when `core.hooksPath` was already configured, overwriting its hook files, and
36+
deleting the entire directory when hooks were disabled.
37+
- Fixed an issue where Windows hook wrappers would not forward arguments containing spaces
38+
correctly, and would arbitrarily cap forwarding at 5 arguments.
39+
- Fixed an issue where PowerShell hooks would mangle user variables that start with `$ARG`, like
40+
`$ARGS`.
41+
342
## 2.3.3
443

544
#### 🛡️ Security

Cargo.lock

Lines changed: 3 additions & 6 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/app/src/queries/changed_files.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,15 @@ async fn query_changed_files_without_stdin(
9999
&& vcs.is_default_branch(&current_branch)
100100
&& options.default_branch;
101101

102-
// Don't check for shallow if base is set,
103-
// since we can assume the user knows what they're doing
102+
// Don't bail on shallow if base is set, since we can assume the
103+
// user knows what they're doing, but still warn them, as the diff
104+
// may be inaccurate without a merge base
104105
if base_value.is_none() {
105106
check_shallow!(vcs);
107+
} else if vcs.is_shallow_checkout().await? {
108+
warn!(
109+
"Detected a shallow checkout while comparing against an explicit base, changed files may be inaccurate. A full Git history is recommended, e.g. a fetch depth of 0."
110+
);
106111
}
107112

108113
let only_local = options.local && base_value.is_none();

crates/cli/tests/exec_test.rs

Lines changed: 50 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -442,13 +442,17 @@ mod exec {
442442
cmd.arg("exec").arg(target("syntaxVar"));
443443
});
444444

445-
assert
446-
.success()
447-
.stdout(predicate::str::contains(if cfg!(windows) {
448-
"substituted-value\nin substituted-value quotes\nprefixed-substituted-value\nsubstituted-value-suffixed"
449-
} else {
450-
"substituted-value in substituted-value quotes prefixed-substituted-value substituted-value-suffixed"
451-
}));
445+
// Windows tasks emit CRLF line endings, so normalize before asserting
446+
let stdout = assert.stdout().replace("\r\n", "\n");
447+
assert.success();
448+
449+
let expected = if cfg!(windows) {
450+
"substituted-value\nin substituted-value quotes\nprefixed-substituted-value\nsubstituted-value-suffixed"
451+
} else {
452+
"substituted-value in substituted-value quotes prefixed-substituted-value substituted-value-suffixed"
453+
};
454+
455+
assert!(stdout.contains(expected), "stdout: {stdout}");
452456
}
453457

454458
#[test]
@@ -954,7 +958,7 @@ mod exec {
954958
.join(".moon/cache/states/outputs/noOutput/stdout.log")
955959
)
956960
.unwrap(),
957-
"No outputs!"
961+
"No outputs!\n"
958962
);
959963
}
960964

@@ -1564,6 +1568,10 @@ mod exec {
15641568
cmd.arg("exec").arg(target("affectedFiles"));
15651569
});
15661570

1571+
// Windows tasks emit CRLF line endings, so normalize before asserting
1572+
let stdout = assert.stdout().replace("\r\n", "\n");
1573+
assert.success();
1574+
15671575
let root = sandbox.path().join(PROJECT_DIR);
15681576

15691577
let mut files = fs::read_dir(&root)
@@ -1587,9 +1595,13 @@ mod exec {
15871595
.join(" ");
15881596
let envs = files.join(if cfg!(windows) { ";" } else { ":" });
15891597

1590-
assert.success().stdout(
1591-
predicate::str::contains(format!("Args: {args}\n"))
1592-
.and(predicate::str::contains(format!("Env: {envs}\n"))),
1598+
assert!(
1599+
stdout.contains(&format!("Args: {args}\n")),
1600+
"stdout: {stdout}"
1601+
);
1602+
assert!(
1603+
stdout.contains(&format!("Env: {envs}\n")),
1604+
"stdout: {stdout}"
15931605
);
15941606
}
15951607

@@ -1612,9 +1624,17 @@ mod exec {
16121624
});
16131625
let envs = ["input1.txt", "input2.txt"].join(if cfg!(windows) { ";" } else { ":" });
16141626

1615-
assert.success().stdout(
1616-
predicate::str::contains("Args: ./input1.txt ./input2.txt\n")
1617-
.and(predicate::str::contains(format!("Env: {envs}\n"))),
1627+
// Windows tasks emit CRLF line endings, so normalize before asserting
1628+
let stdout = assert.stdout().replace("\r\n", "\n");
1629+
assert.success();
1630+
1631+
assert!(
1632+
stdout.contains("Args: ./input1.txt ./input2.txt\n"),
1633+
"stdout: {stdout}"
1634+
);
1635+
assert!(
1636+
stdout.contains(&format!("Env: {envs}\n")),
1637+
"stdout: {stdout}"
16181638
);
16191639
}
16201640

@@ -1636,10 +1656,15 @@ mod exec {
16361656
.arg("--affected");
16371657
});
16381658

1639-
assert.success().stdout(
1640-
predicate::str::contains("Args: ./input1.txt ./input2.txt\n")
1641-
.and(predicate::str::contains("Env: \n")),
1659+
// Windows tasks emit CRLF line endings, so normalize before asserting
1660+
let stdout = assert.stdout().replace("\r\n", "\n");
1661+
assert.success();
1662+
1663+
assert!(
1664+
stdout.contains("Args: ./input1.txt ./input2.txt\n"),
1665+
"stdout: {stdout}"
16421666
);
1667+
assert!(stdout.contains("Env: \n"), "stdout: {stdout}");
16431668
}
16441669

16451670
#[test]
@@ -1661,9 +1686,14 @@ mod exec {
16611686
});
16621687
let envs = ["input1.txt", "input2.txt"].join(if cfg!(windows) { ";" } else { ":" });
16631688

1664-
assert.success().stdout(
1665-
predicate::str::contains("Args: \n")
1666-
.and(predicate::str::contains(format!("Env: {envs}\n"))),
1689+
// Windows tasks emit CRLF line endings, so normalize before asserting
1690+
let stdout = assert.stdout().replace("\r\n", "\n");
1691+
assert.success();
1692+
1693+
assert!(stdout.contains("Args: \n"), "stdout: {stdout}");
1694+
assert!(
1695+
stdout.contains(&format!("Env: {envs}\n")),
1696+
"stdout: {stdout}"
16671697
);
16681698
}
16691699
}

crates/process/src/exec_command.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,10 @@ impl Command {
210210
Ok(output)
211211
}
212212

213+
#[allow(unused, unreachable_code)]
213214
pub async fn exec_stream_and_capture_output(&mut self) -> miette::Result<Output> {
215+
return self.exec_stream_and_capture_output_bytes().await;
216+
214217
let registry = ProcessRegistry::instance();
215218
let instant = Instant::now();
216219
let mut command = self.create_async_command()?;

crates/process/tests/exec_command_test.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ mod exec_stream_and_capture_output {
132132
.unwrap();
133133

134134
assert!(output.success());
135-
assert_eq!(output.stdout, b"a\nb");
135+
assert_eq!(output.stdout, b"a\nb\n");
136136
assert_eq!(output.stderr, b"err");
137137
}
138138
}

crates/vcs-hooks/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ moon_config = { path = "../config" }
1616
moon_hash = { path = "../hash" }
1717
moon_vcs = { path = "../vcs" }
1818
miette = { workspace = true }
19-
rustc-hash = { workspace = true }
19+
regex = { workspace = true }
2020
serde = { workspace = true }
2121
starbase_utils = { workspace = true }
2222
system_env = { workspace = true }
2323
tracing = { workspace = true }
2424

2525
[dev-dependencies]
2626
moon_test_utils = { path = "../test-utils" }
27+
rustc-hash = { workspace = true }
2728
starbase_sandbox = { workspace = true }
2829
tokio = { workspace = true }
2930

crates/vcs-hooks/src/hooks_generator.rs

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,14 @@ use moon_common::{
66
};
77
use moon_config::{VcsConfig, VcsHookFormat};
88
use moon_vcs::VcsHookEnvironment;
9+
use regex::Regex;
910
use starbase_utils::fs;
1011
use std::path::Path;
12+
use std::sync::LazyLock;
1113
use tracing::{debug, instrument, warn};
1214

15+
static ARG_VAR_PATTERN: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\$(ARG\d+)").unwrap());
16+
1317
pub enum ShellType {
1418
Bash,
1519
Pwsh,
@@ -77,7 +81,10 @@ impl<'app> HooksGenerator<'app> {
7781

7882
self.remove_hook_files(&hooks_dir, &state.data.hook_names)?;
7983

80-
if hooks_dir.exists() {
84+
// Only remove the directory itself when moon owns it. State from
85+
// older moon versions may reference a directory managed by another
86+
// tool (husky, lefthook, etc), which we should not delete!
87+
if (dir == ".moon/hooks" || dir == ".config/moon/hooks") && hooks_dir.exists() {
8188
fs::remove_dir_all(hooks_dir)?;
8289
}
8390
}
@@ -203,7 +210,7 @@ impl<'app> HooksGenerator<'app> {
203210
hook_name: &str,
204211
commands: &[String],
205212
) -> miette::Result<()> {
206-
let hook = self.format_hook_file(commands, true)?;
213+
let hook = self.format_hook_file(commands)?;
207214

208215
// Bash only
209216
if self.is_bash_format() {
@@ -224,21 +231,31 @@ impl<'app> HooksGenerator<'app> {
224231
// Create a bash hook to call the PowerShell script
225232
let bash_hook_path = env.hooks_dir.join(hook_name);
226233

227-
self.write_file(&bash_hook_path, format!(
228-
"#!/bin/sh\n{} -NoLogo -NoProfile -ExecutionPolicy Bypass -File \"{}\" $1 $2 $3 $4 $5",
229-
if matches!(self.shell, ShellType::Pwsh) {
230-
"pwsh.exe"
231-
} else {
232-
"powershell.exe"
233-
},
234-
hook_path.relative_to(&env.working_dir).unwrap(),
235-
))?;
234+
// Prefer a path relative from the working directory, which is
235+
// where hooks are executed from, but fall back to the absolute
236+
// path when the hooks directory is outside of it
237+
let script_path = match hook_path.relative_to(&env.working_dir) {
238+
Ok(rel_path) => rel_path.to_string(),
239+
Err(_) => hook_path.to_string_lossy().to_string(),
240+
};
241+
242+
self.write_file(
243+
&bash_hook_path,
244+
format!(
245+
"#!/bin/sh\n{} -NoLogo -NoProfile -ExecutionPolicy Bypass -File \"{script_path}\" \"$@\"",
246+
if matches!(self.shell, ShellType::Pwsh) {
247+
"pwsh.exe"
248+
} else {
249+
"powershell.exe"
250+
},
251+
),
252+
)?;
236253
}
237254

238255
Ok(())
239256
}
240257

241-
fn format_hook_file(&self, commands: &[String], with_header: bool) -> miette::Result<String> {
258+
fn format_hook_file(&self, commands: &[String]) -> miette::Result<String> {
242259
let mut contents = vec![];
243260

244261
if self.is_bash_format() {
@@ -270,13 +287,11 @@ impl<'app> HooksGenerator<'app> {
270287
]);
271288
}
272289

273-
if with_header {
274-
contents.extend([
275-
"# Automatically generated by moon. DO NOT MODIFY!",
276-
"# https://moonrepo.dev/docs/guides/vcs-hooks",
277-
"",
278-
]);
279-
}
290+
contents.extend([
291+
"# Automatically generated by moon. DO NOT MODIFY!",
292+
"# https://moonrepo.dev/docs/guides/vcs-hooks",
293+
"",
294+
]);
280295

281296
for command in commands {
282297
contents.push(command);
@@ -298,7 +313,11 @@ impl<'app> HooksGenerator<'app> {
298313
let mut content = contents.join("\n");
299314

300315
if !self.is_bash_format() {
301-
content = content.replace("$ARG", "$env:ARG");
316+
// Only rewrite the variables that we inject (ARG0, ARG1, etc),
317+
// otherwise user variables like $ARGS would also be mangled
318+
content = ARG_VAR_PATTERN
319+
.replace_all(&content, "$$env:$1")
320+
.to_string();
302321
}
303322

304323
Ok(content)

0 commit comments

Comments
 (0)