1use 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#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct LinkOutcome {
27 pub item: BacklogItem,
29 pub changed: Vec<String>,
31}
32
33pub 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
55pub 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 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
95pub struct ScanOutcome {
96 pub links: Vec<(ItemId, String)>,
98}
99
100pub 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 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
142fn 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
164async 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 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
211async 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 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 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 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 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 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; let b = add_with(dir.path(), "B", &[], None).await; 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; commit(dir.path(), "chore: touch T-12 boundary only").await; commit(dir.path(), "pinto: update T-2").await; commit(dir.path(), "fix: finish B T-2").await; 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 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 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; 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}