Skip to content

Commit 842dabe

Browse files
milesjclaude
andauthored
fix: Exclude local index from changed files when an explicit head is provided (#2617)
* fix: Exclude local index from changed files when an explicit head is provided. `moon query changed-files` (and affected detection) always merged `git status` into the results, even when both `--base` and `--head` were passed as explicit revisions. Local uncommitted changes and untracked files showed up as false positives when walking history commit-by-commit (issue #2511, remaining half after the v2.4 rework fixed the dropped `--head`). The local index is now only consulted when no explicit head is provided, i.e. when the comparison targets the working tree. Head-only queries previously ignored the head in local mode (returning local status only); they now diff merge-base(defaultBranch, head)..head. Additionally, `MOON_BASE`/`MOON_HEAD` values that are set but empty are treated as not provided, as CI templates commonly pass these through unconditionally. Closes #2511 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: Do not let empty base/head environment variables mask explicit options. Review feedback: the empty-value filter ran after the env/option fallback, so MOON_BASE="" or MOON_HEAD="" would discard an explicitly passed --base/--head. Filter the environment variables before falling back, and filter the options after. Also reworded the docs bullet and fixed changelog punctuation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e115849 commit 842dabe

4 files changed

Lines changed: 161 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,11 @@
3535
- Fixed tasks that emit read-only outputs (e.g. `0444` files) failing on every cache hit with a
3636
"Permission denied" error when the `casOutputsCache` experiment is enabled. Caches that already
3737
contain read-only objects are healed automatically.
38+
- Fixed an issue where `moon query changed-files` would include uncommitted changes from the local
39+
index (`git status`) even when an explicit `--head` revision was provided, causing false positives
40+
when comparing 2 revisions. This also applies to affected detection with an explicit head, e.g.
41+
the `MOON_HEAD` environment variable or `--affected base:head`. Additionally, `MOON_BASE` and
42+
`MOON_HEAD` environment variables that are set but empty are now ignored.
3843
- Fixed an issue where synced VCS hooks were always written to `.moon/hooks`, even when the workspace
3944
configuration lived in `.config/moon`. Hooks are now placed alongside the config, in
4045
`.config/moon/hooks`.

crates/app/src/queries/changed_files.rs

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,20 @@ async fn query_changed_files_without_stdin(
8787
let bag = GlobalEnvBag::instance();
8888
let default_branch = vcs.get_default_branch().await?;
8989
let current_branch = vcs.get_local_branch().await?;
90-
let base_value = bag.get("MOON_BASE").or(options.base.clone());
90+
// Treat empty values as not provided, as CI templates typically
91+
// pass these environment variables through unconditionally. An empty
92+
// environment variable must not mask an explicit option either
93+
let base_value = bag
94+
.get("MOON_BASE")
95+
.filter(|value| !value.is_empty())
96+
.or(options.base.clone())
97+
.filter(|value| !value.is_empty());
9198
let base = base_value.as_deref().unwrap_or(&default_branch);
92-
let head_value = bag.get("MOON_HEAD").or(options.head.clone());
99+
let head_value = bag
100+
.get("MOON_HEAD")
101+
.filter(|value| !value.is_empty())
102+
.or(options.head.clone())
103+
.filter(|value| !value.is_empty());
93104
let head = head_value.as_deref().unwrap_or("HEAD");
94105

95106
// Determine whether we should check against the previous
@@ -110,7 +121,7 @@ async fn query_changed_files_without_stdin(
110121
);
111122
}
112123

113-
let only_local = options.local && base_value.is_none();
124+
let only_local = options.local && base_value.is_none() && head_value.is_none();
114125
let mut changed_files_map = ChangedFiles::default();
115126
let mut changed_files = FxHashSet::default();
116127

@@ -138,10 +149,14 @@ async fn query_changed_files_without_stdin(
138149
}
139150
}
140151

141-
// Always include local changes
142-
debug!("Against local index");
152+
// Only include local changes when the head is the working tree;
153+
// an explicit head requests a comparison between 2 revisions,
154+
// of which the local index is not a part of
155+
if head_value.is_none() {
156+
debug!("Against local index");
143157

144-
changed_files_map.merge(vcs.get_changed_files().await?);
158+
changed_files_map.merge(vcs.get_changed_files().await?);
159+
}
145160

146161
if options.status.is_empty() {
147162
debug!(

crates/cli/tests/query_changed_files_test.rs

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,138 @@ mod query_changed_files {
4848
assert_eq!(json.options.status, vec![ChangedStatus::Deleted]);
4949
}
5050

51+
#[test]
52+
fn excludes_local_index_with_explicit_head() {
53+
let sandbox = create_query_sandbox();
54+
55+
sandbox.create_file("basic/committed.txt", "contents");
56+
57+
sandbox.run_git(|cmd| {
58+
cmd.args(["add", "--all", "."]);
59+
});
60+
61+
sandbox.run_git(|cmd| {
62+
cmd.args(["commit", "-m", "Commit"]);
63+
});
64+
65+
// Should not appear in the revision to revision diff
66+
sandbox.create_file("basic/dirty.txt", "contents");
67+
68+
let assert = sandbox.run_bin(|cmd| {
69+
cmd.arg("query")
70+
.arg("changed-files")
71+
.args(["--base", "HEAD~1", "--head", "HEAD"]);
72+
});
73+
74+
let json: QueryChangedFilesResult = serde_json::from_str(assert.stdout().trim()).unwrap();
75+
76+
assert_eq!(
77+
json.files.into_iter().collect::<Vec<_>>(),
78+
["basic/committed.txt"]
79+
);
80+
}
81+
82+
#[test]
83+
fn includes_local_index_without_explicit_head() {
84+
let sandbox = create_query_sandbox();
85+
86+
sandbox.create_file("basic/committed.txt", "contents");
87+
88+
sandbox.run_git(|cmd| {
89+
cmd.args(["add", "--all", "."]);
90+
});
91+
92+
sandbox.run_git(|cmd| {
93+
cmd.args(["commit", "-m", "Commit"]);
94+
});
95+
96+
sandbox.create_file("basic/dirty.txt", "contents");
97+
98+
let assert = sandbox.run_bin(|cmd| {
99+
cmd.arg("query")
100+
.arg("changed-files")
101+
.args(["--base", "HEAD~1"]);
102+
});
103+
104+
let json: QueryChangedFilesResult = serde_json::from_str(assert.stdout().trim()).unwrap();
105+
106+
let mut files = json
107+
.files
108+
.into_iter()
109+
.filter(|file| !file.as_str().starts_with(".moon"))
110+
.collect::<Vec<_>>();
111+
files.sort();
112+
113+
assert_eq!(files, ["basic/committed.txt", "basic/dirty.txt"]);
114+
}
115+
116+
#[test]
117+
fn treats_empty_head_as_working_tree() {
118+
let sandbox = create_query_sandbox();
119+
120+
sandbox.create_file("basic/committed.txt", "contents");
121+
122+
sandbox.run_git(|cmd| {
123+
cmd.args(["add", "--all", "."]);
124+
});
125+
126+
sandbox.run_git(|cmd| {
127+
cmd.args(["commit", "-m", "Commit"]);
128+
});
129+
130+
sandbox.create_file("basic/dirty.txt", "contents");
131+
132+
let assert = sandbox.run_bin(|cmd| {
133+
cmd.arg("query")
134+
.arg("changed-files")
135+
.args(["--base", "HEAD~1"])
136+
.env("MOON_HEAD", "");
137+
});
138+
139+
let json: QueryChangedFilesResult = serde_json::from_str(assert.stdout().trim()).unwrap();
140+
141+
let mut files = json
142+
.files
143+
.into_iter()
144+
.filter(|file| !file.as_str().starts_with(".moon"))
145+
.collect::<Vec<_>>();
146+
files.sort();
147+
148+
assert_eq!(files, ["basic/committed.txt", "basic/dirty.txt"]);
149+
}
150+
151+
#[test]
152+
fn empty_env_vars_dont_mask_explicit_args() {
153+
let sandbox = create_query_sandbox();
154+
155+
sandbox.create_file("basic/committed.txt", "contents");
156+
157+
sandbox.run_git(|cmd| {
158+
cmd.args(["add", "--all", "."]);
159+
});
160+
161+
sandbox.run_git(|cmd| {
162+
cmd.args(["commit", "-m", "Commit"]);
163+
});
164+
165+
sandbox.create_file("basic/dirty.txt", "contents");
166+
167+
let assert = sandbox.run_bin(|cmd| {
168+
cmd.arg("query")
169+
.arg("changed-files")
170+
.args(["--base", "HEAD~1", "--head", "HEAD"])
171+
.env("MOON_BASE", "")
172+
.env("MOON_HEAD", "");
173+
});
174+
175+
let json: QueryChangedFilesResult = serde_json::from_str(assert.stdout().trim()).unwrap();
176+
177+
assert_eq!(
178+
json.files.into_iter().collect::<Vec<_>>(),
179+
["basic/committed.txt"]
180+
);
181+
}
182+
51183
#[test]
52184
fn can_supply_multi_status() {
53185
let sandbox = create_query_sandbox();

website/docs/commands/query/changed-files.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ Changed files are determined using the following logic:
1515
uses.
1616
- If `--local` is provided, changed files are based on your local index only (`git status`).
1717
- Otherwise, then compare the defined base (`--base`) against head (`--head`).
18+
- When no explicit `--head` is provided, the comparison targets your working tree, so changed files
19+
in your local index (`git status`) are also included. An explicit `--head` is a pure revision to
20+
revision comparison, and does not include the local index.
1821

1922
```shell
2023
# Return all files

0 commit comments

Comments
 (0)