@@ -9,7 +9,7 @@ use anyhow::{bail, Result};
99use clap:: { Parser , Subcommand } ;
1010
1111use crate :: config:: { Identity , Remote } ;
12- use crate :: repo:: { short, Merge , Repo } ;
12+ use crate :: repo:: { short, Merge , Rebase , Repo } ;
1313use crate :: sync:: Push ;
1414
1515#[ derive( Parser ) ]
@@ -30,8 +30,11 @@ pub enum Command {
3030 /// Stage a unit of intent (a prompt, note, or decision)
3131 Add {
3232 /// The intent text
33- #[ arg( required = true , num_args = 1 .., value_name = "TEXT" ) ]
33+ #[ arg( num_args = 1 .., value_name = "TEXT" , required_unless_present = "file ") ]
3434 text : Vec < String > ,
35+ /// Read the intent from a file instead; "-" reads stdin
36+ #[ arg( short = 'F' , long, conflicts_with = "text" ) ]
37+ file : Option < PathBuf > ,
3538 /// Override the recorded author name
3639 #[ arg( short, long) ]
3740 author : Option < String > ,
@@ -50,6 +53,25 @@ pub enum Command {
5053 #[ arg( short, long) ]
5154 email : Option < String > ,
5255 } ,
56+ /// Unstage intent, or move the current branch with --to
57+ Reset {
58+ /// Staged entry ids (prefixes) to unstage; all of them if omitted
59+ ids : Vec < String > ,
60+ /// Move the current branch to this commit instead of unstaging
61+ #[ arg( long, value_name = "REF" , conflicts_with = "ids" ) ]
62+ to : Option < String > ,
63+ } ,
64+ /// Rewrite the last commit, folding in staged intent or a new message
65+ Amend {
66+ #[ arg( short, long) ]
67+ message : Option < String > ,
68+ #[ arg( short, long) ]
69+ author : Option < String > ,
70+ #[ arg( short, long) ]
71+ email : Option < String > ,
72+ } ,
73+ /// Replay this branch's commits on top of another branch
74+ Rebase { branch : String } ,
5375 /// Show commit history, newest first
5476 Log ,
5577 /// Show a commit's full message and the intent it recorded
@@ -77,6 +99,16 @@ pub enum Command {
7799 #[ arg( short, long) ]
78100 email : Option < String > ,
79101 } ,
102+ /// Show intent that differs between two refs
103+ Diff {
104+ /// The base ref
105+ a : String ,
106+ /// The other ref
107+ #[ arg( default_value = "HEAD" ) ]
108+ b : String ,
109+ } ,
110+ /// Search intent for a pattern, case-insensitively
111+ Grep { pattern : String } ,
80112 /// Render accumulated intent into a brief an agent can rebuild from
81113 Materialize {
82114 /// Branch or commit to materialize
@@ -92,6 +124,9 @@ pub enum Command {
92124 Push {
93125 remote : Option < String > ,
94126 branch : Option < String > ,
127+ /// Rewrite the remote branch after amend, rebase, or reset --to
128+ #[ arg( short, long) ]
129+ force : bool ,
95130 } ,
96131 /// Download remote intent into tracking refs (default: origin)
97132 Fetch { remote : Option < String > } ,
@@ -136,12 +171,18 @@ pub fn run(cli: Cli, cwd: &Path) -> Result<()> {
136171 }
137172 Command :: Add {
138173 text,
174+ file,
139175 author,
140176 email,
141177 } => {
142178 let repo = Repo :: discover ( cwd) ?;
143179 let who = identity ( & repo, author, email) ?;
144- let id = repo. add ( & who, & text. join ( " " ) , now ( ) ) ?;
180+ let text = match file {
181+ Some ( p) if p. as_os_str ( ) == "-" => std:: io:: read_to_string ( std:: io:: stdin ( ) ) ?,
182+ Some ( p) => std:: fs:: read_to_string ( & p) ?,
183+ None => text. join ( " " ) ,
184+ } ;
185+ let id = repo. add ( & who, & text, now ( ) ) ?;
145186 println ! ( "staged {}" , short( & id) ) ;
146187 }
147188 Command :: Status => {
@@ -170,6 +211,41 @@ pub fn run(cli: Cli, cwd: &Path) -> Result<()> {
170211 let id = repo. commit ( & who, & message, now ( ) ) ?;
171212 println ! ( "committed {} {message}" , short( & id) ) ;
172213 }
214+ Command :: Reset { ids, to } => {
215+ let repo = Repo :: discover ( cwd) ?;
216+ match to {
217+ Some ( reference) => {
218+ let id = repo. reset_to ( & reference) ?;
219+ println ! ( "{} is now at {}" , repo. current_branch( ) ?, short( & id) ) ;
220+ }
221+ None => {
222+ let n = repo. reset ( & ids) ?;
223+ println ! ( "unstaged {n} {}" , if n == 1 { "entry" } else { "entries" } ) ;
224+ }
225+ }
226+ }
227+ Command :: Amend {
228+ message,
229+ author,
230+ email,
231+ } => {
232+ let repo = Repo :: discover ( cwd) ?;
233+ let who = identity ( & repo, author, email) ?;
234+ let id = repo. amend ( & who, message. as_deref ( ) , now ( ) ) ?;
235+ println ! ( "amended {}" , short( & id) ) ;
236+ }
237+ Command :: Rebase { branch } => {
238+ let repo = Repo :: discover ( cwd) ?;
239+ match repo. rebase ( & branch) ? {
240+ Rebase :: UpToDate => println ! ( "already up to date" ) ,
241+ Rebase :: FastForward ( id) => println ! ( "fast-forward to {}" , short( & id) ) ,
242+ Rebase :: Rebased { tip, replayed } => println ! (
243+ "replayed {replayed} {} onto {branch}; now at {}" ,
244+ if replayed == 1 { "commit" } else { "commits" } ,
245+ short( & tip)
246+ ) ,
247+ }
248+ }
173249 Command :: Log => {
174250 let repo = Repo :: discover ( cwd) ?;
175251 let commits = repo. log ( ) ?;
@@ -239,6 +315,40 @@ pub fn run(cli: Cli, cwd: &Path) -> Result<()> {
239315 Merge :: Merged ( id) => println ! ( "merged into {}" , short( & id) ) ,
240316 }
241317 }
318+ Command :: Diff { a, b } => {
319+ let repo = Repo :: discover ( cwd) ?;
320+ let ( only_a, only_b) = repo. diff ( & a, & b) ?;
321+ if only_a. is_empty ( ) && only_b. is_empty ( ) {
322+ println ! ( "no intent differs between {a} and {b}" ) ;
323+ }
324+ let mut rows: Vec < ( char , String , crate :: object:: Entry ) > = only_a
325+ . into_iter ( )
326+ . map ( |( id, e) | ( '-' , id, e) )
327+ . chain ( only_b. into_iter ( ) . map ( |( id, e) | ( '+' , id, e) ) )
328+ . collect ( ) ;
329+ rows. sort_by ( |x, y| x. 2 . timestamp . cmp ( & y. 2 . timestamp ) . then ( x. 1 . cmp ( & y. 1 ) ) ) ;
330+ for ( sign, id, e) in rows {
331+ println ! ( "{sign} {} {}" , short( & id) , oneline( & e. text) ) ;
332+ }
333+ }
334+ Command :: Grep { pattern } => {
335+ let repo = Repo :: discover ( cwd) ?;
336+ let needle = pattern. to_lowercase ( ) ;
337+ for ( id, e) in repo. grep ( & pattern) ? {
338+ let line = e
339+ . text
340+ . lines ( )
341+ . find ( |l| l. to_lowercase ( ) . contains ( & needle) )
342+ . unwrap_or ( "" )
343+ . trim ( ) ;
344+ println ! (
345+ "{} {} {}: {line}" ,
346+ short( & id) ,
347+ crate :: time:: format_ns( e. timestamp) ,
348+ e. author. label( )
349+ ) ;
350+ }
351+ }
242352 Command :: Materialize { reference, out } => {
243353 let repo = Repo :: discover ( cwd) ?;
244354 let brief = repo. materialize ( & reference, now ( ) ) ?;
@@ -259,17 +369,22 @@ pub fn run(cli: Cli, cwd: &Path) -> Result<()> {
259369 crate :: sync:: clone ( & url, & target, token ( ) ) ?;
260370 println ! ( "cloned {url} into {}" , target. display( ) ) ;
261371 }
262- Command :: Push { remote, branch } => {
372+ Command :: Push {
373+ remote,
374+ branch,
375+ force,
376+ } => {
263377 let repo = Repo :: discover ( cwd) ?;
264378 let remote = remote. unwrap_or_else ( || "origin" . into ( ) ) ;
265379 let branch = match branch {
266380 Some ( b) => b,
267381 None => repo. current_branch ( ) ?,
268382 } ;
269- match crate :: sync:: push ( & repo, & remote, & branch) ? {
383+ match crate :: sync:: push ( & repo, & remote, & branch, force ) ? {
270384 Push :: UpToDate => println ! ( "everything up to date" ) ,
271385 Push :: Pushed { objects } => {
272- println ! ( "pushed {branch} to {remote} ({objects} objects)" )
386+ let verb = if force { "force-pushed" } else { "pushed" } ;
387+ println ! ( "{verb} {branch} to {remote} ({objects} objects)" )
273388 }
274389 }
275390 }
@@ -413,8 +528,51 @@ mod tests {
413528 }
414529
415530 #[ test]
416- fn add_requires_text ( ) {
531+ fn parses_diff_with_default_and_grep ( ) {
532+ match Cli :: try_parse_from ( [ "lore" , "diff" , "main" ] )
533+ . unwrap ( )
534+ . command
535+ {
536+ Command :: Diff { a, b } => {
537+ assert_eq ! ( a, "main" ) ;
538+ assert_eq ! ( b, "HEAD" ) ;
539+ }
540+ _ => panic ! ( "wrong command" ) ,
541+ }
542+ match Cli :: try_parse_from ( [ "lore" , "diff" , "main" , "exp" ] )
543+ . unwrap ( )
544+ . command
545+ {
546+ Command :: Diff { a, b } => {
547+ assert_eq ! ( a, "main" ) ;
548+ assert_eq ! ( b, "exp" ) ;
549+ }
550+ _ => panic ! ( "wrong command" ) ,
551+ }
552+ assert ! ( Cli :: try_parse_from( [ "lore" , "diff" ] ) . is_err( ) ) ;
553+ match Cli :: try_parse_from ( [ "lore" , "grep" , "rate limit" ] )
554+ . unwrap ( )
555+ . command
556+ {
557+ Command :: Grep { pattern } => assert_eq ! ( pattern, "rate limit" ) ,
558+ _ => panic ! ( "wrong command" ) ,
559+ }
560+ }
561+
562+ #[ test]
563+ fn add_requires_text_or_file ( ) {
417564 assert ! ( Cli :: try_parse_from( [ "lore" , "add" ] ) . is_err( ) ) ;
565+ assert ! ( Cli :: try_parse_from( [ "lore" , "add" , "text" , "-F" , "f.md" ] ) . is_err( ) ) ;
566+ match Cli :: try_parse_from ( [ "lore" , "add" , "-F" , "-" ] )
567+ . unwrap ( )
568+ . command
569+ {
570+ Command :: Add { text, file, .. } => {
571+ assert ! ( text. is_empty( ) ) ;
572+ assert_eq ! ( file. unwrap( ) . as_os_str( ) , "-" ) ;
573+ }
574+ _ => panic ! ( "wrong command" ) ,
575+ }
418576 }
419577
420578 #[ test]
@@ -431,6 +589,44 @@ mod tests {
431589 }
432590 }
433591
592+ #[ test]
593+ fn parses_reset_variants ( ) {
594+ match Cli :: try_parse_from ( [ "lore" , "reset" ] ) . unwrap ( ) . command {
595+ Command :: Reset { ids, to } => {
596+ assert ! ( ids. is_empty( ) ) ;
597+ assert ! ( to. is_none( ) ) ;
598+ }
599+ _ => panic ! ( "wrong command" ) ,
600+ }
601+ match Cli :: try_parse_from ( [ "lore" , "reset" , "--to" , "abc" ] )
602+ . unwrap ( )
603+ . command
604+ {
605+ Command :: Reset { to, .. } => assert_eq ! ( to. as_deref( ) , Some ( "abc" ) ) ,
606+ _ => panic ! ( "wrong command" ) ,
607+ }
608+ // unstaging ids and moving the branch are mutually exclusive
609+ assert ! ( Cli :: try_parse_from( [ "lore" , "reset" , "abc" , "--to" , "def" ] ) . is_err( ) ) ;
610+ }
611+
612+ #[ test]
613+ fn parses_amend_and_rebase ( ) {
614+ match Cli :: try_parse_from ( [ "lore" , "amend" , "-m" , "better" ] )
615+ . unwrap ( )
616+ . command
617+ {
618+ Command :: Amend { message, .. } => assert_eq ! ( message. as_deref( ) , Some ( "better" ) ) ,
619+ _ => panic ! ( "wrong command" ) ,
620+ }
621+ match Cli :: try_parse_from ( [ "lore" , "rebase" , "main" ] )
622+ . unwrap ( )
623+ . command
624+ {
625+ Command :: Rebase { branch } => assert_eq ! ( branch, "main" ) ,
626+ _ => panic ! ( "wrong command" ) ,
627+ }
628+ }
629+
434630 #[ test]
435631 fn materialize_defaults_to_head ( ) {
436632 let cli = Cli :: try_parse_from ( [ "lore" , "materialize" ] ) . unwrap ( ) ;
@@ -443,6 +639,21 @@ mod tests {
443639 }
444640 }
445641
642+ #[ test]
643+ fn parses_push_force ( ) {
644+ match Cli :: try_parse_from ( [ "lore" , "push" ] ) . unwrap ( ) . command {
645+ Command :: Push { force, .. } => assert ! ( !force) ,
646+ _ => panic ! ( "wrong command" ) ,
647+ }
648+ match Cli :: try_parse_from ( [ "lore" , "push" , "--force" ] )
649+ . unwrap ( )
650+ . command
651+ {
652+ Command :: Push { force, .. } => assert ! ( force) ,
653+ _ => panic ! ( "wrong command" ) ,
654+ }
655+ }
656+
446657 #[ test]
447658 fn parses_clone_with_optional_dir ( ) {
448659 let cli = Cli :: try_parse_from ( [ "lore" , "clone" , "https://lorehub.com/r" ] ) . unwrap ( ) ;
0 commit comments