|
| 1 | +//! Jujutsu works with several backends and could add new ones in the future. Private builds of |
| 2 | +//! it could also have private backends. Those make it hard to use `jj-lib` since it won't have |
| 3 | +//! access to newer or private backends and fail to compute the diffs for them. |
| 4 | +//! |
| 5 | +//! Instead in case there *is* a diff to base ourselves on, we copy it to a tempfile or just use the |
| 6 | +//! current file if not. |
| 7 | +
|
| 8 | +use std::path::Path; |
| 9 | +use std::process::Command; |
| 10 | +use std::sync::Arc; |
| 11 | + |
| 12 | +use anyhow::{Context, Result}; |
| 13 | +use arc_swap::ArcSwap; |
| 14 | + |
| 15 | +use crate::FileChange; |
| 16 | + |
| 17 | +pub(super) fn get_diff_base(repo: &Path, file: &Path) -> Result<Vec<u8>> { |
| 18 | + let file_relative_to_root = file |
| 19 | + .strip_prefix(repo) |
| 20 | + .context("failed to strip JJ repo root path from file")?; |
| 21 | + |
| 22 | + let tmpfile = tempfile::NamedTempFile::with_prefix("helix-jj-diff-") |
| 23 | + .context("could not create tempfile to save jj diff base")?; |
| 24 | + let tmppath = tmpfile.path(); |
| 25 | + |
| 26 | + let copy_bin = if cfg!(windows) { "copy.exe" } else { "cp" }; |
| 27 | + |
| 28 | + let status = Command::new("jj") |
| 29 | + .arg("--repository") |
| 30 | + .arg(repo) |
| 31 | + .args([ |
| 32 | + "--ignore-working-copy", |
| 33 | + "diff", |
| 34 | + "--revision", |
| 35 | + "@", |
| 36 | + "--config-toml", |
| 37 | + ]) |
| 38 | + // Copy the temporary file provided by jujutsu to a temporary path of our own, |
| 39 | + // because the `$left` directory is deleted when `jj` finishes executing. |
| 40 | + .arg(format!( |
| 41 | + "ui.diff.tool = ['{exe}', '$left/{base}', '{target}']", |
| 42 | + exe = copy_bin, |
| 43 | + base = file_relative_to_root.display(), |
| 44 | + // Where to copy the jujutsu-provided file |
| 45 | + target = tmppath.display(), |
| 46 | + )) |
| 47 | + // Restrict the diff to the current file |
| 48 | + .arg(file) |
| 49 | + .stdout(std::process::Stdio::null()) |
| 50 | + .stderr(std::process::Stdio::null()) |
| 51 | + .status() |
| 52 | + .context("failed to execute jj diff command")?; |
| 53 | + |
| 54 | + let use_jj_path = status.success() && std::fs::metadata(tmppath).map_or(false, |m| m.len() > 0); |
| 55 | + // If the copy call inside `jj diff` succeeded, the tempfile is the one containing the base |
| 56 | + // else it's just the original file (so no diff). We check for size since `jj` can return |
| 57 | + // 0-sized files when there are no diffs to present for the file. |
| 58 | + let diff_base_path = if use_jj_path { tmppath } else { file }; |
| 59 | + |
| 60 | + // If the command succeeded, it means we either copied the jujutsu base or the current file, |
| 61 | + // so there should always be something to read and compare to. |
| 62 | + std::fs::read(diff_base_path).context("could not read jj diff base from the target") |
| 63 | +} |
| 64 | + |
| 65 | +pub(crate) fn get_current_head_name(repo: &Path) -> Result<Arc<ArcSwap<Box<str>>>> { |
| 66 | + // See <https://github.com/martinvonz/jj/blob/main/docs/templates.md> |
| 67 | + // |
| 68 | + // This will produce the following: |
| 69 | + // |
| 70 | + // - If there are no branches: `vyvqwlmsvnlkmqrvqktpuluvuknuxpmm` |
| 71 | + // - If there is a single branch: `vyvqwlmsvnlkmqrvqktpuluvuknuxpmm (master)` |
| 72 | + // - If there are 2+ branches: `vyvqwlmsvnlkmqrvqktpuluvuknuxpmm (master, jj-diffs)` |
| 73 | + // |
| 74 | + // Always using the long id makes it easy to share it with others, which would not be the |
| 75 | + // case for shorter ones: they could have a local change that renders it ambiguous. |
| 76 | + let template = r#"separate(" ", change_id, surround("(", ")", branches.join(", ")))"#; |
| 77 | + |
| 78 | + let out = Command::new("jj") |
| 79 | + .arg("--repository") |
| 80 | + .arg(repo) |
| 81 | + .args([ |
| 82 | + "--ignore-working-copy", |
| 83 | + "log", |
| 84 | + "--color", |
| 85 | + "never", |
| 86 | + "--revisions", |
| 87 | + "@", // Only display the current revision |
| 88 | + "--no-graph", |
| 89 | + "--no-pager", |
| 90 | + "--template", |
| 91 | + template, |
| 92 | + ]) |
| 93 | + .output()?; |
| 94 | + |
| 95 | + if !out.status.success() { |
| 96 | + anyhow::bail!("jj log command executed but failed"); |
| 97 | + } |
| 98 | + |
| 99 | + let out = String::from_utf8(out.stdout)?; |
| 100 | + |
| 101 | + let rev = out |
| 102 | + .lines() |
| 103 | + .next() |
| 104 | + // Contrary to git, if a JJ repo exists, it always has at least two revisions: |
| 105 | + // the root (zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz), which cannot be focused, and the current |
| 106 | + // one, which exists even for brand new repos. |
| 107 | + .context("should always find at least one line")?; |
| 108 | + |
| 109 | + Ok(Arc::new(ArcSwap::from_pointee(rev.into()))) |
| 110 | +} |
| 111 | + |
| 112 | +pub(crate) fn for_each_changed_file( |
| 113 | + repo: &Path, |
| 114 | + callback: impl Fn(Result<FileChange>) -> bool, |
| 115 | +) -> Result<()> { |
| 116 | + let out = Command::new("jj") |
| 117 | + .arg("--repository") |
| 118 | + .arg(repo) |
| 119 | + .args([ |
| 120 | + "--ignore-working-copy", |
| 121 | + "diff", |
| 122 | + "--color", |
| 123 | + "never", |
| 124 | + "--revision", |
| 125 | + "@", // Only display the current revision |
| 126 | + "--no-pager", |
| 127 | + "--types", |
| 128 | + ]) |
| 129 | + .output()?; |
| 130 | + |
| 131 | + if !out.status.success() { |
| 132 | + anyhow::bail!("jj log command executed but failed"); |
| 133 | + } |
| 134 | + |
| 135 | + let out = String::from_utf8(out.stdout)?; |
| 136 | + |
| 137 | + for line in out.lines() { |
| 138 | + let Some((status, path)) = line.split_once(' ') else { |
| 139 | + continue; |
| 140 | + }; |
| 141 | + |
| 142 | + let Some(change) = status_to_change(status, path) else { |
| 143 | + continue; |
| 144 | + }; |
| 145 | + |
| 146 | + if !callback(Ok(change)) { |
| 147 | + break; |
| 148 | + } |
| 149 | + } |
| 150 | + |
| 151 | + Ok(()) |
| 152 | +} |
| 153 | + |
| 154 | +pub(crate) fn open_repo(repo_path: &Path) -> Result<()> { |
| 155 | + assert!( |
| 156 | + repo_path.join(".jj").exists(), |
| 157 | + "no .jj where one was expected: {}", |
| 158 | + repo_path.display(), |
| 159 | + ); |
| 160 | + |
| 161 | + let status = Command::new("jj") |
| 162 | + .args(["--ignore-working-copy", "root"]) |
| 163 | + .stdout(std::process::Stdio::null()) |
| 164 | + .stderr(std::process::Stdio::null()) |
| 165 | + .status()?; |
| 166 | + |
| 167 | + if status.success() { |
| 168 | + Ok(()) |
| 169 | + } else { |
| 170 | + anyhow::bail!("not a valid JJ repo") |
| 171 | + } |
| 172 | +} |
| 173 | + |
| 174 | +/// Associate a status to a `FileChange`. |
| 175 | +fn status_to_change(status: &str, path: &str) -> Option<FileChange> { |
| 176 | + if let rename @ Some(_) = find_rename(path) { |
| 177 | + return rename; |
| 178 | + } |
| 179 | + |
| 180 | + // Syntax: <https://github.com/martinvonz/jj/blob/f9cfa5c9ce0eacd38e961c954e461e5e73067d22/cli/src/diff_util.rs#L97-L101> |
| 181 | + Some(match status { |
| 182 | + "FF" | "LL" | "CF" | "CL" | "FL" | "LF" => FileChange::Modified { path: path.into() }, |
| 183 | + "-F" | "-L" => FileChange::Untracked { path: path.into() }, |
| 184 | + "F-" | "L-" => FileChange::Deleted { path: path.into() }, |
| 185 | + "FC" | "LC" => FileChange::Conflict { path: path.into() }, |
| 186 | + // We ignore gitsubmodules here since they not interesting in the context of |
| 187 | + // a file editor. |
| 188 | + _ => return None, |
| 189 | + }) |
| 190 | +} |
| 191 | + |
| 192 | +fn find_rename(path: &str) -> Option<FileChange> { |
| 193 | + let (start, rest) = path.split_once('{')?; |
| 194 | + let (from, rest) = rest.split_once(" => ")?; |
| 195 | + let (to, end) = rest.split_once('}')?; |
| 196 | + |
| 197 | + Some(FileChange::Renamed { |
| 198 | + from_path: format!("{start}{from}{end}").into(), |
| 199 | + to_path: format!("{start}{to}{end}").into(), |
| 200 | + }) |
| 201 | +} |
| 202 | + |
| 203 | +#[cfg(test)] |
| 204 | +mod tests { |
| 205 | + use std::path::PathBuf; |
| 206 | + |
| 207 | + use super::*; |
| 208 | + |
| 209 | + #[test] |
| 210 | + fn test_status_to_change() { |
| 211 | + let p = "helix-vcs/src/lib.rs"; |
| 212 | + let pb = PathBuf::from(p); |
| 213 | + |
| 214 | + for s in ["FF", "LL", "CF", "CL", "FL", "LF"] { |
| 215 | + assert_eq!( |
| 216 | + status_to_change(s, p).unwrap(), |
| 217 | + FileChange::Modified { path: pb.clone() } |
| 218 | + ); |
| 219 | + } |
| 220 | + for s in ["-F", "-L"] { |
| 221 | + assert_eq!( |
| 222 | + status_to_change(s, p).unwrap(), |
| 223 | + FileChange::Untracked { path: pb.clone() } |
| 224 | + ); |
| 225 | + } |
| 226 | + for s in ["F-", "L-"] { |
| 227 | + assert_eq!( |
| 228 | + status_to_change(s, p).unwrap(), |
| 229 | + FileChange::Deleted { path: pb.clone() } |
| 230 | + ); |
| 231 | + } |
| 232 | + for s in ["FC", "LC"] { |
| 233 | + assert_eq!( |
| 234 | + status_to_change(s, p).unwrap(), |
| 235 | + FileChange::Conflict { path: pb.clone() } |
| 236 | + ); |
| 237 | + } |
| 238 | + for s in ["GG", "LG", "ARO", "", " ", " "] { |
| 239 | + assert_eq!(status_to_change(s, p), None); |
| 240 | + } |
| 241 | + } |
| 242 | + |
| 243 | + #[test] |
| 244 | + fn test_find_rename() { |
| 245 | + fn check(path: &str, expected: Option<(&str, &str)>) { |
| 246 | + let result = find_rename(path); |
| 247 | + |
| 248 | + assert_eq!( |
| 249 | + result, |
| 250 | + expected.map(|(f, t)| FileChange::Renamed { |
| 251 | + from_path: f.into(), |
| 252 | + to_path: t.into() |
| 253 | + }) |
| 254 | + ) |
| 255 | + } |
| 256 | + |
| 257 | + // No renames |
| 258 | + check("helix-term/Cargo.toml", None); |
| 259 | + check("helix-term/src/lib.rs", None); |
| 260 | + |
| 261 | + // Rename of first element in path |
| 262 | + check( |
| 263 | + "{helix-term => helix-term2}/Cargo.toml", |
| 264 | + Some(("helix-term/Cargo.toml", "helix-term2/Cargo.toml")), |
| 265 | + ); |
| 266 | + // Rename of final element in path |
| 267 | + check( |
| 268 | + "helix-term/{Cargo.toml => Cargo.toml2}", |
| 269 | + Some(("helix-term/Cargo.toml", "helix-term/Cargo.toml2")), |
| 270 | + ); |
| 271 | + // Rename of a single dir in the middle |
| 272 | + check( |
| 273 | + "helix-term/{src => src2}/lib.rs", |
| 274 | + Some(("helix-term/src/lib.rs", "helix-term/src2/lib.rs")), |
| 275 | + ); |
| 276 | + // Rename of two dirs in the middle |
| 277 | + check( |
| 278 | + "helix-term/{src/ui => src2/ui2}/text.rs", |
| 279 | + Some(("helix-term/src/ui/text.rs", "helix-term/src2/ui2/text.rs")), |
| 280 | + ); |
| 281 | + } |
| 282 | +} |
0 commit comments