debian_analyzer/
debcommit.rs

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