Skip to content

Commit df4fefa

Browse files
committed
fix to windows
1 parent a7f25d2 commit df4fefa

6 files changed

Lines changed: 149 additions & 24 deletions

File tree

‎CHANGELOG.md‎

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

3+
## Unreleased
4+
5+
### Security
6+
7+
- On Windows, a submodule's own config is read again, so its filter drivers
8+
no longer run on every refresh. Git for Windows would not open the path
9+
gitst gave it, and the guard went without that config.
10+
- A submodule's config that cannot be read, or a `.gitmodules` that cannot,
11+
shows a `repo config not read` warning naming the file, instead of being
12+
skipped without a word. The guard goes without that config, so the
13+
submodule's filters may run. The file is tried again on the next refresh.
14+
315
## 0.1.12 (2026-10-09)
416

517
### Security

‎Cargo.lock‎

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "gitst"
3-
version = "0.1.12"
3+
version = "0.1.13"
44
edition = "2024"
55
description = "Live, glanceable git status TUI for small terminal panes"
66
license = "MIT"

‎src/git/cli.rs‎

Lines changed: 97 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
//! `GitBackend` implemented by running the `git` command.
22
3+
use std::borrow::Cow;
34
use std::collections::{BTreeSet, HashMap};
45
use std::io::Read;
56
use std::path::{Path, PathBuf};
@@ -184,9 +185,12 @@ impl CliBackend {
184185

185186
/// Config of the submodules' repositories, whose git runs inside a
186187
/// status with the same overrides. Covers git directories under
187-
/// `modules/` and the submodules `.gitmodules` lists.
188-
fn submodule_config(&self) -> Vec<ConfigEntry> {
188+
/// `modules/` and the submodules `.gitmodules` lists. Also returns why
189+
/// one of those files could not be read, if one could not: the guard
190+
/// then goes without it, and a submodule's filters may run.
191+
fn submodule_config(&self) -> (Vec<ConfigEntry>, Option<String>) {
189192
let mut files = Vec::new();
193+
let mut error = None;
190194
find_configs(&self.repo.common_dir.join("modules"), 0, &mut files);
191195
let gitmodules = self.repo.root.join(".gitmodules");
192196
if gitmodules.is_file() {
@@ -196,7 +200,10 @@ impl CliBackend {
196200
&["config", "--file", ".gitmodules", "--list", "-z"],
197201
MAX_OUTPUT,
198202
)
199-
.unwrap_or_default();
203+
.unwrap_or_else(|e| {
204+
error = Some(format!(".gitmodules: {}", e.message));
205+
Vec::new()
206+
});
200207
for entry in listed.split(|b| *b == 0) {
201208
let entry = String::from_utf8_lossy(entry);
202209
if let Some((key, path)) = entry.split_once('\n')
@@ -217,6 +224,11 @@ impl CliBackend {
217224
}
218225
let read = |f: &Path| {
219226
let f = f.to_string_lossy();
227+
let f = if cfg!(windows) {
228+
without_verbatim_prefix(&f)
229+
} else {
230+
Cow::Borrowed(&*f)
231+
};
220232
let args = [
221233
"config",
222234
"--file",
@@ -228,26 +240,32 @@ impl CliBackend {
228240
];
229241
self.run(base_command(&self.repo.root), &args, MAX_OUTPUT)
230242
.map(|raw| parse::parse_config(&raw))
231-
.unwrap_or_default()
232243
};
233244
let mut cache = self
234245
.submodule_configs
235246
.lock()
236247
.unwrap_or_else(PoisonError::into_inner);
237-
cached_configs(&mut cache, files, &read)
248+
let (entries, unread) = cached_configs(&mut cache, files, &read);
249+
let unread = unread.map(|(f, e)| {
250+
let f = f.strip_prefix(&self.repo.root).unwrap_or(&f);
251+
format!("{}: {e}", f.display())
252+
});
253+
(entries, error.or(unread))
238254
}
239255

240256
/// Reads the config and replaces the overrides every later command
241-
/// gets. Returns the config, and why it could not be read if it could
242-
/// not: the guard then has only its defaults.
257+
/// gets. Returns the config, and why it, or a submodule's, could not be
258+
/// read if it could not: the guard then has only its defaults, or goes
259+
/// without that submodule's config.
243260
fn refresh_guard(&self) -> (Vec<ConfigEntry>, Option<String>) {
244261
let (config, error) = match self.read_config() {
245262
Ok(config) => (config, None),
246263
Err(e) => (Vec::new(), Some(e.message)),
247264
};
248-
let guard = Guard::new(&config, &self.submodule_config(), &Guard::env);
265+
let (submodules, submodule_error) = self.submodule_config();
266+
let guard = Guard::new(&config, &submodules, &Guard::env);
249267
*self.guard.lock().unwrap_or_else(PoisonError::into_inner) = Arc::new(guard);
250-
(config, error)
268+
(config, error.or(submodule_error))
251269
}
252270

253271
/// Commits on a local branch or HEAD that no remote-tracking ref
@@ -506,14 +524,17 @@ struct CachedConfig {
506524

507525
/// The entries of `files`, in order, reading with `read` only a file that
508526
/// is new or whose size or modification time moved since it was last
509-
/// read. Files no longer listed leave the cache.
527+
/// read. Files no longer listed leave the cache. A file that could not be
528+
/// read is left out and not cached, so the next call tries it again; the
529+
/// first such file comes back with why.
510530
fn cached_configs(
511531
cache: &mut HashMap<PathBuf, CachedConfig>,
512532
files: Vec<PathBuf>,
513-
read: &dyn Fn(&Path) -> Vec<ConfigEntry>,
514-
) -> Vec<ConfigEntry> {
533+
read: &dyn Fn(&Path) -> Result<Vec<ConfigEntry>, GitError>,
534+
) -> (Vec<ConfigEntry>, Option<(PathBuf, String)>) {
515535
cache.retain(|p, _| files.contains(p));
516536
let mut out = Vec::new();
537+
let mut error = None;
517538
for f in files {
518539
let Ok(meta) = std::fs::metadata(&f) else {
519540
cache.remove(&f);
@@ -524,7 +545,14 @@ fn cached_configs(
524545
.get(&f)
525546
.is_some_and(|c| c.len == len && c.mtime == mtime);
526547
if !fresh {
527-
let entries = read(&f);
548+
let entries = match read(&f) {
549+
Ok(entries) => entries,
550+
Err(e) => {
551+
cache.remove(&f);
552+
error.get_or_insert((f, e.message));
553+
continue;
554+
}
555+
};
528556
cache.insert(
529557
f.clone(),
530558
CachedConfig {
@@ -536,7 +564,19 @@ fn cached_configs(
536564
}
537565
out.extend(cache[&f].entries.iter().cloned());
538566
}
539-
out
567+
(out, error)
568+
}
569+
570+
/// `path` without the `\\?\` that `canonicalize` puts in front of it on
571+
/// Windows. Git for Windows will not open a path with a `?` in it (it can
572+
/// still change into one), so a `--file` with the prefix reads nothing.
573+
/// `\\?\UNC\server\share` goes back to `\\server\share`.
574+
fn without_verbatim_prefix(path: &str) -> Cow<'_, str> {
575+
if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
576+
Cow::Owned(format!(r"\\{rest}"))
577+
} else {
578+
Cow::Borrowed(path.strip_prefix(r"\\?\").unwrap_or(path))
579+
}
540580
}
541581

542582
/// Collects `config` and `config.worktree` files of the git directories
@@ -1343,6 +1383,15 @@ fn wait_grace(child: &mut Child) {
13431383

13441384
#[cfg(test)]
13451385
mod tests {
1386+
#[test]
1387+
fn verbatim_prefixes_are_taken_off_for_git() {
1388+
use super::without_verbatim_prefix as plain;
1389+
assert_eq!(plain(r"\\?\C:\r\.git\config"), r"C:\r\.git\config");
1390+
assert_eq!(plain(r"\\?\UNC\srv\share\config"), r"\\srv\share\config");
1391+
assert_eq!(plain(r"C:\r\.git\config"), r"C:\r\.git\config");
1392+
assert_eq!(plain("/r/.git/config"), "/r/.git/config");
1393+
}
1394+
13461395
#[test]
13471396
fn submodule_config_files_are_read_once_until_they_change() {
13481397
use std::cell::Cell;
@@ -1356,29 +1405,56 @@ mod tests {
13561405
let reads = Cell::new(0);
13571406
let read = |p: &Path| {
13581407
reads.set(reads.get() + 1);
1359-
vec![super::ConfigEntry {
1408+
Ok(vec![super::ConfigEntry {
13601409
scope: "local".into(),
13611410
key: "a.b".into(),
13621411
value: std::fs::read_to_string(p).unwrap().trim().to_string(),
1363-
}]
1412+
}])
13641413
};
1365-
let first = super::cached_configs(&mut cache, vec![f.clone()], &read);
1366-
assert_eq!(first.len(), 1);
1414+
let (first, error) = super::cached_configs(&mut cache, vec![f.clone()], &read);
1415+
assert_eq!((first.len(), error), (1, None));
13671416
assert_eq!(
1368-
super::cached_configs(&mut cache, vec![f.clone()], &read),
1417+
super::cached_configs(&mut cache, vec![f.clone()], &read).0,
13691418
first
13701419
);
13711420
assert_eq!(reads.get(), 1, "an unchanged file is not read again");
13721421
std::fs::write(&f, "[a]\n\tb = 22\n").unwrap();
13731422
assert_ne!(
1374-
super::cached_configs(&mut cache, vec![f.clone()], &read),
1423+
super::cached_configs(&mut cache, vec![f.clone()], &read).0,
13751424
first
13761425
);
13771426
assert_eq!(reads.get(), 2);
1378-
assert!(super::cached_configs(&mut cache, vec![], &read).is_empty());
1427+
assert!(
1428+
super::cached_configs(&mut cache, vec![], &read)
1429+
.0
1430+
.is_empty()
1431+
);
13791432
assert!(cache.is_empty(), "a file gone is forgotten");
13801433
}
13811434

1435+
#[test]
1436+
fn a_submodule_config_that_cannot_be_read_is_reported_and_read_again() {
1437+
use std::cell::Cell;
1438+
use std::collections::HashMap;
1439+
use std::path::Path;
1440+
1441+
let dir = tempfile::tempdir().unwrap();
1442+
let f = dir.path().join("config");
1443+
std::fs::write(&f, "[a]\n\tb = 1\n").unwrap();
1444+
let mut cache = HashMap::new();
1445+
let reads = Cell::new(0);
1446+
let read = |_: &Path| {
1447+
reads.set(reads.get() + 1);
1448+
Err(super::GitError::new("bad config line 1"))
1449+
};
1450+
for n in 1..=2 {
1451+
let (entries, error) = super::cached_configs(&mut cache, vec![f.clone()], &read);
1452+
assert!(entries.is_empty());
1453+
assert_eq!(error, Some((f.clone(), "bad config line 1".to_string())));
1454+
assert_eq!(reads.get(), n, "a failed read is not cached");
1455+
}
1456+
}
1457+
13821458
use std::ffi::OsStr;
13831459

13841460
use super::*;

‎src/model.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,8 @@ pub struct Snapshot {
220220
pub leak_scan_error: Option<String>,
221221
/// Why the repository's config could not be read, when it could not:
222222
/// its settings then count as unset, and the guard applies only its
223-
/// defaults.
223+
/// defaults. Otherwise, why a submodule's config could not be: the
224+
/// guard then goes without it.
224225
pub config_error: Option<String>,
225226
}
226227

‎tests/git_integration.rs‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1227,6 +1227,42 @@ fn a_submodules_own_config_runs_nothing_on_a_refresh() {
12271227
assert_eq!(take_log(&log), "");
12281228
}
12291229

1230+
#[test]
1231+
fn a_submodule_config_that_cannot_be_read_is_warned_about() {
1232+
let lib = TestRepo::new();
1233+
lib.commit_file("f", "1\n", "lib");
1234+
let r = TestRepo::new();
1235+
r.commit_file("a", "1", "one");
1236+
r.git(&[
1237+
"-c",
1238+
"protocol.file.allow=always",
1239+
"submodule",
1240+
"add",
1241+
"-q",
1242+
lib.path().to_str().unwrap(),
1243+
"lib",
1244+
]);
1245+
r.git(&["commit", "-qm", "add lib"]);
1246+
// A broken file the submodule's config includes only when read from
1247+
// the superproject, as gitst reads it: git itself still works.
1248+
let bad = r.dir.path().join("bad.config");
1249+
std::fs::write(&bad, "[\n").unwrap();
1250+
let git_dir = r.git(&["rev-parse", "--absolute-git-dir"]);
1251+
let git_dir = git_dir.trim().replace('\\', "/");
1252+
let sub = r.path().join("lib");
1253+
r.git(&[
1254+
"-C",
1255+
sub.to_str().unwrap(),
1256+
"config",
1257+
&format!("includeIf.gitdir:{git_dir}.path"),
1258+
&bad.display().to_string().replace('\\', "/"),
1259+
]);
1260+
r.git(&["--no-optional-locks", "status"]);
1261+
let s = snap(&r);
1262+
let e = s.config_error.unwrap_or_default();
1263+
assert!(e.contains("modules") && e.contains("bad config"), "{e:?}");
1264+
}
1265+
12301266
#[test]
12311267
fn the_repositorys_own_config_runs_nothing_on_a_fetch() {
12321268
let r = TestRepo::new();

0 commit comments

Comments
 (0)