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 /// `.gitignore` is a symbolic link. Git does not follow one in the working
53 /// tree, and a cloned repository chooses where it points, so nothing was
54 /// written through it.
55 Linked,
56}
57
58impl Outcome {
59 /// The line `init` prints.
60 pub fn message(&self) -> String {
61 match self {
62 Self::Added => format!("✓ Added {ENTRY} to .gitignore"),
63 Self::Created => format!("✓ Created .gitignore with {ENTRY}"),
64 Self::AlreadyIgnored => format!("· {ENTRY} is already ignored"),
65 Self::Tracked => format!(
66 "! {ENTRY} is tracked by git, so .gitignore will not affect it.\n \
67 Run `git rm --cached {ENTRY}` to stop tracking it, keeping the file."
68 ),
69 Self::Linked => format!(
70 "! .gitignore is a symbolic link, which git does not follow, so {ENTRY} was not \
71 added.\n Replace it with a regular file to ignore {ENTRY}."
72 ),
73 }
74 }
75}
76
77/// Ensure `root`'s `.gitignore` ignores `drep.toml`, and report what happened.
78///
79/// Writes nothing when git already ignores or already tracks the path.
80pub async fn ensure(root: &Path) -> Result<Outcome> {
81 if is_tracked(root).await? {
82 return Ok(Outcome::Tracked);
83 }
84 if is_ignored(root).await? {
85 return Ok(Outcome::AlreadyIgnored);
86 }
87 append(root)
88}
89
90/// Ensure, then report to `out`. The shape `init`'s other steps use.
91pub async fn ensure_to<W: Write>(out: &mut W, root: &Path) -> Result<Outcome> {
92 let outcome = ensure(root).await?;
93 writeln!(out, "{}", outcome.message())?;
94 Ok(outcome)
95}
96
97/// Whether git answers yes to a question about `drep.toml`.
98///
99/// `git check-ignore` and `git ls-files --error-unmatch` both answer by exit
100/// code, which `diff::git_query` turns into an `Option`. Asking git rather than
101/// reading `.gitignore` is what makes a glob (`*.toml`) or a parent directory's
102/// rule count, which no line-by-line comparison would catch.
103async fn git_says_yes(root: &Path, args: &[&str], question: &str) -> Result<bool> {
104 diff::git_query(root, args)
105 .await
106 .map(|answer| answer.is_some())
107 .map_err(|err| anyhow!("could not ask git whether {ENTRY} is {question}: {err}"))
108}
109
110/// Whether git already ignores `drep.toml`.
111async fn is_ignored(root: &Path) -> Result<bool> {
112 git_says_yes(root, &["check-ignore", "--quiet", "--", ENTRY], "ignored").await
113}
114
115/// Whether git already tracks `drep.toml`.
116///
117/// The case where appending to `.gitignore` silently does nothing at all.
118async fn is_tracked(root: &Path) -> Result<bool> {
119 git_says_yes(
120 root,
121 &["ls-files", "--error-unmatch", "--", ENTRY],
122 "tracked",
123 )
124 .await
125}
126
127/// Append the entry, creating `.gitignore` if it does not exist.
128///
129/// A missing trailing newline on the existing file is repaired before
130/// appending, because otherwise the entry joins the last line and both rules
131/// stop working - and the file that gets damaged is one drep did not write.
132///
133/// A symbolic link is reported rather than followed: the repository chose its
134/// target, which may be any file outside it, or a dangling path that writing
135/// would create.
136fn append(root: &Path) -> Result<Outcome> {
137 let path = root.join(".gitignore");
138 // An inspection failure falls through to the read, which reports it.
139 if std::fs::symlink_metadata(&path).is_ok_and(|metadata| metadata.file_type().is_symlink()) {
140 return Ok(Outcome::Linked);
141 }
142 let existing = match std::fs::read_to_string(&path) {
143 Ok(content) => Some(content),
144 Err(err) if err.kind() == std::io::ErrorKind::NotFound => None,
145 Err(err) => return Err(anyhow!("could not read {}: {err}", path.display())),
146 };
147
148 // Only whether the file existed survives; keeping the string alive to ask
149 // that later would copy the whole file for a boolean.
150 let created = existing.is_none();
151 let mut body = existing.unwrap_or_default();
152 if !body.is_empty() {
153 // Terminate the last line if the file did not. Otherwise the comment
154 // runs into it and *both* rules stop working - in a file drep did not
155 // write.
156 if !body.ends_with('\n') {
157 body.push('\n');
158 }
159 // One blank line between what was there and what drep adds. Skipped for
160 // an empty file, where it would be a leading blank line instead.
161 body.push('\n');
162 }
163 body.push_str(COMMENT);
164 body.push('\n');
165 body.push_str(ENTRY);
166 body.push('\n');
167
168 std::fs::write(&path, body)
169 .map_err(|err| anyhow!("could not write {}: {err}", path.display()))?;
170
171 Ok(if created {
172 Outcome::Created
173 } else {
174 Outcome::Added
175 })
176}