Skip to main content

dev_prune/
config.rs

1// Copyright 2026 VKrishna04
2// SPDX-License-Identifier: Apache-2.0
3
4// Configuration and registry management for dev-prune.
5//
6// This module handles persistent storage of:
7// - Global settings (idle threshold, check interval, daemon toggle)
8// - Registered repository paths and their metadata
9//
10// All data is stored in `~/.config/dev-prune/registry.json`.
11
12use std::collections::{HashMap, HashSet};
13use std::fs;
14use std::io::Write as _;
15use std::path::{Path, PathBuf};
16
17use anyhow::{Context, Result};
18use chrono::{DateTime, Utc};
19use serde::{Deserialize, Serialize};
20
21use crate::constants;
22
23/// Global settings that control prune behavior.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
25pub struct Settings {
26    /// Number of inactive days before a repo is eligible for pruning.
27    pub idle_days: u64,
28    /// Interval in days between automated daemon checks.
29    pub check_interval_days: u64,
30    /// Whether the setup pass installs the OS scheduler. On by default.
31    pub auto_daemon: bool,
32    /// Whether the setup pass installs the global Git hooks. On by default.
33    #[serde(default = "default_auto_hooks")]
34    pub auto_hooks: bool,
35    /// Whether dev-prune installs its own missing integrations. On by default.
36    #[serde(default = "default_auto_setup")]
37    pub auto_setup: bool,
38    /// Whether interactive confirmation is required before pruning.
39    #[serde(default = "default_require_confirmation")]
40    pub require_confirmation: bool,
41    /// Timeout in seconds for lockfile enforcement / CLI commands (default 600s = 10m).
42    #[serde(default = "default_command_timeout_secs")]
43    pub command_timeout_secs: u64,
44    /// Smallest bloat directory worth deleting, in MiB. `0` disables the floor.
45    ///
46    /// Below this size the reinstall costs more than the space is worth, so the
47    /// directory is not offered as a candidate at all.
48    #[serde(default = "default_min_size_mb")]
49    pub min_size_mb: u64,
50    /// Whether dev-prune asks GitHub for the latest release from time to time.
51    ///
52    /// On by default, and opt-*out* rather than opt-in: an out-of-date cleanup tool is a
53    /// tool whose safety fixes you do not have. The request sends nothing but itself —
54    /// no identifier, no configuration, no usage data. Turn it off with
55    /// `devp config set update_check false`.
56    #[serde(default = "default_update_check")]
57    pub update_check: bool,
58    /// How many directory levels below a repository root discovery descends.
59    ///
60    /// Six by default. A flat repository never notices; a monorepo that nests projects
61    /// under `packages/@scope/name/app` does. Raise it when `devp status` does not list
62    /// a project you know is there, and remember that the walk gets more expensive with
63    /// every level. Clamped to [`constants::MAX_SCAN_DEPTH_LIMIT`].
64    #[serde(default = "default_scan_depth")]
65    pub scan_depth: usize,
66    /// Whether cargo and go may run the sync command that rewrites tracked manifests.
67    ///
68    /// Off. See [`constants::DEFAULT_ALLOW_MANIFEST_REWRITE`] — with this off, both are
69    /// verified read-only and a project with no lockfile at all is simply not pruned.
70    #[serde(default = "default_allow_manifest_rewrite")]
71    pub allow_manifest_rewrite: bool,
72    /// Days between automatic release checks.
73    ///
74    /// Only the *automatic* check honours this; `devp update` always asks, because you
75    /// are standing there waiting for the answer.
76    #[serde(default = "default_update_check_interval_days")]
77    pub update_check_interval_days: i64,
78    /// How long the release check waits for GitHub before giving up, in seconds.
79    ///
80    /// Five is right on a normal connection and too short behind some corporate proxies,
81    /// which is the whole reason this is a setting rather than a constant.
82    #[serde(default = "default_update_check_timeout_secs")]
83    pub update_check_timeout_secs: u64,
84    /// Whether the setup pass may install the Git hooks *in front of* another tool's.
85    ///
86    /// Off. With it on, a `core.hooksPath` that belongs to husky is not a reason to skip:
87    /// dev-prune takes the slot and forwards every hook back to the directory it
88    /// displaced. Behaviour-preserving, but it is still someone else's setup, so it is
89    /// asked for rather than assumed. Same thing as `devp hook install --chain`.
90    #[serde(default = "default_auto_hooks_chain")]
91    pub auto_hooks_chain: bool,
92}
93
94fn default_require_confirmation() -> bool {
95    constants::DEFAULT_REQUIRE_CONFIRMATION
96}
97
98fn default_command_timeout_secs() -> u64 {
99    constants::DEFAULT_COMMAND_TIMEOUT_SECS
100}
101
102fn default_auto_hooks() -> bool {
103    constants::DEFAULT_AUTO_HOOKS
104}
105
106fn default_auto_setup() -> bool {
107    constants::DEFAULT_AUTO_SETUP
108}
109
110fn default_update_check() -> bool {
111    constants::DEFAULT_UPDATE_CHECK
112}
113
114fn default_min_size_mb() -> u64 {
115    constants::DEFAULT_MIN_SIZE_MB
116}
117
118fn default_scan_depth() -> usize {
119    constants::DEFAULT_SCAN_DEPTH
120}
121
122fn default_allow_manifest_rewrite() -> bool {
123    constants::DEFAULT_ALLOW_MANIFEST_REWRITE
124}
125
126fn default_update_check_interval_days() -> i64 {
127    constants::UPDATE_CHECK_INTERVAL_DAYS
128}
129
130fn default_update_check_timeout_secs() -> u64 {
131    constants::UPDATE_CHECK_TIMEOUT_SECS
132}
133
134fn default_auto_hooks_chain() -> bool {
135    constants::DEFAULT_AUTO_HOOKS_CHAIN
136}
137
138impl Default for Settings {
139    fn default() -> Self {
140        Self {
141            idle_days: constants::DEFAULT_IDLE_DAYS,
142            check_interval_days: constants::DEFAULT_CHECK_INTERVAL_DAYS,
143            auto_daemon: constants::DEFAULT_AUTO_DAEMON,
144            auto_hooks: constants::DEFAULT_AUTO_HOOKS,
145            auto_setup: constants::DEFAULT_AUTO_SETUP,
146            require_confirmation: constants::DEFAULT_REQUIRE_CONFIRMATION,
147            command_timeout_secs: constants::DEFAULT_COMMAND_TIMEOUT_SECS,
148            min_size_mb: constants::DEFAULT_MIN_SIZE_MB,
149            update_check: constants::DEFAULT_UPDATE_CHECK,
150            scan_depth: constants::DEFAULT_SCAN_DEPTH,
151            allow_manifest_rewrite: constants::DEFAULT_ALLOW_MANIFEST_REWRITE,
152            update_check_interval_days: constants::UPDATE_CHECK_INTERVAL_DAYS,
153            update_check_timeout_secs: constants::UPDATE_CHECK_TIMEOUT_SECS,
154            auto_hooks_chain: constants::DEFAULT_AUTO_HOOKS_CHAIN,
155        }
156    }
157}
158
159/// Metadata for a single registered repository.
160#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
161pub struct RepoEntry {
162    /// Timestamp when the repo was added to the registry.
163    pub added_at: DateTime<Utc>,
164    /// Timestamp of the last successful prune, if any.
165    pub last_pruned_at: Option<DateTime<Utc>>,
166    /// Per-repo override for idle days (overrides global setting).
167    pub override_idle_days: Option<u64>,
168    /// Whether this repo is enabled for pruning.
169    pub enabled: bool,
170    /// Cumulative bytes reclaimed from this repository.
171    ///
172    /// Recorded from 1.1.0 onward. Registries written by 1.0.0 have no such figure and
173    /// deserialize to zero, so `devp stats` says where the number starts rather than
174    /// implying a repository pruned last March never freed anything.
175    #[serde(default)]
176    pub total_freed_bytes: u64,
177}
178
179impl RepoEntry {
180    /// Creates a new `RepoEntry` with the current timestamp.
181    pub fn new() -> Self {
182        Self {
183            added_at: Utc::now(),
184            last_pruned_at: None,
185            override_idle_days: None,
186            enabled: true,
187            total_freed_bytes: 0,
188        }
189    }
190}
191
192impl Default for RepoEntry {
193    fn default() -> Self {
194        Self::new()
195    }
196}
197
198/// Resolve where a repository's shared git directory actually lives.
199///
200/// `.git` is a directory in an ordinary clone, but in worktrees and submodules it is a
201/// one-line `gitdir: <path>` pointer file — and a worktree's private gitdir in turn
202/// holds a `commondir` file pointing at the shared one, which is where `info/exclude`
203/// lives. Returns `None` when the path is not inside a git repository at all.
204fn git_common_dir(repo_path: &Path) -> Option<PathBuf> {
205    let dot_git = repo_path.join(".git");
206    let git_dir = if dot_git.is_dir() {
207        dot_git
208    } else {
209        let pointer = fs::read_to_string(&dot_git).ok()?;
210        let target = pointer.strip_prefix("gitdir:")?.trim();
211        let target = Path::new(target);
212        if target.is_absolute() {
213            target.to_path_buf()
214        } else {
215            repo_path.join(target)
216        }
217    };
218    if let Ok(common) = fs::read_to_string(git_dir.join("commondir")) {
219        let target = Path::new(common.trim());
220        if target.is_absolute() {
221            return Some(target.to_path_buf());
222        }
223        return Some(git_dir.join(target));
224    }
225    Some(git_dir)
226}
227
228/// Ensure an entry (e.g. ".devprune.json") is in the repository's `.git/info/exclude`.
229///
230/// The exclude file, not `.gitignore`: the config records one machine's preferences,
231/// and `.gitignore` is a tracked file shared by everyone who clones the repository —
232/// appending to it silently puts an uncommitted change in the user's diff. The exclude
233/// file gives the same "never shows up in `git status`" result without touching
234/// anything the repository tracks.
235pub fn ensure_in_git_exclude(repo_path: &Path, entry: &str) -> Result<()> {
236    let Some(git_dir) = git_common_dir(repo_path) else {
237        return Ok(());
238    };
239    let info_dir = git_dir.join("info");
240    fs::create_dir_all(&info_dir)?;
241    let exclude_path = info_dir.join("exclude");
242    if exclude_path.exists() {
243        let content = fs::read_to_string(&exclude_path)?;
244        if !content.lines().any(|line| line.trim() == entry) {
245            let mut file = fs::OpenOptions::new().append(true).open(&exclude_path)?;
246            let prefix = if content.ends_with('\n') || content.is_empty() {
247                ""
248            } else {
249                "\n"
250            };
251            writeln!(file, "{prefix}{entry}")?;
252        }
253    } else {
254        fs::write(&exclude_path, format!("{entry}\n"))?;
255    }
256    Ok(())
257}
258
259/// Normalise a repository path into the form used as a registry key.
260///
261/// Falls back to the path as given when it cannot be canonicalised (e.g. it no longer
262/// exists), so entries for deleted repos stay addressable.
263pub fn canonical_key(path: &Path) -> PathBuf {
264    path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
265}
266
267/// Expand a leading `~` to the user's home directory.
268///
269/// POSIX shells do this before the argument ever reaches a program, so on Linux and
270/// macOS it is usually a no-op. PowerShell and cmd do not: they hand a native
271/// executable the literal three characters `~/C`, and `devp init ~/Code` — the exact
272/// line in the README and on the landing page — would register a directory called `~`
273/// sitting in the current working directory. Quoting defeats the expansion in *every*
274/// shell, so `devp init "~/Code"` needs this too.
275///
276/// Only a bare `~` or a `~` followed by a separator is expanded. `~alice` means "some
277/// other user's home" in shell syntax and cannot be resolved portably, and `~backup` is
278/// a perfectly ordinary directory name.
279pub fn expand_tilde(raw: &str) -> String {
280    let Some(rest) = raw.strip_prefix('~') else {
281        return raw.to_string();
282    };
283    if !(rest.is_empty() || rest.starts_with('/') || rest.starts_with('\\')) {
284        return raw.to_string();
285    }
286    let Some(home) = dirs::home_dir() else {
287        // No home directory to expand to. Handing back the literal `~` lets the caller
288        // fail with "no such directory", which is a better error than a silent guess.
289        return raw.to_string();
290    };
291    if rest.is_empty() {
292        return home.to_string_lossy().into_owned();
293    }
294    home.join(rest.trim_start_matches(['/', '\\']))
295        .to_string_lossy()
296        .into_owned()
297}
298
299/// Structured per-repository configuration file stored inside repo roots as `.devprune.json`.
300#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
301pub struct PerRepoConfig {
302    /// JSON Schema reference URL for IDE IntelliSense and validation.
303    #[serde(rename = "$schema", default = "default_schema_url")]
304    pub schema: String,
305    /// Custom display name for this project in TUI and CLI status views.
306    #[serde(default)]
307    pub project_name: Option<String>,
308    /// Whether this repository is ignored/excluded from pruning.
309    #[serde(default)]
310    pub ignore: bool,
311    /// Disable global Git auto-registration hooks for this specific workspace.
312    #[serde(default)]
313    pub disable_hooks: bool,
314    /// Disable background daemon automated pruning pass for this specific workspace.
315    #[serde(default)]
316    pub disable_daemon: bool,
317    /// Custom override for idle days threshold (overrides global settings).
318    #[serde(default)]
319    pub override_idle_days: Option<u64>,
320    /// Custom override for the size floor, in MiB (overrides global `min_size_mb`).
321    ///
322    /// `Some(0)` is a meaningful value: it turns the floor off for this repository even
323    /// when a global floor is set.
324    #[serde(default)]
325    pub min_size_mb: Option<u64>,
326    /// Custom override for how deep discovery walks this repository.
327    ///
328    /// The setting that most often needs to differ per repository rather than globally:
329    /// one deeply-nested monorepo should not make every other repository pay for a
330    /// deeper walk. Clamped to [`constants::MAX_SCAN_DEPTH_LIMIT`] like the global one.
331    #[serde(default)]
332    pub scan_depth: Option<usize>,
333}
334
335// Deliberately absent: `allow_manifest_rewrite`.
336//
337// Only the settings whose right value depends on the *project* have a per-repository
338// form. `allow_manifest_rewrite` is a permission the user grants their own machine, and
339// — exactly as with `post_prune_command` below — nothing stops a project from committing
340// its `.devprune.json`: the `.git/info/exclude` entry [`PerRepoConfig::save_to_repo`]
341// writes is local to one clone and excludes nothing already tracked. A
342// per-repository form would therefore let a repository nobody has read grant itself the
343// right to have `cargo generate-lockfile` / `go mod tidy` rewrite its tracked manifests
344// during an unattended pass. The `auto_*` and `update_check*` settings describe the
345// machine rather than a project and would mean nothing here either.
346
347// Removed: `custom_bloat_dirs` and `post_prune_command`.
348//
349// Both were serialized, schema'd and documented but never read by any code path, so
350// setting them did nothing. `post_prune_command` is also not a feature that should be
351// reintroduced casually: nothing stops a project from committing its `.devprune.json`,
352// so honouring it would mean cloning an untrusted repository and running `devp` hands
353// that repository arbitrary code execution on the user's machine.
354
355fn default_schema_url() -> String {
356    if let Ok(config_dir) = Registry::config_dir() {
357        let local_schema = config_dir.join("bin").join("devprune.schema.json");
358        if local_schema.exists() {
359            // `file://` + `/` + an absolute path. Unix paths already start with a
360            // separator, so pasting them in unconditionally produced `file:////home/...`
361            // — four slashes, which editors reject, leaving the `$schema` link dead and
362            // no IntelliSense at all on the platform where most of them run.
363            return file_uri(&crate::output::clean_path(&local_schema));
364        }
365    }
366    constants::JSON_SCHEMA_URL.to_string()
367}
368
369/// A `file://` URI for an absolute path.
370///
371/// `file://` + `/` + the path. Unix paths already start with a separator, so pasting one
372/// in unconditionally produced `file:////home/...` — four slashes, which editors reject,
373/// leaving the `$schema` link dead and no IntelliSense at all on the platform where most
374/// of them run.
375fn file_uri(clean_path: &str) -> String {
376    format!("file:///{}", clean_path.trim_start_matches('/'))
377}
378
379impl Default for PerRepoConfig {
380    fn default() -> Self {
381        Self {
382            schema: default_schema_url(),
383            project_name: None,
384            ignore: false,
385            disable_hooks: false,
386            disable_daemon: false,
387            override_idle_days: None,
388            min_size_mb: None,
389            scan_depth: None,
390        }
391    }
392}
393
394impl PerRepoConfig {
395    /// Load per-repo config from `.devprune.json`, or `None` when there is no such file.
396    ///
397    /// This is the only loader. There used to be a second one that returned `None` for a
398    /// file that failed to parse as well as for one that was absent, and every caller of
399    /// it then went on to act as though the repository had no configuration: the prune
400    /// pass ignored an `"ignore": true` it could not read, and the two workspace toggles
401    /// wrote a fresh default file straight over the user's broken one, taking every
402    /// override in it with them. A caller that genuinely does not care — the display-name
403    /// lookup — says so with `.ok().flatten()`.
404    pub fn load_with_diagnostics(repo_path: &Path) -> Result<Option<Self>, String> {
405        let config_file = repo_path.join(constants::PER_REPO_CONFIG_FILE);
406        if !config_file.exists() {
407            return Ok(None);
408        }
409        let content =
410            fs::read_to_string(&config_file).map_err(|e| format!("Failed to read file: {e}"))?;
411        match serde_json::from_str::<Self>(&content) {
412            Ok(cfg) => Ok(Some(cfg)),
413            // `clean_path`, like every other path this tool shows. `Display` on a
414            // canonicalised Windows path leaks the `\\?\` extended-length prefix into an
415            // error message the user is being asked to act on.
416            Err(e) => Err(format!(
417                "Syntax error in `{}`: {e}",
418                crate::output::clean_path(&config_file)
419            )),
420        }
421    }
422
423    /// Save per-repo config to `.devprune.json` in the repo root, and record it in the
424    /// repository's `.git/info/exclude` so it never shows up in `git status`.
425    pub fn save_to_repo(&self, repo_path: &Path) -> Result<()> {
426        let config_file = repo_path.join(constants::PER_REPO_CONFIG_FILE);
427        let content = serde_json::to_string_pretty(self)?;
428        fs::write(&config_file, content)?;
429        let _ = ensure_in_git_exclude(repo_path, constants::PER_REPO_CONFIG_FILE);
430        let _ = ensure_in_git_exclude(repo_path, constants::DEVPRUNE_IGNORE_FILE);
431        Ok(())
432    }
433}
434
435/// One directory a prune pass deleted.
436///
437/// Enough to put it back and nothing more: which repository it belonged to, which
438/// project inside that repository owned it, and who verified it. No file list — the
439/// lockfile is the record of the contents, which is the whole premise of the tool.
440#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
441pub struct PrunedDir {
442    /// Repository root the directory belonged to.
443    pub repo_path: PathBuf,
444    /// Repository-relative label, `/`-separated: `node_modules`, `frontend/node_modules`.
445    pub bloat_dir: String,
446    /// Adapter that verified and deleted it.
447    pub adapter: String,
448    /// Bytes reclaimed.
449    pub size_freed: u64,
450}
451
452/// What the most recent prune pass deleted, for `devp restore --last-run`.
453///
454/// Only passes that actually deleted something are recorded. A later run that frees
455/// nothing — everything was active, everything was already clean — leaves this alone,
456/// because "put back what you just took" should still mean the pass that took something.
457#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
458pub struct LastPrune {
459    /// When the pass ran.
460    pub at: DateTime<Utc>,
461    /// Every directory it removed.
462    pub dirs: Vec<PrunedDir>,
463}
464
465/// A one-line summary of a completed prune pass, for `devp stats`.
466///
467/// Deliberately not a second copy of [`LastPrune`]. That one exists so
468/// `devp restore --last-run` can put files back, so it carries the full directory list
469/// and only ever describes the most recent pass. This one is a trend line — four numbers
470/// per pass, bounded by [`constants::PRUNE_HISTORY_LIMIT`] — and could not restore
471/// anything if it wanted to.
472#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
473pub struct PruneRunSummary {
474    /// When the pass ran.
475    pub at: DateTime<Utc>,
476    /// Bytes reclaimed by the pass.
477    pub bytes_freed: u64,
478    /// How many directories it removed.
479    pub dirs_removed: usize,
480    /// How many distinct repositories it touched.
481    pub repos_touched: usize,
482}
483
484/// The top-level registry structure persisted to disk.
485#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
486pub struct Registry {
487    /// Schema version for forward compatibility.
488    pub version: String,
489    /// Global settings.
490    pub settings: Settings,
491    /// Map of canonical repo paths to their metadata.
492    pub repositories: HashMap<PathBuf, RepoEntry>,
493    /// Total cumulative bytes freed historically across all prune passes.
494    #[serde(default)]
495    pub total_freed_bytes: u64,
496    /// How many prune passes have deleted something, ever.
497    ///
498    /// One per *pass*, not per repository and not per directory — a `devp run` that
499    /// cleared eleven directories across four repositories counts once. Incremented in
500    /// exactly one place, [`Registry::record_prune`], which is also where the pass is
501    /// recorded for `devp restore --last-run`; keeping the two together is what stops
502    /// them meaning different things depending on which command did the pruning.
503    #[serde(default)]
504    pub total_pruned_count: u64,
505    /// List of repository paths added in the most recent init/link action (for devp undo).
506    #[serde(default)]
507    pub last_added_repos: Vec<PathBuf>,
508    /// What the most recent prune pass deleted (for `devp restore --last-run`).
509    #[serde(default)]
510    pub last_prune: Option<LastPrune>,
511    /// Summaries of recent prune passes, oldest first, for `devp stats`.
512    ///
513    /// Capped at [`constants::PRUNE_HISTORY_LIMIT`]. Recorded from 1.1.0 onward.
514    #[serde(default)]
515    pub prune_history: Vec<PruneRunSummary>,
516    /// When the release check last ran, so it runs at most once every
517    /// `UPDATE_CHECK_INTERVAL_DAYS` instead of on every command.
518    #[serde(default)]
519    pub last_update_check: Option<DateTime<Utc>>,
520    /// The newest release seen by the last check, so the reminder survives until the
521    /// user actually upgrades without needing the network again.
522    #[serde(default)]
523    pub latest_known_version: Option<String>,
524}
525
526impl Default for Registry {
527    fn default() -> Self {
528        Self {
529            version: "1.0".to_string(),
530            settings: Settings::default(),
531            repositories: HashMap::new(),
532            total_freed_bytes: 0,
533            total_pruned_count: 0,
534            last_added_repos: Vec::new(),
535            last_prune: None,
536            prune_history: Vec::new(),
537            last_update_check: None,
538            latest_known_version: None,
539        }
540    }
541}
542
543impl Registry {
544    /// Returns the path to the config directory (`~/.config/dev-prune/`).
545    ///
546    /// Uses the `dirs` crate to resolve the platform-specific config location:
547    /// - Linux/macOS: `~/.config/dev-prune/`
548    /// - Windows: `C:\Users\<user>\AppData\Roaming\dev-prune\` (or `~/.config/dev-prune/`)
549    pub fn config_dir() -> Result<PathBuf> {
550        if let Ok(override_dir) = std::env::var(constants::ENV_CONFIG_DIR_OVERRIDE) {
551            return Ok(PathBuf::from(override_dir));
552        }
553        let base = dirs::config_dir().context("Could not determine config directory")?;
554        Ok(base.join(constants::CONFIG_DIR_NAME))
555    }
556
557    /// Returns the full path to the registry file.
558    pub fn registry_path() -> Result<PathBuf> {
559        Ok(Self::config_dir()?.join(constants::REGISTRY_FILENAME))
560    }
561
562    /// Loads the registry from disk, or the defaults when there is nothing to load.
563    ///
564    /// Reading does not write. This used to persist the default registry on the way
565    /// out, which made `devp --dry-run init` create the very file it had just promised
566    /// not to write and gave `devp status --json` — documented as a pure read — a side
567    /// effect on first use. Every command that actually changes something calls
568    /// [`Registry::save`], and that creates the directory as needed.
569    pub fn load() -> Result<Self> {
570        Self::load_from(&Self::registry_path()?)
571    }
572
573    /// Loads the registry from a specific path (for testing or custom locations).
574    ///
575    /// Non-persisting, exactly like [`Registry::load`], which is implemented on top of
576    /// it. The two used to disagree — this one wrote the defaults out when the file was
577    /// missing — which is the sort of difference that makes a test pass while the
578    /// behaviour it stands in for is broken.
579    pub fn load_from(path: &Path) -> Result<Self> {
580        if !path.exists() {
581            return Ok(Registry::default());
582        }
583        let contents = fs::read_to_string(path)
584            .with_context(|| format!("Failed to read registry at {}", path.display()))?;
585        serde_json::from_str(&contents)
586            .with_context(|| format!("Failed to parse registry at {}", path.display()))
587    }
588
589    /// Saves the registry to disk atomically (write to temp, then rename).
590    pub fn save(&self) -> Result<()> {
591        let path = Self::registry_path()?;
592        self.save_to(&path)
593    }
594
595    /// Saves the registry to a specific path (for testing or custom locations).
596    pub fn save_to(&self, path: &Path) -> Result<()> {
597        if let Some(parent) = path.parent() {
598            fs::create_dir_all(parent)
599                .with_context(|| format!("Failed to create config dir {}", parent.display()))?;
600        }
601        // Unique per process. A manual run and the scheduled daemon pass can save at the
602        // same moment; with a shared `registry.json.tmp`, one process could rename the
603        // other's half-written file into place as a torn, unparseable registry.
604        let tmp_path = path.with_extension(format!("json.{}.tmp", std::process::id()));
605        let contents =
606            serde_json::to_string_pretty(self).context("Failed to serialize registry")?;
607        fs::write(&tmp_path, &contents)
608            .with_context(|| format!("Failed to write temp registry {}", tmp_path.display()))?;
609        fs::rename(&tmp_path, path)
610            .with_context(|| format!("Failed to rename temp registry to {}", path.display()))?;
611        Ok(())
612    }
613
614    /// Adds a repository to the registry. Returns `true` if newly added, `false` if already present.
615    pub fn add_repo(&mut self, path: PathBuf) -> bool {
616        // The registry is keyed by path, so `./foo`, `foo/`, and the absolute form
617        // would otherwise register as three separate repositories.
618        let path = canonical_key(&path);
619        if self.repositories.contains_key(&path) {
620            return false;
621        }
622        self.repositories.insert(path, RepoEntry::new());
623        true
624    }
625
626    /// Removes a repository from the registry. Returns `true` if it was present.
627    pub fn remove_repo(&mut self, path: &Path) -> bool {
628        self.repositories.remove(&canonical_key(path)).is_some()
629    }
630
631    // Removed: `repo_paths` and `effective_idle_days`.
632    //
633    // Neither had a caller outside this file's own tests. `effective_idle_days` had also
634    // drifted from the rule the engine actually applies: it looked the repository up by
635    // the path as given, where every write to `repositories` goes through
636    // `canonical_key`, so `devp`'s own relative paths would have missed the entry and
637    // silently returned the global threshold instead of the repository's override.
638
639    /// Credit `bytes_freed` to one repository, and to the machine-wide total.
640    ///
641    /// Safe to call once per repository or once per directory — every figure it touches
642    /// is either a sum or a timestamp, so the two styles agree. Counting *passes* is
643    /// deliberately not done here for exactly that reason; that lives in
644    /// [`Registry::record_prune`], which is called once per pass.
645    pub fn mark_pruned(&mut self, path: &Path, bytes_freed: u64) {
646        // Same rule as every other accessor: the map is keyed by `canonical_key`, so a
647        // raw lookup would silently skip the per-repo credit for a relative or
648        // differently-spelled path while still growing the machine-wide total.
649        if let Some(entry) = self.repositories.get_mut(&canonical_key(path)) {
650            entry.last_pruned_at = Some(Utc::now());
651            entry.total_freed_bytes += bytes_freed;
652        }
653        self.total_freed_bytes += bytes_freed;
654    }
655
656    /// Record what a prune pass deleted, replacing any earlier record.
657    ///
658    /// A pass that deleted nothing is not a pass worth remembering, so an empty list is
659    /// ignored rather than stored — otherwise `devp run` on an already-clean machine
660    /// would quietly throw away the record of the run the user actually wants back.
661    ///
662    /// This is the one place a prune pass is counted. It sets [`Registry::last_prune`],
663    /// appends a [`PruneRunSummary`] to [`Registry::prune_history`] and bumps
664    /// [`Registry::total_pruned_count`], because "a pass happened and it deleted things"
665    /// is exactly the condition all three describe. Splitting them across call sites is
666    /// how the counter previously came to mean repositories in `devp run` and directories
667    /// in the `devp status` dashboard.
668    pub fn record_prune(&mut self, dirs: Vec<PrunedDir>) {
669        self.record_prune_progress(Utc::now(), dirs);
670    }
671
672    /// Record a pass's progress mid-flight, superseding this same pass's earlier record.
673    ///
674    /// `at` identifies the pass: a repeated call with the same timestamp replaces the
675    /// history entry and `last_prune` it wrote before, rather than counting a second
676    /// pass. This exists so a long pass can persist after every repository — a crash
677    /// half-way through used to leave `devp restore --last-run` pointing at the
678    /// *previous* pass, offering to reinstall directories that were never deleted while
679    /// saying nothing about the ones that were.
680    pub fn record_prune_progress(&mut self, at: DateTime<Utc>, dirs: Vec<PrunedDir>) {
681        if dirs.is_empty() {
682            return;
683        }
684        if self.prune_history.last().map(|s| s.at) == Some(at) {
685            self.prune_history.pop();
686        } else {
687            self.total_pruned_count += 1;
688        }
689
690        self.prune_history.push(PruneRunSummary {
691            at,
692            bytes_freed: dirs.iter().map(|d| d.size_freed).sum(),
693            dirs_removed: dirs.len(),
694            repos_touched: dirs
695                .iter()
696                .map(|d| &d.repo_path)
697                .collect::<HashSet<_>>()
698                .len(),
699        });
700        // Oldest first, so the overflow comes off the front.
701        if self.prune_history.len() > constants::PRUNE_HISTORY_LIMIT {
702            let excess = self.prune_history.len() - constants::PRUNE_HISTORY_LIMIT;
703            self.prune_history.drain(..excess);
704        }
705
706        self.last_prune = Some(LastPrune { at, dirs });
707    }
708
709    /// Returns the number of registered repositories.
710    pub fn repo_count(&self) -> usize {
711        self.repositories.len()
712    }
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use tempfile::TempDir;
719
720    fn test_registry_path(dir: &TempDir) -> PathBuf {
721        dir.path().join("dev-prune").join("registry.json")
722    }
723
724    fn a_pruned_dir(label: &str) -> PrunedDir {
725        PrunedDir {
726            repo_path: PathBuf::from("/repo"),
727            bloat_dir: label.to_string(),
728            adapter: "npm".to_string(),
729            size_freed: 42,
730        }
731    }
732
733    #[test]
734    fn a_prune_that_deleted_nothing_does_not_erase_the_last_one() {
735        // Otherwise a second `devp run` on an already-clean machine throws away the
736        // record of the pass the user actually wants to undo.
737        let mut registry = Registry::default();
738        registry.record_prune(vec![a_pruned_dir("node_modules")]);
739        let recorded = registry.last_prune.clone().expect("first pass recorded");
740
741        registry.record_prune(Vec::new());
742
743        assert_eq!(registry.last_prune, Some(recorded));
744    }
745
746    #[test]
747    fn a_later_prune_replaces_the_record() {
748        let mut registry = Registry::default();
749        registry.record_prune(vec![a_pruned_dir("node_modules")]);
750        registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
751
752        let dirs = registry.last_prune.unwrap().dirs;
753        assert_eq!(dirs.len(), 1);
754        assert_eq!(dirs[0].bloat_dir, "frontend/node_modules");
755    }
756
757    #[test]
758    fn the_last_prune_record_survives_a_save_and_load() {
759        // `restore --last-run` reads it out of a file written by a process that has
760        // already exited, so the round trip is the whole feature.
761        let dir = TempDir::new().unwrap();
762        let path = test_registry_path(&dir);
763
764        let mut registry = Registry::default();
765        registry.record_prune(vec![a_pruned_dir("frontend/node_modules")]);
766        registry.save_to(&path).unwrap();
767
768        let loaded = Registry::load_from(&path).unwrap();
769        assert_eq!(loaded.last_prune, registry.last_prune);
770    }
771
772    #[test]
773    fn a_registry_written_before_the_field_existed_still_loads() {
774        // The registry on disk predates `last_prune`; a missing key means "no pass
775        // recorded", not a parse failure that would lock the user out of their config.
776        let dir = TempDir::new().unwrap();
777        let path = test_registry_path(&dir);
778        fs::create_dir_all(path.parent().unwrap()).unwrap();
779        fs::write(
780            &path,
781            r#"{"version":"1.0","settings":{"idle_days":15,"check_interval_days":2,
782               "auto_daemon":true},"repositories":{}}"#,
783        )
784        .unwrap();
785
786        let loaded = Registry::load_from(&path).unwrap();
787        assert_eq!(loaded.last_prune, None);
788    }
789
790    #[test]
791    fn a_leading_tilde_becomes_the_home_directory() {
792        // The whole reason this exists: PowerShell hands `devp init ~/Code` straight
793        // through, so without expansion the registry gains a repository at `.\~\Code`.
794        let home = dirs::home_dir().expect("test host has a home directory");
795
796        assert_eq!(expand_tilde("~"), home.to_string_lossy());
797        assert_eq!(
798            expand_tilde("~/Code"),
799            home.join("Code").to_string_lossy(),
800            "forward slash, as typed in every shell"
801        );
802        assert_eq!(
803            expand_tilde("~\\Code"),
804            home.join("Code").to_string_lossy(),
805            "backslash, as typed in PowerShell"
806        );
807    }
808
809    #[test]
810    fn a_tilde_that_is_not_a_home_reference_is_left_alone() {
811        // `~alice` is another user's home in shell syntax and cannot be resolved
812        // portably; `~backup` and `./~tmp` are ordinary directory names. Rewriting any
813        // of them would silently point the user at the wrong directory.
814        for raw in ["~alice/Code", "~backup", "./~tmp", "Code~", "", "."] {
815            assert_eq!(expand_tilde(raw), raw, "{raw} must survive untouched");
816        }
817    }
818
819    #[test]
820    fn test_default_settings() {
821        let settings = Settings::default();
822        assert_eq!(settings.idle_days, 15);
823        assert_eq!(settings.check_interval_days, 2);
824        // On by default: dev-prune installs its own integrations, once per version,
825        // and only the ones it finds missing.
826        assert!(settings.auto_daemon);
827        assert!(settings.auto_hooks);
828        assert!(settings.auto_setup);
829    }
830
831    #[test]
832    fn settings_written_before_the_automation_toggles_existed_still_load() {
833        // Real registries on disk predate `auto_hooks` / `auto_setup`; an upgrade must
834        // read them rather than fail to parse and lose every registered repository.
835        let json = r#"{
836            "idle_days": 30,
837            "check_interval_days": 2,
838            "auto_daemon": false
839        }"#;
840        let settings: Settings = serde_json::from_str(json).unwrap();
841        assert_eq!(settings.idle_days, 30);
842        assert!(!settings.auto_daemon, "an explicit opt-out is preserved");
843        assert!(settings.auto_hooks, "a missing key takes the default");
844        assert!(settings.auto_setup);
845    }
846
847    #[test]
848    fn test_default_registry() {
849        let registry = Registry::default();
850        assert_eq!(registry.version, "1.0");
851        assert_eq!(registry.settings, Settings::default());
852        assert!(registry.repositories.is_empty());
853    }
854
855    #[test]
856    fn test_repo_entry_new() {
857        let entry = RepoEntry::new();
858        assert!(entry.enabled);
859        assert!(entry.last_pruned_at.is_none());
860        assert!(entry.override_idle_days.is_none());
861    }
862
863    #[test]
864    fn test_save_and_load() {
865        let tmp = TempDir::new().unwrap();
866        let path = test_registry_path(&tmp);
867
868        let mut registry = Registry::default();
869        registry.add_repo(PathBuf::from("/test/repo"));
870        registry.save_to(&path).unwrap();
871
872        let loaded = Registry::load_from(&path).unwrap();
873        assert_eq!(loaded.repo_count(), 1);
874        assert!(
875            loaded
876                .repositories
877                .contains_key(&PathBuf::from("/test/repo"))
878        );
879    }
880
881    #[test]
882    fn loading_a_missing_registry_yields_the_defaults_and_writes_nothing() {
883        let tmp = TempDir::new().unwrap();
884        let path = test_registry_path(&tmp);
885
886        let loaded = Registry::load_from(&path).unwrap();
887        assert_eq!(loaded, Registry::default());
888        // Reading is not writing. `devp --dry-run` and `devp status --json` both promise
889        // to leave the disk alone, and both start by loading the registry.
890        assert!(!path.exists(), "loading the registry created it");
891    }
892
893    #[test]
894    fn test_add_repo_returns_true_for_new() {
895        let mut registry = Registry::default();
896        assert!(registry.add_repo(PathBuf::from("/test/repo")));
897    }
898
899    #[test]
900    fn test_add_repo_returns_false_for_duplicate() {
901        let mut registry = Registry::default();
902        registry.add_repo(PathBuf::from("/test/repo"));
903        assert!(!registry.add_repo(PathBuf::from("/test/repo")));
904    }
905
906    #[test]
907    fn test_remove_repo() {
908        let mut registry = Registry::default();
909        registry.add_repo(PathBuf::from("/test/repo"));
910        assert!(registry.remove_repo(Path::new("/test/repo")));
911        assert!(!registry.remove_repo(Path::new("/test/repo")));
912        assert_eq!(registry.repo_count(), 0);
913    }
914
915    #[test]
916    fn test_mark_pruned() {
917        let mut registry = Registry::default();
918        registry.add_repo(PathBuf::from("/test/repo"));
919        assert!(
920            registry.repositories[&PathBuf::from("/test/repo")]
921                .last_pruned_at
922                .is_none()
923        );
924        registry.mark_pruned(Path::new("/test/repo"), 1024);
925        assert!(
926            registry.repositories[&PathBuf::from("/test/repo")]
927                .last_pruned_at
928                .is_some()
929        );
930        assert_eq!(registry.total_freed_bytes, 1024);
931        // Not the pass counter — that is `record_prune`'s job, once per pass.
932        assert_eq!(registry.total_pruned_count, 0);
933    }
934
935    #[test]
936    fn a_pass_is_counted_once_however_much_it_deleted() {
937        // The counter is published as `prune_passes`, and it used to be incremented once
938        // per repository by `devp run` and once per *directory* by the status dashboard,
939        // so the same work produced a different number depending on where it started.
940        let mut registry = Registry::default();
941        registry.add_repo(PathBuf::from("/repo"));
942
943        registry.mark_pruned(Path::new("/repo"), 1024);
944        registry.mark_pruned(Path::new("/repo"), 1024);
945        registry.record_prune(vec![
946            a_pruned_dir("node_modules"),
947            a_pruned_dir("frontend/node_modules"),
948        ]);
949
950        assert_eq!(registry.total_pruned_count, 1);
951
952        registry.record_prune(vec![a_pruned_dir("target")]);
953        assert_eq!(registry.total_pruned_count, 2);
954
955        // A pass that deleted nothing is not a pass.
956        registry.record_prune(Vec::new());
957        assert_eq!(registry.total_pruned_count, 2);
958    }
959
960    #[test]
961    fn mark_pruned_credits_the_repo_under_its_canonical_key() {
962        // On Windows, `canonicalize` yields a `\\?\`-prefixed path, so a registry keyed
963        // by the canonical form and a `mark_pruned` looking up the raw form would miss —
964        // growing the machine-wide total while the repository's own figure stayed zero.
965        let tmp = TempDir::new().unwrap();
966        let raw = tmp.path().to_path_buf();
967
968        let mut registry = Registry::default();
969        registry.add_repo(raw.clone());
970        registry.mark_pruned(&raw, 1024);
971
972        let entry = &registry.repositories[&canonical_key(&raw)];
973        assert_eq!(entry.total_freed_bytes, 1024);
974        assert!(entry.last_pruned_at.is_some());
975        assert_eq!(registry.total_freed_bytes, 1024);
976    }
977
978    #[test]
979    fn each_repository_accumulates_its_own_total() {
980        // `devp stats` ranks repositories against each other, so the per-repo figure has
981        // to be a running total and not the size of the most recent pass.
982        let mut registry = Registry::default();
983        registry.add_repo(PathBuf::from("/test/repo"));
984        registry.add_repo(PathBuf::from("/test/other"));
985
986        registry.mark_pruned(Path::new("/test/repo"), 1024);
987        registry.mark_pruned(Path::new("/test/repo"), 2048);
988        registry.mark_pruned(Path::new("/test/other"), 512);
989
990        assert_eq!(
991            registry.repositories[&PathBuf::from("/test/repo")].total_freed_bytes,
992            3072
993        );
994        assert_eq!(
995            registry.repositories[&PathBuf::from("/test/other")].total_freed_bytes,
996            512
997        );
998        assert_eq!(registry.total_freed_bytes, 3584);
999    }
1000
1001    #[test]
1002    fn the_prune_history_summarises_the_pass() {
1003        let mut registry = Registry::default();
1004        registry.record_prune(vec![
1005            a_pruned_dir("node_modules"),
1006            a_pruned_dir("frontend/node_modules"),
1007        ]);
1008
1009        let summary = registry.prune_history.last().expect("pass summarised");
1010        assert_eq!(summary.bytes_freed, 84);
1011        assert_eq!(summary.dirs_removed, 2);
1012        // Both fixtures live under `/repo`, so this is one repository, not two.
1013        assert_eq!(summary.repos_touched, 1);
1014    }
1015
1016    #[test]
1017    fn the_prune_history_is_capped_and_drops_the_oldest() {
1018        // The registry is rewritten in full on every save, so an uncapped list would grow
1019        // the file forever on a machine running the scheduled pass.
1020        let mut registry = Registry::default();
1021        for _ in 0..constants::PRUNE_HISTORY_LIMIT + 5 {
1022            registry.record_prune(vec![a_pruned_dir("node_modules")]);
1023        }
1024
1025        assert_eq!(registry.prune_history.len(), constants::PRUNE_HISTORY_LIMIT);
1026        let first = registry.prune_history.first().unwrap().at;
1027        let last = registry.prune_history.last().unwrap().at;
1028        assert!(first <= last, "oldest first");
1029    }
1030
1031    #[test]
1032    fn test_repo_count() {
1033        let mut registry = Registry::default();
1034        assert_eq!(registry.repo_count(), 0);
1035        registry.add_repo(PathBuf::from("/a"));
1036        registry.add_repo(PathBuf::from("/b"));
1037        assert_eq!(registry.repo_count(), 2);
1038    }
1039
1040    #[test]
1041    fn a_local_schema_uri_has_exactly_three_slashes_on_either_platform() {
1042        assert_eq!(
1043            file_uri("/home/dev/.config/dev-prune/bin/devprune.schema.json"),
1044            "file:///home/dev/.config/dev-prune/bin/devprune.schema.json"
1045        );
1046        assert_eq!(
1047            file_uri("C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"),
1048            "file:///C:/Users/dev/AppData/Roaming/dev-prune/bin/devprune.schema.json"
1049        );
1050    }
1051
1052    #[test]
1053    fn a_broken_per_repo_config_is_an_error_rather_than_an_absent_one() {
1054        // The distinction the whole tool leans on: "no config" means take the defaults,
1055        // "unreadable config" means refuse — never overwrite, never prune on a guess.
1056        let tmp = TempDir::new().unwrap();
1057        let repo = tmp.path();
1058        assert_eq!(PerRepoConfig::load_with_diagnostics(repo), Ok(None));
1059
1060        fs::write(
1061            repo.join(constants::PER_REPO_CONFIG_FILE),
1062            r#"{ "ignore": true, }"#,
1063        )
1064        .unwrap();
1065        let err = PerRepoConfig::load_with_diagnostics(repo).unwrap_err();
1066        assert!(err.contains("Syntax error"), "{err}");
1067
1068        fs::write(
1069            repo.join(constants::PER_REPO_CONFIG_FILE),
1070            r#"{ "ignore": true }"#,
1071        )
1072        .unwrap();
1073        assert!(
1074            PerRepoConfig::load_with_diagnostics(repo)
1075                .unwrap()
1076                .unwrap()
1077                .ignore
1078        );
1079    }
1080
1081    #[test]
1082    fn test_serialization_roundtrip() {
1083        let mut registry = Registry::default();
1084        registry.settings.idle_days = 30;
1085        registry.add_repo(PathBuf::from("/test/repo"));
1086
1087        let json = serde_json::to_string_pretty(&registry).unwrap();
1088        let deserialized: Registry = serde_json::from_str(&json).unwrap();
1089        assert_eq!(registry.settings.idle_days, deserialized.settings.idle_days);
1090        assert_eq!(registry.repo_count(), deserialized.repo_count());
1091    }
1092
1093    #[test]
1094    fn test_atomic_save_leaves_no_tmp() {
1095        let tmp = TempDir::new().unwrap();
1096        let path = test_registry_path(&tmp);
1097
1098        let registry = Registry::default();
1099        registry.save_to(&path).unwrap();
1100
1101        assert!(path.exists());
1102        // Nothing but the registry itself may remain — a leftover `*.tmp` would mean the
1103        // rename never happened.
1104        let leftovers: Vec<_> = fs::read_dir(path.parent().unwrap())
1105            .unwrap()
1106            .flatten()
1107            .filter(|e| e.path() != path)
1108            .collect();
1109        assert!(leftovers.is_empty(), "leftover files: {leftovers:?}");
1110    }
1111
1112    #[test]
1113    fn exclude_entry_lands_in_git_info_exclude_not_gitignore() {
1114        let tmp = TempDir::new().unwrap();
1115        let repo = tmp.path();
1116        fs::create_dir(repo.join(".git")).unwrap();
1117
1118        ensure_in_git_exclude(repo, ".devprune.json").unwrap();
1119
1120        let exclude = fs::read_to_string(repo.join(".git/info/exclude")).unwrap();
1121        assert!(exclude.lines().any(|l| l == ".devprune.json"));
1122        // The whole point of using the exclude file: the shared, tracked `.gitignore`
1123        // must never be created or touched.
1124        assert!(!repo.join(".gitignore").exists());
1125    }
1126
1127    #[test]
1128    fn exclude_entry_is_appended_once_and_preserves_existing_lines() {
1129        let tmp = TempDir::new().unwrap();
1130        let repo = tmp.path();
1131        fs::create_dir_all(repo.join(".git/info")).unwrap();
1132        // No trailing newline, deliberately — the append must not glue two entries
1133        // onto one line.
1134        fs::write(repo.join(".git/info/exclude"), "*.log").unwrap();
1135
1136        ensure_in_git_exclude(repo, ".devprune.json").unwrap();
1137        ensure_in_git_exclude(repo, ".devprune.json").unwrap();
1138
1139        let exclude = fs::read_to_string(repo.join(".git/info/exclude")).unwrap();
1140        let lines: Vec<_> = exclude.lines().collect();
1141        assert_eq!(lines, vec!["*.log", ".devprune.json"]);
1142    }
1143
1144    #[test]
1145    fn exclude_follows_a_gitdir_pointer_file() {
1146        // Worktrees and submodules have a one-line `.git` *file*, and a worktree's
1147        // private gitdir points at the shared one via `commondir` — where the real
1148        // `info/exclude` lives.
1149        let tmp = TempDir::new().unwrap();
1150        let shared = tmp.path().join("main-clone/.git");
1151        let worktree_gitdir = shared.join("worktrees/wt");
1152        fs::create_dir_all(&worktree_gitdir).unwrap();
1153        fs::write(worktree_gitdir.join("commondir"), "../..\n").unwrap();
1154
1155        let wt = tmp.path().join("wt");
1156        fs::create_dir(&wt).unwrap();
1157        fs::write(
1158            wt.join(".git"),
1159            format!("gitdir: {}\n", worktree_gitdir.display()),
1160        )
1161        .unwrap();
1162
1163        ensure_in_git_exclude(&wt, ".devprune.json").unwrap();
1164
1165        let exclude = fs::read_to_string(shared.join("info/exclude")).unwrap();
1166        assert!(exclude.lines().any(|l| l == ".devprune.json"));
1167    }
1168
1169    #[test]
1170    fn exclude_is_a_no_op_outside_a_git_repository() {
1171        let tmp = TempDir::new().unwrap();
1172
1173        ensure_in_git_exclude(tmp.path(), ".devprune.json").unwrap();
1174
1175        assert!(!tmp.path().join(".git").exists());
1176        assert!(!tmp.path().join(".gitignore").exists());
1177    }
1178}