|
1 | | -//! The `git` queries weave asks, and the four ways one can fail. |
| 1 | +//! The `git` queries weave asks, and the ways one can fail. |
2 | 2 | //! |
3 | 3 | //! Every function here shells out to `git`, and every one of them used to |
4 | 4 | //! answer failure with `Box<dyn Error>` built from a `format!` string — so |
@@ -50,6 +50,17 @@ pub enum GitError { |
50 | 50 | status: i32, |
51 | 51 | stderr: String, |
52 | 52 | }, |
| 53 | + |
| 54 | + /// `git` answered fine, but a filesystem operation this module needed on |
| 55 | + /// top of that answer (writing the local exclude file) failed. Distinct |
| 56 | + /// from [`GitError::NotRunnable`] because the process that failed here is |
| 57 | + /// our own I/O, not git's. |
| 58 | + #[error("could not update {path}: {source}")] |
| 59 | + Io { |
| 60 | + path: String, |
| 61 | + #[source] |
| 62 | + source: std::io::Error, |
| 63 | + }, |
53 | 64 | } |
54 | 65 |
|
55 | 66 | /// Run `git -C <dir> <args>` and hand back its output, or say which way it |
@@ -237,6 +248,82 @@ pub fn diff_files( |
237 | 248 | Ok(files) |
238 | 249 | } |
239 | 250 |
|
| 251 | +/// Make sure `entry` (a `.gitignore`-style pattern) is listed in this |
| 252 | +/// repository's *local* exclude file, without touching anything the |
| 253 | +/// repository itself tracks. |
| 254 | +/// |
| 255 | +/// Weave writes its own coordination state (`.weave/`) into a repo's working |
| 256 | +/// tree, and that state must never show up in `git status`, get swept into |
| 257 | +/// `git add -A`, or ride along in a generated patch — it is local machine |
| 258 | +/// state, not repository content. `.git/info/exclude` is the git-native place |
| 259 | +/// for exactly that: it behaves like a `.gitignore` but lives inside `.git/`, |
| 260 | +/// so it is never committed, never pushed, and never collides with a |
| 261 | +/// `.gitignore` the repository's own maintainers write and version. |
| 262 | +/// |
| 263 | +/// Resolved via `git rev-parse --git-path info/exclude` rather than assuming |
| 264 | +/// `<repo_root>/.git/info/exclude`, so this also lands in the right place for |
| 265 | +/// a linked worktree (whose private git dir is elsewhere) or a submodule |
| 266 | +/// (whose `.git` is a file, not a directory) — anywhere git itself would |
| 267 | +/// consider "the local exclude file for this working tree". |
| 268 | +/// |
| 269 | +/// Idempotent: a second call with the same `entry` is a no-op, so this is |
| 270 | +/// safe to call on every save rather than only on first creation — which |
| 271 | +/// also means a repository whose `.weave/` directory predates this fix gets |
| 272 | +/// it retroactively on its very next save. |
| 273 | +/// |
| 274 | +/// A best-effort courtesy, not a requirement: `repo_root` not being inside a |
| 275 | +/// git repository, or `git` not being runnable at all, comes back `Ok(())` — |
| 276 | +/// silently doing nothing is correct there, since there is no repository |
| 277 | +/// tracking to protect against. Only an I/O failure on a `.git` this call |
| 278 | +/// *did* manage to identify is reported, and even that is expected to be |
| 279 | +/// swallowed by callers for whom this is advisory (see |
| 280 | +/// `weave_crdt::EntityStateDoc::save`, which must never fail to write state |
| 281 | +/// just because the exclude file couldn't be touched). |
| 282 | +pub fn ensure_locally_excluded(repo_root: &Path, entry: &str) -> Result<(), GitError> { |
| 283 | + let args = ["rev-parse", "--git-path", "info/exclude"]; |
| 284 | + let output = run(repo_root, &args)?; |
| 285 | + if !output.status.success() { |
| 286 | + // Not inside a git repository (or some other refusal) — nothing to |
| 287 | + // exclude from, and that's fine. |
| 288 | + return Ok(()); |
| 289 | + } |
| 290 | + let printed = String::from_utf8_lossy(&output.stdout).trim().to_string(); |
| 291 | + if printed.is_empty() { |
| 292 | + return Ok(()); |
| 293 | + } |
| 294 | + let exclude_path = { |
| 295 | + let p = PathBuf::from(&printed); |
| 296 | + if p.is_absolute() { |
| 297 | + p |
| 298 | + } else { |
| 299 | + repo_root.join(p) |
| 300 | + } |
| 301 | + }; |
| 302 | + |
| 303 | + let io_err = |source: std::io::Error| GitError::Io { |
| 304 | + path: exclude_path.display().to_string(), |
| 305 | + source, |
| 306 | + }; |
| 307 | + |
| 308 | + if let Some(parent) = exclude_path.parent() { |
| 309 | + std::fs::create_dir_all(parent).map_err(io_err)?; |
| 310 | + } |
| 311 | + |
| 312 | + let existing = std::fs::read_to_string(&exclude_path).unwrap_or_default(); |
| 313 | + if existing.lines().any(|line| line.trim() == entry) { |
| 314 | + return Ok(()); // already excluded |
| 315 | + } |
| 316 | + |
| 317 | + let mut updated = existing; |
| 318 | + if !updated.is_empty() && !updated.ends_with('\n') { |
| 319 | + updated.push('\n'); |
| 320 | + } |
| 321 | + updated.push_str(entry); |
| 322 | + updated.push('\n'); |
| 323 | + std::fs::write(&exclude_path, updated).map_err(io_err)?; |
| 324 | + Ok(()) |
| 325 | +} |
| 326 | + |
240 | 327 | #[cfg(test)] |
241 | 328 | mod tests { |
242 | 329 | use super::*; |
@@ -286,4 +373,78 @@ mod tests { |
286 | 373 | other => panic!("expected NoMergeBase, got {other:?}"), |
287 | 374 | } |
288 | 375 | } |
| 376 | + |
| 377 | + fn init_repo(dir: &Path) { |
| 378 | + for args in [ |
| 379 | + vec!["init", "-q"], |
| 380 | + vec!["config", "user.email", "test@example.com"], |
| 381 | + vec!["config", "user.name", "Test"], |
| 382 | + ] { |
| 383 | + let status = Command::new("git") |
| 384 | + .arg("-C") |
| 385 | + .arg(dir) |
| 386 | + .args(&args) |
| 387 | + .status() |
| 388 | + .expect("git must be runnable for this test"); |
| 389 | + assert!(status.success(), "git {args:?} failed"); |
| 390 | + } |
| 391 | + } |
| 392 | + |
| 393 | + #[test] |
| 394 | + fn ensure_locally_excluded_writes_the_entry_to_info_exclude_not_gitignore() { |
| 395 | + let dir = tempfile::tempdir().expect("tempdir"); |
| 396 | + init_repo(dir.path()); |
| 397 | + |
| 398 | + ensure_locally_excluded(dir.path(), ".weave/").expect("ensure_locally_excluded"); |
| 399 | + |
| 400 | + let exclude = std::fs::read_to_string(dir.path().join(".git/info/exclude")) |
| 401 | + .expect("info/exclude should exist"); |
| 402 | + assert!(exclude.lines().any(|l| l == ".weave/")); |
| 403 | + assert!( |
| 404 | + !dir.path().join(".gitignore").exists(), |
| 405 | + "must never create or touch the repository's own .gitignore" |
| 406 | + ); |
| 407 | + } |
| 408 | + |
| 409 | + #[test] |
| 410 | + fn ensure_locally_excluded_is_idempotent() { |
| 411 | + let dir = tempfile::tempdir().expect("tempdir"); |
| 412 | + init_repo(dir.path()); |
| 413 | + |
| 414 | + ensure_locally_excluded(dir.path(), ".weave/").expect("first call"); |
| 415 | + ensure_locally_excluded(dir.path(), ".weave/").expect("second call"); |
| 416 | + ensure_locally_excluded(dir.path(), ".weave/").expect("third call"); |
| 417 | + |
| 418 | + let exclude = std::fs::read_to_string(dir.path().join(".git/info/exclude")) |
| 419 | + .expect("info/exclude should exist"); |
| 420 | + let hits = exclude.lines().filter(|l| *l == ".weave/").count(); |
| 421 | + assert_eq!(hits, 1, "repeated calls must not duplicate the entry"); |
| 422 | + } |
| 423 | + |
| 424 | + #[test] |
| 425 | + fn ensure_locally_excluded_preserves_an_existing_exclude_file() { |
| 426 | + let dir = tempfile::tempdir().expect("tempdir"); |
| 427 | + init_repo(dir.path()); |
| 428 | + let exclude_path = dir.path().join(".git/info/exclude"); |
| 429 | + std::fs::write(&exclude_path, "*.local-scratch\n").expect("seed existing exclude"); |
| 430 | + |
| 431 | + ensure_locally_excluded(dir.path(), ".weave/").expect("ensure_locally_excluded"); |
| 432 | + |
| 433 | + let exclude = std::fs::read_to_string(&exclude_path).expect("read exclude"); |
| 434 | + assert!(exclude.lines().any(|l| l == "*.local-scratch")); |
| 435 | + assert!(exclude.lines().any(|l| l == ".weave/")); |
| 436 | + } |
| 437 | + |
| 438 | + #[test] |
| 439 | + fn ensure_locally_excluded_is_a_silent_no_op_outside_a_repository() { |
| 440 | + let dir = tempfile::tempdir().expect("tempdir (not a git repo)"); |
| 441 | + |
| 442 | + let result = ensure_locally_excluded(dir.path(), ".weave/"); |
| 443 | + |
| 444 | + assert!(result.is_ok(), "must not fail just because there's no repo"); |
| 445 | + assert!( |
| 446 | + !dir.path().join(".git").exists(), |
| 447 | + "must not create a .git directory as a side effect" |
| 448 | + ); |
| 449 | + } |
289 | 450 | } |
0 commit comments