Skip to main content

drep/cli/init/
gitignore.rs

1//! Adding `drep.toml` to the repository's `.gitignore`.
2//!
3//! Whether the config belongs in version control is a judgement, not a fact.
4//! It names an endpoint, a model and a protocol - shareable, and worth sharing
5//! when a team wants one gate reviewing with one model - but it is also a
6//! personal choice of provider that a collaborator may not have a plan for.
7//! Since 2.1 the *key* is not in the file at all (it lives in the user-level
8//! auth store), so this is a preference rather than a safety requirement, and
9//! `drep init` asks rather than deciding.
10//!
11//! ## Why this is not one `writeln!`
12//!
13//! Two states make a naive append useless, and both are silent:
14//!
15//! - **Already ignored.** By this exact path, by a glob, or by a parent
16//!   directory's rule. Appending again is a duplicate line that never
17//!   changes behaviour.
18//! - **Already tracked.** `.gitignore` has *no effect* on a file git already
19//!   tracks. Appending there looks like it worked, `git status` keeps showing
20//!   the file, and nothing explains why. The fix is `git rm --cached`, so that
21//!   is what gets reported.
22//!
23//! Both are answered by asking git rather than by parsing `.gitignore`, which
24//! is the only way to get glob and parent-directory rules right.
25
26use std::io::Write;
27use std::path::Path;
28
29use anyhow::{Result, anyhow};
30
31use crate::diff;
32
33/// The entry written, and the comment that explains it.
34///
35/// A bare line in someone's `.gitignore` with no attribution is a small
36/// mystery six months later; the comment names what put it there.
37const ENTRY: &str = "drep.toml";
38const COMMENT: &str = "# drep's local config: provider and model choice, no secrets.";
39
40/// What `ensure` did, so the caller can report it precisely.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum Outcome {
43    /// The entry was appended to `.gitignore`.
44    Added,
45    /// The file was created and the entry written to it.
46    Created,
47    /// Git already ignores the path, by whatever rule. Nothing was written.
48    AlreadyIgnored,
49    /// Git tracks the file, so `.gitignore` cannot affect it. Nothing was
50    /// written, because writing would have implied otherwise.
51    Tracked,
52}
53
54impl Outcome {
55    /// The line `init` prints.
56    pub fn message(&self) -> String {
57        match self {
58            Self::Added => format!("✓ Added {ENTRY} to .gitignore"),
59            Self::Created => format!("✓ Created .gitignore with {ENTRY}"),
60            Self::AlreadyIgnored => format!("· {ENTRY} is already ignored"),
61            Self::Tracked => format!(
62                "! {ENTRY} is tracked by git, so .gitignore will not affect it.\n  \
63                 Run `git rm --cached {ENTRY}` to stop tracking it, keeping the file."
64            ),
65        }
66    }
67}
68
69/// Ensure `root`'s `.gitignore` ignores `drep.toml`, and report what happened.
70///
71/// Writes nothing when git already ignores or already tracks the path.
72pub async fn ensure(root: &Path) -> Result<Outcome> {
73    if is_tracked(root).await? {
74        return Ok(Outcome::Tracked);
75    }
76    if is_ignored(root).await? {
77        return Ok(Outcome::AlreadyIgnored);
78    }
79    append(root)
80}
81
82/// Ensure, then report to `out`. The shape `init`'s other steps use.
83pub async fn ensure_to<W: Write>(out: &mut W, root: &Path) -> Result<Outcome> {
84    let outcome = ensure(root).await?;
85    writeln!(out, "{}", outcome.message())?;
86    Ok(outcome)
87}
88
89/// Whether git answers yes to a question about `drep.toml`.
90///
91/// `git check-ignore` and `git ls-files --error-unmatch` both answer by exit
92/// code, which `diff::git_query` turns into an `Option`. Asking git rather than
93/// reading `.gitignore` is what makes a glob (`*.toml`) or a parent directory's
94/// rule count, which no line-by-line comparison would catch.
95async fn git_says_yes(root: &Path, args: &[&str], question: &str) -> Result<bool> {
96    diff::git_query(root, args)
97        .await
98        .map(|answer| answer.is_some())
99        .map_err(|err| anyhow!("could not ask git whether {ENTRY} is {question}: {err}"))
100}
101
102/// Whether git already ignores `drep.toml`.
103async fn is_ignored(root: &Path) -> Result<bool> {
104    git_says_yes(root, &["check-ignore", "--quiet", "--", ENTRY], "ignored").await
105}
106
107/// Whether git already tracks `drep.toml`.
108///
109/// The case where appending to `.gitignore` silently does nothing at all.
110async fn is_tracked(root: &Path) -> Result<bool> {
111    git_says_yes(
112        root,
113        &["ls-files", "--error-unmatch", "--", ENTRY],
114        "tracked",
115    )
116    .await
117}
118
119/// Append the entry, creating `.gitignore` if it does not exist.
120///
121/// A missing trailing newline on the existing file is repaired before
122/// appending, because otherwise the entry joins the last line and both rules
123/// stop working - and the file that gets damaged is one drep did not write.
124fn append(root: &Path) -> Result<Outcome> {
125    let path = root.join(".gitignore");
126    let existing = match std::fs::read_to_string(&path) {
127        Ok(content) => Some(content),
128        Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
129        Err(err) => return Err(anyhow!("could not read {}: {err}", path.display())),
130    };
131
132    // Only whether the file existed survives; keeping the string alive to ask
133    // that later would copy the whole file for a boolean.
134    let created = existing.is_none();
135    let mut body = existing.unwrap_or_default();
136    if !body.is_empty() {
137        // Terminate the last line if the file did not. Otherwise the comment
138        // runs into it and *both* rules stop working - in a file drep did not
139        // write.
140        if !body.ends_with('\n') {
141            body.push('\n');
142        }
143        // One blank line between what was there and what drep adds. Skipped for
144        // an empty file, where it would be a leading blank line instead.
145        body.push('\n');
146    }
147    body.push_str(COMMENT);
148    body.push('\n');
149    body.push_str(ENTRY);
150    body.push('\n');
151
152    std::fs::write(&path, body)
153        .map_err(|err| anyhow!("could not write {}: {err}", path.display()))?;
154
155    Ok(if created {
156        Outcome::Created
157    } else {
158        Outcome::Added
159    })
160}