Skip to main content

pinto/service/
commits.rs

1//! Associate Git commits with backlog items.
2//!
3//! Record which changes correspond to which items by storing commit SHAs. The service provides
4//! three operations:
5//!
6//! - [`link_commits`] — explicitly associate SHAs; Git is not required because they are stored as plain strings.
7//! - [`unlink_commits`] — remove recorded commits, optionally using a unique SHA prefix.
8//! - [`scan_commits`] — scan `git log` and link commits whose messages contain an item ID. This is
9//!   the only operation that requires Git and returns [`Error::Git`] when it is unavailable.
10//!
11//! **Policy**: To avoid another crate, scanning invokes the `git` CLI through
12//! [`tokio::process`]. Waiting for the subprocess is asynchronous; see `docs/DESIGN.md` §3.4.
13//! The selected backend persists all updates to [`BacklogItem::commits`].
14
15use super::open_board_locked;
16use crate::backlog::{BacklogItem, ItemId};
17use crate::error::{Error, Result};
18use crate::storage::BacklogItemRepository;
19use chrono::Utc;
20use std::path::Path;
21use std::process::Output;
22use tokio::process::Command;
23
24/// Result of [`link_commits`] or [`unlink_commits`], including the updated PBI and changed SHAs.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct LinkOutcome {
27    /// PBI with its updated commit list.
28    pub item: BacklogItem,
29    /// SHAs that were actually added or removed; unchanged entries are omitted.
30    pub changed: Vec<String>,
31}
32
33/// Add commit SHAs in `shas` to PBI `id` and return [`LinkOutcome`].
34///
35/// Ignore blank or already-recorded SHAs, making the operation idempotent. Update and save the PBI
36/// only when at least one SHA is new. Return [`Error::NotInitialized`] for an uninitialized board
37/// or [`Error::NotFound`] when `id` does not exist. Git is not required because SHAs are plain text.
38pub async fn link_commits(project_dir: &Path, id: &ItemId, shas: &[String]) -> Result<LinkOutcome> {
39    let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
40    let mut item = repo.load(id).await?;
41    let mut changed = Vec::new();
42    for sha in shas {
43        if item.link_commit(sha.clone()) {
44            changed.push(sha.trim().to_string());
45        }
46    }
47    if !changed.is_empty() {
48        item.updated = Utc::now();
49        repo.save(&item).await?;
50        repo.commit(&format!("pinto: update {}", item.id)).await?;
51    }
52    Ok(LinkOutcome { item, changed })
53}
54
55/// Remove commits matching the SHA prefixes in `shas` from PBI `id` and return [`LinkOutcome`].
56///
57/// Match each argument by prefix, so the shortened SHA shown by `show` can be used. Save only when
58/// something was removed. Return [`Error::NotInitialized`] or [`Error::NotFound`] as appropriate.
59pub async fn unlink_commits(
60    project_dir: &Path,
61    id: &ItemId,
62    shas: &[String],
63) -> Result<LinkOutcome> {
64    let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
65    let mut item = repo.load(id).await?;
66    let mut changed = Vec::new();
67    for arg in shas {
68        let arg = arg.trim();
69        if arg.is_empty() {
70            continue;
71        }
72        // Enumerate prefix matches and then remove them (separate borrowing and deletion during scanning).
73        let matched: Vec<String> = item
74            .commits
75            .iter()
76            .filter(|c| c.starts_with(arg))
77            .cloned()
78            .collect();
79        for sha in matched {
80            if item.unlink_commit(&sha) {
81                changed.push(sha);
82            }
83        }
84    }
85    if !changed.is_empty() {
86        item.updated = Utc::now();
87        repo.save(&item).await?;
88        repo.commit(&format!("pinto: update {}", item.id)).await?;
89    }
90    Ok(LinkOutcome { item, changed })
91}
92
93/// Result of [`scan_commits`], listing new `(PBI ID, SHA)` links.
94#[derive(Debug, Clone, PartialEq, Eq, Default)]
95pub struct ScanOutcome {
96    /// New links in oldest-commit order.
97    pub links: Vec<(ItemId, String)>,
98}
99
100/// Scan `git log` and associate commits whose messages contain a PBI ID.
101///
102/// If `since` is set, scan `<since>..HEAD`; otherwise scan the full history. Match IDs as tokens,
103/// so `T-53` does not match `T-531`. Skip pinto bookkeeping commits whose subject starts with
104/// `pinto:` and do not duplicate existing links.
105///
106/// Return [`Error::Git`] when Git is unavailable or the project is not a repository.
107pub async fn scan_commits(project_dir: &Path, since: Option<&str>) -> Result<ScanOutcome> {
108    let (_board_dir, repo, _config, _lock) = open_board_locked(project_dir).await?;
109    let mut items = repo.list().await?;
110    let commits = git_log(project_dir, since).await?;
111
112    let mut changed = vec![false; items.len()];
113    let mut links = Vec::new();
114    for (sha, message) in &commits {
115        // Skip pinto's bookkeeping commits; scan only commits representing user work.
116        let subject = message.lines().next().unwrap_or("");
117        if subject.trim_start().starts_with("pinto:") {
118            continue;
119        }
120        for (i, item) in items.iter_mut().enumerate() {
121            let id = item.id.to_string();
122            if message_mentions(message, &id) && item.link_commit(sha.clone()) {
123                changed[i] = true;
124                links.push((item.id.clone(), sha.clone()));
125            }
126        }
127    }
128
129    let now = Utc::now();
130    for (i, item) in items.iter_mut().enumerate() {
131        if changed[i] {
132            item.updated = now;
133            repo.save(item).await?;
134        }
135    }
136    if !links.is_empty() {
137        repo.commit("pinto: link commits").await?;
138    }
139    Ok(ScanOutcome { links })
140}
141
142/// Determine whether `message` contains `id` as a separate token.
143///
144/// Since `id` is ASCII (`<PREFIX>-<NUMBER>`), match it only when the preceding and following bytes
145/// are not ASCII alphanumeric characters. This prevents `T-5` from matching part of `T-53`.
146/// UTF-8 bytes for non-ASCII characters are outside the ASCII alphanumeric range and therefore
147/// act as valid boundaries.
148fn message_mentions(message: &str, id: &str) -> bool {
149    let bytes = message.as_bytes();
150    let mut start = 0;
151    while let Some(pos) = message[start..].find(id) {
152        let at = start + pos;
153        let before_ok = at == 0 || !bytes[at - 1].is_ascii_alphanumeric();
154        let after = at + id.len();
155        let after_ok = after >= bytes.len() || !bytes[after].is_ascii_alphanumeric();
156        if before_ok && after_ok {
157            return true;
158        }
159        start = at + 1;
160    }
161    false
162}
163
164/// Run `git log` and return (SHA, full message) in chronological order.
165///
166/// Use the machine-readable format `%H\0%B\x1e` (NUL separates the hash and body; RS separates
167/// records), so message bodies may safely contain line breaks.
168///
169/// Return a clear [`Error::Git`] when the project is not a repository. A repository with no `HEAD`
170/// is treated as empty history without exposing raw Git diagnostics. Both cases are detected using
171/// locale-independent exit codes.
172async fn git_log(project_dir: &Path, since: Option<&str>) -> Result<Vec<(String, String)>> {
173    let inside = run_git(project_dir, &["rev-parse", "--is-inside-work-tree"]).await?;
174    if !inside.status.success() {
175        return Err(Error::Git(
176            "not a git repository; run `git init` first, \
177             or link SHAs manually with `pinto link add`"
178                .to_string(),
179        ));
180    }
181    // A repository with no HEAD has no commits to scan.
182    let head = run_git(project_dir, &["rev-parse", "--verify", "--quiet", "HEAD"]).await?;
183    if !head.status.success() {
184        return Ok(Vec::new());
185    }
186
187    let range = since.map(|s| format!("{s}..HEAD"));
188    let mut args = vec!["log", "--reverse", "--format=%H%x00%B%x1e"];
189    if let Some(range) = range.as_deref() {
190        args.push(range);
191    }
192    let out = run_git(project_dir, &args).await?;
193    if !out.status.success() {
194        let stderr = String::from_utf8_lossy(&out.stderr);
195        return Err(Error::Git(format!("git log failed: {}", stderr.trim())));
196    }
197    let text = String::from_utf8_lossy(&out.stdout);
198    let mut records = Vec::new();
199    for record in text.split('\u{1e}') {
200        let record = record.trim_matches(['\n', '\r']);
201        if record.is_empty() {
202            continue;
203        }
204        if let Some((sha, message)) = record.split_once('\u{0}') {
205            records.push((sha.trim().to_string(), message.to_string()));
206        }
207    }
208    Ok(records)
209}
210
211/// Run `git <args>` in `project_dir`, mapping a missing executable to a clear [`Error::Git`].
212async fn run_git(project_dir: &Path, args: &[&str]) -> Result<Output> {
213    Command::new("git")
214        .args(args)
215        .current_dir(project_dir)
216        .output()
217        .await
218        .map_err(|e| {
219            if e.kind() == std::io::ErrorKind::NotFound {
220                Error::Git(
221                    "`git` command not found; install git to scan commits, \
222                     or link SHAs manually with `pinto link add`"
223                        .to_string(),
224                )
225            } else {
226                Error::Git(format!("failed to run git {}: {e}", args.join(" ")))
227            }
228        })
229}
230
231#[cfg(test)]
232mod tests {
233    use super::super::test_support::{add_with, init_temp};
234    use super::*;
235
236    /// Reload PBI for testing (verify that the save was made permanent).
237    async fn reload(dir: &Path, id: &ItemId) -> BacklogItem {
238        let (_board_dir, repo, _config) = super::super::open_board(dir).await.expect("open");
239        repo.load(id).await.expect("load")
240    }
241
242    #[tokio::test]
243    async fn link_commits_adds_and_persists() {
244        let dir = init_temp().await;
245        let item = add_with(dir.path(), "Task", &[], None).await;
246
247        let out = link_commits(
248            dir.path(),
249            &item.id,
250            &["abc123".to_string(), "def456".to_string()],
251        )
252        .await
253        .expect("link");
254        assert_eq!(out.changed, ["abc123", "def456"]);
255
256        let reloaded = reload(dir.path(), &item.id).await;
257        assert_eq!(reloaded.commits, ["abc123", "def456"]);
258    }
259
260    #[tokio::test]
261    async fn link_commits_is_idempotent() {
262        let dir = init_temp().await;
263        let item = add_with(dir.path(), "Task", &[], None).await;
264        link_commits(dir.path(), &item.id, &["abc123".to_string()])
265            .await
266            .expect("link once");
267
268        let out = link_commits(dir.path(), &item.id, &["abc123".to_string()])
269            .await
270            .expect("link twice");
271        assert!(out.changed.is_empty(), "duplicate adds nothing");
272        assert_eq!(reload(dir.path(), &item.id).await.commits, ["abc123"]);
273    }
274
275    #[tokio::test]
276    async fn unlink_commits_matches_by_prefix() {
277        let dir = init_temp().await;
278        let item = add_with(dir.path(), "Task", &[], None).await;
279        link_commits(
280            dir.path(),
281            &item.id,
282            &["abc12345".to_string(), "def67890".to_string()],
283        )
284        .await
285        .expect("link");
286
287        // It can also be removed with abbreviated SHA (prefix match).
288        let out = unlink_commits(dir.path(), &item.id, &["abc12".to_string()])
289            .await
290            .expect("unlink");
291        assert_eq!(out.changed, ["abc12345"]);
292        assert_eq!(reload(dir.path(), &item.id).await.commits, ["def67890"]);
293    }
294
295    #[tokio::test]
296    async fn link_missing_item_is_not_found() {
297        let dir = init_temp().await;
298        let err = link_commits(dir.path(), &ItemId::new("T", 99), &["abc".to_string()])
299            .await
300            .expect_err("missing id");
301        assert_eq!(err, Error::NotFound(ItemId::new("T", 99)));
302    }
303
304    #[test]
305    fn message_mentions_respects_token_boundaries() {
306        assert!(message_mentions("fix bug (T-53)", "T-53"));
307        assert!(message_mentions("T-53 at start", "T-53"));
308        assert!(message_mentions("close T-53", "T-53"));
309        assert!(
310            message_mentions("完了する T-53 を", "T-53"),
311            "multibyte neighbors"
312        );
313        // Partial matches will not be falsely detected.
314        assert!(!message_mentions("touch T-531 area", "T-53"));
315        assert!(!message_mentions("xT-53", "T-53"));
316        assert!(!message_mentions("no id here", "T-53"));
317    }
318
319    // --- scan (integration test using real `git` CLI. Requires git in execution environment)---
320
321    async fn git(dir: &Path, args: &[&str]) {
322        let out = Command::new("git")
323            .args(args)
324            .current_dir(dir)
325            .output()
326            .await
327            .expect("run git");
328        assert!(
329            out.status.success(),
330            "git {args:?} failed: {}",
331            String::from_utf8_lossy(&out.stderr)
332        );
333    }
334
335    /// Accumulate only messages in the history with an empty commit (does not touch the work tree).
336    async fn commit(dir: &Path, message: &str) {
337        git(dir, &["commit", "--allow-empty", "-m", message]).await;
338    }
339
340    #[tokio::test]
341    async fn scan_links_commits_by_item_id_in_message() {
342        let dir = init_temp().await;
343        let a = add_with(dir.path(), "A", &[], None).await; // T-1
344        let b = add_with(dir.path(), "B", &[], None).await; // T-2
345
346        git(dir.path(), &["init"]).await;
347        git(dir.path(), &["config", "user.email", "t@example.com"]).await;
348        git(dir.path(), &["config", "user.name", "Tester"]).await;
349        commit(dir.path(), "feat: implement A (T-1)").await; // → T-1
350        commit(dir.path(), "chore: touch T-12 boundary only").await; // Must not be a false positive.
351        commit(dir.path(), "pinto: update T-2").await; // Exclude bookkeeping commits.
352        commit(dir.path(), "fix: finish B T-2").await; // → T-2
353
354        let outcome = scan_commits(dir.path(), None).await.expect("scan");
355        assert_eq!(outcome.links.len(), 2, "two new links");
356
357        let a2 = reload(dir.path(), &a.id).await;
358        let b2 = reload(dir.path(), &b.id).await;
359        assert_eq!(a2.commits.len(), 1, "T-1 linked once (not the T-12 commit)");
360        assert_eq!(
361            b2.commits.len(),
362            1,
363            "T-2 linked once (pinto: bookkeeping skipped)"
364        );
365    }
366
367    #[tokio::test]
368    async fn scan_without_git_repo_errors_clearly() {
369        let dir = init_temp().await;
370        add_with(dir.path(), "A", &[], None).await;
371        // There is a `.pinto`, but it is not a Git repository.
372        let err = scan_commits(dir.path(), None).await.expect_err("no repo");
373        assert!(
374            matches!(&err, Error::Git(m) if m.contains("not a git repository")),
375            "clear guidance, got {err:?}"
376        );
377    }
378
379    #[tokio::test]
380    async fn scan_on_repo_without_commits_is_empty_not_error() {
381        let dir = init_temp().await;
382        add_with(dir.path(), "A", &[], None).await;
383        // A repository with no commits (no HEAD). Returns empty without revealing any raw git errors.
384        git(dir.path(), &["init"]).await;
385        let outcome = scan_commits(dir.path(), None).await.expect("empty history");
386        assert!(outcome.links.is_empty());
387    }
388
389    #[tokio::test]
390    async fn scan_is_idempotent() {
391        let dir = init_temp().await;
392        let a = add_with(dir.path(), "A", &[], None).await; // T-1
393
394        git(dir.path(), &["init"]).await;
395        git(dir.path(), &["config", "user.email", "t@example.com"]).await;
396        git(dir.path(), &["config", "user.name", "Tester"]).await;
397        commit(dir.path(), "feat: A T-1").await;
398
399        scan_commits(dir.path(), None).await.expect("scan once");
400        let second = scan_commits(dir.path(), None).await.expect("scan twice");
401        assert!(second.links.is_empty(), "no new links on re-scan");
402        assert_eq!(reload(dir.path(), &a.id).await.commits.len(), 1);
403    }
404}