commit b3ccd83d222dce2451bfb1f80cfdc97edc152bfa Author: Josh Triplett Date: Fri Jul 15 23:21:33 2016 -0700 Initial version of git-series diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..eb5a316cbd195d26e3f768c7dd8e1b47299e17f8 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000000000000000000000000000000000000..4d1886d6b92e1cbf4d7b99a929d796f2b2cb559a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,318 @@ +[root] +name = "git-series" +version = "0.8.0" +dependencies = [ + "chrono 0.2.22 (registry+https://github.com/rust-lang/crates.io-index)", + "clap 2.9.2 (registry+https://github.com/rust-lang/crates.io-index)", + "git2 0.4.3 (git+https://github.com/alexcrichton/git2-rs)", + "isatty 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", + "quick-error 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "tempdir 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "ansi_term" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "bitflags" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "bitflags" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "chrono" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "num 0.1.34 (registry+https://github.com/rust-lang/crates.io-index)", + "time 0.1.35 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "clap" +version = "2.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "ansi_term 0.7.5 (registry+https://github.com/rust-lang/crates.io-index)", + "bitflags 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)", + "strsim 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)", + "term_size 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "unicode-width 0.1.3 (registry+https://github.com/rust-lang/crates.io-index)", + "vec_map 0.6.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "cmake" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "gcc 0.3.31 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "gcc" +version = "0.3.31" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "gdi32-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi-build 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "git2" +version = "0.4.3" +source = "git+https://github.com/alexcrichton/git2-rs#c52ba513ed236b8294e5f53d93655a695d01997c" +dependencies = [ + "bitflags 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)", + "libgit2-sys 0.4.3 (git+https://github.com/alexcrichton/git2-rs)", + "url 1.1.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "idna" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "matches 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", + "unicode-bidi 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)", + "unicode-normalization 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "isatty" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "kernel32-sys 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "kernel32-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi-build 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "libc" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "libgit2-sys" +version = "0.4.3" +source = "git+https://github.com/alexcrichton/git2-rs#c52ba513ed236b8294e5f53d93655a695d01997c" +dependencies = [ + "cmake 0.1.17 (registry+https://github.com/rust-lang/crates.io-index)", + "gcc 0.3.31 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)", + "libssh2-sys 0.1.37 (registry+https://github.com/rust-lang/crates.io-index)", + "libz-sys 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)", + "openssl-sys 0.7.14 (registry+https://github.com/rust-lang/crates.io-index)", + "pkg-config 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "libressl-pnacl-sys" +version = "2.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "pnacl-build-helper 1.4.10 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "libssh2-sys" +version = "0.1.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "cmake 0.1.17 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)", + "libz-sys 1.0.5 (registry+https://github.com/rust-lang/crates.io-index)", + "openssl-sys 0.7.14 (registry+https://github.com/rust-lang/crates.io-index)", + "pkg-config 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "libz-sys" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "gcc 0.3.31 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)", + "pkg-config 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "matches" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "num" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "num-integer 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)", + "num-iter 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)", + "num-traits 0.1.33 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "num-integer" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "num-traits 0.1.33 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "num-iter" +version = "0.1.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "num-integer 0.1.32 (registry+https://github.com/rust-lang/crates.io-index)", + "num-traits 0.1.33 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "num-traits" +version = "0.1.33" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "openssl-sys" +version = "0.7.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "gdi32-sys 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)", + "libressl-pnacl-sys 2.1.6 (registry+https://github.com/rust-lang/crates.io-index)", + "pkg-config 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", + "user32-sys 0.2.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "pkg-config" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "pnacl-build-helper" +version = "1.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "tempdir 0.3.4 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "quick-error" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "rand" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "libc 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "strsim" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "tempdir" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "rand 0.3.14 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "term_size" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "libc 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "time" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "kernel32-sys 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", + "libc 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "unicode-bidi" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "matches 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "unicode-normalization" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "unicode-width" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "url" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "idna 0.1.0 (registry+https://github.com/rust-lang/crates.io-index)", + "matches 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "user32-sys" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "winapi 0.2.8 (registry+https://github.com/rust-lang/crates.io-index)", + "winapi-build 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "vec_map" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "winapi" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "winapi-build" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..2798d4be6a08324cb501312f2f4c0b91d63348f8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "git-series" +version = "0.8.0" +authors = ["Josh Triplett "] +license = "MIT" +readme = "README.md" +repository = "https://github.com/joshtriplett/git-series" +description = "Track patch series in git" + +[dependencies] +chrono = "0.2.22" +clap = "2.7.0" +git2 = { git = "https://github.com/alexcrichton/git2-rs" } +isatty = "0.1.1" +quick-error = "1.0" +tempdir = "0.3.4" diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000000000000000000000000000000000000..eec32991a893bdc73c896fa3321bfc8131d7ed49 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1496 @@ +extern crate chrono; +#[macro_use] +extern crate clap; +extern crate git2; +extern crate isatty; +#[macro_use] +extern crate quick_error; +extern crate tempdir; + +use std::env; +use std::ffi::{OsStr, OsString}; +use std::fmt::Write as FmtWrite; +use std::fs::File; +use std::io::Read; +use std::io::Write as IoWrite; +use std::process::Command; +use chrono::offset::TimeZone; +use clap::{App, AppSettings, Arg, ArgGroup, ArgMatches, SubCommand}; +use git2::{Commit, Diff, ObjectType, Oid, Reference, Repository, TreeBuilder}; +use tempdir::TempDir; + +quick_error! { + #[derive(Debug)] + enum Error { + CommitNoChanges {} + CheckoutConflict {} + Git2(err: git2::Error) { + from() + cause(err) + display("{}", err) + } + IO(err: std::io::Error) { + from() + cause(err) + display("{}", err) + } + Msg(msg: String) { + from() + from(s: &'static str) -> (s.to_string()) + description(msg) + display("{}", msg) + } + Utf8Error(err: std::str::Utf8Error) { + from() + cause(err) + display("{}", err) + } + } +} + +type Result = std::result::Result; + +const COMMIT_MESSAGE_COMMENT: &'static str = " +# Please enter the commit message for your changes. Lines starting +# with '#' will be ignored, and an empty message aborts the commit. +"; +const COVER_LETTER_COMMENT: &'static str = " +# Please enter the cover letter for your changes. Lines starting +# with '#' will be ignored, and an empty message aborts the change. +"; +const REBASE_COMMENT: &'static str = "\ +# +# Commands: +# p, pick = use commit +# r, reword = use commit, but edit the commit message +# e, edit = use commit, but stop for amending +# s, squash = use commit, but meld into previous commit +# f, fixup = like \"squash\", but discard this commit's log message +# x, exec = run command (the rest of the line) using shell +# d, drop = remove commit +# +# These lines can be re-ordered; they are executed from top to bottom. +# +# If you remove a line here THAT COMMIT WILL BE LOST. +# +# However, if you remove everything, the rebase will be aborted. +"; +const SCISSOR_LINE: &'static str = "\ +# ------------------------ >8 ------------------------"; +const SCISSOR_COMMENT: &'static str = "\ +# Do not touch the line above. +# Everything below will be removed. +"; + +const SHELL_METACHARS: &'static str = "|&;<>()$`\\\"' \t\n*?[#~=%"; + +const SERIES_PREFIX: &'static str = "refs/heads/git-series/"; +const SHEAD_REF: &'static str = "refs/SHEAD"; +const STAGED_PREFIX: &'static str = "refs/git-series-internals/staged/"; +const WORKING_PREFIX: &'static str = "refs/git-series-internals/working/"; + +const GIT_FILEMODE_BLOB: u32 = 0o100644; +const GIT_FILEMODE_COMMIT: u32 = 0o160000; + +fn zero_oid() -> Oid { + Oid::from_bytes(b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00").unwrap() +} + +fn peel_to_commit(r: Reference) -> Result { + Ok(try!(try!(r.peel(ObjectType::Commit)).into_commit().map_err(|obj| format!("Internal error: expected a commit: {}", obj.id())))) +} + +fn commit_obj_summarize_components(commit: &mut Commit) -> Result<(String, String)> { + let short_id_buf = try!(commit.as_object().short_id()); + let short_id = short_id_buf.as_str().unwrap(); + let summary = String::from_utf8_lossy(commit.summary_bytes().unwrap()); + Ok((short_id.to_string(), summary.to_string())) +} + +fn commit_summarize_components(repo: &Repository, id: Oid) -> Result<(String, String)> { + let mut commit = try!(repo.find_commit(id)); + commit_obj_summarize_components(&mut commit) +} + +fn commit_obj_summarize(commit: &mut Commit) -> Result { + let (short_id, summary) = try!(commit_obj_summarize_components(commit)); + Ok(format!("{} {}", short_id, summary)) +} + +fn commit_summarize(repo: &Repository, id: Oid) -> Result { + let mut commit = try!(repo.find_commit(id)); + commit_obj_summarize(&mut commit) +} + +fn notfound_to_none(result: std::result::Result) -> Result> { + match result { + Err(ref e) if e.code() == git2::ErrorCode::NotFound => Ok(None), + Err(e) => Err(e.into()), + Ok(x) => Ok(Some(x)), + } +} + +// If current_id_opt is Some, acts like reference_matching. If current_id_opt is None, acts like +// reference. +fn reference_matching_opt<'repo>(repo: &'repo Repository, name: &str, id: Oid, force: bool, current_id_opt: Option, log_message: &str) -> Result> { + match current_id_opt { + None => Ok(try!(repo.reference(name, id, force, log_message))), + Some(current_id) => Ok(try!(repo.reference_matching(name, id, force, current_id, log_message))), + } +} + +fn parents_from_ids(repo: &Repository, mut parents: Vec) -> Result> { + parents.sort(); + parents.dedup(); + parents.drain(..).map(|id| Ok(try!(repo.find_commit(id)))).collect::>>() +} + +struct Internals<'repo> { + staged: TreeBuilder<'repo>, + working: TreeBuilder<'repo>, +} + +impl<'repo> Internals<'repo> { + fn read(repo: &'repo Repository) -> Result { + let shead = try!(repo.find_reference(SHEAD_REF)); + let series_name = try!(shead_series_name(&shead)); + let mut internals = try!(Internals::read_series(repo, &series_name)); + try!(internals.update_series(repo)); + Ok(internals) + } + + fn read_series(repo: &'repo Repository, series_name: &str) -> Result { + let maybe_get_ref = |prefix: &str| -> Result> { + match try!(notfound_to_none(repo.refname_to_id(&format!("{}{}", prefix, series_name)))) { + Some(id) => { + let c = try!(repo.find_commit(id)); + let t = try!(c.tree()); + Ok(try!(repo.treebuilder(Some(&t)))) + } + None => Ok(try!(repo.treebuilder(None))), + } + }; + Ok(Internals { + staged: try!(maybe_get_ref(STAGED_PREFIX)), + working: try!(maybe_get_ref(WORKING_PREFIX)), + }) + } + + fn exists(repo: &'repo Repository, series_name: &str) -> Result { + for prefix in [STAGED_PREFIX, WORKING_PREFIX].iter() { + let prefixed_name = format!("{}{}", prefix, series_name); + if try!(notfound_to_none(repo.refname_to_id(&prefixed_name))).is_some() { + return Ok(true); + } + } + Ok(false) + } + + // Returns true if it had anything to delete. + fn delete(repo: &'repo Repository, series_name: &str) -> Result { + let mut deleted_any = false; + for prefix in [STAGED_PREFIX, WORKING_PREFIX].iter() { + let prefixed_name = format!("{}{}", prefix, series_name); + if let Some(mut r) = try!(notfound_to_none(repo.find_reference(&prefixed_name))) { + try!(r.delete()); + deleted_any = true; + } + } + Ok(deleted_any) + } + + fn update_series(&mut self, repo: &'repo Repository) -> Result<()> { + let head_id = try!(repo.refname_to_id("HEAD")); + try!(self.working.insert("series", head_id, GIT_FILEMODE_COMMIT as i32)); + Ok(()) + } + + fn write(&self, repo: &'repo Repository) -> Result<()> { + let config = try!(repo.config()); + let author = try!(get_signature(&config, "AUTHOR")); + let committer = try!(get_signature(&config, "COMMITTER")); + + let shead = try!(repo.find_reference(SHEAD_REF)); + let series_name = try!(shead_series_name(&shead)); + let maybe_commit = |prefix: &str, tb: &TreeBuilder| -> Result<()> { + let tree_id = try!(tb.write()); + let refname = format!("{}{}", prefix, series_name); + let old_commit_id = try!(notfound_to_none(repo.refname_to_id(&refname))); + if let Some(id) = old_commit_id { + let c = try!(repo.find_commit(id)); + if c.tree_id() == tree_id { + return Ok(()); + } + } + let tree = try!(repo.find_tree(tree_id)); + let mut parents = Vec::new(); + // Include all commits from tree, to keep them reachable and fetchable. Include base, + // because series might not have it as an ancestor; we don't enforce that until commit. + for e in tree.iter() { + if e.kind() == Some(git2::ObjectType::Commit) { + parents.push(e.id()); + } + } + let parents = try!(parents_from_ids(repo, parents)); + let parents_ref: Vec<&_> = parents.iter().collect(); + let commit_id = try!(repo.commit(None, &author, &committer, &refname, &tree, &parents_ref)); + try!(repo.reference_ensure_log(&refname)); + try!(reference_matching_opt(repo, &refname, commit_id, true, old_commit_id, &format!("commit: {}", refname))); + Ok(()) + }; + try!(maybe_commit(STAGED_PREFIX, &self.staged)); + try!(maybe_commit(WORKING_PREFIX, &self.working)); + Ok(()) + } +} + +fn diff_empty(diff: &Diff) -> bool { + diff.deltas().len() == 0 +} + +fn add(repo: &Repository, m: &ArgMatches) -> Result<()> { + let mut internals = try!(Internals::read(repo)); + for file in m.values_of_os("change").unwrap() { + match try!(internals.working.get(file)) { + Some(entry) => { try!(internals.staged.insert(file, entry.id(), entry.filemode())); } + None => { + if try!(internals.staged.get(file)).is_some() { + try!(internals.staged.remove(file)); + } + } + } + } + internals.write(repo) +} + +fn unadd(repo: &Repository, m: &ArgMatches) -> Result<()> { + let shead = try!(repo.find_reference(SHEAD_REF)); + let started = { + let shead_target = try!(shead.symbolic_target().ok_or("SHEAD not a symbolic reference")); + try!(notfound_to_none(repo.find_reference(shead_target))).is_some() + }; + + let mut internals = try!(Internals::read(repo)); + if started { + let shead_commit = try!(peel_to_commit(shead)); + let shead_tree = try!(shead_commit.tree()); + + for file in m.values_of("change").unwrap() { + match shead_tree.get_name(file) { + Some(entry) => { + try!(internals.staged.insert(file, entry.id(), entry.filemode())); + } + None => { try!(internals.staged.remove(file)); } + } + } + } else { + for file in m.values_of("change").unwrap() { + try!(internals.staged.remove(file)) + } + } + internals.write(repo) +} + +fn shead_series_name(shead: &Reference) -> Result { + let shead_target = try!(shead.symbolic_target().ok_or("SHEAD not a symbolic reference")); + if !shead_target.starts_with(SERIES_PREFIX) { + return Err(format!("SHEAD does not start with {}", SERIES_PREFIX).into()); + } + Ok(shead_target[SERIES_PREFIX.len()..].to_string()) +} + +fn series(repo: &Repository) -> Result<()> { + let mut refs = Vec::new(); + for prefix in [SERIES_PREFIX, STAGED_PREFIX, WORKING_PREFIX].iter() { + let l = prefix.len(); + for r in try!(repo.references_glob(&[prefix, "*"].concat())).names() { + refs.push(try!(r)[l..].to_string()); + } + } + let shead_target = if let Some(shead) = try!(notfound_to_none(repo.find_reference(SHEAD_REF))) { + Some(try!(shead_series_name(&shead)).to_string()) + } else { + None + }; + refs.extend(shead_target.clone().into_iter()); + refs.sort(); + refs.dedup(); + for name in refs.iter() { + let star = if Some(name) == shead_target.as_ref() { '*' } else { ' ' }; + let new = if try!(notfound_to_none(repo.refname_to_id(&format!("{}{}", SERIES_PREFIX, name)))).is_none() { + " (new, no commits yet)" + } else { + "" + }; + println!("{} {}{}", star, name, new); + } + if refs.is_empty() { + println!("No series; use \"git series start \" to start"); + } + Ok(()) +} + +fn start(repo: &Repository, m: &ArgMatches) -> Result<()> { + let name = m.value_of("name").unwrap(); + let prefixed_name = &[SERIES_PREFIX, name].concat(); + let branch_exists = try!(notfound_to_none(repo.refname_to_id(&prefixed_name))).is_some() + || try!(Internals::exists(repo, name)); + if branch_exists { + return Err(format!("Series {} already exists.\nUse checkout to resume working on an existing patch series.", name).into()); + } + try!(repo.reference_symbolic(SHEAD_REF, &prefixed_name, true, &format!("git series start {}", name))); + + let internals = try!(Internals::read(repo)); + try!(internals.write(repo)); + Ok(()) +} + +fn checkout_tree(repo: &Repository, treeish: &git2::Object) -> Result<()> { + let mut conflicts = Vec::new(); + let mut dirty = Vec::new(); + let result = { + let mut opts = git2::build::CheckoutBuilder::new(); + opts.safe(); + opts.notify_on(git2::CHECKOUT_NOTIFICATION_CONFLICT | git2::CHECKOUT_NOTIFICATION_DIRTY); + opts.notify(|t, path, _, _, _| { + let path = path.unwrap().to_owned(); + if t == git2::CHECKOUT_NOTIFICATION_CONFLICT { + conflicts.push(path); + } else if t == git2::CHECKOUT_NOTIFICATION_DIRTY { + dirty.push(path); + } + true + }); + if isatty::stdout_isatty() { + opts.progress(|_, completed, total| { + let total = total.to_string(); + print!("\rChecking out files: {1:0$}/{2}", total.len(), completed, total); + }); + } + repo.checkout_tree(treeish, Some(&mut opts)) + }; + match result { + Err(ref e) if e.code() == git2::ErrorCode::Conflict => { + let mut stderr = std::io::stderr(); + writeln!(stderr, "error: Your changes to the following files would be overwritten by checkout:").unwrap(); + for path in conflicts { + writeln!(stderr, " {}", path.to_string_lossy()).unwrap(); + } + writeln!(stderr, "Please, commit your changes or stash them before you switch series.").unwrap(); + return Err(Error::CheckoutConflict); + } + _ => try!(result), + } + println!(""); + if !dirty.is_empty() { + let mut stderr = std::io::stderr(); + writeln!(stderr, "Files with changes unaffected by checkout:").unwrap(); + for path in dirty { + writeln!(stderr, " {}", path.to_string_lossy()).unwrap(); + } + } + Ok(()) +} + +fn checkout(repo: &Repository, m: &ArgMatches) -> Result<()> { + match repo.state() { + git2::RepositoryState::Clean => (), + s => { return Err(format!("{:?} in progress; cannot checkout patch series", s).into()); } + } + let name = m.value_of("name").unwrap(); + let prefixed_name = &[SERIES_PREFIX, name].concat(); + // Make sure the ref exists + let branch_exists = try!(notfound_to_none(repo.refname_to_id(&prefixed_name))).is_some() + || try!(Internals::exists(repo, name)); + if !branch_exists { + return Err(format!("Series {} does not exist.\nUse \"git series start \" to start a new patch series.", name).into()); + } + + let internals = try!(Internals::read_series(repo, name)); + let new_head_id = try!(try!(internals.working.get("series")).ok_or(format!("Could not find \"series\" in working version of \"{}\"", name))).id(); + let new_head = try!(repo.find_commit(new_head_id)).into_object(); + + try!(checkout_tree(repo, &new_head)); + + let head = try!(repo.head()); + let head_commit = try!(peel_to_commit(head)); + let head_id = head_commit.as_object().id(); + println!("Previous HEAD position was {}", try!(commit_summarize(&repo, head_id))); + + try!(repo.reference_symbolic(SHEAD_REF, &prefixed_name, true, &format!("git series checkout {}", name))); + + // git status parses this reflog string; the prefix must remain "checkout: moving from ". + try!(repo.reference("HEAD", new_head_id, true, &format!("checkout: moving from {} to {} (git series checkout {})", head_id, new_head_id, name))); + println!("HEAD is now detached at {}", try!(commit_summarize(&repo, new_head_id))); + + Ok(()) +} + +fn base(repo: &Repository, m: &ArgMatches) -> Result<()> { + let mut internals = try!(Internals::read(repo)); + + let current_base_id = match try!(internals.working.get("base")) { + Some(entry) => entry.id(), + _ => zero_oid(), + }; + + if !m.is_present("delete") && !m.is_present("base") { + if current_base_id.is_zero() { + return Err("Patch series has no base set".into()); + } else { + println!("{}", current_base_id); + return Ok(()); + } + } + + let new_base_id = if m.is_present("delete") { + zero_oid() + } else { + let base = m.value_of("base").unwrap(); + let base_object = try!(repo.revparse_single(base)); + let base_id = base_object.id(); + let s_working_series = try!(try!(internals.working.get("series")).ok_or("Could not find entry \"series\" in working vesion of current series")); + if base_id != s_working_series.id() && !try!(repo.graph_descendant_of(s_working_series.id(), base_id)) { + return Err(format!("Cannot set base to {}: not an ancestor of the patch series {}", base, s_working_series.id()).into()); + } + base_id + }; + + if current_base_id == new_base_id { + return Err("Base unchanged".into()); + } + + if !current_base_id.is_zero() { + println!("Previous base was {}", try!(commit_summarize(&repo, current_base_id))); + } + + if new_base_id.is_zero() { + try!(internals.working.remove("base")); + try!(internals.write(repo)); + println!("Cleared patch series base"); + } else { + try!(internals.working.insert("base", new_base_id, GIT_FILEMODE_COMMIT as i32)); + try!(internals.write(repo)); + println!("Set patch series base to {}", try!(commit_summarize(&repo, new_base_id))); + } + + Ok(()) +} + +fn detach(repo: &Repository) -> Result<()> { + match repo.find_reference(SHEAD_REF) { + Ok(mut r) => try!(r.delete()), + Err(_) => { return Err("No current patch series to detach from.".into()); } + } + Ok(()) +} + +fn delete(repo: &Repository, m: &ArgMatches) -> Result<()> { + let name = m.value_of("name").unwrap(); + if let Ok(shead) = repo.find_reference(SHEAD_REF) { + let shead_target = try!(shead_series_name(&shead)); + if shead_target == name { + return Err(format!("Cannot delete the current series \"{}\"; detach first.", name).into()); + } + } + let prefixed_name = &[SERIES_PREFIX, name].concat(); + let deleted_ref = if let Some(mut r) = try!(notfound_to_none(repo.find_reference(prefixed_name))) { + try!(r.delete()); + true + } else { + false + }; + let deleted_internals = try!(Internals::delete(repo, name)); + if !deleted_ref && !deleted_internals { + return Err(format!("Nothing to delete: series \"{}\" does not exist.", name).into()); + } + Ok(()) +} + +fn get_editor(config: &git2::Config) -> Result { + if let Some(e) = env::var_os("GIT_EDITOR") { + return Ok(e); + } + if let Ok(e) = config.get_path("core.editor") { + return Ok(e.into()); + } + let terminal_is_dumb = match env::var_os("TERM") { + None => true, + Some(t) => t.as_os_str() == "dumb", + }; + if !terminal_is_dumb { + if let Some(e) = env::var_os("VISUAL") { + return Ok(e); + } + } + if let Some(e) = env::var_os("EDITOR") { + return Ok(e); + } + if terminal_is_dumb { + return Err("TERM unset or \"dumb\" but EDITOR unset".into()); + } + return Ok("vi".into()); +} + +// Get the pager to use; with for_cmd set, get the pager for use by the +// specified git command. If get_pager returns None, don't use a pager. +fn get_pager(config: &git2::Config, for_cmd: Option<&str>) -> Option { + if !isatty::stdout_isatty() { + return None; + } + // pager.cmd can contain a boolean (if false, force no pager) or a + // command-specific pager; only treat it as a command if it doesn't parse + // as a boolean. + let maybe_pager = for_cmd.and_then(|cmd| config.get_path(&format!("pager.{}", cmd)).ok()); + let (cmd_want_pager, cmd_pager) = maybe_pager.map_or((true, None), |p| + if let Ok(b) = git2::Config::parse_bool(&p) { + (b, None) + } else { + (true, Some(p)) + } + ); + if !cmd_want_pager { + return None; + } + let pager = + if let Some(e) = env::var_os("GIT_PAGER") { + Some(e) + } else if let Some(p) = cmd_pager { + Some(p.into()) + } else if let Ok(e) = config.get_path("core.pager") { + Some(e.into()) + } else if let Some(e) = env::var_os("PAGER") { + Some(e) + } else { + Some("less".into()) + }; + pager.and_then(|p| if p.is_empty() || p == OsString::from("cat") { None } else { Some(p) }) +} + +/// Construct a Command, using the shell if the command contains shell metachars +fn cmd_maybe_shell>(program: S, args: bool) -> Command { + if program.as_ref().to_string_lossy().contains(|c| SHELL_METACHARS.contains(c)) { + let mut cmd = Command::new("sh"); + cmd.arg("-c"); + if args { + let mut program_with_args = program.as_ref().to_os_string(); + program_with_args.push(" \"$@\""); + cmd.arg(program_with_args).arg(program); + } else { + cmd.arg(program); + } + cmd + } else { + Command::new(program) + } +} + +fn run_editor>(config: &git2::Config, filename: S) -> Result<()> { + let editor = try!(get_editor(&config)); + let editor_status = try!(cmd_maybe_shell(editor, true).arg(&filename).status()); + if !editor_status.success() { + return Err(format!("Editor exited with status {}", editor_status).into()); + } + Ok(()) +} + +enum Pager { + Pager(std::process::Child), + NoPager(std::io::Stdout), +} + +impl Pager { + fn auto(config: &git2::Config, for_cmd: Option<&str>) -> Result { + if let Some(pager) = get_pager(config, for_cmd) { + let mut cmd = cmd_maybe_shell(pager, false); + cmd.stdin(std::process::Stdio::piped()); + if env::var_os("LESS").is_none() { + cmd.env("LESS", "FRX"); + } + if env::var_os("LV").is_none() { + cmd.env("LV", "-c"); + } + let child = try!(cmd.spawn()); + Ok(Pager::Pager(child)) + } else { + Ok(Pager::NoPager(std::io::stdout())) + } + } +} + +impl Drop for Pager { + fn drop(&mut self) { + if let Pager::Pager(ref mut child) = *self { + let status = child.wait().unwrap(); + if !status.success() { + writeln!(std::io::stderr(), "Pager exited with status {}", status).unwrap(); + } + } + } +} + +impl IoWrite for Pager { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + match *self { + Pager::Pager(ref mut child) => child.stdin.as_mut().unwrap().write(buf), + Pager::NoPager(ref mut stdout) => stdout.write(buf), + } + } + + fn flush(&mut self) -> std::io::Result<()> { + match *self { + Pager::Pager(ref mut child) => child.stdin.as_mut().unwrap().flush(), + Pager::NoPager(ref mut stdout) => stdout.flush(), + } + } +} + +fn get_signature(config: &git2::Config, which: &str) -> Result> { + let name_var = ["GIT_", which, "_NAME"].concat(); + let email_var = ["GIT_", which, "_EMAIL"].concat(); + let which_lc = which.to_lowercase(); + let name = try!(env::var(&name_var).or_else( + |_| config.get_string("user.name").or_else( + |_| Err(format!("Could not determine {} name: checked ${} and user.name in git config", which_lc, name_var))))); + let email = try!(env::var(&email_var).or_else( + |_| config.get_string("user.email").or_else( + |_| env::var("EMAIL").or_else( + |_| Err(format!("Could not determine {} email: checked ${}, user.email in git config, and $EMAIL", which_lc, email_var)))))); + Ok(try!(git2::Signature::now(&name, &email))) +} + +fn write_status(status: &mut String, diff: &Diff, heading: &str, show_hints: bool, hints: &[&str]) -> Result { + let mut changes = false; + + try!(diff.foreach(&mut |delta, _| { + if !changes { + changes = true; + writeln!(status, "{}", heading).unwrap(); + if show_hints { + for hint in hints { + writeln!(status, " ({})", hint).unwrap(); + } + } + writeln!(status, "").unwrap(); + } + writeln!(status, " {:?}: {}", delta.status(), delta.old_file().path().unwrap().to_str().unwrap()).unwrap(); + true + }, None, None, None)); + + if changes { + writeln!(status, "").unwrap(); + } + + Ok(changes) +} + +fn commit_status(repo: &Repository, m: &ArgMatches, do_status: bool) -> Result<()> { + let config = try!(repo.config()); + let shead = match repo.find_reference(SHEAD_REF) { + Err(ref e) if e.code() == git2::ErrorCode::NotFound => { println!("No series; use \"git series start \" to start"); return Ok(()); } + result => try!(result), + }; + let series_name = try!(shead_series_name(&shead)); + let mut status = String::new(); + writeln!(status, "On series {}", series_name).unwrap(); + + let mut internals = try!(Internals::read(repo)); + let working_tree = try!(repo.find_tree(try!(internals.working.write()))); + let staged_tree = try!(repo.find_tree(try!(internals.staged.write()))); + + let shead_commit = match shead.resolve() { + Ok(r) => Some(try!(peel_to_commit(r))), + Err(ref e) if e.code() == git2::ErrorCode::NotFound => { + writeln!(status, "\nInitial series commit\n").unwrap(); + None + } + Err(e) => try!(Err(e)), + }; + let shead_tree = match shead_commit { + Some(ref c) => Some(try!(c.tree())), + None => None, + }; + + let commit_all = m.is_present("all"); + + let (changes, tree, diff) = if commit_all { + let diff = try!(repo.diff_tree_to_tree(shead_tree.as_ref(), Some(&working_tree), None)); + let changes = try!(write_status(&mut status, &diff, "Changes to be committed:", false, &[])); + if !changes { + writeln!(status, "nothing to commit; series unchanged").unwrap(); + } + (changes, working_tree, diff) + } else { + let diff = try!(repo.diff_tree_to_tree(shead_tree.as_ref(), Some(&staged_tree), None)); + let changes_to_be_committed = try!(write_status(&mut status, &diff, + "Changes to be committed:", do_status, + &["use \"git series commit\" to commit", + "use \"git series unadd ...\" to undo add"])); + + let diff_not_staged = try!(repo.diff_tree_to_tree(Some(&staged_tree), Some(&working_tree), None)); + let changes_not_staged = try!(write_status(&mut status, &diff_not_staged, + "Changes not staged for commit:", do_status, + &["use \"git series add ...\" to update what will be committed"])); + + if !changes_to_be_committed { + if changes_not_staged { + writeln!(status, "no changes added to commit (use \"git series add\" or \"git series commit -a\")").unwrap(); + } else { + writeln!(status, "nothing to commit; series unchanged").unwrap(); + } + } + + (changes_to_be_committed, staged_tree, diff) + }; + + if do_status || !changes { + print!("{}", status); + if !do_status { + return Err(Error::CommitNoChanges); + } + return Ok(()); + } + + // Check that the commit includes the series + let series_id = match tree.get_name("series") { + None => { return Err(concat!("Cannot commit: initial commit must include \"series\"\n", + "Use \"git series add series\" or \"git series commit -a\"").into()); } + Some(series) => series.id() + }; + + // Check that the base is still an ancestor of the series + if let Some(base) = tree.get_name("base") { + if base.id() != series_id && !try!(repo.graph_descendant_of(series_id, base.id())) { + let (base_short_id, base_summary) = try!(commit_summarize_components(&repo, base.id())); + let (series_short_id, series_summary) = try!(commit_summarize_components(&repo, series_id)); + return Err(format!(concat!( + "Cannot commit: base {} is not an ancestor of patch series {}\n", + "base {} {}\n", + "series {} {}"), + base_short_id, series_short_id, + base_short_id, base_summary, + series_short_id, series_summary).into()); + } + } + + let msg = match m.value_of("m") { + Some(s) => s.to_string(), + None => { + let filename = repo.path().join("SCOMMIT_EDITMSG"); + let mut file = try!(File::create(&filename)); + try!(write!(file, "{}", COMMIT_MESSAGE_COMMENT)); + for line in status.lines() { + if line.is_empty() { + try!(writeln!(file, "#")); + } else { + try!(writeln!(file, "# {}", line)); + } + } + if m.is_present("verbose") { + try!(writeln!(file, "{}\n{}", SCISSOR_LINE, SCISSOR_COMMENT)); + try!(write_diff(&mut file, &diff)); + } + drop(file); + try!(run_editor(&config, &filename)); + let mut file = try!(File::open(&filename)); + let mut msg = String::new(); + try!(file.read_to_string(&mut msg)); + if let Some(scissor_index) = msg.find(SCISSOR_LINE) { + msg.truncate(scissor_index); + } + try!(git2::message_prettify(msg, git2::DEFAULT_COMMENT_CHAR)) + } + }; + if msg.is_empty() { + return Err("Aborting series commit due to empty commit message.".into()); + } + + let author = try!(get_signature(&config, "AUTHOR")); + let committer = try!(get_signature(&config, "COMMITTER")); + let mut parents: Vec = Vec::new(); + // Include all commits from tree, to keep them reachable and fetchable. + for e in tree.iter() { + if e.kind() == Some(git2::ObjectType::Commit) && e.name().unwrap() != "base" { + parents.push(e.id()) + } + } + let parents = try!(parents_from_ids(repo, parents)); + let parents_ref: Vec<&_> = shead_commit.iter().chain(parents.iter()).collect(); + let new_commit_oid = try!(repo.commit(Some(SHEAD_REF), &author, &committer, &msg, &tree, &parents_ref)); + + if commit_all { + internals.staged = try!(repo.treebuilder(Some(&tree))); + try!(internals.write(repo)); + } + + let (new_commit_short_id, new_commit_summary) = try!(commit_summarize_components(&repo, new_commit_oid)); + println!("[{} {}] {}", series_name, new_commit_short_id, new_commit_summary); + + Ok(()) +} + +fn cover(repo: &Repository, m: &ArgMatches) -> Result<()> { + let mut internals = try!(Internals::read(repo)); + + let (working_cover_id, working_cover_content) = match try!(internals.working.get("cover")) { + None => (zero_oid(), String::new()), + Some(entry) => (entry.id(), try!(std::str::from_utf8(try!(repo.find_blob(entry.id())).content())).to_string()), + }; + + if m.is_present("delete") { + if working_cover_id.is_zero() { + return Err("No cover to delete".into()); + } + try!(internals.working.remove("cover")); + try!(internals.write(repo)); + println!("Deleted cover letter"); + return Ok(()); + } + + let filename = repo.path().join("COVER_EDITMSG"); + let mut file = try!(File::create(&filename)); + if working_cover_content.is_empty() { + try!(write!(file, "{}", COVER_LETTER_COMMENT)); + } else { + try!(write!(file, "{}", working_cover_content)); + } + drop(file); + let config = try!(repo.config()); + try!(run_editor(&config, &filename)); + let mut file = try!(File::open(&filename)); + let mut msg = String::new(); + try!(file.read_to_string(&mut msg)); + let msg = try!(git2::message_prettify(msg, git2::DEFAULT_COMMENT_CHAR)); + if msg.is_empty() { + return Err("Empty cover letter; not changing.\n(To delete the cover letter, use \"git series -d\".)".into()); + } + + let new_cover_id = try!(repo.blob(msg.as_bytes())); + if new_cover_id == working_cover_id { + println!("Cover letter unchanged"); + } else { + try!(internals.working.insert("cover", new_cover_id, GIT_FILEMODE_BLOB as i32)); + try!(internals.write(repo)); + println!("Updated cover letter"); + } + + Ok(()) +} + +fn date_822(t: git2::Time) -> String { + let offset = chrono::offset::fixed::FixedOffset::east(t.offset_minutes()*60); + let datetime = offset.timestamp(t.seconds(), 0); + datetime.to_rfc2822() +} + +fn shortlog(commits: &mut [Commit]) -> String { + let mut s = String::new(); + let mut author_map = std::collections::HashMap::new(); + + for mut commit in commits { + let author = commit.author().name().unwrap().to_string(); + author_map.entry(author).or_insert(Vec::new()).push(commit.summary().unwrap().to_string()); + } + + let mut authors: Vec<_> = author_map.keys().collect(); + authors.sort(); + let mut first = true; + for author in authors { + if first { + first = false; + } else { + writeln!(s, "").unwrap(); + } + let summaries = author_map.get(author).unwrap(); + writeln!(s, "{} ({}):", author, summaries.len()).unwrap(); + for summary in summaries { + writeln!(s, " {}", summary).unwrap(); + } + } + + s +} + +fn ascii_isalnum(c: char) -> bool { + (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') +} + +fn sanitize_summary(summary: &str) -> String { + let mut s = String::with_capacity(summary.len()); + let mut prev_dot = false; + let mut need_space = false; + for c in summary.chars() { + if ascii_isalnum(c) || c == '_' || c == '.' { + if need_space { + s.push('-'); + need_space = false; + } + if !(prev_dot && c == '.') { + s.push(c); + } + } else { + if !s.is_empty() { + need_space = true; + } + } + prev_dot = c == '.'; + } + let end = s.trim_right_matches(|c| c == '.' || c == '-').len(); + s.truncate(end); + s +} + +#[test] +fn test_sanitize_summary() { + let tests = vec![ + ("", ""), + ("!!!!!", ""), + ("Test", "Test"), + ("Test case", "Test-case"), + ("Test case", "Test-case"), + (" Test case ", "Test-case"), + ("...Test...case...", ".Test.case"), + ("...Test...case.!!", ".Test.case"), + (".!.Test.!.case.!.", ".-.Test.-.case"), + ]; + for (summary, sanitized) in tests { + assert_eq!(sanitize_summary(summary), sanitized.to_string()); + } +} + +fn split_message(message: &str) -> (&str, &str) { + let mut iter = message.splitn(2, '\n'); + let subject = iter.next().unwrap().trim_right(); + let body = iter.next().map(|s| s.trim_left()).unwrap_or(""); + (subject, body) +} + +fn diffstat(diff: &Diff) -> Result { + let stats = try!(diff.stats()); + let stats_buf = try!(stats.to_buf(git2::DIFF_STATS_FULL|git2::DIFF_STATS_INCLUDE_SUMMARY, 72)); + Ok(stats_buf.as_str().unwrap().to_string()) +} + +fn write_diff(f: &mut W, diff: &Diff) -> Result<()> { + Ok(try!(diff.print(git2::DiffFormat::Patch, |_, _, l| { + let o = l.origin(); + if o == '+' || o == '-' || o == ' ' { + f.write_all(&[o as u8]).unwrap(); + } + f.write_all(l.content()).unwrap(); + true + }))) +} + +fn mail_signature() -> String { + format!("-- \ngit-series {}", crate_version!()) +} + +fn format(repo: &Repository, m: &ArgMatches) -> Result<()> { + let config = try!(repo.config()); + let to_stdout = m.is_present("stdout"); + + let shead_commit = try!(peel_to_commit(try!(try!(repo.find_reference(SHEAD_REF)).resolve()))); + let stree = try!(shead_commit.tree()); + + let series = try!(stree.get_name("series").ok_or("Internal error: series did not contain \"series\"")); + let base = try!(stree.get_name("base").ok_or("Cannot format series; no base set.\nUse \"git series base\" to set base.")); + + let mut revwalk = try!(repo.revwalk()); + revwalk.set_sorting(git2::SORT_TOPOLOGICAL|git2::SORT_REVERSE); + try!(revwalk.push(series.id())); + try!(revwalk.hide(base.id())); + let mut commits: Vec = try!(revwalk.map(|c| { + let id = try!(c); + let commit = try!(repo.find_commit(id)); + if commit.parent_ids().count() > 1 { + return Err(format!("Error: cannot format merge commit as patch:\n{}", try!(commit_summarize(repo, id))).into()); + } + Ok(commit) + }).collect::>()); + if commits.is_empty() { + return Err("No patches to format; series and base identical.".into()); + } + + let author = try!(get_signature(&config, "AUTHOR")); + let author_name = author.name().unwrap(); + let author_email = author.email().unwrap(); + let message_id_suffix = format!("{}.git-series.{}", author.when().seconds(), author_email); + + let cover_entry = stree.get_name("cover"); + let root_message_id = if cover_entry.is_some() { + format!("", shead_commit.id(), message_id_suffix) + } else { + format!("<{}.{}>", commits.first().unwrap().id(), message_id_suffix) + }; + + let signature = mail_signature(); + + let mut out : Box = if to_stdout { + Box::new(try!(Pager::auto(&config, Some("format-patch")))) + } else { + Box::new(std::io::stdout()) + }; + let patch_file = |name: &str| -> Result> { + println!("{}", name); + Ok(Box::new(try!(File::create(name)))) + }; + + if let Some(ref entry) = cover_entry { + let cover_blob = try!(repo.find_blob(entry.id())); + let content = try!(std::str::from_utf8(cover_blob.content())).to_string(); + let (subject, body) = split_message(&content); + + let series_tree = try!(repo.find_commit(series.id())).tree().unwrap(); + let base_tree = try!(repo.find_commit(base.id())).tree().unwrap(); + let diff = try!(repo.diff_tree_to_tree(Some(&base_tree), Some(&series_tree), None)); + let stats = try!(diffstat(&diff)); + + if !to_stdout { + out = try!(patch_file("0000-cover-letter.patch")); + } + try!(writeln!(out, "From {} Mon Sep 17 00:00:00 2001", shead_commit.id())); + try!(writeln!(out, "Message-Id: {}", root_message_id)); + try!(writeln!(out, "From: {} <{}>", author_name, author_email)); + try!(writeln!(out, "Date: {}", date_822(author.when()))); + try!(writeln!(out, "Subject: [PATCH 0/{}] {}\n", commits.len(), subject)); + if !body.is_empty() { + try!(writeln!(out, "{}", body)); + } + try!(writeln!(out, "{}", shortlog(&mut commits))); + try!(writeln!(out, "{}", stats)); + try!(writeln!(out, "{}", signature)); + } + + let mut need_sep = cover_entry.is_some(); + for (commit_num, commit) in commits.iter().enumerate() { + if !need_sep { + need_sep = true; + } else if to_stdout { + try!(writeln!(out, "")); + } + + let message = commit.message().unwrap(); + let (subject, body) = split_message(message); + let commit_id = commit.id(); + let commit_author = commit.author(); + let summary_sanitized = sanitize_summary(&subject); + let message_id = format!("<{}.{}>", commit_id, message_id_suffix); + let parent = try!(commit.parent(0)); + let diff = try!(repo.diff_tree_to_tree(Some(&parent.tree().unwrap()), Some(&commit.tree().unwrap()), None)); + let stats = try!(diffstat(&diff)); + + if !to_stdout { + out = try!(patch_file(&format!("{:04}-{}.patch", commit_num+1, summary_sanitized))); + } + try!(writeln!(out, "From {} Mon Sep 17 00:00:00 2001", commit_id)); + try!(writeln!(out, "Message-Id: {}", message_id)); + try!(writeln!(out, "In-Reply-To: {}", root_message_id)); + try!(writeln!(out, "References: {}", root_message_id)); + try!(writeln!(out, "From: {} <{}>", author_name, author_email)); + try!(writeln!(out, "Date: {}", date_822(commit_author.when()))); + try!(writeln!(out, "Subject: [PATCH {}/{}] {}\n", commit_num+1, commits.len(), subject)); + if !body.is_empty() { + try!(writeln!(out, "{}", body)); + } + try!(writeln!(out, "---")); + try!(writeln!(out, "{}", stats)); + try!(write_diff(&mut out, &diff)); + try!(writeln!(out, "{}", signature)); + } + + Ok(()) +} + +fn log(repo: &Repository, m: &ArgMatches) -> Result<()> { + let config = try!(repo.config()); + let mut out = try!(Pager::auto(&config, Some("log"))); + + let mut revwalk = try!(repo.revwalk()); + revwalk.simplify_first_parent(); + try!(revwalk.push_ref(SHEAD_REF)); + + let show_diff = m.is_present("patch"); + + for oid in revwalk { + let oid = try!(oid); + let commit = try!(repo.find_commit(oid)); + let tree = try!(commit.tree()); + let author = commit.author(); + + let first_parent_id = try!(commit.parent_id(0).map_err(|e| format!("Malformed series commit {}: {}", oid, e))); + let first_series_commit = tree.iter().find(|entry| entry.id() == first_parent_id).is_some(); + + try!(writeln!(out, "commit {}", oid)); + try!(writeln!(out, "Author: {} <{}>", author.name().unwrap(), author.email().unwrap())); + try!(writeln!(out, "Date: {}\n", date_822(author.when()))); + for line in commit.message().unwrap().lines() { + try!(writeln!(out, " {}", line)); + } + if show_diff { + writeln!(out, "").unwrap(); + let parent_tree = if first_series_commit { + None + } else { + Some(try!(try!(repo.find_commit(first_parent_id)).tree())) + }; + let diff = try!(repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)); + try!(write_diff(&mut out, &diff)); + } + + if first_series_commit { + break; + } else { + try!(writeln!(out, "")); + } + } + + Ok(()) +} + +fn rebase(repo: &Repository, m: &ArgMatches) -> Result<()> { + match repo.state() { + git2::RepositoryState::Clean => (), + git2::RepositoryState::RebaseMerge if repo.path().join("rebase-merge").join("git-series").exists() => { + return Err("git series rebase already in progress.\nUse \"git rebase --continue\" or \"git rebase --abort\".".into()); + }, + s => { return Err(format!("{:?} in progress; cannot rebase", s).into()); } + } + + let internals = try!(Internals::read(repo)); + let series = try!(try!(internals.working.get("series")).ok_or("Could not find entry \"series\" in working index")); + let base = try!(try!(internals.working.get("base")).ok_or("Cannot rebase series; no base set.\nUse \"git series base\" to set base.")); + if series.id() == base.id() { + return Err("No patches to rebase; series and base identical.".into()); + } else if !try!(repo.graph_descendant_of(series.id(), base.id())) { + return Err(format!("Cannot rebase: current base {} not an ancestor of series {}", base.id(), series.id()).into()); + } + + // Check for unstaged or uncommitted changes before attempting to rebase. + let series_commit = try!(repo.find_commit(series.id())); + let series_tree = try!(series_commit.tree()); + let mut unclean = String::new(); + if !diff_empty(&try!(repo.diff_tree_to_index(Some(&series_tree), None, None))) { + writeln!(unclean, "Cannot rebase: you have unstaged changes.").unwrap(); + } + if !diff_empty(&try!(repo.diff_index_to_workdir(None, None))) { + if unclean.is_empty() { + writeln!(unclean, "Cannot rebase: your index contains uncommitted changes.").unwrap(); + } else { + writeln!(unclean, "Additionally, your index contains uncommitted changes.").unwrap(); + } + } + if !unclean.is_empty() { + return Err(unclean.into()); + } + + let mut revwalk = try!(repo.revwalk()); + revwalk.set_sorting(git2::SORT_TOPOLOGICAL|git2::SORT_REVERSE); + try!(revwalk.push(series.id())); + try!(revwalk.hide(base.id())); + let commits: Vec = try!(revwalk.map(|c| { + let id = try!(c); + let mut commit = try!(repo.find_commit(id)); + if commit.parent_ids().count() > 1 { + return Err(format!("Error: cannot rebase merge commit:\n{}", try!(commit_obj_summarize(&mut commit))).into()); + } + Ok(commit) + }).collect::>()); + + let interactive = m.is_present("interactive"); + let onto = match m.value_of("onto") { + None => None, + Some(onto) => { + let obj = try!(repo.revparse_single(onto)); + Some(obj.id()) + }, + }; + + let newbase = onto.unwrap_or(base.id()); + let (base_short, _) = try!(commit_summarize_components(&repo, base.id())); + let (newbase_short, _) = try!(commit_summarize_components(&repo, newbase)); + let (series_short, _) = try!(commit_summarize_components(&repo, series.id())); + + let newbase_obj = try!(repo.find_commit(newbase)).into_object(); + + let dir = try!(TempDir::new_in(repo.path(), "rebase-merge")); + let final_path = repo.path().join("rebase-merge"); + let mut create = std::fs::OpenOptions::new(); + create.write(true).create_new(true); + + try!(create.open(dir.path().join("git-series"))); + try!(create.open(dir.path().join("quiet"))); + try!(create.open(dir.path().join("interactive"))); + + let mut head_name_file = try!(create.open(dir.path().join("head-name"))); + try!(writeln!(head_name_file, "detached HEAD")); + + let mut onto_file = try!(create.open(dir.path().join("onto"))); + try!(writeln!(onto_file, "{}", newbase)); + + let mut orig_head_file = try!(create.open(dir.path().join("orig-head"))); + try!(writeln!(orig_head_file, "{}", series.id())); + + let git_rebase_todo_filename = dir.path().join("git-rebase-todo"); + let mut git_rebase_todo = try!(create.open(&git_rebase_todo_filename)); + for mut commit in commits { + try!(writeln!(git_rebase_todo, "pick {}", try!(commit_obj_summarize(&mut commit)))); + } + if let Some(onto) = onto { + try!(writeln!(git_rebase_todo, "exec git series base {}", onto)); + } + try!(writeln!(git_rebase_todo, "\n# Rebase {}..{} onto {}", base_short, series_short, newbase_short)); + try!(write!(git_rebase_todo, "{}", REBASE_COMMENT)); + drop(git_rebase_todo); + + // Interactive editor if interactive { + if interactive { + let config = try!(repo.config()); + try!(run_editor(&config, &git_rebase_todo_filename)); + let mut file = try!(File::open(&git_rebase_todo_filename)); + let mut todo = String::new(); + try!(file.read_to_string(&mut todo)); + let todo = try!(git2::message_prettify(todo, git2::DEFAULT_COMMENT_CHAR)); + if todo.is_empty() { + return Err("Nothing to do".into()); + } + } + + // Avoid races by not calling .into_path until after the rename succeeds. + try!(std::fs::rename(dir.path(), final_path)); + dir.into_path(); + + try!(checkout_tree(repo, &newbase_obj)); + try!(repo.reference("HEAD", newbase, true, &format!("rebase -i (start): checkout {}", newbase))); + + let status = try!(Command::new("git").arg("rebase").arg("--continue").status()); + if !status.success() { + return Err(format!("git rebase --continue exited with status {}", status).into()); + } + + Ok(()) +} + +fn req(repo: &Repository, m: &ArgMatches) -> Result<()> { + let config = try!(repo.config()); + let shead = try!(repo.find_reference(SHEAD_REF)); + let shead_commit = try!(peel_to_commit(try!(shead.resolve()))); + let stree = try!(shead_commit.tree()); + + let series = try!(stree.get_name("series").ok_or("Internal error: series did not contain \"series\"")); + let series_id = series.id(); + let mut series_commit = try!(repo.find_commit(series_id)); + let base = try!(stree.get_name("base").ok_or("Cannot request pull; no base set.\nUse \"git series base\" to set base.")); + let mut base_commit = try!(repo.find_commit(base.id())); + + let (cover_content, subject, cover_body) = if let Some(entry) = stree.get_name("cover") { + let cover_blob = try!(repo.find_blob(entry.id())); + let content = try!(std::str::from_utf8(cover_blob.content())).to_string(); + let (subject, body) = split_message(&content); + (Some(content.to_string()), subject.to_string(), Some(body.to_string())) + } else { + (None, try!(shead_series_name(&shead)), None) + }; + + let url = m.value_of("url").unwrap(); + let tag = m.value_of("tag").unwrap(); + let full_tag = format!("refs/tags/{}", tag); + let full_tag_peeled = format!("{}^{{}}", full_tag); + let full_head = format!("refs/heads/{}", tag); + let mut remote = try!(repo.remote_anonymous(url)); + try!(remote.connect(git2::Direction::Fetch).map_err(|e| format!("Could not connect to remote repository {}\n{}", url, e))); + let remote_heads = try!(remote.list()); + + /* Find the requested name as either a tag or head */ + let mut opt_remote_tag = None; + let mut opt_remote_tag_peeled = None; + let mut opt_remote_head = None; + for h in remote_heads { + if h.name() == full_tag { + opt_remote_tag = Some(h.oid()); + } else if h.name() == full_tag_peeled { + opt_remote_tag_peeled = Some(h.oid()); + } else if h.name() == full_head { + opt_remote_head = Some(h.oid()); + } + } + let (msg, extra_body, remote_pull_name) = match (opt_remote_tag, opt_remote_tag_peeled, opt_remote_head) { + (Some(remote_tag), Some(remote_tag_peeled), _) => { + if remote_tag_peeled != series_id { + return Err(format!("Remote tag {} does not refer to series {}", tag, series_id).into()); + } + let local_tag = try!(repo.find_tag(remote_tag).map_err(|e| + format!("Could not find remote tag {} ({}) in local repository: {}", tag, remote_tag, e))); + let mut local_tag_msg = local_tag.message().unwrap().to_string(); + if let Some(sig_index) = local_tag_msg.find("-----BEGIN PGP ") { + local_tag_msg.truncate(sig_index); + } + let extra_body = match cover_content { + Some(ref content) if !local_tag_msg.contains(content) => cover_body, + _ => None, + }; + (Some(local_tag_msg), extra_body, full_tag) + }, + (Some(remote_tag), None, _) => { + if remote_tag != series_id { + return Err(format!("Remote unannotated tag {} does not refer to series {}", tag, series_id).into()); + } + (cover_content, None, full_tag) + } + (_, _, Some(remote_head)) => { + if remote_head != series_id { + return Err(format!("Remote branch {} does not refer to series {}", tag, series_id).into()); + } + (cover_content, None, full_head) + }, + _ => { + return Err(format!("Remote does not have either a tag or branch named {}", tag).into()) + } + }; + + let commit_subject_date = |commit: &mut Commit| -> String { + let date = date_822(commit.author().when()); + let summary = commit.summary().unwrap(); + format!(" {} ({})", summary, date) + }; + + let mut revwalk = try!(repo.revwalk()); + revwalk.set_sorting(git2::SORT_TOPOLOGICAL|git2::SORT_REVERSE); + try!(revwalk.push(series_id)); + try!(revwalk.hide(base.id())); + let mut commits: Vec = try!(revwalk.map(|c| { + Ok(try!(repo.find_commit(try!(c)))) + }).collect::>()); + if commits.is_empty() { + return Err("No patches to request pull of; series and base identical.".into()); + } + + let author = try!(get_signature(&config, "AUTHOR")); + let author_email = author.email().unwrap(); + let message_id = format!("", shead_commit.id(), author.when().seconds(), author_email); + + let diff = try!(repo.diff_tree_to_tree(Some(&base_commit.tree().unwrap()), Some(&series_commit.tree().unwrap()), None)); + let stats = try!(diffstat(&diff)); + + println!("From {} Mon Sep 17 00:00:00 2001", shead_commit.id()); + println!("Message-Id: {}", message_id); + println!("From: {} <{}>", author.name().unwrap(), author_email); + println!("Date: {}", date_822(author.when())); + println!("Subject: [GIT PULL] {}\n", subject); + if let Some(extra_body) = extra_body { + println!("{}", extra_body); + } + println!("The following changes since commit {}:\n", base.id()); + println!("{}\n", commit_subject_date(&mut base_commit)); + println!("are available in the git repository at:\n"); + println!(" {} {}\n", url, remote_pull_name); + println!("for you to fetch changes up to {}:\n", series.id()); + println!("{}\n", commit_subject_date(&mut series_commit)); + println!("----------------------------------------------------------------"); + if let Some(msg) = msg { + println!("{}", msg); + println!("----------------------------------------------------------------"); + } + println!("{}", shortlog(&mut commits)); + println!("{}", stats); + if m.is_present("patch") { + try!(write_diff(&mut std::io::stdout(), &diff)); + } + println!("{}", mail_signature()); + + Ok(()) +} + +fn git_series() -> Result<()> { + let m = App::new("git-series") + .bin_name("git series") + .about("Track patch series in git") + .author("Josh Triplett ") + .version(crate_version!()) + .global_setting(AppSettings::ColoredHelp) + .global_setting(AppSettings::VersionlessSubcommands) + .subcommands(vec![ + SubCommand::with_name("add") + .about("Add changes to the index for the next series commit") + .arg_from_usage("... 'Changes to add (\"series\", \"base\", \"cover\")'"), + SubCommand::with_name("base") + .about("Get or set the base commit for the patch series") + .arg(Arg::with_name("base").help("Base commit").conflicts_with("delete")) + .arg_from_usage("-d, --delete 'Clear patch series base'"), + SubCommand::with_name("checkout") + .about("Resume work on a patch series; check out the current version") + .arg_from_usage(" 'Patch series to check out'"), + SubCommand::with_name("commit") + .about("Record changes to the patch series") + .arg_from_usage("-a, --all 'Commit all changes'") + .arg_from_usage("-m [msg] 'Commit message'") + .arg_from_usage("-v, --verbose 'Show diff when preparing commit message'"), + SubCommand::with_name("cover") + .about("Create or edit the cover letter for the patch series") + .arg_from_usage("-d, --delete 'Delete cover letter'"), + SubCommand::with_name("delete") + .about("Delete a patch series") + .arg_from_usage(" 'Patch series to delete'"), + SubCommand::with_name("detach") + .about("Stop working on any patch series"), + SubCommand::with_name("format") + .arg_from_usage("--stdout 'Write patches to stdout rather than files.") + .about("Prepare patch series for email"), + SubCommand::with_name("log") + .about("Show the history of the patch series") + .arg_from_usage("-p, --patch 'Include a patch for each change committed to the series'"), + SubCommand::with_name("rebase") + .about("Rebase the patch series") + .arg_from_usage("[onto] 'Commit to rebase onto'") + .arg_from_usage("-i, --interactive 'Interactively edit the list of commits'") + .group(ArgGroup::with_name("action").args(&["onto", "interactive"]).multiple(true).required(true)), + SubCommand::with_name("req") + .about("Generate a mail requesting a pull of the patch series") + .visible_aliases(&["pull-request", "request-pull"]) + .arg_from_usage("-p, --patch 'Include patch in the mail'") + .arg_from_usage(" 'Repository URL to request pull of'") + .arg_from_usage(" 'Tag or branch name to request pull of'"), + SubCommand::with_name("status") + .about("Show the status of the patch series"), + SubCommand::with_name("start") + .about("Start a new patch series") + .arg_from_usage(" 'Patch series name'"), + SubCommand::with_name("unadd") + .about("Undo \"git series add\", removing changes from the next series commit") + .arg_from_usage("... 'Changes to remove (\"series\", \"base\", \"cover\")'"), + ]).get_matches(); + + let repo = try!(git2::Repository::discover(".")); + + match m.subcommand() { + ("", _) => try!(series(&repo)), + ("add", Some(ref sm)) => try!(add(&repo, &sm)), + ("base", Some(ref sm)) => try!(base(&repo, &sm)), + ("checkout", Some(ref sm)) => try!(checkout(&repo, &sm)), + ("commit", Some(ref sm)) => try!(commit_status(&repo, &sm, false)), + ("cover", Some(ref sm)) => try!(cover(&repo, &sm)), + ("delete", Some(ref sm)) => try!(delete(&repo, &sm)), + ("detach", _) => try!(detach(&repo)), + ("format", Some(ref sm)) => try!(format(&repo, &sm)), + ("log", Some(ref sm)) => try!(log(&repo, &sm)), + ("rebase", Some(ref sm)) => try!(rebase(&repo, &sm)), + ("req", Some(ref sm)) => try!(req(&repo, &sm)), + ("start", Some(ref sm)) => try!(start(&repo, &sm)), + ("status", Some(ref sm)) => try!(commit_status(&repo, &sm, true)), + ("unadd", Some(ref sm)) => try!(unadd(&repo, &sm)), + _ => unreachable!() + } + + Ok(()) +} + +fn main() { + if let Err(e) = git_series() { + match e { + Error::CommitNoChanges => {}, + Error::CheckoutConflict => {}, + _ => writeln!(std::io::stderr(), "{}", e).unwrap(), + } + std::process::exit(1); + } +}