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}