debian_analyzer/
debcommit.rs

1//! Functions for creating commits with debcommit-like behavior.
2use crate::release_info::{suite_to_distribution, Vendor};
3use breezyshim::commit::CommitReporter;
4use breezyshim::error::Error as BrzError;
5use breezyshim::tree::{Kind, Path, Tree, WorkingTree};
6use breezyshim::RevisionId;
7use debian_changelog::ChangeLog;
8
9#[derive(Debug)]
10/// Errors that can occur when creating a commit.
11pub enum Error {
12    /// Unreleased changes in a changelog file.
13    UnreleasedChanges(std::path::PathBuf),
14
15    /// Error parsing a changelog file.
16    ChangelogError(debian_changelog::Error),
17
18    /// Error from breezyshim.
19    BrzError(breezyshim::error::Error),
20}
21
22impl std::fmt::Display for Error {
23    fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
24        match self {
25            Error::UnreleasedChanges(path) => write!(f, "Unreleased changes in {}", path.display()),
26            Error::ChangelogError(e) => write!(f, "{}", e),
27            Error::BrzError(e) => write!(f, "{}", e),
28        }
29    }
30}
31
32impl From<breezyshim::error::Error> for Error {
33    fn from(e: breezyshim::error::Error) -> Self {
34        Error::BrzError(e)
35    }
36}
37
38impl From<debian_changelog::Error> for Error {
39    fn from(e: debian_changelog::Error) -> Self {
40        Error::ChangelogError(e)
41    }
42}
43
44impl std::error::Error for Error {}
45
46/// Create a commit with a tag for a release.
47pub fn debcommit_release(
48    tree: &WorkingTree,
49    committer: Option<&str>,
50    subpath: Option<&std::path::Path>,
51    message: Option<&str>,
52    vendor: Option<Vendor>,
53) -> Result<String, Error> {
54    let subpath = subpath.unwrap_or_else(|| std::path::Path::new(""));
55    let cl_path = subpath.join("debian/changelog");
56    let (message, vendor) = if let (Some(message), Some(vendor)) = (message, vendor) {
57        (message.to_string(), vendor)
58    } else {
59        let f = tree.get_file(&cl_path)?;
60        let cl = ChangeLog::read(f)?;
61        let entry = cl.iter().next().unwrap();
62        let message = if let Some(message) = message {
63            message.to_string()
64        } else {
65            format!(
66                "releasing package {} version {}",
67                entry.package().unwrap(),
68                entry.version().unwrap()
69            )
70        };
71        let vendor = vendor.unwrap_or_else(|| {
72            suite_to_distribution(
73                entry
74                    .distributions()
75                    .as_ref()
76                    .and_then(|d| d.first())
77                    .unwrap(),
78            )
79            .unwrap()
80        });
81        (message, vendor)
82    };
83    let tag_name = if let Ok(tag_name) = breezyshim::debian::tree_debian_tag_name(
84        tree,
85        tree.branch().as_ref(),
86        Some(subpath),
87        Some(vendor),
88    ) {
89        tag_name
90    } else {
91        return Err(Error::UnreleasedChanges(cl_path));
92    };
93
94    let mut builder = tree.build_commit().message(&message);
95
96    if let Some(committer) = committer {
97        builder = builder.committer(committer);
98    }
99
100    let revid = builder.commit()?;
101    tree.branch().tags().unwrap().set_tag(&tag_name, &revid)?;
102    Ok(tag_name)
103}
104
105/// Find changes in a changelog file.
106pub fn changelog_changes(
107    tree: &dyn Tree,
108    basis_tree: &dyn Tree,
109    cl_path: &Path,
110) -> Result<Option<Vec<String>>, BrzError> {
111    let mut changes = vec![];
112    for change in tree.iter_changes(basis_tree, Some(&[cl_path]), None, None)? {
113        let change = change?;
114        let paths = change.path;
115        let changed_content = change.changed_content;
116        let versioned = change.versioned;
117        let kind = change.kind;
118        // Content not changed
119        if !changed_content {
120            return Ok(None);
121        }
122        // Not versioned in new tree
123        if !versioned.1.unwrap_or(false) {
124            return Ok(None);
125        }
126        // Not a file in one tree
127        if kind.0 != Some(Kind::File) || kind.1 != Some(Kind::File) {
128            return Ok(None);
129        }
130
131        let old_text = basis_tree.get_file_lines(&paths.0.unwrap())?;
132        let new_text = tree.get_file_lines(&paths.1.unwrap())?;
133        changes.extend(new_changelog_entries(&old_text, &new_text));
134    }
135    Ok(Some(changes))
136}
137
138/// Strip a changelog message like debcommit does.
139///
140/// Takes a list of changes from a changelog entry and applies a transformation
141/// so the message is well formatted for a commit message.
142///
143/// # Arguments
144/// * `changes` - a list of lines from the changelog entry
145///
146/// # Returns
147/// another list of lines with blank lines stripped from the start
148/// and the spaces the start of the lines split if there is only one
149/// logical entry.
150pub fn strip_changelog_message(changes: &[&str]) -> Vec<String> {
151    if changes.is_empty() {
152        return vec![];
153    }
154    let mut changes = changes.to_vec();
155    while changes.last() == Some(&"") {
156        changes.pop();
157    }
158    while changes.first() == Some(&"") {
159        changes.remove(0);
160    }
161
162    let changes = changes
163        .into_iter()
164        .map(|l| lazy_regex::regex_replace!(r"  |\t", l, |_| ""))
165        .collect::<Vec<_>>();
166
167    let leader_re = lazy_regex::regex!(r"^[ \t]*[*+-] ");
168    let leader_changes = changes
169        .iter()
170        .filter(|line| leader_re.is_match(line))
171        .collect::<Vec<_>>();
172
173    if leader_changes.len() == 1 {
174        changes
175            .iter()
176            .map(|line| leader_re.replace(line, "").trim_start().to_string())
177            .collect()
178    } else {
179        changes.into_iter().map(|l| l.to_string()).collect()
180    }
181}
182
183/// Create a commit message based on the new entries in changelog.
184pub fn changelog_commit_message(
185    tree: &dyn Tree,
186    basis_tree: &dyn Tree,
187    path: &Path,
188) -> Result<String, BrzError> {
189    let changes = changelog_changes(tree, basis_tree, path)?;
190    let changes = changes.unwrap_or_default();
191
192    Ok(strip_changelog_message(
193        changes
194            .iter()
195            .map(|s| s.as_str())
196            .collect::<Vec<_>>()
197            .as_slice(),
198    )
199    .concat())
200}
201
202/// Create a git commit with message based on the new entries in changelog.
203///
204/// # Arguments
205/// * `tree` - Tree to commit in
206/// * `committer` - Optional committer identity
207/// * `subpath` - subpath to commit in
208/// * `paths` - specifics paths to commit, if any
209/// * `reporter` - CommitReporter to use
210///
211/// # Returns
212/// Created revision id
213pub fn debcommit(
214    tree: &WorkingTree,
215    committer: Option<&str>,
216    subpath: &Path,
217    paths: Option<&[&Path]>,
218    reporter: Option<&dyn CommitReporter>,
219    message: Option<&str>,
220) -> Result<RevisionId, BrzError> {
221    let message = message.map_or_else(
222        || {
223            changelog_commit_message(
224                tree,
225                &tree.basis_tree().unwrap(),
226                &subpath.join("debian/changelog"),
227            )
228            .unwrap()
229        },
230        |m| m.to_string(),
231    );
232    let specific_files = if let Some(paths) = paths {
233        Some(paths.iter().map(|p| subpath.join(p)).collect())
234    } else if !subpath.to_str().unwrap().is_empty() {
235        Some(vec![subpath.to_path_buf()])
236    } else {
237        None
238    };
239
240    let mut builder = tree.build_commit().message(&message);
241
242    if let Some(reporter) = reporter {
243        builder = builder.reporter(reporter);
244    }
245
246    if let Some(committer) = committer {
247        builder = builder.committer(committer);
248    }
249
250    if let Some(specific_files) = specific_files {
251        builder = builder.specific_files(
252            specific_files
253                .iter()
254                .map(|p| p.as_path())
255                .collect::<Vec<_>>()
256                .as_slice(),
257        );
258    }
259
260    builder.commit()
261}
262
263/// Find newly added changelog entries.
264pub fn new_changelog_entries(old_text: &[Vec<u8>], new_text: &[Vec<u8>]) -> Vec<String> {
265    let mut sm = difflib::sequencematcher::SequenceMatcher::new(old_text, new_text);
266    let mut changes = vec![];
267    for group in sm.get_grouped_opcodes(0) {
268        let (j1, j2) = (group[0].second_start, group.last().unwrap().second_end);
269        for line in new_text[j1..j2].iter() {
270            if line.starts_with(b"  ") {
271                // Debian Policy Manual states that debian/changelog must be UTF-8
272                changes.push(String::from_utf8_lossy(line).to_string());
273            }
274        }
275    }
276    changes
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    mod strip_changelog_message {
283        use super::*;
284
285        #[test]
286        fn test_empty() {
287            assert_eq!(strip_changelog_message(&[]), Vec::<String>::new());
288        }
289
290        #[test]
291        fn test_empty_changes() {
292            assert_eq!(strip_changelog_message(&[""]), Vec::<String>::new());
293        }
294
295        #[test]
296        fn test_removes_leading_whitespace() {
297            assert_eq!(
298                strip_changelog_message(&["foo", "  bar", "\tbaz", "   bang"]),
299                vec!["foo", "bar", "baz", " bang"],
300            );
301        }
302
303        #[test]
304        fn test_removes_star_if_one() {
305            assert_eq!(strip_changelog_message(&["  * foo"]), ["foo"]);
306            assert_eq!(strip_changelog_message(&["\t* foo"]), ["foo"]);
307            assert_eq!(strip_changelog_message(&["  + foo"]), ["foo"]);
308            assert_eq!(strip_changelog_message(&["  - foo"]), ["foo"]);
309            assert_eq!(strip_changelog_message(&["  *  foo"]), ["foo"]);
310            assert_eq!(
311                strip_changelog_message(&["  *  foo", "     bar"]),
312                ["foo", "bar"]
313            );
314        }
315
316        #[test]
317        fn test_leaves_start_if_multiple() {
318            assert_eq!(
319                strip_changelog_message(&["  * foo", "  * bar"]),
320                ["* foo", "* bar"]
321            );
322            assert_eq!(
323                strip_changelog_message(&["  * foo", "  + bar"]),
324                ["* foo", "+ bar"]
325            );
326            assert_eq!(
327                strip_changelog_message(&["  * foo", "  bar", "  * baz"]),
328                ["* foo", "bar", "* baz"],
329            );
330        }
331    }
332}